openvibers/openvibe.host Commits
Stage A operator plane: ovhost validates, plans, deploys and rolls back the host's services without dropping live streams or recordings
Changes
nnew file mode 100644@@@ -0,0 +1,24 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # production runs Node 22++ cache: npm++ - run: npm ci --no-audit --no-fund++ - run: npm test++ - name: syntax check++ run: for f in bin/ovhost lib/*.js lib/commands/*.js; do node --check "$f"; done++ - name: contract check (openvibe-contracts)++ # The published host manifest (contracts v0.7.0) is still the charter placeholder with range++ # ^0.1.0, so this step fails until docs/service-manifest-proposal.json is released in++ # OpenVibe.Contracts. Remove continue-on-error in the same change that bumps the dependency.++ continue-on-error: true++ run: npx openvibe-contracts-check --service host --src libnnew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++# The real inventory lives on the host (/etc/openvibe/host.json) and is never committed.++host.json++*.tar.gz++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.key++.env++.env.*@@@ -2,56 +2,136 @@> The network's deployment/control plane first; then isolated hosting for community sites, bots and mods.--**Status:** placeholder — planning only, no runnable code yet.--**Domain:** `openvibe.host`--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3.++**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It has **not** been installed on the production host yet, and no service's deploy has moved to it. Stages B and C are **not started**.++**Domain:** `openvibe.host`. It keeps its placeholder page on [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites): a CLI for operators is not a public product.++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3 and §15.17; roadmap Wave 21.**License:** AGPL-3.0 (same as every OpenVibe service).## Purpose--Stage A consolidates the deploy/TLS/nginx/readiness/drain/rollback helpers duplicated across Live, Network, Media, Tools and Sites behind stable adapters. Stage B adds tenant static hosting on Media object storage. Stage C hosts sandboxed user code only after isolation and metering are proven.++Every repository ships its own deploy script, and each one encodes rules learned from an outage. Stage A pulls those rules into one tool that works the same way for every service on the host:++++| Rule | Where it came from | How `ovhost` applies it |++|---|---|---|++| Never stop `openvibe-live.socket`; restart only the service unit | Live (socket activation, `deploy/systemd`) | Every `systemctl` call goes through a guard that refuses `stop`/`restart`/`reload` on a `.socket` unit. A socket that is down gets `start`ed. |++| Don't restart Live while anyone is live; `--wait-idle` holds the restart | Live `deploy.sh --wait-idle` (`/api/streams`) | Protected-session probe (`http-json-count`). Checked before the checkout moves and again right before the restart. |++| Don't restart Media while it is recording | Media hazard H3 (`vods-orphans/` in B2) | Protected-session probe (`sqlite-count`: `SELECT count(*) FROM vods WHERE is_recording = 1`), run read-only **as the service user**. |++| Poll `/api/ready` after a restart; roll back if it doesn't come up | Live `deploy.sh` | Readiness poll with a timeout; on failure the checkout goes back to the previous sha, deps are reinstalled if they differed, the unit is restarted again. |++| Install only when the lockfile or dependencies changed; restore `package-lock.json` afterwards | Network `deploy.sh` and host practice | `npm install --omit=dev` as the checkout owner, then `git checkout -- package-lock.json` if npm rewrote it. |++| Every dependency must resolve before anything restarts | Tools `deploy.sh` (an emptied `file:` link crash-looped every unit) | `node_modules/<dep>/package.json` must exist, parse and name the package. A broken dependency is reinstalled once, and if it still fails the deploy aborts and the checkout is restored. |++| Build, install vhosts, `nginx -t`, reload | Sites `deploy.sh` | `build` hooks and `nginx.installOnDeploy`. Vhost installs are transactional: if `nginx -t` fails, the previous files come back and nginx is not reloaded. |++| git as the checkout owner; never leave root-owned files under `/opt` | Community/Events/Tools checkouts | git, npm, builds, SQLite queries and backups all run as the declared owner/service user (`runuser` from root, `sudo -u` otherwise). |++++Stage A adds a few safety rules of its own:++++- A protected-session probe that **cannot answer** while the service is running counts as "sessions may be active", never as zero. Live's `deploy.sh` treated a failed `curl` as 0 live streams.++- Tracked local changes block a deploy. The script never discards them.++- One operation per service at a time, using a pid lock with stale-lock recovery.++- Every attempt goes into the release log, refusals and failures included.## Owns--- service manifests and environment validation, release install/rollback, systemd/container adapters, reverse-proxy generation, certificate inventory/renewal, DNS adapters, readiness/drain orchestration, config snapshots, backup/restore hooks, incident controls--- later: tenant projects, immutable deploy artifacts, domains/TLS, build logs, quotas; sandbox profiles, budgets, secret references, outbound policy++- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, SQLite backup hooks.++- Later: tenant projects, immutable deploy artifacts, domains/TLS, build logs, quotas (Stage B); sandbox profiles, budgets, secret references, outbound policy (Stage C).## Does not own--- product business logic (Host calls product deploy hooks)--- platform-wide credentials for hosted code (never)++- Product business logic. Host calls product hooks (`build`) and reads product endpoints (`/api/ready`, `/api/streams`). It never embeds product rules.++- Secrets. `ovhost` reads env files only to learn variable **names** and whether each one is empty. It never stores, prints or snapshots a value.++- Platform-wide credentials for hosted code (never).++- Certificate issuance/renewal (certbot does this today). Stage A only takes inventory.++++## Depends on--## Planned surfaces++- OpenVibe.Contracts (`openvibe-contracts` v0.7.0): service manifests, for vhost rendering and snapshots.++- OpenVibe.Network, OpenVibe.Media, OpenVibe.Events: Stage B/C only.--- operator CLI/API; hosting dashboard for tenants (Stage B+)++## Stages--## Data (authority tables / families)++| Stage | Scope | State |++|---|---|---|++| **A: operator plane** | `ovhost` CLI and library: inventory, validate, plan, deploy (`--wait-idle`/`--force`), automatic rollback, rollback, status, releases, certs, nginx render/install, snapshot, backup. No daemon and no server. Port **4910** is reserved for a later operator API. | **alpha**: written and tested (fake host, plus the real executor against temp SQLite/HTTP/git). Not installed on the host. |++| **B: tenant static hosting** | Immutable deploy artifacts stored through Media, domains and TLS, quotas, build logs, rollback. | **not started** |++| **C: sandboxed user code** | Isolation profiles, CPU/memory/time/network/storage budgets, secret references, outbound policy, metering, kill/revoke without touching platform services. | **not started**. Blocked until isolation and metering are proven. |--- releases, configs, certificates, tenants, deployments, logs/metrics links++Not in Stage A yet (from the charter/roadmap list): container adapters, DNS adapters, certificate **renewal**, logs/metrics links, incident/maintenance controls, the release-manifest/active-client work in §15.18, `host.release.deployed|rolled_back` events (they need an Events outbox and a service principal), and the restore drills and cutover runbook that Wave 22 will add.--## Capabilities and events++## Acceptance--- `host.release.*`, `host.config.*`, `host.tenant.*`, `host.deployment.*`++What the tests demonstrate (`npm test`, every system call made against `test/fake-host.js`):--Events: ``host.release.deployed|rolled_back``, ``host.deployment.*``++- A Live restart is **refused** while `/api/streams` reports a live stream. The checkout does not move, nothing is restarted, and the refusal is logged. `--wait-idle` waits for two idle checks in a row. `--force` proceeds and prints a warning. An unreachable probe counts as live.++- A Media restart is **refused** while `vods.is_recording = 1`. The query runs read-only as `ubuntu`.++- A failed readiness check **rolls back** to the previous sha, reinstalls the previous dependencies and restarts again (exit 3). If that also fails, the exit code is 4 with MANUAL INTERVENTION.++- A dependency that does not resolve **aborts before any restart** and restores the checkout (exit 2).++- Env validation **never outputs a value**, in text or JSON output.++- The socket unit is **never stopped or restarted**, in any flow.++- Only the service being deployed is restarted. Static-only changes (for example Live `public/`) are deployed without a restart, even while streams are live.++- `nginx -t` failures restore the previous vhost state. `certs` never reads a key file. Snapshots contain no secret values or remote-URL credentials.--## Depends on++Not demonstrated yet: any of this on the production host. The Wave 21 exit criterion ("deploy, restart and roll back one service without interrupting unrelated runtimes or protected sessions") still needs a run on the host, with evidence.++++## Using ovhost++++```++ovhost status [<service>...] unit state, sha, readiness, protected sessions++ovhost validate <service> env NAMES, unit files, port, vhost + nginx -t, deps++ovhost env-names <service> names declared in the checkout's .env.example++ovhost plan <service> [--to <sha>] [--no-fetch]++ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]++ovhost rollback <service> [--to <sha>] [--wait-idle] [--force]++ovhost releases <service> [--limit <n>]++ovhost certs [--warn-days <n>]++ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]++ovhost snapshot <service> [--out <file>]++ovhost backup <service>++```++++Every command accepts `--json` and `--inventory <file>`. Exit codes: `0` ok · `1` usage/precondition (including a held lock) · `2` validation failed, nothing restarted · `3` not ready, rolled back and serving · `4` rollback failed, **manual intervention** · `5` protected sessions active (refused, or `--wait-idle` gave up).++++### Deploy sequence++++1. Take the per-service lock. Refuse if the checkout uses Live's `releases/current` layout (Stage A only drives in-place checkouts, which is what every service runs today).++2. `git fetch` as the owner, then work out the from/to shas, changed files, per-package install need and whether a restart is needed (`noRestartPaths`).++3. Refuse a tracked local change or a wrong branch.++4. **Protected sessions** (only if a restart is needed): refuse, wait (`--wait-idle` or drain policy `wait`), report (drain policy `report`, Events SSE), or `--force`.++5. `git merge --ff-only` as the owner (rollback uses `git reset --hard <sha>`).++6. Install dependencies where needed and restore the lockfile. **Verify every dependency resolves**, reinstalling a broken one once. Run the build hooks. Install repo vhosts (Sites). Install unit files if `--install-units` was given. Back up databases if a `backupOnChange` file changed.++7. Protected sessions again, immediately before the restart. If this or anything in step 6 fails, the checkout and node_modules are restored and nothing is restarted.++8. Start the socket if it is down, restart the service units, poll readiness.++9. If readiness fails, run the automatic rollback (restore, reinstall, restart, poll).++10. Append the release record to `<stateDir>/releases/<service>.jsonl`.++++### Inventory++++`host.example.json` describes every service on the host. It records the checkout, owner/`runAs`, units and socket unit, `unitSources` (repo unit files), env file, `env.required` (names, or `"from-example"`), port, loopback `ready` URL (plus headers, since Tools needs `Host: openvibe.tools`), `packages` (`"apps/*"` for Tools), install command, `build` hooks, `noRestartPaths`, the `protected` probe, the `drain` policy, `databases`, `backupOnChange` and `nginx`. The real file is `/etc/openvibe/host.json` and is **never committed**. When `ovhost` runs as root it refuses an inventory that is not root-owned or that anyone else can write, and it rejects relative paths, non-loopback probe URLs, non-SELECT probe queries and vhost names that contain a path.++++`games` is listed as `managed: false`. It is a pnpm workspace with a TypeScript build, and those steps are not encoded yet. `status`, `validate`, `snapshot` and `backup` work for it; `deploy` and `rollback` refuse it.++++## Installing on the host (for the operator)--- OpenVibe.Network--- OpenVibe.Media--- OpenVibe.Events--- OpenVibe.Contracts++Nothing here has been done yet. These are the steps:--## Acceptance (must be true before "done")++1. **Code** is root-owned and outside `/opt`, so it never mixes with the ubuntu-owned service checkouts:++ ```++ sudo git clone https://github.com/OpenVibers/OpenVibe.Host /usr/local/lib/openvibe-host++ cd /usr/local/lib/openvibe-host && sudo npm ci --omit=dev --no-audit --no-fund++ sudo ln -s /usr/local/lib/openvibe-host/bin/ovhost /usr/local/bin/ovhost++ ```++ The tree must stay world-readable (default 0755/0644). The SQLite worker runs as `ubuntu` from this directory.++2. **Inventory:** `sudo install -o root -g root -m 0640 host.example.json /etc/openvibe/host.json`, then correct it against the machine. Check the Community database path, the Tools unit list, and Events, which was not deployed as of 22 Sep.++3. **State:** `/var/lib/openvibe-host` (root, 0750) holds the release logs, locks and snapshots. `/var/backups/openvibe/<service>` is created per service, owned by the service user, so the backup worker can write there. Both directories are created on first use.++4. **Run as root:** `sudo ovhost …`. `ovhost` drops to `ubuntu` for git/npm/SQLite (`runuser`) and needs root for `systemctl`, `nginx -t`, reading `/etc/openvibe/*.env` (names only) and writing vhosts. For least privilege, give operator accounts a sudoers rule for `/usr/local/bin/ovhost` alone instead of a general `ALL`. Do **not** grant it to the `ubuntu` service user if that account has no sudo today.++5. **First run:** `sudo ovhost status`, then `sudo ovhost validate <each service>`, then `sudo ovhost plan live`, all read-only apart from `git fetch`. Adopt `deploy` one service at a time, starting with one that has no protected sessions.--- deploy/restart/rollback one service without interrupting unrelated runtimes or protected sessions--- certificate renewal cannot expose provider credentials--- a hosted static project cannot read another tenant's objects--- killing/revoking one extension does not affect Chat/OpenRe/Live++## Development--## Bootstrap / extraction source++```++npm test # every test, each file in its own process++node test/deploy.test.js # one file++```--The deploy scripts in each current repository (`deploy/scripts/deploy.sh` variants), Network's TLS/nginx helpers and Sites' vhost generator.++Everything that touches the system goes through the executor (`lib/executor.js`). The tests replace it with `test/fake-host.js`, an in-memory host with git repos, systemd units, npm, nginx, ss, HTTP endpoints and SQLite. No test runs systemctl, git, npm, nginx or curl against the real machine. `test/executor.test.js` covers the real executor only where that is safe: SQLite on a temp database, HTTP to a local server, and temp files.## Launch rule@@@ -1,7 +1,22 @@{"repository": "OpenVibers/OpenVibe.Host",-- "stage": "placeholder",-- "code": false,++ "stage": "alpha",++ "code": true,++ "wave": "W21 Stage A","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-21"++ "runtime": "node 22, CLI only (no server)",++ "port": null,++ "portReserved": 4910,++ "domain": "openvibe.host",++ "contracts": "openvibe-contracts v0.7.0",++ "capabilities": [],++ "capabilitiesRegistered": false,++ "stages": {++ "A": "alpha: ovhost CLI written and tested against a fake host; not installed on the production host",++ "B": "not started",++ "C": "not started"++ },++ "installedOnHost": false,++ "adoptedByServices": [],++ "updated": "2026-09-22"}nnew file mode 100755@@@ -0,0 +1,6 @@++#!/usr/bin/env node++'use strict';++require('../lib/cli').main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (err) => {++ process.stderr.write(`[ovhost] ${err.stack || err.message}\n`);++ process.exitCode = 2;++});nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "version": "0.1.0",++ "contractRanges": {++ "openvibe-contracts": ">=0.7.0 <1.0.0"++ },++ "id": "host",++ "name": "OpenVibe.Host",++ "status": "alpha",++ "repository": "OpenVibers/OpenVibe.Host",++ "domains": [],++ "capabilities": [],++ "eventsProduced": [],++ "eventsConsumed": [],++ "namespacesOwned": [++ "host.*"++ ],++ "notes": "Stage A operator plane only: the ovhost CLI, run by an operator on the host (validate, plan, deploy with protected-session guards, automatic rollback, status, certificate inventory, vhost rendering, config snapshots, SQLite backups). No server and no public API yet; port 4910 is reserved for the later operator API. Stages B (tenant static hosting) and C (sandboxed user code) are not started."++}nnew file mode 100644@@@ -0,0 +1,161 @@++{++ "_about": "Example ovhost inventory for the single OpenVibe host (openvibe-oregon). Copy to /etc/openvibe/host.json (root:root, 0640) and correct anything that differs on the machine; the real file is never committed. Paths, units, ports and env file locations come from OpenVibe.Network docs/roadmap-baseline/data/services.json and prod-snapshot.json (22 Sep 2026) and each repository's deploy/ directory. env.required lists NAMES only.",++ "host": "openvibe-oregon",++ "stateDir": "/var/lib/openvibe-host",++ "backupDir": "/var/backups/openvibe",++ "nginx": {++ "sitesAvailable": "/etc/nginx/sites-available",++ "sitesEnabled": "/etc/nginx/sites-enabled",++ "bin": "nginx"++ },++ "defaults": {++ "owner": "ubuntu",++ "runAs": "ubuntu",++ "remote": "origin",++ "branch": "main"++ },++ "services": {++ "network": {++ "repo": "/opt/openvibe.network",++ "units": ["openvibe-network.service"],++ "unitSources": { "openvibe-network.service": "deploy/systemd/openvibe-network.service" },++ "envFile": "/etc/openvibe/network.env",++ "env": { "required": ["BASE_URL", "JWT_PRIVATE_KEY", "JWT_PUBLIC_KEY", "DB_PATH"] },++ "port": 4000,++ "ready": { "url": "http://127.0.0.1:4000/api/health", "timeoutSeconds": 30 },++ "noRestartPaths": ["docs/", "test/", "**/*.md"],++ "databases": [{ "name": "network", "path": "/opt/openvibe.network/data/network.db" }],++ "nginx": { "vhost": "openvibe.network.conf", "repoVhost": "deploy/nginx/openvibe.network.conf", "variant": "http" }++ },++ "live": {++ "repo": "/opt/openvibe.live",++ "units": ["openvibe-live.service"],++ "socketUnit": "openvibe-live.socket",++ "unitSources": {++ "openvibe-live.service": "deploy/systemd/openvibe-live.service",++ "openvibe-live.socket": "deploy/systemd/openvibe-live.socket",++ "openvibe-live.service.d/socket.conf": "deploy/systemd/openvibe-live.service.d/socket.conf"++ },++ "envFile": "/etc/openvibe/live.env",++ "env": { "required": ["BASE_URL", "JWT_SECRET", "OV_NETWORK_URL", "OV_OAUTH_CLIENT_ID", "OV_OAUTH_CLIENT_SECRET", "MEDIA_URL", "MEDIA_API_KEY", "MEDIA_WEBHOOK_SECRET"] },++ "port": 3000,++ "ready": { "url": "http://127.0.0.1:3000/api/ready", "timeoutSeconds": 90 },++ "noRestartPaths": ["public/", "docs/", "test/", "scripts/", "hardware/", "deploy/nginx/", "deploy/cloudflare/", "deploy/fail2ban/", "**/*.md"],++ "protected": {++ "kind": "http-json-count",++ "url": "http://127.0.0.1:3000/api/streams",++ "array": "streams",++ "where": { "is_live": true },++ "label": "live streams"++ },++ "drain": { "policy": "refuse", "waitMaxSeconds": 28800, "pollSeconds": 60, "quietChecks": 2 },++ "databases": [++ { "name": "live", "path": "/opt/openvibe.live/data/live.db" },++ { "name": "rs-companion", "path": "/opt/openvibe.live/data/rs-companion.db" }++ ],++ "_databasesNote": "analytics.db (1.7 GB, append-only pageviews) is left out of routine backups on purpose; add it here to include it.",++ "backupOnChange": ["server/db/migrations.js", "server/db/schema.sql"],++ "nginx": { "vhost": "openvibe.live.conf", "repoVhost": "deploy/nginx/openvibe.live.conf", "variant": "websocket", "wsPaths": ["/ws/"], "maxBody": "3m" }++ },++ "media": {++ "repo": "/opt/openvibe.media",++ "units": ["openvibe-media.service"],++ "unitSources": { "openvibe-media.service": "deploy/systemd/openvibe-media.service" },++ "envFile": "/etc/openvibe/media.env",++ "env": { "required": ["DB_PATH", "VOD_PATH", "MEDIA_B2_ENDPOINT", "MEDIA_B2_BUCKET", "MEDIA_B2_KEY_ID", "MEDIA_B2_APP_KEY", "MEDIA_R2_ENDPOINT", "MEDIA_R2_BUCKET", "MEDIA_R2_ACCESS_KEY_ID", "MEDIA_R2_SECRET_ACCESS_KEY"] },++ "port": 4100,++ "ready": { "url": "http://127.0.0.1:4100/healthz", "timeoutSeconds": 60 },++ "noRestartPaths": ["docs/", "test/", "**/*.md"],++ "protected": {++ "kind": "sqlite-count",++ "db": "/opt/openvibe.media/data/media.db",++ "sql": "SELECT count(*) AS n FROM vods WHERE is_recording = 1",++ "label": "recordings in progress"++ },++ "drain": { "policy": "refuse", "waitMaxSeconds": 43200, "pollSeconds": 60, "quietChecks": 2 },++ "databases": [{ "name": "media", "path": "/opt/openvibe.media/data/media.db" }],++ "nginx": { "vhost": "openvibe.media.conf", "repoVhost": "deploy/nginx/openvibe.media.conf", "variant": "http" }++ },++ "tools": {++ "repo": "/opt/openvibe.tools",++ "packages": ["apps/*"],++ "units": [++ "openvibe-tools.service",++ "openvibe-tools-maps.service",++ "openvibe-tools-food.service",++ "openvibe-tools-img.service",++ "openvibe-tools-yt.service",++ "openvibe-tools-audio.service",++ "openvibe-tools-text.service",++ "openvibe-tools-docs.service"++ ],++ "envFile": "/etc/openvibe/tools.env",++ "env": { "required": ["OV_NETWORK_URL", "OV_OAUTH_CLIENT_ID", "OV_OAUTH_CLIENT_SECRET", "MEDIA_URL"] },++ "port": 4001,++ "ready": { "url": "http://127.0.0.1:4001/api/health", "headers": { "Host": "openvibe.tools" }, "timeoutSeconds": 60 },++ "noRestartPaths": ["**/*.md"],++ "databases": [++ { "name": "tools-audio-analytics", "path": "/opt/openvibe.tools/apps/audio/data/analytics.db" },++ { "name": "tools-docs-analytics", "path": "/opt/openvibe.tools/apps/docs/data/analytics.db" },++ { "name": "tools-food-analytics", "path": "/opt/openvibe.tools/apps/food/data/analytics.db" },++ { "name": "tools-img-analytics", "path": "/opt/openvibe.tools/apps/img/data/analytics.db" },++ { "name": "tools-maps-analytics", "path": "/opt/openvibe.tools/apps/maps/data/analytics.db" },++ { "name": "tools-text-analytics", "path": "/opt/openvibe.tools/apps/text/data/analytics.db" },++ { "name": "tools-yt-analytics", "path": "/opt/openvibe.tools/apps/yt/data/analytics.db" }++ ]++ },++ "community": {++ "repo": "/opt/openvibe.community",++ "units": ["openvibe-community.service"],++ "unitSources": { "openvibe-community.service": "deploy/systemd/openvibe-community.service" },++ "envFile": "/etc/openvibe/community.env",++ "env": { "required": ["BASE_URL", "OV_NETWORK_URL", "OV_OAUTH_CLIENT_ID", "OV_OAUTH_CLIENT_SECRET", "OV_OAUTH_REDIRECT_URI"] },++ "port": 4200,++ "ready": { "url": "http://127.0.0.1:4200/api/ready", "timeoutSeconds": 30 },++ "noRestartPaths": ["docs/", "test/", "**/*.md"],++ "databases": [++ { "name": "community", "path": "/var/lib/openvibe-community/community.db" },++ { "name": "community-legacy-path", "path": "/opt/openvibe.community/data/community.db" }++ ],++ "_databasesNote": "The repo unit puts the database in /var/lib/openvibe-community; the 22 Sep production snapshot found it under /opt/openvibe.community/data. Keep the one that exists (backup skips a missing path).",++ "nginx": { "vhost": "openvibe.community.conf", "repoVhost": "deploy/nginx/openvibe.community.conf", "variant": "http" }++ },++ "events": {++ "_note": "Not deployed as of the 22 Sep 2026 production snapshot (no events.env on the host).",++ "repo": "/opt/openvibe.events",++ "units": ["openvibe-events.service"],++ "unitSources": { "openvibe-events.service": "deploy/systemd/openvibe-events.service" },++ "envFile": "/etc/openvibe/events.env",++ "env": { "required": ["OV_NETWORK_URL", "OV_NETWORK_INTERNAL_URL"] },++ "port": 4300,++ "ready": { "url": "http://127.0.0.1:4300/api/ready", "timeoutSeconds": 30 },++ "noRestartPaths": ["docs/", "test/", "**/*.md"],++ "protected": {++ "kind": "http-json-count",++ "url": "http://127.0.0.1:4300/api/ready",++ "field": "realtime_connections",++ "label": "SSE connections"++ },++ "drain": { "policy": "report" },++ "databases": [{ "name": "events", "path": "/var/lib/openvibe-events/events.db" }],++ "nginx": { "vhost": "events.openvibe.network.conf", "repoVhost": "deploy/nginx/events.openvibe.network.conf", "variant": "sse", "ssePaths": ["/realtime/stream"], "certName": "openvibe.network" }++ },++ "games": {++ "repo": "/opt/openvibe.games",++ "managed": false,++ "unmanagedReason": "pnpm workspace with a TypeScript build; its deploy steps are not encoded in Stage A (status, validate, snapshot and backup still work)",++ "units": ["openvibe-games.service"],++ "envFile": "/etc/openvibe/games.env",++ "port": 8000,++ "ready": { "url": "http://127.0.0.1:8000/healthz", "timeoutSeconds": 60 },++ "databases": [{ "name": "world", "path": "/opt/openvibe.games/data/world.db" }]++ },++ "sites": {++ "repo": "/opt/openvibe.sites",++ "units": [],++ "install": { "command": ["npm", "ci", "--omit=dev", "--no-audit", "--no-fund"], "always": true },++ "build": [["node", "build.js"]],++ "nginx": { "repoVhosts": "deploy/nginx/*.conf", "installOnDeploy": true }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++/**++ * Certificate inventory: every `ssl_certificate` referenced by an enabled nginx vhost, with its++ * subject, names, issuer and expiry. Only certificate blocks are ever parsed; `ssl_certificate_key`++ * lines are never followed, a path that looks like a key is refused, and a combined PEM file has++ * everything except its CERTIFICATE blocks discarded before parsing. No key material is read into++ * output, logs or records.++ */++const crypto = require('crypto');++const path = require('path');++++const CERT_LINE_RE = /^\s*ssl_certificate\s+("?)([^";\s]+)\1\s*;/gm;++const KEY_PATH_RE = /(privkey|private|\.key$)/i;++const CERT_BLOCK_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/;++++function referencedCerts(vhostText) {++ const out = [];++ let m;++ CERT_LINE_RE.lastIndex = 0;++ while ((m = CERT_LINE_RE.exec(vhostText))) out.push(m[2]);++ return out;++}++++/** X509Certificate subjects are newline-separated "K=V" lines. */++function field(dn, key) {++ const m = String(dn || '').match(new RegExp(`(?:^|\\n)${key}=([^\\n]+)`));++ return m ? m[1] : null;++}++++function describe(pem, now) {++ const block = pem.match(CERT_BLOCK_RE);++ if (!block) return { error: 'no CERTIFICATE block' };++ const x = new crypto.X509Certificate(block[0]);++ const notAfter = new Date(x.validTo);++ const daysLeft = Math.floor((notAfter.getTime() - now) / 86400000);++ const cn = field(x.subject, 'CN');++ const issuer = [field(x.issuer, 'O'), field(x.issuer, 'CN')].filter(Boolean).join(' ');++ const names = (x.subjectAltName || '').split(',').map((s) => s.trim().replace(/^DNS:/, '')).filter(Boolean);++ return { subject: cn, names, issuer, notBefore: new Date(x.validFrom).toISOString(), notAfter: notAfter.toISOString(), daysLeft, fingerprint256: x.fingerprint256 };++}++++async function inventory(exec, inv, { warnDays = 21 } = {}) {++ const entries = (await exec.readdir(inv.nginx.sitesEnabled)) || [];++ const byPath = new Map();++ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {++ const file = path.join(inv.nginx.sitesEnabled, e.name);++ const text = await exec.readFile(file, { privileged: true });++ if (text == null) continue;++ for (const p of referencedCerts(text)) (byPath.get(p) || byPath.set(p, []).get(p)).push(e.name);++ }++ const now = exec.now();++ const certs = [];++ for (const [certPath, vhosts] of byPath) {++ const row = { path: certPath, vhosts };++ if (certPath.includes('$')) { row.status = 'dynamic'; row.error = 'path uses an nginx variable'; certs.push(row); continue; }++ if (KEY_PATH_RE.test(path.basename(certPath))) { row.status = 'refused'; row.error = 'looks like a key file; not read'; certs.push(row); continue; }++ const pem = await exec.readFile(certPath, { privileged: true });++ if (pem == null) { row.status = 'missing'; row.error = 'file not found'; certs.push(row); continue; }++ try {++ Object.assign(row, describe(pem, now));++ row.status = row.error ? 'unreadable' : row.daysLeft < 0 ? 'expired' : row.daysLeft < warnDays ? 'expiring' : 'ok';++ } catch (err) {++ row.status = 'unreadable';++ row.error = err.message;++ }++ certs.push(row);++ }++ return certs;++}++++module.exports = { inventory, referencedCerts, describe };nnew file mode 100644@@@ -0,0 +1,194 @@++'use strict';++/**++ * ovhost command line. main(argv, { exec, out, env }) returns the exit code, so the tests drive the++ * whole CLI against the fake host.++ */++const lib = require('./index');++const { InventoryError } = require('./inventory');++const { LockedError } = require('./lock');++const { SocketGuardError } = require('./systemd');++++const USAGE = `ovhost — OpenVibe.Host operator plane (Stage A)++++Usage: ovhost <command> [args] [--inventory <file>] [--json]++++ status [<service>...] unit state, sha, readiness, protected sessions (all services)++ validate <service> env names, unit files, port, nginx vhost + nginx -t, deps++ env-names <service> variable NAMES declared in the checkout's .env.example++ plan <service> [--to <sha>] [--no-fetch]++ what a deploy would do (fetches the remote; changes nothing else)++ deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]++ rollback <service> [--to <sha>] [--wait-idle] [--force]++ releases <service> [--limit <n>] the release log++ certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry++ nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]++ print a vhost from the service manifest; --install writes it,++ runs nginx -t and reloads only if clean++ snapshot <service> [--out <file>] config tarball: units, vhost, env NAMES, sha — no secret values++ backup <service> sqlite .backup of the declared databases to a dated directory++++Inventory: --inventory, else $OVHOST_INVENTORY, else /etc/openvibe/host.json.++Exit codes: 0 ok · 1 usage/precondition · 2 validation failed (nothing restarted) · 3 not ready,++rolled back · 4 rollback failed (manual intervention) · 5 protected sessions active++`;++++const FLAGS_WITH_VALUE = new Set(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout']);++++function parseArgs(argv) {++ const pos = [];++ const opts = {};++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '-h' || a === '--help') { opts.help = true; continue; }++ if (a.startsWith('--')) {++ const eq = a.indexOf('=');++ const name = eq > 0 ? a.slice(2, eq) : a.slice(2);++ if (FLAGS_WITH_VALUE.has(name)) {++ const v = eq > 0 ? a.slice(eq + 1) : argv[++i];++ if (v === undefined) throw new UsageError(`--${name} needs a value`);++ opts[name] = v;++ } else opts[name] = true;++ } else pos.push(a);++ }++ return { pos, opts };++}++++class UsageError extends Error {}++++const ICON = { ok: ' ok ', warn: 'WARN ', error: 'FAIL ', info: 'info ' };++++function short(sha) { return sha ? sha.slice(0, 12) : '-'; }++++async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env } = {}) {++ let parsed;++ try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }++ const { pos, opts } = parsed;++ const cmd = pos[0];++ if (!cmd || opts.help) { out(USAGE); return cmd || opts.help ? 0 : 1; }++ const json = !!opts.json;++ const log = json ? () => {} : (s) => out(`[ovhost] ${s}`);++ const emit = (obj) => out(JSON.stringify(obj, null, 2));++ const needService = () => { if (!pos[1]) throw new UsageError(`${cmd} needs a <service>`); return pos[1]; };++++ try {++ const inv = await lib.inventory.load(exec, { file: opts.inventory, env });++ const ctx = { exec, inv, log };++ switch (cmd) {++ case 'validate': {++ const r = await lib.validate(ctx, needService());++ if (json) emit(r);++ else {++ for (const f of r.findings) out(`${ICON[f.level]} ${f.area.padEnd(8)} ${f.message}`);++ out(`${r.service}: ${r.ok ? 'valid' : 'NOT valid'} (${r.errors} error(s), ${r.warnings} warning(s))`);++ }++ return r.ok ? 0 : 2;++ }++ case 'env-names': {++ const r = await lib.envNames(ctx, needService());++ if (json) emit(r);++ else if (!r.found) out(`${r.file} not found`);++ else {++ out(`${r.file}: ${r.declared.length} declared name(s)`);++ out(r.declared.join('\n'));++ if (r.commented.length) out(`commented out: ${r.commented.join(', ')}`);++ }++ return r.found ? 0 : 1;++ }++ case 'plan': {++ const p = await lib.plan(ctx, needService(), { to: opts.to, fetch: !opts['no-fetch'], restart: !!opts.restart });++ if (json) { emit(p); return 0; }++ out(`${p.service}: ${short(p.from)} → ${short(p.to)}${p.upToDate ? ' (up to date)' : ''} on ${p.branch}${p.branch !== p.expectedBranch ? ` (EXPECTED ${p.expectedBranch})` : ''}`);++ for (const c of p.commits.slice(0, 30)) out(` ${c}`);++ out(` files changed ${p.changedFiles} (${p.codeChanged} outside the no-restart paths)`);++ out(` tracked changes ${p.dirty.length ? `${p.dirty.join(', ')} — deploy will refuse` : 'none'}`);++ for (const i of p.installs) out(` ${i.pkg.padEnd(18)} ${i.install ? `install (${[i.lockfileChanged && 'lockfile changed', i.depsChanged && 'deps changed', i.missingNodeModules && 'no node_modules'].filter(Boolean).join(', ') || 'always'})` : 'dependencies unchanged'}`);++ out(` restart ${p.restartNeeded ? `yes: ${p.units.join(', ')}${p.socketUnit ? ` (socket ${p.socketUnit} stays up)` : ''}` : 'no'}`);++ if (p.protectedSessions) {++ const s = p.protectedSessions;++ out(` protected ${s.count == null ? `UNKNOWN (${s.unknown}) — treated as active` : `${s.count} ${s.label}`}${s.notRunning ? ' (service not running)' : ''}; drain policy ${p.drainPolicy}`);++ }++ if (p.backupNeeded) out(' backup schema files changed — databases are backed up before the restart');++ if (p.unitDrift.length) out(` unit files differ from the repo: ${p.unitDrift.map((u) => u.unit).join(', ')} (--install-units)`);++ if (p.installVhosts) out(' nginx repo vhosts are installed (nginx -t, reload) on deploy');++ if (!p.managed) out(' NOT MANAGED deploy/rollback refuse this service');++ return 0;++ }++ case 'deploy':++ case 'rollback': {++ const id = needService();++ const o = { waitIdle: !!opts['wait-idle'], force: !!opts.force, restart: !!opts.restart, to: opts.to, installUnits: !!opts['install-units'], readyTimeout: opts['ready-timeout'] ? Number(opts['ready-timeout']) : undefined };++ const r = cmd === 'deploy' ? await lib.deploy(ctx, id, o) : await lib.rollback(ctx, id, o);++ if (json) emit(r.record);++ else if (r.record.result !== 'unchanged') out(`[ovhost] result: ${r.record.result} (release ${r.record.id})`);++ return r.exitCode;++ }++ case 'status': {++ const rows = await lib.status(ctx, pos.slice(1));++ if (json) { emit(rows); return 0; }++ for (const r of rows) {++ const units = r.units.map((u) => `${u.unit.replace(/\.service$/, '')}=${u.active}${u.restarts ? `(${u.restarts} restarts)` : ''}`).join(' ');++ const sock = r.socket ? ` socket=${r.socket.active}` : '';++ const ready = r.ready ? (r.ready.ok ? 'ready' : `NOT READY (${r.ready.status || r.ready.error})`) : '-';++ const prot = r.protected ? (r.protected.count == null ? ` ${r.protected.label}=unknown` : ` ${r.protected.label}=${r.protected.count}`) : '';++ out(`${r.service.padEnd(10)} ${short(r.sha)} ${ready.padEnd(12)} ${units || '(no units)'}${sock}${prot}${r.managed ? '' : ' [unmanaged]'}`);++ }++ return 0;++ }++ case 'releases': {++ const id = lib.inventory.service(inv, needService()).id;++ const list = await lib.releases.list(exec, inv, id);++ const limit = Number(opts.limit || 20);++ const shown = list.slice(-limit);++ if (json) { emit(shown); return 0; }++ if (!shown.length) out(`no releases recorded for ${id}`);++ for (const r of shown) {++ out(`${r.startedAt} ${r.action.padEnd(8)} ${short(r.from)} → ${short(r.to)} ${String(r.result).padEnd(18)} ${r.operator || ''}${r.forced ? ' FORCED' : ''}${r.lockfileChanged ? ' lockfile' : ''}${r.error ? ` (${r.error.split('\n')[0].slice(0, 100)})` : ''}`);++ }++ return 0;++ }++ case 'certs': {++ const list = await lib.certs.inventory(exec, inv, { warnDays: Number(opts['warn-days'] || 21) });++ if (json) emit(list);++ else {++ for (const c of list) {++ out(`${String(c.status).toUpperCase().padEnd(9)} ${c.notAfter ? `${c.notAfter.slice(0, 10)} (${c.daysLeft}d)` : ''.padEnd(10)} ${c.path}${c.names ? ` [${c.names.slice(0, 4).join(', ')}${c.names.length > 4 ? `, +${c.names.length - 4}` : ''}]` : ''}${c.error ? ` ${c.error}` : ''} ← ${c.vhosts.join(', ')}`);++ }++ out(`${list.length} certificate(s) referenced by ${inv.nginx.sitesEnabled}`);++ }++ return list.some((c) => ['expired', 'missing', 'unreadable'].includes(c.status)) ? 2 : 0;++ }++ case 'nginx': {++ if (pos[1] !== 'render') throw new UsageError('usage: ovhost nginx render <service> [--variant …] [--install]');++ const id = pos[2];++ if (!id) throw new UsageError('nginx render needs a <service>');++ const svc = lib.inventory.service(inv, id);++ const r = lib.nginx.render(inv, svc, { variant: opts.variant, manifestFile: opts.manifest });++ if (!opts.install) { out(r.text); return 0; }++ const res = await lib.nginx.install(exec, inv, [r], { log });++ if (!res.changed.length) log(`${r.name} already installed and enabled; nothing changed`);++ return 0;++ }++ case 'snapshot': {++ const r = await lib.snapshot(ctx, needService(), { out: opts.out });++ if (json) emit(r); else { out(`snapshot: ${r.file}`); out(` ${r.entries.join('\n ')}`); }++ return 0;++ }++ case 'backup': {++ const r = await lib.backup(ctx, needService());++ if (json) emit(r); else for (const f of r.files) out(f.skipped ? `skipped ${f.source}: ${f.skipped}` : `${f.source} → ${f.dest} (${f.bytes} bytes)`);++ return 0;++ }++ default:++ throw new UsageError(`unknown command "${cmd}"`);++ }++ } catch (err) {++ if (err instanceof UsageError) { out(err.message); return 1; }++ if (err instanceof InventoryError || err instanceof LockedError) { out(`[ovhost] ${err.message}`); return 1; }++ if (err instanceof SocketGuardError) { out(`[ovhost] ${err.message}`); return 1; }++ if (err instanceof lib.OpError) { out(`[ovhost] ✗ ${err.message}`); return err.exitCode; }++ out(`[ovhost] ✗ ${err.message}`);++ return 2;++ }++}++++module.exports = { main, parseArgs, USAGE };nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++/**++ * ovhost backup <service>: an online SQLite `.backup` of every database the inventory declares for++ * the service, into <backupDir>/<service>/<YYYYMMDD-HHMMSS>/<name>.db. The copy is made by a worker++ * running as the service user (root never opens a service database — see sqlite-worker.js), checked++ * with PRAGMA quick_check, and recorded in <stateDir>/backups/<service>.jsonl.++ *++ * Backups are consistent snapshots taken while the service runs; they are not a restore drill++ * (that is Wave 22's job) and nothing here prunes old backups.++ */++const path = require('path');++const { service } = require('../inventory');++++function stamp(ms) {++ return new Date(ms).toISOString().replace(/[-:]/g, '').replace('T', '-').replace(/\.\d+Z$/, '');++}++++async function backup(ctx, id, { reason = 'manual' } = {}) {++ const { exec, inv, log } = ctx;++ const svc = service(inv, id);++ if (!svc.databases.length) throw new Error(`${id} declares no databases`);++ const dir = path.join(inv.backupDir, id, stamp(exec.now()));++ // The worker writes as the service user, so the base must be traversable and the service's++ // directory its own.++ await exec.mkdir(inv.backupDir, { mode: 0o755 });++ await exec.mkdir(path.join(inv.backupDir, id), { owner: svc.runAs, mode: 0o750 });++ await exec.mkdir(dir, { owner: svc.runAs, mode: 0o750 });++ const files = [];++ for (const db of svc.databases) {++ if (!(await exec.stat(db.path))) { files.push({ name: db.name, source: db.path, skipped: 'not found' }); log(`skip ${db.path}: not found`); continue; }++ // Named by the inventory name: two databases may share a file name (community.db twice).++ const dest = path.join(dir, `${db.name}${path.extname(db.path) || '.db'}`);++ log(`backing up ${db.path} → ${dest} (as ${svc.runAs})`);++ const t0 = exec.now();++ await exec.sqliteBackup(db.path, dest, { as: svc.runAs });++ const st = await exec.stat(dest);++ files.push({ name: db.name, source: db.path, dest, bytes: st ? st.size : null, seconds: Math.round((exec.now() - t0) / 1000) });++ }++ const record = { service: id, at: new Date(exec.now()).toISOString(), reason, dir, files };++ await exec.appendFile(path.join(inv.stateDir, 'backups', `${id}.jsonl`), `${JSON.stringify(record)}\n`);++ return record;++}++++module.exports = { backup, stamp };nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * ovhost snapshot <service>: the service's configuration as a .tar.gz WITHOUT secret values —++ *++ * snapshot.json sha, branch, remote (credentials stripped), inventory entry, service++ * manifest, env file mode + variable NAMES, unit paths, lockfile hashes++ * units/<unit> installed unit files and drop-ins; Environment= values whose name looks++ * secret (KEY, TOKEN, SECRET, PASSWORD, …) are replaced by <redacted>++ * nginx/<vhost> the installed vhost++ * env/<file>.names the env file's variable names, one per line — never values++ *++ * Written to <stateDir>/snapshots/<service>-<stamp>-<sha>.tar.gz (or --out), mode 0640.++ */++const path = require('path');++const crypto = require('crypto');++const { service } = require('../inventory');++const envfile = require('../envfile');++const systemd = require('../systemd');++const deps = require('../deps');++const { git } = require('../git');++const { createTarGz } = require('../tar');++const { stamp } = require('./backup');++++/** Inventory entries may carry request headers for probes; keep the names, drop the values. */++function withoutHeaderValues(svc) {++ const copy = JSON.parse(JSON.stringify(svc));++ for (const k of ['ready', 'protected']) {++ if (copy[k] && copy[k].headers) copy[k].headers = Object.fromEntries(Object.keys(copy[k].headers).map((h) => [h, '<redacted>']));++ }++ return copy;++}++++async function snapshot(ctx, id, { out } = {}) {++ const { exec, inv } = ctx;++ const svc = service(inv, id);++ const files = [];++ const meta = { service: id, takenAt: new Date(exec.now()).toISOString(), host: await exec.hostname(), inventory: withoutHeaderValues(svc) };++++ const g = git(exec, svc);++ try {++ meta.sha = await g.head();++ meta.branch = await g.branch();++ meta.remote = await g.remoteUrl();++ } catch (err) { meta.gitError = err.message.split('\n')[0]; }++++ try {++ const contracts = require('openvibe-contracts');++ meta.manifest = contracts.services.get(svc.manifest) || null;++ meta.contractsVersion = require('openvibe-contracts/package.json').version;++ } catch { meta.manifest = null; }++++ if (svc.envFile) {++ const st = await exec.stat(svc.envFile);++ const text = st ? await exec.readFile(svc.envFile, { privileged: true }) : null;++ const names = text == null ? [] : envfile.parseNames(text);++ meta.env = { file: svc.envFile, exists: !!st, mode: st ? (st.mode & 0o777).toString(8) : null, names: names.map((n) => n.name), empty: names.filter((n) => n.empty).map((n) => n.name) };++ files.push({ name: `env/${path.basename(svc.envFile)}.names`, content: `${names.map((n) => n.name).join('\n')}\n` });++ }++++ meta.units = [];++ for (const u of [...svc.units, ...(svc.socketUnit ? [svc.socketUnit] : [])]) {++ const st = await systemd.show(exec, u);++ meta.units.push({ unit: u, fragmentPath: st.fragmentPath, dropIns: st.dropIns, active: st.active });++ if (st.fragmentPath) {++ const text = await exec.readFile(st.fragmentPath, { privileged: true });++ if (text != null) files.push({ name: `units/${u}`, content: envfile.redactUnit(text) });++ }++ for (const d of st.dropIns) {++ const text = await exec.readFile(d, { privileged: true });++ if (text != null) files.push({ name: `units/${u}.d/${path.basename(d)}`, content: envfile.redactUnit(text) });++ }++ }++++ if (svc.nginx && svc.nginx.vhost) {++ const text = await exec.readFile(path.join(inv.nginx.sitesAvailable, svc.nginx.vhost), { privileged: true });++ if (text != null) files.push({ name: `nginx/${svc.nginx.vhost}`, content: text });++ meta.vhost = { name: svc.nginx.vhost, present: text != null };++ }++++ meta.lockfiles = {};++ for (const pkg of await deps.expandPackages(exec, svc)) {++ const lockText = await exec.readFile(path.join(svc.repo, pkg, 'package-lock.json'));++ if (lockText != null) meta.lockfiles[pkg] = crypto.createHash('sha256').update(lockText).digest('hex');++ }++++ files.unshift({ name: 'snapshot.json', content: `${JSON.stringify(meta, null, 2)}\n` });++ const dest = out || path.join(inv.stateDir, 'snapshots', `${id}-${stamp(exec.now())}-${(meta.sha || 'nosha').slice(0, 12)}.tar.gz`);++ await exec.mkdir(path.dirname(dest), { mode: 0o750 });++ await exec.writeFile(dest, createTarGz(files, exec.now()), { mode: 0o640 });++ return { service: id, file: dest, entries: files.map((f) => f.name), sha: meta.sha || null };++}++++module.exports = { snapshot };nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++/** ovhost status: every service — unit states, checkout sha, readiness, protected sessions. Read-only. */++const systemd = require('../systemd');++const readiness = require('../readiness');++const probes = require('../probes');++const { git } = require('../git');++++async function statusOne(ctx, svc) {++ const { exec } = ctx;++ const row = { service: svc.id, managed: svc.managed, units: [], socket: null, sha: null, branch: null, ready: null, protected: null };++ for (const u of svc.units) {++ const st = await systemd.show(exec, u);++ row.units.push({ unit: u, active: st.active, sub: st.sub, restarts: st.restarts });++ }++ if (svc.socketUnit) {++ const st = await systemd.show(exec, svc.socketUnit);++ row.socket = { unit: svc.socketUnit, active: st.active, sub: st.sub };++ }++ try {++ const g = git(exec, svc);++ row.sha = await g.head();++ row.branch = await g.branch();++ } catch (err) {++ row.shaError = err.message.split('\n')[0];++ }++ if (svc.ready) {++ const p = await readiness.probe(exec, svc);++ row.ready = { ok: p.ok, status: p.status || null, error: p.error || null, latencyMs: p.latencyMs };++ }++ if (svc.protected) {++ const c = await probes.countProtected(exec, svc);++ row.protected = { label: c.label, count: c.count, unknown: c.unknown || null, notRunning: !!c.notRunning };++ }++ return row;++}++++async function status(ctx, ids) {++ const list = ids && ids.length ? ids.map((id) => require('../inventory').service(ctx.inv, id)) : Object.values(ctx.inv.services);++ const rows = [];++ for (const svc of list) rows.push(await statusOne(ctx, svc));++ return rows;++}++++module.exports = { status };nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++/**++ * ovhost validate <service>: is the host set up the way the inventory says?++ *++ * env the env file exists, is not world-readable, and every required NAME is present and++ * non-empty (names from the unit's Environment= lines count as present). Values are++ * never read into the result — only { name, empty }.++ * units every unit is loaded from a unit file; the socket unit is active; repo unit files that++ * differ from the installed ones are reported.++ * port free (service down) or held by the service / its socket — nothing else.++ * nginx the vhost is in sites-available and linked in sites-enabled; `nginx -t` is clean.++ * checkout owned by the declared owner, on the declared branch, no tracked changes.++ * deps every dependency resolves (the Tools rule).++ *++ * Every finding is { level: error|warn|ok|info, area, message }. Exit 2 if any error.++ */++const path = require('path');++const { service } = require('../inventory');++const envfile = require('../envfile');++const systemd = require('../systemd');++const nginx = require('../nginx');++const deps = require('../deps');++const { git } = require('../git');++++async function validate(ctx, id) {++ const { exec, inv } = ctx;++ const svc = service(inv, id);++ const findings = [];++ const add = (level, area, message) => findings.push({ level, area, message });++++ // ── checkout ──++ const repoStat = await exec.stat(svc.repo);++ if (!repoStat || !repoStat.isDir) add('error', 'checkout', `${svc.repo} does not exist`);++ else {++ if (repoStat.owner !== svc.owner) add('error', 'checkout', `${svc.repo} is owned by ${repoStat.owner}, expected ${svc.owner}`);++ else add('ok', 'checkout', `${svc.repo} owned by ${svc.owner}`);++ try {++ const g = git(exec, svc);++ const [branch, head, dirty] = [await g.branch(), await g.head(), await g.dirty()];++ if (branch !== svc.branch) add('error', 'checkout', `on branch "${branch}", expected "${svc.branch}"`);++ add(dirty.length ? 'error' : 'ok', 'checkout', dirty.length ? `tracked local changes: ${dirty.slice(0, 8).join(', ')}` : `clean at ${head.slice(0, 12)}`);++ } catch (err) {++ add('error', 'checkout', err.message);++ }++ }++++ // ── env ──++ let unitEnvNames = [];++ for (const u of svc.units) {++ const st = await systemd.show(exec, u);++ if (st.fragmentPath) unitEnvNames = unitEnvNames.concat(envfile.unitEnvironmentNames(await exec.readFile(st.fragmentPath, { privileged: true })));++ for (const d of st.dropIns) unitEnvNames = unitEnvNames.concat(envfile.unitEnvironmentNames(await exec.readFile(d, { privileged: true })));++ }++ if (svc.envFile) {++ const st = await exec.stat(svc.envFile);++ if (!st) add('error', 'env', `${svc.envFile} does not exist`);++ else {++ if (st.mode & 0o004) add('error', 'env', `${svc.envFile} is world-readable (mode ${(st.mode & 0o777).toString(8)}); chmod 600`);++ else if (st.mode & 0o040) add('warn', 'env', `${svc.envFile} is group-readable (mode ${(st.mode & 0o777).toString(8)})`);++ else add('ok', 'env', `${svc.envFile} mode ${(st.mode & 0o777).toString(8)}`);++ if (st.mode & 0o002) add('error', 'env', `${svc.envFile} is world-writable`);++ const present = new Map(envfile.parseNames(await exec.readFile(svc.envFile, { privileged: true })).map((e) => [e.name, e]));++ for (const n of unitEnvNames) if (!present.has(n)) present.set(n, { name: n, empty: false, fromUnit: true });++ const example = await exec.readFile(path.join(svc.repo, svc.env.example));++ const exampleNames = example ? envfile.parseExample(example).declared : [];++ let required = svc.env.required;++ if (required === 'from-example') {++ required = exampleNames;++ if (!example) add('warn', 'env', `${svc.env.example} not found in the checkout; no required names`);++ }++ if (!required.length) add('info', 'env', 'no required names declared (env.required)');++ const missing = required.filter((n) => !present.has(n));++ const empty = required.filter((n) => present.has(n) && present.get(n).empty);++ if (missing.length) add('error', 'env', `missing: ${missing.join(', ')}`);++ if (empty.length) add('error', 'env', `empty: ${empty.join(', ')}`);++ if (required.length && !missing.length && !empty.length) add('ok', 'env', `${required.length} required name(s) present and non-empty`);++ const notSet = exampleNames.filter((n) => !present.has(n) && !required.includes(n));++ if (notSet.length) add('info', 'env', `in ${svc.env.example} but not set (optional unless declared required): ${notSet.join(', ')}`);++ }++ } else add('info', 'env', 'no env file declared');++++ // ── units ──++ const pids = new Set();++ for (const u of svc.units) {++ const st = await systemd.show(exec, u);++ if (st.load !== 'loaded' || !st.fragmentPath) add('error', 'units', `${u} is not loaded (${st.load})`);++ else add(st.active === 'active' ? 'ok' : 'warn', 'units', `${u} ${st.active}/${st.sub} from ${st.fragmentPath}`);++ if (st.mainPid) pids.add(st.mainPid);++ }++ if (svc.socketUnit) {++ const st = await systemd.show(exec, svc.socketUnit);++ if (st.load !== 'loaded') add('error', 'units', `${svc.socketUnit} is not loaded (${st.load})`);++ else add(st.active === 'active' ? 'ok' : 'error', 'units', `${svc.socketUnit} ${st.active}/${st.sub}${st.active === 'active' ? '' : ' — without it a restart refuses connections'}`);++ if (st.partOf.some((u) => svc.units.includes(u))) {++ add('warn', 'units', `${svc.socketUnit} has PartOf=${st.partOf.join(' ')}: systemd propagates a restart of that service to the socket (ovhost itself never stops or restarts it)`);++ }++ }++ for (const [unit, src] of Object.entries(svc.unitSources)) {++ const want = await exec.readFile(path.join(svc.repo, src));++ if (want == null) { add('warn', 'units', `unit source ${src} not in the checkout`); continue; }++ const have = await exec.readFile(path.join('/etc/systemd/system', unit), { privileged: true });++ if (have !== want) add('warn', 'units', `${unit} differs from ${src} (deploy --install-units installs it)`);++ }++++ // ── port ──++ if (svc.port) {++ try {++ const holders = await exec.listeners(svc.port);++ if (!holders.length) {++ const anyActive = [...pids].length > 0;++ add(anyActive ? 'error' : 'ok', 'port', anyActive ? `${svc.port} has no listener although ${svc.units.join(', ')} is running` : `${svc.port} is free`);++ } else {++ const foreign = holders.filter((h) => !(h.pid && pids.has(h.pid)) && !(svc.socketUnit && h.pid === 1));++ if (foreign.length) add('error', 'port', `${svc.port} is held by ${foreign.map((h) => `${h.process || '?'}(${h.pid || '?'})`).join(', ')}, not by ${svc.units.join(', ') || svc.id}`);++ else add('ok', 'port', `${svc.port} held by ${holders.map((h) => (h.pid === 1 ? `systemd (${svc.socketUnit})` : `${h.process}(${h.pid})`)).join(', ')}`);++ }++ } catch (err) {++ add('warn', 'port', `could not list listeners: ${err.message}`);++ }++ }++++ // ── nginx ──++ if (svc.nginx && svc.nginx.vhost) {++ const available = path.join(inv.nginx.sitesAvailable, svc.nginx.vhost);++ const enabled = path.join(inv.nginx.sitesEnabled, svc.nginx.vhost);++ const a = await exec.stat(available);++ const e = await exec.stat(enabled);++ add(a ? 'ok' : 'error', 'nginx', a ? `${available} present` : `${available} missing`);++ add(e ? 'ok' : 'error', 'nginx', e ? `${enabled} enabled` : `${enabled} missing (not enabled)`);++ if (a && svc.nginx.repoVhost) {++ const repoText = await exec.readFile(path.join(svc.repo, svc.nginx.repoVhost));++ const live = await exec.readFile(available, { privileged: true });++ if (repoText != null && repoText !== live) add('warn', 'nginx', `${svc.nginx.vhost} differs from ${svc.nginx.repoVhost} in the checkout`);++ }++ }++ if (svc.nginx) {++ const t = await nginx.test(exec, inv);++ add(t.ok ? 'ok' : 'error', 'nginx', t.ok ? 'nginx -t clean' : `nginx -t failed: ${t.output.split('\n').slice(-3).join(' / ')}`);++ }++++ // ── deps ──++ if (repoStat && repoStat.isDir && svc.managed) {++ const pkgDirs = await deps.expandPackages(exec, svc);++ const problems = await deps.verify(exec, svc, pkgDirs);++ if (problems.length) for (const p of problems) add('error', 'deps', `${p.pkg}: ${p.dep} — ${p.reason}`);++ else add('ok', 'deps', `every dependency resolves (${pkgDirs.join(', ') || 'no packages'})`);++ }++++ const errors = findings.filter((f) => f.level === 'error').length;++ return { service: id, ok: errors === 0, errors, warnings: findings.filter((f) => f.level === 'warn').length, findings };++}++++/** Names declared in the checkout's .env.example — to help write env.required. Names only. */++async function envNames(ctx, id) {++ const svc = service(ctx.inv, id);++ const text = await ctx.exec.readFile(path.join(svc.repo, svc.env.example));++ if (text == null) return { service: id, file: path.join(svc.repo, svc.env.example), found: false, declared: [], commented: [] };++ return { service: id, file: path.join(svc.repo, svc.env.example), found: true, ...envfile.parseExample(text) };++}++++module.exports = { validate, envNames };nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++/**++ * Dependencies: when to install, how, and proof that every dependency resolves before anything++ * restarts.++ *++ * The verification rule comes from a Tools outage: a dependency that used to be a `file:` link left++ * an empty directory that npm treated as installed, and every unit crash-looped on the missing++ * module. So a package counts as resolved only when node_modules/<dep>/package.json exists, parses,++ * and names that package. A broken one is removed and reinstalled once; if it still does not++ * resolve, the deploy aborts with nothing restarted.++ */++const path = require('path');++++/** package dirs (relative) from patterns like ".", "apps/*" — only dirs that have a package.json. */++async function expandPackages(exec, svc) {++ const out = [];++ for (const pattern of svc.packages) {++ if (pattern.endsWith('/*')) {++ const base = pattern.slice(0, -2);++ const entries = (await exec.readdir(path.join(svc.repo, base))) || [];++ for (const e of entries.filter((x) => x.isDir).sort((a, b) => a.name.localeCompare(b.name))) {++ const dir = path.join(base, e.name);++ if (await exec.stat(path.join(svc.repo, dir, 'package.json'))) out.push(dir);++ }++ } else if (await exec.stat(path.join(svc.repo, pattern, 'package.json'))) {++ out.push(path.normalize(pattern));++ }++ }++ return out;++}++++function depFields(text) {++ try {++ const j = JSON.parse(text);++ return JSON.stringify([j.dependencies || {}, j.optionalDependencies || {}, j.engines || {}]);++ } catch { return null; }++}++++/** Whether a package dir needs an install between two revisions (lockfile or dependency fields changed). */++async function needsInstall(g, from, to, pkgDir) {++ const lock = path.join(pkgDir, 'package-lock.json');++ const pkg = path.join(pkgDir, 'package.json');++ const [lockA, lockB, pkgA, pkgB] = await Promise.all([g.show(from, lock), g.show(to, lock), g.show(from, pkg), g.show(to, pkg)]);++ const lockfileChanged = lockA !== lockB;++ const depsChanged = depFields(pkgA) !== depFields(pkgB);++ return { lockfileChanged, depsChanged, needed: lockfileChanged || depsChanged };++}++++/**++ * npm install as the checkout owner. Afterwards the lockfile is restored from git: npm rewrites it++ * on the host (different npm version), and a dirty lockfile blocks the next fast-forward pull.++ */++async function install(exec, svc, g, pkgDir) {++ const [cmd, ...args] = svc.install.command;++ const r = await exec.run(cmd, args, { as: svc.owner, cwd: path.join(svc.repo, pkgDir), timeoutMs: 20 * 60 * 1000 });++ if (r.code !== 0) throw new Error(`${svc.install.command.join(' ')} failed in ${pkgDir}: ${(r.stderr || r.stdout).trim().split('\n').slice(-3).join(' / ')}`);++ if (svc.install.restoreLockfile) {++ const lock = path.join(pkgDir, 'package-lock.json');++ const dirty = await g.dirty();++ if (dirty.includes(lock)) await g.checkoutFile(lock);++ }++}++++/** Returns [{ pkg, dep, reason }] for every declared dependency that does not resolve. */++async function verify(exec, svc, pkgDirs) {++ const problems = [];++ for (const pkgDir of pkgDirs) {++ const root = path.join(svc.repo, pkgDir);++ const text = await exec.readFile(path.join(root, 'package.json'));++ let pkg;++ try { pkg = JSON.parse(text); } catch { problems.push({ pkg: pkgDir, dep: null, reason: 'package.json does not parse' }); continue; }++ for (const dep of Object.keys(pkg.dependencies || {})) {++ const depJson = await exec.readFile(path.join(root, 'node_modules', dep, 'package.json'));++ if (depJson == null) { problems.push({ pkg: pkgDir, dep, reason: 'node_modules/' + dep + '/package.json is missing' }); continue; }++ let meta;++ try { meta = JSON.parse(depJson); } catch { problems.push({ pkg: pkgDir, dep, reason: 'its package.json does not parse' }); continue; }++ if (meta.name !== dep) problems.push({ pkg: pkgDir, dep, reason: `installed package is named "${meta.name}"` });++ }++ }++ return problems;++}++++/** verify; remove and reinstall anything broken once; verify again. Returns the remaining problems. */++async function verifyAndRepair(exec, svc, g, pkgDirs, log = () => {}) {++ let problems = await verify(exec, svc, pkgDirs);++ if (!problems.length) return { problems, repaired: [] };++ const repaired = [];++ const byPkg = new Map();++ for (const p of problems) (byPkg.get(p.pkg) || byPkg.set(p.pkg, []).get(p.pkg)).push(p);++ for (const [pkgDir, list] of byPkg) {++ for (const p of list) {++ log(`dependency ${p.dep || '(package.json)'} in ${pkgDir} does not resolve (${p.reason}) — reinstalling`);++ if (p.dep) await exec.run('rm', ['-rf', '--', path.join(svc.repo, pkgDir, 'node_modules', p.dep)], { as: svc.owner });++ }++ await install(exec, svc, g, pkgDir);++ repaired.push(pkgDir);++ }++ problems = await verify(exec, svc, pkgDirs);++ return { problems, repaired };++}++++module.exports = { expandPackages, needsInstall, install, verify, verifyAndRepair };nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * Environment files, NAMES ONLY. parseNames() reads KEY=value lines and returns { name, empty } —++ * the value is looked at just long enough to know whether it is empty and is never returned,++ * logged or stored. Nothing in ovhost has a code path that prints an env value.++ */++const LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;++const SECRET_NAME_RE = /(SECRET|PASSWORD|PASSWD|PASS$|TOKEN|KEY|CREDENTIAL|PRIVATE|COOKIE|SALT|DSN)/i;++++function isEmptyValue(raw) {++ let v = raw.trim();++ if (!v.startsWith('"') && !v.startsWith("'")) v = v.replace(/(^|\s+)#.*$/, '');++ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) v = v.slice(1, -1);++ return v.trim() === '';++}++++function parseNames(text) {++ const seen = new Map();++ for (const line of String(text || '').split('\n')) {++ const m = line.match(LINE_RE);++ if (!m) continue;++ seen.set(m[1], { name: m[1], empty: isEmptyValue(m[2]) });++ }++ return [...seen.values()];++}++++/** Names a .env.example declares (uncommented lines) and names it only mentions in comments. */++function parseExample(text) {++ const declared = [];++ const commented = [];++ for (const line of String(text || '').split('\n')) {++ let m = line.match(LINE_RE);++ if (m) { if (!declared.includes(m[1])) declared.push(m[1]); continue; }++ m = line.match(/^\s*#\s*(?:export\s+)?([A-Z][A-Z0-9_]*)=/);++ if (m && !commented.includes(m[1])) commented.push(m[1]);++ }++ return { declared, commented: commented.filter((n) => !declared.includes(n)) };++}++++/** Environment=A=1 "B=two words" lines in a unit file -> names. */++function unitEnvironmentNames(unitText) {++ const names = [];++ for (const line of String(unitText || '').split('\n')) {++ const m = line.match(/^\s*Environment=(.*)$/);++ if (!m) continue;++ for (const part of splitAssignments(m[1])) {++ const n = part.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);++ if (n && !names.includes(n[1])) names.push(n[1]);++ }++ }++ return names;++}++++function splitAssignments(s) {++ const out = [];++ let cur = '';++ let quote = null;++ for (const ch of s) {++ if (quote) { if (ch === quote) quote = null; else cur += ch; continue; }++ if (ch === '"' || ch === "'") { quote = ch; continue; }++ if (/\s/.test(ch)) { if (cur) out.push(cur); cur = ''; continue; }++ cur += ch;++ }++ if (cur) out.push(cur);++ return out;++}++++/**++ * Unit text with the value of every secret-looking Environment= assignment replaced by <redacted>.++ * Used by snapshots: unit files are config worth keeping, but an operator may have put a secret++ * straight into a unit instead of the EnvironmentFile.++ */++function redactUnit(unitText) {++ return String(unitText || '').split('\n').map((line) => {++ const m = line.match(/^(\s*Environment=)(.*)$/);++ if (!m) return line;++ const parts = splitAssignments(m[2]).map((a) => {++ const i = a.indexOf('=');++ if (i < 0) return a;++ const name = a.slice(0, i);++ return SECRET_NAME_RE.test(name) ? `${name}=<redacted>` : a;++ });++ return `${m[1]}${parts.map((p) => (/\s/.test(p) ? `"${p}"` : p)).join(' ')}`;++ }).join('\n');++}++++module.exports = { parseNames, parseExample, unitEnvironmentNames, redactUnit, SECRET_NAME_RE };nnew file mode 100644@@@ -0,0 +1,235 @@++'use strict';++/**++ * The system executor: the ONLY module in ovhost that touches the machine (processes, files, HTTP,++ * SQLite). Every command and library function takes an executor, so the tests swap this for the++ * fake host in test/fake-host.js and nothing in the suite ever runs systemctl, git, npm or curl.++ *++ * Interface (all async):++ * run(cmd, args, { as, privileged, cwd, timeoutMs, input }) -> { code, stdout, stderr }++ * as run as this user (git/npm as the checkout owner, sqlite as the service user);++ * root uses runuser with the user's HOME, the same user runs directly, anyone else++ * goes through `sudo -n -H -u`.++ * privileged run as root (systemctl, nginx -t, reading 0600 env files); `sudo -n` unless root.++ * readFile(path, { privileged }) -> string | null++ * writeFile(path, content, { privileged, mode }) ; appendFile(path, text) ; removeFile(path, { privileged })++ * symlink(target, path, { privileged }) ; readlink(path) -> string | null++ * stat(path) -> { mode, uid, gid, owner, isFile, isDir, isSymlink, size } | null++ * readdir(path) -> [{ name, isDir }] | null++ * mkdir(path, { owner, mode }) ; createExclusive(path, content) -> bool ; pidAlive(pid) -> bool++ * http(url, { timeoutMs, headers }) -> { status, body } | { status: 0, error }++ * sqlite(db, sql, { as }) -> rows ; sqliteBackup(db, dest, { as })++ * listeners(port) -> [{ pid, process }]++ * sleep(ms) ; now() -> ms ; isRoot() ; userName() ; hostname()++ */++const { spawn } = require('child_process');++const fs = require('fs');++const httpLib = require('http');++const os = require('os');++const path = require('path');++++const WORKER = path.join(__dirname, 'sqlite-worker.js');++++function passwdEntry(user) {++ try {++ for (const line of fs.readFileSync('/etc/passwd', 'utf8').split('\n')) {++ const f = line.split(':');++ if (f[0] === user) return { name: f[0], uid: Number(f[2]), gid: Number(f[3]), home: f[5] };++ }++ } catch { /* no passwd file: callers fall back */ }++ return null;++}++++function userById(uid) {++ try {++ for (const line of fs.readFileSync('/etc/passwd', 'utf8').split('\n')) {++ const f = line.split(':');++ if (Number(f[2]) === uid) return f[0];++ }++ } catch { /* ignore */ }++ return String(uid);++}++++function createSystemExecutor({ env = process.env } = {}) {++ const isRoot = () => typeof process.getuid === 'function' && process.getuid() === 0;++ const me = () => { try { return os.userInfo().username; } catch { return env.USER || 'unknown'; } };++++ function wrap(cmd, args, { as, privileged } = {}) {++ if (as && as !== me()) {++ if (isRoot()) {++ const pw = passwdEntry(as);++ const home = pw ? pw.home : `/home/${as}`;++ return ['runuser', ['-u', as, '--', 'env', `HOME=${home}`, `PATH=${env.PATH || '/usr/local/bin:/usr/bin:/bin'}`, cmd, ...args]];++ }++ return ['sudo', ['-n', '-H', '-u', as, '--', cmd, ...args]];++ }++ if (privileged && !isRoot()) return ['sudo', ['-n', '--', cmd, ...args]];++ return [cmd, args];++ }++++ function run(cmd, args = [], opts = {}) {++ const [c, a] = wrap(cmd, args, opts);++ return new Promise((resolve) => {++ let stdout = '';++ let stderr = '';++ let child;++ try {++ // A process started as another user from root's cwd (/root) cannot read its own cwd, and++ // git refuses to start; give it / unless the caller chose a directory.++ child = spawn(c, a, { cwd: opts.cwd || (opts.as ? '/' : undefined), env: { ...env, ...(opts.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });++ } catch (err) {++ resolve({ code: 127, stdout: '', stderr: err.message });++ return;++ }++ const timer = opts.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), opts.timeoutMs) : null;++ child.stdout.on('data', (d) => { stdout += d; });++ child.stderr.on('data', (d) => { stderr += d; });++ child.on('error', (err) => { stderr += err.message; });++ child.on('close', (code) => {++ if (timer) clearTimeout(timer);++ resolve({ code: code === null ? 124 : code, stdout, stderr });++ });++ if (opts.input) child.stdin.end(opts.input); else child.stdin.end();++ });++ }++++ async function readFile(p, { privileged } = {}) {++ try {++ return fs.readFileSync(p, 'utf8');++ } catch (err) {++ if (err.code === 'ENOENT') return null;++ if (err.code === 'EACCES' && privileged && !isRoot()) {++ const r = await run('cat', ['--', p], { privileged: true });++ return r.code === 0 ? r.stdout : null;++ }++ throw err;++ }++ }++++ async function writeFile(p, content, { privileged, mode = 0o644 } = {}) {++ if (!privileged || isRoot()) {++ fs.writeFileSync(p, content, { mode });++ fs.chmodSync(p, mode);++ return;++ }++ const r = await run('install', ['-m', mode.toString(8), '/dev/stdin', p], { privileged: true, input: content });++ if (r.code !== 0) throw new Error(`could not write ${p}: ${r.stderr.trim()}`);++ }++++ async function removeFile(p, { privileged } = {}) {++ if (!privileged || isRoot()) { fs.rmSync(p, { force: true }); return; }++ await run('rm', ['-f', '--', p], { privileged: true });++ }++++ async function symlink(target, p, { privileged } = {}) {++ if (!privileged || isRoot()) { fs.symlinkSync(target, p); return; }++ const r = await run('ln', ['-s', target, p], { privileged: true });++ if (r.code !== 0) throw new Error(`could not link ${p}: ${r.stderr.trim()}`);++ }++++ async function readlink(p) { try { return fs.readlinkSync(p); } catch { return null; } }++++ async function stat(p) {++ try {++ const l = fs.lstatSync(p);++ const s = l.isSymbolicLink() ? fs.statSync(p) : l;++ return { mode: s.mode & 0o7777, uid: s.uid, gid: s.gid, owner: userById(s.uid), isFile: s.isFile(), isDir: s.isDirectory(), isSymlink: l.isSymbolicLink(), size: s.size };++ } catch { return null; }++ }++++ async function readdir(p) {++ try { return fs.readdirSync(p, { withFileTypes: true }).map((d) => ({ name: d.name, isDir: d.isDirectory() })); } catch { return null; }++ }++++ async function mkdir(p, { owner, mode = 0o750 } = {}) {++ fs.mkdirSync(p, { recursive: true, mode });++ if (owner && isRoot()) {++ const pw = passwdEntry(owner);++ if (pw) fs.chownSync(p, pw.uid, pw.gid);++ }++ }++++ async function appendFile(p, text) {++ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o750 });++ fs.appendFileSync(p, text, { mode: 0o640 });++ }++++ async function createExclusive(p, content) {++ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o750 });++ try { fs.writeFileSync(p, content, { flag: 'wx', mode: 0o640 }); return true; } catch (err) { if (err.code === 'EEXIST') return false; throw err; }++ }++++ function pidAlive(pid) {++ try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }++ }++++ // node:http rather than fetch: fetch silently drops a Host header, and the Tools gateway only++ // answers its health check for Host: openvibe.tools. Probes are loopback http:// only.++ function http(url, { timeoutMs = 5000, headers = {} } = {}) {++ return new Promise((resolve) => {++ let req;++ const timer = setTimeout(() => { if (req) req.destroy(); resolve({ status: 0, error: 'timeout' }); }, timeoutMs);++ try {++ req = httpLib.get(url, { headers }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (c) => { if (body.length < 4 * 1024 * 1024) body += c; });++ res.on('end', () => { clearTimeout(timer); resolve({ status: res.statusCode, body }); });++ res.on('error', (err) => { clearTimeout(timer); resolve({ status: 0, error: err.code || err.message }); });++ });++ req.on('error', (err) => { clearTimeout(timer); resolve({ status: 0, error: err.code || err.message }); });++ } catch (err) {++ clearTimeout(timer);++ resolve({ status: 0, error: err.message });++ }++ });++ }++++ async function sqliteCall(payload, as) {++ const r = await run(process.execPath, [WORKER, JSON.stringify(payload)], { as, timeoutMs: 6 * 3600 * 1000 });++ let out;++ try { out = JSON.parse(r.stdout); } catch { throw new Error(`sqlite worker failed: ${(r.stderr || r.stdout).trim().slice(0, 300)}`); }++ if (!out.ok) throw new Error(out.error);++ return out;++ }++++ async function listeners(port) {++ const r = await run('ss', ['-H', '-ltnp', `sport = :${port}`], { privileged: true });++ if (r.code !== 0) throw new Error(`ss failed: ${r.stderr.trim()}`);++ const out = [];++ for (const line of r.stdout.split('\n')) {++ if (!line.trim()) continue;++ const users = line.match(/users:\(\((.*)\)\)/);++ const procs = users ? [...users[1].matchAll(/"([^"]+)",pid=(\d+)/g)] : [];++ if (!procs.length) out.push({ pid: null, process: null });++ for (const m of procs) out.push({ pid: Number(m[2]), process: m[1] });++ }++ return out;++ }++++ return {++ kind: 'system',++ run,++ readFile,++ writeFile,++ removeFile,++ symlink,++ readlink,++ stat,++ readdir,++ mkdir,++ appendFile,++ createExclusive,++ pidAlive: async (pid) => pidAlive(pid),++ http,++ sqlite: async (db, sql, { as } = {}) => (await sqliteCall({ op: 'query', db, sql }, as)).rows,++ sqliteBackup: async (db, dest, { as } = {}) => { await sqliteCall({ op: 'backup', db, dest }, as); },++ listeners,++ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),++ now: () => Date.now(),++ isRoot: async () => isRoot(),++ userName: async () => me(),++ hostname: async () => os.hostname(),++ };++}++++module.exports = { createSystemExecutor };nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++/**++ * git adapter. Every command runs AS THE CHECKOUT OWNER (ubuntu on the host): root running git in++ * an ubuntu-owned checkout either trips "dubious ownership" or leaves root-owned objects behind++ * that break the next pull.++ */++function git(exec, svc) {++ const run = async (args, { allowFail = false } = {}) => {++ const r = await exec.run('git', ['-C', svc.repo, ...args], { as: svc.owner });++ if (r.code !== 0 && !allowFail) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || r.stdout).trim().split('\n').slice(-3).join(' / ')}`);++ return r;++ };++ return {++ head: async () => (await run(['rev-parse', 'HEAD'])).stdout.trim(),++ branch: async () => (await run(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim(),++ revParse: async (ref) => (await run(['rev-parse', '--verify', `${ref}^{commit}`])).stdout.trim(),++ hasCommit: async (sha) => (await run(['cat-file', '-e', `${sha}^{commit}`], { allowFail: true })).code === 0,++ /** Tracked changes only; untracked files (data/, local notes) never block a deploy. */++ dirty: async () => (await run(['status', '--porcelain', '--untracked-files=no'])).stdout.split('\n').filter(Boolean).map((l) => l.slice(3)),++ fetch: async () => { await run(['fetch', '--quiet', svc.remote, svc.branch]); },++ changedFiles: async (from, to) => (await run(['diff', '--name-only', from, to])).stdout.split('\n').filter(Boolean),++ log: async (from, to) => (await run(['log', '--oneline', '--no-decorate', `${from}..${to}`], { allowFail: true })).stdout.split('\n').filter(Boolean),++ show: async (sha, file) => { const r = await run(['show', `${sha}:${file}`], { allowFail: true }); return r.code === 0 ? r.stdout : null; },++ ffMerge: async (sha) => { await run(['merge', '--ff-only', '--quiet', sha]); },++ resetHard: async (sha) => { await run(['reset', '--hard', '--quiet', sha]); },++ checkoutFile: async (file) => { await run(['checkout', '--', file]); },++ remoteUrl: async () => redactUrl((await run(['config', '--get', `remote.${svc.remote}.url`], { allowFail: true })).stdout.trim()),++ };++}++++/** https://user:[email protected]/... -> https://github.com/... */++function redactUrl(url) {++ return String(url || '').replace(/^(\w+:\/\/)[^@/]+@/, '$1');++}++++module.exports = { git, redactUrl };nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++/**++ * OpenVibe.Host Stage A library. Every function takes a context { exec, inv, log }: `exec` is the++ * system executor (lib/executor.js) or the fake host in tests, `inv` the loaded inventory.++ */++const inventory = require('./inventory');++const ops = require('./release-ops');++const { validate, envNames } = require('./commands/validate');++const { status } = require('./commands/status');++const { backup } = require('./commands/backup');++const { snapshot } = require('./commands/snapshot');++const releases = require('./releases');++const certs = require('./certs');++const nginx = require('./nginx');++const { createSystemExecutor } = require('./executor');++++module.exports = {++ createSystemExecutor,++ inventory,++ validate,++ envNames,++ plan: ops.plan,++ deploy: ops.deploy,++ rollback: ops.rollback,++ status,++ backup,++ snapshot,++ releases,++ certs,++ nginx,++ EXIT: ops.EXIT,++ OpError: ops.OpError,++};nnew file mode 100644@@@ -0,0 +1,163 @@++'use strict';++/**++ * The host inventory: one JSON file describing every service on the host (see host.example.json).++ * The real file lives at /etc/openvibe/host.json and is never committed.++ *++ * load() normalises every entry and rejects anything that could turn a typo into damage: relative++ * paths, unit names that are not units, a socket unit listed as a service unit, vhost names with a++ * path in them. When ovhost runs as root the inventory must be root-owned and not group/world++ * writable — otherwise anyone who can edit it could point a root process at arbitrary files.++ */++const path = require('path');++++const DEFAULT_PATHS = ['/etc/openvibe/host.json'];++++class InventoryError extends Error {}++++const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;++const UNIT_RE = /^[A-Za-z0-9@._-]+\.(service|socket)$/;++const FILE_NAME_RE = /^[A-Za-z0-9._-]+$/;++const DRAIN_POLICIES = ['refuse', 'wait', 'report'];++const PROBE_KINDS = ['http-json-count', 'sqlite-count'];++++function abs(value, where) {++ if (typeof value !== 'string' || !path.isAbsolute(value) || value.includes('\0')) throw new InventoryError(`${where} must be an absolute path`);++ return path.normalize(value);++}++++function rel(value, where) {++ if (typeof value !== 'string' || !value || path.isAbsolute(value) || path.normalize(value).startsWith('..')) throw new InventoryError(`${where} must be a path inside the checkout`);++ return path.normalize(value);++}++++function normaliseService(id, raw, defaults) {++ if (!ID_RE.test(id)) throw new InventoryError(`service id "${id}" must match ${ID_RE}`);++ const where = `services.${id}`;++ const s = { ...defaults, ...raw };++ const out = {++ id,++ manifest: s.manifest || id,++ description: s.description || null,++ repo: abs(s.repo, `${where}.repo`),++ owner: s.owner,++ runAs: s.runAs || s.owner,++ remote: s.remote || 'origin',++ branch: s.branch || 'main',++ managed: s.managed !== false,++ unmanagedReason: s.unmanagedReason || null,++ units: s.units || [],++ socketUnit: s.socketUnit || null,++ unitSources: s.unitSources || {},++ envFile: s.envFile ? abs(s.envFile, `${where}.envFile`) : null,++ env: { required: (s.env && s.env.required) || [], example: (s.env && s.env.example) || '.env.example' },++ port: s.port == null ? null : Number(s.port),++ ready: s.ready || null,++ health: s.health || null,++ packages: s.packages || ['.'],++ install: {++ command: (s.install && s.install.command) || ['npm', 'install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'],++ always: !!(s.install && s.install.always),++ restoreLockfile: !(s.install && s.install.restoreLockfile === false),++ },++ build: s.build || [],++ noRestartPaths: s.noRestartPaths || [],++ protected: s.protected || null,++ drain: { policy: 'refuse', waitMaxSeconds: 8 * 3600, pollSeconds: 60, quietChecks: 2, ...(s.drain || {}) },++ databases: (s.databases || []).map((d, i) => {++ const name = d.name || path.basename(String(d.path), '.db');++ if (!FILE_NAME_RE.test(name)) throw new InventoryError(`${where}.databases[${i}].name must be a plain name`);++ return { name, path: abs(d.path, `${where}.databases[${i}].path`) };++ }),++ backupOnChange: s.backupOnChange || [],++ nginx: s.nginx || null,++ };++ if (typeof out.owner !== 'string' || !/^[a-z_][a-z0-9_-]*$/.test(out.owner)) throw new InventoryError(`${where}.owner must be a user name`);++ if (!/^[a-z_][a-z0-9_-]*$/.test(out.runAs)) throw new InventoryError(`${where}.runAs must be a user name`);++ if (!Array.isArray(out.units)) throw new InventoryError(`${where}.units must be an array`);++ for (const u of out.units) {++ if (!UNIT_RE.test(u)) throw new InventoryError(`${where}.units: "${u}" is not a unit name`);++ if (u.endsWith('.socket')) throw new InventoryError(`${where}.units: "${u}" is a socket unit — declare it as socketUnit; ovhost never restarts or stops socket units`);++ }++ if (out.socketUnit && !(UNIT_RE.test(out.socketUnit) && out.socketUnit.endsWith('.socket'))) throw new InventoryError(`${where}.socketUnit must be a .socket unit`);++ for (const [unit, src] of Object.entries(out.unitSources)) {++ if (!UNIT_RE.test(unit) && !/^[A-Za-z0-9@._-]+\.(service|socket)\.d\/[A-Za-z0-9._-]+\.conf$/.test(unit)) throw new InventoryError(`${where}.unitSources: "${unit}" is not a unit or drop-in name`);++ rel(src, `${where}.unitSources.${unit}`);++ }++ if (out.port !== null && !(Number.isInteger(out.port) && out.port > 0 && out.port < 65536)) throw new InventoryError(`${where}.port must be a TCP port`);++ if (out.ready) {++ if (typeof out.ready.url !== 'string' || !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])[:/]/.test(out.ready.url)) throw new InventoryError(`${where}.ready.url must be a loopback http:// URL`);++ out.ready = { timeoutSeconds: 90, headers: {}, ...out.ready };++ }++ for (const p of out.packages) if (p !== '.') rel(p.replace(/\/\*$/, ''), `${where}.packages`);++ if (!Array.isArray(out.install.command) || !out.install.command.length) throw new InventoryError(`${where}.install.command must be an argv array`);++ for (const b of out.build) if (!Array.isArray(b) || !b.length) throw new InventoryError(`${where}.build entries must be argv arrays`);++ if (!DRAIN_POLICIES.includes(out.drain.policy)) throw new InventoryError(`${where}.drain.policy must be one of ${DRAIN_POLICIES.join(', ')}`);++ if (out.protected) {++ const p = out.protected;++ if (!PROBE_KINDS.includes(p.kind)) throw new InventoryError(`${where}.protected.kind must be one of ${PROBE_KINDS.join(', ')}`);++ if (p.kind === 'http-json-count' && !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])[:/]/.test(p.url || '')) throw new InventoryError(`${where}.protected.url must be a loopback http:// URL`);++ if (p.kind === 'sqlite-count') {++ abs(p.db, `${where}.protected.db`);++ if (!/^\s*select\b/i.test(p.sql || '')) throw new InventoryError(`${where}.protected.sql must be a SELECT`);++ }++ }++ if (out.nginx) {++ const n = out.nginx;++ if (n.vhost && !FILE_NAME_RE.test(n.vhost)) throw new InventoryError(`${where}.nginx.vhost must be a file name, not a path`);++ if (n.repoVhost) rel(n.repoVhost, `${where}.nginx.repoVhost`);++ if (n.repoVhosts) rel(n.repoVhosts.replace(/\*\.conf$/, 'x.conf'), `${where}.nginx.repoVhosts`);++ if (n.variant && !['http', 'sse', 'websocket'].includes(n.variant)) throw new InventoryError(`${where}.nginx.variant must be http, sse or websocket`);++ if (n.certName && !FILE_NAME_RE.test(n.certName)) throw new InventoryError(`${where}.nginx.certName must be a name`);++ }++ for (const p of out.backupOnChange) rel(p, `${where}.backupOnChange`);++ return out;++}++++function normalise(raw) {++ if (!raw || typeof raw !== 'object' || !raw.services || typeof raw.services !== 'object') throw new InventoryError('inventory needs a "services" object');++ const defaults = raw.defaults || {};++ const inv = {++ host: raw.host || null,++ stateDir: abs(raw.stateDir || '/var/lib/openvibe-host', 'stateDir'),++ backupDir: abs(raw.backupDir || '/var/backups/openvibe', 'backupDir'),++ nginx: {++ sitesAvailable: abs((raw.nginx && raw.nginx.sitesAvailable) || '/etc/nginx/sites-available', 'nginx.sitesAvailable'),++ sitesEnabled: abs((raw.nginx && raw.nginx.sitesEnabled) || '/etc/nginx/sites-enabled', 'nginx.sitesEnabled'),++ bin: (raw.nginx && raw.nginx.bin) || 'nginx',++ },++ services: {},++ };++ for (const [id, svc] of Object.entries(raw.services)) {++ if (id.startsWith('_')) continue;++ inv.services[id] = normaliseService(id, svc, defaults);++ }++ return inv;++}++++/** Resolve and read the inventory through the executor. Root requires a root-owned, non-writable file. */++async function load(exec, { file, env = process.env } = {}) {++ const candidates = file ? [file] : env.OVHOST_INVENTORY ? [env.OVHOST_INVENTORY] : DEFAULT_PATHS;++ for (const f of candidates) {++ const st = await exec.stat(f);++ if (!st) continue;++ if (await exec.isRoot()) {++ if (st.uid !== 0) throw new InventoryError(`${f} must be owned by root when ovhost runs as root`);++ if (st.mode & 0o022) throw new InventoryError(`${f} must not be group- or world-writable when ovhost runs as root`);++ }++ const text = await exec.readFile(f, { privileged: true });++ let raw;++ try { raw = JSON.parse(text); } catch (err) { throw new InventoryError(`${f} is not valid JSON: ${err.message}`); }++ const inv = normalise(raw);++ inv.file = f;++ return inv;++ }++ throw new InventoryError(`no inventory found (looked at ${candidates.join(', ')}); copy host.example.json to /etc/openvibe/host.json`);++}++++function service(inv, id) {++ const s = inv.services[id];++ if (!s) throw new InventoryError(`unknown service "${id}" (inventory has: ${Object.keys(inv.services).join(', ')})`);++ return s;++}++++module.exports = { load, normalise, service, InventoryError };nnew file mode 100644@@@ -0,0 +1,21 @@++'use strict';++/** One operation per service at a time: <stateDir>/locks/<service>.lock holds the owner's pid. */++const path = require('path');++++class LockedError extends Error {}++++async function acquire(exec, inv, serviceId, pid = process.pid) {++ const file = path.join(inv.stateDir, 'locks', `${serviceId}.lock`);++ const body = JSON.stringify({ pid, at: new Date(exec.now()).toISOString() });++ if (await exec.createExclusive(file, body)) return { file, release: () => exec.removeFile(file) };++ let holder = null;++ try { holder = JSON.parse(await exec.readFile(file)); } catch { /* unreadable lock: treat as stale */ }++ if (holder && holder.pid && await exec.pidAlive(holder.pid)) {++ throw new LockedError(`another ovhost operation on ${serviceId} is running (pid ${holder.pid}, since ${holder.at}); lock: ${file}`);++ }++ await exec.removeFile(file);++ if (await exec.createExclusive(file, body)) return { file, release: () => exec.removeFile(file), recoveredStale: true };++ throw new LockedError(`could not take the lock ${file}`);++}++++module.exports = { acquire, LockedError };nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * nginx: render a vhost from a service manifest + template, install vhosts safely, and run nginx -t.++ *++ * Installing is always transactional: write the new files, `nginx -t`, and on failure put back++ * exactly what was there before (previous content, or no file / no link) — nginx is only reloaded++ * after a clean test. Nothing under /etc is written unless the caller asked to install.++ */++const fs = require('fs');++const path = require('path');++const systemd = require('./systemd');++++const TEMPLATE_DIR = path.join(__dirname, '..', 'templates', 'nginx');++++function fill(template, vars) {++ return template.replace(/\{\{(\w+)\}\}/g, (_, k) => {++ if (!(k in vars)) throw new Error(`template variable {{${k}}} has no value`);++ return String(vars[k]);++ });++}++++function readTemplate(name) {++ return fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf8');++}++++/** openvibe.network for events.openvibe.network: the host's certificates are per registrable domain (wildcards). */++function defaultCertName(domain) {++ return domain.split('.').slice(-2).join('.');++}++++function loadManifest(id, manifestFile) {++ if (manifestFile) return { manifest: JSON.parse(fs.readFileSync(manifestFile, 'utf8')), contractsVersion: `file ${path.basename(manifestFile)}` };++ const contracts = require('openvibe-contracts');++ const version = require('openvibe-contracts/package.json').version;++ return { manifest: contracts.services.get(id) || null, contractsVersion: `v${version}` };++}++++/**++ * -> { name, text } for svc. Domains come from the manifest (inventory nginx.domains overrides);++ * the port from the inventory; the variant from --variant or the inventory.++ */++function render(inv, svc, { variant, manifestFile } = {}) {++ const n = svc.nginx || {};++ const { manifest, contractsVersion } = loadManifest(svc.manifest, manifestFile);++ const domains = n.domains || (manifest && manifest.domains) || [];++ if (!domains.length) throw new Error(`no domains for ${svc.id}: the "${svc.manifest}" manifest lists none and the inventory sets no nginx.domains`);++ if (!svc.port) throw new Error(`${svc.id} has no port in the inventory`);++ const v = variant || n.variant || 'http';++ if (!['http', 'sse', 'websocket'].includes(v)) throw new Error(`unknown variant ${v} (http, sse, websocket)`);++ for (const d of domains) if (!/^[a-z0-9.-]+$/i.test(d)) throw new Error(`domain "${d}" is not a host name`);++ const zone = `ov${svc.id.replace(/[^a-z0-9]/g, '')}`;++ const primary = domains[0];++ const base = { port: svc.port, zone };++ let locations = '';++ let httpContext = '';++ if (v === 'sse') {++ const paths = n.ssePaths || ['/realtime/stream'];++ locations = paths.map((p) => fill(readTemplate('location-sse.tmpl'), { ...base, path: safePath(p) })).join('');++ } else if (v === 'websocket') {++ const paths = n.wsPaths || ['/ws/'];++ httpContext = `\n${fill(readTemplate('http-websocket.tmpl'), base)}`;++ locations = paths.map((p) => fill(readTemplate('location-websocket.tmpl'), { ...base, path: safePath(p) })).join('');++ }++ const name = n.vhost || `${primary}.conf`;++ const text = fill(readTemplate('vhost.conf.tmpl'), {++ ...base,++ primary,++ service: svc.id,++ manifestId: svc.manifest,++ contractsVersion,++ variant: v,++ vhost: name,++ sitesAvailable: inv.nginx.sitesAvailable,++ serverNames: domains.join(' '),++ certName: n.certName || defaultCertName(primary),++ maxBody: n.maxBody || '2m',++ locations,++ httpContext,++ });++ return { name, text };++}++++function safePath(p) {++ if (!/^\/[A-Za-z0-9/._-]*$/.test(p)) throw new Error(`location path "${p}" is not a plain path`);++ return p;++}++++async function test(exec, inv) {++ const r = await exec.run(inv.nginx.bin, ['-t'], { privileged: true });++ return { ok: r.code === 0, output: `${r.stderr}${r.stdout}`.trim() };++}++++/**++ * files: [{ name, text }]. Writes changed files to sites-available, links them into sites-enabled,++ * runs nginx -t; restores the previous state on failure; reloads nginx on success.++ * -> { changed: [names], reloaded }++ */++async function install(exec, inv, files, { log = () => {} } = {}) {++ const undo = [];++ const changed = [];++ for (const f of files) {++ if (!/^[A-Za-z0-9._-]+$/.test(f.name)) throw new Error(`vhost name "${f.name}" is not a file name`);++ const available = path.join(inv.nginx.sitesAvailable, f.name);++ const enabled = path.join(inv.nginx.sitesEnabled, f.name);++ const prev = await exec.readFile(available, { privileged: true });++ if (prev !== f.text) {++ undo.push(prev == null ? () => exec.removeFile(available, { privileged: true }) : () => exec.writeFile(available, prev, { privileged: true, mode: 0o644 }));++ await exec.writeFile(available, f.text, { privileged: true, mode: 0o644 });++ changed.push(f.name);++ }++ if (!(await exec.stat(enabled))) {++ await exec.symlink(available, enabled, { privileged: true });++ undo.push(() => exec.removeFile(enabled, { privileged: true }));++ if (!changed.includes(f.name)) changed.push(f.name);++ }++ }++ if (!changed.length) return { changed, reloaded: false };++ const t = await test(exec, inv);++ if (!t.ok) {++ for (const u of undo.reverse()) await u();++ const err = new Error(`nginx -t failed; the previous vhost files were restored:\n${t.output}`);++ err.nginxOutput = t.output;++ throw err;++ }++ await systemd.reloadNginx(exec);++ log(`nginx: installed ${changed.join(', ')}; nginx -t clean; reloaded`);++ return { changed, reloaded: true };++}++++module.exports = { render, test, install, defaultCertName, fill };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * Protected-session probes: how many sessions a restart of this service would cut.++ *++ * http-json-count GET a loopback URL and count: `array` (dotted path) filtered by `where`++ * (key: value equality), or a numeric `field` (dotted path).++ * Live: /api/streams, streams where is_live. Events: /api/ready realtime_connections.++ * sqlite-count a read-only SELECT against the service's database, run as the service user.++ * Media: SELECT count(*) FROM vods WHERE is_recording = 1.++ *++ * A probe that cannot answer while the service is running is UNKNOWN, never zero: "could not ask"++ * must not be read as "nobody is live". (Live's deploy.sh counted a failed curl as 0.)++ */++const systemd = require('./systemd');++++function at(obj, dotted) {++ return String(dotted || '').split('.').filter(Boolean).reduce((o, k) => (o == null ? undefined : o[k]), obj);++}++++async function running(exec, svc) {++ if (!svc.units.length) return false;++ for (const u of svc.units) {++ const st = await systemd.show(exec, u);++ if (['active', 'activating', 'reloading', 'deactivating'].includes(st.active)) return true;++ }++ return false;++}++++/** -> { count: number|null, label, unknown?: reason, notRunning?: true } */++async function countProtected(exec, svc) {++ const p = svc.protected;++ if (!p) return { count: 0, label: 'protected sessions', none: true };++ const label = p.label || 'protected sessions';++ if (!(await running(exec, svc))) return { count: 0, label, notRunning: true };++ try {++ if (p.kind === 'http-json-count') {++ const r = await exec.http(p.url, { timeoutMs: 5000, headers: p.headers || {} });++ if (r.status < 200 || r.status >= 300) return { count: null, label, unknown: r.status ? `HTTP ${r.status}` : r.error || 'unreachable' };++ let body;++ try { body = JSON.parse(r.body); } catch { return { count: null, label, unknown: 'response is not JSON' }; }++ if (p.array) {++ const arr = at(body, p.array);++ if (!Array.isArray(arr)) return { count: null, label, unknown: `no array at "${p.array}"` };++ const where = p.where || {};++ // `true` matches any truthy value (SQLite rows say is_live: 1).++ const matches = (x) => Object.entries(where).every(([k, v]) => {++ const actual = x == null ? undefined : x[k];++ return v === true ? Boolean(actual) && actual !== '0' : actual === v;++ });++ return { count: arr.filter(matches).length, label };++ }++ const n = Number(at(body, p.field));++ if (!Number.isFinite(n)) return { count: null, label, unknown: `no number at "${p.field}"` };++ return { count: n, label };++ }++ if (p.kind === 'sqlite-count') {++ const rows = await exec.sqlite(p.db, p.sql, { as: svc.runAs });++ const first = rows && rows[0] ? Object.values(rows[0])[0] : undefined;++ const n = Number(first);++ if (!Number.isFinite(n)) return { count: null, label, unknown: 'query returned no number' };++ return { count: n, label };++ }++ } catch (err) {++ return { count: null, label, unknown: err.message };++ }++ return { count: null, label, unknown: `unknown probe kind ${p.kind}` };++}++++module.exports = { countProtected, running, at };nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++/**++ * Readiness: poll the service's loopback ready URL until it answers 2xx or the timeout passes.++ * A unit that systemd reports as `failed` ends the wait early — there is nothing left to wait for.++ */++const systemd = require('./systemd');++++async function probe(exec, svc) {++ if (!svc.ready) return { ok: null, detail: 'no ready URL declared' };++ const t0 = exec.now();++ const r = await exec.http(svc.ready.url, { timeoutMs: 5000, headers: svc.ready.headers || {} });++ return { ok: r.status >= 200 && r.status < 300, status: r.status, error: r.error, latencyMs: exec.now() - t0 };++}++++async function waitReady(exec, svc, { timeoutSeconds, log = () => {} } = {}) {++ if (!svc.ready) return { ok: true, skipped: true, seconds: 0 };++ const limit = (timeoutSeconds || svc.ready.timeoutSeconds || 90) * 1000;++ const start = exec.now();++ let last = null;++ while (exec.now() - start < limit) {++ last = await probe(exec, svc);++ if (last.ok) return { ok: true, seconds: Math.round((exec.now() - start) / 1000) };++ for (const u of svc.units) {++ const st = await systemd.show(exec, u);++ if (st.active === 'failed') {++ log(`${u} is in state failed`);++ return { ok: false, reason: `${u} failed`, seconds: Math.round((exec.now() - start) / 1000) };++ }++ }++ await exec.sleep(1000);++ }++ return { ok: false, reason: last ? (last.status ? `HTTP ${last.status}` : last.error || 'unreachable') : 'never probed', seconds: Math.round((exec.now() - start) / 1000) };++}++++module.exports = { probe, waitReady };nnew file mode 100644@@@ -0,0 +1,438 @@++'use strict';++/**++ * plan, deploy and rollback for a service that runs from a git checkout (every service on the host++ * today). The rules this encodes come from the per-repository deploy scripts it replaces:++ *++ * - git and npm run as the checkout owner; nothing root-owned is left under the checkout.++ * - a tracked local change blocks the deploy (untracked files do not).++ * - dependencies install only when package.json dependency fields or the lockfile changed++ * (Network), the lockfile is restored from git afterwards, and EVERY dependency must resolve++ * before anything restarts (Tools).++ * - protected sessions are checked before the checkout moves and again right before the restart++ * (Live: /api/streams; Media: recordings in progress). With sessions active the deploy refuses,++ * or waits with --wait-idle; --force drops them and says so loudly. A probe that cannot answer is++ * treated as "sessions may be active", never as zero.++ * - only .service units are restarted; a socket unit is started if it is down, never stopped.++ * - readiness is polled after the restart; if it fails the checkout goes back to the previous sha,++ * dependencies are reinstalled if they differed, and the service is restarted again.++ * - every attempt (including refusals) is appended to the release log.++ *++ * Exit codes: 0 ok · 1 usage/precondition · 2 validation failed, nothing restarted ·++ * 3 not ready, rolled back and serving · 4 rollback failed — MANUAL INTERVENTION ·++ * 5 protected sessions active (refused, or --wait-idle gave up)++ */++const path = require('path');++const { git } = require('./git');++const deps = require('./deps');++const systemd = require('./systemd');++const probes = require('./probes');++const readiness = require('./readiness');++const releases = require('./releases');++const lock = require('./lock');++const nginx = require('./nginx');++++const EXIT = { OK: 0, USAGE: 1, VALIDATION: 2, ROLLED_BACK: 3, ROLLBACK_FAILED: 4, PROTECTED: 5 };++++class OpError extends Error {++ constructor(message, exitCode, extra = {}) {++ super(message);++ this.exitCode = exitCode;++ Object.assign(this, extra);++ }++}++++function matchesPath(file, patterns) {++ return patterns.some((p) => {++ if (p.endsWith('/')) return file.startsWith(p);++ if (p.startsWith('*.')) return !file.includes('/') && file.endsWith(p.slice(1));++ if (p.startsWith('**/*.')) return file.endsWith(p.slice(4));++ return file === p;++ });++}++++async function operatorName(exec) {++ return process.env.SUDO_USER || (await exec.userName());++}++++// ── plan ─────────────────────────────────────────────────────────────────────++++/**++ * What a deploy (or rollback) to `to` would do. Fetches the remote unless fetch === false; changes++ * nothing else.++ */++async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true, restart = false } = {}) {++ const { exec } = ctx;++ if (to != null && !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(String(to))) throw new OpError(`"${to}" is not a sha or ref name`, EXIT.USAGE);++ const g = git(exec, svc);++ const from = await g.head();++ const branch = await g.branch();++ const dirty = await g.dirty();++ let target;++ if (mode === 'deploy') {++ if (fetch) await g.fetch();++ target = await g.revParse(to || `${svc.remote}/${svc.branch}`);++ } else {++ if (!to) throw new OpError('rollback needs a target sha', EXIT.USAGE);++ if (!(await g.hasCommit(to))) throw new OpError(`commit ${to} is not in ${svc.repo}`, EXIT.USAGE);++ target = await g.revParse(to);++ }++ const changed = from === target ? [] : await g.changedFiles(from, target);++ const commits = from === target ? [] : mode === 'deploy' ? await g.log(from, target) : await g.log(target, from);++++ const pkgDirs = await deps.expandPackages(exec, svc);++ for (const f of changed) {++ if (path.basename(f) !== 'package.json') continue;++ const dir = path.dirname(f) || '.';++ const inPatterns = svc.packages.some((p) => p === dir || (p.endsWith('/*') && path.dirname(dir) === p.slice(0, -2)));++ if (inPatterns && !pkgDirs.includes(dir)) pkgDirs.push(dir);++ }++ const installs = [];++ for (const pkg of pkgDirs) {++ const need = from === target ? { lockfileChanged: false, depsChanged: false, needed: false } : await deps.needsInstall(g, from, target, pkg);++ const hasModules = !!(await exec.stat(path.join(svc.repo, pkg, 'node_modules')));++ installs.push({ pkg, ...need, missingNodeModules: !hasModules, install: need.needed || svc.install.always || !hasModules });++ }++++ const codeChanged = changed.filter((f) => !matchesPath(f, svc.noRestartPaths));++ const restartNeeded = svc.units.length > 0 && (restart || codeChanged.length > 0);++++ const unitDrift = [];++ for (const [unit, src] of Object.entries(svc.unitSources)) {++ const want = await g.show(target, src);++ if (want == null) continue;++ const have = await exec.readFile(path.join('/etc/systemd/system', unit), { privileged: true });++ if (have !== want) unitDrift.push({ unit, source: src, installed: have != null });++ }++++ const backupNeeded = svc.databases.length > 0 && changed.some((f) => svc.backupOnChange.includes(f));++ const sessions = restartNeeded ? await probes.countProtected(exec, svc) : null;++++ return {++ service: svc.id,++ mode,++ repo: svc.repo,++ branch,++ expectedBranch: svc.branch,++ from,++ to: target,++ upToDate: from === target,++ dirty,++ commits,++ changedFiles: changed.length,++ codeChanged: codeChanged.length,++ lockfileChanged: installs.some((i) => i.lockfileChanged),++ installs,++ restartNeeded,++ units: svc.units,++ socketUnit: svc.socketUnit,++ unitDrift,++ backupNeeded,++ protectedSessions: sessions,++ drainPolicy: svc.drain.policy,++ managed: svc.managed,++ installVhosts: !!(svc.nginx && svc.nginx.installOnDeploy),++ };++}++++// ── protected sessions ───────────────────────────────────────────────────────++++async function guardSessions(ctx, svc, opts, record, when) {++ const { exec, log } = ctx;++ const first = await probes.countProtected(exec, svc);++ record.protectedSessions = { count: first.count, label: first.label, unknown: first.unknown || null, checkedWhen: when };++ if (first.notRunning || first.count === 0) return;++ const describe = (r) => (r.count == null ? `${r.label}: unknown (${r.unknown}) — treated as active` : `${r.count} ${r.label}`);++ if (opts.force) {++ record.forced = true;++ log('');++ log('!!! --force: restarting with protected sessions active');++ log(`!!! ${describe(first)} WILL BE DROPPED (${when})`);++ log('');++ return;++ }++ if (svc.drain.policy === 'report' && first.count != null) {++ log(`${describe(first)}; drain policy "report": they reconnect after the restart`);++ return;++ }++ const wait = opts.waitIdle || svc.drain.policy === 'wait';++ if (!wait) {++ throw new OpError(`${describe(first)} — refusing to restart ${svc.units.join(', ')} (${when}). Re-run with --wait-idle to hold until idle, or --force to drop them.`, EXIT.PROTECTED, { result: 'refused' });++ }++ const maxMs = svc.drain.waitMaxSeconds * 1000;++ const pollMs = svc.drain.pollSeconds * 1000;++ const start = exec.now();++ let quiet = 0;++ let last = first;++ log(`${describe(first)}; --wait-idle: holding the restart (${when}); need ${svc.drain.quietChecks} idle checks ${svc.drain.pollSeconds}s apart, max ${svc.drain.waitMaxSeconds}s`);++ while (exec.now() - start < maxMs) {++ await exec.sleep(pollMs);++ last = await probes.countProtected(exec, svc);++ if (last.notRunning || last.count === 0) quiet += 1; else quiet = 0;++ if (quiet >= svc.drain.quietChecks) {++ record.protectedSessions.waitedSeconds = Math.round((exec.now() - start) / 1000);++ record.protectedSessions.count = 0;++ log(`idle for ${quiet} checks — proceeding (waited ${record.protectedSessions.waitedSeconds}s)`);++ return;++ }++ log(`${describe(last)}; still holding (${Math.round((exec.now() - start) / 1000)}s)`);++ }++ throw new OpError(`${describe(last)} after waiting ${svc.drain.waitMaxSeconds}s — gave up; nothing was restarted`, EXIT.PROTECTED, { result: 'gave-up-waiting' });++}++++// ── helpers ──────────────────────────────────────────────────────────────────++++async function installAll(ctx, svc, g, installs, record) {++ for (const i of installs) {++ if (!i.install) continue;++ ctx.log(`installing dependencies in ${i.pkg} (${[i.lockfileChanged && 'lockfile changed', i.depsChanged && 'package.json dependencies changed', i.missingNodeModules && 'no node_modules', svc.install.always && 'always'].filter(Boolean).join(', ')})`);++ await deps.install(ctx.exec, svc, g, i.pkg);++ record.installed.push(i.pkg);++ }++}++++async function verifyDeps(ctx, svc, g) {++ const pkgDirs = await deps.expandPackages(ctx.exec, svc);++ const { problems, repaired } = await deps.verifyAndRepair(ctx.exec, svc, g, pkgDirs, ctx.log);++ return { pkgDirs, problems, repaired };++}++++async function runBuild(ctx, svc) {++ for (const argv of svc.build) {++ ctx.log(`build: ${argv.join(' ')}`);++ const r = await ctx.exec.run(argv[0], argv.slice(1), { as: svc.owner, cwd: svc.repo, timeoutMs: 20 * 60 * 1000 });++ if (r.code !== 0) throw new Error(`build step "${argv.join(' ')}" failed: ${(r.stderr || r.stdout).trim().split('\n').slice(-3).join(' / ')}`);++ }++}++++async function installRepoVhosts(ctx, svc) {++ const n = svc.nginx;++ const pattern = n.repoVhosts || n.repoVhost;++ if (!pattern) return null;++ let names;++ if (pattern.endsWith('*.conf')) {++ const dir = path.dirname(pattern);++ names = ((await ctx.exec.readdir(path.join(svc.repo, dir))) || []).filter((e) => !e.isDir && e.name.endsWith('.conf')).map((e) => path.join(dir, e.name));++ } else names = [pattern];++ const files = [];++ for (const rel of names.sort()) files.push({ name: path.basename(rel), text: await ctx.exec.readFile(path.join(svc.repo, rel)) });++ return nginx.install(ctx.exec, ctx.inv, files, { log: ctx.log });++}++++async function installUnits(ctx, svc, record) {++ const changed = [];++ for (const [unit, src] of Object.entries(svc.unitSources)) {++ const want = await ctx.exec.readFile(path.join(svc.repo, src));++ if (want == null) continue;++ const dest = path.join('/etc/systemd/system', unit);++ const have = await ctx.exec.readFile(dest, { privileged: true });++ if (have === want) continue;++ await ctx.exec.run('install', ['-m', '0644', '-D', path.join(svc.repo, src), dest], { privileged: true });++ changed.push(unit);++ }++ if (changed.length) {++ await systemd.daemonReload(ctx.exec);++ ctx.log(`installed unit files: ${changed.join(', ')}; daemon-reload`);++ }++ record.unitsInstalled = changed;++}++++async function backupBeforeRestart(ctx, svc, record) {++ const { backup } = require('./commands/backup');++ ctx.log('schema files changed — backing up the declared databases first');++ const res = await backup(ctx, svc.id, { reason: `pre-deploy ${record.to.slice(0, 12)}` });++ record.backup = res.files.map((f) => f.dest);++}++++/** Restart the service units (never the socket) and poll readiness. -> { ok, reason } */++async function restartAndWait(ctx, svc, opts = {}) {++ if (svc.socketUnit) {++ const s = await systemd.ensureSocketActive(ctx.exec, svc.socketUnit);++ ctx.log(s.started ? `${svc.socketUnit} was ${s.state.active}; started it` : `${svc.socketUnit} active — new HTTP connections queue through the restart`);++ }++ for (const u of svc.units) {++ ctx.log(`restarting ${u}`);++ await systemd.restart(ctx.exec, u);++ }++ const r = await readiness.waitReady(ctx.exec, svc, { timeoutSeconds: opts.readyTimeout, log: ctx.log });++ if (r.ok) ctx.log(r.skipped ? 'no ready URL declared; restart issued' : `ready after ${r.seconds}s`);++ else ctx.log(`NOT READY after ${r.seconds}s (${r.reason}); see: journalctl -u ${svc.units[0]} -n 50`);++ return r;++}++++/** Put the checkout back on `sha` and make node_modules match it again. */++async function restoreCheckout(ctx, svc, g, sha, record) {++ ctx.log(`restoring the checkout to ${sha.slice(0, 12)}`);++ await g.resetHard(sha);++ if (record.installed.length) {++ for (const pkg of record.installed) {++ ctx.log(`reinstalling dependencies in ${pkg} for ${sha.slice(0, 12)}`);++ await deps.install(ctx.exec, svc, g, pkg);++ }++ }++ const v = await verifyDeps(ctx, svc, g);++ if (v.problems.length) throw new Error(`after restoring ${sha.slice(0, 12)} these dependencies still do not resolve: ${v.problems.map((p) => `${p.pkg}:${p.dep}`).join(', ')}`);++}++++// ── deploy / rollback ────────────────────────────────────────────────────────++++/**++ * mode 'deploy': fast-forward to origin/<branch> (or opts.to). mode 'rollback': reset to opts.to.++ * -> { exitCode, record }++ */++async function apply(ctx, svc, mode, opts = {}) {++ const { exec, inv, log } = ctx;++ if (!svc.managed) throw new OpError(`${svc.id} is not managed by ovhost${svc.unmanagedReason ? `: ${svc.unmanagedReason}` : ''}`, EXIT.USAGE);++ // Live's deploy.sh can also run a releases/ + current symlink layout. Stage A only drives the++ // in-place checkout every service runs today; it will not guess its way through the other one.++ if (await exec.readlink(path.join(svc.repo, 'current'))) {++ throw new OpError(`${svc.repo} uses the releases/current layout, which Stage A does not drive yet; use the repository's own deploy script`, EXIT.USAGE);++ }++ const held = await lock.acquire(exec, inv, svc.id);++ if (held.recoveredStale) log(`removed a stale lock (${held.file})`);++ const g = git(exec, svc);++ const record = {++ id: releases.newId(exec.now()),++ service: svc.id,++ action: mode,++ startedAt: new Date(exec.now()).toISOString(),++ operator: await operatorName(exec),++ host: await exec.hostname(),++ from: null,++ to: null,++ commits: 0,++ lockfileChanged: false,++ installed: [],++ depsVerified: null,++ restartNeeded: null,++ restarted: false,++ ready: null,++ forced: false,++ result: null,++ };++ let exitCode = EXIT.OK;++ let writeRecord = true;++ try {++ const plan = await computePlan(ctx, svc, { mode, to: opts.to, fetch: opts.fetch !== false, restart: opts.restart });++ Object.assign(record, { from: plan.from, to: plan.to, commits: plan.commits.length, lockfileChanged: plan.lockfileChanged, restartNeeded: plan.restartNeeded });++ log(`${svc.id}: ${mode} ${plan.from.slice(0, 12)} → ${plan.to.slice(0, 12)} (${plan.commits.length} commit(s), ${plan.changedFiles} file(s) changed)`);++ if (plan.branch !== svc.branch) throw new OpError(`${svc.repo} is on "${plan.branch}", expected "${svc.branch}"`, EXIT.USAGE);++ if (plan.dirty.length) throw new OpError(`${svc.repo} has tracked local changes (${plan.dirty.slice(0, 5).join(', ')}${plan.dirty.length > 5 ? ', …' : ''}); commit or discard them first`, EXIT.VALIDATION);++ if (plan.upToDate && !opts.restart) {++ log(`already at ${plan.to.slice(0, 12)}; nothing to do`);++ record.result = 'unchanged';++ writeRecord = false;++ return { exitCode: EXIT.OK, record, plan };++ }++ for (const c of plan.commits.slice(0, 20)) log(` ${c}`);++ if (plan.unitDrift.length && !opts.installUnits) log(`note: unit files differ from the repo (${plan.unitDrift.map((u) => u.unit).join(', ')}); pass --install-units to install them`);++++ // Sessions first: in place, the running process would otherwise see its files change under it.++ if (plan.restartNeeded) await guardSessions(ctx, svc, opts, record, 'before the checkout moves');++++ if (!plan.upToDate) {++ if (mode === 'deploy') await g.ffMerge(plan.to); else await g.resetHard(plan.to);++ record.checkoutMoved = true;++ }++ try {++ await installAll(ctx, svc, g, plan.installs, record);++ const v = await verifyDeps(ctx, svc, g);++ record.depsVerified = v.problems.length === 0;++ if (v.repaired.length) record.depsRepaired = v.repaired;++ if (v.problems.length) throw new OpError(`dependencies do not resolve: ${v.problems.map((p) => `${p.pkg}: ${p.dep} (${p.reason})`).join('; ')}`, EXIT.VALIDATION);++ log(`every dependency resolves (${v.pkgDirs.join(', ') || 'no packages'})`);++ await runBuild(ctx, svc);++ if (plan.installVhosts) await installRepoVhosts(ctx, svc);++ if (opts.installUnits) await installUnits(ctx, svc, record);++ if (plan.backupNeeded && plan.restartNeeded) await backupBeforeRestart(ctx, svc, record);++ if (plan.restartNeeded) await guardSessions(ctx, svc, opts, record, 'immediately before the restart');++ } catch (err) {++ // Nothing has been restarted: put the files back the way the running process expects.++ if (record.checkoutMoved) {++ try { await restoreCheckout(ctx, svc, g, plan.from, record); record.checkoutRestored = true; } catch (e2) {++ throw new OpError(`${err.message}\nAND restoring ${plan.from.slice(0, 12)} failed: ${e2.message} — MANUAL INTERVENTION REQUIRED`, EXIT.ROLLBACK_FAILED, { result: 'restore-failed' });++ }++ }++ if (err instanceof OpError) { err.message += record.checkoutRestored ? ` (checkout restored to ${plan.from.slice(0, 12)}; nothing restarted)` : ' (nothing restarted)'; throw err; }++ throw new OpError(`${err.message} (${record.checkoutRestored ? `checkout restored to ${plan.from.slice(0, 12)}; ` : ''}nothing restarted)`, EXIT.VALIDATION);++ }++++ if (!plan.restartNeeded) {++ record.result = mode === 'deploy' ? 'deployed' : 'rolled-back';++ log(`${plan.units.length ? `only ${svc.noRestartPaths.join(' ')} changed — no restart needed` : 'no units to restart'}`);++ if (svc.ready) {++ const p = await readiness.probe(exec, svc);++ record.ready = p.ok;++ if (!p.ok) log(`warning: ${svc.ready.url} answers ${p.status || p.error}`);++ }++ return { exitCode: EXIT.OK, record, plan };++ }++++ const r = await restartAndWait(ctx, svc, opts);++ record.restarted = true;++ record.ready = r.ok;++ if (r.ok) {++ record.result = mode === 'deploy' ? 'deployed' : 'rolled-back';++ log(`${svc.id}: ${plan.from.slice(0, 12)} → ${plan.to.slice(0, 12)} ${mode === 'deploy' ? 'deployed' : 'rolled back'}`);++ if (svc.socketUnit || svc.protected) log('note: established WebSocket/WHIP/RTMP/WebRTC/SSE sessions were reconnected, not preserved.');++ return { exitCode: EXIT.OK, record, plan };++ }++++ // Not ready: back to where we were.++ log(`automatic rollback to ${plan.from.slice(0, 12)}`);++ record.autoRollback = { to: plan.from };++ try {++ await restoreCheckout(ctx, svc, g, plan.from, record);++ const back = await restartAndWait(ctx, svc, opts);++ record.autoRollback.ready = back.ok;++ if (back.ok) {++ record.result = 'failed-rolled-back';++ exitCode = EXIT.ROLLED_BACK;++ log(`${svc.id} is back on ${plan.from.slice(0, 12)} and serving; ${plan.to.slice(0, 12)} was not deployed`);++ } else {++ record.result = 'rollback-failed';++ exitCode = EXIT.ROLLBACK_FAILED;++ log(`ROLLBACK FAILED: ${plan.from.slice(0, 12)} is not ready either — MANUAL INTERVENTION REQUIRED`);++ }++ } catch (err) {++ record.result = 'rollback-failed';++ record.error = err.message;++ exitCode = EXIT.ROLLBACK_FAILED;++ log(`ROLLBACK FAILED: ${err.message} — MANUAL INTERVENTION REQUIRED`);++ }++ return { exitCode, record, plan };++ } catch (err) {++ record.result = err.result || record.result || 'failed';++ record.error = err.message;++ throw err;++ } finally {++ record.finishedAt = new Date(exec.now()).toISOString();++ if (writeRecord) {++ try { await releases.append(exec, inv, record); } catch (e) { log(`warning: could not append to the release log: ${e.message}`); }++ }++ await held.release();++ }++}++++async function deploy(ctx, id, opts = {}) {++ const svc = require('./inventory').service(ctx.inv, id);++ return apply(ctx, svc, 'deploy', opts);++}++++async function rollback(ctx, id, opts = {}) {++ const svc = require('./inventory').service(ctx.inv, id);++ let to = opts.to;++ if (!to) {++ const current = await git(ctx.exec, svc).head();++ to = releases.previousFor(await releases.list(ctx.exec, ctx.inv, id), current);++ if (!to) throw new OpError(`no deploy in the release log brought ${id} to ${current.slice(0, 12)}; pass --to <sha>`, EXIT.USAGE);++ ctx.log(`rolling back to ${to.slice(0, 12)} (the release before ${current.slice(0, 12)} in the release log)`);++ }++ return apply(ctx, svc, 'rollback', { ...opts, to });++}++++async function plan(ctx, id, opts = {}) {++ const svc = require('./inventory').service(ctx.inv, id);++ return computePlan(ctx, svc, { mode: 'deploy', to: opts.to, fetch: opts.fetch !== false, restart: opts.restart });++}++++module.exports = { plan, deploy, rollback, computePlan, guardSessions, matchesPath, OpError, EXIT };nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++/**++ * The release log: one JSON object per line in <stateDir>/releases/<service>.jsonl, appended for++ * every deploy and rollback attempt — including refusals and failures, which are the ones an++ * operator most needs to read later. Records carry shas, flags and counts; never env values.++ */++const path = require('path');++const crypto = require('crypto');++++function logPath(inv, serviceId) {++ return path.join(inv.stateDir, 'releases', `${serviceId}.jsonl`);++}++++function newId(now) {++ const d = new Date(now);++ const stamp = d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');++ return `rel_${stamp}_${crypto.randomBytes(3).toString('hex')}`;++}++++async function append(exec, inv, record) {++ await exec.appendFile(logPath(inv, record.service), `${JSON.stringify(record)}\n`);++}++++async function list(exec, inv, serviceId) {++ const text = await exec.readFile(logPath(inv, serviceId));++ if (!text) return [];++ const out = [];++ for (const line of text.split('\n')) {++ if (!line.trim()) continue;++ try { out.push(JSON.parse(line)); } catch { /* a torn last line is skipped, not fatal */ }++ }++ return out;++}++++/**++ * The sha to roll back to when none is given: the `from` of the most recent successful deploy++ * that brought the checkout to `currentSha`.++ */++function previousFor(records, currentSha) {++ for (let i = records.length - 1; i >= 0; i--) {++ const r = records[i];++ if (r.result === 'deployed' && r.to === currentSha && r.from && r.from !== currentSha) return r.from;++ }++ return null;++}++++module.exports = { append, list, previousFor, logPath, newId };nnew file mode 100644@@@ -0,0 +1,55 @@++#!/usr/bin/env node++'use strict';++/**++ * Runs one SQLite operation in its own process so the executor can start it AS THE SERVICE USER++ * (runuser/sudo -u). Root never opens a service's database: a root-owned -wal/-shm file left under++ * /opt would stop the service from writing its own database.++ *++ * node sqlite-worker.js '{"op":"query","db":"/opt/x/data/x.db","sql":"SELECT count(*) AS n FROM t"}'++ * node sqlite-worker.js '{"op":"backup","db":"/opt/x/data/x.db","dest":"/var/backups/openvibe/x/…/x.db"}'++ *++ * The source database is always opened read-only (fileMustExist); a query that tries to write fails.++ * Prints one JSON line: { ok: true, rows } | { ok: true, bytes } | { ok: false, error }.++ */++const fs = require('fs');++++function done(obj, code = 0) {++ process.stdout.write(`${JSON.stringify(obj)}\n`);++ process.exit(code);++}++++let req;++try { req = JSON.parse(process.argv[2] || ''); } catch { done({ ok: false, error: 'bad request' }, 2); }++++let Database;++try { Database = require('better-sqlite3'); } catch (err) { done({ ok: false, error: `better-sqlite3 unavailable: ${err.message}` }, 2); }++++let db;++try {++ db = new Database(req.db, { readonly: true, fileMustExist: true });++ db.pragma('busy_timeout = 5000');++} catch (err) {++ done({ ok: false, error: `cannot open ${req.db}: ${err.message}` }, 1);++}++++if (req.op === 'query') {++ try {++ const stmt = db.prepare(req.sql);++ if (!stmt.reader) done({ ok: false, error: 'only read-only statements are allowed' }, 1);++ done({ ok: true, rows: stmt.all() });++ } catch (err) {++ done({ ok: false, error: err.message }, 1);++ }++} else if (req.op === 'backup') {++ if (!req.dest) done({ ok: false, error: 'dest required' }, 2);++ if (fs.existsSync(req.dest)) done({ ok: false, error: `refusing to overwrite ${req.dest}` }, 1);++ db.backup(req.dest).then(() => {++ const check = new Database(req.dest, { readonly: true });++ const result = check.pragma('quick_check', { simple: true });++ check.close();++ if (result !== 'ok') done({ ok: false, error: `backup failed quick_check: ${result}` }, 1);++ done({ ok: true, bytes: fs.statSync(req.dest).size });++ }, (err) => done({ ok: false, error: err.message }, 1));++} else {++ done({ ok: false, error: `unknown op ${req.op}` }, 2);++}nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * systemd adapter. Every systemctl call ovhost makes goes through systemctl() below, which refuses++ * any action that would stop a socket unit. Socket-activated services (Live) keep their listener in++ * the .socket unit so HTTP connections queue in the kernel through a restart; stopping that unit is++ * what turns a restart into Cloudflare 502s. Only the .service unit is ever restarted.++ */++const SOCKET_SAFE_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'cat', 'status']);++const ALLOWED_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'restart', 'reload', 'cat', 'status', 'daemon-reload']);++const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts'];++++class SocketGuardError extends Error {}++++async function systemctl(exec, action, unit) {++ if (!ALLOWED_ACTIONS.has(action)) throw new Error(`ovhost does not run "systemctl ${action}"`);++ if (unit && unit.endsWith('.socket') && !SOCKET_SAFE_ACTIONS.has(action)) {++ throw new SocketGuardError(`refusing "systemctl ${action} ${unit}": socket units are never stopped or restarted (they hold the listener that keeps HTTP up through a restart)`);++ }++ const args = action === 'show' ? ['show', unit, `--property=${PROPS.join(',')}`] : unit ? [action, unit] : [action];++ const privileged = !['show', 'is-active', 'is-enabled', 'cat', 'status'].includes(action);++ return exec.run('systemctl', args, { privileged });++}++++async function show(exec, unit) {++ const r = await systemctl(exec, 'show', unit);++ const props = {};++ for (const line of r.stdout.split('\n')) {++ const i = line.indexOf('=');++ if (i > 0) props[line.slice(0, i)] = line.slice(i + 1);++ }++ return {++ unit,++ load: props.LoadState || 'unknown',++ active: props.ActiveState || 'unknown',++ sub: props.SubState || 'unknown',++ mainPid: Number(props.MainPID || 0),++ fragmentPath: props.FragmentPath || '',++ unitFileState: props.UnitFileState || '',++ dropIns: (props.DropInPaths || '').split(/\s+/).filter(Boolean),++ partOf: (props.PartOf || '').split(/\s+/).filter(Boolean),++ restarts: Number(props.NRestarts || 0),++ };++}++++async function restart(exec, unit) {++ const r = await systemctl(exec, 'restart', unit);++ if (r.code !== 0) throw new Error(`systemctl restart ${unit} failed: ${r.stderr.trim()}`);++}++++/** A socket unit that is not listening gets started (never restarted): new connections need it. */++async function ensureSocketActive(exec, socketUnit) {++ const st = await show(exec, socketUnit);++ if (st.active === 'active') return { started: false, state: st };++ const r = await systemctl(exec, 'start', socketUnit);++ if (r.code !== 0) throw new Error(`systemctl start ${socketUnit} failed: ${r.stderr.trim()}`);++ return { started: true, state: st };++}++++async function daemonReload(exec) {++ const r = await systemctl(exec, 'daemon-reload');++ if (r.code !== 0) throw new Error(`systemctl daemon-reload failed: ${r.stderr.trim()}`);++}++++async function reloadNginx(exec) {++ const r = await systemctl(exec, 'reload', 'nginx.service');++ if (r.code !== 0) throw new Error(`systemctl reload nginx failed: ${r.stderr.trim()}`);++}++++module.exports = { systemctl, show, restart, ensureSocketActive, daemonReload, reloadNginx, SocketGuardError };nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++/**++ * A minimal ustar writer + gzip, enough for config snapshots (regular files only, names under 100++ * bytes). Written here so snapshots need neither a tar binary nor a dependency, and the tests can++ * read the archive back with readTarGz().++ */++const zlib = require('zlib');++++function header(name, size, mtime) {++ const buf = Buffer.alloc(512, 0);++ if (Buffer.byteLength(name) > 99) throw new Error(`tar: name too long: ${name}`);++ buf.write(name, 0, 'utf8');++ buf.write('0000640\0', 100);++ buf.write('0000000\0', 108);++ buf.write('0000000\0', 116);++ buf.write(`${size.toString(8).padStart(11, '0')}\0`, 124);++ buf.write(`${Math.floor(mtime / 1000).toString(8).padStart(11, '0')}\0`, 136);++ buf.write(' ', 148);++ buf.write('0', 156);++ buf.write('ustar\0', 257);++ buf.write('00', 263);++ let sum = 0;++ for (const b of buf) sum += b;++ buf.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148);++ return buf;++}++++/** files: [{ name, content (string|Buffer) }] -> gzipped tar Buffer */++function createTarGz(files, mtime = Date.now()) {++ const parts = [];++ for (const f of files) {++ const data = Buffer.isBuffer(f.content) ? f.content : Buffer.from(String(f.content), 'utf8');++ parts.push(header(f.name, data.length, mtime), data);++ const pad = (512 - (data.length % 512)) % 512;++ if (pad) parts.push(Buffer.alloc(pad, 0));++ }++ parts.push(Buffer.alloc(1024, 0));++ return zlib.gzipSync(Buffer.concat(parts));++}++++function readTarGz(buf) {++ const tar = zlib.gunzipSync(buf);++ const out = [];++ let off = 0;++ while (off + 512 <= tar.length) {++ const name = tar.subarray(off, off + 100).toString('utf8').replace(/\0.*$/s, '');++ if (!name) break;++ const size = parseInt(tar.subarray(off + 124, off + 136).toString('utf8').replace(/\0.*$/s, '').trim(), 8);++ out.push({ name, content: tar.subarray(off + 512, off + 512 + size).toString('utf8') });++ off += 512 + Math.ceil(size / 512) * 512;++ }++ return out;++}++++module.exports = { createTarGz, readTarGz };nnew file mode 100644@@@ -0,0 +1,558 @@++{++ "name": "openvibe-host",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-host",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"++ },++ "bin": {++ "ovhost": "bin/ovhost"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/base64-js": {++ "version": "1.5.1",++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/better-sqlite3": {++ "version": "11.10.0",++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",++ "hasInstallScript": true,++ "license": "MIT",++ "dependencies": {++ "bindings": "^1.5.0",++ "prebuild-install": "^7.1.1"++ }++ },++ "node_modules/bindings": {++ "version": "1.5.0",++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",++ "license": "MIT",++ "dependencies": {++ "file-uri-to-path": "1.0.0"++ }++ },++ "node_modules/bl": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",++ "license": "MIT",++ "dependencies": {++ "buffer": "^5.5.0",++ "inherits": "^2.0.4",++ "readable-stream": "^3.4.0"++ }++ },++ "node_modules/buffer": {++ "version": "5.7.1",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "base64-js": "^1.3.1",++ "ieee754": "^1.1.13"++ }++ },++ "node_modules/chownr": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",++ "license": "ISC"++ },++ "node_modules/decompress-response": {++ "version": "6.0.0",++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",++ "license": "MIT",++ "dependencies": {++ "mimic-response": "^3.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/deep-extend": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",++ "license": "MIT",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },++ "node_modules/end-of-stream": {++ "version": "1.4.5",++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",++ "license": "MIT",++ "dependencies": {++ "once": "^1.4.0"++ }++ },++ "node_modules/expand-template": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",++ "license": "(MIT OR WTFPL)",++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/file-uri-to-path": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",++ "license": "MIT"++ },++ "node_modules/fs-constants": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",++ "license": "MIT"++ },++ "node_modules/github-from-package": {++ "version": "0.0.0",++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",++ "license": "MIT"++ },++ "node_modules/ieee754": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/ini": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",++ "license": "ISC"++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/mimic-response": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/minimist": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/mkdirp-classic": {++ "version": "0.5.3",++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",++ "license": "MIT"++ },++ "node_modules/napi-build-utils": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",++ "license": "MIT"++ },++ "node_modules/node-abi": {++ "version": "3.96.0",++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",++ "license": "MIT",++ "dependencies": {++ "semver": "^7.3.5"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/once": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",++ "license": "ISC",++ "dependencies": {++ "wrappy": "1"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.7.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0",++ "integrity": "sha512-gUrjxbTy/HX0QMQGoDPphmFdwm7iitmNMMchs9tDSDGR47FRFHL/RgBgIKYR1S7M6ICu261YycFV8+YVglafXA==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/prebuild-install": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",++ "license": "MIT",++ "dependencies": {++ "detect-libc": "^2.0.0",++ "expand-template": "^2.0.3",++ "github-from-package": "0.0.0",++ "minimist": "^1.2.3",++ "mkdirp-classic": "^0.5.3",++ "napi-build-utils": "^2.0.0",++ "node-abi": "^3.3.0",++ "pump": "^3.0.0",++ "rc": "^1.2.7",++ "simple-get": "^4.0.0",++ "tar-fs": "^2.0.0",++ "tunnel-agent": "^0.6.0"++ },++ "bin": {++ "prebuild-install": "bin.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/pump": {++ "version": "3.0.4",++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",++ "license": "MIT",++ "dependencies": {++ "end-of-stream": "^1.1.0",++ "once": "^1.3.1"++ }++ },++ "node_modules/rc": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "dependencies": {++ "deep-extend": "^0.6.0",++ "ini": "~1.3.0",++ "minimist": "^1.2.0",++ "strip-json-comments": "~2.0.1"++ },++ "bin": {++ "rc": "cli.js"++ }++ },++ "node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/simple-concat": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/simple-get": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "decompress-response": "^6.0.0",++ "once": "^1.3.1",++ "simple-concat": "^1.0.0"++ }++ },++ "node_modules/string_decoder": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.2.0"++ }++ },++ "node_modules/strip-json-comments": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/tar-fs": {++ "version": "2.1.5",++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",++ "license": "MIT",++ "dependencies": {++ "chownr": "^1.1.1",++ "mkdirp-classic": "^0.5.2",++ "pump": "^3.0.0",++ "tar-stream": "^2.1.4"++ }++ },++ "node_modules/tar-stream": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",++ "license": "MIT",++ "dependencies": {++ "bl": "^4.0.3",++ "end-of-stream": "^1.4.1",++ "fs-constants": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^3.1.1"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/tunnel-agent": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ },++ "engines": {++ "node": "*"++ }++ },++ "node_modules/util-deprecate": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",++ "license": "MIT"++ },++ "node_modules/wrappy": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",++ "license": "ISC"++ }++ }++}nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "name": "openvibe-host",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Host Stage A: the operator plane. The ovhost CLI validates, deploys, rolls back, snapshots and backs up the services on the OpenVibe host, and encodes the rules that keep live streams and recordings alive through a deploy.",++ "main": "lib/index.js",++ "bin": {++ "ovhost": "bin/ovhost"++ },++ "scripts": {++ "test": "node test/run.js"++ },++ "keywords": ["openvibe", "deploy", "systemd", "nginx", "operator"],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.Host",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"++ },++ "engines": {++ "node": ">=20.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,5 @@++# Upgrade header -> Connection header, named per service so two vhosts never define the same variable.++map $http_upgrade ${{zone}}_connection {++ default upgrade;++ '' close;++}nnew file mode 100644@@@ -0,0 +1,20 @@++++ # Server-Sent Events: never buffered or compressed; long reads (the service sends heartbeats++ # well inside Cloudflare's 100 s idle limit).++ location = {{path}} {++ proxy_pass http://127.0.0.1:{{port}};++ proxy_http_version 1.1;++ proxy_set_header Connection "";++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_set_header Last-Event-ID $http_last_event_id;++ proxy_buffering off;++ proxy_cache off;++ gzip off;++ chunked_transfer_encoding on;++ proxy_read_timeout 1h;++ proxy_send_timeout 1h;++ send_timeout 1h;++ }nnew file mode 100644@@@ -0,0 +1,15 @@++++ # WebSocket upgrade: long-lived, unbuffered.++ location {{path}} {++ proxy_pass http://127.0.0.1:{{port}};++ proxy_http_version 1.1;++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection ${{zone}}_connection;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_buffering off;++ proxy_read_timeout 1h;++ proxy_send_timeout 1h;++ }nnew file mode 100644@@@ -0,0 +1,53 @@++## ═══════════════════════════════════════════════════════════════++## {{primary}} — generated by `ovhost nginx render {{service}}`++##++## Source: the "{{manifestId}}" service manifest (openvibe-contracts {{contractsVersion}}) and the++## host inventory (port {{port}}). Variant: {{variant}}.++## `ovhost nginx render {{service}} --install` writes {{sitesAvailable}}/{{vhost}}, links it into++## sites-enabled, and reloads nginx only if `nginx -t` passes (the previous file is restored if not).++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone={{zone}}_req:10m rate=10r/s;++{{httpContext}}++# ── HTTP → HTTPS redirect ──────────────────────────────────++server {++ listen 80;++ listen [::]:80;++ server_name {{serverNames}};++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://$host$request_uri; }++}++++# ── HTTPS server ───────────────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name {{serverNames}};++++ ssl_certificate /etc/letsencrypt/live/{{certName}}/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/{{certName}}/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/{{primary}}.access.log;++ error_log /var/log/nginx/{{primary}}.error.log;++++ client_max_body_size {{maxBody}};++++ add_header X-Content-Type-Options nosniff always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++{{locations}}++ location / {++ limit_req zone={{zone}}_req burst=40 nodelay;++ proxy_pass http://127.0.0.1:{{port}};++ proxy_http_version 1.1;++ proxy_set_header Connection "";++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_read_timeout 60s;++ }++}nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++const assert = require('assert');++const crypto = require('crypto');++const { scenario, test, runTests } = require('./helpers');++const { SHORT, LONG } = require('./fixtures/certs');++++const KEY_BLOCK = '-----BEGIN PRIVATE KEY-----\nTESTKEYMATERIALMUSTNOTBEPRINTED\n-----END PRIVATE KEY-----\n';++++function withCerts(host) {++ host.put('/etc/letsencrypt/live/test10.example/fullchain.pem', SHORT, { mode: 0o644 });++ host.put('/etc/letsencrypt/live/test10.example/privkey.pem', KEY_BLOCK, { mode: 0o600 });++ host.put('/etc/letsencrypt/live/test400.example/combined.pem', `${KEY_BLOCK}${LONG}`, { mode: 0o600 });++ host.put('/etc/nginx/sites-available/a.conf', [++ 'server {',++ ' ssl_certificate /etc/letsencrypt/live/test10.example/fullchain.pem;',++ ' ssl_certificate_key /etc/letsencrypt/live/test10.example/privkey.pem;',++ ' # ssl_certificate /etc/letsencrypt/live/commented/fullchain.pem;',++ '}',++ ].join('\n'));++ host.put('/etc/nginx/sites-available/b.conf', 'server {\n ssl_certificate /etc/letsencrypt/live/test400.example/combined.pem;\n ssl_certificate /etc/letsencrypt/live/gone.example/fullchain.pem;\n ssl_certificate /etc/ssl/private/site.key;\n}\n');++ host.exec.symlink('/etc/nginx/sites-available/a.conf', '/etc/nginx/sites-enabled/a.conf');++ host.exec.symlink('/etc/nginx/sites-available/b.conf', '/etc/nginx/sites-enabled/b.conf');++ // Put the clock five days before the short certificate expires.++ host.advance(new crypto.X509Certificate(SHORT).validToDate.getTime() - 5 * 86400000 - host.exec.now());++}++++runTests([++ test('certs: every certificate referenced by an enabled vhost, with expiry; never key material', async () => {++ const host = scenario();++ withCerts(host);++ const r = await host.cli('certs', '--json');++ const list = JSON.parse(r.out);++ const by = Object.fromEntries(list.map((c) => [c.path, c]));++ assert.strictEqual(by['/etc/letsencrypt/live/test10.example/fullchain.pem'].status, 'expiring');++ assert.ok(by['/etc/letsencrypt/live/test10.example/fullchain.pem'].daysLeft <= 5);++ assert.deepStrictEqual(by['/etc/letsencrypt/live/test10.example/fullchain.pem'].names, ['test10.example', '*.test10.example']);++ assert.strictEqual(by['/etc/letsencrypt/live/test400.example/combined.pem'].status, 'ok');++ assert.strictEqual(by['/etc/letsencrypt/live/test400.example/combined.pem'].subject, 'test400.example');++ assert.strictEqual(by['/etc/letsencrypt/live/gone.example/fullchain.pem'].status, 'missing');++ assert.strictEqual(by['/etc/ssl/private/site.key'].status, 'refused');++ assert.ok(!by['/etc/letsencrypt/live/commented/fullchain.pem'], 'commented-out lines are ignored');++ assert.ok(!list.some((c) => /privkey/.test(c.path)), 'ssl_certificate_key is never followed');++ assert.strictEqual(r.code, 2, 'a missing certificate fails the command');++ const text = await host.cli('certs');++ for (const out of [r.out, text.out]) {++ assert.ok(!out.includes('TESTKEYMATERIAL'));++ assert.ok(!out.includes('BEGIN PRIVATE KEY'));++ }++ assert.ok(!host.reads.some((f) => /privkey|\.key$/.test(f)), 'no key file was read');++ assert.ok(!host.calls.some((c) => c.args.some((a) => /privkey/.test(String(a)))), 'no command touched a key path');++ assert.match(text.out, /EXPIRING .*test10\.example\/fullchain\.pem/);++ }),++++ test('nginx render: vhost from the manifest domains + inventory port; SSE and WebSocket variants; stdout only', async () => {++ const host = scenario();++ const ev = await host.cli('nginx', 'render', 'events');++ assert.strictEqual(ev.code, 0, ev.out);++ assert.match(ev.out, /server_name events\.openvibe\.network;/);++ assert.match(ev.out, /ssl_certificate +\/etc\/letsencrypt\/live\/openvibe\.network\/fullchain\.pem;/, 'wildcard certificate of the registrable domain');++ assert.match(ev.out, /location = \/realtime\/stream \{[\s\S]*proxy_buffering off;[\s\S]*proxy_read_timeout 1h;/);++ assert.match(ev.out, /proxy_pass http:\/\/127\.0\.0\.1:4300;/);++ const live = await host.cli('nginx', 'render', 'live');++ assert.match(live.out, /server_name openvibe\.live ingest\.openvibe\.live;/);++ assert.match(live.out, /map \$http_upgrade \$ovlive_connection/);++ assert.match(live.out, /location \/ws\/ \{[\s\S]*proxy_set_header Upgrade \$http_upgrade;[\s\S]*proxy_set_header Connection \$ovlive_connection;/);++ assert.ok(!/\{\{/.test(live.out), 'no unfilled template variables');++ const plain = await host.cli('nginx', 'render', 'media', '--variant', 'http');++ assert.match(plain.out, /server_name openvibe\.media;/);++ assert.ok(!/proxy_buffering off/.test(plain.out));++ assert.strictEqual(host.read('/etc/nginx/sites-available/events.openvibe.network.conf'), null, 'render alone writes nothing');++ assert.ok(!host.calls.some((c) => c.cmd === 'nginx' || (c.cmd === 'systemctl' && c.args[0] === 'reload')));++ }),++++ test('nginx render --install: writes, links, tests, reloads; a failed nginx -t restores the previous state', async () => {++ const host = scenario();++ const ok = await host.cli('nginx', 'render', 'events', '--install');++ assert.strictEqual(ok.code, 0, ok.out);++ const installed = host.read('/etc/nginx/sites-available/events.openvibe.network.conf');++ assert.match(installed, /events\.openvibe\.network/);++ assert.ok(host.files.get('/etc/nginx/sites-enabled/events.openvibe.network.conf'));++ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload' && c.args[1] === 'nginx.service'));++++ host.put('/etc/nginx/sites-available/events.openvibe.network.conf', '# hand-edited previous version\n');++ host.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] duplicate zone' });++ const reloadsBefore = host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'reload').length;++ const bad = await host.cli('nginx', 'render', 'events', '--install');++ assert.strictEqual(bad.code, 2, bad.out);++ assert.match(bad.out, /nginx -t failed; the previous vhost files were restored/);++ assert.strictEqual(host.read('/etc/nginx/sites-available/events.openvibe.network.conf'), '# hand-edited previous version\n');++ assert.strictEqual(host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'reload').length, reloadsBefore, 'no reload after a failed test');++++ // A brand-new vhost that fails the test is removed again, link included.++ const fresh = scenario();++ fresh.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] x' });++ const r = await fresh.cli('nginx', 'render', 'media', '--install');++ assert.strictEqual(r.code, 2);++ assert.strictEqual(fresh.read('/etc/nginx/sites-available/openvibe.media.conf'), null);++ assert.strictEqual(fresh.files.get('/etc/nginx/sites-enabled/openvibe.media.conf'), undefined);++ }),++++ test('nginx render refuses a service with no domains', async () => {++ const host = scenario();++ const r = await host.cli('nginx', 'render', 'sites');++ assert.notStrictEqual(r.code, 0);++ assert.match(r.out, /no domains|has no port/);++ }),++]);nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { scenario, push, test, runTests } = require('./helpers');++const { normalise } = require('../lib/inventory');++const { createFakeHost } = require('./fake-host');++const { main } = require('../lib/cli');++++async function cli(host, ...argv) {++ const lines = [];++ const code = await main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {} });++ return { code, out: lines.join('\n') };++}++++runTests([++ test('host.example.json is a valid inventory for every service on the host', () => {++ const raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'host.example.json'), 'utf8'));++ const inv = normalise(raw);++ assert.deepStrictEqual(Object.keys(inv.services), ['network', 'live', 'media', 'tools', 'community', 'events', 'games', 'sites']);++ assert.strictEqual(inv.services.live.socketUnit, 'openvibe-live.socket');++ assert.ok(!inv.services.live.units.includes('openvibe-live.socket'));++ assert.strictEqual(inv.services.live.protected.url, 'http://127.0.0.1:3000/api/streams');++ assert.match(inv.services.media.protected.sql, /is_recording = 1/);++ assert.strictEqual(inv.services.games.managed, false);++ for (const s of Object.values(inv.services)) assert.strictEqual(s.owner, 'ubuntu', `${s.id} checkout owner`);++ const text = JSON.stringify(raw);++ assert.ok(!/(sk_|ghp_|BEGIN [A-Z ]*PRIVATE KEY|password\s*[:=]\s*\S)/i.test(text), 'no secret values in the example');++ }),++++ test('the service manifest proposal is valid against the contracts schema', () => {++ const contracts = require('openvibe-contracts');++ const m = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));++ contracts.assertValid('registry.service-manifest', m);++ assert.strictEqual(m.id, 'host');++ assert.deepStrictEqual(m.capabilities, [], 'Stage A exposes no API, so it enforces no capability');++ }),++++ test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {++ const host = scenario();++ host.files.get('/etc/openvibe/host.json').owner = 'ubuntu';++ let r = await host.cli('status');++ assert.strictEqual(r.code, 1);++ assert.match(r.out, /must be owned by root/);++ host.files.get('/etc/openvibe/host.json').owner = 'root';++ host.files.get('/etc/openvibe/host.json').mode = 0o666;++ r = await host.cli('status');++ assert.strictEqual(r.code, 1);++ assert.match(r.out, /must not be group- or world-writable/);++ }),++++ test('inventory validation rejects paths and names that could point a root process somewhere else', () => {++ const base = { owner: 'ubuntu' };++ assert.throws(() => normalise({ services: { x: { ...base, repo: 'opt/x' } } }), /absolute path/);++ assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', nginx: { vhost: '../../etc/passwd' } } } }), /file name/);++ assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', ready: { url: 'http://evil.example/ready' } } } }), /loopback/);++ assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', protected: { kind: 'sqlite-count', db: '/d.db', sql: 'DELETE FROM vods' } } } }), /SELECT/);++ assert.throws(() => normalise({ services: { 'X Y': { ...base, repo: '/opt/x' } } }), /service id/);++ assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', unitSources: { 'a.service': '../../etc/shadow' } } } }), /inside the checkout/);++ }),++++ test('no inventory, unknown service, unknown command, help', async () => {++ const empty = createFakeHost();++ let r = await cli(empty, 'status');++ assert.strictEqual(r.code, 1);++ assert.match(r.out, /no inventory found/);++ const host = scenario();++ r = await host.cli('validate', 'nope');++ assert.strictEqual(r.code, 1);++ assert.match(r.out, /unknown service "nope"/);++ r = await host.cli('frobnicate');++ assert.strictEqual(r.code, 1);++ r = await host.cli('--help');++ assert.strictEqual(r.code, 0);++ assert.match(r.out, /ovhost — OpenVibe.Host operator plane/);++ r = await host.cli('releases', '../../etc/x');++ assert.strictEqual(r.code, 1);++ r = await host.cli('deploy', 'live', '--to', '--upload-pack=touch /tmp/x');++ assert.strictEqual(r.code, 1);++ assert.match(r.out, /not a sha or ref name/);++ assert.ok(!host.calls.some((c) => c.cmd === 'git' && c.args.some((a) => a.includes('upload-pack'))));++ r = await host.cli('deploy');++ assert.strictEqual(r.code, 1);++ assert.match(r.out, /needs a <service>/);++ }),++++ test('status covers every service: units, socket, sha, readiness, protected sessions', async () => {++ const host = scenario();++ host.liveStreams = [{ is_live: 1 }];++ host.recording = 2;++ host.units.get('openvibe-events.service').active = 'failed';++ const r = await host.cli('status');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /^live +[0-9a-f]{12} ready +openvibe-live=active socket=active live streams=1$/m);++ assert.match(r.out, /^media .* recordings in progress=2$/m);++ assert.match(r.out, /^tools .*openvibe-tools=active openvibe-tools-maps=active/m);++ assert.match(r.out, /^events .*NOT READY \(502\)/m);++ assert.match(r.out, /^sites .*\(no units\)/m);++ const json = JSON.parse((await host.cli('status', 'live', '--json')).out);++ assert.strictEqual(json.length, 1);++ assert.strictEqual(json[0].protected.count, 1);++ assert.deepStrictEqual(host.restarts(), [], 'status never restarts anything');++ }),++++ test('releases prints the log; an unmanaged service refuses deploy; the releases/current layout is refused', async () => {++ const host = scenario();++ push(host, 'live', { 'server/index.js': 'z();' }, 'z change');++ await host.cli('deploy', 'live');++ const r = await host.cli('releases', 'live');++ assert.match(r.out, /deploy +[0-9a-f]{12} → [0-9a-f]{12} +deployed/);++ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));++ raw.services.events.managed = false;++ raw.services.events.unmanagedReason = 'test';++ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });++ const u = await host.cli('deploy', 'events');++ assert.strictEqual(u.code, 1);++ assert.match(u.out, /not managed by ovhost: test/);++ await host.exec.symlink('/opt/openvibe.media/releases/x', '/opt/openvibe.media/current');++ const rl = await host.cli('deploy', 'media');++ assert.strictEqual(rl.code, 1);++ assert.match(rl.out, /releases\/current layout/);++ }),++]);nnew file mode 100644@@@ -0,0 +1,358 @@++'use strict';++const assert = require('assert');++const path = require('path');++const { scenario, push, test, runTests, pkgJson } = require('./helpers');++const systemd = require('../lib/systemd');++const releases = require('../lib/releases');++++const liveServerChange = (host) => push(host, 'live', { 'server/index.js': 'console.log(2);' }, 'server change');++++runTests([++ test('deploys a Live server change: ff as ubuntu, restarts only the service unit, polls readiness, logs the release', async () => {++ const host = scenario();++ const from = host.repo('live').head;++ const to = liveServerChange(host);++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 0, r.out);++ assert.strictEqual(host.repo('live').head, to);++ assert.deepStrictEqual(host.restarts(), ['openvibe-live.service']);++ assert.deepStrictEqual(host.socketViolations(), []);++ for (const c of host.calls.filter((x) => x.cmd === 'git')) assert.strictEqual(c.as, 'ubuntu', `git ran as ${c.as}: ${c.args.join(' ')}`);++ assert.ok(!host.calls.some((c) => c.cmd === 'npm'), 'no install without a dependency change');++ const [rec] = await releases.list(host.exec, host.inv, 'live');++ assert.strictEqual(rec.result, 'deployed');++ assert.strictEqual(rec.from, from);++ assert.strictEqual(rec.to, to);++ assert.strictEqual(rec.lockfileChanged, false);++ assert.strictEqual(rec.depsVerified, true);++ assert.strictEqual(rec.ready, true);++ assert.match(r.out, /openvibe-live\.socket active — new HTTP connections queue/);++ }),++++ test('refuses to restart Live while streams are live; the checkout does not move', async () => {++ const host = scenario();++ const from = host.repo('live').head;++ liveServerChange(host);++ host.liveStreams = [{ id: 1, is_live: 1 }, { id: 2, is_live: 0 }];++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 5, r.out);++ assert.match(r.out, /1 live streams — refusing to restart/);++ assert.match(r.out, /--wait-idle/);++ assert.strictEqual(host.repo('live').head, from);++ assert.deepStrictEqual(host.restarts(), []);++ const [rec] = await releases.list(host.exec, host.inv, 'live');++ assert.strictEqual(rec.result, 'refused');++ assert.strictEqual(rec.protectedSessions.count, 1);++ }),++++ test('an unanswerable stream probe counts as live, not as zero', async () => {++ const host = scenario();++ liveServerChange(host);++ host.http.set('http://127.0.0.1:3000/api/streams', () => ({ status: 500, body: 'oops' }));++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 5, r.out);++ assert.match(r.out, /unknown \(HTTP 500\) — treated as active/);++ assert.deepStrictEqual(host.restarts(), []);++ }),++++ test('--wait-idle holds until two consecutive idle checks, then deploys', async () => {++ const host = scenario();++ const to = liveServerChange(host);++ host.liveStreams = [{ is_live: true }];++ let polls = 0;++ host.http.set('http://127.0.0.1:3000/api/streams', () => {++ polls += 1;++ return { status: 200, body: { streams: polls < 4 ? host.liveStreams : [] } };++ });++ const r = await host.cli('deploy', 'live', '--wait-idle');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /holding the restart/);++ assert.match(r.out, /idle for 2 checks — proceeding/);++ assert.strictEqual(host.repo('live').head, to);++ assert.deepStrictEqual(host.restarts(), ['openvibe-live.service']);++ }),++++ test('--wait-idle gives up after drain.waitMaxSeconds with nothing restarted', async () => {++ const host = scenario();++ const from = host.repo('live').head;++ liveServerChange(host);++ host.liveStreams = [{ is_live: true }];++ const r = await host.cli('deploy', 'live', '--wait-idle');++ assert.strictEqual(r.code, 5, r.out);++ assert.match(r.out, /gave up; nothing was restarted/);++ assert.strictEqual(host.repo('live').head, from);++ assert.deepStrictEqual(host.restarts(), []);++ }),++++ test('--force restarts with streams live, loudly, and records it', async () => {++ const host = scenario();++ liveServerChange(host);++ host.liveStreams = [{ is_live: true }, { is_live: true }];++ const r = await host.cli('deploy', 'live', '--force');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /!!! --force/);++ assert.match(r.out, /2 live streams WILL BE DROPPED/);++ const [rec] = await releases.list(host.exec, host.inv, 'live');++ assert.strictEqual(rec.forced, true);++ assert.deepStrictEqual(host.socketViolations(), []);++ }),++++ test('a static-only Live change (public/) deploys without a restart, even with streams live', async () => {++ const host = scenario();++ const to = push(host, 'live', { 'public/app.js': 'app2();' });++ host.liveStreams = [{ is_live: true }];++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 0, r.out);++ assert.strictEqual(host.repo('live').head, to);++ assert.deepStrictEqual(host.restarts(), []);++ assert.match(r.out, /no restart needed/);++ }),++++ test('sessions that start during the install stop the restart and put the checkout back', async () => {++ const host = scenario();++ const from = host.repo('live').head;++ push(host, 'live', { 'server/index.js': 'x();', 'package-lock.json': '{"lockfileVersion":3,"v":2}' });++ host.npm = () => { host.liveStreams = [{ is_live: true }]; return undefined; };++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 5, r.out);++ assert.match(r.out, /immediately before the restart/);++ assert.strictEqual(host.repo('live').head, from);++ assert.deepStrictEqual(host.restarts(), []);++ }),++++ test('refuses to restart Media while a recording is in progress (read-only query as the service user)', async () => {++ const host = scenario();++ const from = host.repo('media').head;++ push(host, 'media', { 'server/index.js': 'media2();' });++ host.recording = 1;++ const r = await host.cli('deploy', 'media');++ assert.strictEqual(r.code, 5, r.out);++ assert.match(r.out, /1 recordings in progress — refusing/);++ assert.strictEqual(host.repo('media').head, from);++ assert.deepStrictEqual(host.restarts(), []);++ const q = host.sqliteCalls.find((c) => c.op === 'query');++ assert.strictEqual(q.as, 'ubuntu');++ assert.strictEqual(q.db, '/opt/openvibe.media/data/media.db');++ assert.match(q.sql, /^SELECT count\(\*\) AS n FROM vods WHERE is_recording = 1$/);++ }),++++ test('Media deploys once no recording is running', async () => {++ const host = scenario();++ const to = push(host, 'media', { 'server/index.js': 'media2();' });++ const r = await host.cli('deploy', 'media');++ assert.strictEqual(r.code, 0, r.out);++ assert.strictEqual(host.repo('media').head, to);++ assert.deepStrictEqual(host.restarts(), ['openvibe-media.service']);++ }),++++ test('rolls back automatically when readiness fails, reinstalling the previous dependencies', async () => {++ const host = scenario();++ const from = host.repo('live').head;++ const bad = push(host, 'live', { 'server/index.js': 'broken();', 'package-lock.json': '{"lockfileVersion":3,"v":2}' });++ host.badShas.add(bad);++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 3, r.out);++ assert.strictEqual(host.repo('live').head, from);++ assert.deepStrictEqual(host.restarts(), ['openvibe-live.service', 'openvibe-live.service']);++ assert.strictEqual(host.units.get('openvibe-live.service').runningSha, from);++ const npmRuns = host.calls.filter((c) => c.cmd === 'npm');++ assert.strictEqual(npmRuns.length, 2, 'installed for the new sha, reinstalled for the old one');++ const [rec] = await releases.list(host.exec, host.inv, 'live');++ assert.strictEqual(rec.result, 'failed-rolled-back');++ assert.strictEqual(rec.autoRollback.ready, true);++ assert.deepStrictEqual(host.socketViolations(), []);++ }),++++ test('exit 4 and MANUAL INTERVENTION when the previous release is not ready either', async () => {++ const host = scenario();++ const from = host.repo('live').head;++ const bad = liveServerChange(host);++ host.badShas.add(bad);++ host.badShas.add(from);++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 4, r.out);++ assert.match(r.out, /MANUAL INTERVENTION REQUIRED/);++ const [rec] = await releases.list(host.exec, host.inv, 'live');++ assert.strictEqual(rec.result, 'rollback-failed');++ }),++++ test('a dependency that still does not resolve after a reinstall aborts before any restart', async () => {++ const host = scenario();++ const from = host.repo('tools').head;++ push(host, 'tools', { 'apps/gateway/package.json': pkgJson('tools-gateway', ['express', 'openvibe-shared', 'openvibe-contracts']) });++ // The Tools incident: npm leaves an empty directory where openvibe-shared should be++ // (here: only for the new revision's package.json).++ host.npm = (cwd) => {++ const pkg = JSON.parse(host.read(path.join(cwd, 'package.json')));++ const broken = 'openvibe-contracts' in pkg.dependencies;++ for (const dep of Object.keys(pkg.dependencies)) {++ if (broken && dep === 'openvibe-shared') { host.files.delete(path.join(cwd, 'node_modules', dep, 'package.json')); host.ensureDir(path.join(cwd, 'node_modules', dep)); } else host.put(path.join(cwd, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep }));++ }++ return { code: 0 };++ };++ const r = await host.cli('deploy', 'tools');++ assert.strictEqual(r.code, 2, r.out);++ assert.match(r.out, /dependencies do not resolve: apps\/gateway: openvibe-shared/);++ assert.match(r.out, /nothing restarted/);++ assert.deepStrictEqual(host.restarts(), []);++ assert.strictEqual(host.repo('tools').head, from, 'checkout restored');++ const [rec] = await releases.list(host.exec, host.inv, 'tools');++ assert.strictEqual(rec.depsVerified, false);++ }),++++ test('a dependency that resolves after one reinstall is repaired and the deploy continues', async () => {++ const host = scenario();++ const to = push(host, 'tools', { 'apps/maps/server.js': 'maps2();' });++ host.files.delete('/opt/openvibe.tools/apps/gateway/node_modules/openvibe-shared/package.json');++ const r = await host.cli('deploy', 'tools');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /openvibe-shared in apps\/gateway does not resolve .* reinstalling/);++ assert.strictEqual(host.repo('tools').head, to);++ assert.deepStrictEqual(host.restarts(), ['openvibe-tools.service', 'openvibe-tools-maps.service']);++ const [rec] = await releases.list(host.exec, host.inv, 'tools');++ assert.deepStrictEqual(rec.depsRepaired, ['apps/gateway']);++ }),++++ test('installs only where the lockfile changed, as the owner, then restores the lockfile npm rewrote', async () => {++ const host = scenario();++ push(host, 'tools', { 'apps/maps/package-lock.json': '{"v":2}' });++ const r = await host.cli('deploy', 'tools');++ assert.strictEqual(r.code, 0, r.out);++ const npmRuns = host.calls.filter((c) => c.cmd === 'npm');++ assert.strictEqual(npmRuns.length, 1);++ assert.strictEqual(npmRuns[0].cwd, '/opt/openvibe.tools/apps/maps');++ assert.strictEqual(npmRuns[0].as, 'ubuntu');++ assert.ok(host.calls.some((c) => c.cmd === 'git' && c.args.includes('checkout') && c.args.includes('apps/maps/package-lock.json')));++ assert.strictEqual(host.read('/opt/openvibe.tools/apps/maps/package-lock.json'), '{"v":2}');++ const [rec] = await releases.list(host.exec, host.inv, 'tools');++ assert.strictEqual(rec.lockfileChanged, true);++ assert.deepStrictEqual(rec.installed, ['apps/maps']);++ }),++++ test('tracked local changes block the deploy', async () => {++ const host = scenario();++ liveServerChange(host);++ host.put('/opt/openvibe.live/server/index.js', 'hotfix();', { owner: 'ubuntu' });++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 2, r.out);++ assert.match(r.out, /tracked local changes \(server\/index\.js\)/);++ assert.deepStrictEqual(host.restarts(), []);++ }),++++ test('the socket unit is never stopped or restarted; a down socket is started', async () => {++ const host = scenario();++ await assert.rejects(systemd.systemctl(host.exec, 'restart', 'openvibe-live.socket'), systemd.SocketGuardError);++ await assert.rejects(systemd.systemctl(host.exec, 'reload', 'openvibe-live.socket'), systemd.SocketGuardError);++ await assert.rejects(systemd.systemctl(host.exec, 'stop', 'openvibe-live.service'), /does not run "systemctl stop"/);++ host.units.get('openvibe-live.socket').active = 'inactive';++ liveServerChange(host);++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 0, r.out);++ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'start' && c.args[1] === 'openvibe-live.socket'));++ assert.deepStrictEqual(host.socketViolations(), []);++ assert.throws(() => require('../lib/inventory').normalise({ services: { x: { repo: '/opt/x', owner: 'ubuntu', units: ['openvibe-live.socket'] } } }), /socket unit/);++ }),++++ test('Events (drain policy "report") restarts with SSE connections open and says so', async () => {++ const host = scenario();++ push(host, 'events', { 'server/index.js': 'events2();' });++ host.sseConnections = 7;++ const r = await host.cli('deploy', 'events');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /7 SSE connections; drain policy "report"/);++ assert.deepStrictEqual(host.restarts(), ['openvibe-events.service']);++ }),++++ test('a schema change backs up the declared databases (as the service user) before the restart', async () => {++ const host = scenario();++ push(host, 'live', { 'server/db/schema.sql': 'CREATE TABLE b(y);' });++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 0, r.out);++ const b = host.sqliteCalls.find((c) => c.op === 'backup');++ assert.ok(b, 'backup taken');++ assert.strictEqual(b.as, 'ubuntu');++ assert.match(b.dest, /^\/var\/backups\/openvibe\/live\/\d{8}-\d{6}\/live\.db$/);++ const backupIdx = host.calls.findIndex((c) => c.cmd === 'sqlite-backup');++ const restartIdx = host.calls.findIndex((c) => c.cmd === 'systemctl' && c.args[0] === 'restart');++ assert.ok(backupIdx < restartIdx, 'backup before restart');++ }),++++ test('rollback without --to returns to the release before the current one', async () => {++ const host = scenario();++ const first = host.repo('live').head;++ const second = liveServerChange(host);++ assert.strictEqual((await host.cli('deploy', 'live')).code, 0);++ const r = await host.cli('rollback', 'live');++ assert.strictEqual(r.code, 0, r.out);++ assert.strictEqual(host.repo('live').head, first);++ const recs = await releases.list(host.exec, host.inv, 'live');++ assert.strictEqual(recs[1].action, 'rollback');++ assert.strictEqual(recs[1].from, second);++ assert.strictEqual(recs[1].to, first);++ assert.strictEqual(recs[1].result, 'rolled-back');++ // A later deploy fast-forwards again.++ const again = await host.cli('deploy', 'live');++ assert.strictEqual(again.code, 0, again.out);++ assert.strictEqual(host.repo('live').head, second);++ }),++++ test('rollback respects protected sessions too', async () => {++ const host = scenario();++ const first = host.repo('live').head;++ liveServerChange(host);++ await host.cli('deploy', 'live');++ host.liveStreams = [{ is_live: true }];++ const r = await host.cli('rollback', 'live', '--to', first);++ assert.strictEqual(r.code, 5, r.out);++ assert.notStrictEqual(host.repo('live').head, first);++ }),++++ test('a second operation on the same service is refused while the first holds the lock', async () => {++ const host = scenario();++ host.put('/var/lib/openvibe-host/locks/live.lock', JSON.stringify({ pid: 4242, at: 'x' }));++ host.alivePids.add(4242);++ liveServerChange(host);++ const r = await host.cli('deploy', 'live');++ assert.strictEqual(r.code, 1, r.out);++ assert.match(r.out, /another ovhost operation on live is running \(pid 4242/);++ host.alivePids.delete(4242);++ const r2 = await host.cli('deploy', 'live');++ assert.strictEqual(r2.code, 0, r2.out);++ assert.match(r2.out, /removed a stale lock/);++ }),++++ test('Sites: npm ci + build as the owner, vhosts installed with nginx -t; a failing test restores and aborts', async () => {++ const host = scenario();++ push(host, 'sites', { 'deploy/nginx/openvibe.chat.conf': 'server { listen 443; server_name openvibe.chat; } # v2\n', 'build.js': 'build2();' });++ const r = await host.cli('deploy', 'sites');++ assert.strictEqual(r.code, 0, r.out);++ assert.ok(host.calls.some((c) => c.cmd === 'npm' && c.args[0] === 'ci' && c.as === 'ubuntu'));++ assert.ok(host.calls.some((c) => c.cmd === 'node' && c.args[0] === 'build.js' && c.as === 'ubuntu'));++ assert.match(host.read('/etc/nginx/sites-available/openvibe.chat.conf'), /# v2/);++ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload' && c.args[1] === 'nginx.service'));++++ const before = host.repo('sites').head;++ push(host, 'sites', { 'deploy/nginx/openvibe.chat.conf': 'server { broken\n' });++ host.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] unexpected end of file' });++ const r2 = await host.cli('deploy', 'sites');++ assert.strictEqual(r2.code, 2, r2.out);++ assert.match(r2.out, /nginx -t failed/);++ assert.match(host.read('/etc/nginx/sites-available/openvibe.chat.conf'), /# v2/, 'previous vhost restored');++ assert.strictEqual(host.repo('sites').head, before, 'checkout restored');++ }),++++ test('plan shows the change without moving anything', async () => {++ const host = scenario();++ const from = host.repo('live').head;++ push(host, 'live', { 'server/index.js': 'y();', 'package-lock.json': '{"v":9}' });++ host.liveStreams = [{ is_live: true }];++ const r = await host.cli('plan', 'live');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /install \(lockfile changed\)/);++ assert.match(r.out, /restart +yes: openvibe-live\.service \(socket openvibe-live\.socket stays up\)/);++ assert.match(r.out, /protected +1 live streams; drain policy refuse/);++ assert.strictEqual(host.repo('live').head, from);++ assert.deepStrictEqual(host.restarts(), []);++ assert.ok(!host.calls.some((c) => c.cmd === 'npm'));++ }),++]);nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * The real executor, exercised only where that is safe on any machine: the SQLite worker against a++ * temp database, HTTP against a local server, files in a temp directory, and run() as the current++ * user. No systemctl/git/npm/nginx command is run.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const Database = require('better-sqlite3');++const { createSystemExecutor } = require('../lib/executor');++const { test, runTests } = require('./helpers');++++const exec = createSystemExecutor();++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ovhost-exec-'));++process.on('exit', () => fs.rmSync(dir, { recursive: true, force: true }));++++runTests([++ test('sqlite: read-only count as the current user; writes are refused', async () => {++ const dbPath = path.join(dir, 'media.db');++ const db = new Database(dbPath);++ db.pragma('journal_mode = WAL');++ db.exec('CREATE TABLE vods (id INTEGER PRIMARY KEY, is_recording INTEGER); INSERT INTO vods (is_recording) VALUES (1), (0), (1);');++ const me = await exec.userName();++ const rows = await exec.sqlite(dbPath, 'SELECT count(*) AS n FROM vods WHERE is_recording = 1', { as: me });++ assert.deepStrictEqual(rows, [{ n: 2 }]);++ await assert.rejects(exec.sqlite(dbPath, 'DELETE FROM vods', { as: me }), /read-only|readonly/);++ assert.strictEqual(db.prepare('SELECT count(*) AS n FROM vods').get().n, 3);++ await assert.rejects(exec.sqlite(path.join(dir, 'missing.db'), 'SELECT 1'), /cannot open/);++ db.close();++ }),++++ test('sqlite backup: consistent copy that passes quick_check; never overwrites', async () => {++ const src = path.join(dir, 'src.db');++ const db = new Database(src);++ db.exec('CREATE TABLE t (x); INSERT INTO t VALUES (1), (2);');++ const dest = path.join(dir, 'backup.db');++ await exec.sqliteBackup(src, dest);++ const copy = new Database(dest, { readonly: true });++ assert.strictEqual(copy.prepare('SELECT count(*) AS n FROM t').get().n, 2);++ copy.close();++ await assert.rejects(exec.sqliteBackup(src, dest), /refusing to overwrite/);++ db.close();++ }),++++ test('http, files, locks and run() behave as the fake host assumes', async () => {++ const server = http.createServer((req, res) => { res.writeHead(req.url === '/ok' ? 200 : 503); res.end(JSON.stringify({ host: req.headers.host })); });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const ok = await exec.http(`${base}/ok`, { headers: { Host: 'openvibe.tools' } });++ assert.strictEqual(ok.status, 200);++ assert.strictEqual(JSON.parse(ok.body).host, 'openvibe.tools');++ assert.strictEqual((await exec.http(`${base}/no`)).status, 503);++ server.close();++ const refused = await exec.http('http://127.0.0.1:1/');++ assert.strictEqual(refused.status, 0);++++ const f = path.join(dir, 'a', 'lock');++ assert.strictEqual(await exec.createExclusive(f, '1'), true);++ assert.strictEqual(await exec.createExclusive(f, '2'), false);++ assert.strictEqual(await exec.readFile(f), '1');++ assert.strictEqual(await exec.readFile(path.join(dir, 'nope')), null);++ const st = await exec.stat(f);++ assert.strictEqual(st.isFile, true);++ assert.strictEqual(st.mode & 0o777, 0o640);++++ const r = await exec.run(process.execPath, ['-e', 'process.stdout.write(process.argv[1])', 'hi'], { as: await exec.userName() });++ assert.deepStrictEqual([r.code, r.stdout], [0, 'hi']);++ }),++]);nnew file mode 100644@@@ -0,0 +1,265 @@++'use strict';++/**++ * A fake host implementing the executor interface (lib/executor.js) in memory: a filesystem, git++ * repositories, systemd units, npm, nginx, ss, HTTP endpoints and SQLite. Nothing here touches the++ * real machine. Every run() call is recorded in `calls` so tests can assert what would have been++ * executed (and as whom).++ */++const crypto = require('crypto');++const path = require('path');++++function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', start = Date.parse('2026-09-22T12:00:00Z') } = {}) {++ const files = new Map(); // path -> { type: 'file'|'dir'|'symlink', content, mode, owner, target }++ const calls = [];++ const repos = new Map(); // repoPath -> repo++ const units = new Map(); // unit -> state++ const http = new Map(); // url -> fn({ headers }) -> { status, body }++ const alivePids = new Set();++ const listeners = new Map(); // port -> [{ pid, process }]++ let clock = start;++ let seq = 0;++ const host = {++ files, calls, repos, units, http, alivePids, listeners,++ nginxTest: () => ({ code: 0, stderr: 'nginx: configuration file /etc/nginx/nginx.conf test is successful' }),++ npm: null, // (cwd, argv, as) -> undefined | { code, stderr } ; default installs package.json deps++ npmRewritesLockfile: true,++ sqliteHandler: () => [{ n: 0 }],++ sqliteCalls: [],++ reads: [],++ onRestart: null, // (unit) -> void++ };++++ // ── filesystem ──++ function ensureDir(p, owner = 'root', mode = 0o755) {++ const parts = path.resolve(p).split('/').filter(Boolean);++ let cur = '';++ for (const part of parts) {++ cur += `/${part}`;++ if (!files.has(cur)) files.set(cur, { type: 'dir', mode, owner });++ }++ }++ function put(p, content, { mode = 0o644, owner = 'root' } = {}) {++ ensureDir(path.dirname(p), owner);++ files.set(path.resolve(p), { type: 'file', content, mode, owner });++ }++ function get(p) {++ let e = files.get(path.resolve(p));++ let hops = 0;++ while (e && e.type === 'symlink' && hops++ < 10) e = files.get(path.resolve(path.dirname(p), e.target));++ return e || null;++ }++ function rmTree(p) {++ const abs = path.resolve(p);++ for (const k of [...files.keys()]) if (k === abs || k.startsWith(`${abs}/`)) files.delete(k);++ }++ host.put = put;++ host.read = (p) => { const e = get(p); return e && e.type === 'file' ? String(e.content) : null; };++ host.ensureDir = ensureDir;++++ // ── git ──++ function newSha(seed) { seq += 1; return crypto.createHash('sha1').update(`${seq}:${seed}`).digest('hex'); }++ host.createRepo = (repoPath, { owner = 'ubuntu', branch = 'main', remote = 'origin' } = {}) => {++ const repo = { path: repoPath, owner, branch, remote, commits: new Map(), head: null, remoteRefs: {} };++ repos.set(repoPath, repo);++ ensureDir(repoPath, owner);++ files.get(path.resolve(repoPath)).owner = owner;++ repo.commit = (changes, { parent = repo.remoteRefs[`${remote}/${branch}`] || repo.head, message = 'change' } = {}) => {++ const base = parent ? { ...repo.commits.get(parent).files } : {};++ for (const [k, v] of Object.entries(changes)) { if (v === null) delete base[k]; else base[k] = v; }++ const sha = newSha(JSON.stringify(base) + message);++ repo.commits.set(sha, { sha, parent, message, files: base });++ return sha;++ };++ repo.publish = (sha) => { repo.remoteRefs[`${remote}/${branch}`] = sha; };++ repo.checkout = (sha) => {++ const old = repo.head ? repo.commits.get(repo.head).files : {};++ const next = repo.commits.get(sha).files;++ for (const f of Object.keys(old)) if (!(f in next)) files.delete(path.join(repoPath, f));++ for (const [f, c] of Object.entries(next)) put(path.join(repoPath, f), c, { owner });++ repo.head = sha;++ };++ return repo;++ };++ function isAncestor(repo, anc, sha) {++ let cur = sha;++ while (cur) { if (cur === anc) return true; cur = repo.commits.get(cur).parent; }++ return false;++ }++ function resolveRef(repo, ref) {++ const r = ref.replace(/\^\{commit\}$/, '');++ if (r === 'HEAD') return repo.head;++ if (repo.remoteRefs[r]) return repo.remoteRefs[r];++ if (repo.commits.has(r)) return r;++ const matches = [...repo.commits.keys()].filter((k) => k.startsWith(r));++ return matches.length === 1 ? matches[0] : null;++ }++ function gitCmd(args, opts) {++ const repo = repos.get(args[1]);++ if (!repo) return { code: 128, stdout: '', stderr: 'not a git repository' };++ if (opts.as !== repo.owner) return { code: 128, stdout: '', stderr: `fatal: detected dubious ownership in repository at '${repo.path}' (ran as ${opts.as || 'root'})` };++ const [sub, ...rest] = args.slice(2);++ const ok = (stdout = '') => ({ code: 0, stdout, stderr: '' });++ const fail = (stderr) => ({ code: 1, stdout: '', stderr });++ switch (sub) {++ case 'rev-parse':++ if (rest[0] === '--abbrev-ref') return ok(`${repo.branch}\n`);++ if (rest[0] === '--verify') { const s = resolveRef(repo, rest[1]); return s ? ok(`${s}\n`) : fail(`fatal: Needed a single revision (${rest[1]})`); }++ return ok(`${resolveRef(repo, rest[0])}\n`);++ case 'cat-file': return resolveRef(repo, rest[1]) ? ok() : fail('bad object');++ case 'status': {++ const head = repo.commits.get(repo.head).files;++ const dirty = Object.entries(head).filter(([f, c]) => host.read(path.join(repo.path, f)) !== c).map(([f]) => ` M ${f}`);++ return ok(dirty.length ? `${dirty.join('\n')}\n` : '');++ }++ case 'fetch': return ok();++ case 'diff': {++ const a = repo.commits.get(resolveRef(repo, rest[1])).files;++ const b = repo.commits.get(resolveRef(repo, rest[2])).files;++ const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((f) => a[f] !== b[f]).sort();++ return ok(names.length ? `${names.join('\n')}\n` : '');++ }++ case 'log': {++ const [from, to] = rest[rest.length - 1].split('..');++ const out = [];++ let cur = resolveRef(repo, to);++ while (cur && cur !== resolveRef(repo, from)) { out.push(`${cur.slice(0, 7)} ${repo.commits.get(cur).message}`); cur = repo.commits.get(cur).parent; }++ return ok(out.length ? `${out.join('\n')}\n` : '');++ }++ case 'show': {++ const [ref, file] = rest[0].split(':');++ const c = repo.commits.get(resolveRef(repo, ref));++ return c && file in c.files ? ok(c.files[file]) : fail(`fatal: path '${file}' does not exist`);++ }++ case 'merge': {++ const sha = resolveRef(repo, rest[rest.length - 1]);++ if (!isAncestor(repo, repo.head, sha)) return fail('fatal: Not possible to fast-forward, aborting.');++ repo.checkout(sha);++ return ok();++ }++ case 'reset': repo.checkout(resolveRef(repo, rest[rest.length - 1])); return ok();++ case 'checkout': {++ const f = rest[rest.length - 1];++ put(path.join(repo.path, f), repo.commits.get(repo.head).files[f], { owner: repo.owner });++ return ok();++ }++ case 'config': return ok(`https://x-access-token:[email protected]/OpenVibers/${path.basename(repo.path)}.git\n`);++ default: return fail(`fake git: unsupported ${sub}`);++ }++ }++++ // ── npm ──++ function npmCmd(argv, opts) {++ if (host.npm) {++ const r = host.npm(opts.cwd, argv, opts.as);++ if (r) return { stdout: '', stderr: '', ...r };++ }++ const pkg = JSON.parse(host.read(path.join(opts.cwd, 'package.json')));++ for (const dep of Object.keys(pkg.dependencies || {})) put(path.join(opts.cwd, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep, version: '1.0.0' }), { owner: opts.as });++ const lock = path.join(opts.cwd, 'package-lock.json');++ if (host.npmRewritesLockfile && host.read(lock) != null) put(lock, `${host.read(lock)}\n// rewritten by npm on the host`, { owner: opts.as });++ return { code: 0, stdout: 'added packages', stderr: '' };++ }++++ // ── systemd ──++ host.addUnit = (unit, state = {}) => {++ units.set(unit, { load: 'loaded', active: 'active', sub: 'running', mainPid: 1000 + units.size, fragmentPath: `/etc/systemd/system/${unit}`, dropIns: [], partOf: [], restarts: 0, runningSha: null, ...state });++ return units.get(unit);++ };++ function systemctlCmd(args) {++ const [action, unit] = args;++ const u = units.get(unit);++ switch (action) {++ case 'show': {++ if (!u) return { code: 0, stdout: 'LoadState=not-found\nActiveState=inactive\nSubState=dead\nMainPID=0\nFragmentPath=\n', stderr: '' };++ return { code: 0, stdout: [`LoadState=${u.load}`, `ActiveState=${u.active}`, `SubState=${u.sub}`, `MainPID=${u.active === 'active' ? u.mainPid : 0}`, `FragmentPath=${u.fragmentPath}`, `UnitFileState=enabled`, `DropInPaths=${u.dropIns.join(' ')}`, `PartOf=${u.partOf.join(' ')}`, `NRestarts=${u.restarts}`].join('\n'), stderr: '' };++ }++ case 'restart':++ if (!u) return { code: 5, stdout: '', stderr: `Unit ${unit} not found.` };++ u.active = 'active'; u.sub = 'running'; u.restarts += 1;++ if (host.onRestart) host.onRestart(unit, u);++ return { code: 0, stdout: '', stderr: '' };++ case 'start': if (u) { u.active = 'active'; u.sub = unit.endsWith('.socket') ? 'listening' : 'running'; } return { code: 0, stdout: '', stderr: '' };++ case 'stop': if (u) { u.active = 'inactive'; u.sub = 'dead'; } return { code: 0, stdout: '', stderr: '' };++ case 'reload': case 'daemon-reload': return { code: 0, stdout: '', stderr: '' };++ case 'is-active': return { code: u && u.active === 'active' ? 0 : 3, stdout: '', stderr: '' };++ default: return { code: 1, stdout: '', stderr: `fake systemctl: ${action}` };++ }++ }++++ host.socketViolations = () => calls.filter((c) => c.cmd === 'systemctl' && ['stop', 'restart', 'kill', 'try-restart', 'reload-or-restart', 'disable', 'mask'].includes(c.args[0]) && String(c.args[1]).endsWith('.socket'));++ host.restarts = () => calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'restart').map((c) => c.args[1]);++++ const exec = {++ kind: 'fake',++ async run(cmd, args = [], opts = {}) {++ calls.push({ cmd, args, as: opts.as || null, privileged: !!opts.privileged, cwd: opts.cwd || null });++ switch (cmd) {++ case 'git': return gitCmd(args, opts);++ case 'npm': return npmCmd(args, opts);++ case 'systemctl': return systemctlCmd(args);++ case 'nginx': return { stdout: '', ...host.nginxTest() };++ case 'rm': rmTree(args[args.length - 1]); return { code: 0, stdout: '', stderr: '' };++ case 'install': { const src = args[args.length - 2]; const dest = args[args.length - 1]; put(dest, host.read(src)); return { code: 0, stdout: '', stderr: '' }; }++ case 'node': return { code: 0, stdout: '', stderr: '' };++ default: return { code: 127, stdout: '', stderr: `fake host: no ${cmd}` };++ }++ },++ async readFile(p) { host.reads.push(path.resolve(p)); const e = get(p); return e && e.type === 'file' ? (Buffer.isBuffer(e.content) ? e.content.toString('utf8') : String(e.content)) : null; },++ async writeFile(p, content, { mode = 0o644 } = {}) { put(p, content, { mode, owner: user }); },++ async appendFile(p, text) { const prev = host.read(p) || ''; put(p, prev + text, { mode: 0o640, owner: user }); },++ async removeFile(p) { files.delete(path.resolve(p)); },++ async symlink(target, p) { ensureDir(path.dirname(p)); files.set(path.resolve(p), { type: 'symlink', target, owner: user, mode: 0o777 }); },++ async readlink(p) { const e = files.get(path.resolve(p)); return e && e.type === 'symlink' ? e.target : null; },++ async stat(p) {++ const l = files.get(path.resolve(p));++ const e = get(p);++ if (!e) return null;++ const size = e.type === 'file' ? Buffer.byteLength(Buffer.isBuffer(e.content) ? e.content : String(e.content)) : 4096;++ return { mode: e.mode, uid: e.owner === 'root' ? 0 : 1000, gid: 0, owner: e.owner, isFile: e.type === 'file', isDir: e.type === 'dir', isSymlink: !!l && l.type === 'symlink', size };++ },++ async readdir(p) {++ const abs = path.resolve(p);++ const e = files.get(abs);++ if (!e || e.type !== 'dir') return null;++ const out = new Map();++ for (const [k, v] of files) {++ if (!k.startsWith(`${abs}/`)) continue;++ const name = k.slice(abs.length + 1).split('/')[0];++ if (!out.has(name)) out.set(name, { name, isDir: k === `${abs}/${name}` ? v.type === 'dir' : true });++ }++ return [...out.values()];++ },++ async mkdir(p, { owner, mode = 0o750 } = {}) { ensureDir(p, owner || user, mode); const e = files.get(path.resolve(p)); e.owner = owner || e.owner; e.mode = mode; },++ async createExclusive(p, content) { if (files.has(path.resolve(p))) return false; put(p, content, { mode: 0o640, owner: user }); return true; },++ async pidAlive(pid) { return alivePids.has(pid); },++ async http(url, { headers = {} } = {}) {++ calls.push({ cmd: 'curl', args: [url], as: null, privileged: false, cwd: null });++ const h = http.get(url);++ if (!h) return { status: 0, error: 'ECONNREFUSED' };++ const r = h({ headers });++ return { status: r.status, body: typeof r.body === 'string' ? r.body : JSON.stringify(r.body || {}) };++ },++ async sqlite(db, sql, { as } = {}) {++ host.sqliteCalls.push({ op: 'query', db, sql, as });++ calls.push({ cmd: 'sqlite', args: [db, sql], as: as || null, privileged: false, cwd: null });++ return host.sqliteHandler(db, sql, as);++ },++ async sqliteBackup(db, dest, { as } = {}) {++ host.sqliteCalls.push({ op: 'backup', db, dest, as });++ calls.push({ cmd: 'sqlite-backup', args: [db, dest], as: as || null, privileged: false, cwd: null });++ if (files.has(path.resolve(dest))) throw new Error(`refusing to overwrite ${dest}`);++ put(dest, `backup of ${db}`, { owner: as || user, mode: 0o640 });++ },++ async listeners(port) { return listeners.get(port) || []; },++ async sleep(ms) { clock += ms; },++ now: () => clock,++ async isRoot() { return root; },++ async userName() { return user; },++ async hostname() { return hostname; },++ };++ host.exec = exec;++ host.advance = (ms) => { clock += ms; };++ return host;++}++++module.exports = { createFakeHost };nnew file mode 100644@@@ -0,0 +1,7 @@++"use strict";++// Throwaway self-signed certificates (public certificates only; their keys were discarded when they++// were made). SHORT expires 10 days after it was issued, LONG 400 days.++module.exports = {++ SHORT: "-----BEGIN CERTIFICATE-----\nMIIB5jCCAYugAwIBAgIUT8gniQdJ7HbNXh1X7TsOraeYBX4wCgYIKoZIzj0EAwIw\nMTEWMBQGA1UECgwNT3BlblZpYmUgVGVzdDEXMBUGA1UEAwwOdGVzdDEwLmV4YW1w\nbGUwHhcNMjYwOTIzMDEzMDU4WhcNMjYxMDAzMDEzMDU4WjAxMRYwFAYDVQQKDA1P\ncGVuVmliZSBUZXN0MRcwFQYDVQQDDA50ZXN0MTAuZXhhbXBsZTBZMBMGByqGSM49\nAgEGCCqGSM49AwEHA0IABCdLY6FAxxs9PyH8a+Pr5+Q48IO+vFBJX9ESe7dfUtLe\n3acFWK/gKcj9jbE3qwakhKN3M1G0gUQ77ykQN79li7SjgYAwfjAdBgNVHQ4EFgQU\n42CHJRMjCZNVtEJm67p1won9RF4wHwYDVR0jBBgwFoAU42CHJRMjCZNVtEJm67p1\nwon9RF4wDwYDVR0TAQH/BAUwAwEB/zArBgNVHREEJDAigg50ZXN0MTAuZXhhbXBs\nZYIQKi50ZXN0MTAuZXhhbXBsZTAKBggqhkjOPQQDAgNJADBGAiEAg0c6SXxBgoFa\nzCpqEDWQOj08+yL0FFW/5bx9NIeUG10CIQDzQ+8WhGtTF7wRQQn4fvD3DcTS3SxS\nHchtVwk26B3x+A==\n-----END CERTIFICATE-----\n",++ LONG: "-----BEGIN CERTIFICATE-----\nMIIB6TCCAZCgAwIBAgIUJ6CIYdSvjumEcJPhbaXUYegBYt8wCgYIKoZIzj0EAwIw\nMjEWMBQGA1UECgwNT3BlblZpYmUgVGVzdDEYMBYGA1UEAwwPdGVzdDQwMC5leGFt\ncGxlMB4XDTI2MDkyMzAxMzA1OVoXDTI3MTAyODAxMzA1OVowMjEWMBQGA1UECgwN\nT3BlblZpYmUgVGVzdDEYMBYGA1UEAwwPdGVzdDQwMC5leGFtcGxlMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAEpF4N+SjqbGgfP+EiykCQDH93skOyEj52vMQgkMZI\ntO5KXst032+eGl5/3vIQr9QdLtq1x5Lwj2IZ7SbSGY6DAKOBgzCBgDAdBgNVHQ4E\nFgQUID4B2hpbv32WVR+h283zQwykH/owHwYDVR0jBBgwFoAUID4B2hpbv32WVR+h\n283zQwykH/owDwYDVR0TAQH/BAUwAwEB/zAtBgNVHREEJjAkgg90ZXN0NDAwLmV4\nYW1wbGWCESoudGVzdDQwMC5leGFtcGxlMAoGCCqGSM49BAMCA0cAMEQCIAI1LWYt\nQbxsN+sDuI7wHvof60oMaoOkUFvo1soZkyjyAiBGh1TGc0WUHkSjT16YaiQKeXtX\ndqY9FrChgdrzLznLdQ==\n-----END CERTIFICATE-----\n",++};nnew file mode 100644@@@ -0,0 +1,208 @@++'use strict';++/**++ * A standard fake host with the shapes of the real estate: Live (socket-activated, /api/streams),++ * Media (recordings counted in SQLite), Tools (several packages and units), Sites (static build +++ * nginx vhosts, no units) and Events (SSE, drain policy "report").++ */++const path = require('path');++const { createFakeHost } = require('./fake-host');++const { main } = require('../lib/cli');++const { normalise } = require('../lib/inventory');++++const SECRET = 'sk_live_THIS_VALUE_MUST_NEVER_APPEAR_4f9a2c';++++function pkgJson(name, deps) {++ return JSON.stringify({ name, version: '1.0.0', dependencies: Object.fromEntries(deps.map((d) => [d, '^1.0.0'])) }, null, 2);++}++++function inventoryDoc() {++ return {++ host: 'fake-host',++ stateDir: '/var/lib/openvibe-host',++ backupDir: '/var/backups/openvibe',++ defaults: { owner: 'ubuntu', branch: 'main' },++ services: {++ live: {++ repo: '/opt/openvibe.live',++ units: ['openvibe-live.service'],++ socketUnit: 'openvibe-live.socket',++ unitSources: { 'openvibe-live.service': 'deploy/systemd/openvibe-live.service' },++ envFile: '/etc/openvibe/live.env',++ env: { required: ['JWT_SECRET', 'OV_OAUTH_CLIENT_SECRET', 'BASE_URL', 'PAYPAL_CLIENT_SECRET'] },++ port: 3000,++ ready: { url: 'http://127.0.0.1:3000/api/ready', timeoutSeconds: 30 },++ noRestartPaths: ['public/', 'docs/', '*.md'],++ protected: { kind: 'http-json-count', url: 'http://127.0.0.1:3000/api/streams', array: 'streams', where: { is_live: true }, label: 'live streams' },++ drain: { policy: 'refuse', waitMaxSeconds: 600, pollSeconds: 60, quietChecks: 2 },++ databases: [{ name: 'live', path: '/opt/openvibe.live/data/live.db' }],++ backupOnChange: ['server/db/schema.sql'],++ nginx: { vhost: 'openvibe.live.conf', variant: 'websocket', wsPaths: ['/ws/'] },++ },++ media: {++ repo: '/opt/openvibe.media',++ units: ['openvibe-media.service'],++ envFile: '/etc/openvibe/media.env',++ env: { required: 'from-example' },++ port: 4100,++ ready: { url: 'http://127.0.0.1:4100/healthz', timeoutSeconds: 30 },++ protected: { kind: 'sqlite-count', db: '/opt/openvibe.media/data/media.db', sql: 'SELECT count(*) AS n FROM vods WHERE is_recording = 1', label: 'recordings in progress' },++ databases: [{ name: 'media', path: '/opt/openvibe.media/data/media.db' }],++ },++ tools: {++ repo: '/opt/openvibe.tools',++ packages: ['apps/*'],++ units: ['openvibe-tools.service', 'openvibe-tools-maps.service'],++ port: 4001,++ ready: { url: 'http://127.0.0.1:4001/api/health', headers: { Host: 'openvibe.tools' }, timeoutSeconds: 30 },++ },++ sites: {++ repo: '/opt/openvibe.sites',++ units: [],++ install: { command: ['npm', 'ci', '--omit=dev', '--no-audit', '--no-fund'], always: true },++ build: [['node', 'build.js']],++ nginx: { repoVhosts: 'deploy/nginx/*.conf', installOnDeploy: true },++ },++ events: {++ repo: '/opt/openvibe.events',++ units: ['openvibe-events.service'],++ port: 4300,++ ready: { url: 'http://127.0.0.1:4300/api/ready', timeoutSeconds: 30 },++ protected: { kind: 'http-json-count', url: 'http://127.0.0.1:4300/api/ready', field: 'realtime_connections', label: 'SSE connections' },++ drain: { policy: 'report' },++ nginx: { variant: 'sse', ssePaths: ['/realtime/stream'] },++ },++ },++ };++}++++function scenario() {++ const host = createFakeHost();++ const doc = inventoryDoc();++ host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });++ const inv = normalise(doc);++ host.inv = inv;++ host.badShas = new Set();++ host.liveStreams = [];++ host.recording = 0;++ host.sseConnections = 0;++ host.unitRepo = {};++++ const repoFor = {};++ function service(id, initialFiles, packages) {++ const svc = inv.services[id];++ const repo = host.createRepo(svc.repo, { owner: 'ubuntu' });++ const sha = repo.commit(initialFiles, { message: 'initial' });++ repo.publish(sha);++ repo.checkout(sha);++ for (const pkgDir of packages) {++ const pkg = JSON.parse(initialFiles[path.join(pkgDir, 'package.json')]);++ for (const dep of Object.keys(pkg.dependencies || {})) host.put(path.join(svc.repo, pkgDir, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep }), { owner: 'ubuntu' });++ }++ for (const u of svc.units) {++ host.addUnit(u, { runningSha: sha });++ host.unitRepo[u] = repo;++ }++ repoFor[id] = repo;++ return repo;++ }++++ service('live', {++ 'package.json': pkgJson('openvibe-live', ['express', 'better-sqlite3', 'openvibe-shared']),++ 'package-lock.json': '{"lockfileVersion":3,"v":1}',++ 'server/index.js': 'console.log(1);',++ 'server/db/schema.sql': 'CREATE TABLE a(x);',++ 'public/app.js': 'app();',++ 'deploy/systemd/openvibe-live.service': '[Service]\nExecStart=node server/index.js\n',++ '.env.example': 'JWT_SECRET=\nBASE_URL=https://openvibe.live\nPAYPAL_CLIENT_SECRET=\nOV_OAUTH_CLIENT_SECRET=\n# OPTIONAL_THING=\nTURN_URL=\n',++ }, ['.']);++ host.addUnit('openvibe-live.socket', { sub: 'listening', partOf: ['openvibe-live.service'], mainPid: 0 });++ host.put('/etc/systemd/system/openvibe-live.service', '[Service]\nExecStart=node server/index.js\nEnvironment=NODE_ENV=production\n');++ host.put('/etc/openvibe/live.env', `JWT_SECRET=${SECRET}\nBASE_URL=https://openvibe.live\nPAYPAL_CLIENT_SECRET="${SECRET}-paypal"\nOV_OAUTH_CLIENT_SECRET=${SECRET}-oauth\n`, { mode: 0o600 });++ host.put('/opt/openvibe.live/data/live.db', 'sqlite', { owner: 'ubuntu' });++++ service('media', {++ 'package.json': pkgJson('openvibe-media', ['express', 'better-sqlite3']),++ 'package-lock.json': '{"lockfileVersion":3,"v":1}',++ 'server/index.js': 'media();',++ '.env.example': 'PORT=4100\nMEDIA_B2_APP_KEY=\nMEDIA_R2_SECRET_ACCESS_KEY=\n',++ }, ['.']);++ host.put('/etc/openvibe/media.env', `PORT=4100\nMEDIA_B2_APP_KEY=${SECRET}-b2\nMEDIA_R2_SECRET_ACCESS_KEY=${SECRET}-r2\n`, { mode: 0o600 });++ host.put('/opt/openvibe.media/data/media.db', 'sqlite', { owner: 'ubuntu' });++++ service('tools', {++ 'apps/gateway/package.json': pkgJson('tools-gateway', ['express', 'openvibe-shared']),++ 'apps/gateway/package-lock.json': '{"v":1}',++ 'apps/gateway/server.js': 'gw();',++ 'apps/maps/package.json': pkgJson('tools-maps', ['express']),++ 'apps/maps/package-lock.json': '{"v":1}',++ 'apps/maps/server.js': 'maps();',++ }, ['apps/gateway', 'apps/maps']);++++ service('sites', {++ 'package.json': pkgJson('openvibe-sites', ['openvibe-shared']),++ 'package-lock.json': '{"v":1}',++ 'build.js': 'build();',++ 'deploy/nginx/openvibe.chat.conf': 'server { listen 443; server_name openvibe.chat; }\n',++ }, ['.']);++++ service('events', {++ 'package.json': pkgJson('openvibe-events', ['express']),++ 'package-lock.json': '{"v":1}',++ 'server/index.js': 'events();',++ }, ['.']);++++ host.ensureDir('/etc/nginx/sites-available');++ host.ensureDir('/etc/nginx/sites-enabled');++++ // A restarted unit runs whatever its checkout holds at that moment.++ host.onRestart = (unit, u) => { if (host.unitRepo[unit]) u.runningSha = host.unitRepo[unit].head; };++ const readyFor = (unit) => () => {++ const u = host.units.get(unit);++ if (!u || u.active !== 'active') return { status: 502 };++ return host.badShas.has(u.runningSha) ? { status: 503, body: { status: 'not_ready' } } : { status: 200, body: { status: 'ready', realtime_connections: host.sseConnections } };++ };++ host.http.set('http://127.0.0.1:3000/api/ready', readyFor('openvibe-live.service'));++ host.http.set('http://127.0.0.1:3000/api/streams', () => ({ status: 200, body: { streams: host.liveStreams } }));++ host.http.set('http://127.0.0.1:4100/healthz', readyFor('openvibe-media.service'));++ host.http.set('http://127.0.0.1:4001/api/health', readyFor('openvibe-tools.service'));++ host.http.set('http://127.0.0.1:4300/api/ready', readyFor('openvibe-events.service'));++ host.sqliteHandler = (db, sql) => (/is_recording/.test(sql) ? [{ n: host.recording }] : [{ n: 0 }]);++++ host.repo = (id) => repoFor[id];++ host.ctx = () => ({ exec: host.exec, inv, log: (s) => host.logs.push(s) });++ host.logs = [];++ host.cli = async (...argv) => {++ const lines = [];++ const code = await main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {} });++ return { code, out: lines.join('\n') };++ };++ return host;++}++++/** Publish a new commit on origin/main for a service. */++function push(host, id, changes, message = 'change') {++ const repo = host.repo(id);++ const sha = repo.commit(changes, { message });++ repo.publish(sha);++ return sha;++}++++function test(name, fn) {++ return { name, fn };++}++++async function runTests(tests) {++ let failed = 0;++ for (const t of tests) {++ try {++ await t.fn();++ console.log(` ✓ ${t.name}`);++ } catch (err) {++ failed += 1;++ console.log(` ✗ ${t.name}\n${err.stack}`);++ }++ }++ if (failed) { console.log(`${failed} failed`); process.exit(1); }++}++++module.exports = { scenario, push, test, runTests, SECRET, pkgJson };nnew file mode 100644@@@ -0,0 +1,56 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails if any++ * of them fails. Every test drives ovhost against the in-memory fake host (test/fake-host.js):++ * no systemctl, git, npm, nginx, curl or SQLite call reaches the real machine.++ *++ * npm test # everything++ * npm test -- deploy certs # only files whose name contains one of the words++ */++'use strict';++const { spawn } = require('child_process');++const fs = require('fs');++const path = require('path');++++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));++const files = fs.readdirSync(__dirname)++ .filter((f) => f.endsWith('.test.js'))++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))++ .sort();++const TIMEOUT_MS = 60000;++++function runOne(file) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [path.join(__dirname, file)], {++ cwd: path.join(__dirname, '..'),++ env: { ...process.env, NODE_ENV: 'test' },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);++ child.on('close', (code, signal) => {++ clearTimeout(timer);++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });++ });++ });++}++++(async () => {++ if (!files.length) { console.error('no test files matched'); process.exit(1); }++ const results = [];++ for (const f of files) {++ const r = await runOne(f);++ results.push(r);++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);++ }++ const failed = results.filter((r) => !r.ok);++ for (const r of failed) {++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);++ console.log(r.output.split('\n').slice(-60).join('\n'));++ }++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);++ process.exit(failed.length ? 1 : 0);++})();nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++const assert = require('assert');++const { scenario, test, runTests, SECRET } = require('./helpers');++const { readTarGz } = require('../lib/tar');++++runTests([++ test('snapshot: units, vhost, env NAMES and sha in a tarball with no secret values', async () => {++ const host = scenario();++ host.put('/etc/systemd/system/openvibe-live.service', `[Service]\nEnvironment=NODE_ENV=production "ADMIN_TOKEN=${SECRET}" PORT=3000\nEnvironmentFile=/etc/openvibe/live.env\n`);++ host.units.get('openvibe-live.service').dropIns = ['/etc/systemd/system/openvibe-live.service.d/socket.conf'];++ host.put('/etc/systemd/system/openvibe-live.service.d/socket.conf', '[Unit]\nRequires=openvibe-live.socket\n');++ host.put('/etc/systemd/system/openvibe-live.socket', '[Socket]\nListenStream=0.0.0.0:3000\n');++ host.put('/etc/nginx/sites-available/openvibe.live.conf', 'server { server_name openvibe.live; }\n');++ const r = await host.cli('snapshot', 'live', '--json');++ assert.strictEqual(r.code, 0, r.out);++ const res = JSON.parse(r.out);++ assert.match(res.file, /^\/var\/lib\/openvibe-host\/snapshots\/live-\d{8}-\d{6}-[0-9a-f]{12}\.tar\.gz$/);++ const entry = host.files.get(res.file);++ assert.strictEqual(entry.mode, 0o640);++ const tar = readTarGz(entry.content);++ const names = tar.map((e) => e.name);++ assert.deepStrictEqual(names, ['snapshot.json', 'env/live.env.names', 'units/openvibe-live.service', 'units/openvibe-live.service.d/socket.conf', 'units/openvibe-live.socket', 'nginx/openvibe.live.conf']);++ const all = tar.map((e) => e.content).join('\n');++ assert.ok(!all.includes(SECRET), 'a secret value leaked into the snapshot');++ assert.ok(!all.includes('ghp_FAKE_TOKEN_VALUE'), 'remote URL credentials leaked');++ const unit = tar.find((e) => e.name === 'units/openvibe-live.service').content;++ assert.match(unit, /ADMIN_TOKEN=<redacted>/);++ assert.match(unit, /NODE_ENV=production/);++ assert.strictEqual(tar.find((e) => e.name === 'env/live.env.names').content, 'JWT_SECRET\nBASE_URL\nPAYPAL_CLIENT_SECRET\nOV_OAUTH_CLIENT_SECRET\n');++ const meta = JSON.parse(tar[0].content);++ assert.strictEqual(meta.sha, host.repo('live').head);++ assert.strictEqual(meta.remote, 'https://github.com/OpenVibers/openvibe.live.git');++ assert.strictEqual(meta.manifest.id, 'live');++ assert.deepStrictEqual(meta.env.names, ['JWT_SECRET', 'BASE_URL', 'PAYPAL_CLIENT_SECRET', 'OV_OAUTH_CLIENT_SECRET']);++ assert.ok(meta.lockfiles['.']);++ }),++++ test('backup: sqlite .backup of each declared database, run as the service user, into a dated directory', async () => {++ const host = scenario();++ const r = await host.cli('backup', 'media', '--json');++ assert.strictEqual(r.code, 0, r.out);++ const res = JSON.parse(r.out);++ assert.match(res.dir, /^\/var\/backups\/openvibe\/media\/20260922-120000$/);++ assert.strictEqual(res.files[0].dest, `${res.dir}/media.db`);++ const call = host.sqliteCalls.find((c) => c.op === 'backup');++ assert.strictEqual(call.as, 'ubuntu');++ assert.strictEqual(call.db, '/opt/openvibe.media/data/media.db');++ assert.strictEqual(host.files.get(res.dir).owner, 'ubuntu', 'backup dir owned by the service user');++ const log = host.read('/var/lib/openvibe-host/backups/media.jsonl');++ assert.strictEqual(JSON.parse(log.trim()).files[0].dest, res.files[0].dest);++ // A second backup in the same second never overwrites the first.++ const again = await host.cli('backup', 'media');++ assert.notStrictEqual(again.code, 0);++ }),++++ test('backup refuses a service without declared databases', async () => {++ const host = scenario();++ const r = await host.cli('backup', 'tools');++ assert.strictEqual(r.code, 2);++ assert.match(r.out, /declares no databases/);++ }),++]);nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++const assert = require('assert');++const { scenario, test, runTests, SECRET } = require('./helpers');++const envfile = require('../lib/envfile');++++function noSecrets(text) {++ assert.ok(!text.includes(SECRET), 'output contains a secret value');++ assert.ok(!text.includes('4f9a2c'), 'output contains part of a secret value');++}++++runTests([++ test('env validation reports names only — never a value — in text and JSON output', async () => {++ const host = scenario();++ host.put('/etc/openvibe/live.env', `JWT_SECRET=${SECRET}\nBASE_URL=\nPAYPAL_CLIENT_SECRET=""\n`, { mode: 0o644 });++ for (const args of [['validate', 'live'], ['validate', 'live', '--json']]) {++ const r = await host.cli(...args);++ assert.strictEqual(r.code, 2, r.out);++ noSecrets(r.out);++ assert.match(r.out, /missing: OV_OAUTH_CLIENT_SECRET/);++ assert.match(r.out, /empty: BASE_URL, PAYPAL_CLIENT_SECRET/);++ assert.match(r.out, /world-readable \(mode 644\)/);++ }++ }),++++ test('a correct env file passes; names from the unit Environment= lines count as present', async () => {++ const host = scenario();++ host.inv.services.live.env.required.push('NODE_ENV');++ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));++ raw.services.live.env.required.push('NODE_ENV');++ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });++ host.listeners.set(3000, [{ pid: 1, process: 'systemd' }, { pid: host.units.get('openvibe-live.service').mainPid, process: 'node' }]);++ host.put('/etc/nginx/sites-available/openvibe.live.conf', 'server {}');++ await host.exec.symlink('/etc/nginx/sites-available/openvibe.live.conf', '/etc/nginx/sites-enabled/openvibe.live.conf');++ host.put('/etc/systemd/system/openvibe-live.service', host.repo('live').commits.get(host.repo('live').head).files['deploy/systemd/openvibe-live.service'].replace('\n', '\nEnvironment=NODE_ENV=production\n'));++ const r = await host.cli('validate', 'live', '--json');++ const res = JSON.parse(r.out);++ const errors = res.findings.filter((f) => f.level === 'error');++ assert.deepStrictEqual(errors, []);++ assert.strictEqual(r.code, 0);++ assert.ok(res.findings.some((f) => f.area === 'env' && /5 required name\(s\) present/.test(f.message)));++ assert.ok(res.findings.some((f) => f.area === 'port' && /held by systemd \(openvibe-live\.socket\), node/.test(f.message)));++ assert.ok(res.findings.some((f) => f.level === 'warn' && /PartOf=openvibe-live\.service/.test(f.message)), 'PartOf propagation is pointed out');++ assert.ok(res.findings.some((f) => f.level === 'info' && /TURN_URL/.test(f.message)), 'optional names from .env.example listed');++ noSecrets(r.out);++ }),++++ test('required: "from-example" takes every name the checkout .env.example declares', async () => {++ const host = scenario();++ host.put('/etc/openvibe/media.env', `PORT=4100\nMEDIA_B2_APP_KEY=${SECRET}\n`, { mode: 0o600 });++ const r = await host.cli('validate', 'media');++ assert.match(r.out, /missing: MEDIA_R2_SECRET_ACCESS_KEY/);++ noSecrets(r.out);++ }),++++ test('flags a missing unit, a port held by someone else, a missing vhost and a failing nginx -t', async () => {++ const host = scenario();++ host.units.delete('openvibe-live.service');++ host.listeners.set(3000, [{ pid: 999, process: 'python3' }]);++ host.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] unknown directive "proxy_passs"' });++ const r = await host.cli('validate', 'live');++ assert.strictEqual(r.code, 2);++ assert.match(r.out, /openvibe-live\.service is not loaded/);++ assert.match(r.out, /3000 is held by python3\(999\)/);++ assert.match(r.out, /sites-available\/openvibe\.live\.conf missing/);++ assert.match(r.out, /nginx -t failed: .*proxy_passs/);++ }),++++ test('checkout owner, branch and dependency resolution are checked', async () => {++ const host = scenario();++ host.files.get('/opt/openvibe.events').owner = 'root';++ host.files.delete('/opt/openvibe.events/node_modules/express/package.json');++ const r = await host.cli('validate', 'events');++ assert.match(r.out, /owned by root, expected ubuntu/);++ assert.match(r.out, /express — node_modules\/express\/package\.json is missing/);++ }),++++ test('the env parser keeps no values', () => {++ const parsed = envfile.parseNames(`A=${SECRET}\nexport B="x y"\nC=\nD=''\n# E=1\nF= # comment\n`);++ assert.deepStrictEqual(parsed, [++ { name: 'A', empty: false }, { name: 'B', empty: false }, { name: 'C', empty: true }, { name: 'D', empty: true }, { name: 'F', empty: true },++ ]);++ assert.ok(!JSON.stringify(parsed).includes(SECRET));++ assert.deepStrictEqual(envfile.parseExample('A=1\n# B=\nC=\n').declared, ['A', 'C']);++ assert.deepStrictEqual(envfile.unitEnvironmentNames('Environment=A=1 "B=two words"\nEnvironment=C=3\n'), ['A', 'B', 'C']);++ }),++++ test('env-names lists .env.example names', async () => {++ const host = scenario();++ const r = await host.cli('env-names', 'live');++ assert.strictEqual(r.code, 0);++ assert.match(r.out, /JWT_SECRET/);++ assert.match(r.out, /commented out: OPTIONAL_THING/);++ }),++]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # production runs Node 22
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ - name: syntax check
+ run: for f in bin/ovhost lib/*.js lib/commands/*.js; do node --check "$f"; done
+ - name: contract check (openvibe-contracts)
+ # The published host manifest (contracts v0.7.0) is still the charter placeholder with range
+ # ^0.1.0, so this step fails until docs/service-manifest-proposal.json is released in
+ # OpenVibe.Contracts. Remove continue-on-error in the same change that bumps the dependency.
+ continue-on-error: true
+ run: npx openvibe-contracts-check --service host --src lib
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+# The real inventory lives on the host (/etc/openvibe/host.json) and is never committed.
+host.json
+*.tar.gz
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
+.env
+.env.*
@@ -2,56 +2,136 @@
@@ -2,56 +2,136 @@
> The network's deployment/control plane first; then isolated hosting for community sites, bots and mods.
> The network's deployment/control plane first; then isolated hosting for community sites, bots and mods.
-**Status:** placeholder — planning only, no runnable code yet.
-**Domain:** `openvibe.host`
-**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3.
+**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It has **not** been installed on the production host yet, and no service's deploy has moved to it. Stages B and C are **not started**.
+**Domain:** `openvibe.host`. It keeps its placeholder page on [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites): a CLI for operators is not a public product.
+**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3 and §15.17; roadmap Wave 21.
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
## Purpose
## Purpose
-Stage A consolidates the deploy/TLS/nginx/readiness/drain/rollback helpers duplicated across Live, Network, Media, Tools and Sites behind stable adapters. Stage B adds tenant static hosting on Media object storage. Stage C hosts sandboxed user code only after isolation and metering are proven.
+Every repository ships its own deploy script, and each one encodes rules learned from an outage. Stage A pulls those rules into one tool that works the same way for every service on the host:
+
+| Rule | Where it came from | How `ovhost` applies it |
+|---|---|---|
+| Never stop `openvibe-live.socket`; restart only the service unit | Live (socket activation, `deploy/systemd`) | Every `systemctl` call goes through a guard that refuses `stop`/`restart`/`reload` on a `.socket` unit. A socket that is down gets `start`ed. |
+| Don't restart Live while anyone is live; `--wait-idle` holds the restart | Live `deploy.sh --wait-idle` (`/api/streams`) | Protected-session probe (`http-json-count`). Checked before the checkout moves and again right before the restart. |
+| Don't restart Media while it is recording | Media hazard H3 (`vods-orphans/` in B2) | Protected-session probe (`sqlite-count`: `SELECT count(*) FROM vods WHERE is_recording = 1`), run read-only **as the service user**. |
+| Poll `/api/ready` after a restart; roll back if it doesn't come up | Live `deploy.sh` | Readiness poll with a timeout; on failure the checkout goes back to the previous sha, deps are reinstalled if they differed, the unit is restarted again. |
+| Install only when the lockfile or dependencies changed; restore `package-lock.json` afterwards | Network `deploy.sh` and host practice | `npm install --omit=dev` as the checkout owner, then `git checkout -- package-lock.json` if npm rewrote it. |
+| Every dependency must resolve before anything restarts | Tools `deploy.sh` (an emptied `file:` link crash-looped every unit) | `node_modules/<dep>/package.json` must exist, parse and name the package. A broken dependency is reinstalled once, and if it still fails the deploy aborts and the checkout is restored. |
+| Build, install vhosts, `nginx -t`, reload | Sites `deploy.sh` | `build` hooks and `nginx.installOnDeploy`. Vhost installs are transactional: if `nginx -t` fails, the previous files come back and nginx is not reloaded. |
+| git as the checkout owner; never leave root-owned files under `/opt` | Community/Events/Tools checkouts | git, npm, builds, SQLite queries and backups all run as the declared owner/service user (`runuser` from root, `sudo -u` otherwise). |
+
+Stage A adds a few safety rules of its own:
+
+- A protected-session probe that **cannot answer** while the service is running counts as "sessions may be active", never as zero. Live's `deploy.sh` treated a failed `curl` as 0 live streams.
+- Tracked local changes block a deploy. The script never discards them.
+- One operation per service at a time, using a pid lock with stale-lock recovery.
+- Every attempt goes into the release log, refusals and failures included.
## Owns
## Owns
-- service manifests and environment validation, release install/rollback, systemd/container adapters, reverse-proxy generation, certificate inventory/renewal, DNS adapters, readiness/drain orchestration, config snapshots, backup/restore hooks, incident controls
-- later: tenant projects, immutable deploy artifacts, domains/TLS, build logs, quotas; sandbox profiles, budgets, secret references, outbound policy
+- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, SQLite backup hooks.
+- Later: tenant projects, immutable deploy artifacts, domains/TLS, build logs, quotas (Stage B); sandbox profiles, budgets, secret references, outbound policy (Stage C).
## Does not own
## Does not own
-- product business logic (Host calls product deploy hooks)
-- platform-wide credentials for hosted code (never)
+- Product business logic. Host calls product hooks (`build`) and reads product endpoints (`/api/ready`, `/api/streams`). It never embeds product rules.
+- Secrets. `ovhost` reads env files only to learn variable **names** and whether each one is empty. It never stores, prints or snapshots a value.
+- Platform-wide credentials for hosted code (never).
+- Certificate issuance/renewal (certbot does this today). Stage A only takes inventory.
+
+## Depends on
-## Planned surfaces
+- OpenVibe.Contracts (`openvibe-contracts` v0.7.0): service manifests, for vhost rendering and snapshots.
+- OpenVibe.Network, OpenVibe.Media, OpenVibe.Events: Stage B/C only.
-- operator CLI/API; hosting dashboard for tenants (Stage B+)
+## Stages
-## Data (authority tables / families)
+| Stage | Scope | State |
+|---|---|---|
+| **A: operator plane** | `ovhost` CLI and library: inventory, validate, plan, deploy (`--wait-idle`/`--force`), automatic rollback, rollback, status, releases, certs, nginx render/install, snapshot, backup. No daemon and no server. Port **4910** is reserved for a later operator API. | **alpha**: written and tested (fake host, plus the real executor against temp SQLite/HTTP/git). Not installed on the host. |
+| **B: tenant static hosting** | Immutable deploy artifacts stored through Media, domains and TLS, quotas, build logs, rollback. | **not started** |
+| **C: sandboxed user code** | Isolation profiles, CPU/memory/time/network/storage budgets, secret references, outbound policy, metering, kill/revoke without touching platform services. | **not started**. Blocked until isolation and metering are proven. |
-- releases, configs, certificates, tenants, deployments, logs/metrics links
+Not in Stage A yet (from the charter/roadmap list): container adapters, DNS adapters, certificate **renewal**, logs/metrics links, incident/maintenance controls, the release-manifest/active-client work in §15.18, `host.release.deployed|rolled_back` events (they need an Events outbox and a service principal), and the restore drills and cutover runbook that Wave 22 will add.
-## Capabilities and events
+## Acceptance
-- `host.release.*`, `host.config.*`, `host.tenant.*`, `host.deployment.*`
+What the tests demonstrate (`npm test`, every system call made against `test/fake-host.js`):
-Events: ``host.release.deployed|rolled_back``, ``host.deployment.*``
+- A Live restart is **refused** while `/api/streams` reports a live stream. The checkout does not move, nothing is restarted, and the refusal is logged. `--wait-idle` waits for two idle checks in a row. `--force` proceeds and prints a warning. An unreachable probe counts as live.
+- A Media restart is **refused** while `vods.is_recording = 1`. The query runs read-only as `ubuntu`.
+- A failed readiness check **rolls back** to the previous sha, reinstalls the previous dependencies and restarts again (exit 3). If that also fails, the exit code is 4 with MANUAL INTERVENTION.
+- A dependency that does not resolve **aborts before any restart** and restores the checkout (exit 2).
+- Env validation **never outputs a value**, in text or JSON output.
+- The socket unit is **never stopped or restarted**, in any flow.
+- Only the service being deployed is restarted. Static-only changes (for example Live `public/`) are deployed without a restart, even while streams are live.
+- `nginx -t` failures restore the previous vhost state. `certs` never reads a key file. Snapshots contain no secret values or remote-URL credentials.
-## Depends on
+Not demonstrated yet: any of this on the production host. The Wave 21 exit criterion ("deploy, restart and roll back one service without interrupting unrelated runtimes or protected sessions") still needs a run on the host, with evidence.
+
+## Using ovhost
+
+```
+ovhost status [<service>...] unit state, sha, readiness, protected sessions
+ovhost validate <service> env NAMES, unit files, port, vhost + nginx -t, deps
+ovhost env-names <service> names declared in the checkout's .env.example
+ovhost plan <service> [--to <sha>] [--no-fetch]
+ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
+ovhost rollback <service> [--to <sha>] [--wait-idle] [--force]
+ovhost releases <service> [--limit <n>]
+ovhost certs [--warn-days <n>]
+ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
+ovhost snapshot <service> [--out <file>]
+ovhost backup <service>
+```
+
+Every command accepts `--json` and `--inventory <file>`. Exit codes: `0` ok · `1` usage/precondition (including a held lock) · `2` validation failed, nothing restarted · `3` not ready, rolled back and serving · `4` rollback failed, **manual intervention** · `5` protected sessions active (refused, or `--wait-idle` gave up).
+
+### Deploy sequence
+
+1. Take the per-service lock. Refuse if the checkout uses Live's `releases/current` layout (Stage A only drives in-place checkouts, which is what every service runs today).
+2. `git fetch` as the owner, then work out the from/to shas, changed files, per-package install need and whether a restart is needed (`noRestartPaths`).
+3. Refuse a tracked local change or a wrong branch.
+4. **Protected sessions** (only if a restart is needed): refuse, wait (`--wait-idle` or drain policy `wait`), report (drain policy `report`, Events SSE), or `--force`.
+5. `git merge --ff-only` as the owner (rollback uses `git reset --hard <sha>`).
+6. Install dependencies where needed and restore the lockfile. **Verify every dependency resolves**, reinstalling a broken one once. Run the build hooks. Install repo vhosts (Sites). Install unit files if `--install-units` was given. Back up databases if a `backupOnChange` file changed.
+7. Protected sessions again, immediately before the restart. If this or anything in step 6 fails, the checkout and node_modules are restored and nothing is restarted.
+8. Start the socket if it is down, restart the service units, poll readiness.
+9. If readiness fails, run the automatic rollback (restore, reinstall, restart, poll).
+10. Append the release record to `<stateDir>/releases/<service>.jsonl`.
+
+### Inventory
+
+`host.example.json` describes every service on the host. It records the checkout, owner/`runAs`, units and socket unit, `unitSources` (repo unit files), env file, `env.required` (names, or `"from-example"`), port, loopback `ready` URL (plus headers, since Tools needs `Host: openvibe.tools`), `packages` (`"apps/*"` for Tools), install command, `build` hooks, `noRestartPaths`, the `protected` probe, the `drain` policy, `databases`, `backupOnChange` and `nginx`. The real file is `/etc/openvibe/host.json` and is **never committed**. When `ovhost` runs as root it refuses an inventory that is not root-owned or that anyone else can write, and it rejects relative paths, non-loopback probe URLs, non-SELECT probe queries and vhost names that contain a path.
+
+`games` is listed as `managed: false`. It is a pnpm workspace with a TypeScript build, and those steps are not encoded yet. `status`, `validate`, `snapshot` and `backup` work for it; `deploy` and `rollback` refuse it.
+
+## Installing on the host (for the operator)
-- OpenVibe.Network
-- OpenVibe.Media
-- OpenVibe.Events
-- OpenVibe.Contracts
+Nothing here has been done yet. These are the steps:
-## Acceptance (must be true before "done")
+1. **Code** is root-owned and outside `/opt`, so it never mixes with the ubuntu-owned service checkouts:
+ ```
+ sudo git clone https://github.com/OpenVibers/OpenVibe.Host /usr/local/lib/openvibe-host
+ cd /usr/local/lib/openvibe-host && sudo npm ci --omit=dev --no-audit --no-fund
+ sudo ln -s /usr/local/lib/openvibe-host/bin/ovhost /usr/local/bin/ovhost
+ ```
+ The tree must stay world-readable (default 0755/0644). The SQLite worker runs as `ubuntu` from this directory.
+2. **Inventory:** `sudo install -o root -g root -m 0640 host.example.json /etc/openvibe/host.json`, then correct it against the machine. Check the Community database path, the Tools unit list, and Events, which was not deployed as of 22 Sep.
+3. **State:** `/var/lib/openvibe-host` (root, 0750) holds the release logs, locks and snapshots. `/var/backups/openvibe/<service>` is created per service, owned by the service user, so the backup worker can write there. Both directories are created on first use.
+4. **Run as root:** `sudo ovhost …`. `ovhost` drops to `ubuntu` for git/npm/SQLite (`runuser`) and needs root for `systemctl`, `nginx -t`, reading `/etc/openvibe/*.env` (names only) and writing vhosts. For least privilege, give operator accounts a sudoers rule for `/usr/local/bin/ovhost` alone instead of a general `ALL`. Do **not** grant it to the `ubuntu` service user if that account has no sudo today.
+5. **First run:** `sudo ovhost status`, then `sudo ovhost validate <each service>`, then `sudo ovhost plan live`, all read-only apart from `git fetch`. Adopt `deploy` one service at a time, starting with one that has no protected sessions.
-- deploy/restart/rollback one service without interrupting unrelated runtimes or protected sessions
-- certificate renewal cannot expose provider credentials
-- a hosted static project cannot read another tenant's objects
-- killing/revoking one extension does not affect Chat/OpenRe/Live
+## Development
-## Bootstrap / extraction source
+```
+npm test # every test, each file in its own process
+node test/deploy.test.js # one file
+```
-The deploy scripts in each current repository (`deploy/scripts/deploy.sh` variants), Network's TLS/nginx helpers and Sites' vhost generator.
+Everything that touches the system goes through the executor (`lib/executor.js`). The tests replace it with `test/fake-host.js`, an in-memory host with git repos, systemd units, npm, nginx, ss, HTTP endpoints and SQLite. No test runs systemctl, git, npm, nginx or curl against the real machine. `test/executor.test.js` covers the real executor only where that is safe: SQLite on a temp database, HTTP to a local server, and temp files.
## Launch rule
## Launch rule
@@ -1,7 +1,22 @@
@@ -1,7 +1,22 @@
{
{
"repository": "OpenVibers/OpenVibe.Host",
"repository": "OpenVibers/OpenVibe.Host",
- "stage": "placeholder",
- "code": false,
+ "stage": "alpha",
+ "code": true,
+ "wave": "W21 Stage A",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-21"
+ "runtime": "node 22, CLI only (no server)",
+ "port": null,
+ "portReserved": 4910,
+ "domain": "openvibe.host",
+ "contracts": "openvibe-contracts v0.7.0",
+ "capabilities": [],
+ "capabilitiesRegistered": false,
+ "stages": {
+ "A": "alpha: ovhost CLI written and tested against a fake host; not installed on the production host",
+ "B": "not started",
+ "C": "not started"
+ },
+ "installedOnHost": false,
+ "adoptedByServices": [],
+ "updated": "2026-09-22"
}
}
new file mode 100755
new file mode 100755
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+#!/usr/bin/env node
+'use strict';
+require('../lib/cli').main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (err) => {
+ process.stderr.write(`[ovhost] ${err.stack || err.message}\n`);
+ process.exitCode = 2;
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "version": "0.1.0",
+ "contractRanges": {
+ "openvibe-contracts": ">=0.7.0 <1.0.0"
+ },
+ "id": "host",
+ "name": "OpenVibe.Host",
+ "status": "alpha",
+ "repository": "OpenVibers/OpenVibe.Host",
+ "domains": [],
+ "capabilities": [],
+ "eventsProduced": [],
+ "eventsConsumed": [],
+ "namespacesOwned": [
+ "host.*"
+ ],
+ "notes": "Stage A operator plane only: the ovhost CLI, run by an operator on the host (validate, plan, deploy with protected-session guards, automatic rollback, status, certificate inventory, vhost rendering, config snapshots, SQLite backups). No server and no public API yet; port 4910 is reserved for the later operator API. Stages B (tenant static hosting) and C (sandboxed user code) are not started."
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+{
+ "_about": "Example ovhost inventory for the single OpenVibe host (openvibe-oregon). Copy to /etc/openvibe/host.json (root:root, 0640) and correct anything that differs on the machine; the real file is never committed. Paths, units, ports and env file locations come from OpenVibe.Network docs/roadmap-baseline/data/services.json and prod-snapshot.json (22 Sep 2026) and each repository's deploy/ directory. env.required lists NAMES only.",
+ "host": "openvibe-oregon",
+ "stateDir": "/var/lib/openvibe-host",
+ "backupDir": "/var/backups/openvibe",
+ "nginx": {
+ "sitesAvailable": "/etc/nginx/sites-available",
+ "sitesEnabled": "/etc/nginx/sites-enabled",
+ "bin": "nginx"
+ },
+ "defaults": {
+ "owner": "ubuntu",
+ "runAs": "ubuntu",
+ "remote": "origin",
+ "branch": "main"
+ },
+ "services": {
+ "network": {
+ "repo": "/opt/openvibe.network",
+ "units": ["openvibe-network.service"],
+ "unitSources": { "openvibe-network.service": "deploy/systemd/openvibe-network.service" },
+ "envFile": "/etc/openvibe/network.env",
+ "env": { "required": ["BASE_URL", "JWT_PRIVATE_KEY", "JWT_PUBLIC_KEY", "DB_PATH"] },
+ "port": 4000,
+ "ready": { "url": "http://127.0.0.1:4000/api/health", "timeoutSeconds": 30 },
+ "noRestartPaths": ["docs/", "test/", "**/*.md"],
+ "databases": [{ "name": "network", "path": "/opt/openvibe.network/data/network.db" }],
+ "nginx": { "vhost": "openvibe.network.conf", "repoVhost": "deploy/nginx/openvibe.network.conf", "variant": "http" }
+ },
+ "live": {
+ "repo": "/opt/openvibe.live",
+ "units": ["openvibe-live.service"],
+ "socketUnit": "openvibe-live.socket",
+ "unitSources": {
+ "openvibe-live.service": "deploy/systemd/openvibe-live.service",
+ "openvibe-live.socket": "deploy/systemd/openvibe-live.socket",
+ "openvibe-live.service.d/socket.conf": "deploy/systemd/openvibe-live.service.d/socket.conf"
+ },
+ "envFile": "/etc/openvibe/live.env",
+ "env": { "required": ["BASE_URL", "JWT_SECRET", "OV_NETWORK_URL", "OV_OAUTH_CLIENT_ID", "OV_OAUTH_CLIENT_SECRET", "MEDIA_URL", "MEDIA_API_KEY", "MEDIA_WEBHOOK_SECRET"] },
+ "port": 3000,
+ "ready": { "url": "http://127.0.0.1:3000/api/ready", "timeoutSeconds": 90 },
+ "noRestartPaths": ["public/", "docs/", "test/", "scripts/", "hardware/", "deploy/nginx/", "deploy/cloudflare/", "deploy/fail2ban/", "**/*.md"],
+ "protected": {
+ "kind": "http-json-count",
+ "url": "http://127.0.0.1:3000/api/streams",
+ "array": "streams",
+ "where": { "is_live": true },
+ "label": "live streams"
+ },
+ "drain": { "policy": "refuse", "waitMaxSeconds": 28800, "pollSeconds": 60, "quietChecks": 2 },
+ "databases": [
+ { "name": "live", "path": "/opt/openvibe.live/data/live.db" },
+ { "name": "rs-companion", "path": "/opt/openvibe.live/data/rs-companion.db" }
+ ],
+ "_databasesNote": "analytics.db (1.7 GB, append-only pageviews) is left out of routine backups on purpose; add it here to include it.",
+ "backupOnChange": ["server/db/migrations.js", "server/db/schema.sql"],
+ "nginx": { "vhost": "openvibe.live.conf", "repoVhost": "deploy/nginx/openvibe.live.conf", "variant": "websocket", "wsPaths": ["/ws/"], "maxBody": "3m" }
+ },
+ "media": {
+ "repo": "/opt/openvibe.media",
+ "units": ["openvibe-media.service"],
+ "unitSources": { "openvibe-media.service": "deploy/systemd/openvibe-media.service" },
+ "envFile": "/etc/openvibe/media.env",
+ "env": { "required": ["DB_PATH", "VOD_PATH", "MEDIA_B2_ENDPOINT", "MEDIA_B2_BUCKET", "MEDIA_B2_KEY_ID", "MEDIA_B2_APP_KEY", "MEDIA_R2_ENDPOINT", "MEDIA_R2_BUCKET", "MEDIA_R2_ACCESS_KEY_ID", "MEDIA_R2_SECRET_ACCESS_KEY"] },
+ "port": 4100,
+ "ready": { "url": "http://127.0.0.1:4100/healthz", "timeoutSeconds": 60 },
+ "noRestartPaths": ["docs/", "test/", "**/*.md"],
+ "protected": {
+ "kind": "sqlite-count",
+ "db": "/opt/openvibe.media/data/media.db",
+ "sql": "SELECT count(*) AS n FROM vods WHERE is_recording = 1",
+ "label": "recordings in progress"
+ },
+ "drain": { "policy": "refuse", "waitMaxSeconds": 43200, "pollSeconds": 60, "quietChecks": 2 },
+ "databases": [{ "name": "media", "path": "/opt/openvibe.media/data/media.db" }],
+ "nginx": { "vhost": "openvibe.media.conf", "repoVhost": "deploy/nginx/openvibe.media.conf", "variant": "http" }
+ },
+ "tools": {
+ "repo": "/opt/openvibe.tools",
+ "packages": ["apps/*"],
+ "units": [
+ "openvibe-tools.service",
+ "openvibe-tools-maps.service",
+ "openvibe-tools-food.service",
+ "openvibe-tools-img.service",
+ "openvibe-tools-yt.service",
+ "openvibe-tools-audio.service",
+ "openvibe-tools-text.service",
+ "openvibe-tools-docs.service"
+ ],
+ "envFile": "/etc/openvibe/tools.env",
+ "env": { "required": ["OV_NETWORK_URL", "OV_OAUTH_CLIENT_ID", "OV_OAUTH_CLIENT_SECRET", "MEDIA_URL"] },
+ "port": 4001,
+ "ready": { "url": "http://127.0.0.1:4001/api/health", "headers": { "Host": "openvibe.tools" }, "timeoutSeconds": 60 },
+ "noRestartPaths": ["**/*.md"],
+ "databases": [
+ { "name": "tools-audio-analytics", "path": "/opt/openvibe.tools/apps/audio/data/analytics.db" },
+ { "name": "tools-docs-analytics", "path": "/opt/openvibe.tools/apps/docs/data/analytics.db" },
+ { "name": "tools-food-analytics", "path": "/opt/openvibe.tools/apps/food/data/analytics.db" },
+ { "name": "tools-img-analytics", "path": "/opt/openvibe.tools/apps/img/data/analytics.db" },
+ { "name": "tools-maps-analytics", "path": "/opt/openvibe.tools/apps/maps/data/analytics.db" },
+ { "name": "tools-text-analytics", "path": "/opt/openvibe.tools/apps/text/data/analytics.db" },
+ { "name": "tools-yt-analytics", "path": "/opt/openvibe.tools/apps/yt/data/analytics.db" }
+ ]
+ },
+ "community": {
+ "repo": "/opt/openvibe.community",
+ "units": ["openvibe-community.service"],
+ "unitSources": { "openvibe-community.service": "deploy/systemd/openvibe-community.service" },
+ "envFile": "/etc/openvibe/community.env",
+ "env": { "required": ["BASE_URL", "OV_NETWORK_URL", "OV_OAUTH_CLIENT_ID", "OV_OAUTH_CLIENT_SECRET", "OV_OAUTH_REDIRECT_URI"] },
+ "port": 4200,
+ "ready": { "url": "http://127.0.0.1:4200/api/ready", "timeoutSeconds": 30 },
+ "noRestartPaths": ["docs/", "test/", "**/*.md"],
+ "databases": [
+ { "name": "community", "path": "/var/lib/openvibe-community/community.db" },
+ { "name": "community-legacy-path", "path": "/opt/openvibe.community/data/community.db" }
+ ],
+ "_databasesNote": "The repo unit puts the database in /var/lib/openvibe-community; the 22 Sep production snapshot found it under /opt/openvibe.community/data. Keep the one that exists (backup skips a missing path).",
+ "nginx": { "vhost": "openvibe.community.conf", "repoVhost": "deploy/nginx/openvibe.community.conf", "variant": "http" }
+ },
+ "events": {
+ "_note": "Not deployed as of the 22 Sep 2026 production snapshot (no events.env on the host).",
+ "repo": "/opt/openvibe.events",
+ "units": ["openvibe-events.service"],
+ "unitSources": { "openvibe-events.service": "deploy/systemd/openvibe-events.service" },
+ "envFile": "/etc/openvibe/events.env",
+ "env": { "required": ["OV_NETWORK_URL", "OV_NETWORK_INTERNAL_URL"] },
+ "port": 4300,
+ "ready": { "url": "http://127.0.0.1:4300/api/ready", "timeoutSeconds": 30 },
+ "noRestartPaths": ["docs/", "test/", "**/*.md"],
+ "protected": {
+ "kind": "http-json-count",
+ "url": "http://127.0.0.1:4300/api/ready",
+ "field": "realtime_connections",
+ "label": "SSE connections"
+ },
+ "drain": { "policy": "report" },
+ "databases": [{ "name": "events", "path": "/var/lib/openvibe-events/events.db" }],
+ "nginx": { "vhost": "events.openvibe.network.conf", "repoVhost": "deploy/nginx/events.openvibe.network.conf", "variant": "sse", "ssePaths": ["/realtime/stream"], "certName": "openvibe.network" }
+ },
+ "games": {
+ "repo": "/opt/openvibe.games",
+ "managed": false,
+ "unmanagedReason": "pnpm workspace with a TypeScript build; its deploy steps are not encoded in Stage A (status, validate, snapshot and backup still work)",
+ "units": ["openvibe-games.service"],
+ "envFile": "/etc/openvibe/games.env",
+ "port": 8000,
+ "ready": { "url": "http://127.0.0.1:8000/healthz", "timeoutSeconds": 60 },
+ "databases": [{ "name": "world", "path": "/opt/openvibe.games/data/world.db" }]
+ },
+ "sites": {
+ "repo": "/opt/openvibe.sites",
+ "units": [],
+ "install": { "command": ["npm", "ci", "--omit=dev", "--no-audit", "--no-fund"], "always": true },
+ "build": [["node", "build.js"]],
+ "nginx": { "repoVhosts": "deploy/nginx/*.conf", "installOnDeploy": true }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * Certificate inventory: every `ssl_certificate` referenced by an enabled nginx vhost, with its
+ * subject, names, issuer and expiry. Only certificate blocks are ever parsed; `ssl_certificate_key`
+ * lines are never followed, a path that looks like a key is refused, and a combined PEM file has
+ * everything except its CERTIFICATE blocks discarded before parsing. No key material is read into
+ * output, logs or records.
+ */
+const crypto = require('crypto');
+const path = require('path');
+
+const CERT_LINE_RE = /^\s*ssl_certificate\s+("?)([^";\s]+)\1\s*;/gm;
+const KEY_PATH_RE = /(privkey|private|\.key$)/i;
+const CERT_BLOCK_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/;
+
+function referencedCerts(vhostText) {
+ const out = [];
+ let m;
+ CERT_LINE_RE.lastIndex = 0;
+ while ((m = CERT_LINE_RE.exec(vhostText))) out.push(m[2]);
+ return out;
+}
+
+/** X509Certificate subjects are newline-separated "K=V" lines. */
+function field(dn, key) {
+ const m = String(dn || '').match(new RegExp(`(?:^|\\n)${key}=([^\\n]+)`));
+ return m ? m[1] : null;
+}
+
+function describe(pem, now) {
+ const block = pem.match(CERT_BLOCK_RE);
+ if (!block) return { error: 'no CERTIFICATE block' };
+ const x = new crypto.X509Certificate(block[0]);
+ const notAfter = new Date(x.validTo);
+ const daysLeft = Math.floor((notAfter.getTime() - now) / 86400000);
+ const cn = field(x.subject, 'CN');
+ const issuer = [field(x.issuer, 'O'), field(x.issuer, 'CN')].filter(Boolean).join(' ');
+ const names = (x.subjectAltName || '').split(',').map((s) => s.trim().replace(/^DNS:/, '')).filter(Boolean);
+ return { subject: cn, names, issuer, notBefore: new Date(x.validFrom).toISOString(), notAfter: notAfter.toISOString(), daysLeft, fingerprint256: x.fingerprint256 };
+}
+
+async function inventory(exec, inv, { warnDays = 21 } = {}) {
+ const entries = (await exec.readdir(inv.nginx.sitesEnabled)) || [];
+ const byPath = new Map();
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
+ const file = path.join(inv.nginx.sitesEnabled, e.name);
+ const text = await exec.readFile(file, { privileged: true });
+ if (text == null) continue;
+ for (const p of referencedCerts(text)) (byPath.get(p) || byPath.set(p, []).get(p)).push(e.name);
+ }
+ const now = exec.now();
+ const certs = [];
+ for (const [certPath, vhosts] of byPath) {
+ const row = { path: certPath, vhosts };
+ if (certPath.includes('$')) { row.status = 'dynamic'; row.error = 'path uses an nginx variable'; certs.push(row); continue; }
+ if (KEY_PATH_RE.test(path.basename(certPath))) { row.status = 'refused'; row.error = 'looks like a key file; not read'; certs.push(row); continue; }
+ const pem = await exec.readFile(certPath, { privileged: true });
+ if (pem == null) { row.status = 'missing'; row.error = 'file not found'; certs.push(row); continue; }
+ try {
+ Object.assign(row, describe(pem, now));
+ row.status = row.error ? 'unreadable' : row.daysLeft < 0 ? 'expired' : row.daysLeft < warnDays ? 'expiring' : 'ok';
+ } catch (err) {
+ row.status = 'unreadable';
+ row.error = err.message;
+ }
+ certs.push(row);
+ }
+ return certs;
+}
+
+module.exports = { inventory, referencedCerts, describe };
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
+'use strict';
+/**
+ * ovhost command line. main(argv, { exec, out, env }) returns the exit code, so the tests drive the
+ * whole CLI against the fake host.
+ */
+const lib = require('./index');
+const { InventoryError } = require('./inventory');
+const { LockedError } = require('./lock');
+const { SocketGuardError } = require('./systemd');
+
+const USAGE = `ovhost — OpenVibe.Host operator plane (Stage A)
+
+Usage: ovhost <command> [args] [--inventory <file>] [--json]
+
+ status [<service>...] unit state, sha, readiness, protected sessions (all services)
+ validate <service> env names, unit files, port, nginx vhost + nginx -t, deps
+ env-names <service> variable NAMES declared in the checkout's .env.example
+ plan <service> [--to <sha>] [--no-fetch]
+ what a deploy would do (fetches the remote; changes nothing else)
+ deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
+ rollback <service> [--to <sha>] [--wait-idle] [--force]
+ releases <service> [--limit <n>] the release log
+ certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
+ nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
+ print a vhost from the service manifest; --install writes it,
+ runs nginx -t and reloads only if clean
+ snapshot <service> [--out <file>] config tarball: units, vhost, env NAMES, sha — no secret values
+ backup <service> sqlite .backup of the declared databases to a dated directory
+
+Inventory: --inventory, else $OVHOST_INVENTORY, else /etc/openvibe/host.json.
+Exit codes: 0 ok · 1 usage/precondition · 2 validation failed (nothing restarted) · 3 not ready,
+rolled back · 4 rollback failed (manual intervention) · 5 protected sessions active
+`;
+
+const FLAGS_WITH_VALUE = new Set(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout']);
+
+function parseArgs(argv) {
+ const pos = [];
+ const opts = {};
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '-h' || a === '--help') { opts.help = true; continue; }
+ if (a.startsWith('--')) {
+ const eq = a.indexOf('=');
+ const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
+ if (FLAGS_WITH_VALUE.has(name)) {
+ const v = eq > 0 ? a.slice(eq + 1) : argv[++i];
+ if (v === undefined) throw new UsageError(`--${name} needs a value`);
+ opts[name] = v;
+ } else opts[name] = true;
+ } else pos.push(a);
+ }
+ return { pos, opts };
+}
+
+class UsageError extends Error {}
+
+const ICON = { ok: ' ok ', warn: 'WARN ', error: 'FAIL ', info: 'info ' };
+
+function short(sha) { return sha ? sha.slice(0, 12) : '-'; }
+
+async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env } = {}) {
+ let parsed;
+ try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }
+ const { pos, opts } = parsed;
+ const cmd = pos[0];
+ if (!cmd || opts.help) { out(USAGE); return cmd || opts.help ? 0 : 1; }
+ const json = !!opts.json;
+ const log = json ? () => {} : (s) => out(`[ovhost] ${s}`);
+ const emit = (obj) => out(JSON.stringify(obj, null, 2));
+ const needService = () => { if (!pos[1]) throw new UsageError(`${cmd} needs a <service>`); return pos[1]; };
+
+ try {
+ const inv = await lib.inventory.load(exec, { file: opts.inventory, env });
+ const ctx = { exec, inv, log };
+ switch (cmd) {
+ case 'validate': {
+ const r = await lib.validate(ctx, needService());
+ if (json) emit(r);
+ else {
+ for (const f of r.findings) out(`${ICON[f.level]} ${f.area.padEnd(8)} ${f.message}`);
+ out(`${r.service}: ${r.ok ? 'valid' : 'NOT valid'} (${r.errors} error(s), ${r.warnings} warning(s))`);
+ }
+ return r.ok ? 0 : 2;
+ }
+ case 'env-names': {
+ const r = await lib.envNames(ctx, needService());
+ if (json) emit(r);
+ else if (!r.found) out(`${r.file} not found`);
+ else {
+ out(`${r.file}: ${r.declared.length} declared name(s)`);
+ out(r.declared.join('\n'));
+ if (r.commented.length) out(`commented out: ${r.commented.join(', ')}`);
+ }
+ return r.found ? 0 : 1;
+ }
+ case 'plan': {
+ const p = await lib.plan(ctx, needService(), { to: opts.to, fetch: !opts['no-fetch'], restart: !!opts.restart });
+ if (json) { emit(p); return 0; }
+ out(`${p.service}: ${short(p.from)} → ${short(p.to)}${p.upToDate ? ' (up to date)' : ''} on ${p.branch}${p.branch !== p.expectedBranch ? ` (EXPECTED ${p.expectedBranch})` : ''}`);
+ for (const c of p.commits.slice(0, 30)) out(` ${c}`);
+ out(` files changed ${p.changedFiles} (${p.codeChanged} outside the no-restart paths)`);
+ out(` tracked changes ${p.dirty.length ? `${p.dirty.join(', ')} — deploy will refuse` : 'none'}`);
+ for (const i of p.installs) out(` ${i.pkg.padEnd(18)} ${i.install ? `install (${[i.lockfileChanged && 'lockfile changed', i.depsChanged && 'deps changed', i.missingNodeModules && 'no node_modules'].filter(Boolean).join(', ') || 'always'})` : 'dependencies unchanged'}`);
+ out(` restart ${p.restartNeeded ? `yes: ${p.units.join(', ')}${p.socketUnit ? ` (socket ${p.socketUnit} stays up)` : ''}` : 'no'}`);
+ if (p.protectedSessions) {
+ const s = p.protectedSessions;
+ out(` protected ${s.count == null ? `UNKNOWN (${s.unknown}) — treated as active` : `${s.count} ${s.label}`}${s.notRunning ? ' (service not running)' : ''}; drain policy ${p.drainPolicy}`);
+ }
+ if (p.backupNeeded) out(' backup schema files changed — databases are backed up before the restart');
+ if (p.unitDrift.length) out(` unit files differ from the repo: ${p.unitDrift.map((u) => u.unit).join(', ')} (--install-units)`);
+ if (p.installVhosts) out(' nginx repo vhosts are installed (nginx -t, reload) on deploy');
+ if (!p.managed) out(' NOT MANAGED deploy/rollback refuse this service');
+ return 0;
+ }
+ case 'deploy':
+ case 'rollback': {
+ const id = needService();
+ const o = { waitIdle: !!opts['wait-idle'], force: !!opts.force, restart: !!opts.restart, to: opts.to, installUnits: !!opts['install-units'], readyTimeout: opts['ready-timeout'] ? Number(opts['ready-timeout']) : undefined };
+ const r = cmd === 'deploy' ? await lib.deploy(ctx, id, o) : await lib.rollback(ctx, id, o);
+ if (json) emit(r.record);
+ else if (r.record.result !== 'unchanged') out(`[ovhost] result: ${r.record.result} (release ${r.record.id})`);
+ return r.exitCode;
+ }
+ case 'status': {
+ const rows = await lib.status(ctx, pos.slice(1));
+ if (json) { emit(rows); return 0; }
+ for (const r of rows) {
+ const units = r.units.map((u) => `${u.unit.replace(/\.service$/, '')}=${u.active}${u.restarts ? `(${u.restarts} restarts)` : ''}`).join(' ');
+ const sock = r.socket ? ` socket=${r.socket.active}` : '';
+ const ready = r.ready ? (r.ready.ok ? 'ready' : `NOT READY (${r.ready.status || r.ready.error})`) : '-';
+ const prot = r.protected ? (r.protected.count == null ? ` ${r.protected.label}=unknown` : ` ${r.protected.label}=${r.protected.count}`) : '';
+ out(`${r.service.padEnd(10)} ${short(r.sha)} ${ready.padEnd(12)} ${units || '(no units)'}${sock}${prot}${r.managed ? '' : ' [unmanaged]'}`);
+ }
+ return 0;
+ }
+ case 'releases': {
+ const id = lib.inventory.service(inv, needService()).id;
+ const list = await lib.releases.list(exec, inv, id);
+ const limit = Number(opts.limit || 20);
+ const shown = list.slice(-limit);
+ if (json) { emit(shown); return 0; }
+ if (!shown.length) out(`no releases recorded for ${id}`);
+ for (const r of shown) {
+ out(`${r.startedAt} ${r.action.padEnd(8)} ${short(r.from)} → ${short(r.to)} ${String(r.result).padEnd(18)} ${r.operator || ''}${r.forced ? ' FORCED' : ''}${r.lockfileChanged ? ' lockfile' : ''}${r.error ? ` (${r.error.split('\n')[0].slice(0, 100)})` : ''}`);
+ }
+ return 0;
+ }
+ case 'certs': {
+ const list = await lib.certs.inventory(exec, inv, { warnDays: Number(opts['warn-days'] || 21) });
+ if (json) emit(list);
+ else {
+ for (const c of list) {
+ out(`${String(c.status).toUpperCase().padEnd(9)} ${c.notAfter ? `${c.notAfter.slice(0, 10)} (${c.daysLeft}d)` : ''.padEnd(10)} ${c.path}${c.names ? ` [${c.names.slice(0, 4).join(', ')}${c.names.length > 4 ? `, +${c.names.length - 4}` : ''}]` : ''}${c.error ? ` ${c.error}` : ''} ← ${c.vhosts.join(', ')}`);
+ }
+ out(`${list.length} certificate(s) referenced by ${inv.nginx.sitesEnabled}`);
+ }
+ return list.some((c) => ['expired', 'missing', 'unreadable'].includes(c.status)) ? 2 : 0;
+ }
+ case 'nginx': {
+ if (pos[1] !== 'render') throw new UsageError('usage: ovhost nginx render <service> [--variant …] [--install]');
+ const id = pos[2];
+ if (!id) throw new UsageError('nginx render needs a <service>');
+ const svc = lib.inventory.service(inv, id);
+ const r = lib.nginx.render(inv, svc, { variant: opts.variant, manifestFile: opts.manifest });
+ if (!opts.install) { out(r.text); return 0; }
+ const res = await lib.nginx.install(exec, inv, [r], { log });
+ if (!res.changed.length) log(`${r.name} already installed and enabled; nothing changed`);
+ return 0;
+ }
+ case 'snapshot': {
+ const r = await lib.snapshot(ctx, needService(), { out: opts.out });
+ if (json) emit(r); else { out(`snapshot: ${r.file}`); out(` ${r.entries.join('\n ')}`); }
+ return 0;
+ }
+ case 'backup': {
+ const r = await lib.backup(ctx, needService());
+ if (json) emit(r); else for (const f of r.files) out(f.skipped ? `skipped ${f.source}: ${f.skipped}` : `${f.source} → ${f.dest} (${f.bytes} bytes)`);
+ return 0;
+ }
+ default:
+ throw new UsageError(`unknown command "${cmd}"`);
+ }
+ } catch (err) {
+ if (err instanceof UsageError) { out(err.message); return 1; }
+ if (err instanceof InventoryError || err instanceof LockedError) { out(`[ovhost] ${err.message}`); return 1; }
+ if (err instanceof SocketGuardError) { out(`[ovhost] ${err.message}`); return 1; }
+ if (err instanceof lib.OpError) { out(`[ovhost] ✗ ${err.message}`); return err.exitCode; }
+ out(`[ovhost] ✗ ${err.message}`);
+ return 2;
+ }
+}
+
+module.exports = { main, parseArgs, USAGE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/**
+ * ovhost backup <service>: an online SQLite `.backup` of every database the inventory declares for
+ * the service, into <backupDir>/<service>/<YYYYMMDD-HHMMSS>/<name>.db. The copy is made by a worker
+ * running as the service user (root never opens a service database — see sqlite-worker.js), checked
+ * with PRAGMA quick_check, and recorded in <stateDir>/backups/<service>.jsonl.
+ *
+ * Backups are consistent snapshots taken while the service runs; they are not a restore drill
+ * (that is Wave 22's job) and nothing here prunes old backups.
+ */
+const path = require('path');
+const { service } = require('../inventory');
+
+function stamp(ms) {
+ return new Date(ms).toISOString().replace(/[-:]/g, '').replace('T', '-').replace(/\.\d+Z$/, '');
+}
+
+async function backup(ctx, id, { reason = 'manual' } = {}) {
+ const { exec, inv, log } = ctx;
+ const svc = service(inv, id);
+ if (!svc.databases.length) throw new Error(`${id} declares no databases`);
+ const dir = path.join(inv.backupDir, id, stamp(exec.now()));
+ // The worker writes as the service user, so the base must be traversable and the service's
+ // directory its own.
+ await exec.mkdir(inv.backupDir, { mode: 0o755 });
+ await exec.mkdir(path.join(inv.backupDir, id), { owner: svc.runAs, mode: 0o750 });
+ await exec.mkdir(dir, { owner: svc.runAs, mode: 0o750 });
+ const files = [];
+ for (const db of svc.databases) {
+ if (!(await exec.stat(db.path))) { files.push({ name: db.name, source: db.path, skipped: 'not found' }); log(`skip ${db.path}: not found`); continue; }
+ // Named by the inventory name: two databases may share a file name (community.db twice).
+ const dest = path.join(dir, `${db.name}${path.extname(db.path) || '.db'}`);
+ log(`backing up ${db.path} → ${dest} (as ${svc.runAs})`);
+ const t0 = exec.now();
+ await exec.sqliteBackup(db.path, dest, { as: svc.runAs });
+ const st = await exec.stat(dest);
+ files.push({ name: db.name, source: db.path, dest, bytes: st ? st.size : null, seconds: Math.round((exec.now() - t0) / 1000) });
+ }
+ const record = { service: id, at: new Date(exec.now()).toISOString(), reason, dir, files };
+ await exec.appendFile(path.join(inv.stateDir, 'backups', `${id}.jsonl`), `${JSON.stringify(record)}\n`);
+ return record;
+}
+
+module.exports = { backup, stamp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * ovhost snapshot <service>: the service's configuration as a .tar.gz WITHOUT secret values —
+ *
+ * snapshot.json sha, branch, remote (credentials stripped), inventory entry, service
+ * manifest, env file mode + variable NAMES, unit paths, lockfile hashes
+ * units/<unit> installed unit files and drop-ins; Environment= values whose name looks
+ * secret (KEY, TOKEN, SECRET, PASSWORD, …) are replaced by <redacted>
+ * nginx/<vhost> the installed vhost
+ * env/<file>.names the env file's variable names, one per line — never values
+ *
+ * Written to <stateDir>/snapshots/<service>-<stamp>-<sha>.tar.gz (or --out), mode 0640.
+ */
+const path = require('path');
+const crypto = require('crypto');
+const { service } = require('../inventory');
+const envfile = require('../envfile');
+const systemd = require('../systemd');
+const deps = require('../deps');
+const { git } = require('../git');
+const { createTarGz } = require('../tar');
+const { stamp } = require('./backup');
+
+/** Inventory entries may carry request headers for probes; keep the names, drop the values. */
+function withoutHeaderValues(svc) {
+ const copy = JSON.parse(JSON.stringify(svc));
+ for (const k of ['ready', 'protected']) {
+ if (copy[k] && copy[k].headers) copy[k].headers = Object.fromEntries(Object.keys(copy[k].headers).map((h) => [h, '<redacted>']));
+ }
+ return copy;
+}
+
+async function snapshot(ctx, id, { out } = {}) {
+ const { exec, inv } = ctx;
+ const svc = service(inv, id);
+ const files = [];
+ const meta = { service: id, takenAt: new Date(exec.now()).toISOString(), host: await exec.hostname(), inventory: withoutHeaderValues(svc) };
+
+ const g = git(exec, svc);
+ try {
+ meta.sha = await g.head();
+ meta.branch = await g.branch();
+ meta.remote = await g.remoteUrl();
+ } catch (err) { meta.gitError = err.message.split('\n')[0]; }
+
+ try {
+ const contracts = require('openvibe-contracts');
+ meta.manifest = contracts.services.get(svc.manifest) || null;
+ meta.contractsVersion = require('openvibe-contracts/package.json').version;
+ } catch { meta.manifest = null; }
+
+ if (svc.envFile) {
+ const st = await exec.stat(svc.envFile);
+ const text = st ? await exec.readFile(svc.envFile, { privileged: true }) : null;
+ const names = text == null ? [] : envfile.parseNames(text);
+ meta.env = { file: svc.envFile, exists: !!st, mode: st ? (st.mode & 0o777).toString(8) : null, names: names.map((n) => n.name), empty: names.filter((n) => n.empty).map((n) => n.name) };
+ files.push({ name: `env/${path.basename(svc.envFile)}.names`, content: `${names.map((n) => n.name).join('\n')}\n` });
+ }
+
+ meta.units = [];
+ for (const u of [...svc.units, ...(svc.socketUnit ? [svc.socketUnit] : [])]) {
+ const st = await systemd.show(exec, u);
+ meta.units.push({ unit: u, fragmentPath: st.fragmentPath, dropIns: st.dropIns, active: st.active });
+ if (st.fragmentPath) {
+ const text = await exec.readFile(st.fragmentPath, { privileged: true });
+ if (text != null) files.push({ name: `units/${u}`, content: envfile.redactUnit(text) });
+ }
+ for (const d of st.dropIns) {
+ const text = await exec.readFile(d, { privileged: true });
+ if (text != null) files.push({ name: `units/${u}.d/${path.basename(d)}`, content: envfile.redactUnit(text) });
+ }
+ }
+
+ if (svc.nginx && svc.nginx.vhost) {
+ const text = await exec.readFile(path.join(inv.nginx.sitesAvailable, svc.nginx.vhost), { privileged: true });
+ if (text != null) files.push({ name: `nginx/${svc.nginx.vhost}`, content: text });
+ meta.vhost = { name: svc.nginx.vhost, present: text != null };
+ }
+
+ meta.lockfiles = {};
+ for (const pkg of await deps.expandPackages(exec, svc)) {
+ const lockText = await exec.readFile(path.join(svc.repo, pkg, 'package-lock.json'));
+ if (lockText != null) meta.lockfiles[pkg] = crypto.createHash('sha256').update(lockText).digest('hex');
+ }
+
+ files.unshift({ name: 'snapshot.json', content: `${JSON.stringify(meta, null, 2)}\n` });
+ const dest = out || path.join(inv.stateDir, 'snapshots', `${id}-${stamp(exec.now())}-${(meta.sha || 'nosha').slice(0, 12)}.tar.gz`);
+ await exec.mkdir(path.dirname(dest), { mode: 0o750 });
+ await exec.writeFile(dest, createTarGz(files, exec.now()), { mode: 0o640 });
+ return { service: id, file: dest, entries: files.map((f) => f.name), sha: meta.sha || null };
+}
+
+module.exports = { snapshot };
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/** ovhost status: every service — unit states, checkout sha, readiness, protected sessions. Read-only. */
+const systemd = require('../systemd');
+const readiness = require('../readiness');
+const probes = require('../probes');
+const { git } = require('../git');
+
+async function statusOne(ctx, svc) {
+ const { exec } = ctx;
+ const row = { service: svc.id, managed: svc.managed, units: [], socket: null, sha: null, branch: null, ready: null, protected: null };
+ for (const u of svc.units) {
+ const st = await systemd.show(exec, u);
+ row.units.push({ unit: u, active: st.active, sub: st.sub, restarts: st.restarts });
+ }
+ if (svc.socketUnit) {
+ const st = await systemd.show(exec, svc.socketUnit);
+ row.socket = { unit: svc.socketUnit, active: st.active, sub: st.sub };
+ }
+ try {
+ const g = git(exec, svc);
+ row.sha = await g.head();
+ row.branch = await g.branch();
+ } catch (err) {
+ row.shaError = err.message.split('\n')[0];
+ }
+ if (svc.ready) {
+ const p = await readiness.probe(exec, svc);
+ row.ready = { ok: p.ok, status: p.status || null, error: p.error || null, latencyMs: p.latencyMs };
+ }
+ if (svc.protected) {
+ const c = await probes.countProtected(exec, svc);
+ row.protected = { label: c.label, count: c.count, unknown: c.unknown || null, notRunning: !!c.notRunning };
+ }
+ return row;
+}
+
+async function status(ctx, ids) {
+ const list = ids && ids.length ? ids.map((id) => require('../inventory').service(ctx.inv, id)) : Object.values(ctx.inv.services);
+ const rows = [];
+ for (const svc of list) rows.push(await statusOne(ctx, svc));
+ return rows;
+}
+
+module.exports = { status };
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+/**
+ * ovhost validate <service>: is the host set up the way the inventory says?
+ *
+ * env the env file exists, is not world-readable, and every required NAME is present and
+ * non-empty (names from the unit's Environment= lines count as present). Values are
+ * never read into the result — only { name, empty }.
+ * units every unit is loaded from a unit file; the socket unit is active; repo unit files that
+ * differ from the installed ones are reported.
+ * port free (service down) or held by the service / its socket — nothing else.
+ * nginx the vhost is in sites-available and linked in sites-enabled; `nginx -t` is clean.
+ * checkout owned by the declared owner, on the declared branch, no tracked changes.
+ * deps every dependency resolves (the Tools rule).
+ *
+ * Every finding is { level: error|warn|ok|info, area, message }. Exit 2 if any error.
+ */
+const path = require('path');
+const { service } = require('../inventory');
+const envfile = require('../envfile');
+const systemd = require('../systemd');
+const nginx = require('../nginx');
+const deps = require('../deps');
+const { git } = require('../git');
+
+async function validate(ctx, id) {
+ const { exec, inv } = ctx;
+ const svc = service(inv, id);
+ const findings = [];
+ const add = (level, area, message) => findings.push({ level, area, message });
+
+ // ── checkout ──
+ const repoStat = await exec.stat(svc.repo);
+ if (!repoStat || !repoStat.isDir) add('error', 'checkout', `${svc.repo} does not exist`);
+ else {
+ if (repoStat.owner !== svc.owner) add('error', 'checkout', `${svc.repo} is owned by ${repoStat.owner}, expected ${svc.owner}`);
+ else add('ok', 'checkout', `${svc.repo} owned by ${svc.owner}`);
+ try {
+ const g = git(exec, svc);
+ const [branch, head, dirty] = [await g.branch(), await g.head(), await g.dirty()];
+ if (branch !== svc.branch) add('error', 'checkout', `on branch "${branch}", expected "${svc.branch}"`);
+ add(dirty.length ? 'error' : 'ok', 'checkout', dirty.length ? `tracked local changes: ${dirty.slice(0, 8).join(', ')}` : `clean at ${head.slice(0, 12)}`);
+ } catch (err) {
+ add('error', 'checkout', err.message);
+ }
+ }
+
+ // ── env ──
+ let unitEnvNames = [];
+ for (const u of svc.units) {
+ const st = await systemd.show(exec, u);
+ if (st.fragmentPath) unitEnvNames = unitEnvNames.concat(envfile.unitEnvironmentNames(await exec.readFile(st.fragmentPath, { privileged: true })));
+ for (const d of st.dropIns) unitEnvNames = unitEnvNames.concat(envfile.unitEnvironmentNames(await exec.readFile(d, { privileged: true })));
+ }
+ if (svc.envFile) {
+ const st = await exec.stat(svc.envFile);
+ if (!st) add('error', 'env', `${svc.envFile} does not exist`);
+ else {
+ if (st.mode & 0o004) add('error', 'env', `${svc.envFile} is world-readable (mode ${(st.mode & 0o777).toString(8)}); chmod 600`);
+ else if (st.mode & 0o040) add('warn', 'env', `${svc.envFile} is group-readable (mode ${(st.mode & 0o777).toString(8)})`);
+ else add('ok', 'env', `${svc.envFile} mode ${(st.mode & 0o777).toString(8)}`);
+ if (st.mode & 0o002) add('error', 'env', `${svc.envFile} is world-writable`);
+ const present = new Map(envfile.parseNames(await exec.readFile(svc.envFile, { privileged: true })).map((e) => [e.name, e]));
+ for (const n of unitEnvNames) if (!present.has(n)) present.set(n, { name: n, empty: false, fromUnit: true });
+ const example = await exec.readFile(path.join(svc.repo, svc.env.example));
+ const exampleNames = example ? envfile.parseExample(example).declared : [];
+ let required = svc.env.required;
+ if (required === 'from-example') {
+ required = exampleNames;
+ if (!example) add('warn', 'env', `${svc.env.example} not found in the checkout; no required names`);
+ }
+ if (!required.length) add('info', 'env', 'no required names declared (env.required)');
+ const missing = required.filter((n) => !present.has(n));
+ const empty = required.filter((n) => present.has(n) && present.get(n).empty);
+ if (missing.length) add('error', 'env', `missing: ${missing.join(', ')}`);
+ if (empty.length) add('error', 'env', `empty: ${empty.join(', ')}`);
+ if (required.length && !missing.length && !empty.length) add('ok', 'env', `${required.length} required name(s) present and non-empty`);
+ const notSet = exampleNames.filter((n) => !present.has(n) && !required.includes(n));
+ if (notSet.length) add('info', 'env', `in ${svc.env.example} but not set (optional unless declared required): ${notSet.join(', ')}`);
+ }
+ } else add('info', 'env', 'no env file declared');
+
+ // ── units ──
+ const pids = new Set();
+ for (const u of svc.units) {
+ const st = await systemd.show(exec, u);
+ if (st.load !== 'loaded' || !st.fragmentPath) add('error', 'units', `${u} is not loaded (${st.load})`);
+ else add(st.active === 'active' ? 'ok' : 'warn', 'units', `${u} ${st.active}/${st.sub} from ${st.fragmentPath}`);
+ if (st.mainPid) pids.add(st.mainPid);
+ }
+ if (svc.socketUnit) {
+ const st = await systemd.show(exec, svc.socketUnit);
+ if (st.load !== 'loaded') add('error', 'units', `${svc.socketUnit} is not loaded (${st.load})`);
+ else add(st.active === 'active' ? 'ok' : 'error', 'units', `${svc.socketUnit} ${st.active}/${st.sub}${st.active === 'active' ? '' : ' — without it a restart refuses connections'}`);
+ if (st.partOf.some((u) => svc.units.includes(u))) {
+ add('warn', 'units', `${svc.socketUnit} has PartOf=${st.partOf.join(' ')}: systemd propagates a restart of that service to the socket (ovhost itself never stops or restarts it)`);
+ }
+ }
+ for (const [unit, src] of Object.entries(svc.unitSources)) {
+ const want = await exec.readFile(path.join(svc.repo, src));
+ if (want == null) { add('warn', 'units', `unit source ${src} not in the checkout`); continue; }
+ const have = await exec.readFile(path.join('/etc/systemd/system', unit), { privileged: true });
+ if (have !== want) add('warn', 'units', `${unit} differs from ${src} (deploy --install-units installs it)`);
+ }
+
+ // ── port ──
+ if (svc.port) {
+ try {
+ const holders = await exec.listeners(svc.port);
+ if (!holders.length) {
+ const anyActive = [...pids].length > 0;
+ add(anyActive ? 'error' : 'ok', 'port', anyActive ? `${svc.port} has no listener although ${svc.units.join(', ')} is running` : `${svc.port} is free`);
+ } else {
+ const foreign = holders.filter((h) => !(h.pid && pids.has(h.pid)) && !(svc.socketUnit && h.pid === 1));
+ if (foreign.length) add('error', 'port', `${svc.port} is held by ${foreign.map((h) => `${h.process || '?'}(${h.pid || '?'})`).join(', ')}, not by ${svc.units.join(', ') || svc.id}`);
+ else add('ok', 'port', `${svc.port} held by ${holders.map((h) => (h.pid === 1 ? `systemd (${svc.socketUnit})` : `${h.process}(${h.pid})`)).join(', ')}`);
+ }
+ } catch (err) {
+ add('warn', 'port', `could not list listeners: ${err.message}`);
+ }
+ }
+
+ // ── nginx ──
+ if (svc.nginx && svc.nginx.vhost) {
+ const available = path.join(inv.nginx.sitesAvailable, svc.nginx.vhost);
+ const enabled = path.join(inv.nginx.sitesEnabled, svc.nginx.vhost);
+ const a = await exec.stat(available);
+ const e = await exec.stat(enabled);
+ add(a ? 'ok' : 'error', 'nginx', a ? `${available} present` : `${available} missing`);
+ add(e ? 'ok' : 'error', 'nginx', e ? `${enabled} enabled` : `${enabled} missing (not enabled)`);
+ if (a && svc.nginx.repoVhost) {
+ const repoText = await exec.readFile(path.join(svc.repo, svc.nginx.repoVhost));
+ const live = await exec.readFile(available, { privileged: true });
+ if (repoText != null && repoText !== live) add('warn', 'nginx', `${svc.nginx.vhost} differs from ${svc.nginx.repoVhost} in the checkout`);
+ }
+ }
+ if (svc.nginx) {
+ const t = await nginx.test(exec, inv);
+ add(t.ok ? 'ok' : 'error', 'nginx', t.ok ? 'nginx -t clean' : `nginx -t failed: ${t.output.split('\n').slice(-3).join(' / ')}`);
+ }
+
+ // ── deps ──
+ if (repoStat && repoStat.isDir && svc.managed) {
+ const pkgDirs = await deps.expandPackages(exec, svc);
+ const problems = await deps.verify(exec, svc, pkgDirs);
+ if (problems.length) for (const p of problems) add('error', 'deps', `${p.pkg}: ${p.dep} — ${p.reason}`);
+ else add('ok', 'deps', `every dependency resolves (${pkgDirs.join(', ') || 'no packages'})`);
+ }
+
+ const errors = findings.filter((f) => f.level === 'error').length;
+ return { service: id, ok: errors === 0, errors, warnings: findings.filter((f) => f.level === 'warn').length, findings };
+}
+
+/** Names declared in the checkout's .env.example — to help write env.required. Names only. */
+async function envNames(ctx, id) {
+ const svc = service(ctx.inv, id);
+ const text = await ctx.exec.readFile(path.join(svc.repo, svc.env.example));
+ if (text == null) return { service: id, file: path.join(svc.repo, svc.env.example), found: false, declared: [], commented: [] };
+ return { service: id, file: path.join(svc.repo, svc.env.example), found: true, ...envfile.parseExample(text) };
+}
+
+module.exports = { validate, envNames };
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+/**
+ * Dependencies: when to install, how, and proof that every dependency resolves before anything
+ * restarts.
+ *
+ * The verification rule comes from a Tools outage: a dependency that used to be a `file:` link left
+ * an empty directory that npm treated as installed, and every unit crash-looped on the missing
+ * module. So a package counts as resolved only when node_modules/<dep>/package.json exists, parses,
+ * and names that package. A broken one is removed and reinstalled once; if it still does not
+ * resolve, the deploy aborts with nothing restarted.
+ */
+const path = require('path');
+
+/** package dirs (relative) from patterns like ".", "apps/*" — only dirs that have a package.json. */
+async function expandPackages(exec, svc) {
+ const out = [];
+ for (const pattern of svc.packages) {
+ if (pattern.endsWith('/*')) {
+ const base = pattern.slice(0, -2);
+ const entries = (await exec.readdir(path.join(svc.repo, base))) || [];
+ for (const e of entries.filter((x) => x.isDir).sort((a, b) => a.name.localeCompare(b.name))) {
+ const dir = path.join(base, e.name);
+ if (await exec.stat(path.join(svc.repo, dir, 'package.json'))) out.push(dir);
+ }
+ } else if (await exec.stat(path.join(svc.repo, pattern, 'package.json'))) {
+ out.push(path.normalize(pattern));
+ }
+ }
+ return out;
+}
+
+function depFields(text) {
+ try {
+ const j = JSON.parse(text);
+ return JSON.stringify([j.dependencies || {}, j.optionalDependencies || {}, j.engines || {}]);
+ } catch { return null; }
+}
+
+/** Whether a package dir needs an install between two revisions (lockfile or dependency fields changed). */
+async function needsInstall(g, from, to, pkgDir) {
+ const lock = path.join(pkgDir, 'package-lock.json');
+ const pkg = path.join(pkgDir, 'package.json');
+ const [lockA, lockB, pkgA, pkgB] = await Promise.all([g.show(from, lock), g.show(to, lock), g.show(from, pkg), g.show(to, pkg)]);
+ const lockfileChanged = lockA !== lockB;
+ const depsChanged = depFields(pkgA) !== depFields(pkgB);
+ return { lockfileChanged, depsChanged, needed: lockfileChanged || depsChanged };
+}
+
+/**
+ * npm install as the checkout owner. Afterwards the lockfile is restored from git: npm rewrites it
+ * on the host (different npm version), and a dirty lockfile blocks the next fast-forward pull.
+ */
+async function install(exec, svc, g, pkgDir) {
+ const [cmd, ...args] = svc.install.command;
+ const r = await exec.run(cmd, args, { as: svc.owner, cwd: path.join(svc.repo, pkgDir), timeoutMs: 20 * 60 * 1000 });
+ if (r.code !== 0) throw new Error(`${svc.install.command.join(' ')} failed in ${pkgDir}: ${(r.stderr || r.stdout).trim().split('\n').slice(-3).join(' / ')}`);
+ if (svc.install.restoreLockfile) {
+ const lock = path.join(pkgDir, 'package-lock.json');
+ const dirty = await g.dirty();
+ if (dirty.includes(lock)) await g.checkoutFile(lock);
+ }
+}
+
+/** Returns [{ pkg, dep, reason }] for every declared dependency that does not resolve. */
+async function verify(exec, svc, pkgDirs) {
+ const problems = [];
+ for (const pkgDir of pkgDirs) {
+ const root = path.join(svc.repo, pkgDir);
+ const text = await exec.readFile(path.join(root, 'package.json'));
+ let pkg;
+ try { pkg = JSON.parse(text); } catch { problems.push({ pkg: pkgDir, dep: null, reason: 'package.json does not parse' }); continue; }
+ for (const dep of Object.keys(pkg.dependencies || {})) {
+ const depJson = await exec.readFile(path.join(root, 'node_modules', dep, 'package.json'));
+ if (depJson == null) { problems.push({ pkg: pkgDir, dep, reason: 'node_modules/' + dep + '/package.json is missing' }); continue; }
+ let meta;
+ try { meta = JSON.parse(depJson); } catch { problems.push({ pkg: pkgDir, dep, reason: 'its package.json does not parse' }); continue; }
+ if (meta.name !== dep) problems.push({ pkg: pkgDir, dep, reason: `installed package is named "${meta.name}"` });
+ }
+ }
+ return problems;
+}
+
+/** verify; remove and reinstall anything broken once; verify again. Returns the remaining problems. */
+async function verifyAndRepair(exec, svc, g, pkgDirs, log = () => {}) {
+ let problems = await verify(exec, svc, pkgDirs);
+ if (!problems.length) return { problems, repaired: [] };
+ const repaired = [];
+ const byPkg = new Map();
+ for (const p of problems) (byPkg.get(p.pkg) || byPkg.set(p.pkg, []).get(p.pkg)).push(p);
+ for (const [pkgDir, list] of byPkg) {
+ for (const p of list) {
+ log(`dependency ${p.dep || '(package.json)'} in ${pkgDir} does not resolve (${p.reason}) — reinstalling`);
+ if (p.dep) await exec.run('rm', ['-rf', '--', path.join(svc.repo, pkgDir, 'node_modules', p.dep)], { as: svc.owner });
+ }
+ await install(exec, svc, g, pkgDir);
+ repaired.push(pkgDir);
+ }
+ problems = await verify(exec, svc, pkgDirs);
+ return { problems, repaired };
+}
+
+module.exports = { expandPackages, needsInstall, install, verify, verifyAndRepair };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * Environment files, NAMES ONLY. parseNames() reads KEY=value lines and returns { name, empty } —
+ * the value is looked at just long enough to know whether it is empty and is never returned,
+ * logged or stored. Nothing in ovhost has a code path that prints an env value.
+ */
+const LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
+const SECRET_NAME_RE = /(SECRET|PASSWORD|PASSWD|PASS$|TOKEN|KEY|CREDENTIAL|PRIVATE|COOKIE|SALT|DSN)/i;
+
+function isEmptyValue(raw) {
+ let v = raw.trim();
+ if (!v.startsWith('"') && !v.startsWith("'")) v = v.replace(/(^|\s+)#.*$/, '');
+ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) v = v.slice(1, -1);
+ return v.trim() === '';
+}
+
+function parseNames(text) {
+ const seen = new Map();
+ for (const line of String(text || '').split('\n')) {
+ const m = line.match(LINE_RE);
+ if (!m) continue;
+ seen.set(m[1], { name: m[1], empty: isEmptyValue(m[2]) });
+ }
+ return [...seen.values()];
+}
+
+/** Names a .env.example declares (uncommented lines) and names it only mentions in comments. */
+function parseExample(text) {
+ const declared = [];
+ const commented = [];
+ for (const line of String(text || '').split('\n')) {
+ let m = line.match(LINE_RE);
+ if (m) { if (!declared.includes(m[1])) declared.push(m[1]); continue; }
+ m = line.match(/^\s*#\s*(?:export\s+)?([A-Z][A-Z0-9_]*)=/);
+ if (m && !commented.includes(m[1])) commented.push(m[1]);
+ }
+ return { declared, commented: commented.filter((n) => !declared.includes(n)) };
+}
+
+/** Environment=A=1 "B=two words" lines in a unit file -> names. */
+function unitEnvironmentNames(unitText) {
+ const names = [];
+ for (const line of String(unitText || '').split('\n')) {
+ const m = line.match(/^\s*Environment=(.*)$/);
+ if (!m) continue;
+ for (const part of splitAssignments(m[1])) {
+ const n = part.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
+ if (n && !names.includes(n[1])) names.push(n[1]);
+ }
+ }
+ return names;
+}
+
+function splitAssignments(s) {
+ const out = [];
+ let cur = '';
+ let quote = null;
+ for (const ch of s) {
+ if (quote) { if (ch === quote) quote = null; else cur += ch; continue; }
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
+ if (/\s/.test(ch)) { if (cur) out.push(cur); cur = ''; continue; }
+ cur += ch;
+ }
+ if (cur) out.push(cur);
+ return out;
+}
+
+/**
+ * Unit text with the value of every secret-looking Environment= assignment replaced by <redacted>.
+ * Used by snapshots: unit files are config worth keeping, but an operator may have put a secret
+ * straight into a unit instead of the EnvironmentFile.
+ */
+function redactUnit(unitText) {
+ return String(unitText || '').split('\n').map((line) => {
+ const m = line.match(/^(\s*Environment=)(.*)$/);
+ if (!m) return line;
+ const parts = splitAssignments(m[2]).map((a) => {
+ const i = a.indexOf('=');
+ if (i < 0) return a;
+ const name = a.slice(0, i);
+ return SECRET_NAME_RE.test(name) ? `${name}=<redacted>` : a;
+ });
+ return `${m[1]}${parts.map((p) => (/\s/.test(p) ? `"${p}"` : p)).join(' ')}`;
+ }).join('\n');
+}
+
+module.exports = { parseNames, parseExample, unitEnvironmentNames, redactUnit, SECRET_NAME_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,235 @@
@@ -0,0 +1,235 @@
+'use strict';
+/**
+ * The system executor: the ONLY module in ovhost that touches the machine (processes, files, HTTP,
+ * SQLite). Every command and library function takes an executor, so the tests swap this for the
+ * fake host in test/fake-host.js and nothing in the suite ever runs systemctl, git, npm or curl.
+ *
+ * Interface (all async):
+ * run(cmd, args, { as, privileged, cwd, timeoutMs, input }) -> { code, stdout, stderr }
+ * as run as this user (git/npm as the checkout owner, sqlite as the service user);
+ * root uses runuser with the user's HOME, the same user runs directly, anyone else
+ * goes through `sudo -n -H -u`.
+ * privileged run as root (systemctl, nginx -t, reading 0600 env files); `sudo -n` unless root.
+ * readFile(path, { privileged }) -> string | null
+ * writeFile(path, content, { privileged, mode }) ; appendFile(path, text) ; removeFile(path, { privileged })
+ * symlink(target, path, { privileged }) ; readlink(path) -> string | null
+ * stat(path) -> { mode, uid, gid, owner, isFile, isDir, isSymlink, size } | null
+ * readdir(path) -> [{ name, isDir }] | null
+ * mkdir(path, { owner, mode }) ; createExclusive(path, content) -> bool ; pidAlive(pid) -> bool
+ * http(url, { timeoutMs, headers }) -> { status, body } | { status: 0, error }
+ * sqlite(db, sql, { as }) -> rows ; sqliteBackup(db, dest, { as })
+ * listeners(port) -> [{ pid, process }]
+ * sleep(ms) ; now() -> ms ; isRoot() ; userName() ; hostname()
+ */
+const { spawn } = require('child_process');
+const fs = require('fs');
+const httpLib = require('http');
+const os = require('os');
+const path = require('path');
+
+const WORKER = path.join(__dirname, 'sqlite-worker.js');
+
+function passwdEntry(user) {
+ try {
+ for (const line of fs.readFileSync('/etc/passwd', 'utf8').split('\n')) {
+ const f = line.split(':');
+ if (f[0] === user) return { name: f[0], uid: Number(f[2]), gid: Number(f[3]), home: f[5] };
+ }
+ } catch { /* no passwd file: callers fall back */ }
+ return null;
+}
+
+function userById(uid) {
+ try {
+ for (const line of fs.readFileSync('/etc/passwd', 'utf8').split('\n')) {
+ const f = line.split(':');
+ if (Number(f[2]) === uid) return f[0];
+ }
+ } catch { /* ignore */ }
+ return String(uid);
+}
+
+function createSystemExecutor({ env = process.env } = {}) {
+ const isRoot = () => typeof process.getuid === 'function' && process.getuid() === 0;
+ const me = () => { try { return os.userInfo().username; } catch { return env.USER || 'unknown'; } };
+
+ function wrap(cmd, args, { as, privileged } = {}) {
+ if (as && as !== me()) {
+ if (isRoot()) {
+ const pw = passwdEntry(as);
+ const home = pw ? pw.home : `/home/${as}`;
+ return ['runuser', ['-u', as, '--', 'env', `HOME=${home}`, `PATH=${env.PATH || '/usr/local/bin:/usr/bin:/bin'}`, cmd, ...args]];
+ }
+ return ['sudo', ['-n', '-H', '-u', as, '--', cmd, ...args]];
+ }
+ if (privileged && !isRoot()) return ['sudo', ['-n', '--', cmd, ...args]];
+ return [cmd, args];
+ }
+
+ function run(cmd, args = [], opts = {}) {
+ const [c, a] = wrap(cmd, args, opts);
+ return new Promise((resolve) => {
+ let stdout = '';
+ let stderr = '';
+ let child;
+ try {
+ // A process started as another user from root's cwd (/root) cannot read its own cwd, and
+ // git refuses to start; give it / unless the caller chose a directory.
+ child = spawn(c, a, { cwd: opts.cwd || (opts.as ? '/' : undefined), env: { ...env, ...(opts.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
+ } catch (err) {
+ resolve({ code: 127, stdout: '', stderr: err.message });
+ return;
+ }
+ const timer = opts.timeoutMs ? setTimeout(() => child.kill('SIGKILL'), opts.timeoutMs) : null;
+ child.stdout.on('data', (d) => { stdout += d; });
+ child.stderr.on('data', (d) => { stderr += d; });
+ child.on('error', (err) => { stderr += err.message; });
+ child.on('close', (code) => {
+ if (timer) clearTimeout(timer);
+ resolve({ code: code === null ? 124 : code, stdout, stderr });
+ });
+ if (opts.input) child.stdin.end(opts.input); else child.stdin.end();
+ });
+ }
+
+ async function readFile(p, { privileged } = {}) {
+ try {
+ return fs.readFileSync(p, 'utf8');
+ } catch (err) {
+ if (err.code === 'ENOENT') return null;
+ if (err.code === 'EACCES' && privileged && !isRoot()) {
+ const r = await run('cat', ['--', p], { privileged: true });
+ return r.code === 0 ? r.stdout : null;
+ }
+ throw err;
+ }
+ }
+
+ async function writeFile(p, content, { privileged, mode = 0o644 } = {}) {
+ if (!privileged || isRoot()) {
+ fs.writeFileSync(p, content, { mode });
+ fs.chmodSync(p, mode);
+ return;
+ }
+ const r = await run('install', ['-m', mode.toString(8), '/dev/stdin', p], { privileged: true, input: content });
+ if (r.code !== 0) throw new Error(`could not write ${p}: ${r.stderr.trim()}`);
+ }
+
+ async function removeFile(p, { privileged } = {}) {
+ if (!privileged || isRoot()) { fs.rmSync(p, { force: true }); return; }
+ await run('rm', ['-f', '--', p], { privileged: true });
+ }
+
+ async function symlink(target, p, { privileged } = {}) {
+ if (!privileged || isRoot()) { fs.symlinkSync(target, p); return; }
+ const r = await run('ln', ['-s', target, p], { privileged: true });
+ if (r.code !== 0) throw new Error(`could not link ${p}: ${r.stderr.trim()}`);
+ }
+
+ async function readlink(p) { try { return fs.readlinkSync(p); } catch { return null; } }
+
+ async function stat(p) {
+ try {
+ const l = fs.lstatSync(p);
+ const s = l.isSymbolicLink() ? fs.statSync(p) : l;
+ return { mode: s.mode & 0o7777, uid: s.uid, gid: s.gid, owner: userById(s.uid), isFile: s.isFile(), isDir: s.isDirectory(), isSymlink: l.isSymbolicLink(), size: s.size };
+ } catch { return null; }
+ }
+
+ async function readdir(p) {
+ try { return fs.readdirSync(p, { withFileTypes: true }).map((d) => ({ name: d.name, isDir: d.isDirectory() })); } catch { return null; }
+ }
+
+ async function mkdir(p, { owner, mode = 0o750 } = {}) {
+ fs.mkdirSync(p, { recursive: true, mode });
+ if (owner && isRoot()) {
+ const pw = passwdEntry(owner);
+ if (pw) fs.chownSync(p, pw.uid, pw.gid);
+ }
+ }
+
+ async function appendFile(p, text) {
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o750 });
+ fs.appendFileSync(p, text, { mode: 0o640 });
+ }
+
+ async function createExclusive(p, content) {
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o750 });
+ try { fs.writeFileSync(p, content, { flag: 'wx', mode: 0o640 }); return true; } catch (err) { if (err.code === 'EEXIST') return false; throw err; }
+ }
+
+ function pidAlive(pid) {
+ try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
+ }
+
+ // node:http rather than fetch: fetch silently drops a Host header, and the Tools gateway only
+ // answers its health check for Host: openvibe.tools. Probes are loopback http:// only.
+ function http(url, { timeoutMs = 5000, headers = {} } = {}) {
+ return new Promise((resolve) => {
+ let req;
+ const timer = setTimeout(() => { if (req) req.destroy(); resolve({ status: 0, error: 'timeout' }); }, timeoutMs);
+ try {
+ req = httpLib.get(url, { headers }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (c) => { if (body.length < 4 * 1024 * 1024) body += c; });
+ res.on('end', () => { clearTimeout(timer); resolve({ status: res.statusCode, body }); });
+ res.on('error', (err) => { clearTimeout(timer); resolve({ status: 0, error: err.code || err.message }); });
+ });
+ req.on('error', (err) => { clearTimeout(timer); resolve({ status: 0, error: err.code || err.message }); });
+ } catch (err) {
+ clearTimeout(timer);
+ resolve({ status: 0, error: err.message });
+ }
+ });
+ }
+
+ async function sqliteCall(payload, as) {
+ const r = await run(process.execPath, [WORKER, JSON.stringify(payload)], { as, timeoutMs: 6 * 3600 * 1000 });
+ let out;
+ try { out = JSON.parse(r.stdout); } catch { throw new Error(`sqlite worker failed: ${(r.stderr || r.stdout).trim().slice(0, 300)}`); }
+ if (!out.ok) throw new Error(out.error);
+ return out;
+ }
+
+ async function listeners(port) {
+ const r = await run('ss', ['-H', '-ltnp', `sport = :${port}`], { privileged: true });
+ if (r.code !== 0) throw new Error(`ss failed: ${r.stderr.trim()}`);
+ const out = [];
+ for (const line of r.stdout.split('\n')) {
+ if (!line.trim()) continue;
+ const users = line.match(/users:\(\((.*)\)\)/);
+ const procs = users ? [...users[1].matchAll(/"([^"]+)",pid=(\d+)/g)] : [];
+ if (!procs.length) out.push({ pid: null, process: null });
+ for (const m of procs) out.push({ pid: Number(m[2]), process: m[1] });
+ }
+ return out;
+ }
+
+ return {
+ kind: 'system',
+ run,
+ readFile,
+ writeFile,
+ removeFile,
+ symlink,
+ readlink,
+ stat,
+ readdir,
+ mkdir,
+ appendFile,
+ createExclusive,
+ pidAlive: async (pid) => pidAlive(pid),
+ http,
+ sqlite: async (db, sql, { as } = {}) => (await sqliteCall({ op: 'query', db, sql }, as)).rows,
+ sqliteBackup: async (db, dest, { as } = {}) => { await sqliteCall({ op: 'backup', db, dest }, as); },
+ listeners,
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
+ now: () => Date.now(),
+ isRoot: async () => isRoot(),
+ userName: async () => me(),
+ hostname: async () => os.hostname(),
+ };
+}
+
+module.exports = { createSystemExecutor };
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+/**
+ * git adapter. Every command runs AS THE CHECKOUT OWNER (ubuntu on the host): root running git in
+ * an ubuntu-owned checkout either trips "dubious ownership" or leaves root-owned objects behind
+ * that break the next pull.
+ */
+function git(exec, svc) {
+ const run = async (args, { allowFail = false } = {}) => {
+ const r = await exec.run('git', ['-C', svc.repo, ...args], { as: svc.owner });
+ if (r.code !== 0 && !allowFail) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || r.stdout).trim().split('\n').slice(-3).join(' / ')}`);
+ return r;
+ };
+ return {
+ head: async () => (await run(['rev-parse', 'HEAD'])).stdout.trim(),
+ branch: async () => (await run(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim(),
+ revParse: async (ref) => (await run(['rev-parse', '--verify', `${ref}^{commit}`])).stdout.trim(),
+ hasCommit: async (sha) => (await run(['cat-file', '-e', `${sha}^{commit}`], { allowFail: true })).code === 0,
+ /** Tracked changes only; untracked files (data/, local notes) never block a deploy. */
+ dirty: async () => (await run(['status', '--porcelain', '--untracked-files=no'])).stdout.split('\n').filter(Boolean).map((l) => l.slice(3)),
+ fetch: async () => { await run(['fetch', '--quiet', svc.remote, svc.branch]); },
+ changedFiles: async (from, to) => (await run(['diff', '--name-only', from, to])).stdout.split('\n').filter(Boolean),
+ log: async (from, to) => (await run(['log', '--oneline', '--no-decorate', `${from}..${to}`], { allowFail: true })).stdout.split('\n').filter(Boolean),
+ show: async (sha, file) => { const r = await run(['show', `${sha}:${file}`], { allowFail: true }); return r.code === 0 ? r.stdout : null; },
+ ffMerge: async (sha) => { await run(['merge', '--ff-only', '--quiet', sha]); },
+ resetHard: async (sha) => { await run(['reset', '--hard', '--quiet', sha]); },
+ checkoutFile: async (file) => { await run(['checkout', '--', file]); },
+ remoteUrl: async () => redactUrl((await run(['config', '--get', `remote.${svc.remote}.url`], { allowFail: true })).stdout.trim()),
+ };
+}
+
+/** https://user:[email protected]/... -> https://github.com/... */
+function redactUrl(url) {
+ return String(url || '').replace(/^(\w+:\/\/)[^@/]+@/, '$1');
+}
+
+module.exports = { git, redactUrl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+/**
+ * OpenVibe.Host Stage A library. Every function takes a context { exec, inv, log }: `exec` is the
+ * system executor (lib/executor.js) or the fake host in tests, `inv` the loaded inventory.
+ */
+const inventory = require('./inventory');
+const ops = require('./release-ops');
+const { validate, envNames } = require('./commands/validate');
+const { status } = require('./commands/status');
+const { backup } = require('./commands/backup');
+const { snapshot } = require('./commands/snapshot');
+const releases = require('./releases');
+const certs = require('./certs');
+const nginx = require('./nginx');
+const { createSystemExecutor } = require('./executor');
+
+module.exports = {
+ createSystemExecutor,
+ inventory,
+ validate,
+ envNames,
+ plan: ops.plan,
+ deploy: ops.deploy,
+ rollback: ops.rollback,
+ status,
+ backup,
+ snapshot,
+ releases,
+ certs,
+ nginx,
+ EXIT: ops.EXIT,
+ OpError: ops.OpError,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+'use strict';
+/**
+ * The host inventory: one JSON file describing every service on the host (see host.example.json).
+ * The real file lives at /etc/openvibe/host.json and is never committed.
+ *
+ * load() normalises every entry and rejects anything that could turn a typo into damage: relative
+ * paths, unit names that are not units, a socket unit listed as a service unit, vhost names with a
+ * path in them. When ovhost runs as root the inventory must be root-owned and not group/world
+ * writable — otherwise anyone who can edit it could point a root process at arbitrary files.
+ */
+const path = require('path');
+
+const DEFAULT_PATHS = ['/etc/openvibe/host.json'];
+
+class InventoryError extends Error {}
+
+const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
+const UNIT_RE = /^[A-Za-z0-9@._-]+\.(service|socket)$/;
+const FILE_NAME_RE = /^[A-Za-z0-9._-]+$/;
+const DRAIN_POLICIES = ['refuse', 'wait', 'report'];
+const PROBE_KINDS = ['http-json-count', 'sqlite-count'];
+
+function abs(value, where) {
+ if (typeof value !== 'string' || !path.isAbsolute(value) || value.includes('\0')) throw new InventoryError(`${where} must be an absolute path`);
+ return path.normalize(value);
+}
+
+function rel(value, where) {
+ if (typeof value !== 'string' || !value || path.isAbsolute(value) || path.normalize(value).startsWith('..')) throw new InventoryError(`${where} must be a path inside the checkout`);
+ return path.normalize(value);
+}
+
+function normaliseService(id, raw, defaults) {
+ if (!ID_RE.test(id)) throw new InventoryError(`service id "${id}" must match ${ID_RE}`);
+ const where = `services.${id}`;
+ const s = { ...defaults, ...raw };
+ const out = {
+ id,
+ manifest: s.manifest || id,
+ description: s.description || null,
+ repo: abs(s.repo, `${where}.repo`),
+ owner: s.owner,
+ runAs: s.runAs || s.owner,
+ remote: s.remote || 'origin',
+ branch: s.branch || 'main',
+ managed: s.managed !== false,
+ unmanagedReason: s.unmanagedReason || null,
+ units: s.units || [],
+ socketUnit: s.socketUnit || null,
+ unitSources: s.unitSources || {},
+ envFile: s.envFile ? abs(s.envFile, `${where}.envFile`) : null,
+ env: { required: (s.env && s.env.required) || [], example: (s.env && s.env.example) || '.env.example' },
+ port: s.port == null ? null : Number(s.port),
+ ready: s.ready || null,
+ health: s.health || null,
+ packages: s.packages || ['.'],
+ install: {
+ command: (s.install && s.install.command) || ['npm', 'install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'],
+ always: !!(s.install && s.install.always),
+ restoreLockfile: !(s.install && s.install.restoreLockfile === false),
+ },
+ build: s.build || [],
+ noRestartPaths: s.noRestartPaths || [],
+ protected: s.protected || null,
+ drain: { policy: 'refuse', waitMaxSeconds: 8 * 3600, pollSeconds: 60, quietChecks: 2, ...(s.drain || {}) },
+ databases: (s.databases || []).map((d, i) => {
+ const name = d.name || path.basename(String(d.path), '.db');
+ if (!FILE_NAME_RE.test(name)) throw new InventoryError(`${where}.databases[${i}].name must be a plain name`);
+ return { name, path: abs(d.path, `${where}.databases[${i}].path`) };
+ }),
+ backupOnChange: s.backupOnChange || [],
+ nginx: s.nginx || null,
+ };
+ if (typeof out.owner !== 'string' || !/^[a-z_][a-z0-9_-]*$/.test(out.owner)) throw new InventoryError(`${where}.owner must be a user name`);
+ if (!/^[a-z_][a-z0-9_-]*$/.test(out.runAs)) throw new InventoryError(`${where}.runAs must be a user name`);
+ if (!Array.isArray(out.units)) throw new InventoryError(`${where}.units must be an array`);
+ for (const u of out.units) {
+ if (!UNIT_RE.test(u)) throw new InventoryError(`${where}.units: "${u}" is not a unit name`);
+ if (u.endsWith('.socket')) throw new InventoryError(`${where}.units: "${u}" is a socket unit — declare it as socketUnit; ovhost never restarts or stops socket units`);
+ }
+ if (out.socketUnit && !(UNIT_RE.test(out.socketUnit) && out.socketUnit.endsWith('.socket'))) throw new InventoryError(`${where}.socketUnit must be a .socket unit`);
+ for (const [unit, src] of Object.entries(out.unitSources)) {
+ if (!UNIT_RE.test(unit) && !/^[A-Za-z0-9@._-]+\.(service|socket)\.d\/[A-Za-z0-9._-]+\.conf$/.test(unit)) throw new InventoryError(`${where}.unitSources: "${unit}" is not a unit or drop-in name`);
+ rel(src, `${where}.unitSources.${unit}`);
+ }
+ if (out.port !== null && !(Number.isInteger(out.port) && out.port > 0 && out.port < 65536)) throw new InventoryError(`${where}.port must be a TCP port`);
+ if (out.ready) {
+ if (typeof out.ready.url !== 'string' || !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])[:/]/.test(out.ready.url)) throw new InventoryError(`${where}.ready.url must be a loopback http:// URL`);
+ out.ready = { timeoutSeconds: 90, headers: {}, ...out.ready };
+ }
+ for (const p of out.packages) if (p !== '.') rel(p.replace(/\/\*$/, ''), `${where}.packages`);
+ if (!Array.isArray(out.install.command) || !out.install.command.length) throw new InventoryError(`${where}.install.command must be an argv array`);
+ for (const b of out.build) if (!Array.isArray(b) || !b.length) throw new InventoryError(`${where}.build entries must be argv arrays`);
+ if (!DRAIN_POLICIES.includes(out.drain.policy)) throw new InventoryError(`${where}.drain.policy must be one of ${DRAIN_POLICIES.join(', ')}`);
+ if (out.protected) {
+ const p = out.protected;
+ if (!PROBE_KINDS.includes(p.kind)) throw new InventoryError(`${where}.protected.kind must be one of ${PROBE_KINDS.join(', ')}`);
+ if (p.kind === 'http-json-count' && !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])[:/]/.test(p.url || '')) throw new InventoryError(`${where}.protected.url must be a loopback http:// URL`);
+ if (p.kind === 'sqlite-count') {
+ abs(p.db, `${where}.protected.db`);
+ if (!/^\s*select\b/i.test(p.sql || '')) throw new InventoryError(`${where}.protected.sql must be a SELECT`);
+ }
+ }
+ if (out.nginx) {
+ const n = out.nginx;
+ if (n.vhost && !FILE_NAME_RE.test(n.vhost)) throw new InventoryError(`${where}.nginx.vhost must be a file name, not a path`);
+ if (n.repoVhost) rel(n.repoVhost, `${where}.nginx.repoVhost`);
+ if (n.repoVhosts) rel(n.repoVhosts.replace(/\*\.conf$/, 'x.conf'), `${where}.nginx.repoVhosts`);
+ if (n.variant && !['http', 'sse', 'websocket'].includes(n.variant)) throw new InventoryError(`${where}.nginx.variant must be http, sse or websocket`);
+ if (n.certName && !FILE_NAME_RE.test(n.certName)) throw new InventoryError(`${where}.nginx.certName must be a name`);
+ }
+ for (const p of out.backupOnChange) rel(p, `${where}.backupOnChange`);
+ return out;
+}
+
+function normalise(raw) {
+ if (!raw || typeof raw !== 'object' || !raw.services || typeof raw.services !== 'object') throw new InventoryError('inventory needs a "services" object');
+ const defaults = raw.defaults || {};
+ const inv = {
+ host: raw.host || null,
+ stateDir: abs(raw.stateDir || '/var/lib/openvibe-host', 'stateDir'),
+ backupDir: abs(raw.backupDir || '/var/backups/openvibe', 'backupDir'),
+ nginx: {
+ sitesAvailable: abs((raw.nginx && raw.nginx.sitesAvailable) || '/etc/nginx/sites-available', 'nginx.sitesAvailable'),
+ sitesEnabled: abs((raw.nginx && raw.nginx.sitesEnabled) || '/etc/nginx/sites-enabled', 'nginx.sitesEnabled'),
+ bin: (raw.nginx && raw.nginx.bin) || 'nginx',
+ },
+ services: {},
+ };
+ for (const [id, svc] of Object.entries(raw.services)) {
+ if (id.startsWith('_')) continue;
+ inv.services[id] = normaliseService(id, svc, defaults);
+ }
+ return inv;
+}
+
+/** Resolve and read the inventory through the executor. Root requires a root-owned, non-writable file. */
+async function load(exec, { file, env = process.env } = {}) {
+ const candidates = file ? [file] : env.OVHOST_INVENTORY ? [env.OVHOST_INVENTORY] : DEFAULT_PATHS;
+ for (const f of candidates) {
+ const st = await exec.stat(f);
+ if (!st) continue;
+ if (await exec.isRoot()) {
+ if (st.uid !== 0) throw new InventoryError(`${f} must be owned by root when ovhost runs as root`);
+ if (st.mode & 0o022) throw new InventoryError(`${f} must not be group- or world-writable when ovhost runs as root`);
+ }
+ const text = await exec.readFile(f, { privileged: true });
+ let raw;
+ try { raw = JSON.parse(text); } catch (err) { throw new InventoryError(`${f} is not valid JSON: ${err.message}`); }
+ const inv = normalise(raw);
+ inv.file = f;
+ return inv;
+ }
+ throw new InventoryError(`no inventory found (looked at ${candidates.join(', ')}); copy host.example.json to /etc/openvibe/host.json`);
+}
+
+function service(inv, id) {
+ const s = inv.services[id];
+ if (!s) throw new InventoryError(`unknown service "${id}" (inventory has: ${Object.keys(inv.services).join(', ')})`);
+ return s;
+}
+
+module.exports = { load, normalise, service, InventoryError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+'use strict';
+/** One operation per service at a time: <stateDir>/locks/<service>.lock holds the owner's pid. */
+const path = require('path');
+
+class LockedError extends Error {}
+
+async function acquire(exec, inv, serviceId, pid = process.pid) {
+ const file = path.join(inv.stateDir, 'locks', `${serviceId}.lock`);
+ const body = JSON.stringify({ pid, at: new Date(exec.now()).toISOString() });
+ if (await exec.createExclusive(file, body)) return { file, release: () => exec.removeFile(file) };
+ let holder = null;
+ try { holder = JSON.parse(await exec.readFile(file)); } catch { /* unreadable lock: treat as stale */ }
+ if (holder && holder.pid && await exec.pidAlive(holder.pid)) {
+ throw new LockedError(`another ovhost operation on ${serviceId} is running (pid ${holder.pid}, since ${holder.at}); lock: ${file}`);
+ }
+ await exec.removeFile(file);
+ if (await exec.createExclusive(file, body)) return { file, release: () => exec.removeFile(file), recoveredStale: true };
+ throw new LockedError(`could not take the lock ${file}`);
+}
+
+module.exports = { acquire, LockedError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * nginx: render a vhost from a service manifest + template, install vhosts safely, and run nginx -t.
+ *
+ * Installing is always transactional: write the new files, `nginx -t`, and on failure put back
+ * exactly what was there before (previous content, or no file / no link) — nginx is only reloaded
+ * after a clean test. Nothing under /etc is written unless the caller asked to install.
+ */
+const fs = require('fs');
+const path = require('path');
+const systemd = require('./systemd');
+
+const TEMPLATE_DIR = path.join(__dirname, '..', 'templates', 'nginx');
+
+function fill(template, vars) {
+ return template.replace(/\{\{(\w+)\}\}/g, (_, k) => {
+ if (!(k in vars)) throw new Error(`template variable {{${k}}} has no value`);
+ return String(vars[k]);
+ });
+}
+
+function readTemplate(name) {
+ return fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf8');
+}
+
+/** openvibe.network for events.openvibe.network: the host's certificates are per registrable domain (wildcards). */
+function defaultCertName(domain) {
+ return domain.split('.').slice(-2).join('.');
+}
+
+function loadManifest(id, manifestFile) {
+ if (manifestFile) return { manifest: JSON.parse(fs.readFileSync(manifestFile, 'utf8')), contractsVersion: `file ${path.basename(manifestFile)}` };
+ const contracts = require('openvibe-contracts');
+ const version = require('openvibe-contracts/package.json').version;
+ return { manifest: contracts.services.get(id) || null, contractsVersion: `v${version}` };
+}
+
+/**
+ * -> { name, text } for svc. Domains come from the manifest (inventory nginx.domains overrides);
+ * the port from the inventory; the variant from --variant or the inventory.
+ */
+function render(inv, svc, { variant, manifestFile } = {}) {
+ const n = svc.nginx || {};
+ const { manifest, contractsVersion } = loadManifest(svc.manifest, manifestFile);
+ const domains = n.domains || (manifest && manifest.domains) || [];
+ if (!domains.length) throw new Error(`no domains for ${svc.id}: the "${svc.manifest}" manifest lists none and the inventory sets no nginx.domains`);
+ if (!svc.port) throw new Error(`${svc.id} has no port in the inventory`);
+ const v = variant || n.variant || 'http';
+ if (!['http', 'sse', 'websocket'].includes(v)) throw new Error(`unknown variant ${v} (http, sse, websocket)`);
+ for (const d of domains) if (!/^[a-z0-9.-]+$/i.test(d)) throw new Error(`domain "${d}" is not a host name`);
+ const zone = `ov${svc.id.replace(/[^a-z0-9]/g, '')}`;
+ const primary = domains[0];
+ const base = { port: svc.port, zone };
+ let locations = '';
+ let httpContext = '';
+ if (v === 'sse') {
+ const paths = n.ssePaths || ['/realtime/stream'];
+ locations = paths.map((p) => fill(readTemplate('location-sse.tmpl'), { ...base, path: safePath(p) })).join('');
+ } else if (v === 'websocket') {
+ const paths = n.wsPaths || ['/ws/'];
+ httpContext = `\n${fill(readTemplate('http-websocket.tmpl'), base)}`;
+ locations = paths.map((p) => fill(readTemplate('location-websocket.tmpl'), { ...base, path: safePath(p) })).join('');
+ }
+ const name = n.vhost || `${primary}.conf`;
+ const text = fill(readTemplate('vhost.conf.tmpl'), {
+ ...base,
+ primary,
+ service: svc.id,
+ manifestId: svc.manifest,
+ contractsVersion,
+ variant: v,
+ vhost: name,
+ sitesAvailable: inv.nginx.sitesAvailable,
+ serverNames: domains.join(' '),
+ certName: n.certName || defaultCertName(primary),
+ maxBody: n.maxBody || '2m',
+ locations,
+ httpContext,
+ });
+ return { name, text };
+}
+
+function safePath(p) {
+ if (!/^\/[A-Za-z0-9/._-]*$/.test(p)) throw new Error(`location path "${p}" is not a plain path`);
+ return p;
+}
+
+async function test(exec, inv) {
+ const r = await exec.run(inv.nginx.bin, ['-t'], { privileged: true });
+ return { ok: r.code === 0, output: `${r.stderr}${r.stdout}`.trim() };
+}
+
+/**
+ * files: [{ name, text }]. Writes changed files to sites-available, links them into sites-enabled,
+ * runs nginx -t; restores the previous state on failure; reloads nginx on success.
+ * -> { changed: [names], reloaded }
+ */
+async function install(exec, inv, files, { log = () => {} } = {}) {
+ const undo = [];
+ const changed = [];
+ for (const f of files) {
+ if (!/^[A-Za-z0-9._-]+$/.test(f.name)) throw new Error(`vhost name "${f.name}" is not a file name`);
+ const available = path.join(inv.nginx.sitesAvailable, f.name);
+ const enabled = path.join(inv.nginx.sitesEnabled, f.name);
+ const prev = await exec.readFile(available, { privileged: true });
+ if (prev !== f.text) {
+ undo.push(prev == null ? () => exec.removeFile(available, { privileged: true }) : () => exec.writeFile(available, prev, { privileged: true, mode: 0o644 }));
+ await exec.writeFile(available, f.text, { privileged: true, mode: 0o644 });
+ changed.push(f.name);
+ }
+ if (!(await exec.stat(enabled))) {
+ await exec.symlink(available, enabled, { privileged: true });
+ undo.push(() => exec.removeFile(enabled, { privileged: true }));
+ if (!changed.includes(f.name)) changed.push(f.name);
+ }
+ }
+ if (!changed.length) return { changed, reloaded: false };
+ const t = await test(exec, inv);
+ if (!t.ok) {
+ for (const u of undo.reverse()) await u();
+ const err = new Error(`nginx -t failed; the previous vhost files were restored:\n${t.output}`);
+ err.nginxOutput = t.output;
+ throw err;
+ }
+ await systemd.reloadNginx(exec);
+ log(`nginx: installed ${changed.join(', ')}; nginx -t clean; reloaded`);
+ return { changed, reloaded: true };
+}
+
+module.exports = { render, test, install, defaultCertName, fill };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * Protected-session probes: how many sessions a restart of this service would cut.
+ *
+ * http-json-count GET a loopback URL and count: `array` (dotted path) filtered by `where`
+ * (key: value equality), or a numeric `field` (dotted path).
+ * Live: /api/streams, streams where is_live. Events: /api/ready realtime_connections.
+ * sqlite-count a read-only SELECT against the service's database, run as the service user.
+ * Media: SELECT count(*) FROM vods WHERE is_recording = 1.
+ *
+ * A probe that cannot answer while the service is running is UNKNOWN, never zero: "could not ask"
+ * must not be read as "nobody is live". (Live's deploy.sh counted a failed curl as 0.)
+ */
+const systemd = require('./systemd');
+
+function at(obj, dotted) {
+ return String(dotted || '').split('.').filter(Boolean).reduce((o, k) => (o == null ? undefined : o[k]), obj);
+}
+
+async function running(exec, svc) {
+ if (!svc.units.length) return false;
+ for (const u of svc.units) {
+ const st = await systemd.show(exec, u);
+ if (['active', 'activating', 'reloading', 'deactivating'].includes(st.active)) return true;
+ }
+ return false;
+}
+
+/** -> { count: number|null, label, unknown?: reason, notRunning?: true } */
+async function countProtected(exec, svc) {
+ const p = svc.protected;
+ if (!p) return { count: 0, label: 'protected sessions', none: true };
+ const label = p.label || 'protected sessions';
+ if (!(await running(exec, svc))) return { count: 0, label, notRunning: true };
+ try {
+ if (p.kind === 'http-json-count') {
+ const r = await exec.http(p.url, { timeoutMs: 5000, headers: p.headers || {} });
+ if (r.status < 200 || r.status >= 300) return { count: null, label, unknown: r.status ? `HTTP ${r.status}` : r.error || 'unreachable' };
+ let body;
+ try { body = JSON.parse(r.body); } catch { return { count: null, label, unknown: 'response is not JSON' }; }
+ if (p.array) {
+ const arr = at(body, p.array);
+ if (!Array.isArray(arr)) return { count: null, label, unknown: `no array at "${p.array}"` };
+ const where = p.where || {};
+ // `true` matches any truthy value (SQLite rows say is_live: 1).
+ const matches = (x) => Object.entries(where).every(([k, v]) => {
+ const actual = x == null ? undefined : x[k];
+ return v === true ? Boolean(actual) && actual !== '0' : actual === v;
+ });
+ return { count: arr.filter(matches).length, label };
+ }
+ const n = Number(at(body, p.field));
+ if (!Number.isFinite(n)) return { count: null, label, unknown: `no number at "${p.field}"` };
+ return { count: n, label };
+ }
+ if (p.kind === 'sqlite-count') {
+ const rows = await exec.sqlite(p.db, p.sql, { as: svc.runAs });
+ const first = rows && rows[0] ? Object.values(rows[0])[0] : undefined;
+ const n = Number(first);
+ if (!Number.isFinite(n)) return { count: null, label, unknown: 'query returned no number' };
+ return { count: n, label };
+ }
+ } catch (err) {
+ return { count: null, label, unknown: err.message };
+ }
+ return { count: null, label, unknown: `unknown probe kind ${p.kind}` };
+}
+
+module.exports = { countProtected, running, at };
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+/**
+ * Readiness: poll the service's loopback ready URL until it answers 2xx or the timeout passes.
+ * A unit that systemd reports as `failed` ends the wait early — there is nothing left to wait for.
+ */
+const systemd = require('./systemd');
+
+async function probe(exec, svc) {
+ if (!svc.ready) return { ok: null, detail: 'no ready URL declared' };
+ const t0 = exec.now();
+ const r = await exec.http(svc.ready.url, { timeoutMs: 5000, headers: svc.ready.headers || {} });
+ return { ok: r.status >= 200 && r.status < 300, status: r.status, error: r.error, latencyMs: exec.now() - t0 };
+}
+
+async function waitReady(exec, svc, { timeoutSeconds, log = () => {} } = {}) {
+ if (!svc.ready) return { ok: true, skipped: true, seconds: 0 };
+ const limit = (timeoutSeconds || svc.ready.timeoutSeconds || 90) * 1000;
+ const start = exec.now();
+ let last = null;
+ while (exec.now() - start < limit) {
+ last = await probe(exec, svc);
+ if (last.ok) return { ok: true, seconds: Math.round((exec.now() - start) / 1000) };
+ for (const u of svc.units) {
+ const st = await systemd.show(exec, u);
+ if (st.active === 'failed') {
+ log(`${u} is in state failed`);
+ return { ok: false, reason: `${u} failed`, seconds: Math.round((exec.now() - start) / 1000) };
+ }
+ }
+ await exec.sleep(1000);
+ }
+ return { ok: false, reason: last ? (last.status ? `HTTP ${last.status}` : last.error || 'unreachable') : 'never probed', seconds: Math.round((exec.now() - start) / 1000) };
+}
+
+module.exports = { probe, waitReady };
new file mode 100644
new file mode 100644
@@ -0,0 +1,438 @@
@@ -0,0 +1,438 @@
+'use strict';
+/**
+ * plan, deploy and rollback for a service that runs from a git checkout (every service on the host
+ * today). The rules this encodes come from the per-repository deploy scripts it replaces:
+ *
+ * - git and npm run as the checkout owner; nothing root-owned is left under the checkout.
+ * - a tracked local change blocks the deploy (untracked files do not).
+ * - dependencies install only when package.json dependency fields or the lockfile changed
+ * (Network), the lockfile is restored from git afterwards, and EVERY dependency must resolve
+ * before anything restarts (Tools).
+ * - protected sessions are checked before the checkout moves and again right before the restart
+ * (Live: /api/streams; Media: recordings in progress). With sessions active the deploy refuses,
+ * or waits with --wait-idle; --force drops them and says so loudly. A probe that cannot answer is
+ * treated as "sessions may be active", never as zero.
+ * - only .service units are restarted; a socket unit is started if it is down, never stopped.
+ * - readiness is polled after the restart; if it fails the checkout goes back to the previous sha,
+ * dependencies are reinstalled if they differed, and the service is restarted again.
+ * - every attempt (including refusals) is appended to the release log.
+ *
+ * Exit codes: 0 ok · 1 usage/precondition · 2 validation failed, nothing restarted ·
+ * 3 not ready, rolled back and serving · 4 rollback failed — MANUAL INTERVENTION ·
+ * 5 protected sessions active (refused, or --wait-idle gave up)
+ */
+const path = require('path');
+const { git } = require('./git');
+const deps = require('./deps');
+const systemd = require('./systemd');
+const probes = require('./probes');
+const readiness = require('./readiness');
+const releases = require('./releases');
+const lock = require('./lock');
+const nginx = require('./nginx');
+
+const EXIT = { OK: 0, USAGE: 1, VALIDATION: 2, ROLLED_BACK: 3, ROLLBACK_FAILED: 4, PROTECTED: 5 };
+
+class OpError extends Error {
+ constructor(message, exitCode, extra = {}) {
+ super(message);
+ this.exitCode = exitCode;
+ Object.assign(this, extra);
+ }
+}
+
+function matchesPath(file, patterns) {
+ return patterns.some((p) => {
+ if (p.endsWith('/')) return file.startsWith(p);
+ if (p.startsWith('*.')) return !file.includes('/') && file.endsWith(p.slice(1));
+ if (p.startsWith('**/*.')) return file.endsWith(p.slice(4));
+ return file === p;
+ });
+}
+
+async function operatorName(exec) {
+ return process.env.SUDO_USER || (await exec.userName());
+}
+
+// ── plan ─────────────────────────────────────────────────────────────────────
+
+/**
+ * What a deploy (or rollback) to `to` would do. Fetches the remote unless fetch === false; changes
+ * nothing else.
+ */
+async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true, restart = false } = {}) {
+ const { exec } = ctx;
+ if (to != null && !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(String(to))) throw new OpError(`"${to}" is not a sha or ref name`, EXIT.USAGE);
+ const g = git(exec, svc);
+ const from = await g.head();
+ const branch = await g.branch();
+ const dirty = await g.dirty();
+ let target;
+ if (mode === 'deploy') {
+ if (fetch) await g.fetch();
+ target = await g.revParse(to || `${svc.remote}/${svc.branch}`);
+ } else {
+ if (!to) throw new OpError('rollback needs a target sha', EXIT.USAGE);
+ if (!(await g.hasCommit(to))) throw new OpError(`commit ${to} is not in ${svc.repo}`, EXIT.USAGE);
+ target = await g.revParse(to);
+ }
+ const changed = from === target ? [] : await g.changedFiles(from, target);
+ const commits = from === target ? [] : mode === 'deploy' ? await g.log(from, target) : await g.log(target, from);
+
+ const pkgDirs = await deps.expandPackages(exec, svc);
+ for (const f of changed) {
+ if (path.basename(f) !== 'package.json') continue;
+ const dir = path.dirname(f) || '.';
+ const inPatterns = svc.packages.some((p) => p === dir || (p.endsWith('/*') && path.dirname(dir) === p.slice(0, -2)));
+ if (inPatterns && !pkgDirs.includes(dir)) pkgDirs.push(dir);
+ }
+ const installs = [];
+ for (const pkg of pkgDirs) {
+ const need = from === target ? { lockfileChanged: false, depsChanged: false, needed: false } : await deps.needsInstall(g, from, target, pkg);
+ const hasModules = !!(await exec.stat(path.join(svc.repo, pkg, 'node_modules')));
+ installs.push({ pkg, ...need, missingNodeModules: !hasModules, install: need.needed || svc.install.always || !hasModules });
+ }
+
+ const codeChanged = changed.filter((f) => !matchesPath(f, svc.noRestartPaths));
+ const restartNeeded = svc.units.length > 0 && (restart || codeChanged.length > 0);
+
+ const unitDrift = [];
+ for (const [unit, src] of Object.entries(svc.unitSources)) {
+ const want = await g.show(target, src);
+ if (want == null) continue;
+ const have = await exec.readFile(path.join('/etc/systemd/system', unit), { privileged: true });
+ if (have !== want) unitDrift.push({ unit, source: src, installed: have != null });
+ }
+
+ const backupNeeded = svc.databases.length > 0 && changed.some((f) => svc.backupOnChange.includes(f));
+ const sessions = restartNeeded ? await probes.countProtected(exec, svc) : null;
+
+ return {
+ service: svc.id,
+ mode,
+ repo: svc.repo,
+ branch,
+ expectedBranch: svc.branch,
+ from,
+ to: target,
+ upToDate: from === target,
+ dirty,
+ commits,
+ changedFiles: changed.length,
+ codeChanged: codeChanged.length,
+ lockfileChanged: installs.some((i) => i.lockfileChanged),
+ installs,
+ restartNeeded,
+ units: svc.units,
+ socketUnit: svc.socketUnit,
+ unitDrift,
+ backupNeeded,
+ protectedSessions: sessions,
+ drainPolicy: svc.drain.policy,
+ managed: svc.managed,
+ installVhosts: !!(svc.nginx && svc.nginx.installOnDeploy),
+ };
+}
+
+// ── protected sessions ───────────────────────────────────────────────────────
+
+async function guardSessions(ctx, svc, opts, record, when) {
+ const { exec, log } = ctx;
+ const first = await probes.countProtected(exec, svc);
+ record.protectedSessions = { count: first.count, label: first.label, unknown: first.unknown || null, checkedWhen: when };
+ if (first.notRunning || first.count === 0) return;
+ const describe = (r) => (r.count == null ? `${r.label}: unknown (${r.unknown}) — treated as active` : `${r.count} ${r.label}`);
+ if (opts.force) {
+ record.forced = true;
+ log('');
+ log('!!! --force: restarting with protected sessions active');
+ log(`!!! ${describe(first)} WILL BE DROPPED (${when})`);
+ log('');
+ return;
+ }
+ if (svc.drain.policy === 'report' && first.count != null) {
+ log(`${describe(first)}; drain policy "report": they reconnect after the restart`);
+ return;
+ }
+ const wait = opts.waitIdle || svc.drain.policy === 'wait';
+ if (!wait) {
+ throw new OpError(`${describe(first)} — refusing to restart ${svc.units.join(', ')} (${when}). Re-run with --wait-idle to hold until idle, or --force to drop them.`, EXIT.PROTECTED, { result: 'refused' });
+ }
+ const maxMs = svc.drain.waitMaxSeconds * 1000;
+ const pollMs = svc.drain.pollSeconds * 1000;
+ const start = exec.now();
+ let quiet = 0;
+ let last = first;
+ log(`${describe(first)}; --wait-idle: holding the restart (${when}); need ${svc.drain.quietChecks} idle checks ${svc.drain.pollSeconds}s apart, max ${svc.drain.waitMaxSeconds}s`);
+ while (exec.now() - start < maxMs) {
+ await exec.sleep(pollMs);
+ last = await probes.countProtected(exec, svc);
+ if (last.notRunning || last.count === 0) quiet += 1; else quiet = 0;
+ if (quiet >= svc.drain.quietChecks) {
+ record.protectedSessions.waitedSeconds = Math.round((exec.now() - start) / 1000);
+ record.protectedSessions.count = 0;
+ log(`idle for ${quiet} checks — proceeding (waited ${record.protectedSessions.waitedSeconds}s)`);
+ return;
+ }
+ log(`${describe(last)}; still holding (${Math.round((exec.now() - start) / 1000)}s)`);
+ }
+ throw new OpError(`${describe(last)} after waiting ${svc.drain.waitMaxSeconds}s — gave up; nothing was restarted`, EXIT.PROTECTED, { result: 'gave-up-waiting' });
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+async function installAll(ctx, svc, g, installs, record) {
+ for (const i of installs) {
+ if (!i.install) continue;
+ ctx.log(`installing dependencies in ${i.pkg} (${[i.lockfileChanged && 'lockfile changed', i.depsChanged && 'package.json dependencies changed', i.missingNodeModules && 'no node_modules', svc.install.always && 'always'].filter(Boolean).join(', ')})`);
+ await deps.install(ctx.exec, svc, g, i.pkg);
+ record.installed.push(i.pkg);
+ }
+}
+
+async function verifyDeps(ctx, svc, g) {
+ const pkgDirs = await deps.expandPackages(ctx.exec, svc);
+ const { problems, repaired } = await deps.verifyAndRepair(ctx.exec, svc, g, pkgDirs, ctx.log);
+ return { pkgDirs, problems, repaired };
+}
+
+async function runBuild(ctx, svc) {
+ for (const argv of svc.build) {
+ ctx.log(`build: ${argv.join(' ')}`);
+ const r = await ctx.exec.run(argv[0], argv.slice(1), { as: svc.owner, cwd: svc.repo, timeoutMs: 20 * 60 * 1000 });
+ if (r.code !== 0) throw new Error(`build step "${argv.join(' ')}" failed: ${(r.stderr || r.stdout).trim().split('\n').slice(-3).join(' / ')}`);
+ }
+}
+
+async function installRepoVhosts(ctx, svc) {
+ const n = svc.nginx;
+ const pattern = n.repoVhosts || n.repoVhost;
+ if (!pattern) return null;
+ let names;
+ if (pattern.endsWith('*.conf')) {
+ const dir = path.dirname(pattern);
+ names = ((await ctx.exec.readdir(path.join(svc.repo, dir))) || []).filter((e) => !e.isDir && e.name.endsWith('.conf')).map((e) => path.join(dir, e.name));
+ } else names = [pattern];
+ const files = [];
+ for (const rel of names.sort()) files.push({ name: path.basename(rel), text: await ctx.exec.readFile(path.join(svc.repo, rel)) });
+ return nginx.install(ctx.exec, ctx.inv, files, { log: ctx.log });
+}
+
+async function installUnits(ctx, svc, record) {
+ const changed = [];
+ for (const [unit, src] of Object.entries(svc.unitSources)) {
+ const want = await ctx.exec.readFile(path.join(svc.repo, src));
+ if (want == null) continue;
+ const dest = path.join('/etc/systemd/system', unit);
+ const have = await ctx.exec.readFile(dest, { privileged: true });
+ if (have === want) continue;
+ await ctx.exec.run('install', ['-m', '0644', '-D', path.join(svc.repo, src), dest], { privileged: true });
+ changed.push(unit);
+ }
+ if (changed.length) {
+ await systemd.daemonReload(ctx.exec);
+ ctx.log(`installed unit files: ${changed.join(', ')}; daemon-reload`);
+ }
+ record.unitsInstalled = changed;
+}
+
+async function backupBeforeRestart(ctx, svc, record) {
+ const { backup } = require('./commands/backup');
+ ctx.log('schema files changed — backing up the declared databases first');
+ const res = await backup(ctx, svc.id, { reason: `pre-deploy ${record.to.slice(0, 12)}` });
+ record.backup = res.files.map((f) => f.dest);
+}
+
+/** Restart the service units (never the socket) and poll readiness. -> { ok, reason } */
+async function restartAndWait(ctx, svc, opts = {}) {
+ if (svc.socketUnit) {
+ const s = await systemd.ensureSocketActive(ctx.exec, svc.socketUnit);
+ ctx.log(s.started ? `${svc.socketUnit} was ${s.state.active}; started it` : `${svc.socketUnit} active — new HTTP connections queue through the restart`);
+ }
+ for (const u of svc.units) {
+ ctx.log(`restarting ${u}`);
+ await systemd.restart(ctx.exec, u);
+ }
+ const r = await readiness.waitReady(ctx.exec, svc, { timeoutSeconds: opts.readyTimeout, log: ctx.log });
+ if (r.ok) ctx.log(r.skipped ? 'no ready URL declared; restart issued' : `ready after ${r.seconds}s`);
+ else ctx.log(`NOT READY after ${r.seconds}s (${r.reason}); see: journalctl -u ${svc.units[0]} -n 50`);
+ return r;
+}
+
+/** Put the checkout back on `sha` and make node_modules match it again. */
+async function restoreCheckout(ctx, svc, g, sha, record) {
+ ctx.log(`restoring the checkout to ${sha.slice(0, 12)}`);
+ await g.resetHard(sha);
+ if (record.installed.length) {
+ for (const pkg of record.installed) {
+ ctx.log(`reinstalling dependencies in ${pkg} for ${sha.slice(0, 12)}`);
+ await deps.install(ctx.exec, svc, g, pkg);
+ }
+ }
+ const v = await verifyDeps(ctx, svc, g);
+ if (v.problems.length) throw new Error(`after restoring ${sha.slice(0, 12)} these dependencies still do not resolve: ${v.problems.map((p) => `${p.pkg}:${p.dep}`).join(', ')}`);
+}
+
+// ── deploy / rollback ────────────────────────────────────────────────────────
+
+/**
+ * mode 'deploy': fast-forward to origin/<branch> (or opts.to). mode 'rollback': reset to opts.to.
+ * -> { exitCode, record }
+ */
+async function apply(ctx, svc, mode, opts = {}) {
+ const { exec, inv, log } = ctx;
+ if (!svc.managed) throw new OpError(`${svc.id} is not managed by ovhost${svc.unmanagedReason ? `: ${svc.unmanagedReason}` : ''}`, EXIT.USAGE);
+ // Live's deploy.sh can also run a releases/ + current symlink layout. Stage A only drives the
+ // in-place checkout every service runs today; it will not guess its way through the other one.
+ if (await exec.readlink(path.join(svc.repo, 'current'))) {
+ throw new OpError(`${svc.repo} uses the releases/current layout, which Stage A does not drive yet; use the repository's own deploy script`, EXIT.USAGE);
+ }
+ const held = await lock.acquire(exec, inv, svc.id);
+ if (held.recoveredStale) log(`removed a stale lock (${held.file})`);
+ const g = git(exec, svc);
+ const record = {
+ id: releases.newId(exec.now()),
+ service: svc.id,
+ action: mode,
+ startedAt: new Date(exec.now()).toISOString(),
+ operator: await operatorName(exec),
+ host: await exec.hostname(),
+ from: null,
+ to: null,
+ commits: 0,
+ lockfileChanged: false,
+ installed: [],
+ depsVerified: null,
+ restartNeeded: null,
+ restarted: false,
+ ready: null,
+ forced: false,
+ result: null,
+ };
+ let exitCode = EXIT.OK;
+ let writeRecord = true;
+ try {
+ const plan = await computePlan(ctx, svc, { mode, to: opts.to, fetch: opts.fetch !== false, restart: opts.restart });
+ Object.assign(record, { from: plan.from, to: plan.to, commits: plan.commits.length, lockfileChanged: plan.lockfileChanged, restartNeeded: plan.restartNeeded });
+ log(`${svc.id}: ${mode} ${plan.from.slice(0, 12)} → ${plan.to.slice(0, 12)} (${plan.commits.length} commit(s), ${plan.changedFiles} file(s) changed)`);
+ if (plan.branch !== svc.branch) throw new OpError(`${svc.repo} is on "${plan.branch}", expected "${svc.branch}"`, EXIT.USAGE);
+ if (plan.dirty.length) throw new OpError(`${svc.repo} has tracked local changes (${plan.dirty.slice(0, 5).join(', ')}${plan.dirty.length > 5 ? ', …' : ''}); commit or discard them first`, EXIT.VALIDATION);
+ if (plan.upToDate && !opts.restart) {
+ log(`already at ${plan.to.slice(0, 12)}; nothing to do`);
+ record.result = 'unchanged';
+ writeRecord = false;
+ return { exitCode: EXIT.OK, record, plan };
+ }
+ for (const c of plan.commits.slice(0, 20)) log(` ${c}`);
+ if (plan.unitDrift.length && !opts.installUnits) log(`note: unit files differ from the repo (${plan.unitDrift.map((u) => u.unit).join(', ')}); pass --install-units to install them`);
+
+ // Sessions first: in place, the running process would otherwise see its files change under it.
+ if (plan.restartNeeded) await guardSessions(ctx, svc, opts, record, 'before the checkout moves');
+
+ if (!plan.upToDate) {
+ if (mode === 'deploy') await g.ffMerge(plan.to); else await g.resetHard(plan.to);
+ record.checkoutMoved = true;
+ }
+ try {
+ await installAll(ctx, svc, g, plan.installs, record);
+ const v = await verifyDeps(ctx, svc, g);
+ record.depsVerified = v.problems.length === 0;
+ if (v.repaired.length) record.depsRepaired = v.repaired;
+ if (v.problems.length) throw new OpError(`dependencies do not resolve: ${v.problems.map((p) => `${p.pkg}: ${p.dep} (${p.reason})`).join('; ')}`, EXIT.VALIDATION);
+ log(`every dependency resolves (${v.pkgDirs.join(', ') || 'no packages'})`);
+ await runBuild(ctx, svc);
+ if (plan.installVhosts) await installRepoVhosts(ctx, svc);
+ if (opts.installUnits) await installUnits(ctx, svc, record);
+ if (plan.backupNeeded && plan.restartNeeded) await backupBeforeRestart(ctx, svc, record);
+ if (plan.restartNeeded) await guardSessions(ctx, svc, opts, record, 'immediately before the restart');
+ } catch (err) {
+ // Nothing has been restarted: put the files back the way the running process expects.
+ if (record.checkoutMoved) {
+ try { await restoreCheckout(ctx, svc, g, plan.from, record); record.checkoutRestored = true; } catch (e2) {
+ throw new OpError(`${err.message}\nAND restoring ${plan.from.slice(0, 12)} failed: ${e2.message} — MANUAL INTERVENTION REQUIRED`, EXIT.ROLLBACK_FAILED, { result: 'restore-failed' });
+ }
+ }
+ if (err instanceof OpError) { err.message += record.checkoutRestored ? ` (checkout restored to ${plan.from.slice(0, 12)}; nothing restarted)` : ' (nothing restarted)'; throw err; }
+ throw new OpError(`${err.message} (${record.checkoutRestored ? `checkout restored to ${plan.from.slice(0, 12)}; ` : ''}nothing restarted)`, EXIT.VALIDATION);
+ }
+
+ if (!plan.restartNeeded) {
+ record.result = mode === 'deploy' ? 'deployed' : 'rolled-back';
+ log(`${plan.units.length ? `only ${svc.noRestartPaths.join(' ')} changed — no restart needed` : 'no units to restart'}`);
+ if (svc.ready) {
+ const p = await readiness.probe(exec, svc);
+ record.ready = p.ok;
+ if (!p.ok) log(`warning: ${svc.ready.url} answers ${p.status || p.error}`);
+ }
+ return { exitCode: EXIT.OK, record, plan };
+ }
+
+ const r = await restartAndWait(ctx, svc, opts);
+ record.restarted = true;
+ record.ready = r.ok;
+ if (r.ok) {
+ record.result = mode === 'deploy' ? 'deployed' : 'rolled-back';
+ log(`${svc.id}: ${plan.from.slice(0, 12)} → ${plan.to.slice(0, 12)} ${mode === 'deploy' ? 'deployed' : 'rolled back'}`);
+ if (svc.socketUnit || svc.protected) log('note: established WebSocket/WHIP/RTMP/WebRTC/SSE sessions were reconnected, not preserved.');
+ return { exitCode: EXIT.OK, record, plan };
+ }
+
+ // Not ready: back to where we were.
+ log(`automatic rollback to ${plan.from.slice(0, 12)}`);
+ record.autoRollback = { to: plan.from };
+ try {
+ await restoreCheckout(ctx, svc, g, plan.from, record);
+ const back = await restartAndWait(ctx, svc, opts);
+ record.autoRollback.ready = back.ok;
+ if (back.ok) {
+ record.result = 'failed-rolled-back';
+ exitCode = EXIT.ROLLED_BACK;
+ log(`${svc.id} is back on ${plan.from.slice(0, 12)} and serving; ${plan.to.slice(0, 12)} was not deployed`);
+ } else {
+ record.result = 'rollback-failed';
+ exitCode = EXIT.ROLLBACK_FAILED;
+ log(`ROLLBACK FAILED: ${plan.from.slice(0, 12)} is not ready either — MANUAL INTERVENTION REQUIRED`);
+ }
+ } catch (err) {
+ record.result = 'rollback-failed';
+ record.error = err.message;
+ exitCode = EXIT.ROLLBACK_FAILED;
+ log(`ROLLBACK FAILED: ${err.message} — MANUAL INTERVENTION REQUIRED`);
+ }
+ return { exitCode, record, plan };
+ } catch (err) {
+ record.result = err.result || record.result || 'failed';
+ record.error = err.message;
+ throw err;
+ } finally {
+ record.finishedAt = new Date(exec.now()).toISOString();
+ if (writeRecord) {
+ try { await releases.append(exec, inv, record); } catch (e) { log(`warning: could not append to the release log: ${e.message}`); }
+ }
+ await held.release();
+ }
+}
+
+async function deploy(ctx, id, opts = {}) {
+ const svc = require('./inventory').service(ctx.inv, id);
+ return apply(ctx, svc, 'deploy', opts);
+}
+
+async function rollback(ctx, id, opts = {}) {
+ const svc = require('./inventory').service(ctx.inv, id);
+ let to = opts.to;
+ if (!to) {
+ const current = await git(ctx.exec, svc).head();
+ to = releases.previousFor(await releases.list(ctx.exec, ctx.inv, id), current);
+ if (!to) throw new OpError(`no deploy in the release log brought ${id} to ${current.slice(0, 12)}; pass --to <sha>`, EXIT.USAGE);
+ ctx.log(`rolling back to ${to.slice(0, 12)} (the release before ${current.slice(0, 12)} in the release log)`);
+ }
+ return apply(ctx, svc, 'rollback', { ...opts, to });
+}
+
+async function plan(ctx, id, opts = {}) {
+ const svc = require('./inventory').service(ctx.inv, id);
+ return computePlan(ctx, svc, { mode: 'deploy', to: opts.to, fetch: opts.fetch !== false, restart: opts.restart });
+}
+
+module.exports = { plan, deploy, rollback, computePlan, guardSessions, matchesPath, OpError, EXIT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+/**
+ * The release log: one JSON object per line in <stateDir>/releases/<service>.jsonl, appended for
+ * every deploy and rollback attempt — including refusals and failures, which are the ones an
+ * operator most needs to read later. Records carry shas, flags and counts; never env values.
+ */
+const path = require('path');
+const crypto = require('crypto');
+
+function logPath(inv, serviceId) {
+ return path.join(inv.stateDir, 'releases', `${serviceId}.jsonl`);
+}
+
+function newId(now) {
+ const d = new Date(now);
+ const stamp = d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
+ return `rel_${stamp}_${crypto.randomBytes(3).toString('hex')}`;
+}
+
+async function append(exec, inv, record) {
+ await exec.appendFile(logPath(inv, record.service), `${JSON.stringify(record)}\n`);
+}
+
+async function list(exec, inv, serviceId) {
+ const text = await exec.readFile(logPath(inv, serviceId));
+ if (!text) return [];
+ const out = [];
+ for (const line of text.split('\n')) {
+ if (!line.trim()) continue;
+ try { out.push(JSON.parse(line)); } catch { /* a torn last line is skipped, not fatal */ }
+ }
+ return out;
+}
+
+/**
+ * The sha to roll back to when none is given: the `from` of the most recent successful deploy
+ * that brought the checkout to `currentSha`.
+ */
+function previousFor(records, currentSha) {
+ for (let i = records.length - 1; i >= 0; i--) {
+ const r = records[i];
+ if (r.result === 'deployed' && r.to === currentSha && r.from && r.from !== currentSha) return r.from;
+ }
+ return null;
+}
+
+module.exports = { append, list, previousFor, logPath, newId };
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Runs one SQLite operation in its own process so the executor can start it AS THE SERVICE USER
+ * (runuser/sudo -u). Root never opens a service's database: a root-owned -wal/-shm file left under
+ * /opt would stop the service from writing its own database.
+ *
+ * node sqlite-worker.js '{"op":"query","db":"/opt/x/data/x.db","sql":"SELECT count(*) AS n FROM t"}'
+ * node sqlite-worker.js '{"op":"backup","db":"/opt/x/data/x.db","dest":"/var/backups/openvibe/x/…/x.db"}'
+ *
+ * The source database is always opened read-only (fileMustExist); a query that tries to write fails.
+ * Prints one JSON line: { ok: true, rows } | { ok: true, bytes } | { ok: false, error }.
+ */
+const fs = require('fs');
+
+function done(obj, code = 0) {
+ process.stdout.write(`${JSON.stringify(obj)}\n`);
+ process.exit(code);
+}
+
+let req;
+try { req = JSON.parse(process.argv[2] || ''); } catch { done({ ok: false, error: 'bad request' }, 2); }
+
+let Database;
+try { Database = require('better-sqlite3'); } catch (err) { done({ ok: false, error: `better-sqlite3 unavailable: ${err.message}` }, 2); }
+
+let db;
+try {
+ db = new Database(req.db, { readonly: true, fileMustExist: true });
+ db.pragma('busy_timeout = 5000');
+} catch (err) {
+ done({ ok: false, error: `cannot open ${req.db}: ${err.message}` }, 1);
+}
+
+if (req.op === 'query') {
+ try {
+ const stmt = db.prepare(req.sql);
+ if (!stmt.reader) done({ ok: false, error: 'only read-only statements are allowed' }, 1);
+ done({ ok: true, rows: stmt.all() });
+ } catch (err) {
+ done({ ok: false, error: err.message }, 1);
+ }
+} else if (req.op === 'backup') {
+ if (!req.dest) done({ ok: false, error: 'dest required' }, 2);
+ if (fs.existsSync(req.dest)) done({ ok: false, error: `refusing to overwrite ${req.dest}` }, 1);
+ db.backup(req.dest).then(() => {
+ const check = new Database(req.dest, { readonly: true });
+ const result = check.pragma('quick_check', { simple: true });
+ check.close();
+ if (result !== 'ok') done({ ok: false, error: `backup failed quick_check: ${result}` }, 1);
+ done({ ok: true, bytes: fs.statSync(req.dest).size });
+ }, (err) => done({ ok: false, error: err.message }, 1));
+} else {
+ done({ ok: false, error: `unknown op ${req.op}` }, 2);
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * systemd adapter. Every systemctl call ovhost makes goes through systemctl() below, which refuses
+ * any action that would stop a socket unit. Socket-activated services (Live) keep their listener in
+ * the .socket unit so HTTP connections queue in the kernel through a restart; stopping that unit is
+ * what turns a restart into Cloudflare 502s. Only the .service unit is ever restarted.
+ */
+const SOCKET_SAFE_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'cat', 'status']);
+const ALLOWED_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'restart', 'reload', 'cat', 'status', 'daemon-reload']);
+const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts'];
+
+class SocketGuardError extends Error {}
+
+async function systemctl(exec, action, unit) {
+ if (!ALLOWED_ACTIONS.has(action)) throw new Error(`ovhost does not run "systemctl ${action}"`);
+ if (unit && unit.endsWith('.socket') && !SOCKET_SAFE_ACTIONS.has(action)) {
+ throw new SocketGuardError(`refusing "systemctl ${action} ${unit}": socket units are never stopped or restarted (they hold the listener that keeps HTTP up through a restart)`);
+ }
+ const args = action === 'show' ? ['show', unit, `--property=${PROPS.join(',')}`] : unit ? [action, unit] : [action];
+ const privileged = !['show', 'is-active', 'is-enabled', 'cat', 'status'].includes(action);
+ return exec.run('systemctl', args, { privileged });
+}
+
+async function show(exec, unit) {
+ const r = await systemctl(exec, 'show', unit);
+ const props = {};
+ for (const line of r.stdout.split('\n')) {
+ const i = line.indexOf('=');
+ if (i > 0) props[line.slice(0, i)] = line.slice(i + 1);
+ }
+ return {
+ unit,
+ load: props.LoadState || 'unknown',
+ active: props.ActiveState || 'unknown',
+ sub: props.SubState || 'unknown',
+ mainPid: Number(props.MainPID || 0),
+ fragmentPath: props.FragmentPath || '',
+ unitFileState: props.UnitFileState || '',
+ dropIns: (props.DropInPaths || '').split(/\s+/).filter(Boolean),
+ partOf: (props.PartOf || '').split(/\s+/).filter(Boolean),
+ restarts: Number(props.NRestarts || 0),
+ };
+}
+
+async function restart(exec, unit) {
+ const r = await systemctl(exec, 'restart', unit);
+ if (r.code !== 0) throw new Error(`systemctl restart ${unit} failed: ${r.stderr.trim()}`);
+}
+
+/** A socket unit that is not listening gets started (never restarted): new connections need it. */
+async function ensureSocketActive(exec, socketUnit) {
+ const st = await show(exec, socketUnit);
+ if (st.active === 'active') return { started: false, state: st };
+ const r = await systemctl(exec, 'start', socketUnit);
+ if (r.code !== 0) throw new Error(`systemctl start ${socketUnit} failed: ${r.stderr.trim()}`);
+ return { started: true, state: st };
+}
+
+async function daemonReload(exec) {
+ const r = await systemctl(exec, 'daemon-reload');
+ if (r.code !== 0) throw new Error(`systemctl daemon-reload failed: ${r.stderr.trim()}`);
+}
+
+async function reloadNginx(exec) {
+ const r = await systemctl(exec, 'reload', 'nginx.service');
+ if (r.code !== 0) throw new Error(`systemctl reload nginx failed: ${r.stderr.trim()}`);
+}
+
+module.exports = { systemctl, show, restart, ensureSocketActive, daemonReload, reloadNginx, SocketGuardError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+/**
+ * A minimal ustar writer + gzip, enough for config snapshots (regular files only, names under 100
+ * bytes). Written here so snapshots need neither a tar binary nor a dependency, and the tests can
+ * read the archive back with readTarGz().
+ */
+const zlib = require('zlib');
+
+function header(name, size, mtime) {
+ const buf = Buffer.alloc(512, 0);
+ if (Buffer.byteLength(name) > 99) throw new Error(`tar: name too long: ${name}`);
+ buf.write(name, 0, 'utf8');
+ buf.write('0000640\0', 100);
+ buf.write('0000000\0', 108);
+ buf.write('0000000\0', 116);
+ buf.write(`${size.toString(8).padStart(11, '0')}\0`, 124);
+ buf.write(`${Math.floor(mtime / 1000).toString(8).padStart(11, '0')}\0`, 136);
+ buf.write(' ', 148);
+ buf.write('0', 156);
+ buf.write('ustar\0', 257);
+ buf.write('00', 263);
+ let sum = 0;
+ for (const b of buf) sum += b;
+ buf.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148);
+ return buf;
+}
+
+/** files: [{ name, content (string|Buffer) }] -> gzipped tar Buffer */
+function createTarGz(files, mtime = Date.now()) {
+ const parts = [];
+ for (const f of files) {
+ const data = Buffer.isBuffer(f.content) ? f.content : Buffer.from(String(f.content), 'utf8');
+ parts.push(header(f.name, data.length, mtime), data);
+ const pad = (512 - (data.length % 512)) % 512;
+ if (pad) parts.push(Buffer.alloc(pad, 0));
+ }
+ parts.push(Buffer.alloc(1024, 0));
+ return zlib.gzipSync(Buffer.concat(parts));
+}
+
+function readTarGz(buf) {
+ const tar = zlib.gunzipSync(buf);
+ const out = [];
+ let off = 0;
+ while (off + 512 <= tar.length) {
+ const name = tar.subarray(off, off + 100).toString('utf8').replace(/\0.*$/s, '');
+ if (!name) break;
+ const size = parseInt(tar.subarray(off + 124, off + 136).toString('utf8').replace(/\0.*$/s, '').trim(), 8);
+ out.push({ name, content: tar.subarray(off + 512, off + 512 + size).toString('utf8') });
+ off += 512 + Math.ceil(size / 512) * 512;
+ }
+ return out;
+}
+
+module.exports = { createTarGz, readTarGz };
new file mode 100644
new file mode 100644
@@ -0,0 +1,558 @@
@@ -0,0 +1,558 @@
+{
+ "name": "openvibe-host",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-host",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"
+ },
+ "bin": {
+ "ovhost": "bin/ovhost"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.7.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0",
+ "integrity": "sha512-gUrjxbTy/HX0QMQGoDPphmFdwm7iitmNMMchs9tDSDGR47FRFHL/RgBgIKYR1S7M6ICu261YycFV8+YVglafXA==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "name": "openvibe-host",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Host Stage A: the operator plane. The ovhost CLI validates, deploys, rolls back, snapshots and backs up the services on the OpenVibe host, and encodes the rules that keep live streams and recordings alive through a deploy.",
+ "main": "lib/index.js",
+ "bin": {
+ "ovhost": "bin/ovhost"
+ },
+ "scripts": {
+ "test": "node test/run.js"
+ },
+ "keywords": ["openvibe", "deploy", "systemd", "nginx", "operator"],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.Host",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+# Upgrade header -> Connection header, named per service so two vhosts never define the same variable.
+map $http_upgrade ${{zone}}_connection {
+ default upgrade;
+ '' close;
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+
+ # Server-Sent Events: never buffered or compressed; long reads (the service sends heartbeats
+ # well inside Cloudflare's 100 s idle limit).
+ location = {{path}} {
+ proxy_pass http://127.0.0.1:{{port}};
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Last-Event-ID $http_last_event_id;
+ proxy_buffering off;
+ proxy_cache off;
+ gzip off;
+ chunked_transfer_encoding on;
+ proxy_read_timeout 1h;
+ proxy_send_timeout 1h;
+ send_timeout 1h;
+ }
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+
+ # WebSocket upgrade: long-lived, unbuffered.
+ location {{path}} {
+ proxy_pass http://127.0.0.1:{{port}};
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection ${{zone}}_connection;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off;
+ proxy_read_timeout 1h;
+ proxy_send_timeout 1h;
+ }
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+## ═══════════════════════════════════════════════════════════════
+## {{primary}} — generated by `ovhost nginx render {{service}}`
+##
+## Source: the "{{manifestId}}" service manifest (openvibe-contracts {{contractsVersion}}) and the
+## host inventory (port {{port}}). Variant: {{variant}}.
+## `ovhost nginx render {{service}} --install` writes {{sitesAvailable}}/{{vhost}}, links it into
+## sites-enabled, and reloads nginx only if `nginx -t` passes (the previous file is restored if not).
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone={{zone}}_req:10m rate=10r/s;
+{{httpContext}}
+# ── HTTP → HTTPS redirect ──────────────────────────────────
+server {
+ listen 80;
+ listen [::]:80;
+ server_name {{serverNames}};
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://$host$request_uri; }
+}
+
+# ── HTTPS server ───────────────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name {{serverNames}};
+
+ ssl_certificate /etc/letsencrypt/live/{{certName}}/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/{{certName}}/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/{{primary}}.access.log;
+ error_log /var/log/nginx/{{primary}}.error.log;
+
+ client_max_body_size {{maxBody}};
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+{{locations}}
+ location / {
+ limit_req zone={{zone}}_req burst=40 nodelay;
+ proxy_pass http://127.0.0.1:{{port}};
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 60s;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+const assert = require('assert');
+const crypto = require('crypto');
+const { scenario, test, runTests } = require('./helpers');
+const { SHORT, LONG } = require('./fixtures/certs');
+
+const KEY_BLOCK = '-----BEGIN PRIVATE KEY-----\nTESTKEYMATERIALMUSTNOTBEPRINTED\n-----END PRIVATE KEY-----\n';
+
+function withCerts(host) {
+ host.put('/etc/letsencrypt/live/test10.example/fullchain.pem', SHORT, { mode: 0o644 });
+ host.put('/etc/letsencrypt/live/test10.example/privkey.pem', KEY_BLOCK, { mode: 0o600 });
+ host.put('/etc/letsencrypt/live/test400.example/combined.pem', `${KEY_BLOCK}${LONG}`, { mode: 0o600 });
+ host.put('/etc/nginx/sites-available/a.conf', [
+ 'server {',
+ ' ssl_certificate /etc/letsencrypt/live/test10.example/fullchain.pem;',
+ ' ssl_certificate_key /etc/letsencrypt/live/test10.example/privkey.pem;',
+ ' # ssl_certificate /etc/letsencrypt/live/commented/fullchain.pem;',
+ '}',
+ ].join('\n'));
+ host.put('/etc/nginx/sites-available/b.conf', 'server {\n ssl_certificate /etc/letsencrypt/live/test400.example/combined.pem;\n ssl_certificate /etc/letsencrypt/live/gone.example/fullchain.pem;\n ssl_certificate /etc/ssl/private/site.key;\n}\n');
+ host.exec.symlink('/etc/nginx/sites-available/a.conf', '/etc/nginx/sites-enabled/a.conf');
+ host.exec.symlink('/etc/nginx/sites-available/b.conf', '/etc/nginx/sites-enabled/b.conf');
+ // Put the clock five days before the short certificate expires.
+ host.advance(new crypto.X509Certificate(SHORT).validToDate.getTime() - 5 * 86400000 - host.exec.now());
+}
+
+runTests([
+ test('certs: every certificate referenced by an enabled vhost, with expiry; never key material', async () => {
+ const host = scenario();
+ withCerts(host);
+ const r = await host.cli('certs', '--json');
+ const list = JSON.parse(r.out);
+ const by = Object.fromEntries(list.map((c) => [c.path, c]));
+ assert.strictEqual(by['/etc/letsencrypt/live/test10.example/fullchain.pem'].status, 'expiring');
+ assert.ok(by['/etc/letsencrypt/live/test10.example/fullchain.pem'].daysLeft <= 5);
+ assert.deepStrictEqual(by['/etc/letsencrypt/live/test10.example/fullchain.pem'].names, ['test10.example', '*.test10.example']);
+ assert.strictEqual(by['/etc/letsencrypt/live/test400.example/combined.pem'].status, 'ok');
+ assert.strictEqual(by['/etc/letsencrypt/live/test400.example/combined.pem'].subject, 'test400.example');
+ assert.strictEqual(by['/etc/letsencrypt/live/gone.example/fullchain.pem'].status, 'missing');
+ assert.strictEqual(by['/etc/ssl/private/site.key'].status, 'refused');
+ assert.ok(!by['/etc/letsencrypt/live/commented/fullchain.pem'], 'commented-out lines are ignored');
+ assert.ok(!list.some((c) => /privkey/.test(c.path)), 'ssl_certificate_key is never followed');
+ assert.strictEqual(r.code, 2, 'a missing certificate fails the command');
+ const text = await host.cli('certs');
+ for (const out of [r.out, text.out]) {
+ assert.ok(!out.includes('TESTKEYMATERIAL'));
+ assert.ok(!out.includes('BEGIN PRIVATE KEY'));
+ }
+ assert.ok(!host.reads.some((f) => /privkey|\.key$/.test(f)), 'no key file was read');
+ assert.ok(!host.calls.some((c) => c.args.some((a) => /privkey/.test(String(a)))), 'no command touched a key path');
+ assert.match(text.out, /EXPIRING .*test10\.example\/fullchain\.pem/);
+ }),
+
+ test('nginx render: vhost from the manifest domains + inventory port; SSE and WebSocket variants; stdout only', async () => {
+ const host = scenario();
+ const ev = await host.cli('nginx', 'render', 'events');
+ assert.strictEqual(ev.code, 0, ev.out);
+ assert.match(ev.out, /server_name events\.openvibe\.network;/);
+ assert.match(ev.out, /ssl_certificate +\/etc\/letsencrypt\/live\/openvibe\.network\/fullchain\.pem;/, 'wildcard certificate of the registrable domain');
+ assert.match(ev.out, /location = \/realtime\/stream \{[\s\S]*proxy_buffering off;[\s\S]*proxy_read_timeout 1h;/);
+ assert.match(ev.out, /proxy_pass http:\/\/127\.0\.0\.1:4300;/);
+ const live = await host.cli('nginx', 'render', 'live');
+ assert.match(live.out, /server_name openvibe\.live ingest\.openvibe\.live;/);
+ assert.match(live.out, /map \$http_upgrade \$ovlive_connection/);
+ assert.match(live.out, /location \/ws\/ \{[\s\S]*proxy_set_header Upgrade \$http_upgrade;[\s\S]*proxy_set_header Connection \$ovlive_connection;/);
+ assert.ok(!/\{\{/.test(live.out), 'no unfilled template variables');
+ const plain = await host.cli('nginx', 'render', 'media', '--variant', 'http');
+ assert.match(plain.out, /server_name openvibe\.media;/);
+ assert.ok(!/proxy_buffering off/.test(plain.out));
+ assert.strictEqual(host.read('/etc/nginx/sites-available/events.openvibe.network.conf'), null, 'render alone writes nothing');
+ assert.ok(!host.calls.some((c) => c.cmd === 'nginx' || (c.cmd === 'systemctl' && c.args[0] === 'reload')));
+ }),
+
+ test('nginx render --install: writes, links, tests, reloads; a failed nginx -t restores the previous state', async () => {
+ const host = scenario();
+ const ok = await host.cli('nginx', 'render', 'events', '--install');
+ assert.strictEqual(ok.code, 0, ok.out);
+ const installed = host.read('/etc/nginx/sites-available/events.openvibe.network.conf');
+ assert.match(installed, /events\.openvibe\.network/);
+ assert.ok(host.files.get('/etc/nginx/sites-enabled/events.openvibe.network.conf'));
+ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload' && c.args[1] === 'nginx.service'));
+
+ host.put('/etc/nginx/sites-available/events.openvibe.network.conf', '# hand-edited previous version\n');
+ host.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] duplicate zone' });
+ const reloadsBefore = host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'reload').length;
+ const bad = await host.cli('nginx', 'render', 'events', '--install');
+ assert.strictEqual(bad.code, 2, bad.out);
+ assert.match(bad.out, /nginx -t failed; the previous vhost files were restored/);
+ assert.strictEqual(host.read('/etc/nginx/sites-available/events.openvibe.network.conf'), '# hand-edited previous version\n');
+ assert.strictEqual(host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'reload').length, reloadsBefore, 'no reload after a failed test');
+
+ // A brand-new vhost that fails the test is removed again, link included.
+ const fresh = scenario();
+ fresh.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] x' });
+ const r = await fresh.cli('nginx', 'render', 'media', '--install');
+ assert.strictEqual(r.code, 2);
+ assert.strictEqual(fresh.read('/etc/nginx/sites-available/openvibe.media.conf'), null);
+ assert.strictEqual(fresh.files.get('/etc/nginx/sites-enabled/openvibe.media.conf'), undefined);
+ }),
+
+ test('nginx render refuses a service with no domains', async () => {
+ const host = scenario();
+ const r = await host.cli('nginx', 'render', 'sites');
+ assert.notStrictEqual(r.code, 0);
+ assert.match(r.out, /no domains|has no port/);
+ }),
+]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { scenario, push, test, runTests } = require('./helpers');
+const { normalise } = require('../lib/inventory');
+const { createFakeHost } = require('./fake-host');
+const { main } = require('../lib/cli');
+
+async function cli(host, ...argv) {
+ const lines = [];
+ const code = await main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {} });
+ return { code, out: lines.join('\n') };
+}
+
+runTests([
+ test('host.example.json is a valid inventory for every service on the host', () => {
+ const raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'host.example.json'), 'utf8'));
+ const inv = normalise(raw);
+ assert.deepStrictEqual(Object.keys(inv.services), ['network', 'live', 'media', 'tools', 'community', 'events', 'games', 'sites']);
+ assert.strictEqual(inv.services.live.socketUnit, 'openvibe-live.socket');
+ assert.ok(!inv.services.live.units.includes('openvibe-live.socket'));
+ assert.strictEqual(inv.services.live.protected.url, 'http://127.0.0.1:3000/api/streams');
+ assert.match(inv.services.media.protected.sql, /is_recording = 1/);
+ assert.strictEqual(inv.services.games.managed, false);
+ for (const s of Object.values(inv.services)) assert.strictEqual(s.owner, 'ubuntu', `${s.id} checkout owner`);
+ const text = JSON.stringify(raw);
+ assert.ok(!/(sk_|ghp_|BEGIN [A-Z ]*PRIVATE KEY|password\s*[:=]\s*\S)/i.test(text), 'no secret values in the example');
+ }),
+
+ test('the service manifest proposal is valid against the contracts schema', () => {
+ const contracts = require('openvibe-contracts');
+ const m = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));
+ contracts.assertValid('registry.service-manifest', m);
+ assert.strictEqual(m.id, 'host');
+ assert.deepStrictEqual(m.capabilities, [], 'Stage A exposes no API, so it enforces no capability');
+ }),
+
+ test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {
+ const host = scenario();
+ host.files.get('/etc/openvibe/host.json').owner = 'ubuntu';
+ let r = await host.cli('status');
+ assert.strictEqual(r.code, 1);
+ assert.match(r.out, /must be owned by root/);
+ host.files.get('/etc/openvibe/host.json').owner = 'root';
+ host.files.get('/etc/openvibe/host.json').mode = 0o666;
+ r = await host.cli('status');
+ assert.strictEqual(r.code, 1);
+ assert.match(r.out, /must not be group- or world-writable/);
+ }),
+
+ test('inventory validation rejects paths and names that could point a root process somewhere else', () => {
+ const base = { owner: 'ubuntu' };
+ assert.throws(() => normalise({ services: { x: { ...base, repo: 'opt/x' } } }), /absolute path/);
+ assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', nginx: { vhost: '../../etc/passwd' } } } }), /file name/);
+ assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', ready: { url: 'http://evil.example/ready' } } } }), /loopback/);
+ assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', protected: { kind: 'sqlite-count', db: '/d.db', sql: 'DELETE FROM vods' } } } }), /SELECT/);
+ assert.throws(() => normalise({ services: { 'X Y': { ...base, repo: '/opt/x' } } }), /service id/);
+ assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', unitSources: { 'a.service': '../../etc/shadow' } } } }), /inside the checkout/);
+ }),
+
+ test('no inventory, unknown service, unknown command, help', async () => {
+ const empty = createFakeHost();
+ let r = await cli(empty, 'status');
+ assert.strictEqual(r.code, 1);
+ assert.match(r.out, /no inventory found/);
+ const host = scenario();
+ r = await host.cli('validate', 'nope');
+ assert.strictEqual(r.code, 1);
+ assert.match(r.out, /unknown service "nope"/);
+ r = await host.cli('frobnicate');
+ assert.strictEqual(r.code, 1);
+ r = await host.cli('--help');
+ assert.strictEqual(r.code, 0);
+ assert.match(r.out, /ovhost — OpenVibe.Host operator plane/);
+ r = await host.cli('releases', '../../etc/x');
+ assert.strictEqual(r.code, 1);
+ r = await host.cli('deploy', 'live', '--to', '--upload-pack=touch /tmp/x');
+ assert.strictEqual(r.code, 1);
+ assert.match(r.out, /not a sha or ref name/);
+ assert.ok(!host.calls.some((c) => c.cmd === 'git' && c.args.some((a) => a.includes('upload-pack'))));
+ r = await host.cli('deploy');
+ assert.strictEqual(r.code, 1);
+ assert.match(r.out, /needs a <service>/);
+ }),
+
+ test('status covers every service: units, socket, sha, readiness, protected sessions', async () => {
+ const host = scenario();
+ host.liveStreams = [{ is_live: 1 }];
+ host.recording = 2;
+ host.units.get('openvibe-events.service').active = 'failed';
+ const r = await host.cli('status');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /^live +[0-9a-f]{12} ready +openvibe-live=active socket=active live streams=1$/m);
+ assert.match(r.out, /^media .* recordings in progress=2$/m);
+ assert.match(r.out, /^tools .*openvibe-tools=active openvibe-tools-maps=active/m);
+ assert.match(r.out, /^events .*NOT READY \(502\)/m);
+ assert.match(r.out, /^sites .*\(no units\)/m);
+ const json = JSON.parse((await host.cli('status', 'live', '--json')).out);
+ assert.strictEqual(json.length, 1);
+ assert.strictEqual(json[0].protected.count, 1);
+ assert.deepStrictEqual(host.restarts(), [], 'status never restarts anything');
+ }),
+
+ test('releases prints the log; an unmanaged service refuses deploy; the releases/current layout is refused', async () => {
+ const host = scenario();
+ push(host, 'live', { 'server/index.js': 'z();' }, 'z change');
+ await host.cli('deploy', 'live');
+ const r = await host.cli('releases', 'live');
+ assert.match(r.out, /deploy +[0-9a-f]{12} → [0-9a-f]{12} +deployed/);
+ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
+ raw.services.events.managed = false;
+ raw.services.events.unmanagedReason = 'test';
+ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });
+ const u = await host.cli('deploy', 'events');
+ assert.strictEqual(u.code, 1);
+ assert.match(u.out, /not managed by ovhost: test/);
+ await host.exec.symlink('/opt/openvibe.media/releases/x', '/opt/openvibe.media/current');
+ const rl = await host.cli('deploy', 'media');
+ assert.strictEqual(rl.code, 1);
+ assert.match(rl.out, /releases\/current layout/);
+ }),
+]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,358 @@
@@ -0,0 +1,358 @@
+'use strict';
+const assert = require('assert');
+const path = require('path');
+const { scenario, push, test, runTests, pkgJson } = require('./helpers');
+const systemd = require('../lib/systemd');
+const releases = require('../lib/releases');
+
+const liveServerChange = (host) => push(host, 'live', { 'server/index.js': 'console.log(2);' }, 'server change');
+
+runTests([
+ test('deploys a Live server change: ff as ubuntu, restarts only the service unit, polls readiness, logs the release', async () => {
+ const host = scenario();
+ const from = host.repo('live').head;
+ const to = liveServerChange(host);
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.strictEqual(host.repo('live').head, to);
+ assert.deepStrictEqual(host.restarts(), ['openvibe-live.service']);
+ assert.deepStrictEqual(host.socketViolations(), []);
+ for (const c of host.calls.filter((x) => x.cmd === 'git')) assert.strictEqual(c.as, 'ubuntu', `git ran as ${c.as}: ${c.args.join(' ')}`);
+ assert.ok(!host.calls.some((c) => c.cmd === 'npm'), 'no install without a dependency change');
+ const [rec] = await releases.list(host.exec, host.inv, 'live');
+ assert.strictEqual(rec.result, 'deployed');
+ assert.strictEqual(rec.from, from);
+ assert.strictEqual(rec.to, to);
+ assert.strictEqual(rec.lockfileChanged, false);
+ assert.strictEqual(rec.depsVerified, true);
+ assert.strictEqual(rec.ready, true);
+ assert.match(r.out, /openvibe-live\.socket active — new HTTP connections queue/);
+ }),
+
+ test('refuses to restart Live while streams are live; the checkout does not move', async () => {
+ const host = scenario();
+ const from = host.repo('live').head;
+ liveServerChange(host);
+ host.liveStreams = [{ id: 1, is_live: 1 }, { id: 2, is_live: 0 }];
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 5, r.out);
+ assert.match(r.out, /1 live streams — refusing to restart/);
+ assert.match(r.out, /--wait-idle/);
+ assert.strictEqual(host.repo('live').head, from);
+ assert.deepStrictEqual(host.restarts(), []);
+ const [rec] = await releases.list(host.exec, host.inv, 'live');
+ assert.strictEqual(rec.result, 'refused');
+ assert.strictEqual(rec.protectedSessions.count, 1);
+ }),
+
+ test('an unanswerable stream probe counts as live, not as zero', async () => {
+ const host = scenario();
+ liveServerChange(host);
+ host.http.set('http://127.0.0.1:3000/api/streams', () => ({ status: 500, body: 'oops' }));
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 5, r.out);
+ assert.match(r.out, /unknown \(HTTP 500\) — treated as active/);
+ assert.deepStrictEqual(host.restarts(), []);
+ }),
+
+ test('--wait-idle holds until two consecutive idle checks, then deploys', async () => {
+ const host = scenario();
+ const to = liveServerChange(host);
+ host.liveStreams = [{ is_live: true }];
+ let polls = 0;
+ host.http.set('http://127.0.0.1:3000/api/streams', () => {
+ polls += 1;
+ return { status: 200, body: { streams: polls < 4 ? host.liveStreams : [] } };
+ });
+ const r = await host.cli('deploy', 'live', '--wait-idle');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /holding the restart/);
+ assert.match(r.out, /idle for 2 checks — proceeding/);
+ assert.strictEqual(host.repo('live').head, to);
+ assert.deepStrictEqual(host.restarts(), ['openvibe-live.service']);
+ }),
+
+ test('--wait-idle gives up after drain.waitMaxSeconds with nothing restarted', async () => {
+ const host = scenario();
+ const from = host.repo('live').head;
+ liveServerChange(host);
+ host.liveStreams = [{ is_live: true }];
+ const r = await host.cli('deploy', 'live', '--wait-idle');
+ assert.strictEqual(r.code, 5, r.out);
+ assert.match(r.out, /gave up; nothing was restarted/);
+ assert.strictEqual(host.repo('live').head, from);
+ assert.deepStrictEqual(host.restarts(), []);
+ }),
+
+ test('--force restarts with streams live, loudly, and records it', async () => {
+ const host = scenario();
+ liveServerChange(host);
+ host.liveStreams = [{ is_live: true }, { is_live: true }];
+ const r = await host.cli('deploy', 'live', '--force');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /!!! --force/);
+ assert.match(r.out, /2 live streams WILL BE DROPPED/);
+ const [rec] = await releases.list(host.exec, host.inv, 'live');
+ assert.strictEqual(rec.forced, true);
+ assert.deepStrictEqual(host.socketViolations(), []);
+ }),
+
+ test('a static-only Live change (public/) deploys without a restart, even with streams live', async () => {
+ const host = scenario();
+ const to = push(host, 'live', { 'public/app.js': 'app2();' });
+ host.liveStreams = [{ is_live: true }];
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.strictEqual(host.repo('live').head, to);
+ assert.deepStrictEqual(host.restarts(), []);
+ assert.match(r.out, /no restart needed/);
+ }),
+
+ test('sessions that start during the install stop the restart and put the checkout back', async () => {
+ const host = scenario();
+ const from = host.repo('live').head;
+ push(host, 'live', { 'server/index.js': 'x();', 'package-lock.json': '{"lockfileVersion":3,"v":2}' });
+ host.npm = () => { host.liveStreams = [{ is_live: true }]; return undefined; };
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 5, r.out);
+ assert.match(r.out, /immediately before the restart/);
+ assert.strictEqual(host.repo('live').head, from);
+ assert.deepStrictEqual(host.restarts(), []);
+ }),
+
+ test('refuses to restart Media while a recording is in progress (read-only query as the service user)', async () => {
+ const host = scenario();
+ const from = host.repo('media').head;
+ push(host, 'media', { 'server/index.js': 'media2();' });
+ host.recording = 1;
+ const r = await host.cli('deploy', 'media');
+ assert.strictEqual(r.code, 5, r.out);
+ assert.match(r.out, /1 recordings in progress — refusing/);
+ assert.strictEqual(host.repo('media').head, from);
+ assert.deepStrictEqual(host.restarts(), []);
+ const q = host.sqliteCalls.find((c) => c.op === 'query');
+ assert.strictEqual(q.as, 'ubuntu');
+ assert.strictEqual(q.db, '/opt/openvibe.media/data/media.db');
+ assert.match(q.sql, /^SELECT count\(\*\) AS n FROM vods WHERE is_recording = 1$/);
+ }),
+
+ test('Media deploys once no recording is running', async () => {
+ const host = scenario();
+ const to = push(host, 'media', { 'server/index.js': 'media2();' });
+ const r = await host.cli('deploy', 'media');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.strictEqual(host.repo('media').head, to);
+ assert.deepStrictEqual(host.restarts(), ['openvibe-media.service']);
+ }),
+
+ test('rolls back automatically when readiness fails, reinstalling the previous dependencies', async () => {
+ const host = scenario();
+ const from = host.repo('live').head;
+ const bad = push(host, 'live', { 'server/index.js': 'broken();', 'package-lock.json': '{"lockfileVersion":3,"v":2}' });
+ host.badShas.add(bad);
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 3, r.out);
+ assert.strictEqual(host.repo('live').head, from);
+ assert.deepStrictEqual(host.restarts(), ['openvibe-live.service', 'openvibe-live.service']);
+ assert.strictEqual(host.units.get('openvibe-live.service').runningSha, from);
+ const npmRuns = host.calls.filter((c) => c.cmd === 'npm');
+ assert.strictEqual(npmRuns.length, 2, 'installed for the new sha, reinstalled for the old one');
+ const [rec] = await releases.list(host.exec, host.inv, 'live');
+ assert.strictEqual(rec.result, 'failed-rolled-back');
+ assert.strictEqual(rec.autoRollback.ready, true);
+ assert.deepStrictEqual(host.socketViolations(), []);
+ }),
+
+ test('exit 4 and MANUAL INTERVENTION when the previous release is not ready either', async () => {
+ const host = scenario();
+ const from = host.repo('live').head;
+ const bad = liveServerChange(host);
+ host.badShas.add(bad);
+ host.badShas.add(from);
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 4, r.out);
+ assert.match(r.out, /MANUAL INTERVENTION REQUIRED/);
+ const [rec] = await releases.list(host.exec, host.inv, 'live');
+ assert.strictEqual(rec.result, 'rollback-failed');
+ }),
+
+ test('a dependency that still does not resolve after a reinstall aborts before any restart', async () => {
+ const host = scenario();
+ const from = host.repo('tools').head;
+ push(host, 'tools', { 'apps/gateway/package.json': pkgJson('tools-gateway', ['express', 'openvibe-shared', 'openvibe-contracts']) });
+ // The Tools incident: npm leaves an empty directory where openvibe-shared should be
+ // (here: only for the new revision's package.json).
+ host.npm = (cwd) => {
+ const pkg = JSON.parse(host.read(path.join(cwd, 'package.json')));
+ const broken = 'openvibe-contracts' in pkg.dependencies;
+ for (const dep of Object.keys(pkg.dependencies)) {
+ if (broken && dep === 'openvibe-shared') { host.files.delete(path.join(cwd, 'node_modules', dep, 'package.json')); host.ensureDir(path.join(cwd, 'node_modules', dep)); } else host.put(path.join(cwd, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep }));
+ }
+ return { code: 0 };
+ };
+ const r = await host.cli('deploy', 'tools');
+ assert.strictEqual(r.code, 2, r.out);
+ assert.match(r.out, /dependencies do not resolve: apps\/gateway: openvibe-shared/);
+ assert.match(r.out, /nothing restarted/);
+ assert.deepStrictEqual(host.restarts(), []);
+ assert.strictEqual(host.repo('tools').head, from, 'checkout restored');
+ const [rec] = await releases.list(host.exec, host.inv, 'tools');
+ assert.strictEqual(rec.depsVerified, false);
+ }),
+
+ test('a dependency that resolves after one reinstall is repaired and the deploy continues', async () => {
+ const host = scenario();
+ const to = push(host, 'tools', { 'apps/maps/server.js': 'maps2();' });
+ host.files.delete('/opt/openvibe.tools/apps/gateway/node_modules/openvibe-shared/package.json');
+ const r = await host.cli('deploy', 'tools');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /openvibe-shared in apps\/gateway does not resolve .* reinstalling/);
+ assert.strictEqual(host.repo('tools').head, to);
+ assert.deepStrictEqual(host.restarts(), ['openvibe-tools.service', 'openvibe-tools-maps.service']);
+ const [rec] = await releases.list(host.exec, host.inv, 'tools');
+ assert.deepStrictEqual(rec.depsRepaired, ['apps/gateway']);
+ }),
+
+ test('installs only where the lockfile changed, as the owner, then restores the lockfile npm rewrote', async () => {
+ const host = scenario();
+ push(host, 'tools', { 'apps/maps/package-lock.json': '{"v":2}' });
+ const r = await host.cli('deploy', 'tools');
+ assert.strictEqual(r.code, 0, r.out);
+ const npmRuns = host.calls.filter((c) => c.cmd === 'npm');
+ assert.strictEqual(npmRuns.length, 1);
+ assert.strictEqual(npmRuns[0].cwd, '/opt/openvibe.tools/apps/maps');
+ assert.strictEqual(npmRuns[0].as, 'ubuntu');
+ assert.ok(host.calls.some((c) => c.cmd === 'git' && c.args.includes('checkout') && c.args.includes('apps/maps/package-lock.json')));
+ assert.strictEqual(host.read('/opt/openvibe.tools/apps/maps/package-lock.json'), '{"v":2}');
+ const [rec] = await releases.list(host.exec, host.inv, 'tools');
+ assert.strictEqual(rec.lockfileChanged, true);
+ assert.deepStrictEqual(rec.installed, ['apps/maps']);
+ }),
+
+ test('tracked local changes block the deploy', async () => {
+ const host = scenario();
+ liveServerChange(host);
+ host.put('/opt/openvibe.live/server/index.js', 'hotfix();', { owner: 'ubuntu' });
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 2, r.out);
+ assert.match(r.out, /tracked local changes \(server\/index\.js\)/);
+ assert.deepStrictEqual(host.restarts(), []);
+ }),
+
+ test('the socket unit is never stopped or restarted; a down socket is started', async () => {
+ const host = scenario();
+ await assert.rejects(systemd.systemctl(host.exec, 'restart', 'openvibe-live.socket'), systemd.SocketGuardError);
+ await assert.rejects(systemd.systemctl(host.exec, 'reload', 'openvibe-live.socket'), systemd.SocketGuardError);
+ await assert.rejects(systemd.systemctl(host.exec, 'stop', 'openvibe-live.service'), /does not run "systemctl stop"/);
+ host.units.get('openvibe-live.socket').active = 'inactive';
+ liveServerChange(host);
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'start' && c.args[1] === 'openvibe-live.socket'));
+ assert.deepStrictEqual(host.socketViolations(), []);
+ assert.throws(() => require('../lib/inventory').normalise({ services: { x: { repo: '/opt/x', owner: 'ubuntu', units: ['openvibe-live.socket'] } } }), /socket unit/);
+ }),
+
+ test('Events (drain policy "report") restarts with SSE connections open and says so', async () => {
+ const host = scenario();
+ push(host, 'events', { 'server/index.js': 'events2();' });
+ host.sseConnections = 7;
+ const r = await host.cli('deploy', 'events');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /7 SSE connections; drain policy "report"/);
+ assert.deepStrictEqual(host.restarts(), ['openvibe-events.service']);
+ }),
+
+ test('a schema change backs up the declared databases (as the service user) before the restart', async () => {
+ const host = scenario();
+ push(host, 'live', { 'server/db/schema.sql': 'CREATE TABLE b(y);' });
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 0, r.out);
+ const b = host.sqliteCalls.find((c) => c.op === 'backup');
+ assert.ok(b, 'backup taken');
+ assert.strictEqual(b.as, 'ubuntu');
+ assert.match(b.dest, /^\/var\/backups\/openvibe\/live\/\d{8}-\d{6}\/live\.db$/);
+ const backupIdx = host.calls.findIndex((c) => c.cmd === 'sqlite-backup');
+ const restartIdx = host.calls.findIndex((c) => c.cmd === 'systemctl' && c.args[0] === 'restart');
+ assert.ok(backupIdx < restartIdx, 'backup before restart');
+ }),
+
+ test('rollback without --to returns to the release before the current one', async () => {
+ const host = scenario();
+ const first = host.repo('live').head;
+ const second = liveServerChange(host);
+ assert.strictEqual((await host.cli('deploy', 'live')).code, 0);
+ const r = await host.cli('rollback', 'live');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.strictEqual(host.repo('live').head, first);
+ const recs = await releases.list(host.exec, host.inv, 'live');
+ assert.strictEqual(recs[1].action, 'rollback');
+ assert.strictEqual(recs[1].from, second);
+ assert.strictEqual(recs[1].to, first);
+ assert.strictEqual(recs[1].result, 'rolled-back');
+ // A later deploy fast-forwards again.
+ const again = await host.cli('deploy', 'live');
+ assert.strictEqual(again.code, 0, again.out);
+ assert.strictEqual(host.repo('live').head, second);
+ }),
+
+ test('rollback respects protected sessions too', async () => {
+ const host = scenario();
+ const first = host.repo('live').head;
+ liveServerChange(host);
+ await host.cli('deploy', 'live');
+ host.liveStreams = [{ is_live: true }];
+ const r = await host.cli('rollback', 'live', '--to', first);
+ assert.strictEqual(r.code, 5, r.out);
+ assert.notStrictEqual(host.repo('live').head, first);
+ }),
+
+ test('a second operation on the same service is refused while the first holds the lock', async () => {
+ const host = scenario();
+ host.put('/var/lib/openvibe-host/locks/live.lock', JSON.stringify({ pid: 4242, at: 'x' }));
+ host.alivePids.add(4242);
+ liveServerChange(host);
+ const r = await host.cli('deploy', 'live');
+ assert.strictEqual(r.code, 1, r.out);
+ assert.match(r.out, /another ovhost operation on live is running \(pid 4242/);
+ host.alivePids.delete(4242);
+ const r2 = await host.cli('deploy', 'live');
+ assert.strictEqual(r2.code, 0, r2.out);
+ assert.match(r2.out, /removed a stale lock/);
+ }),
+
+ test('Sites: npm ci + build as the owner, vhosts installed with nginx -t; a failing test restores and aborts', async () => {
+ const host = scenario();
+ push(host, 'sites', { 'deploy/nginx/openvibe.chat.conf': 'server { listen 443; server_name openvibe.chat; } # v2\n', 'build.js': 'build2();' });
+ const r = await host.cli('deploy', 'sites');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.ok(host.calls.some((c) => c.cmd === 'npm' && c.args[0] === 'ci' && c.as === 'ubuntu'));
+ assert.ok(host.calls.some((c) => c.cmd === 'node' && c.args[0] === 'build.js' && c.as === 'ubuntu'));
+ assert.match(host.read('/etc/nginx/sites-available/openvibe.chat.conf'), /# v2/);
+ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload' && c.args[1] === 'nginx.service'));
+
+ const before = host.repo('sites').head;
+ push(host, 'sites', { 'deploy/nginx/openvibe.chat.conf': 'server { broken\n' });
+ host.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] unexpected end of file' });
+ const r2 = await host.cli('deploy', 'sites');
+ assert.strictEqual(r2.code, 2, r2.out);
+ assert.match(r2.out, /nginx -t failed/);
+ assert.match(host.read('/etc/nginx/sites-available/openvibe.chat.conf'), /# v2/, 'previous vhost restored');
+ assert.strictEqual(host.repo('sites').head, before, 'checkout restored');
+ }),
+
+ test('plan shows the change without moving anything', async () => {
+ const host = scenario();
+ const from = host.repo('live').head;
+ push(host, 'live', { 'server/index.js': 'y();', 'package-lock.json': '{"v":9}' });
+ host.liveStreams = [{ is_live: true }];
+ const r = await host.cli('plan', 'live');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /install \(lockfile changed\)/);
+ assert.match(r.out, /restart +yes: openvibe-live\.service \(socket openvibe-live\.socket stays up\)/);
+ assert.match(r.out, /protected +1 live streams; drain policy refuse/);
+ assert.strictEqual(host.repo('live').head, from);
+ assert.deepStrictEqual(host.restarts(), []);
+ assert.ok(!host.calls.some((c) => c.cmd === 'npm'));
+ }),
+]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * The real executor, exercised only where that is safe on any machine: the SQLite worker against a
+ * temp database, HTTP against a local server, files in a temp directory, and run() as the current
+ * user. No systemctl/git/npm/nginx command is run.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const Database = require('better-sqlite3');
+const { createSystemExecutor } = require('../lib/executor');
+const { test, runTests } = require('./helpers');
+
+const exec = createSystemExecutor();
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ovhost-exec-'));
+process.on('exit', () => fs.rmSync(dir, { recursive: true, force: true }));
+
+runTests([
+ test('sqlite: read-only count as the current user; writes are refused', async () => {
+ const dbPath = path.join(dir, 'media.db');
+ const db = new Database(dbPath);
+ db.pragma('journal_mode = WAL');
+ db.exec('CREATE TABLE vods (id INTEGER PRIMARY KEY, is_recording INTEGER); INSERT INTO vods (is_recording) VALUES (1), (0), (1);');
+ const me = await exec.userName();
+ const rows = await exec.sqlite(dbPath, 'SELECT count(*) AS n FROM vods WHERE is_recording = 1', { as: me });
+ assert.deepStrictEqual(rows, [{ n: 2 }]);
+ await assert.rejects(exec.sqlite(dbPath, 'DELETE FROM vods', { as: me }), /read-only|readonly/);
+ assert.strictEqual(db.prepare('SELECT count(*) AS n FROM vods').get().n, 3);
+ await assert.rejects(exec.sqlite(path.join(dir, 'missing.db'), 'SELECT 1'), /cannot open/);
+ db.close();
+ }),
+
+ test('sqlite backup: consistent copy that passes quick_check; never overwrites', async () => {
+ const src = path.join(dir, 'src.db');
+ const db = new Database(src);
+ db.exec('CREATE TABLE t (x); INSERT INTO t VALUES (1), (2);');
+ const dest = path.join(dir, 'backup.db');
+ await exec.sqliteBackup(src, dest);
+ const copy = new Database(dest, { readonly: true });
+ assert.strictEqual(copy.prepare('SELECT count(*) AS n FROM t').get().n, 2);
+ copy.close();
+ await assert.rejects(exec.sqliteBackup(src, dest), /refusing to overwrite/);
+ db.close();
+ }),
+
+ test('http, files, locks and run() behave as the fake host assumes', async () => {
+ const server = http.createServer((req, res) => { res.writeHead(req.url === '/ok' ? 200 : 503); res.end(JSON.stringify({ host: req.headers.host })); });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const ok = await exec.http(`${base}/ok`, { headers: { Host: 'openvibe.tools' } });
+ assert.strictEqual(ok.status, 200);
+ assert.strictEqual(JSON.parse(ok.body).host, 'openvibe.tools');
+ assert.strictEqual((await exec.http(`${base}/no`)).status, 503);
+ server.close();
+ const refused = await exec.http('http://127.0.0.1:1/');
+ assert.strictEqual(refused.status, 0);
+
+ const f = path.join(dir, 'a', 'lock');
+ assert.strictEqual(await exec.createExclusive(f, '1'), true);
+ assert.strictEqual(await exec.createExclusive(f, '2'), false);
+ assert.strictEqual(await exec.readFile(f), '1');
+ assert.strictEqual(await exec.readFile(path.join(dir, 'nope')), null);
+ const st = await exec.stat(f);
+ assert.strictEqual(st.isFile, true);
+ assert.strictEqual(st.mode & 0o777, 0o640);
+
+ const r = await exec.run(process.execPath, ['-e', 'process.stdout.write(process.argv[1])', 'hi'], { as: await exec.userName() });
+ assert.deepStrictEqual([r.code, r.stdout], [0, 'hi']);
+ }),
+]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,265 @@
@@ -0,0 +1,265 @@
+'use strict';
+/**
+ * A fake host implementing the executor interface (lib/executor.js) in memory: a filesystem, git
+ * repositories, systemd units, npm, nginx, ss, HTTP endpoints and SQLite. Nothing here touches the
+ * real machine. Every run() call is recorded in `calls` so tests can assert what would have been
+ * executed (and as whom).
+ */
+const crypto = require('crypto');
+const path = require('path');
+
+function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', start = Date.parse('2026-09-22T12:00:00Z') } = {}) {
+ const files = new Map(); // path -> { type: 'file'|'dir'|'symlink', content, mode, owner, target }
+ const calls = [];
+ const repos = new Map(); // repoPath -> repo
+ const units = new Map(); // unit -> state
+ const http = new Map(); // url -> fn({ headers }) -> { status, body }
+ const alivePids = new Set();
+ const listeners = new Map(); // port -> [{ pid, process }]
+ let clock = start;
+ let seq = 0;
+ const host = {
+ files, calls, repos, units, http, alivePids, listeners,
+ nginxTest: () => ({ code: 0, stderr: 'nginx: configuration file /etc/nginx/nginx.conf test is successful' }),
+ npm: null, // (cwd, argv, as) -> undefined | { code, stderr } ; default installs package.json deps
+ npmRewritesLockfile: true,
+ sqliteHandler: () => [{ n: 0 }],
+ sqliteCalls: [],
+ reads: [],
+ onRestart: null, // (unit) -> void
+ };
+
+ // ── filesystem ──
+ function ensureDir(p, owner = 'root', mode = 0o755) {
+ const parts = path.resolve(p).split('/').filter(Boolean);
+ let cur = '';
+ for (const part of parts) {
+ cur += `/${part}`;
+ if (!files.has(cur)) files.set(cur, { type: 'dir', mode, owner });
+ }
+ }
+ function put(p, content, { mode = 0o644, owner = 'root' } = {}) {
+ ensureDir(path.dirname(p), owner);
+ files.set(path.resolve(p), { type: 'file', content, mode, owner });
+ }
+ function get(p) {
+ let e = files.get(path.resolve(p));
+ let hops = 0;
+ while (e && e.type === 'symlink' && hops++ < 10) e = files.get(path.resolve(path.dirname(p), e.target));
+ return e || null;
+ }
+ function rmTree(p) {
+ const abs = path.resolve(p);
+ for (const k of [...files.keys()]) if (k === abs || k.startsWith(`${abs}/`)) files.delete(k);
+ }
+ host.put = put;
+ host.read = (p) => { const e = get(p); return e && e.type === 'file' ? String(e.content) : null; };
+ host.ensureDir = ensureDir;
+
+ // ── git ──
+ function newSha(seed) { seq += 1; return crypto.createHash('sha1').update(`${seq}:${seed}`).digest('hex'); }
+ host.createRepo = (repoPath, { owner = 'ubuntu', branch = 'main', remote = 'origin' } = {}) => {
+ const repo = { path: repoPath, owner, branch, remote, commits: new Map(), head: null, remoteRefs: {} };
+ repos.set(repoPath, repo);
+ ensureDir(repoPath, owner);
+ files.get(path.resolve(repoPath)).owner = owner;
+ repo.commit = (changes, { parent = repo.remoteRefs[`${remote}/${branch}`] || repo.head, message = 'change' } = {}) => {
+ const base = parent ? { ...repo.commits.get(parent).files } : {};
+ for (const [k, v] of Object.entries(changes)) { if (v === null) delete base[k]; else base[k] = v; }
+ const sha = newSha(JSON.stringify(base) + message);
+ repo.commits.set(sha, { sha, parent, message, files: base });
+ return sha;
+ };
+ repo.publish = (sha) => { repo.remoteRefs[`${remote}/${branch}`] = sha; };
+ repo.checkout = (sha) => {
+ const old = repo.head ? repo.commits.get(repo.head).files : {};
+ const next = repo.commits.get(sha).files;
+ for (const f of Object.keys(old)) if (!(f in next)) files.delete(path.join(repoPath, f));
+ for (const [f, c] of Object.entries(next)) put(path.join(repoPath, f), c, { owner });
+ repo.head = sha;
+ };
+ return repo;
+ };
+ function isAncestor(repo, anc, sha) {
+ let cur = sha;
+ while (cur) { if (cur === anc) return true; cur = repo.commits.get(cur).parent; }
+ return false;
+ }
+ function resolveRef(repo, ref) {
+ const r = ref.replace(/\^\{commit\}$/, '');
+ if (r === 'HEAD') return repo.head;
+ if (repo.remoteRefs[r]) return repo.remoteRefs[r];
+ if (repo.commits.has(r)) return r;
+ const matches = [...repo.commits.keys()].filter((k) => k.startsWith(r));
+ return matches.length === 1 ? matches[0] : null;
+ }
+ function gitCmd(args, opts) {
+ const repo = repos.get(args[1]);
+ if (!repo) return { code: 128, stdout: '', stderr: 'not a git repository' };
+ if (opts.as !== repo.owner) return { code: 128, stdout: '', stderr: `fatal: detected dubious ownership in repository at '${repo.path}' (ran as ${opts.as || 'root'})` };
+ const [sub, ...rest] = args.slice(2);
+ const ok = (stdout = '') => ({ code: 0, stdout, stderr: '' });
+ const fail = (stderr) => ({ code: 1, stdout: '', stderr });
+ switch (sub) {
+ case 'rev-parse':
+ if (rest[0] === '--abbrev-ref') return ok(`${repo.branch}\n`);
+ if (rest[0] === '--verify') { const s = resolveRef(repo, rest[1]); return s ? ok(`${s}\n`) : fail(`fatal: Needed a single revision (${rest[1]})`); }
+ return ok(`${resolveRef(repo, rest[0])}\n`);
+ case 'cat-file': return resolveRef(repo, rest[1]) ? ok() : fail('bad object');
+ case 'status': {
+ const head = repo.commits.get(repo.head).files;
+ const dirty = Object.entries(head).filter(([f, c]) => host.read(path.join(repo.path, f)) !== c).map(([f]) => ` M ${f}`);
+ return ok(dirty.length ? `${dirty.join('\n')}\n` : '');
+ }
+ case 'fetch': return ok();
+ case 'diff': {
+ const a = repo.commits.get(resolveRef(repo, rest[1])).files;
+ const b = repo.commits.get(resolveRef(repo, rest[2])).files;
+ const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((f) => a[f] !== b[f]).sort();
+ return ok(names.length ? `${names.join('\n')}\n` : '');
+ }
+ case 'log': {
+ const [from, to] = rest[rest.length - 1].split('..');
+ const out = [];
+ let cur = resolveRef(repo, to);
+ while (cur && cur !== resolveRef(repo, from)) { out.push(`${cur.slice(0, 7)} ${repo.commits.get(cur).message}`); cur = repo.commits.get(cur).parent; }
+ return ok(out.length ? `${out.join('\n')}\n` : '');
+ }
+ case 'show': {
+ const [ref, file] = rest[0].split(':');
+ const c = repo.commits.get(resolveRef(repo, ref));
+ return c && file in c.files ? ok(c.files[file]) : fail(`fatal: path '${file}' does not exist`);
+ }
+ case 'merge': {
+ const sha = resolveRef(repo, rest[rest.length - 1]);
+ if (!isAncestor(repo, repo.head, sha)) return fail('fatal: Not possible to fast-forward, aborting.');
+ repo.checkout(sha);
+ return ok();
+ }
+ case 'reset': repo.checkout(resolveRef(repo, rest[rest.length - 1])); return ok();
+ case 'checkout': {
+ const f = rest[rest.length - 1];
+ put(path.join(repo.path, f), repo.commits.get(repo.head).files[f], { owner: repo.owner });
+ return ok();
+ }
+ case 'config': return ok(`https://x-access-token:[email protected]/OpenVibers/${path.basename(repo.path)}.git\n`);
+ default: return fail(`fake git: unsupported ${sub}`);
+ }
+ }
+
+ // ── npm ──
+ function npmCmd(argv, opts) {
+ if (host.npm) {
+ const r = host.npm(opts.cwd, argv, opts.as);
+ if (r) return { stdout: '', stderr: '', ...r };
+ }
+ const pkg = JSON.parse(host.read(path.join(opts.cwd, 'package.json')));
+ for (const dep of Object.keys(pkg.dependencies || {})) put(path.join(opts.cwd, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep, version: '1.0.0' }), { owner: opts.as });
+ const lock = path.join(opts.cwd, 'package-lock.json');
+ if (host.npmRewritesLockfile && host.read(lock) != null) put(lock, `${host.read(lock)}\n// rewritten by npm on the host`, { owner: opts.as });
+ return { code: 0, stdout: 'added packages', stderr: '' };
+ }
+
+ // ── systemd ──
+ host.addUnit = (unit, state = {}) => {
+ units.set(unit, { load: 'loaded', active: 'active', sub: 'running', mainPid: 1000 + units.size, fragmentPath: `/etc/systemd/system/${unit}`, dropIns: [], partOf: [], restarts: 0, runningSha: null, ...state });
+ return units.get(unit);
+ };
+ function systemctlCmd(args) {
+ const [action, unit] = args;
+ const u = units.get(unit);
+ switch (action) {
+ case 'show': {
+ if (!u) return { code: 0, stdout: 'LoadState=not-found\nActiveState=inactive\nSubState=dead\nMainPID=0\nFragmentPath=\n', stderr: '' };
+ return { code: 0, stdout: [`LoadState=${u.load}`, `ActiveState=${u.active}`, `SubState=${u.sub}`, `MainPID=${u.active === 'active' ? u.mainPid : 0}`, `FragmentPath=${u.fragmentPath}`, `UnitFileState=enabled`, `DropInPaths=${u.dropIns.join(' ')}`, `PartOf=${u.partOf.join(' ')}`, `NRestarts=${u.restarts}`].join('\n'), stderr: '' };
+ }
+ case 'restart':
+ if (!u) return { code: 5, stdout: '', stderr: `Unit ${unit} not found.` };
+ u.active = 'active'; u.sub = 'running'; u.restarts += 1;
+ if (host.onRestart) host.onRestart(unit, u);
+ return { code: 0, stdout: '', stderr: '' };
+ case 'start': if (u) { u.active = 'active'; u.sub = unit.endsWith('.socket') ? 'listening' : 'running'; } return { code: 0, stdout: '', stderr: '' };
+ case 'stop': if (u) { u.active = 'inactive'; u.sub = 'dead'; } return { code: 0, stdout: '', stderr: '' };
+ case 'reload': case 'daemon-reload': return { code: 0, stdout: '', stderr: '' };
+ case 'is-active': return { code: u && u.active === 'active' ? 0 : 3, stdout: '', stderr: '' };
+ default: return { code: 1, stdout: '', stderr: `fake systemctl: ${action}` };
+ }
+ }
+
+ host.socketViolations = () => calls.filter((c) => c.cmd === 'systemctl' && ['stop', 'restart', 'kill', 'try-restart', 'reload-or-restart', 'disable', 'mask'].includes(c.args[0]) && String(c.args[1]).endsWith('.socket'));
+ host.restarts = () => calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'restart').map((c) => c.args[1]);
+
+ const exec = {
+ kind: 'fake',
+ async run(cmd, args = [], opts = {}) {
+ calls.push({ cmd, args, as: opts.as || null, privileged: !!opts.privileged, cwd: opts.cwd || null });
+ switch (cmd) {
+ case 'git': return gitCmd(args, opts);
+ case 'npm': return npmCmd(args, opts);
+ case 'systemctl': return systemctlCmd(args);
+ case 'nginx': return { stdout: '', ...host.nginxTest() };
+ case 'rm': rmTree(args[args.length - 1]); return { code: 0, stdout: '', stderr: '' };
+ case 'install': { const src = args[args.length - 2]; const dest = args[args.length - 1]; put(dest, host.read(src)); return { code: 0, stdout: '', stderr: '' }; }
+ case 'node': return { code: 0, stdout: '', stderr: '' };
+ default: return { code: 127, stdout: '', stderr: `fake host: no ${cmd}` };
+ }
+ },
+ async readFile(p) { host.reads.push(path.resolve(p)); const e = get(p); return e && e.type === 'file' ? (Buffer.isBuffer(e.content) ? e.content.toString('utf8') : String(e.content)) : null; },
+ async writeFile(p, content, { mode = 0o644 } = {}) { put(p, content, { mode, owner: user }); },
+ async appendFile(p, text) { const prev = host.read(p) || ''; put(p, prev + text, { mode: 0o640, owner: user }); },
+ async removeFile(p) { files.delete(path.resolve(p)); },
+ async symlink(target, p) { ensureDir(path.dirname(p)); files.set(path.resolve(p), { type: 'symlink', target, owner: user, mode: 0o777 }); },
+ async readlink(p) { const e = files.get(path.resolve(p)); return e && e.type === 'symlink' ? e.target : null; },
+ async stat(p) {
+ const l = files.get(path.resolve(p));
+ const e = get(p);
+ if (!e) return null;
+ const size = e.type === 'file' ? Buffer.byteLength(Buffer.isBuffer(e.content) ? e.content : String(e.content)) : 4096;
+ return { mode: e.mode, uid: e.owner === 'root' ? 0 : 1000, gid: 0, owner: e.owner, isFile: e.type === 'file', isDir: e.type === 'dir', isSymlink: !!l && l.type === 'symlink', size };
+ },
+ async readdir(p) {
+ const abs = path.resolve(p);
+ const e = files.get(abs);
+ if (!e || e.type !== 'dir') return null;
+ const out = new Map();
+ for (const [k, v] of files) {
+ if (!k.startsWith(`${abs}/`)) continue;
+ const name = k.slice(abs.length + 1).split('/')[0];
+ if (!out.has(name)) out.set(name, { name, isDir: k === `${abs}/${name}` ? v.type === 'dir' : true });
+ }
+ return [...out.values()];
+ },
+ async mkdir(p, { owner, mode = 0o750 } = {}) { ensureDir(p, owner || user, mode); const e = files.get(path.resolve(p)); e.owner = owner || e.owner; e.mode = mode; },
+ async createExclusive(p, content) { if (files.has(path.resolve(p))) return false; put(p, content, { mode: 0o640, owner: user }); return true; },
+ async pidAlive(pid) { return alivePids.has(pid); },
+ async http(url, { headers = {} } = {}) {
+ calls.push({ cmd: 'curl', args: [url], as: null, privileged: false, cwd: null });
+ const h = http.get(url);
+ if (!h) return { status: 0, error: 'ECONNREFUSED' };
+ const r = h({ headers });
+ return { status: r.status, body: typeof r.body === 'string' ? r.body : JSON.stringify(r.body || {}) };
+ },
+ async sqlite(db, sql, { as } = {}) {
+ host.sqliteCalls.push({ op: 'query', db, sql, as });
+ calls.push({ cmd: 'sqlite', args: [db, sql], as: as || null, privileged: false, cwd: null });
+ return host.sqliteHandler(db, sql, as);
+ },
+ async sqliteBackup(db, dest, { as } = {}) {
+ host.sqliteCalls.push({ op: 'backup', db, dest, as });
+ calls.push({ cmd: 'sqlite-backup', args: [db, dest], as: as || null, privileged: false, cwd: null });
+ if (files.has(path.resolve(dest))) throw new Error(`refusing to overwrite ${dest}`);
+ put(dest, `backup of ${db}`, { owner: as || user, mode: 0o640 });
+ },
+ async listeners(port) { return listeners.get(port) || []; },
+ async sleep(ms) { clock += ms; },
+ now: () => clock,
+ async isRoot() { return root; },
+ async userName() { return user; },
+ async hostname() { return hostname; },
+ };
+ host.exec = exec;
+ host.advance = (ms) => { clock += ms; };
+ return host;
+}
+
+module.exports = { createFakeHost };
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+"use strict";
+// Throwaway self-signed certificates (public certificates only; their keys were discarded when they
+// were made). SHORT expires 10 days after it was issued, LONG 400 days.
+module.exports = {
+ SHORT: "-----BEGIN CERTIFICATE-----\nMIIB5jCCAYugAwIBAgIUT8gniQdJ7HbNXh1X7TsOraeYBX4wCgYIKoZIzj0EAwIw\nMTEWMBQGA1UECgwNT3BlblZpYmUgVGVzdDEXMBUGA1UEAwwOdGVzdDEwLmV4YW1w\nbGUwHhcNMjYwOTIzMDEzMDU4WhcNMjYxMDAzMDEzMDU4WjAxMRYwFAYDVQQKDA1P\ncGVuVmliZSBUZXN0MRcwFQYDVQQDDA50ZXN0MTAuZXhhbXBsZTBZMBMGByqGSM49\nAgEGCCqGSM49AwEHA0IABCdLY6FAxxs9PyH8a+Pr5+Q48IO+vFBJX9ESe7dfUtLe\n3acFWK/gKcj9jbE3qwakhKN3M1G0gUQ77ykQN79li7SjgYAwfjAdBgNVHQ4EFgQU\n42CHJRMjCZNVtEJm67p1won9RF4wHwYDVR0jBBgwFoAU42CHJRMjCZNVtEJm67p1\nwon9RF4wDwYDVR0TAQH/BAUwAwEB/zArBgNVHREEJDAigg50ZXN0MTAuZXhhbXBs\nZYIQKi50ZXN0MTAuZXhhbXBsZTAKBggqhkjOPQQDAgNJADBGAiEAg0c6SXxBgoFa\nzCpqEDWQOj08+yL0FFW/5bx9NIeUG10CIQDzQ+8WhGtTF7wRQQn4fvD3DcTS3SxS\nHchtVwk26B3x+A==\n-----END CERTIFICATE-----\n",
+ LONG: "-----BEGIN CERTIFICATE-----\nMIIB6TCCAZCgAwIBAgIUJ6CIYdSvjumEcJPhbaXUYegBYt8wCgYIKoZIzj0EAwIw\nMjEWMBQGA1UECgwNT3BlblZpYmUgVGVzdDEYMBYGA1UEAwwPdGVzdDQwMC5leGFt\ncGxlMB4XDTI2MDkyMzAxMzA1OVoXDTI3MTAyODAxMzA1OVowMjEWMBQGA1UECgwN\nT3BlblZpYmUgVGVzdDEYMBYGA1UEAwwPdGVzdDQwMC5leGFtcGxlMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAEpF4N+SjqbGgfP+EiykCQDH93skOyEj52vMQgkMZI\ntO5KXst032+eGl5/3vIQr9QdLtq1x5Lwj2IZ7SbSGY6DAKOBgzCBgDAdBgNVHQ4E\nFgQUID4B2hpbv32WVR+h283zQwykH/owHwYDVR0jBBgwFoAUID4B2hpbv32WVR+h\n283zQwykH/owDwYDVR0TAQH/BAUwAwEB/zAtBgNVHREEJjAkgg90ZXN0NDAwLmV4\nYW1wbGWCESoudGVzdDQwMC5leGFtcGxlMAoGCCqGSM49BAMCA0cAMEQCIAI1LWYt\nQbxsN+sDuI7wHvof60oMaoOkUFvo1soZkyjyAiBGh1TGc0WUHkSjT16YaiQKeXtX\ndqY9FrChgdrzLznLdQ==\n-----END CERTIFICATE-----\n",
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -0,0 +1,208 @@
+'use strict';
+/**
+ * A standard fake host with the shapes of the real estate: Live (socket-activated, /api/streams),
+ * Media (recordings counted in SQLite), Tools (several packages and units), Sites (static build +
+ * nginx vhosts, no units) and Events (SSE, drain policy "report").
+ */
+const path = require('path');
+const { createFakeHost } = require('./fake-host');
+const { main } = require('../lib/cli');
+const { normalise } = require('../lib/inventory');
+
+const SECRET = 'sk_live_THIS_VALUE_MUST_NEVER_APPEAR_4f9a2c';
+
+function pkgJson(name, deps) {
+ return JSON.stringify({ name, version: '1.0.0', dependencies: Object.fromEntries(deps.map((d) => [d, '^1.0.0'])) }, null, 2);
+}
+
+function inventoryDoc() {
+ return {
+ host: 'fake-host',
+ stateDir: '/var/lib/openvibe-host',
+ backupDir: '/var/backups/openvibe',
+ defaults: { owner: 'ubuntu', branch: 'main' },
+ services: {
+ live: {
+ repo: '/opt/openvibe.live',
+ units: ['openvibe-live.service'],
+ socketUnit: 'openvibe-live.socket',
+ unitSources: { 'openvibe-live.service': 'deploy/systemd/openvibe-live.service' },
+ envFile: '/etc/openvibe/live.env',
+ env: { required: ['JWT_SECRET', 'OV_OAUTH_CLIENT_SECRET', 'BASE_URL', 'PAYPAL_CLIENT_SECRET'] },
+ port: 3000,
+ ready: { url: 'http://127.0.0.1:3000/api/ready', timeoutSeconds: 30 },
+ noRestartPaths: ['public/', 'docs/', '*.md'],
+ protected: { kind: 'http-json-count', url: 'http://127.0.0.1:3000/api/streams', array: 'streams', where: { is_live: true }, label: 'live streams' },
+ drain: { policy: 'refuse', waitMaxSeconds: 600, pollSeconds: 60, quietChecks: 2 },
+ databases: [{ name: 'live', path: '/opt/openvibe.live/data/live.db' }],
+ backupOnChange: ['server/db/schema.sql'],
+ nginx: { vhost: 'openvibe.live.conf', variant: 'websocket', wsPaths: ['/ws/'] },
+ },
+ media: {
+ repo: '/opt/openvibe.media',
+ units: ['openvibe-media.service'],
+ envFile: '/etc/openvibe/media.env',
+ env: { required: 'from-example' },
+ port: 4100,
+ ready: { url: 'http://127.0.0.1:4100/healthz', timeoutSeconds: 30 },
+ protected: { kind: 'sqlite-count', db: '/opt/openvibe.media/data/media.db', sql: 'SELECT count(*) AS n FROM vods WHERE is_recording = 1', label: 'recordings in progress' },
+ databases: [{ name: 'media', path: '/opt/openvibe.media/data/media.db' }],
+ },
+ tools: {
+ repo: '/opt/openvibe.tools',
+ packages: ['apps/*'],
+ units: ['openvibe-tools.service', 'openvibe-tools-maps.service'],
+ port: 4001,
+ ready: { url: 'http://127.0.0.1:4001/api/health', headers: { Host: 'openvibe.tools' }, timeoutSeconds: 30 },
+ },
+ sites: {
+ repo: '/opt/openvibe.sites',
+ units: [],
+ install: { command: ['npm', 'ci', '--omit=dev', '--no-audit', '--no-fund'], always: true },
+ build: [['node', 'build.js']],
+ nginx: { repoVhosts: 'deploy/nginx/*.conf', installOnDeploy: true },
+ },
+ events: {
+ repo: '/opt/openvibe.events',
+ units: ['openvibe-events.service'],
+ port: 4300,
+ ready: { url: 'http://127.0.0.1:4300/api/ready', timeoutSeconds: 30 },
+ protected: { kind: 'http-json-count', url: 'http://127.0.0.1:4300/api/ready', field: 'realtime_connections', label: 'SSE connections' },
+ drain: { policy: 'report' },
+ nginx: { variant: 'sse', ssePaths: ['/realtime/stream'] },
+ },
+ },
+ };
+}
+
+function scenario() {
+ const host = createFakeHost();
+ const doc = inventoryDoc();
+ host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
+ const inv = normalise(doc);
+ host.inv = inv;
+ host.badShas = new Set();
+ host.liveStreams = [];
+ host.recording = 0;
+ host.sseConnections = 0;
+ host.unitRepo = {};
+
+ const repoFor = {};
+ function service(id, initialFiles, packages) {
+ const svc = inv.services[id];
+ const repo = host.createRepo(svc.repo, { owner: 'ubuntu' });
+ const sha = repo.commit(initialFiles, { message: 'initial' });
+ repo.publish(sha);
+ repo.checkout(sha);
+ for (const pkgDir of packages) {
+ const pkg = JSON.parse(initialFiles[path.join(pkgDir, 'package.json')]);
+ for (const dep of Object.keys(pkg.dependencies || {})) host.put(path.join(svc.repo, pkgDir, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep }), { owner: 'ubuntu' });
+ }
+ for (const u of svc.units) {
+ host.addUnit(u, { runningSha: sha });
+ host.unitRepo[u] = repo;
+ }
+ repoFor[id] = repo;
+ return repo;
+ }
+
+ service('live', {
+ 'package.json': pkgJson('openvibe-live', ['express', 'better-sqlite3', 'openvibe-shared']),
+ 'package-lock.json': '{"lockfileVersion":3,"v":1}',
+ 'server/index.js': 'console.log(1);',
+ 'server/db/schema.sql': 'CREATE TABLE a(x);',
+ 'public/app.js': 'app();',
+ 'deploy/systemd/openvibe-live.service': '[Service]\nExecStart=node server/index.js\n',
+ '.env.example': 'JWT_SECRET=\nBASE_URL=https://openvibe.live\nPAYPAL_CLIENT_SECRET=\nOV_OAUTH_CLIENT_SECRET=\n# OPTIONAL_THING=\nTURN_URL=\n',
+ }, ['.']);
+ host.addUnit('openvibe-live.socket', { sub: 'listening', partOf: ['openvibe-live.service'], mainPid: 0 });
+ host.put('/etc/systemd/system/openvibe-live.service', '[Service]\nExecStart=node server/index.js\nEnvironment=NODE_ENV=production\n');
+ host.put('/etc/openvibe/live.env', `JWT_SECRET=${SECRET}\nBASE_URL=https://openvibe.live\nPAYPAL_CLIENT_SECRET="${SECRET}-paypal"\nOV_OAUTH_CLIENT_SECRET=${SECRET}-oauth\n`, { mode: 0o600 });
+ host.put('/opt/openvibe.live/data/live.db', 'sqlite', { owner: 'ubuntu' });
+
+ service('media', {
+ 'package.json': pkgJson('openvibe-media', ['express', 'better-sqlite3']),
+ 'package-lock.json': '{"lockfileVersion":3,"v":1}',
+ 'server/index.js': 'media();',
+ '.env.example': 'PORT=4100\nMEDIA_B2_APP_KEY=\nMEDIA_R2_SECRET_ACCESS_KEY=\n',
+ }, ['.']);
+ host.put('/etc/openvibe/media.env', `PORT=4100\nMEDIA_B2_APP_KEY=${SECRET}-b2\nMEDIA_R2_SECRET_ACCESS_KEY=${SECRET}-r2\n`, { mode: 0o600 });
+ host.put('/opt/openvibe.media/data/media.db', 'sqlite', { owner: 'ubuntu' });
+
+ service('tools', {
+ 'apps/gateway/package.json': pkgJson('tools-gateway', ['express', 'openvibe-shared']),
+ 'apps/gateway/package-lock.json': '{"v":1}',
+ 'apps/gateway/server.js': 'gw();',
+ 'apps/maps/package.json': pkgJson('tools-maps', ['express']),
+ 'apps/maps/package-lock.json': '{"v":1}',
+ 'apps/maps/server.js': 'maps();',
+ }, ['apps/gateway', 'apps/maps']);
+
+ service('sites', {
+ 'package.json': pkgJson('openvibe-sites', ['openvibe-shared']),
+ 'package-lock.json': '{"v":1}',
+ 'build.js': 'build();',
+ 'deploy/nginx/openvibe.chat.conf': 'server { listen 443; server_name openvibe.chat; }\n',
+ }, ['.']);
+
+ service('events', {
+ 'package.json': pkgJson('openvibe-events', ['express']),
+ 'package-lock.json': '{"v":1}',
+ 'server/index.js': 'events();',
+ }, ['.']);
+
+ host.ensureDir('/etc/nginx/sites-available');
+ host.ensureDir('/etc/nginx/sites-enabled');
+
+ // A restarted unit runs whatever its checkout holds at that moment.
+ host.onRestart = (unit, u) => { if (host.unitRepo[unit]) u.runningSha = host.unitRepo[unit].head; };
+ const readyFor = (unit) => () => {
+ const u = host.units.get(unit);
+ if (!u || u.active !== 'active') return { status: 502 };
+ return host.badShas.has(u.runningSha) ? { status: 503, body: { status: 'not_ready' } } : { status: 200, body: { status: 'ready', realtime_connections: host.sseConnections } };
+ };
+ host.http.set('http://127.0.0.1:3000/api/ready', readyFor('openvibe-live.service'));
+ host.http.set('http://127.0.0.1:3000/api/streams', () => ({ status: 200, body: { streams: host.liveStreams } }));
+ host.http.set('http://127.0.0.1:4100/healthz', readyFor('openvibe-media.service'));
+ host.http.set('http://127.0.0.1:4001/api/health', readyFor('openvibe-tools.service'));
+ host.http.set('http://127.0.0.1:4300/api/ready', readyFor('openvibe-events.service'));
+ host.sqliteHandler = (db, sql) => (/is_recording/.test(sql) ? [{ n: host.recording }] : [{ n: 0 }]);
+
+ host.repo = (id) => repoFor[id];
+ host.ctx = () => ({ exec: host.exec, inv, log: (s) => host.logs.push(s) });
+ host.logs = [];
+ host.cli = async (...argv) => {
+ const lines = [];
+ const code = await main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {} });
+ return { code, out: lines.join('\n') };
+ };
+ return host;
+}
+
+/** Publish a new commit on origin/main for a service. */
+function push(host, id, changes, message = 'change') {
+ const repo = host.repo(id);
+ const sha = repo.commit(changes, { message });
+ repo.publish(sha);
+ return sha;
+}
+
+function test(name, fn) {
+ return { name, fn };
+}
+
+async function runTests(tests) {
+ let failed = 0;
+ for (const t of tests) {
+ try {
+ await t.fn();
+ console.log(` ✓ ${t.name}`);
+ } catch (err) {
+ failed += 1;
+ console.log(` ✗ ${t.name}\n${err.stack}`);
+ }
+ }
+ if (failed) { console.log(`${failed} failed`); process.exit(1); }
+}
+
+module.exports = { scenario, push, test, runTests, SECRET, pkgJson };
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails if any
+ * of them fails. Every test drives ovhost against the in-memory fake host (test/fake-host.js):
+ * no systemctl, git, npm, nginx, curl or SQLite call reaches the real machine.
+ *
+ * npm test # everything
+ * npm test -- deploy certs # only files whose name contains one of the words
+ */
+'use strict';
+const { spawn } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
+const files = fs.readdirSync(__dirname)
+ .filter((f) => f.endsWith('.test.js'))
+ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
+ .sort();
+const TIMEOUT_MS = 60000;
+
+function runOne(file) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [path.join(__dirname, file)], {
+ cwd: path.join(__dirname, '..'),
+ env: { ...process.env, NODE_ENV: 'test' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
+ child.on('close', (code, signal) => {
+ clearTimeout(timer);
+ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
+ });
+ });
+}
+
+(async () => {
+ if (!files.length) { console.error('no test files matched'); process.exit(1); }
+ const results = [];
+ for (const f of files) {
+ const r = await runOne(f);
+ results.push(r);
+ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);
+ }
+ const failed = results.filter((r) => !r.ok);
+ for (const r of failed) {
+ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
+ console.log(r.output.split('\n').slice(-60).join('\n'));
+ }
+ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
+ process.exit(failed.length ? 1 : 0);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+const assert = require('assert');
+const { scenario, test, runTests, SECRET } = require('./helpers');
+const { readTarGz } = require('../lib/tar');
+
+runTests([
+ test('snapshot: units, vhost, env NAMES and sha in a tarball with no secret values', async () => {
+ const host = scenario();
+ host.put('/etc/systemd/system/openvibe-live.service', `[Service]\nEnvironment=NODE_ENV=production "ADMIN_TOKEN=${SECRET}" PORT=3000\nEnvironmentFile=/etc/openvibe/live.env\n`);
+ host.units.get('openvibe-live.service').dropIns = ['/etc/systemd/system/openvibe-live.service.d/socket.conf'];
+ host.put('/etc/systemd/system/openvibe-live.service.d/socket.conf', '[Unit]\nRequires=openvibe-live.socket\n');
+ host.put('/etc/systemd/system/openvibe-live.socket', '[Socket]\nListenStream=0.0.0.0:3000\n');
+ host.put('/etc/nginx/sites-available/openvibe.live.conf', 'server { server_name openvibe.live; }\n');
+ const r = await host.cli('snapshot', 'live', '--json');
+ assert.strictEqual(r.code, 0, r.out);
+ const res = JSON.parse(r.out);
+ assert.match(res.file, /^\/var\/lib\/openvibe-host\/snapshots\/live-\d{8}-\d{6}-[0-9a-f]{12}\.tar\.gz$/);
+ const entry = host.files.get(res.file);
+ assert.strictEqual(entry.mode, 0o640);
+ const tar = readTarGz(entry.content);
+ const names = tar.map((e) => e.name);
+ assert.deepStrictEqual(names, ['snapshot.json', 'env/live.env.names', 'units/openvibe-live.service', 'units/openvibe-live.service.d/socket.conf', 'units/openvibe-live.socket', 'nginx/openvibe.live.conf']);
+ const all = tar.map((e) => e.content).join('\n');
+ assert.ok(!all.includes(SECRET), 'a secret value leaked into the snapshot');
+ assert.ok(!all.includes('ghp_FAKE_TOKEN_VALUE'), 'remote URL credentials leaked');
+ const unit = tar.find((e) => e.name === 'units/openvibe-live.service').content;
+ assert.match(unit, /ADMIN_TOKEN=<redacted>/);
+ assert.match(unit, /NODE_ENV=production/);
+ assert.strictEqual(tar.find((e) => e.name === 'env/live.env.names').content, 'JWT_SECRET\nBASE_URL\nPAYPAL_CLIENT_SECRET\nOV_OAUTH_CLIENT_SECRET\n');
+ const meta = JSON.parse(tar[0].content);
+ assert.strictEqual(meta.sha, host.repo('live').head);
+ assert.strictEqual(meta.remote, 'https://github.com/OpenVibers/openvibe.live.git');
+ assert.strictEqual(meta.manifest.id, 'live');
+ assert.deepStrictEqual(meta.env.names, ['JWT_SECRET', 'BASE_URL', 'PAYPAL_CLIENT_SECRET', 'OV_OAUTH_CLIENT_SECRET']);
+ assert.ok(meta.lockfiles['.']);
+ }),
+
+ test('backup: sqlite .backup of each declared database, run as the service user, into a dated directory', async () => {
+ const host = scenario();
+ const r = await host.cli('backup', 'media', '--json');
+ assert.strictEqual(r.code, 0, r.out);
+ const res = JSON.parse(r.out);
+ assert.match(res.dir, /^\/var\/backups\/openvibe\/media\/20260922-120000$/);
+ assert.strictEqual(res.files[0].dest, `${res.dir}/media.db`);
+ const call = host.sqliteCalls.find((c) => c.op === 'backup');
+ assert.strictEqual(call.as, 'ubuntu');
+ assert.strictEqual(call.db, '/opt/openvibe.media/data/media.db');
+ assert.strictEqual(host.files.get(res.dir).owner, 'ubuntu', 'backup dir owned by the service user');
+ const log = host.read('/var/lib/openvibe-host/backups/media.jsonl');
+ assert.strictEqual(JSON.parse(log.trim()).files[0].dest, res.files[0].dest);
+ // A second backup in the same second never overwrites the first.
+ const again = await host.cli('backup', 'media');
+ assert.notStrictEqual(again.code, 0);
+ }),
+
+ test('backup refuses a service without declared databases', async () => {
+ const host = scenario();
+ const r = await host.cli('backup', 'tools');
+ assert.strictEqual(r.code, 2);
+ assert.match(r.out, /declares no databases/);
+ }),
+]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+const assert = require('assert');
+const { scenario, test, runTests, SECRET } = require('./helpers');
+const envfile = require('../lib/envfile');
+
+function noSecrets(text) {
+ assert.ok(!text.includes(SECRET), 'output contains a secret value');
+ assert.ok(!text.includes('4f9a2c'), 'output contains part of a secret value');
+}
+
+runTests([
+ test('env validation reports names only — never a value — in text and JSON output', async () => {
+ const host = scenario();
+ host.put('/etc/openvibe/live.env', `JWT_SECRET=${SECRET}\nBASE_URL=\nPAYPAL_CLIENT_SECRET=""\n`, { mode: 0o644 });
+ for (const args of [['validate', 'live'], ['validate', 'live', '--json']]) {
+ const r = await host.cli(...args);
+ assert.strictEqual(r.code, 2, r.out);
+ noSecrets(r.out);
+ assert.match(r.out, /missing: OV_OAUTH_CLIENT_SECRET/);
+ assert.match(r.out, /empty: BASE_URL, PAYPAL_CLIENT_SECRET/);
+ assert.match(r.out, /world-readable \(mode 644\)/);
+ }
+ }),
+
+ test('a correct env file passes; names from the unit Environment= lines count as present', async () => {
+ const host = scenario();
+ host.inv.services.live.env.required.push('NODE_ENV');
+ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
+ raw.services.live.env.required.push('NODE_ENV');
+ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });
+ host.listeners.set(3000, [{ pid: 1, process: 'systemd' }, { pid: host.units.get('openvibe-live.service').mainPid, process: 'node' }]);
+ host.put('/etc/nginx/sites-available/openvibe.live.conf', 'server {}');
+ await host.exec.symlink('/etc/nginx/sites-available/openvibe.live.conf', '/etc/nginx/sites-enabled/openvibe.live.conf');
+ host.put('/etc/systemd/system/openvibe-live.service', host.repo('live').commits.get(host.repo('live').head).files['deploy/systemd/openvibe-live.service'].replace('\n', '\nEnvironment=NODE_ENV=production\n'));
+ const r = await host.cli('validate', 'live', '--json');
+ const res = JSON.parse(r.out);
+ const errors = res.findings.filter((f) => f.level === 'error');
+ assert.deepStrictEqual(errors, []);
+ assert.strictEqual(r.code, 0);
+ assert.ok(res.findings.some((f) => f.area === 'env' && /5 required name\(s\) present/.test(f.message)));
+ assert.ok(res.findings.some((f) => f.area === 'port' && /held by systemd \(openvibe-live\.socket\), node/.test(f.message)));
+ assert.ok(res.findings.some((f) => f.level === 'warn' && /PartOf=openvibe-live\.service/.test(f.message)), 'PartOf propagation is pointed out');
+ assert.ok(res.findings.some((f) => f.level === 'info' && /TURN_URL/.test(f.message)), 'optional names from .env.example listed');
+ noSecrets(r.out);
+ }),
+
+ test('required: "from-example" takes every name the checkout .env.example declares', async () => {
+ const host = scenario();
+ host.put('/etc/openvibe/media.env', `PORT=4100\nMEDIA_B2_APP_KEY=${SECRET}\n`, { mode: 0o600 });
+ const r = await host.cli('validate', 'media');
+ assert.match(r.out, /missing: MEDIA_R2_SECRET_ACCESS_KEY/);
+ noSecrets(r.out);
+ }),
+
+ test('flags a missing unit, a port held by someone else, a missing vhost and a failing nginx -t', async () => {
+ const host = scenario();
+ host.units.delete('openvibe-live.service');
+ host.listeners.set(3000, [{ pid: 999, process: 'python3' }]);
+ host.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] unknown directive "proxy_passs"' });
+ const r = await host.cli('validate', 'live');
+ assert.strictEqual(r.code, 2);
+ assert.match(r.out, /openvibe-live\.service is not loaded/);
+ assert.match(r.out, /3000 is held by python3\(999\)/);
+ assert.match(r.out, /sites-available\/openvibe\.live\.conf missing/);
+ assert.match(r.out, /nginx -t failed: .*proxy_passs/);
+ }),
+
+ test('checkout owner, branch and dependency resolution are checked', async () => {
+ const host = scenario();
+ host.files.get('/opt/openvibe.events').owner = 'root';
+ host.files.delete('/opt/openvibe.events/node_modules/express/package.json');
+ const r = await host.cli('validate', 'events');
+ assert.match(r.out, /owned by root, expected ubuntu/);
+ assert.match(r.out, /express — node_modules\/express\/package\.json is missing/);
+ }),
+
+ test('the env parser keeps no values', () => {
+ const parsed = envfile.parseNames(`A=${SECRET}\nexport B="x y"\nC=\nD=''\n# E=1\nF= # comment\n`);
+ assert.deepStrictEqual(parsed, [
+ { name: 'A', empty: false }, { name: 'B', empty: false }, { name: 'C', empty: true }, { name: 'D', empty: true }, { name: 'F', empty: true },
+ ]);
+ assert.ok(!JSON.stringify(parsed).includes(SECRET));
+ assert.deepStrictEqual(envfile.parseExample('A=1\n# B=\nC=\n').declared, ['A', 'C']);
+ assert.deepStrictEqual(envfile.unitEnvironmentNames('Environment=A=1 "B=two words"\nEnvironment=C=3\n'), ['A', 'B', 'C']);
+ }),
+
+ test('env-names lists .env.example names', async () => {
+ const host = scenario();
+ const r = await host.cli('env-names', 'live');
+ assert.strictEqual(r.code, 0);
+ assert.match(r.out, /JWT_SECRET/);
+ assert.match(r.out, /commented out: OPTIONAL_THING/);
+ }),
+]);