openvibers/openvibe.host Commits

ovhost absorbs the per-repository deploy scripts (roadmap WS-N task 11). A service entry's `strategy` picks the engine and the defaults the script it replaces encoded: git-checkout (the default, what ovhost did before), multi-app (Tools: apps/_* skipped, untracked lockfiles the release tracks removed before the merge, preflight checks in each app, every openvibe-tools* unit file restarted via unitsMatch, ready.release and ready.allUnits), static-build (Sites: tracked dist/ restored from git before the merge, npm ci, node build.js, repo vhosts behind nginx -t, one release notification per dist/*/release.json), pnpm-build (Games: one pnpm install --frozen-lockfile for the workspace, pnpm build, generated dist-types/, rebuild on restore) and release-layout (Live, OpenRe; lib/release-layout.js: a release prepared in releases/<id> while current serves, node_modules hard-linked unless dependencies changed, node --check on changed files, an atomic current switch, static-only switches without a restart, the release's unit files, readiness with /release.json naming the new sha, switch back on failure, pruning that keeps any release a worker instance runs from, --prepare-only, ovhost rollback to the release the log says current replaced). The socket rule: pid 1 must hold the listener; only when the socket unit file changed or systemd does not hold the port is the service stopped, the socket restarted and the service started (systemd.rebindSocket, the only socket restart). --wait-idle now also holds a report-policy service. ovhost capabilities <svc> and --version are what the repositories' deploy.sh wrappers probe (deploy-api=1) before handing over, falling back to their deploy-legacy.sh otherwise. docs/deploy-strategies.md: the engines, every inventory field, the rule-by-rule mapping, what was not ported and why, the production cutover checklist and the proposed /etc/openvibe/host.json diff (not applied); host.example.json carries it. Tests: test/strategy-release-layout.test.js, test/strategy-in-place.test.js (fake host: worktrees, symlinked paths, mv -Tf, cp -al, pnpm). Version 0.3.0.

OP OpenVibers committed 27 Sept 2026 · e4c7a58f37 · 27 files, +2898 −162


Changes

ddiff --git a/README.md b/README.md
iindex 3a1652a..b3a0b3a 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> The network's deployment/control plane first; then isolated hosting for community sites, bots and mods.
--**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It is installed on the production host (2026-09-23, `/usr/local/bin/ovhost`, inventory `/etc/openvibe/host.json`) and it deploys 20 services (`ovhost deploy <svc>`: Network, Events, Chat, Billing, Tips, VIP, AI, Search, Sources, Wiki, Blog, News, Reviews, Deals, Coupons, Trade, Codes, Host, Media, Community; 70+ releases since 2026-09-23, with automatic rollback on a failed readiness check) and runs the nightly backups and the restore drills of 19 services. Live, Tools, Sites, Games and OpenRe still deploy with their own scripts. Stage B (tenant static hosting) is a service, written and tested; it runs on the host on loopback `:4910` (2026-09-23 audit) and is **not launched**: `openvibe.host` is still the Sites placeholder and `*.openvibe.host` answers 404. The launch is ready to run: [docs/launch.md](docs/launch.md) (steps, verification, rollback and the launch-rule evidence) and [docs/threat-review.md](docs/threat-review.md). Stage C (sandboxed user code) is **not started**.
++**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It is installed on the production host (2026-09-23, `/usr/local/bin/ovhost`, inventory `/etc/openvibe/host.json`) and it deploys 20 services (`ovhost deploy <svc>`: Network, Events, Chat, Billing, Tips, VIP, AI, Search, Sources, Wiki, Blog, News, Reviews, Deals, Coupons, Trade, Codes, Host, Media, Community; 70+ releases since 2026-09-23, with automatic rollback on a failed readiness check) and runs the nightly backups and the restore drills of 19 services. Live, Tools, Sites, Games and OpenRe still deploy with their own scripts on the host: the deploy strategies that replace them (`release-layout`, `multi-app`, `static-build`, `pnpm-build`; roadmap WS-N task 11) are written and tested, each repository's `deploy/scripts/deploy.sh` is a thin wrapper that hands over to `ovhost deploy <svc>` or falls back to its old script, and the production cutover is a checklist for the operator: [docs/deploy-strategies.md](docs/deploy-strategies.md). Stage B (tenant static hosting) is a service, written and tested; it runs on the host on loopback `:4910` (2026-09-23 audit) and is **not launched**: `openvibe.host` is still the Sites placeholder and `*.openvibe.host` answers 404. The launch is ready to run: [docs/launch.md](docs/launch.md) (steps, verification, rollback and the launch-rule evidence) and [docs/threat-review.md](docs/threat-review.md). Stage C (sandboxed user code) is **not started**.
**Domain:** `openvibe.host` (dashboard and API) and `*.openvibe.host` (tenant sites). The domain keeps its placeholder page on [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until Stage B is deployed and launched (see [Launch rule](#launch-rule)).
**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).
@@@ -13,13 +13,15 @@ Every repository ships its own deploy script, and each one encodes rules learned
| 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. |
++| 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. The one exception is the rebind Live's script also made (`systemd.rebindSocket`, release-layout only): when the socket unit file changed or pid 1 does not hold the port, the service is stopped, the socket restarted and the service started on it. |
| 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. |
++| Build a release while the old one serves; switch a symlink; roll back to a release with its own `node_modules`; pid 1 must hold the socket | Live `deploy.sh` (release layout), OpenRe `deploy.sh release`/`api` | Strategy `release-layout` ([docs/deploy-strategies.md](docs/deploy-strategies.md)). The socket unit is restarted only when its unit file changed or systemd does not hold the port. |
++| Per-app installs (not `apps/_shared`), jobs runtime and guard load before the restart, every `openvibe-tools*` unit | Tools `deploy.sh` | Strategy `multi-app`: `skipPackages`, `preflight`, `unitsMatch`, `ready.release`. |
| 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:
@@@ -71,7 +73,7 @@ What the tests demonstrate (`npm test`, every system call made against `test/fak
- 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.
++- The socket unit is **never stopped or restarted**, in any flow, except the release-layout rebind: only when its unit file changed or systemd does not hold the port (`test/strategy-release-layout.test.js`).
- 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.
- A **restore drill** restores the latest backup into a service-user-owned temp directory and requires `integrity_check = ok`. It starts a sandboxed second instance through `systemd-run` with the production env file plus an override file. It compares the declared paths and row counts with production, then stops the instance by its own pid and removes the directory. Failed integrity, a readiness timeout, an instance that dies, mismatches and a failed `systemd-run` are all reported, logged and cleaned up. It refuses to run without root or on a port already in use. A filesystem diff of the fake host shows it writes nothing outside the drill directory, its lock and its log, and it never reads the env file or passes on a secret-looking unit `Environment=`.
@@@ -94,9 +96,11 @@ ovhost status [<service>...] unit state, sha, readiness, protected se
ovhost validate <service> [--manifest <file>] env NAMES, unit files, port, vhost + nginx -t, deps, lifecycle
ovhost env-names <service> names declared in the checkout's .env.example
ovhost show [<service>...] the inventory as ovhost reads it (repo, owner, units, socket, workers, probes)
--ovhost plan <service> [--to <sha>] [--no-fetch]
--ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check] [--no-announce]
--ovhost rollback <service> [--to <sha>] [--wait-idle] [--force] [--no-announce]
++ovhost plan <service> [--to <sha>] [--no-fetch] [--restart]
++ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check] [--no-announce] [--prepare-only]
++ovhost rollback <service> [--to <sha|release id>] [--wait-idle] [--force] [--no-announce]
++ovhost capabilities [<service>] key=value lines the deploy wrappers probe (deploy-api, strategy, managed)
++ovhost --version
ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notification
ovhost releases <service> [--limit <n>]
ovhost certs [--warn-days <n>]
@@@ -111,7 +115,9 @@ ovhost restore-download <service> <run|latest> [--out <dir>] [--from-host <name>
ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)
```
--Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passed, `1` refused (not root, port in use, no backup, unsupported), `2` failed. Other 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).
++Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passed, `1` refused (not root, port in use, no backup, unsupported), `2` failed. Other 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) · `6` frozen (`ovhost freeze`; `--force` goes through, rollbacks are never frozen).
++
++**Deploy strategies** (roadmap WS-N task 11). The inventory entry's `strategy` picks how a service deploys: `git-checkout` (the default: an in-place checkout, every service above), `multi-app` (Tools), `static-build` (Sites), `pnpm-build` (Games) or `release-layout` (Live, OpenRe: `releases/<id>` behind a `current` symlink, prepared while the old release serves, `--prepare-only` to stop after preparing). Each strategy carries the rules of the script it replaces: preflight checks, build output restored from git, untracked lockfiles, `unitsMatch`, `/release.json` naming the new sha, the socket rule. [docs/deploy-strategies.md](docs/deploy-strategies.md) has the engines, every inventory field, the rule-by-rule mapping from each script, what was not ported and why, the production cutover checklist and the proposed inventory diff.
`deploy --browser-check` checks the service's public site in headless Chrome after a deploy that went through. It runs [`scripts/browser-check.js`](docs/browser-check.md) for that one site. The check is report only: it never changes the exit code or the release record. It needs Chrome where ovhost runs.
@@@ -123,7 +129,7 @@ Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passe
### 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).
++1. Take the per-service lock. A `release-layout` service goes to its own engine ([docs/deploy-strategies.md](docs/deploy-strategies.md#release-layout-release-layout)); an in-place strategy refuses a checkout that has a `releases/current` layout.
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`.
@@@ -171,7 +177,7 @@ The sandbox blocks writes outside the drill directory and addresses beyond loopb
**Lifecycle** (roadmap WS-P task 1, `lib/lifecycle.js`). Each service's lifecycle is the `lifecycle` block of its manifest in openvibe-contracts (≥ 0.55.0): liveness, shutdown (signal, `deadlineSeconds`, drains, workers), startupRecovery, rollback, contracts and leases. A service entry may carry its own `lifecycle` block, which replaces the manifest's on this host, and `validate --manifest <file>` reads another manifest file. `ovhost validate` fails when no block is found or a field is missing, naming the service and the field (`live: lifecycle.shutdown.deadlineSeconds is missing`). It also fails when `deadlineSeconds` exceeds a unit's stop timeout (systemd's effective `TimeoutStopUSec`, else `TimeoutStopSec` in the unit source) or the unit's `KillSignal` is another signal, and when the checkout's installed openvibe-contracts is outside `contracts.range`. Worker units that drain longer than their stop timeout are a warning, since ovhost never stops them. This repository still pins openvibe-contracts v0.49.0, which has no lifecycle blocks, so until that pin moves to v0.55.0 or later `validate` reports every service as undeclared.
--`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.
++`games` deploys with the `pnpm-build` strategy (a pnpm workspace with a TypeScript build: `pnpm install --frozen-lockfile`, `pnpm build`, the tracked `dist-types/` restored from git before the merge). An entry with `layout: "release"` and no `strategy` stays unmanaged: `deploy` and `rollback` refuse it and the repository's own script deploys it. The deploy-strategy fields (`strategy`, `release`, `preflight`, `skipPackages`, `removeUntrackedLockfiles`, `generated`, `installUnits`, `unitsMatch`, `announce.releaseFiles`, `install.lockfile`, `install.workspace`, `ready.release`, `ready.allUnits`) are listed in [docs/deploy-strategies.md](docs/deploy-strategies.md#inventory-fields).
## Installing on the host (for the operator)
ddiff --git a/docs/cutover-runbook.md b/docs/cutover-runbook.md
iindex 1f4abd9..2299aec 100644
---- a/docs/cutover-runbook.md
++++ b/docs/cutover-runbook.md
@@@ -70,7 +70,10 @@ These actions need the owner's accounts or physical presence. Nothing in this ru
## 0. Deploy rules that apply to every step
These rules are learned from outages. `ovhost` encodes them (see [README](../README.md#purpose)). As of
--2026-09-23 it is used read-only on the host, so the per-repo scripts are still what runs.
++2026-09-23 it is used read-only on the host, so the per-repo scripts are still what runs. Since WS-N task 11
++(2026-09-27) those scripts are wrappers around `ovhost deploy <svc>` that fall back to the old script
++(`deploy/scripts/deploy-legacy.sh`) until the host's ovhost and inventory are cut over
++([deploy-strategies.md](deploy-strategies.md)); the rules below hold either way.
- **Live**: `sudo /opt/openvibe.live/deploy/scripts/deploy.sh [--wait-idle|--restart|--force]`
(`deploy/scripts/deploy.sh:7-12`).
ddiff --git a/docs/deploy-strategies.md b/docs/deploy-strategies.md
nnew file mode 100644
iindex 0000000..750985b
---- /dev/null
++++ b/docs/deploy-strategies.md
@@@ -0,0 +1,314 @@
++# Deploy strategies (roadmap WS-N task 11)
++
++`ovhost deploy <svc>` now covers the services that deployed with their own `deploy/scripts/deploy.sh`:
++Live (release layout), Tools, Sites, OpenRe and Network, and Games (which deployed by hand). Each service
++entry in the inventory names a **strategy**: an engine plus the defaults the script it replaces encoded.
++Every repository keeps `deploy/scripts/deploy.sh` as a thin wrapper that maps its old flags onto ovhost,
++with the old script body kept as `deploy/scripts/deploy-legacy.sh` for the fallback.
++
++**State (2026-09-27):** written and tested against the fake host (`test/strategy-release-layout.test.js`,
++`test/strategy-in-place.test.js`) and in each repository (the wrapper tests). **Not on the host yet**:
++`/usr/local/lib/openvibe-host` and `/etc/openvibe/host.json` are unchanged. Until the operator runs the
++[cutover checklist](#production-cutover-checklist-for-the-operator), every wrapper finds an ovhost without
++`capabilities` and runs its legacy script, so deploys work exactly as before.
++
++| Service | Strategy | Replaces | Wrapper (`deploy/scripts/deploy.sh`) |
++|---|---|---|---|
++| live | `release-layout` | Live `deploy.sh` (release layout) | `--wait-idle`, `--restart`, `--rollback` → `ovhost rollback live`, `DRY_RUN=1` → `ovhost plan live`; `--force` refused (see below) |
++| openre | `release-layout` | OpenRe `deploy.sh release` + `api` | `deploy`, `release [<ref>]` → `--prepare-only`, `api [<sha>]` → `deploy --to <sha>`, `rollback`, `plan`; `workers`, `status`, `prune` stay in the legacy script |
++| tools | `multi-app` | Tools `deploy.sh` | `--wait-idle`, `--restart`, `--force`, `--rollback`, `DRY_RUN=1` |
++| sites | `static-build` | Sites `deploy.sh` (after a manual pull) | always `ovhost deploy sites --restart` (rebuild every run, as before); `DRY_RUN=1` |
++| games | `pnpm-build` | the manual procedure | `--wait-idle`, `--restart`, `--force`, `--rollback`, `DRY_RUN=1` |
++| network | `git-checkout` | Network `deploy.sh` | always `--install-units`; `--restart`, `--wait-idle`, `--force`, `--rollback`, `DRY_RUN=1` |
++
++Every other service keeps the default strategy, `git-checkout`, which is what `ovhost deploy` did before.
++
++## The wrappers and their fallback
++
++Each wrapper first asks `ovhost capabilities <svc>` (run as root: `sudo` when the wrapper is not root). It
++hands over to ovhost only when every line it needs is there:
++
++```
++ovhost=0.3.0
++deploy-api=1
++strategies=git-checkout,multi-app,static-build,pnpm-build,release-layout
++commands=plan,deploy,rollback,announce,capabilities
++deploy-flags=--wait-idle,--force,--restart,--to,--install-units,--ready-timeout,--prepare-only,--no-announce
++service=live
++strategy=release-layout
++managed=yes
++layout=release
++```
++
++`deploy-api` is at least 1, `strategy=` is the one the wrapper was written for, and `managed=yes`.
++Otherwise it prints why (`ovhost not found`, `no 'capabilities' (too old)`, `deploy-api is 0`, `the host
++inventory does not deploy live with strategy release-layout (none)`, …) and runs `deploy-legacy.sh` with the
++original arguments. So an old ovhost, a missing one, or an inventory that has not been changed yet never
++breaks a deploy. `OVHOST_LEGACY=1` forces the fallback; `OVHOST=<path>` picks another ovhost. Where the legacy
++script cannot do what was asked (Tools, Network and Games have no `--rollback`; none of them has `DRY_RUN`
++except Live), the wrapper refuses instead of doing something else. The Sites fallback pulls first (it restores
++`dist/` and runs `git pull --ff-only` as the checkout owner), then runs the pulled legacy script. OpenRe's
++fallback runs `release` then `api` for `deploy`.
++
++Exit codes are ovhost's: `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
++· `6` frozen.
++
++**Live `--force`.** The old script's `--force` meant "discard local tracked changes" (legacy in-place layout
++only). ovhost never discards changes, and its `--force` drops live streams and passes a freeze. The Live
++wrapper therefore refuses `--force`; run `sudo ovhost deploy live --force` yourself when that is what you
++mean. (In the fallback, `--force` goes to the legacy script as before.)
++
++## The two engines
++
++### In place (git-checkout, multi-app, static-build, pnpm-build)
++
++`lib/release-ops.js`, the Stage A engine, with the strategy's defaults:
++
++1. Lock, fetch as the owner, plan (from/to, changed files, installs, restart needed, unit drift).
++2. Refuse a wrong branch or tracked local changes. Tracked **build output** (`generated`: Sites' `dist/`,
++ Games' `dist-types/`) is not a local change: it is restored from git before the merge.
++3. Protected sessions (refuse, `--wait-idle`, `--force`, drain policy) before the checkout moves.
++4. Remove untracked lockfiles the release tracks (`removeUntrackedLockfiles`), then `git merge --ff-only`.
++5. Install where needed (per package, or once for a workspace), verify every dependency resolves, build,
++ **preflight**, install repo vhosts behind `nginx -t`, install unit files (`installUnits` or
++ `--install-units`), back up databases on a schema change, protected sessions again.
++6. Any failure in 5 restores the checkout, its dependencies **and rebuilds** (a build writes what is served);
++ nothing restarted (exit 2).
++7. Restart the units (plus unit files matching `unitsMatch`), wait for readiness (`ready.release`,
++ `ready.allUnits`), and on failure roll back: restore, reinstall, rebuild, restart (exit 3, or 4).
++8. Record the release; announce it (Sites: one notification per `dist/<domain>/release.json`).
++
++### Release layout (release-layout)
++
++`lib/release-layout.js`:
++
++1. Lock, fetch in the clone (`release.git`), plan against the release `current` points at.
++2. Prepare the new release while the current one serves: `git worktree add --detach releases/<id> <sha>`,
++ the links (`data -> ../../shared/data`), `npm ci` when the lockfile or dependency fields changed or
++ `release.reuseModules` is false, else `cp -al` of the current `node_modules`; every dependency resolves;
++ preflight (Live: `node --check` on each changed `.js`, `JSON.parse` on each changed `.json`); `release.chown`.
++ Any failure removes the new release: nothing restarted, `current` untouched (exit 2). `--prepare-only`
++ stops here.
++3. Static-only change (nothing outside `noRestartPaths`, no unit file changed): switch `current` (a new link
++ renamed over the old one), wait `release.settleSeconds`, the ready URL must answer, else switch back
++ (exit 3).
++4. Otherwise: protected sessions (a refusal removes the prepared release), a backup on a schema change,
++ switch `current`, install the release's unit files (`unitSources` are paths inside the release), restart,
++ readiness. Not ready: switch back, reinstall the previous release's unit files, restart (exit 3, or 4).
++5. **The socket rule** (Live): pid 1 must hold the socket unit's listener (`ss`, the service port or
++ `release.socketPort`). Only when the socket unit file changed in this deploy, or systemd does not hold the
++ port, does ovhost stop the service, restart the socket and start the service on it
++ (`systemd.rebindSocket`, the one place ovhost ever restarts a socket unit, and only for those two
++ reasons). After readiness a listener that still is not systemd's is reported (`✗ socket activation is not
++ in effect`) and recorded (`socketHeld: false`).
++6. Prune to `release.keep`: never `current`, never the release just left, never one a worker unit instance
++ runs from (OpenRe's `openre-rtmp-ingest@<id>`).
++7. Record (`fromRelease`, `toRelease`), announce.
++
++`ovhost rollback <svc>` switches back to the release the release log says the current one replaced (or
++`--to <release id|sha>`, or the newest other release), with its own `node_modules`, restarting only when
++code or unit files differ. Rollbacks are never frozen.
++
++## Inventory fields
++
++New fields (all optional; `lib/inventory.js` validates them). A strategy's defaults apply first, the entry
++overrides them.
++
++| Field | Meaning | Default |
++|---|---|---|
++| `strategy` | `git-checkout`, `multi-app`, `static-build`, `pnpm-build` or `release-layout` | `git-checkout`; an entry with `layout: "release"` and no strategy stays unmanaged (deployed by its own script), as before |
++| `release.git` | the clone releases are made from (relative to `repo`, or absolute inside it) | `repo` |
++| `release.releases`, `release.current` | the releases directory and the link | `releases`, `current` |
++| `release.id` | `time-sha8` (`<UTC yyyymmdd-HHMMSS>-<sha8>`, Live; the old script used the host's local time, the same on a UTC host) or `sha12` (OpenRe; one release per commit, reused) | `time-sha8` |
++| `release.links` | `{ name: target }` links made in every release | `{}` |
++| `release.reuseModules` | hard-link the current `node_modules` when dependencies did not change | `true` |
++| `release.chown` | `user[:group]` for each new release (OpenRe) | none |
++| `release.keep` | releases kept (2–50) | 5 |
++| `release.settleSeconds` | wait after a switch without restart before the ready check | 3 |
++| `release.socketPort` | the port pid 1 must hold | the service `port` |
++| `preflight.syntaxCheck` | `node --check` changed `.js`, parse changed `.json` | false |
++| `preflight.dirs` | directories made in each package as `runAs` | `[]` |
++| `preflight.checks` | `[{ label, packages: "*" \| [dirs], argv, timeoutSeconds }]`, run as `runAs` in each package | `[]` |
++| `skipPackages` | package directories (or `prefix*`) that are not installed, verified or checked | multi-app: `["apps/_*"]` |
++| `removeUntrackedLockfiles` | remove an untracked lockfile the incoming release tracks | multi-app: true |
++| `generated` | tracked paths a build rewrites; restored from git before the merge, never a local change | static-build: `["dist/"]` |
++| `installUnits` | install drifted `unitSources` on every deploy (as `--install-units`) | false |
++| `unitsMatch` | also restart unit files matching this glob (named when the inventory lacks them) | none |
++| `announce.releaseFiles` | one notification per `<dir>/*/release.json` instead of one for the service | static-build: `dist/*/release.json` |
++| `install.lockfile` | the lockfile that decides an install | `package-lock.json` (pnpm-build: `pnpm-lock.yaml`) |
++| `install.workspace` | one install at the root for a workspace | pnpm-build: true |
++| `ready.release` | after a restart, `<ready origin>/release.json` must name the new sha | false |
++| `ready.allUnits` | every unit must be active, not only the one behind the ready URL | false |
++
++Strategy defaults: `static-build` also sets `install: npm ci, always`, `build: [["node", "build.js"]]` and
++`nginx: { repoVhosts: "deploy/nginx/*.conf", installOnDeploy: true }`; `pnpm-build` sets
++`packages: ["apps/*", "packages/*"]`, `install: pnpm install --frozen-lockfile, always` and
++`build: [["pnpm", "build"]]`; `release-layout` installs with `npm ci --omit=dev`.
++
++A release-layout entry's `repo` is the release root (`/opt/openvibe.live`); an entry that names the link
++itself (`/opt/openvibe.live/current`) is read the same way. `status`, `validate`, `snapshot`, `drill` and
++`announce` read the code from `current` (the new `codeDir`).
++
++## What each script did, and where it went
++
++| Rule (script) | Where it is now |
++|---|---|
++| Live: release built while the old one serves; `npm ci` only on a dependency change, else `cp -al` | release-layout step 2 |
++| Live: `node --check` changed JS, parse changed JSON | `preflight.syntaxCheck` |
++| Live: `public/`, `docs/` switch without a restart; the site must answer, else switch back (exit 3) | `noRestartPaths`, step 3 |
++| Live: `--wait-idle` (8 h, two idle checks), `/api/streams` | `protected`, `drain` (a probe that cannot answer counts as live; the script counted 0) |
++| Live: schema change → backup first | `backupOnChange` (to `/var/backups/openvibe/live/<stamp>/`, root-only, checked) |
++| Live: units from `deploy/systemd/release`, daemon-reload, enable | `unitSources` inside the release |
++| Live: `socket_held()`, `SOCKET_CHANGED` → stop, restart socket, start; warn after | the socket rule, step 5 |
++| Live: readiness 90 s, roll back (3), rollback fails (4) | `ready`, step 4 |
++| Live: `--rollback` to a release with its own `node_modules`; prune to 5 | `ovhost rollback live`; `release.keep` |
++| Live: `ovhost announce live` | the deploy announces |
++| Tools: untracked lockfiles removed before pulling | `removeUntrackedLockfiles` |
++| Tools: install per app when `package.json` changed or no `node_modules`; skip `apps/_*` | installs (lockfile or dependency fields), `skipPackages` |
++| Tools: every dependency resolves, reinstall once, else ABORT | dependency verification (all strategies) |
++| Tools: jobs runtime in img/audio/docs, guard in every app, `data/` made | `preflight.checks`, `preflight.dirs` |
++| Tools: restart every `openvibe-tools*` unit; gateway health; print each unit | `unitsMatch`, `ready` (Host header), `ready.release`, `ready.allUnits` |
++| Sites: pull BEFORE running | ovhost fetches and merges first by construction |
++| Sites: `npm ci`, `node build.js`, vhosts, `nginx -t` then reload, never reload after a failed test | static-build defaults; `nginx.install` restores the previous files |
++| Sites: one announce per placeholder, stop at the first failure | `announce.releaseFiles` |
++| OpenRe: `release` (worktree, `npm ci`, chown ubuntu), `api` (switch, restart API + coordinator, 60 s ready, roll back) | release-layout with `release.id: sha12`, `reuseModules: false`, `chown`; `--prepare-only` |
++| OpenRe: never touch a worker; `prune` keeps what runs | `workerUnits` are never restarted; pruning skips releases a worker instance runs from |
++| Network: pull, `npm install` on a lockfile change, unit file installed when it differs, restart, health | git-checkout with `installUnits` |
++| Games: `git pull`, `pnpm install --frozen-lockfile`, `pnpm build`, restart | pnpm-build (git as the owner, not `sudo git`) |
++
++Stricter than the scripts, on purpose: Tools and Network roll back automatically and check every dependency;
++Tools checks `/api/ready` (not `/api/health`) and the new sha; OpenRe refuses an API restart while an ingest
++session is open (the script did not check; `--wait-idle` holds, `--force` goes ahead; if the restart provably
++never cuts a session, set its `drain.policy` to `report`); `--wait-idle` now also holds a `report`-policy
++service (Tools jobs, Games players, Events SSE) instead of being ignored; every attempt is in the release
++log and a freeze refuses.
++
++## Not ported (and why)
++
++- **Live's chat notice** (`notify_chat`, `POST /api/admin/broadcast` with `ADMIN_TOKEN` from the
++ operator's environment): `sudo` does not pass `ADMIN_TOKEN`, so it said "chat notification skipped" in
++ practice, and Live posts its own deploy notice when it boots on new commits (`server/chat/deploy-notice.js`,
++ through Events).
++- **Live's in-place (legacy) layout branch** of `deploy.sh`: production runs the release layout since
++ 2026-09-24; an in-place Live would be `strategy: git-checkout`.
++- **Live `--force`** (discard local tracked changes): ovhost never discards changes (see above).
++- **Network's "restart anyway" when nothing is new**: only with `--restart` now.
++- **OpenRe `workers`, `status`, `prune`**: they stay in the repository's script. Starting a worker
++ generation is not a deploy ovhost makes (it never starts, stops or restarts a worker unit); ovhost prunes
++ on its own and keeps every release a worker runs from.
++- **Games `sudo git`**: ovhost runs git as the checkout owner. If earlier `sudo git pull`s left root-owned
++ objects in `.git`, fix the ownership once (checklist below).
++
++## Production cutover checklist (for the operator)
++
++Nothing here has been run. Each step is read-only until step 5.
++
++1. **Update ovhost.** `cd /usr/local/lib/openvibe-host && sudo git pull --ff-only && sudo npm ci --omit=dev
++ --no-audit --no-fund`, then `ovhost --version` (0.3.0) and `sudo ovhost capabilities` (`deploy-api=1`).
++ Until step 3 every wrapper still falls back (the inventory has no strategies).
++2. **Check the checkouts.**
++ - `sudo find /opt/openvibe.games/.git ! -user ubuntu | head`: if anything is listed,
++ `sudo chown -R ubuntu:ubuntu /opt/openvibe.games/.git` (earlier `sudo git` pulls).
++ - `sudo -u ubuntu bash -lc 'command -v pnpm && pnpm --version'`: ovhost runs pnpm as ubuntu with the
++ PATH it was started with; if pnpm lives elsewhere, set `install.command`/`build` to its absolute path.
++ - `ls -ld /opt/openvibe.live/repo /opt/openre.stream/repo` (root-owned clones, `owner: root`).
++ - `sudo ss -Hltnp 'sport = :3000'` shows `"systemd",pid=1` (else the first Live deploy rebinds the socket).
++3. **Edit the inventory.** `sudo cp /etc/openvibe/host.json /etc/openvibe/host.json.pre-ws-n-11`, apply the
++ [proposed diff](#proposed-production-inventory-diff), then `sudo ovhost show live openre tools sites games
++ network` and `sudo ovhost validate <svc>` for each (no new errors).
++4. **Compare each plan with the old script** (`sudo ovhost plan <svc>` only fetches):
++ - live: `sudo ovhost plan live` against `cd /opt/openvibe.live/current && sudo DRY_RUN=1 bash
++ deploy/scripts/deploy-legacy.sh` (the old script; `deploy.sh` before the wrapper is deployed): same target sha and change classes (restart yes/no),
++ `node_modules` hard-linked vs `npm ci` the same way, `socket held by systemd on :3000`,
++ `strategy release-layout (release layout, keep 5)`.
++ - openre: `sudo ovhost plan openre` against `sudo bash /opt/openre.stream/repo/deploy/scripts/deploy.sh
++ status`: current release = the `current` link, restart = `openre-api.service,
++ openre-session-coordinator.service` only, ingest sessions counted.
++ - tools: `sudo ovhost plan tools`: the installs match the apps whose `package.json`/lockfile changed
++ (`git -C /opt/openvibe.tools diff --name-only HEAD origin/main -- 'apps/*/package*.json'`), no
++ `apps/_shared` line, the restart lists every `systemctl list-unit-files 'openvibe-tools*'` unit (no
++ "not in the inventory" warning), the preflight lists the jobs and guard checks.
++ - sites: `sudo ovhost plan sites --restart`: build output restored, `npm ci`, `node build.js`, repo vhosts,
++ one notification per placeholder, no tracked changes.
++ - games: `sudo ovhost plan games`: tracked changes none (dist-types restored), `install (always)`, build,
++ preflight better-sqlite3, restart `openvibe-games.service`, players online counted (drain report).
++ - network: `sudo ovhost plan network`: nothing unexpected (Network already deploys with ovhost; the
++ entry only gains `installUnits`).
++5. **Pull the wrappers.** Tools, Sites, Network and Games get theirs with their next pull (the old Tools and
++ Network scripts pull themselves, so the deploy that brings the wrapper is still run by the old script).
++ Live's arrives in the release that contains it; OpenRe's with `git -C /opt/openre.stream/repo pull`.
++6. **Deploy through the wrapper**, one service at a time, lowest risk first: network, sites, games (quiet
++ hour or `--wait-idle`), tools, openre (no ingest session, or `--wait-idle`), live (`--wait-idle`). Each
++ wrapper prints `ovhost deploy <svc> …`; if it prints "running deploy-legacy.sh", read why.
++7. **Verify.** `sudo ovhost releases <svc>` (strategy, from/to, result), `sudo ovhost status`, and for Live
++ `sudo ss -Hltnp 'sport = :3000'` still shows `"systemd",pid=1`.
++8. **Undo** (any step): `OVHOST_LEGACY=1 sudo deploy/scripts/deploy.sh` runs the old script for one deploy;
++ restoring `/etc/openvibe/host.json.pre-ws-n-11` (or removing a `strategy`) makes the wrapper fall back
++ for good.
++
++## Proposed production inventory diff
++
++`/etc/openvibe/host.json` is not changed by this work. These are the fields each entry needs (merge them
++into the existing entries; `host.example.json` in this repository has the full entries). JSON has no
++comments: the `//` notes below are for reading only.
++
++```jsonc
++"live": {
++ "repo": "/opt/openvibe.live", // the release root (".../current" is read the same way)
++ "owner": "root", // git runs as root in the root-owned clone
++ "runAs": "ubuntu",
++ "strategy": "release-layout",
++ "release": { "git": "repo", "id": "time-sha8", "links": { "data": "../../shared/data" }, "keep": 5 },
++ // MUST be the release-layout units: the in-place ones (deploy/systemd/*.service) run from
++ // /opt/openvibe.live, and installing them would point Live at the stale checkout.
++ "unitSources": {
++ "openvibe-live.service": "deploy/systemd/release/openvibe-live.service",
++ "openvibe-live.socket": "deploy/systemd/release/openvibe-live.socket",
++ "openvibe-live.service.d/socket.conf": "deploy/systemd/release/openvibe-live.service.d/socket.conf"
++ },
++ "ready": { "url": "http://127.0.0.1:3000/api/ready", "timeoutSeconds": 90, "release": true },
++ "preflight": { "syntaxCheck": true }
++ // unchanged: units, socketUnit, port, noRestartPaths, protected, drain (refuse, 28800 s), databases,
++ // backupOnChange, nginx (repoVhost is never installed by a deploy; the plan names a change)
++},
++"openre": {
++ "repo": "/opt/openre.stream", // the release root; the clone is /opt/openre.stream/repo
++ "strategy": "release-layout", // replaces "layout": "release"
++ "owner": "root",
++ "runAs": "ubuntu",
++ "release": { "git": "repo", "id": "sha12", "reuseModules": false, "chown": "ubuntu:ubuntu", "keep": 5 },
++ "ready": { "url": "http://127.0.0.1:4500/api/ready", "timeoutSeconds": 60 }
++ // unchanged: units (API + coordinator), workerUnits, protected (ingest sessions), drain
++},
++"tools": {
++ "strategy": "multi-app", // skipPackages apps/_*, removeUntrackedLockfiles
++ "unitsMatch": "openvibe-tools*.service",
++ "ready": { "url": "http://127.0.0.1:4001/api/ready", "headers": { "Host": "openvibe.tools" },
++ "timeoutSeconds": 60, "release": true, "allUnits": true },
++ "preflight": {
++ "dirs": ["data"],
++ "checks": [
++ { "label": "jobs runtime loads", "packages": ["apps/img", "apps/audio", "apps/docs"],
++ "argv": ["node", "-e", "const D=require('better-sqlite3'); new D(':memory:').close(); require('openvibe-contracts'); require('openvibe-sdk'); require('../_shared/jobs')"] },
++ { "label": "guard loads", "packages": "*",
++ "argv": ["node", "-e", "const D=require('better-sqlite3'); new D(':memory:').close(); require('../_shared/guard')"] }
++ ]
++ }
++},
++"sites": {
++ "strategy": "static-build" // npm ci, node build.js, generated dist/, repo vhosts
++ // behind nginx -t, one announce per dist/*/release.json
++},
++"games": {
++ // remove "managed": false and "unmanagedReason"
++ "strategy": "pnpm-build",
++ "generated": ["apps/client/dist-types/", "apps/server/dist-types/"],
++ "preflight": { "checks": [ { "label": "better-sqlite3 loads under this Node", "packages": ["apps/server"],
++ "argv": ["node", "-e", "new (require('better-sqlite3'))(':memory:').close()"] } ] }
++ // unchanged: units, ready, protected (players online), drain (report), databases, drill
++},
++"network": {
++ "installUnits": true // the unit file is installed whenever it differs
++}
++```
ddiff --git a/docs/release-notifications.md b/docs/release-notifications.md
iindex d8de72c..8b99aa1 100644
---- a/docs/release-notifications.md
++++ b/docs/release-notifications.md
@@@ -25,13 +25,17 @@ visibility `internal`, its own payload). Browsers never receive that one.
- `ovhost deploy <service>` and `ovhost rollback <service>` announce after a deploy that went live
(result `deployed` or `rolled-back`, exit 0). `--no-announce` skips it.
--- Services deployed by their own scripts call `ovhost announce <service>` at the end:
-- - Live: `deploy/scripts/deploy.sh`.
-- - Tools: `deploy/scripts/deploy.sh`.
-- - Sites: `deploy/scripts/deploy.sh`, one call per placeholder with `--release` and `--origin`.
--
-- OpenRe and Games can do the same by hand or in their scripts. The scripts do nothing if `ovhost` is
-- missing or has no `announce`, and a failed announcement never fails the deploy.
++- A `static-build` service with `announce.releaseFiles` (Sites) sends one notification per
++ `dist/<domain>/release.json`, with that placeholder's service, release and `https://<domain>` origin,
++ and stops at the first failure.
++- Since WS-N task 11 each repository's `deploy/scripts/deploy.sh` is a wrapper around `ovhost deploy <svc>`
++ ([deploy-strategies.md](deploy-strategies.md)), so the deploy announces. Their fallback scripts
++ (`deploy/scripts/deploy-legacy.sh`) still call `ovhost announce <service>` at the end:
++ - Live, Tools, Games: `ovhost announce live|tools|games`.
++ - Sites: one call per placeholder with `--release` and `--origin`.
++
++ They do nothing if `ovhost` is missing or has no `announce`, and a failed announcement never fails the
++ deploy.
`ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] [--events-env <file>] [--json]`
ddiff --git a/host.example.json b/host.example.json
iindex 768f65c..5bc57dc 100644
---- a/host.example.json
++++ b/host.example.json
@@@ -31,6 +31,7 @@
"unitSources": {
"openvibe-network.service": "deploy/systemd/openvibe-network.service"
},
++ "installUnits": true,
"envFile": "/etc/openvibe/network.env",
"env": {
"required": [
@@@ -108,17 +109,30 @@
}
},
"live": {
-- "repo": "/opt/openvibe.live/current",
++ "repo": "/opt/openvibe.live",
"owner": "root",
"runAs": "ubuntu",
++ "strategy": "release-layout",
++ "release": {
++ "git": "repo",
++ "releases": "releases",
++ "current": "current",
++ "id": "time-sha8",
++ "links": {
++ "data": "../../shared/data"
++ },
++ "keep": 5,
++ "settleSeconds": 3
++ },
++ "_strategyNote": "Live deploy/scripts/deploy.sh (release layout) as ovhost deploy live: a new release is prepared in releases/<UTC time>-<sha8> while current serves (node_modules hard-linked from the current release unless package-lock.json or dependency fields changed, then npm ci into the new release; every dependency resolves; node --check on every changed .js, JSON.parse on every changed .json), data -> ../../shared/data in each release, current switched by an atomic rename, the release's deploy/systemd/release/* unit files installed. public/, docs/ and the other noRestartPaths switch without a restart. The socket unit must be held by pid 1 on 127.0.0.1:3000: when its unit file changed or systemd does not hold the port, the service is stopped, the socket restarted and the service started (the only socket restart ovhost makes). --rollback is ovhost rollback 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"
++ "openvibe-live.service": "deploy/systemd/release/openvibe-live.service",
++ "openvibe-live.socket": "deploy/systemd/release/openvibe-live.socket",
++ "openvibe-live.service.d/socket.conf": "deploy/systemd/release/openvibe-live.service.d/socket.conf"
},
"envFile": "/etc/openvibe/live.env",
"env": {
@@@ -136,7 +150,8 @@
"port": 3000,
"ready": {
"url": "http://127.0.0.1:3000/api/ready",
-- "timeoutSeconds": 90
++ "timeoutSeconds": 90,
++ "release": true
},
"noRestartPaths": [
"public/",
@@@ -179,6 +194,9 @@
"server/db/migrations.js",
"server/db/schema.sql"
],
++ "preflight": {
++ "syntaxCheck": true
++ },
"nginx": {
"vhost": "openvibe.live.conf",
"repoVhost": "deploy/nginx/openvibe.live.conf",
@@@ -387,6 +405,8 @@
},
"tools": {
"repo": "/opt/openvibe.tools",
++ "strategy": "multi-app",
++ "_strategyNote": "Tools deploy/scripts/deploy.sh as ovhost deploy tools: untracked apps/*/package-lock.json files the release tracks are removed before the merge; apps/_* are packages, not apps (skipPackages, the multi-app default); npm install where an app's lockfile or dependencies changed or node_modules is missing; every dependency resolves (reinstalled once); each app's data/ is made as ubuntu (the units' ReadWritePaths); the jobs runtime loads in img, audio and docs and the guard in every app; every openvibe-tools*.service unit file restarts (unitsMatch; one the inventory does not list is named); ready when the gateway answers /api/ready, its /release.json names the new sha and every unit is active; otherwise the checkout goes back and everything restarts again.",
"packages": [
"apps/*"
],
@@@ -400,6 +420,7 @@
"openvibe-tools-text.service",
"openvibe-tools-docs.service"
],
++ "unitsMatch": "openvibe-tools*.service",
"envFile": "/etc/openvibe/tools.env",
"env": {
"required": [
@@@ -447,11 +468,42 @@
"headers": {
"Host": "openvibe.tools"
},
-- "timeoutSeconds": 60
++ "timeoutSeconds": 60,
++ "release": true,
++ "allUnits": true
},
"noRestartPaths": [
"**/*.md"
],
++ "preflight": {
++ "dirs": [
++ "data"
++ ],
++ "checks": [
++ {
++ "label": "jobs runtime loads",
++ "packages": [
++ "apps/img",
++ "apps/audio",
++ "apps/docs"
++ ],
++ "argv": [
++ "node",
++ "-e",
++ "const D=require('better-sqlite3'); new D(':memory:').close(); require('openvibe-contracts'); require('openvibe-sdk'); require('../_shared/jobs')"
++ ]
++ },
++ {
++ "label": "guard loads",
++ "packages": "*",
++ "argv": [
++ "node",
++ "-e",
++ "const D=require('better-sqlite3'); new D(':memory:').close(); require('../_shared/guard')"
++ ]
++ }
++ ]
++ },
"databases": [
{
"name": "tools-audio-analytics",
@@@ -746,8 +798,12 @@
},
"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)",
++ "strategy": "pnpm-build",
++ "_strategyNote": "The production procedure (git pull; pnpm install --frozen-lockfile; pnpm build; systemctl restart openvibe-games) as ovhost deploy games, with git as the checkout owner (not sudo git), the tracked apps/*/dist-types/ a previous build rewrote restored from git before the merge (generated), every workspace package's dependencies verified, better-sqlite3 loaded under this Node before the restart, /api/ready polled, and on failure the checkout restored, reinstalled and rebuilt (the build writes apps/client/dist, which is served) and the unit restarted again. Players online are reported (drain report) and reconnect; --wait-idle holds until nobody plays.",
++ "generated": [
++ "apps/client/dist-types/",
++ "apps/server/dist-types/"
++ ],
"units": [
"openvibe-games.service"
],
@@@ -757,6 +813,21 @@
"url": "http://127.0.0.1:8000/api/ready",
"timeoutSeconds": 60
},
++ "preflight": {
++ "checks": [
++ {
++ "label": "better-sqlite3 loads under this Node",
++ "packages": [
++ "apps/server"
++ ],
++ "argv": [
++ "node",
++ "-e",
++ "new (require('better-sqlite3'))(':memory:').close()"
++ ]
++ }
++ ]
++ },
"databases": [
{
"name": "world",
@@@ -819,6 +890,11 @@
"sites": {
"metrics": false,
"repo": "/opt/openvibe.sites",
++ "strategy": "static-build",
++ "_strategyNote": "Sites deploy/scripts/deploy.sh as ovhost deploy sites: the pull always comes first (ovhost fetches and fast-forwards before building, so a stale checkout never installs old vhosts); the tracked dist/ a previous build rewrote (dates) is restored from git before the merge (generated); npm ci; node build.js; every deploy/nginx/*.conf installed and enabled, nginx -t, reload, and on a failed nginx -t the previous vhost files come back and nginx is not reloaded; then one release notification per dist/<domain>/release.json. A vhost that left the repository is named, never removed.",
++ "generated": [
++ "dist/"
++ ],
"units": [],
"install": {
"command": [
@@@ -839,6 +915,9 @@
"nginx": {
"repoVhosts": "deploy/nginx/*.conf",
"installOnDeploy": true
++ },
++ "announce": {
++ "releaseFiles": "dist/*/release.json"
}
},
"host": {
@@@ -1930,9 +2009,18 @@
}
},
"openre": {
-- "_note": "Release layout: the service runs from /opt/openre.stream/current and deploys with its own deploy/scripts/deploy.sh (release | api | workers). units are the API and the session coordinator. workerUnits are the transport workers: one instance per release (openre-rtmp-ingest@<release>), started by deploy.sh workers, drained by the coordinator when a newer generation is ready, and exiting on their own. ovhost lists them in status/validate and never starts, stops or restarts them (the inventory refuses a worker unit in units).",
++ "_note": "Release layout: /opt/openre.stream/repo is the git clone, releases/<sha12> the releases (npm ci into each, chowned to ubuntu), `current` the link the API and the coordinator run from (deploy.sh release + api as ovhost deploy openre; ovhost rollback openre). units are the API and the session coordinator. workerUnits are the transport workers: one instance per release (openre-rtmp-ingest@<release>), started by deploy.sh workers (still the repository's script), drained by the coordinator when a newer generation is ready, and exiting on their own. ovhost lists them in status/validate, never starts, stops or restarts them (the inventory refuses a worker unit in units), and never prunes a release a worker instance runs from.",
"repo": "/opt/openre.stream",
-- "layout": "release",
++ "strategy": "release-layout",
++ "owner": "root",
++ "runAs": "ubuntu",
++ "release": {
++ "git": "repo",
++ "id": "sha12",
++ "reuseModules": false,
++ "chown": "ubuntu:ubuntu",
++ "keep": 5
++ },
"units": [
"openre-api.service",
"openre-session-coordinator.service"
@@@ -1948,7 +2036,7 @@
"port": 4500,
"ready": {
"url": "http://127.0.0.1:4500/api/ready",
-- "timeoutSeconds": 30
++ "timeoutSeconds": 60
},
"noRestartPaths": [
"docs/",
ddiff --git a/lib/announce.js b/lib/announce.js
iindex 8055ac1..43282d7 100644
---- a/lib/announce.js
++++ b/lib/announce.js
@@@ -163,7 +163,7 @@ async function describeRelease(ctx, id, { release = null, commit = null, origin
let sha = head && /^[0-9a-f]{40}$/.test(head) ? head : null;
if (!sha && svc && (!rel || !cm)) {
try {
-- const h = await git(exec, svc).head();
++ const h = await git(exec, svc, { dir: svc.codeDir || svc.repo, safeDirectory: svc.layout === 'release' }).head();
if (/^[0-9a-f]{40}$/.test(h)) sha = h;
} catch (err) { notes.push(`the checkout's HEAD is unknown (${err.message.split('\n')[0].slice(0, 120)})`); }
}
ddiff --git a/lib/cli.js b/lib/cli.js
iindex ddf791b..dd5b73b 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -9,6 +9,10 @@ const { LockedError } = require('./lock');
const { SocketGuardError } = require('./systemd');
const { runBrowserCheck } = require('./browser-check-hook');
++const VERSION = require('../package.json').version;
++// What deploy wrappers probe (`ovhost capabilities <service>`): bump when the deploy contract changes.
++const DEPLOY_API = 1; // 1: strategies, capabilities, deploy --prepare-only
++
const USAGE = `ovhost — OpenVibe.Host operator plane (Stage A)
Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@@ -23,11 +27,20 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
workers, port, ready URL, protected probe (no values)
plan <service> [--to <sha>] [--no-fetch]
what a deploy would do (fetches the remote; changes nothing else)
++ by the service's strategy: git-checkout, multi-app, static-build,
++ pnpm-build or release-layout (docs/deploy-strategies.md)
deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
[--browser-check] then check the service's public site in headless Chrome
(scripts/browser-check.js; report only, never changes the exit code)
[--no-announce] a deploy that went live is announced (see announce) unless this is set
++ [--prepare-only] release-layout: make the new release (worktree, dependencies,
++ preflight) and stop; deploy --to <sha> later switches to it
rollback <service> [--to <sha>] [--wait-idle] [--force] [--no-announce]
++ release-layout: --to <release id|sha>; default the release the
++ current one replaced (release log), with its own node_modules
++ capabilities [<service>] key=value lines for deploy wrappers: ovhost version, strategies,
++ and whether <service> is managed and by which strategy
++ version | --version ovhost's version
announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run]
publish host.release.published to OpenVibe.Events (public), so open
tabs check /release.json now: for services deployed by their own
@@@ -92,7 +105,7 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
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 ·
++rolled back · 4 rollback failed (manual intervention) · 5 protected sessions active · 6 frozen ·
announce: 0 sent, already sent or dry run · 1 usage or not configured · 2 publishing failed ·
drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported) · 2 drill failed ·
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
@@@ -152,6 +165,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }
const { pos, opts } = parsed;
const cmd = pos[0];
++ if (opts.version || cmd === 'version') { out(`ovhost ${VERSION}`); return 0; }
if (!cmd || opts.help) { out(USAGE); return cmd || opts.help ? 0 : 1; }
const json = !!opts.json;
const log = json ? () => {} : (s) => out(`[ovhost] ${s}`);
@@@ -162,11 +176,25 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
// The DNS adapter runs on the operator's workstation (where the Cloudflare token is), which has no inventory.
let inv;
try { inv = await lib.inventory.load(exec, { file: opts.inventory, env }); } catch (err) {
-- if (cmd !== 'dns' || opts.inventory || !(err instanceof InventoryError)) throw err;
++ const noInventoryOk = cmd === 'dns' || (cmd === 'capabilities' && !pos[1]);
++ if (!noInventoryOk || opts.inventory || !(err instanceof InventoryError)) throw err;
inv = { dns: { dnsOnly: lib.dns.DEFAULT_DNS_ONLY }, services: {} };
}
const ctx = { exec, inv, log };
switch (cmd) {
++ case 'capabilities': {
++ // For the per-repository deploy wrappers (deploy/scripts/deploy.sh): they use ovhost only when
++ // it knows the strategy they need and the inventory manages the service with it.
++ const lines = [`ovhost=${VERSION}`, `deploy-api=${DEPLOY_API}`, `strategies=${lib.inventory.STRATEGIES.join(',')}`, 'commands=plan,deploy,rollback,announce,capabilities', 'deploy-flags=--wait-idle,--force,--restart,--to,--install-units,--ready-timeout,--prepare-only,--no-announce'];
++ const row = { ovhost: VERSION, deployApi: DEPLOY_API, strategies: lib.inventory.STRATEGIES };
++ if (pos[1]) {
++ const svc = lib.inventory.service(inv, pos[1]);
++ lines.push(`service=${svc.id}`, `strategy=${svc.strategy || 'none'}`, `managed=${svc.managed ? 'yes' : 'no'}`, `layout=${svc.layout}`);
++ Object.assign(row, { service: svc.id, strategy: svc.strategy, managed: svc.managed, layout: svc.layout, unmanagedReason: svc.unmanagedReason });
++ }
++ if (json) emit(row); else for (const l of lines) out(l);
++ return 0;
++ }
case 'validate': {
const r = await lib.validate(ctx, needService(), { manifestFile: opts.manifest });
if (json) emit(r);
@@@ -200,35 +228,58 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
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})` : ''}`);
++ out(` strategy ${p.strategy || 'none'}${p.layout === 'release' ? ` (release layout, keep ${p.keep})` : ''}`);
++ if (p.layout === 'release') out(` release ${p.fromRelease} → ${p.upToDate ? p.fromRelease : p.reuse ? `${p.toRelease} (exists; reused)` : `${p.toRelease} (new, prepared while ${p.fromRelease} serves)`}`);
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.layout !== 'release') out(` tracked changes ${p.dirty.length ? `${p.dirty.join(', ')} — deploy will refuse` : 'none'}${p.generatedDirty && p.generatedDirty.length ? ` (${p.generatedDirty.length} build output file(s) restored from git first)` : ''}`);
++ for (const f of p.untrackedLockfiles || []) out(` untracked ${f} is removed before the merge (the release tracks it)`);
++ 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'})` : i.reuse ? `dependencies unchanged — node_modules ${i.reuse === 'its own' ? 'kept (its own)' : `hard-linked from ${i.reuse}`}` : 'dependencies unchanged'}`);
++ if (p.build && p.build.length) out(` build ${p.build.join(' && ')}`);
++ if (p.preflight && p.preflight.length) out(` preflight ${p.preflight.join('; ')}`);
++ out(` restart ${p.restartNeeded ? `yes: ${p.units.join(', ')}${p.socketUnit ? ` (socket ${p.socketUnit} stays up)` : ''}` : p.layout === 'release' && !p.upToDate ? 'no — current switches, the process keeps running' : 'no'}`);
++ if (p.extraUnits && p.extraUnits.length) out(` units ${p.extraUnits.join(', ')} are not in the inventory but are restarted too (unitsMatch)`);
++ if (p.socket) out(` socket ${p.socket.held ? `held by systemd on :${p.socket.port}` : p.socket.held === false ? `NOT held by systemd on :${p.socket.port} — a restart rebinds it (stop, restart the socket, start)` : 'unknown'}${p.socket.changed ? '; the socket unit file changed — a restart rebinds it' : ''}`);
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.unitDrift.length) out(` unit files differ from the repo: ${p.unitDrift.map((u) => u.unit).join(', ')}${p.installUnits ? ' (installed by the deploy)' : ' (--install-units)'}`);
if (p.installVhosts) out(' nginx repo vhosts are installed (nginx -t, reload) on deploy');
++ for (const v of p.vhostsRemoved || []) out(` nginx ${v} left the repository; it stays installed (remove by hand)`);
++ if (p.nginxNotInstalled) out(' nginx the repo vhost changed and is NOT installed by a deploy (by hand: nginx -t, reload)');
++ if (p.announceFrom) out(` announce one release notification per ${p.announceFrom}`);
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 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, prepareOnly: cmd === 'deploy' && !!opts['prepare-only'] };
const r = cmd === 'deploy' ? await lib.deploy(ctx, id, o) : await lib.rollback(ctx, id, o);
// Release notification (lib/announce.js): best effort, never changes the exit code.
let announced = null;
if (r.exitCode === 0 && ['deployed', 'rolled-back'].includes(r.record.result) && !opts['no-announce']) {
-- try {
-- announced = await lib.announce(ctx, r.record.service, { head: r.record.to, rollback: cmd === 'rollback', envFile: opts['events-env'], env });
-- } catch (e) { announced = { service: r.record.service, published: false, error: e.message }; }
-- log(lib.announceSummary(announced));
++ if (r.announcements) {
++ // Sites: one notification per placeholder; the first failure stops the rest (they
++ // would fail the same way), and none of it changes the exit code.
++ announced = { service: r.record.service, each: [] };
++ for (const a of r.announcements) {
++ let x;
++ try { x = await lib.announce(ctx, a.service, { release: a.release, origin: a.origin, rollback: cmd === 'rollback', envFile: opts['events-env'], env }); } catch (e) { x = { service: a.service, published: false, error: e.message }; }
++ announced.each.push(x);
++ log(lib.announceSummary(x));
++ if (x.error || x.notConfigured) { if (!x.notConfigured) log('release notifications stopped at the first failure (the deploy stands)'); break; }
++ }
++ } else {
++ try {
++ announced = await lib.announce(ctx, r.record.service, { head: r.record.to, rollback: cmd === 'rollback', envFile: opts['events-env'], env });
++ } catch (e) { announced = { service: r.record.service, published: false, error: e.message }; }
++ log(lib.announceSummary(announced));
++ }
}
-- if (json) emit(announced ? { ...r.record, announce: announcedSummary(announced) } : r.record);
++ if (json) emit(announced ? { ...r.record, announce: announced.each ? { each: announced.each.map(announcedSummary) } : announcedSummary(announced) } : r.record);
else if (r.record.result !== 'unchanged') out(`[ovhost] result: ${r.record.result} (release ${r.record.id})`);
if (cmd === 'deploy' && opts['browser-check'] && !json && r.exitCode === 0 && r.record.result === 'deployed') {
log(`browser check of ${id}'s public site (report only)…`);
ddiff --git a/lib/commands/drill.js b/lib/commands/drill.js
iindex c4ac462..da1a294 100644
---- a/lib/commands/drill.js
++++ b/lib/commands/drill.js
@@@ -194,7 +194,7 @@ async function startSpec(exec, svc) {
if (envfile.SECRET_NAME_RE.test(e.name)) skippedEnvironment.push(e.name);
else environment.push(e);
}
-- return { unit, argv, cwd: directives.workingDirectory || svc.repo, unitUser: directives.user, environment, skippedEnvironment };
++ return { unit, argv, cwd: directives.workingDirectory || svc.codeDir, unitUser: directives.user, environment, skippedEnvironment };
}
/**
@@@ -204,7 +204,7 @@ async function startSpec(exec, svc) {
async function missingRequirements(exec, svc) {
const missing = [];
for (const r of svc.drill.requires) {
-- const text = await exec.readFile(path.join(svc.repo, r.file));
++ const text = await exec.readFile(path.join(svc.codeDir, r.file));
if (text == null || !text.includes(r.contains)) missing.push(`${r.contains} in ${r.file}${text == null ? ' (file not found)' : ''}`);
}
return missing;
@@@ -221,7 +221,7 @@ async function drill(ctx, id, { backup: backupArg = null, keep = false } = {}) {
const d = svc.drill;
if (d.compare.length && !d.productionPort) throw new DrillError(`${id} declares compare paths but no production port`);
const missing = await missingRequirements(exec, svc);
-- if (missing.length) throw new DrillError(`${id}: the deployed checkout (${svc.repo}) does not have what this drill's overrides rely on: ${missing.join('; ')}. Deploy a release with the drill switch first.`);
++ if (missing.length) throw new DrillError(`${id}: the deployed checkout (${svc.codeDir}) does not have what this drill's overrides rely on: ${missing.join('; ')}. Deploy a release with the drill switch first.`);
if (svc.envFile && !(await exec.stat(svc.envFile))) throw new DrillError(`${svc.envFile} not found`);
const busy = await exec.listeners(d.port);
if (busy.length) throw new DrillError(`port ${d.port} is already in use (${busy.map((l) => `${l.process || '?'} pid ${l.pid || '?'}`).join(', ')}); free it or change drill.port`);
ddiff --git a/lib/commands/snapshot.js b/lib/commands/snapshot.js
iindex f2d42aa..2220440 100644
---- a/lib/commands/snapshot.js
++++ b/lib/commands/snapshot.js
@@@ -36,7 +36,7 @@ async function snapshot(ctx, id, { out } = {}) {
const files = [];
const meta = { service: id, takenAt: new Date(exec.now()).toISOString(), host: await exec.hostname(), inventory: withoutHeaderValues(svc) };
-- const g = git(exec, svc);
++ const g = git(exec, svc, { dir: svc.codeDir, safeDirectory: svc.layout === 'release' });
try {
meta.sha = await g.head();
meta.branch = await g.branch();
@@@ -78,8 +78,8 @@ async function snapshot(ctx, id, { out } = {}) {
}
meta.lockfiles = {};
-- for (const pkg of await deps.expandPackages(exec, svc)) {
-- const lockText = await exec.readFile(path.join(svc.repo, pkg, 'package-lock.json'));
++ for (const pkg of await deps.expandPackages(exec, { ...svc, repo: svc.codeDir })) {
++ const lockText = await exec.readFile(path.join(svc.codeDir, pkg, svc.install.lockfile || 'package-lock.json'));
if (lockText != null) meta.lockfiles[pkg] = crypto.createHash('sha256').update(lockText).digest('hex');
}
ddiff --git a/lib/commands/status.js b/lib/commands/status.js
iindex c46ee59..fb17558 100644
---- a/lib/commands/status.js
++++ b/lib/commands/status.js
@@@ -26,7 +26,7 @@ async function statusOne(ctx, svc) {
}
try {
if (svc.layout === 'release') {
-- const target = await exec.readlink(`${svc.repo}/current`);
++ const target = await exec.readlink(svc.codeDir);
row.sha = target ? String(target).split('/').pop() : null;
row.branch = 'release';
} else {
ddiff --git a/lib/commands/validate.js b/lib/commands/validate.js
iindex 721283c..993f49d 100644
---- a/lib/commands/validate.js
++++ b/lib/commands/validate.js
@@@ -45,14 +45,19 @@ async function validate(ctx, id, opts = {}) {
else if (svc.layout === 'release') {
// Release layout: <repo>/current -> <repo>/releases/<id>, owned by root or the checkout owner
// (like every git checkout here), never by anyone else.
-- const cur = `${svc.repo}/current`;
++ const cur = svc.codeDir;
++ const releasesDir = svc.release ? svc.release.releasesDir : `${svc.repo}/releases`;
const target = await exec.readlink(cur);
const st = target ? await exec.stat(cur) : null;
-- const resolved = target ? require('path').resolve(svc.repo, target) : '';
++ const resolved = target ? require('path').resolve(path.dirname(cur), target) : '';
if (!target || !st || !st.isDir) add('error', 'checkout', `${cur} is not a symlink to a release directory`);
-- else if (!resolved.startsWith(`${svc.repo}/releases/`)) add('error', 'checkout', `${cur} points outside ${svc.repo}/releases (${target})`);
-- else if (![ 'root', svc.owner ].includes(st.owner)) add('error', 'checkout', `the current release is owned by ${st.owner}, expected root or ${svc.owner}`);
++ else if (!resolved.startsWith(`${releasesDir}/`)) add('error', 'checkout', `${cur} points outside ${releasesDir} (${target})`);
++ else if (![ 'root', svc.owner, svc.release && svc.release.chown && svc.release.chown.split(':')[0] ].includes(st.owner)) add('error', 'checkout', `the current release is owned by ${st.owner}, expected root or ${svc.owner}${svc.release && svc.release.chown ? ` or ${svc.release.chown.split(':')[0]} (release.chown)` : ''}`);
else add('ok', 'checkout', `release layout: current -> ${target}`);
++ if (svc.release) {
++ const gs = await exec.stat(svc.release.git);
++ add(gs && gs.isDir ? 'ok' : 'error', 'checkout', gs && gs.isDir ? `strategy release-layout: releases are made from ${svc.release.git}` : `${svc.release.git} (release.git) does not exist; ovhost deploy ${svc.id} needs it`);
++ }
} 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}`);
@@@ -65,7 +70,9 @@ async function validate(ctx, id, opts = {}) {
const release = link && path.resolve(path.dirname(svc.repo), link).startsWith(`${path.dirname(svc.repo)}/releases/`);
if (release) add('ok', 'checkout', `release ${link} (detached; deployed by the repository's own script)`);
else 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)}`);
++ const own = dirty.filter((f) => !require('../release-ops').matchesPath(f, svc.generated));
++ if (own.length < dirty.length) add('info', 'checkout', `${dirty.length - own.length} tracked build output file(s) differ from git (${svc.generated.join(', ')}); a deploy restores them first`);
++ add(own.length ? 'error' : 'ok', 'checkout', own.length ? `tracked local changes: ${own.slice(0, 8).join(', ')}` : `clean at ${head.slice(0, 12)}`);
} catch (err) {
add('error', 'checkout', err.message);
}
@@@ -88,7 +95,7 @@ async function validate(ctx, id, opts = {}) {
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 example = await exec.readFile(path.join(svc.codeDir, svc.env.example));
const exampleNames = example ? envfile.parseExample(example).declared : [];
let required = svc.env.required;
if (required === 'from-example') {
@@@ -130,10 +137,10 @@ async function validate(ctx, id, opts = {}) {
}
}
for (const [unit, src] of Object.entries(svc.unitSources)) {
-- const want = await exec.readFile(path.join(svc.repo, src));
++ const want = await exec.readFile(path.join(svc.codeDir, 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)`);
++ if (have !== want) add('warn', 'units', `${unit} differs from ${src} (${svc.strategy === 'release-layout' || svc.installUnits ? 'the next deploy that restarts installs it' : 'deploy --install-units installs it'})`);
}
// ── lifecycle ──
@@@ -165,7 +172,7 @@ async function validate(ctx, id, opts = {}) {
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 repoText = await exec.readFile(path.join(svc.codeDir, 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`);
}
@@@ -177,8 +184,9 @@ async function validate(ctx, id, opts = {}) {
// ── deps ──
if (repoStat && repoStat.isDir && svc.managed) {
-- const pkgDirs = await deps.expandPackages(exec, svc);
-- const problems = await deps.verify(exec, svc, pkgDirs);
++ const code = { ...svc, repo: svc.codeDir };
++ const pkgDirs = await deps.expandPackages(exec, code);
++ const problems = await deps.verify(exec, code, 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'})`);
}
@@@ -208,7 +216,7 @@ async function checkLifecycle({ exec }, svc, repoStat, add, opts) {
from = `systemd TimeoutStopUSec=${st.timeoutStop}`;
}
if (timeout == null && svc.unitSources[u]) {
-- const t = lifecycle.unitFileTimeout(await exec.readFile(path.join(svc.repo, svc.unitSources[u])));
++ const t = lifecycle.unitFileTimeout(await exec.readFile(path.join(svc.codeDir, svc.unitSources[u])));
if (t != null) { timeout = t; from = `TimeoutStopSec in ${svc.unitSources[u]}`; }
}
if (timeout == null) add('info', 'lifecycle', `${u}: stop timeout unknown (unit not loaded, no TimeoutStopSec in a unit source); deadlineSeconds ${sd.deadlineSeconds} not compared`);
@@@ -234,7 +242,7 @@ async function checkLifecycle({ exec }, svc, repoStat, add, opts) {
const c = lc.contracts;
if (c && !lifecycle.isNone(c) && typeof c.range === 'string' && repoStat && repoStat.isDir) {
-- const root = svc.layout === 'release' ? path.join(svc.repo, 'current') : svc.repo;
++ const root = svc.codeDir;
const pkgDirs = await deps.expandPackages(exec, { ...svc, repo: root });
let found = 0;
for (const d of pkgDirs) {
@@@ -254,9 +262,10 @@ async function checkLifecycle({ exec }, svc, repoStat, add, opts) {
/** 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) };
++ const file = path.join(svc.codeDir, svc.env.example);
++ const text = await ctx.exec.readFile(file);
++ if (text == null) return { service: id, file, found: false, declared: [], commented: [] };
++ return { service: id, file, found: true, ...envfile.parseExample(text) };
}
module.exports = { validate, envNames };
ddiff --git a/lib/deps.js b/lib/deps.js
iindex 3f1030d..344546a 100644
---- a/lib/deps.js
++++ b/lib/deps.js
@@@ -11,7 +11,15 @@
*/
const path = require('path');
--/** package dirs (relative) from patterns like ".", "apps/*" — only dirs that have a package.json. */
++/** Whether a package dir is excluded by skipPackages ("apps/_shared", or a prefix such as "apps/_*"). */
++function skipped(dir, patterns = []) {
++ return patterns.some((p) => (p.endsWith('*') ? dir.startsWith(p.slice(0, -1)) : dir === path.normalize(p)));
++}
++
++/**
++ * package dirs (relative) from patterns like ".", "apps/*" — only dirs that have a package.json, and
++ * none that skipPackages excludes (Tools: apps/_shared is a package the apps require, not an app).
++ */
async function expandPackages(exec, svc) {
const out = [];
for (const pattern of svc.packages) {
@@@ -20,9 +28,10 @@ async function expandPackages(exec, svc) {
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 (skipped(dir, svc.skipPackages)) continue;
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'))) {
++ } else if (!skipped(path.normalize(pattern), svc.skipPackages) && await exec.stat(path.join(svc.repo, pattern, 'package.json'))) {
out.push(path.normalize(pattern));
}
}
@@@ -37,8 +46,8 @@ function depFields(text) {
}
/** 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');
++async function needsInstall(g, from, to, pkgDir, lockfile = 'package-lock.json') {
++ const lock = path.join(pkgDir, lockfile);
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;
@@@ -55,7 +64,7 @@ async function install(exec, svc, g, pkgDir) {
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 lock = path.join(pkgDir, svc.install.lockfile || 'package-lock.json');
const dirty = await g.dirty();
if (dirty.includes(lock)) await g.checkoutFile(lock);
}
@@@ -92,11 +101,12 @@ async function verifyAndRepair(exec, svc, g, pkgDirs, log = () => {}) {
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);
++ // A workspace installs once, at its root (pnpm).
++ const at = svc.install.workspace ? '.' : pkgDir;
++ if (!repaired.includes(at)) { await install(exec, svc, g, at); repaired.push(at); }
}
problems = await verify(exec, svc, pkgDirs);
return { problems, repaired };
}
--module.exports = { expandPackages, needsInstall, install, verify, verifyAndRepair };
++module.exports = { expandPackages, needsInstall, install, verify, verifyAndRepair, skipped };
ddiff --git a/lib/git.js b/lib/git.js
iindex d96fe88..96f452a 100644
---- a/lib/git.js
++++ b/lib/git.js
@@@ -4,9 +4,12 @@
* an ubuntu-owned checkout either trips "dubious ownership" or leaves root-owned objects behind
* that break the next pull.
*/
--function git(exec, svc) {
++function git(exec, svc, { dir = svc.repo, safeDirectory = false } = {}) {
++ // A release directory may belong to the service user while git runs as the clone's owner (OpenRe
++ // chowns each release): -c safe.directory names that one directory, nothing wider.
++ const pre = ['-C', dir, ...(safeDirectory ? ['-c', `safe.directory=${dir}`] : [])];
const run = async (args, { allowFail = false } = {}) => {
-- const r = await exec.run('git', ['-C', svc.repo, ...args], { as: svc.owner });
++ const r = await exec.run('git', [...pre, ...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;
};
@@@ -24,6 +27,12 @@ function git(exec, svc) {
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]); },
++ /** Restore tracked files from HEAD (generated output a build rewrote). */
++ checkoutPaths: async (files) => { for (let i = 0; i < files.length; i += 200) await run(['checkout', '--', ...files.slice(i, i + 200)]); },
++ /** A detached worktree of <sha> at <dir> (a new release). */
++ worktreeAdd: async (wt, sha) => { await run(['worktree', 'add', '--detach', '--quiet', wt, sha]); },
++ worktreeRemove: async (wt) => (await run(['worktree', 'remove', '--force', wt], { allowFail: true })).code === 0,
++ worktreePrune: async () => { await run(['worktree', 'prune'], { allowFail: true }); },
remoteUrl: async () => redactUrl((await run(['config', '--get', `remote.${svc.remote}.url`], { allowFail: true })).stdout.trim()),
};
}
ddiff --git a/lib/inventory.js b/lib/inventory.js
iindex 6ba8773..07bd5e2 100644
---- a/lib/inventory.js
++++ b/lib/inventory.js
@@@ -39,24 +39,142 @@ function httpsOrigin(value, where) {
return u.origin;
}
++/**
++ * Deploy strategies (roadmap WS-N task 11; docs/deploy-strategies.md). A strategy is the engine plus
++ * the defaults the per-repository deploy script it replaces encoded; every default can be overridden
++ * in the service entry.
++ *
++ * git-checkout an in-place checkout (Network, Events, Media, …): the default
++ * multi-app an in-place checkout of several apps (Tools): per-app installs (apps/_* skipped),
++ * untracked lockfiles the release tracks are removed first, preflight loads
++ * static-build an in-place checkout with a build and repo vhosts and no process (Sites)
++ * pnpm-build an in-place pnpm workspace with a build (Games)
++ * release-layout releases/<id> behind a `current` symlink (Live, OpenRe): a new release is prepared
++ * while the old one serves, switching is an atomic rename, rollback switches back
++ */
++const STRATEGIES = ['git-checkout', 'multi-app', 'static-build', 'pnpm-build', 'release-layout'];
++const NPM_CI = ['npm', 'ci', '--omit=dev', '--no-audit', '--no-fund'];
++const PRESETS = {
++ 'git-checkout': {},
++ 'multi-app': { packages: ['apps/*'], skipPackages: ['apps/_*'], removeUntrackedLockfiles: true },
++ 'static-build': {
++ install: { command: NPM_CI, always: true },
++ build: [['node', 'build.js']],
++ generated: ['dist/'],
++ nginx: { repoVhosts: 'deploy/nginx/*.conf', installOnDeploy: true },
++ announce: { releaseFiles: 'dist/*/release.json' },
++ },
++ 'pnpm-build': {
++ packages: ['apps/*', 'packages/*'],
++ install: { command: ['pnpm', 'install', '--frozen-lockfile'], always: true, lockfile: 'pnpm-lock.yaml', restoreLockfile: false, workspace: true },
++ build: [['pnpm', 'build']],
++ },
++ 'release-layout': { install: { command: NPM_CI } },
++};
++const NESTED = ['install', 'nginx', 'announce', 'preflight', 'release'];
++
++function withPreset(s, strategy) {
++ const preset = PRESETS[strategy] || {};
++ const out = { ...preset, ...s };
++ for (const k of NESTED) {
++ if (preset[k] && s[k] && typeof s[k] === 'object' && !Array.isArray(s[k])) out[k] = { ...preset[k], ...s[k] };
++ }
++ return out;
++}
++
++const REL_ID_FORMATS = ['time-sha8', 'sha12'];
++
++/** The `release` block of a release-layout service: where the git clone, the releases and `current` are. */
++function normaliseRelease(raw, base, where) {
++ const r = raw || {};
++ const w = `${where}.release`;
++ const inside = (v, dflt, name) => {
++ const p = v == null ? path.join(base, dflt) : abs(path.isAbsolute(String(v)) ? v : path.join(base, String(v)), `${w}.${name}`);
++ return path.normalize(p);
++ };
++ const out = {
++ git: inside(r.git, 'repo', 'git'),
++ releasesDir: inside(r.releases, 'releases', 'releases'),
++ current: inside(r.current, 'current', 'current'),
++ id: r.id == null ? 'time-sha8' : r.id,
++ links: r.links || {},
++ reuseModules: r.reuseModules !== false,
++ chown: r.chown == null ? null : r.chown,
++ keep: r.keep == null ? 5 : Number(r.keep),
++ settleSeconds: r.settleSeconds == null ? 3 : Number(r.settleSeconds),
++ socketPort: r.socketPort == null ? null : Number(r.socketPort),
++ };
++ if (!REL_ID_FORMATS.includes(out.id)) throw new InventoryError(`${w}.id must be one of ${REL_ID_FORMATS.join(', ')}`);
++ if (!out.releasesDir.startsWith(`${base}/`) || !out.current.startsWith(`${base}/`)) throw new InventoryError(`${w}: releases and current must be inside ${base}`);
++ if (typeof out.links !== 'object' || Array.isArray(out.links)) throw new InventoryError(`${w}.links must map a name inside each release to a link target`);
++ for (const [name, target] of Object.entries(out.links)) {
++ if (!FILE_NAME_RE.test(name)) throw new InventoryError(`${w}.links: "${name}" must be a plain name`);
++ const resolved = typeof target === 'string' && target && !target.includes('\0') ? path.resolve(out.releasesDir, 'x', target) : '';
++ if (!resolved.startsWith(`${base}/`)) throw new InventoryError(`${w}.links.${name} must point inside ${base}`);
++ }
++ if (out.chown != null && !/^[a-z_][a-z0-9_-]*(:[a-z_][a-z0-9_-]*)?$/.test(out.chown)) throw new InventoryError(`${w}.chown must be user or user:group`);
++ if (!(Number.isInteger(out.keep) && out.keep >= 2 && out.keep <= 50)) throw new InventoryError(`${w}.keep must be a whole number from 2 to 50`);
++ if (!(out.settleSeconds >= 0 && out.settleSeconds <= 60)) throw new InventoryError(`${w}.settleSeconds must be 0 to 60`);
++ if (out.socketPort != null && !(Number.isInteger(out.socketPort) && out.socketPort > 0 && out.socketPort < 65536)) throw new InventoryError(`${w}.socketPort must be a TCP port`);
++ return out;
++}
++
++/** `preflight`: checks that must pass before anything restarts (every strategy). */
++function normalisePreflight(raw, where) {
++ const p = raw || {};
++ const w = `${where}.preflight`;
++ const out = { syntaxCheck: p.syntaxCheck === true, dirs: p.dirs || [], checks: [] };
++ if (!Array.isArray(out.dirs)) throw new InventoryError(`${w}.dirs must be a list of directories made in each package`);
++ for (const d of out.dirs) rel(d, `${w}.dirs`);
++ for (const [i, c] of (p.checks || []).entries()) {
++ const at = `${w}.checks[${i}]`;
++ if (!c || !Array.isArray(c.argv) || !c.argv.length || c.argv.some((a) => typeof a !== 'string')) throw new InventoryError(`${at}.argv must be an argv array`);
++ const pk = c.packages == null ? '*' : c.packages;
++ if (pk !== '*' && (!Array.isArray(pk) || !pk.length)) throw new InventoryError(`${at}.packages must be "*" or a list of package directories`);
++ if (Array.isArray(pk)) for (const d of pk) if (d !== '.') rel(d, `${at}.packages`);
++ out.checks.push({ label: String(c.label || c.argv.join(' ')).slice(0, 80), packages: pk, argv: c.argv, timeoutSeconds: Number(c.timeoutSeconds || 120) });
++ }
++ return out;
++}
++
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 strategy = raw.strategy == null ? (raw.layout === 'release' ? null : 'git-checkout') : raw.strategy;
++ if (strategy !== null && !STRATEGIES.includes(strategy)) throw new InventoryError(`${where}.strategy must be one of ${STRATEGIES.join(', ')}`);
++ if (strategy && strategy !== 'release-layout' && raw.layout === 'release') throw new InventoryError(`${where}: layout "release" needs strategy "release-layout"`);
++ const s = withPreset({ ...defaults, ...raw }, strategy);
++ const releaseLayout = strategy === 'release-layout' || s.layout === 'release';
++ let repo = abs(s.repo, `${where}.repo`);
++ // A release-layout entry may name the `current` link itself as its repo (what the service runs):
++ // the release root is then its parent.
++ if (releaseLayout && path.basename(repo) === 'current' && !(s.release && s.release.current)) repo = path.dirname(repo);
++ const release = strategy === 'release-layout' ? normaliseRelease(s.release, repo, where) : null;
const out = {
id,
manifest: s.manifest || id,
description: s.description || null,
-- repo: abs(s.repo, `${where}.repo`),
++ repo,
++ // Where the running code is: the checkout, or <repo>/current for the release layout.
++ codeDir: releaseLayout ? (release ? release.current : path.join(repo, 'current')) : repo,
owner: s.owner,
runAs: s.runAs || s.owner,
remote: s.remote || 'origin',
branch: s.branch || 'main',
-- // 'git' (a checkout ovhost can move) or 'release' (releases/<id> behind a `current` symlink,
-- // installed by the service's own deploy script: never managed here).
-- layout: s.layout === 'release' ? 'release' : 'git',
-- managed: s.layout === 'release' ? false : s.managed !== false,
-- unmanagedReason: s.unmanagedReason || (s.layout === 'release' ? 'release layout: deployed by its own deploy script' : null),
++ strategy,
++ release,
++ // 'git' (a checkout ovhost can move) or 'release' (releases/<id> behind a `current` symlink).
++ // A release-layout entry without strategy "release-layout" is deployed by its own script.
++ layout: releaseLayout ? 'release' : 'git',
++ managed: strategy === null ? false : s.managed !== false,
++ unmanagedReason: s.unmanagedReason || (strategy === null ? 'release layout without strategy "release-layout": deployed by its own deploy script' : null),
++ skipPackages: s.skipPackages || [],
++ removeUntrackedLockfiles: s.removeUntrackedLockfiles === true,
++ generated: s.generated || [],
++ preflight: normalisePreflight(s.preflight, where),
++ installUnits: s.installUnits === true,
++ unitsMatch: s.unitsMatch || null,
++ announce: { releaseFiles: (s.announce && s.announce.releaseFiles) || null },
units: s.units || [],
// Units that belong to the service but that ovhost never starts, stops or restarts: e.g.
// OpenRe's transport workers ([email protected], one instance per release), which
@@@ -74,6 +192,9 @@ function normaliseService(id, raw, defaults) {
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),
++ lockfile: (s.install && s.install.lockfile) || 'package-lock.json',
++ // One install at the checkout root for the whole workspace (pnpm), not one per package.
++ workspace: !!(s.install && s.install.workspace),
},
build: s.build || [],
noRestartPaths: s.noRestartPaths || [],
@@@ -117,9 +238,20 @@ function normaliseService(id, raw, defaults) {
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 };
++ // release: after a restart, <ready origin>/release.json must name the release just deployed.
++ // allUnits: every unit must be active, not only the one behind the ready URL.
++ out.ready = { timeoutSeconds: 90, headers: {}, release: false, allUnits: false, ...out.ready };
}
for (const p of out.packages) if (p !== '.') rel(p.replace(/\/\*$/, ''), `${where}.packages`);
++ for (const p of out.skipPackages) if (typeof p !== 'string' || !p || path.isAbsolute(p) || p.includes('..')) throw new InventoryError(`${where}.skipPackages must be package directories or prefixes such as apps/_*`);
++ for (const p of out.generated) rel(p, `${where}.generated`);
++ if (!FILE_NAME_RE.test(out.install.lockfile)) throw new InventoryError(`${where}.install.lockfile must be a file name`);
++ if (out.unitsMatch != null && !/^[A-Za-z0-9@._-]*\*[A-Za-z0-9@._*-]*\.service$/.test(out.unitsMatch)) throw new InventoryError(`${where}.unitsMatch must be a unit-file glob such as openvibe-tools*.service`);
++ if (out.announce.releaseFiles != null && !/^[A-Za-z0-9._-]+\/\*\/release\.json$/.test(out.announce.releaseFiles)) throw new InventoryError(`${where}.announce.releaseFiles must look like dist/*/release.json`);
++ if (out.strategy === 'release-layout') {
++ for (const src of Object.values(out.unitSources)) rel(src, `${where}.unitSources`);
++ if (out.generated.length) throw new InventoryError(`${where}.generated: a release-layout service builds each release in its own directory`);
++ }
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(', ')}`);
@@@ -378,4 +510,4 @@ function service(inv, id) {
return s;
}
--module.exports = { load, normalise, service, InventoryError };
++module.exports = { load, normalise, service, InventoryError, STRATEGIES };
ddiff --git a/lib/preflight.js b/lib/preflight.js
nnew file mode 100644
iindex 0000000..c67e72e
---- /dev/null
++++ b/lib/preflight.js
@@@ -0,0 +1,83 @@
++'use strict';
++/**
++ * Preflight: what must hold before anything restarts (every deploy strategy; inventory `preflight`).
++ * Each rule comes from a per-repository deploy script:
++ *
++ * syntaxCheck every changed .js file passes `node --check` and every changed .json file parses
++ * (Live's syntax_check), in the tree that is about to run
++ * dirs directories each package needs, made as the service user before the restart (Tools:
++ * apps/<app>/data, which every unit's ReadWritePaths names; systemd refuses to start a
++ * unit whose ReadWritePaths is missing)
++ * checks an argv run in each named package directory as the service user; non-zero stops the
++ * deploy (Tools: the jobs runtime loads in img/audio/docs and the guard in every app, so
++ * a native-module ABI mismatch shows up here, not in a crash loop; Games: better-sqlite3
++ * loads under this Node)
++ *
++ * run() never restarts or changes a unit. -> { syntaxChecked, dirsMade, checksRun, problems: [text] }
++ */
++const path = require('path');
++
++async function syntaxCheck(ctx, svc, root, changed) {
++ const problems = [];
++ let checked = 0;
++ for (const f of changed) {
++ const file = path.join(root, f);
++ if (f.endsWith('.js') || f.endsWith('.cjs')) {
++ const st = await ctx.exec.stat(file);
++ if (!st || !st.isFile) continue;
++ const r = await ctx.exec.run('node', ['--check', file], { as: svc.owner, cwd: root, timeoutMs: 60 * 1000 });
++ checked += 1;
++ if (r.code !== 0) problems.push(`syntax error in ${f}: ${(r.stderr || r.stdout || '').trim().split('\n').slice(0, 3).join(' / ')}`);
++ } else if (f.endsWith('.json')) {
++ const text = await ctx.exec.readFile(file);
++ if (text == null) continue;
++ try { JSON.parse(text); } catch (err) { problems.push(`invalid JSON in ${f}: ${err.message}`); }
++ }
++ }
++ return { checked, problems };
++}
++
++async function run(ctx, svc, { root = svc.repo, changed = [], pkgDirs = [] } = {}) {
++ const p = svc.preflight || { syntaxCheck: false, dirs: [], checks: [] };
++ const out = { syntaxChecked: 0, dirsMade: [], checksRun: 0, problems: [] };
++ if (p.syntaxCheck && changed.length) {
++ const s = await syntaxCheck(ctx, svc, root, changed);
++ out.syntaxChecked = s.checked;
++ out.problems.push(...s.problems);
++ if (!s.problems.length) ctx.log(`syntax-checked ${s.checked} changed JS file(s)`);
++ if (out.problems.length) return out;
++ }
++ for (const pkg of pkgDirs) {
++ for (const d of p.dirs) {
++ const dir = path.join(root, pkg, d);
++ if (await ctx.exec.stat(dir)) continue;
++ await ctx.exec.mkdir(dir, { owner: svc.runAs, mode: 0o755 });
++ out.dirsMade.push(path.join(pkg, d));
++ }
++ }
++ if (out.dirsMade.length) ctx.log(`made ${out.dirsMade.join(', ')} (as ${svc.runAs})`);
++ for (const c of p.checks) {
++ const dirs = c.packages === '*' ? pkgDirs : c.packages;
++ for (const pkg of dirs) {
++ const cwd = path.join(root, pkg);
++ if (!(await ctx.exec.stat(cwd))) { out.problems.push(`${c.label}: ${pkg} does not exist`); continue; }
++ const r = await ctx.exec.run(c.argv[0], c.argv.slice(1), { as: svc.runAs, cwd, timeoutMs: c.timeoutSeconds * 1000 });
++ out.checksRun += 1;
++ if (r.code !== 0) out.problems.push(`${c.label} failed in ${pkg}: ${(r.stderr || r.stdout || `exit ${r.code}`).trim().split('\n').slice(-3).join(' / ')}`);
++ }
++ }
++ if (p.checks.length && !out.problems.length) ctx.log(`preflight: ${out.checksRun} check(s) passed (${p.checks.map((c) => c.label).join('; ')})`);
++ return out;
++}
++
++/** What a plan says about the preflight, without running it. */
++function describe(svc) {
++ const p = svc.preflight || { syntaxCheck: false, dirs: [], checks: [] };
++ const parts = [];
++ if (p.syntaxCheck) parts.push('syntax check of changed .js/.json');
++ if (p.dirs.length) parts.push(`make ${p.dirs.join(', ')} in each package`);
++ for (const c of p.checks) parts.push(`${c.label} (${c.packages === '*' ? 'every package' : c.packages.join(', ')})`);
++ return parts;
++}
++
++module.exports = { run, describe, syntaxCheck };
ddiff --git a/lib/readiness.js b/lib/readiness.js
iindex fced37b..79c365d 100644
---- a/lib/readiness.js
++++ b/lib/readiness.js
@@@ -2,6 +2,13 @@
/**
* 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.
++ *
++ * Two stricter checks a service entry can ask for (ready.release, ready.allUnits):
++ * release <ready origin>/release.json must name the release just deployed (a prefix of its sha):
++ * the process that answers is the new code, not a survivor (Tools: "gateway healthy with
++ * the new sha").
++ * allUnits every unit must be active, not only the one behind the ready URL (Tools: eight units,
++ * one ready URL; a satellite crash-looping on a missing module reads "activating").
*/
const systemd = require('./systemd');
@@@ -12,24 +19,54 @@ async function probe(exec, svc) {
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 = () => {} } = {}) {
++/** Does <ready origin>/release.json name `sha`? -> { ok, release?, reason? } */
++async function releaseMatches(exec, svc, sha) {
++ let url;
++ try { url = new URL('/release.json', svc.ready.url).href; } catch { return { ok: false, reason: 'no URL for /release.json' }; }
++ const r = await exec.http(url, { timeoutMs: 5000, headers: svc.ready.headers || {} });
++ if (r.status !== 200) return { ok: false, reason: `/release.json answered ${r.status || r.error || 'nothing'}` };
++ let m;
++ try { m = JSON.parse(r.body); } catch { return { ok: false, reason: '/release.json is not JSON' }; }
++ const rel = String((m && m.release) || '').toLowerCase();
++ if (/^[0-9a-f]{7,40}$/.test(rel) && String(sha).startsWith(rel)) return { ok: true, release: rel };
++ return { ok: false, reason: `/release.json names ${rel || 'no release'}, not ${String(sha).slice(0, 12)}` };
++}
++
++async function waitReady(exec, svc, { timeoutSeconds, log = () => {}, expectSha = null, units = svc.units } = {}) {
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;
++ let reason = 'never probed';
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);
++ const last = await probe(exec, svc);
++ let states = null;
++ if (last.ok) {
++ reason = null;
++ if (svc.ready.allUnits) {
++ states = [];
++ for (const u of units) states.push(await systemd.show(exec, u));
++ const down = states.filter((st) => st.active !== 'active');
++ if (down.length) reason = `not active: ${down.map((st) => `${st.unit} (${st.active})`).join(', ')}`;
++ }
++ if (!reason && svc.ready.release && expectSha) {
++ const m = await releaseMatches(exec, svc, expectSha);
++ if (!m.ok) reason = m.reason;
++ }
++ if (!reason) return { ok: true, seconds: Math.round((exec.now() - start) / 1000) };
++ } else reason = last.status ? `HTTP ${last.status}` : last.error || 'unreachable';
++ if (!states) {
++ states = [];
++ for (const u of units) states.push(await systemd.show(exec, u));
++ }
++ for (const st of states) {
if (st.active === 'failed') {
-- log(`${u} is in state failed`);
-- return { ok: false, reason: `${u} failed`, seconds: Math.round((exec.now() - start) / 1000) };
++ log(`${st.unit} is in state failed`);
++ return { ok: false, reason: `${st.unit} 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) };
++ return { ok: false, reason, seconds: Math.round((exec.now() - start) / 1000) };
}
--module.exports = { probe, waitReady };
++module.exports = { probe, waitReady, releaseMatches };
ddiff --git a/lib/release-layout.js b/lib/release-layout.js
nnew file mode 100644
iindex 0000000..3aeda41
---- /dev/null
++++ b/lib/release-layout.js
@@@ -0,0 +1,565 @@
++'use strict';
++/**
++ * The release-layout strategy (roadmap WS-N task 11): Live's deploy/scripts/deploy.sh (release layout)
++ * and OpenRe.Stream's deploy.sh `release` + `api`, as one engine.
++ *
++ * <repo>/repo the git clone releases are made from (release.git), owned by `owner`
++ * <repo>/releases/<id> a detached worktree per release with its own node_modules
++ * (id: <UTC yyyymmdd-HHMMSS>-<sha8> for Live, <sha12> for OpenRe)
++ * <repo>/current -> releases/<id>, switched by an atomic rename (ln -s + mv -T)
++ * release.links links made in every release (Live: data -> ../../shared/data)
++ *
++ * deploy
++ * 1. fetch; the target is origin/<branch> (or --to); nothing new and no --restart: done.
++ * 2. prepare the new release while the current one serves: worktree, links, `npm ci` when the
++ * lockfile or dependency fields changed (else the current node_modules hard-linked: cp -al), every
++ * dependency resolves, preflight (Live: syntax check of the changed files), release.chown (OpenRe).
++ * Any failure removes the new release: the current one is untouched and nothing restarted (exit 2).
++ * 3. static-only change (everything outside noRestartPaths unchanged, unit files unchanged): switch
++ * `current`, wait release.settleSeconds, the ready URL must answer, else switch back (exit 3).
++ * 4. otherwise: protected sessions (refuse, --wait-idle, --force; drain policy), a backup when a
++ * backupOnChange file changed, switch `current`, install the release's unit files (unitSources are
++ * paths inside the release), restart the units, wait for readiness (and /release.json naming the
++ * new sha when ready.release). Not ready: switch back, put the previous release's unit files back,
++ * restart, exit 3; if that is not ready either, exit 4 (MANUAL INTERVENTION).
++ * 5. prune to release.keep, never the current release, the one just left, or one a worker unit
++ * instance (OpenRe's openre-rtmp-ingest@<id>) still runs from.
++ *
++ * The socket rule (Live): pid 1 must hold the socket unit's listener. When the socket unit file changed
++ * in this deploy, or systemd does not hold the listener, the service is stopped, the socket restarted
++ * and the service started on it (systemd.rebindSocket: the only socket restart ovhost makes). Otherwise
++ * only the service restarts and new connections queue on the socket. A listener still not systemd's
++ * after readiness is reported loudly and recorded (socketHeld: false).
++ *
++ * --prepare-only (OpenRe's `deploy.sh release`): step 2 and stop; a later `deploy --to <sha>` of the
++ * same commit switches to it (sha12 ids name one release per commit, so it is reused).
++ *
++ * rollback: back to the release the release log says the current one replaced (or --to <id|sha>, or
++ * the newest other release), with its own node_modules; a restart only when code or units differ.
++ * Rollbacks are never frozen.
++ */
++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 preflight = require('./preflight');
++const ops = require('./release-ops');
++
++const { OpError, EXIT } = ops;
++
++const pad = (n) => String(n).padStart(2, '0');
++
++function newReleaseId(now, sha, format) {
++ if (format === 'sha12') return sha.slice(0, 12);
++ const d = new Date(now);
++ return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}-${sha.slice(0, 8)}`;
++}
++
++const gitClone = (exec, svc) => git(exec, svc, { dir: svc.release.git });
++const gitRelease = (exec, svc, dir) => git(exec, svc, { dir, safeDirectory: true });
++
++/** The sha a release directory holds: its worktree HEAD, else the sha its id names. */
++async function releaseSha(ctx, svc, dir) {
++ try {
++ const h = await gitRelease(ctx.exec, svc, dir).head();
++ if (/^[0-9a-f]{40}$/.test(h)) return h;
++ } catch { /* fall back to the id */ }
++ const m = /([0-9a-f]{8,40})$/.exec(path.basename(dir));
++ if (!m) return null;
++ try { return await gitClone(ctx.exec, svc).revParse(m[1]); } catch { return null; }
++}
++
++/** Where `current` points now. -> { link, dir, id, sha } (dir null when current is not a release) */
++async function currentRelease(ctx, svc) {
++ const link = svc.release.current;
++ const target = await ctx.exec.readlink(link);
++ if (!target) return { link, dir: null, id: null, sha: null };
++ const dir = path.resolve(path.dirname(link), target);
++ const inside = dir.startsWith(`${svc.release.releasesDir}/`) && path.dirname(dir) === svc.release.releasesDir;
++ const st = await ctx.exec.stat(dir);
++ if (!inside || !st || !st.isDir) return { link, dir: null, id: null, sha: null, target };
++ return { link, dir, id: path.basename(dir), sha: await releaseSha(ctx, svc, dir) };
++}
++
++/** Every release directory, newest first (time ids sort by name; others by mtime, then name). */
++async function listReleases(ctx, svc) {
++ const entries = ((await ctx.exec.readdir(svc.release.releasesDir)) || []).filter((e) => e.isDir && !e.name.startsWith('.'));
++ const out = [];
++ for (const e of entries) {
++ const dir = path.join(svc.release.releasesDir, e.name);
++ const st = await ctx.exec.stat(dir);
++ out.push({ id: e.name, dir, mtime: (st && st.mtime) || 0 });
++ }
++ const timeIds = svc.release.id === 'time-sha8';
++ out.sort((a, b) => (timeIds ? b.id.localeCompare(a.id) : (b.mtime - a.mtime) || b.id.localeCompare(a.id)));
++ return out;
++}
++
++/** Release ids a worker unit instance still runs from (openre-rtmp-ingest@<id>.service). */
++async function releasesInUse(ctx, svc) {
++ const used = new Set();
++ for (const w of svc.workerUnits) {
++ if (!w.endsWith('@.service')) continue;
++ let list = [];
++ try { list = await systemd.instances(ctx.exec, w); } catch { continue; }
++ for (const i of list) {
++ if (['inactive', 'dead'].includes(i.active) && i.sub === 'dead') continue;
++ const m = /@([^@]+)\.service$/.exec(i.unit);
++ if (m) used.add(m[1]);
++ }
++ }
++ return used;
++}
++
++/** The release a rollback returns to. -> { id, dir, sha } */
++async function rollbackTarget(ctx, svc, cur, to) {
++ const list = await listReleases(ctx, svc);
++ if (to != null) {
++ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(String(to))) throw new OpError(`"${to}" is not a release id or sha`, EXIT.USAGE);
++ let hit = list.find((r) => r.id === to);
++ if (!hit && /^[0-9a-f]{7,40}$/.test(to)) {
++ for (const r of list) {
++ const sha = await releaseSha(ctx, svc, r.dir);
++ if (sha && sha.startsWith(to)) { hit = { ...r, sha }; break; }
++ }
++ }
++ if (!hit) throw new OpError(`no release ${to} in ${svc.release.releasesDir}`, EXIT.USAGE);
++ return { ...hit, sha: hit.sha || await releaseSha(ctx, svc, hit.dir) };
++ }
++ const records = await releases.list(ctx.exec, ctx.inv, svc.id);
++ for (let i = records.length - 1; i >= 0; i--) {
++ const r = records[i];
++ if (r.result === 'deployed' && r.toRelease === cur.id && r.fromRelease && r.fromRelease !== cur.id) {
++ const hit = list.find((x) => x.id === r.fromRelease);
++ if (hit) {
++ ctx.log(`rolling back to ${hit.id} (the release ${cur.id} replaced, from the release log)`);
++ return { ...hit, sha: await releaseSha(ctx, svc, hit.dir) };
++ }
++ }
++ }
++ const other = list.find((r) => r.id !== cur.id);
++ if (!other) throw new OpError(`no previous release to roll back to in ${svc.release.releasesDir}`, EXIT.USAGE);
++ ctx.log(`rolling back to ${other.id} (the newest other release; the release log names none)`);
++ return { ...other, sha: await releaseSha(ctx, svc, other.dir) };
++}
++
++// ── plan ─────────────────────────────────────────────────────────────────────
++
++async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true, restart = false, rollbackTo = null } = {}) {
++ const { exec } = ctx;
++ const rel = svc.release;
++ if (mode === 'deploy' && 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 cur = await currentRelease(ctx, svc);
++ if (!cur.dir) throw new OpError(`${rel.current} is not a link to a release in ${rel.releasesDir}${cur.target ? ` (it points at ${cur.target})` : ''}; set up the release layout first (Live: deploy/scripts/migrate-to-releases.sh)`, EXIT.USAGE);
++ if (!cur.sha) throw new OpError(`cannot tell which commit ${cur.dir} holds`, EXIT.USAGE);
++ if (!(await exec.stat(rel.git))) throw new OpError(`${rel.git} (release.git) does not exist`, EXIT.USAGE);
++ const g = gitClone(exec, svc);
++ let target;
++ let targetDir = null;
++ let targetId;
++ let reuse = false;
++ if (mode === 'deploy') {
++ if (fetch) await g.fetch();
++ target = await g.revParse(to || `${svc.remote}/${svc.branch}`);
++ targetId = newReleaseId(exec.now(), target, rel.id);
++ const existing = path.join(rel.releasesDir, targetId);
++ if (rel.id === 'sha12' && targetId !== cur.id && await exec.stat(existing)) { reuse = true; targetDir = existing; } else targetDir = existing;
++ } else {
++ const t = rollbackTo || await rollbackTarget(ctx, svc, cur, to);
++ target = t.sha;
++ targetDir = t.dir;
++ targetId = t.id;
++ if (!target) throw new OpError(`cannot tell which commit ${t.dir} holds`, EXIT.USAGE);
++ }
++ const upToDate = mode === 'deploy' ? target === cur.sha : targetId === cur.id;
++ const changed = target === cur.sha ? [] : await g.changedFiles(cur.sha, target);
++ const commits = target === cur.sha ? [] : mode === 'deploy' ? await g.log(cur.sha, target) : await g.log(target, cur.sha);
++ const codeChanged = changed.filter((f) => !ops.matchesPath(f, svc.noRestartPaths));
++
++ const unitDrift = [];
++ let socketChanged = false;
++ 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 });
++ if (unit === svc.socketUnit && have != null) socketChanged = true;
++ }
++ }
++ const restartNeeded = svc.units.length > 0 && (restart || codeChanged.length > 0 || unitDrift.length > 0);
++
++ const pkgDirs = await deps.expandPackages(exec, { ...svc, repo: cur.dir });
++ if (!pkgDirs.length) pkgDirs.push('.');
++ const installs = [];
++ for (const pkg of pkgDirs) {
++ if (mode === 'rollback' || reuse) { installs.push({ pkg, install: false, reuse: 'its own', lockfileChanged: false, depsChanged: false }); continue; }
++ const need = target === cur.sha ? { lockfileChanged: false, depsChanged: false, needed: false } : await deps.needsInstall(g, cur.sha, target, pkg, svc.install.lockfile);
++ const curModules = !!(await exec.stat(path.join(cur.dir, pkg, 'node_modules')));
++ const install = need.needed || !rel.reuseModules || !curModules;
++ installs.push({ pkg, ...need, install, reuse: install ? null : cur.id, missingNodeModules: !curModules });
++ }
++ const backupNeeded = svc.databases.length > 0 && changed.some((f) => svc.backupOnChange.includes(f));
++ const sessions = restartNeeded ? await probes.countProtected(exec, svc) : null;
++ let socket = null;
++ if (svc.socketUnit) {
++ const port = rel.socketPort || svc.port;
++ const held = port ? await systemd.socketHeld(exec, port) : { held: null, holders: [] };
++ socket = { unit: svc.socketUnit, port, held: held.held, holders: held.holders, changed: socketChanged };
++ }
++ return {
++ service: svc.id,
++ strategy: svc.strategy,
++ layout: 'release',
++ mode,
++ repo: svc.repo,
++ branch: mode === 'deploy' ? svc.branch : 'release',
++ expectedBranch: mode === 'deploy' ? svc.branch : 'release',
++ from: cur.sha,
++ to: target,
++ fromRelease: cur.id,
++ toRelease: targetId,
++ currentDir: cur.dir,
++ targetDir,
++ reuse,
++ upToDate,
++ dirty: [],
++ generatedDirty: [],
++ commits,
++ changedFiles: changed.length,
++ changed,
++ codeChanged: codeChanged.length,
++ lockfileChanged: installs.some((i) => i.lockfileChanged),
++ installs,
++ untrackedLockfiles: [],
++ build: [],
++ preflight: preflight.describe(svc),
++ restartNeeded,
++ units: svc.units,
++ extraUnits: [],
++ socketUnit: svc.socketUnit,
++ socket,
++ unitDrift,
++ installUnits: true,
++ backupNeeded,
++ protectedSessions: sessions,
++ drainPolicy: svc.drain.policy,
++ managed: svc.managed,
++ installVhosts: false,
++ vhostsRemoved: [],
++ nginxNotInstalled: !!(svc.nginx && svc.nginx.repoVhost && changed.includes(svc.nginx.repoVhost)),
++ keep: rel.keep,
++ };
++}
++
++// ── steps ────────────────────────────────────────────────────────────────────
++
++/** Point `current` at dir: a new link beside the old one, renamed over it (atomic). */
++async function switchCurrent(ctx, svc, dir) {
++ const link = svc.release.current;
++ const tmp = path.join(path.dirname(link), `.${path.basename(link)}.tmp`);
++ await ctx.exec.removeFile(tmp, { privileged: true });
++ await ctx.exec.symlink(dir, tmp, { privileged: true });
++ const r = await ctx.exec.run('mv', ['-T', '-f', tmp, link], { privileged: true });
++ if (r.code !== 0) throw new Error(`could not switch ${link} to ${dir}: ${(r.stderr || '').trim()}`);
++ ctx.log(`current -> ${path.basename(dir)}`);
++}
++
++/** Install the unit files a release carries (unitSources are paths inside the release). */
++async function installReleaseUnits(ctx, svc, dir) {
++ const changed = [];
++ let socketChanged = false;
++ for (const [unit, src] of Object.entries(svc.unitSources)) {
++ const file = path.join(dir, src);
++ const want = await ctx.exec.readFile(file);
++ 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;
++ const r = await ctx.exec.run('install', ['-m', '0644', '-D', file, dest], { privileged: true });
++ if (r.code !== 0) throw new Error(`could not install ${dest}: ${(r.stderr || '').trim()}`);
++ if (unit === svc.socketUnit && have != null) socketChanged = true;
++ changed.push(unit);
++ }
++ if (changed.length) {
++ await systemd.daemonReload(ctx.exec);
++ if (svc.socketUnit) await systemd.enable(ctx.exec, svc.socketUnit);
++ for (const u of svc.units) await systemd.enable(ctx.exec, u);
++ ctx.log(`installed unit files from ${path.basename(dir)}: ${changed.join(', ')}; daemon-reload`);
++ }
++ return { changed, socketChanged };
++}
++
++/** Restart the units (rebinding the socket only when it must) and wait for readiness. */
++async function restartRelease(ctx, svc, { socketChanged = false, expectSha = null, readyTimeout } = {}) {
++ const { exec, log } = ctx;
++ let rebind = null;
++ const port = svc.release.socketPort || svc.port;
++ if (svc.socketUnit) {
++ const s = await systemd.ensureSocketActive(exec, svc.socketUnit);
++ if (s.started) log(`${svc.socketUnit} was ${s.state.active}; started it`);
++ const held = port ? await systemd.socketHeld(exec, port) : { held: true, holders: [] };
++ if (socketChanged) rebind = 'socket-changed';
++ else if (held.held === false) rebind = 'socket-not-held';
++ }
++ let units = svc.units;
++ if (rebind) {
++ log(`${svc.socketUnit} ${rebind === 'socket-changed' ? 'changed' : 'is not holding its listener (pid 1 does not hold :' + port + ')'} — stopping ${svc.units[0]}, restarting the socket, starting ${svc.units[0]} on it (HTTP is refused for that moment, once)`);
++ await systemd.rebindSocket(exec, svc.units[0], svc.socketUnit, rebind);
++ units = svc.units.slice(1);
++ } else if (svc.socketUnit) log(`${svc.socketUnit} holds the listener — new HTTP connections queue through the restart`);
++ for (const u of units) {
++ log(`restarting ${u}`);
++ await systemd.restart(exec, u);
++ }
++ const r = await readiness.waitReady(exec, svc, { timeoutSeconds: readyTimeout, log, expectSha });
++ if (r.ok) log(r.skipped ? 'no ready URL declared; restart issued' : `ready after ${r.seconds}s`);
++ else log(`NOT READY after ${r.seconds}s (${r.reason}); see: journalctl -u ${svc.units[0]} -n 50`);
++ let socketHeld = null;
++ if (r.ok && svc.socketUnit && port) {
++ socketHeld = (await systemd.socketHeld(exec, port)).held;
++ if (!socketHeld) log(`✗ socket activation is not in effect: systemd does not hold :${port}, so restarts refuse connections (journalctl -u ${svc.socketUnit})`);
++ }
++ return { ...r, rebind, socketHeld };
++}
++
++/** Remove releases beyond keep: never current, the one just left, or one a worker still runs from. */
++async function prune(ctx, svc, { keepIds = [] } = {}) {
++ const list = await listReleases(ctx, svc);
++ const cur = await currentRelease(ctx, svc);
++ const inUse = await releasesInUse(ctx, svc);
++ const g = gitClone(ctx.exec, svc);
++ const removed = [];
++ for (const r of list.slice(svc.release.keep)) {
++ if (r.id === cur.id || keepIds.includes(r.id) || inUse.has(r.id)) continue;
++ ctx.log(`pruning ${r.id}`);
++ if (!(await g.worktreeRemove(r.dir))) await ctx.exec.run('rm', ['-rf', '--', r.dir], { privileged: true });
++ removed.push(r.id);
++ }
++ await g.worktreePrune();
++ return removed;
++}
++
++/** Make the new release: worktree, links, node_modules, dependencies, preflight, owner. */
++async function prepare(ctx, svc, plan, record) {
++ const { exec, log } = ctx;
++ const g = gitClone(exec, svc);
++ const dir = plan.targetDir;
++ const rsvc = { ...svc, repo: dir, install: { ...svc.install, restoreLockfile: false } };
++ if (plan.reuse) {
++ // sha12 ids: a release prepared earlier (--prepare-only) is used as it is, checked again.
++ log(`release ${plan.toRelease} already exists; checking it again`);
++ } else {
++ log(`preparing release ${plan.toRelease} while ${plan.fromRelease} keeps serving…`);
++ await exec.mkdir(svc.release.releasesDir, { mode: 0o755 });
++ await g.worktreeAdd(dir, plan.to);
++ record.prepared = plan.toRelease;
++ for (const [name, target] of Object.entries(svc.release.links)) {
++ const at = path.join(dir, name);
++ if (await exec.readlink(at)) continue;
++ if (await exec.stat(at)) await exec.run('rm', ['-rf', '--', at], { privileged: true });
++ await exec.symlink(target, at, { privileged: true });
++ }
++ for (const i of plan.installs) {
++ if (i.install) {
++ log(`installing dependencies in the new release${i.pkg === '.' ? '' : ` (${i.pkg})`} (${[i.lockfileChanged && 'lockfile changed', i.depsChanged && 'package.json dependencies changed', i.missingNodeModules && `no node_modules in ${plan.fromRelease}`, !svc.release.reuseModules && 'always'].filter(Boolean).join(', ') || 'install'}) — nothing interrupted yet`);
++ await deps.install(exec, rsvc, gitRelease(exec, svc, dir), i.pkg);
++ record.installed.push(i.pkg);
++ } else {
++ log(`dependencies unchanged — hard-linking node_modules from ${plan.fromRelease}${i.pkg === '.' ? '' : ` (${i.pkg})`}`);
++ const r = await exec.run('cp', ['-al', path.join(plan.currentDir, i.pkg, 'node_modules'), path.join(dir, i.pkg, 'node_modules')], { as: svc.owner });
++ if (r.code !== 0) throw new Error(`cp -al node_modules failed: ${(r.stderr || '').trim()}`);
++ }
++ }
++ }
++ const pkgDirs = await deps.expandPackages(exec, rsvc);
++ const v = await deps.verifyAndRepair(exec, rsvc, gitRelease(exec, svc, dir), pkgDirs, log);
++ 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 (${pkgDirs.join(', ') || 'no packages'})`);
++ const pf = await preflight.run(ctx, rsvc, { root: dir, changed: plan.changed, pkgDirs });
++ if (pf.problems.length) {
++ record.preflight = { ok: false, problems: pf.problems };
++ throw new OpError(`preflight failed: ${pf.problems.join('; ')}`, EXIT.VALIDATION);
++ }
++ if (svc.preflight.checks.length || svc.preflight.syntaxCheck) record.preflight = { ok: true, checks: pf.checksRun, syntaxChecked: pf.syntaxChecked };
++ if (svc.release.chown) {
++ const r = await exec.run('chown', ['-R', svc.release.chown, dir], { privileged: true });
++ if (r.code !== 0) throw new Error(`chown -R ${svc.release.chown} ${dir} failed: ${(r.stderr || '').trim()}`);
++ }
++}
++
++// ── deploy / rollback ────────────────────────────────────────────────────────
++
++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);
++ const held = await lock.acquire(exec, inv, svc.id);
++ if (held.recoveredStale) log(`removed a stale lock (${held.file})`);
++ const record = {
++ id: releases.newId(exec.now()),
++ service: svc.id,
++ strategy: svc.strategy,
++ layout: 'release',
++ action: mode,
++ startedAt: new Date(exec.now()).toISOString(),
++ operator: await ops.operatorName(exec),
++ host: await exec.hostname(),
++ from: null,
++ to: null,
++ fromRelease: null,
++ toRelease: null,
++ commits: 0,
++ lockfileChanged: false,
++ installed: [],
++ depsVerified: null,
++ restartNeeded: null,
++ switched: false,
++ restarted: false,
++ ready: null,
++ forced: false,
++ result: null,
++ };
++ 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, fromRelease: plan.fromRelease, toRelease: plan.toRelease, commits: plan.commits.length, lockfileChanged: plan.lockfileChanged, restartNeeded: plan.restartNeeded });
++ log(`${svc.id}: ${mode} ${plan.fromRelease} (${plan.from.slice(0, 12)}) → ${mode === 'deploy' && !plan.reuse ? `new release ${plan.toRelease}` : plan.toRelease} (${plan.to.slice(0, 12)}; ${plan.commits.length} commit(s), ${plan.changedFiles} file(s) changed)`);
++ if (plan.upToDate && !opts.restart) {
++ log(mode === 'deploy' ? `already at ${plan.to.slice(0, 12)}; nothing to deploy` : `${plan.toRelease} is already current; 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.nginxNotInstalled) log(`note: ${svc.nginx.repoVhost} changed and is NOT installed by a deploy — review it, copy it to ${inv.nginx.sitesAvailable}, nginx -t, reload`);
++
++ let dir = plan.targetDir;
++ const leaving = plan.currentDir;
++ if (mode === 'deploy' && !plan.upToDate) {
++ try {
++ await prepare(ctx, svc, plan, record);
++ } catch (err) {
++ if (record.prepared) {
++ await gitClone(exec, svc).worktreeRemove(dir);
++ record.removedPrepared = true;
++ }
++ const msg = `${err.message} (release ${plan.toRelease} ${record.prepared ? 'removed' : 'not made'}; ${plan.fromRelease} untouched; nothing restarted)`;
++ throw new OpError(msg, err.exitCode === EXIT.VALIDATION || !(err instanceof OpError) ? EXIT.VALIDATION : err.exitCode);
++ }
++ } else if (plan.upToDate) {
++ dir = leaving;
++ }
++ if (opts.prepareOnly) {
++ record.result = 'prepared';
++ log(`release ${plan.toRelease} ${plan.reuse ? 'already exists' : 'is prepared'}; ${plan.fromRelease} keeps serving (--prepare-only: nothing switched or restarted)`);
++ return { exitCode: EXIT.OK, record, plan };
++ }
++
++ const done = (result) => {
++ record.result = result;
++ return result;
++ };
++
++ if (!plan.restartNeeded) {
++ if (dir !== leaving) {
++ await switchCurrent(ctx, svc, dir);
++ record.switched = true;
++ }
++ if (svc.ready) {
++ await exec.sleep(svc.release.settleSeconds * 1000);
++ const p = await readiness.probe(exec, svc);
++ record.ready = p.ok;
++ if (!p.ok) {
++ log(`${svc.ready.url} answers ${p.status || p.error} after the switch — switching back to ${plan.fromRelease}`);
++ await switchCurrent(ctx, svc, leaving);
++ record.autoRollback = { to: plan.fromRelease, ready: (await readiness.probe(exec, svc)).ok };
++ done('failed-rolled-back');
++ return { exitCode: EXIT.ROLLED_BACK, record, plan };
++ }
++ }
++ done(mode === 'deploy' ? 'deployed' : 'rolled-back');
++ log(`switched to ${plan.toRelease} without a restart (only ${svc.noRestartPaths.join(' ')} changed)`);
++ if (mode === 'deploy') record.pruned = await prune(ctx, svc, { keepIds: [plan.fromRelease] });
++ return { exitCode: EXIT.OK, record, plan };
++ }
++
++ // A restart: sessions first, then the backup, then the switch. Refused or given up: the new
++ // release is removed again and the current one never stopped serving.
++ try {
++ await ops.guardSessions(ctx, svc, opts, record, 'before the switch');
++ if (plan.backupNeeded) {
++ await ops.backupBeforeRestart(ctx, svc, record);
++ await ops.guardSessions(ctx, svc, opts, record, 'immediately before the restart');
++ }
++ } catch (err) {
++ if (record.prepared) {
++ await gitClone(exec, svc).worktreeRemove(dir);
++ record.removedPrepared = true;
++ if (err instanceof OpError) err.message += ` (release ${plan.toRelease} removed; ${plan.fromRelease} keeps serving)`;
++ }
++ throw err;
++ }
++ if (dir !== leaving) {
++ await switchCurrent(ctx, svc, dir);
++ record.switched = true;
++ }
++ const u = await installReleaseUnits(ctx, svc, dir);
++ record.unitsInstalled = u.changed;
++ const r = await restartRelease(ctx, svc, { socketChanged: u.socketChanged, expectSha: plan.to, readyTimeout: opts.readyTimeout });
++ record.restarted = true;
++ record.ready = r.ok;
++ if (r.rebind) record.socketRebind = r.rebind;
++ if (r.socketHeld != null) record.socketHeld = r.socketHeld;
++ if (r.ok) {
++ done(mode === 'deploy' ? 'deployed' : 'rolled-back');
++ log(`${svc.id}: ${plan.fromRelease} → ${plan.toRelease} ${mode === 'deploy' ? 'deployed' : 'rolled back'}`);
++ if (svc.socketUnit || svc.protected) log('note: established WebSocket/WHIP/RTMP/WebRTC/SSE sessions were reconnected, not preserved.');
++ if (mode === 'deploy') record.pruned = await prune(ctx, svc, { keepIds: [plan.fromRelease] });
++ return { exitCode: EXIT.OK, record, plan };
++ }
++
++ // Not ready: back to the release we left.
++ log(`automatic rollback to ${plan.fromRelease}`);
++ record.autoRollback = { to: plan.fromRelease };
++ let exitCode;
++ try {
++ if (record.switched) await switchCurrent(ctx, svc, leaving);
++ const back = await installReleaseUnits(ctx, svc, leaving);
++ const b = await restartRelease(ctx, svc, { socketChanged: back.socketChanged, expectSha: plan.from, readyTimeout: opts.readyTimeout });
++ record.autoRollback.ready = b.ok;
++ if (b.ok) {
++ done('failed-rolled-back');
++ exitCode = EXIT.ROLLED_BACK;
++ log(`${svc.id} is back on ${plan.fromRelease} and serving; ${plan.toRelease} was not deployed (it is kept for inspection)`);
++ } else {
++ done('rollback-failed');
++ exitCode = EXIT.ROLLBACK_FAILED;
++ log(`ROLLBACK FAILED: ${plan.fromRelease} is not ready either — MANUAL INTERVENTION REQUIRED`);
++ }
++ } catch (err) {
++ done('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();
++ }
++}
++
++module.exports = { apply, computePlan, currentRelease, listReleases, rollbackTarget, newReleaseId, prune, switchCurrent };
ddiff --git a/lib/release-ops.js b/lib/release-ops.js
iindex 49c20cb..dd30e3c 100644
---- a/lib/release-ops.js
++++ b/lib/release-ops.js
@@@ -1,7 +1,10 @@
'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:
++ * plan, deploy and rollback. A service's `strategy` (lib/inventory.js, docs/deploy-strategies.md)
++ * picks the engine: release-layout services (Live, OpenRe) go to lib/release-layout.js; every other
++ * strategy (git-checkout, multi-app, static-build, pnpm-build) is an in-place git checkout driven
++ * here, with the strategy's defaults. 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).
@@@ -16,6 +19,12 @@
* - 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.
++ * - multi-app (Tools): apps/_* are packages, not apps (skipPackages); an untracked lockfile the
++ * incoming release tracks is removed before the merge (it would block it); preflight checks run
++ * in each app before anything restarts; every unit file matching unitsMatch is restarted.
++ * - static-build (Sites) and pnpm-build (Games): tracked build output (`generated`) is put back
++ * from git before the checkout moves, so a previous build never blocks a deploy; when anything
++ * fails the checkout is restored AND rebuilt, since a build writes what is being served.
*
* Exit codes: 0 ok · 1 usage/precondition · 2 validation failed, nothing restarted ·
* 3 not ready, rolled back and serving · 4 rollback failed — MANUAL INTERVENTION ·
@@@ -30,6 +39,7 @@ const readiness = require('./readiness');
const releases = require('./releases');
const lock = require('./lock');
const nginx = require('./nginx');
++const preflight = require('./preflight');
const EXIT = { OK: 0, USAGE: 1, VALIDATION: 2, ROLLED_BACK: 3, ROLLBACK_FAILED: 4, PROTECTED: 5, FROZEN: 6 };
@@@ -66,7 +76,10 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
const g = git(exec, svc);
const from = await g.head();
const branch = await g.branch();
-- const dirty = await g.dirty();
++ const dirtyAll = await g.dirty();
++ // Tracked output a previous build rewrote (Sites' dist/, Games' dist-types/) is not a local change.
++ const generatedDirty = dirtyAll.filter((f) => matchesPath(f, svc.generated));
++ const dirty = dirtyAll.filter((f) => !matchesPath(f, svc.generated));
let target;
if (mode === 'deploy') {
if (fetch) await g.fetch();
@@@ -84,17 +97,50 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
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);
++ if (inPatterns && !pkgDirs.includes(dir) && !deps.skipped(dir, svc.skipPackages)) pkgDirs.push(dir);
++ }
++ // npm leaves an untracked lockfile in an app that did not track one; once the release tracks it,
++ // the merge refuses to overwrite it. Those files are generated: they are removed before the merge.
++ const untrackedLockfiles = [];
++ if (svc.removeUntrackedLockfiles) {
++ for (const f of changed) {
++ if (path.basename(f) !== svc.install.lockfile || deps.skipped(path.dirname(f), svc.skipPackages)) continue;
++ if (!(await exec.stat(path.join(svc.repo, f)))) continue;
++ if ((await g.show(from, f)) == null) untrackedLockfiles.push(f);
++ }
}
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 });
++ if (svc.install.workspace) {
++ // A workspace (pnpm): one install at the root, needed when its lockfile or any package's
++ // dependency fields changed; every package is still verified afterwards.
++ let need = { lockfileChanged: false, depsChanged: false, needed: false };
++ if (from !== target) {
++ for (const pkg of ['.', ...pkgDirs]) {
++ const n = await deps.needsInstall(g, from, target, pkg, svc.install.lockfile);
++ if (pkg === '.') need.lockfileChanged = n.lockfileChanged;
++ need.depsChanged = need.depsChanged || n.depsChanged;
++ }
++ need.needed = need.lockfileChanged || need.depsChanged;
++ }
++ const hasModules = !!(await exec.stat(path.join(svc.repo, 'node_modules')));
++ installs.push({ pkg: '.', ...need, missingNodeModules: !hasModules, install: need.needed || svc.install.always || !hasModules });
++ } else {
++ for (const pkg of pkgDirs) {
++ const need = from === target ? { lockfileChanged: false, depsChanged: false, needed: false } : await deps.needsInstall(g, from, target, pkg, svc.install.lockfile);
++ 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);
++ // unitsMatch (Tools: openvibe-tools*.service): a unit file on the host that the inventory does not
++ // list is restarted too, as the script did, and named so the inventory can be corrected.
++ let extraUnits = [];
++ if (svc.unitsMatch) {
++ try { extraUnits = (await systemd.listUnitFiles(exec, svc.unitsMatch)).filter((u) => !svc.units.includes(u) && !svc.workerUnits.includes(u)); } catch { extraUnits = []; }
++ }
++ const units = [...svc.units, ...extraUnits];
++ const restartNeeded = units.length > 0 && (restart || codeChanged.length > 0);
const unitDrift = [];
for (const [unit, src] of Object.entries(svc.unitSources)) {
@@@ -107,8 +153,18 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
const backupNeeded = svc.databases.length > 0 && changed.some((f) => svc.backupOnChange.includes(f));
const sessions = restartNeeded ? await probes.countProtected(exec, svc) : null;
++ // A vhost the release no longer has stays installed: removing it could take down the real service
++ // that now owns the domain (its vhost may even have the same name). It is named, never removed.
++ const vhostsRemoved = [];
++ if (svc.nginx && svc.nginx.installOnDeploy && svc.nginx.repoVhosts) {
++ const vdir = path.dirname(svc.nginx.repoVhosts);
++ for (const f of changed) if (f.endsWith('.conf') && path.dirname(f) === vdir && (await g.show(target, f)) == null) vhostsRemoved.push(path.basename(f));
++ }
++
return {
service: svc.id,
++ strategy: svc.strategy,
++ layout: 'git',
mode,
repo: svc.repo,
branch,
@@@ -117,20 +173,30 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
to: target,
upToDate: from === target,
dirty,
++ generatedDirty,
commits,
changedFiles: changed.length,
++ changed,
codeChanged: codeChanged.length,
lockfileChanged: installs.some((i) => i.lockfileChanged),
installs,
++ untrackedLockfiles,
++ build: svc.build.map((b) => b.join(' ')),
++ preflight: preflight.describe(svc),
restartNeeded,
-- units: svc.units,
++ units,
++ extraUnits,
socketUnit: svc.socketUnit,
unitDrift,
++ installUnits: svc.installUnits,
backupNeeded,
protectedSessions: sessions,
drainPolicy: svc.drain.policy,
managed: svc.managed,
installVhosts: !!(svc.nginx && svc.nginx.installOnDeploy),
++ vhostsRemoved,
++ nginxNotInstalled: !!(svc.nginx && svc.nginx.repoVhost && !svc.nginx.installOnDeploy && changed.includes(svc.nginx.repoVhost)),
++ announceFrom: svc.announce.releaseFiles,
};
}
@@@ -150,7 +216,8 @@ async function guardSessions(ctx, svc, opts, record, when) {
log('');
return;
}
-- if (svc.drain.policy === 'report' && first.count != null) {
++ // Drain policy "report" restarts and says so, unless the operator asked to wait (--wait-idle).
++ if (svc.drain.policy === 'report' && first.count != null && !opts.waitIdle) {
log(`${describe(first)}; drain policy "report": they reconnect after the restart`);
return;
}
@@@ -196,7 +263,8 @@ async function verifyDeps(ctx, svc, g) {
return { pkgDirs, problems, repaired };
}
--async function runBuild(ctx, svc) {
++async function runBuild(ctx, svc, record = null) {
++ if (record && svc.build.length) record.built = true;
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 });
@@@ -249,11 +317,11 @@ async function restartAndWait(ctx, svc, opts = {}) {
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) {
++ for (const u of opts.units || 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 });
++ const r = await readiness.waitReady(ctx.exec, svc, { timeoutSeconds: opts.readyTimeout, log: ctx.log, expectSha: opts.expectSha, units: opts.units || svc.units });
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;
@@@ -271,6 +339,27 @@ async function restoreCheckout(ctx, svc, g, sha, record) {
}
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(', ')}`);
++ // A build writes what is served (Sites' dist/, Games' apps/client/dist): build the old release again.
++ if (record.built) {
++ ctx.log(`rebuilding ${sha.slice(0, 12)}`);
++ await runBuild(ctx, svc);
++ }
++}
++
++/** Sites: one release notification per placeholder, from each dist/<domain>/release.json. */
++async function releaseAnnouncements(ctx, svc) {
++ const pattern = svc.announce.releaseFiles;
++ if (!pattern) return null;
++ const dir = pattern.split('/')[0];
++ const out = [];
++ const entries = ((await ctx.exec.readdir(path.join(svc.repo, dir))) || []).filter((e) => e.isDir).map((e) => e.name).sort();
++ for (const domain of entries) {
++ let m;
++ try { m = JSON.parse((await ctx.exec.readFile(path.join(svc.repo, dir, domain, 'release.json'))) || 'null'); } catch { m = null; }
++ if (!m || !/^[a-z][a-z0-9-]{1,39}$/.test(String(m.service)) || !/^[0-9a-f]{7,40}$/.test(String(m.release)) || !/^[a-z0-9.-]+$/.test(domain)) continue;
++ out.push({ service: m.service, release: m.release, origin: `https://${domain}` });
++ }
++ return out;
}
// ── deploy / rollback ────────────────────────────────────────────────────────
@@@ -282,10 +371,11 @@ async function restoreCheckout(ctx, svc, g, sha, 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 (svc.strategy === 'release-layout') return require('./release-layout').apply(ctx, svc, mode, opts);
++ if (opts.prepareOnly) throw new OpError(`--prepare-only needs a release-layout service; ${svc.id} is ${svc.strategy}`, EXIT.USAGE);
++ // An in-place strategy never guesses its way through a releases/ + current layout.
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);
++ throw new OpError(`${svc.repo} uses the releases/current layout; set strategy "release-layout" for ${svc.id} (docs/deploy-strategies.md) or 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})`);
@@@ -293,6 +383,7 @@ async function apply(ctx, svc, mode, opts = {}) {
const record = {
id: releases.newId(exec.now()),
service: svc.id,
++ strategy: svc.strategy,
action: mode,
startedAt: new Date(exec.now()).toISOString(),
operator: await operatorName(exec),
@@@ -324,11 +415,21 @@ async function apply(ctx, svc, mode, opts = {}) {
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`);
++ if (plan.unitDrift.length && !opts.installUnits && !svc.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.generatedDirty.length && !plan.upToDate) {
++ log(`restoring ${plan.generatedDirty.length} tracked build output file(s) from git (${svc.generated.join(', ')})`);
++ await g.checkoutPaths(plan.generatedDirty);
++ }
++ for (const f of plan.untrackedLockfiles) {
++ log(`removing untracked ${f} (the release tracks it)`);
++ await exec.run('rm', ['-f', '--', path.join(svc.repo, f)], { as: svc.owner });
++ (record.removedUntracked = record.removedUntracked || []).push(f);
++ }
++
if (!plan.upToDate) {
if (mode === 'deploy') await g.ffMerge(plan.to); else await g.resetHard(plan.to);
record.checkoutMoved = true;
@@@ -340,9 +441,18 @@ async function apply(ctx, svc, mode, opts = {}) {
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);
++ await runBuild(ctx, svc, record);
++ const pf = await preflight.run(ctx, svc, { root: svc.repo, changed: plan.changed, pkgDirs: v.pkgDirs });
++ if (pf.problems.length) {
++ record.preflight = { ok: false, problems: pf.problems };
++ throw new OpError(`preflight failed: ${pf.problems.join('; ')}`, EXIT.VALIDATION);
++ }
++ if (svc.preflight.checks.length || svc.preflight.syntaxCheck) record.preflight = { ok: true, checks: pf.checksRun, syntaxChecked: pf.syntaxChecked };
++ if (plan.installVhosts) {
++ try { await installRepoVhosts(ctx, svc); } catch (err) { throw new OpError(err.message, EXIT.VALIDATION); }
++ for (const name of plan.vhostsRemoved) log(`note: ${name} is no longer in the repository and stays installed; remove it by hand once its domain is served elsewhere (nginx -t, reload)`);
++ }
++ if (opts.installUnits || svc.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) {
@@@ -356,6 +466,7 @@ async function apply(ctx, svc, mode, opts = {}) {
throw new OpError(`${err.message} (${record.checkoutRestored ? `checkout restored to ${plan.from.slice(0, 12)}; ` : ''}nothing restarted)`, EXIT.VALIDATION);
}
++ const announcements = await releaseAnnouncements(ctx, svc);
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'}`);
@@@ -364,17 +475,19 @@ async function apply(ctx, svc, mode, opts = {}) {
record.ready = p.ok;
if (!p.ok) log(`warning: ${svc.ready.url} answers ${p.status || p.error}`);
}
-- return { exitCode: EXIT.OK, record, plan };
++ return { exitCode: EXIT.OK, record, plan, announcements };
}
-- const r = await restartAndWait(ctx, svc, opts);
++ if (plan.extraUnits.length) log(`warning: ${plan.extraUnits.join(', ')} match ${svc.unitsMatch} but are not in the inventory's units; restarting them too (add them to services.${svc.id}.units)`);
++ const restartOpts = { ...opts, units: plan.units, expectSha: plan.to };
++ const r = await restartAndWait(ctx, svc, restartOpts);
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 };
++ return { exitCode: EXIT.OK, record, plan, announcements };
}
// Not ready: back to where we were.
@@@ -382,7 +495,7 @@ async function apply(ctx, svc, mode, opts = {}) {
record.autoRollback = { to: plan.from };
try {
await restoreCheckout(ctx, svc, g, plan.from, record);
-- const back = await restartAndWait(ctx, svc, opts);
++ const back = await restartAndWait(ctx, svc, { ...restartOpts, expectSha: plan.from });
record.autoRollback.ready = back.ok;
if (back.ok) {
record.result = 'failed-rolled-back';
@@@ -426,6 +539,7 @@ async function deploy(ctx, id, opts = {}) {
async function rollback(ctx, id, opts = {}) {
const svc = require('./inventory').service(ctx.inv, id);
++ if (svc.managed && svc.strategy === 'release-layout') return require('./release-layout').apply(ctx, svc, 'rollback', opts);
let to = opts.to;
if (!to) {
const current = await git(ctx.exec, svc).head();
@@@ -438,7 +552,8 @@ async function rollback(ctx, id, opts = {}) {
async function plan(ctx, id, opts = {}) {
const svc = require('./inventory').service(ctx.inv, id);
++ if (svc.strategy === 'release-layout') return require('./release-layout').computePlan(ctx, svc, { mode: 'deploy', to: opts.to, fetch: opts.fetch !== false, restart: opts.restart });
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 };
++module.exports = { plan, deploy, rollback, computePlan, guardSessions, matchesPath, installUnits, backupBeforeRestart, OpError, EXIT, operatorName };
ddiff --git a/lib/systemd.js b/lib/systemd.js
iindex b94f3e4..51c8c15 100644
---- a/lib/systemd.js
++++ b/lib/systemd.js
@@@ -5,9 +5,9 @@
* 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', 'list-units']);
--const ALLOWED_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'restart', 'reload', 'cat', 'status', 'daemon-reload', 'list-units']);
--const READ_ONLY_ACTIONS = ['show', 'is-active', 'is-enabled', 'cat', 'status', 'list-units'];
++const SOCKET_SAFE_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'cat', 'status', 'list-units', 'list-unit-files']);
++const ALLOWED_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'restart', 'reload', 'cat', 'status', 'daemon-reload', 'list-units', 'list-unit-files']);
++const READ_ONLY_ACTIONS = ['show', 'is-active', 'is-enabled', 'cat', 'status', 'list-units', 'list-unit-files'];
const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts', 'TimeoutStopUSec', 'KillSignal'];
class SocketGuardError extends Error {}
@@@ -20,6 +20,7 @@ async function systemctl(exec, action, unit) {
let args;
if (action === 'show') args = ['show', unit, `--property=${PROPS.join(',')}`];
else if (action === 'list-units') args = ['list-units', '--all', '--plain', '--no-legend', '--no-pager', unit];
++ else if (action === 'list-unit-files') args = ['list-unit-files', '--no-legend', '--no-pager', unit];
else args = unit ? [action, unit] : [action];
const privileged = !READ_ONLY_ACTIONS.includes(action);
return exec.run('systemctl', args, { privileged });
@@@ -80,6 +81,47 @@ async function ensureSocketActive(exec, socketUnit) {
return { started: true, state: st };
}
++/** Unit files matching a glob (read-only): the units a multi-app service has on this host. -> [unit] */
++async function listUnitFiles(exec, pattern) {
++ const r = await systemctl(exec, 'list-unit-files', pattern);
++ if (r.code !== 0 && String(r.stdout || '').trim()) throw new Error(`systemctl list-unit-files ${pattern} failed: ${(r.stderr || '').trim()}`);
++ return String(r.stdout || '').split('\n').map((l) => l.trim().split(/\s+/)[0]).filter((u) => u && u.endsWith('.service') && !u.endsWith('@.service'));
++}
++
++async function enable(exec, unit) {
++ const r = await systemctl(exec, 'enable', unit);
++ return r.code === 0;
++}
++
++/**
++ * Does pid 1 (systemd) hold the listener on `port`? `is-active` is not enough: a socket unit whose
++ * descriptor was dropped still reads active while the service binds the port itself, and then every
++ * restart refuses connections (Live, 2026-09-25 to 2026-09-26). -> { held, holders }
++ */
++async function socketHeld(exec, port) {
++ const holders = await exec.listeners(port);
++ return { held: holders.some((h) => h.pid === 1 && (!h.process || h.process === 'systemd')), holders };
++}
++
++const REBIND_REASONS = new Set(['socket-changed', 'socket-not-held']);
++
++/**
++ * The one sequence in ovhost that stops a service and restarts its socket unit, and only for the two
++ * reasons Live's deploy.sh had: the socket unit file changed (after daemon-reload systemd may drop its
++ * descriptor), or systemd does not hold the listener. Stop the service (it may hold the port itself),
++ * restart the socket, start the service on it. HTTP is refused for that moment, once. Every other
++ * path goes through systemctl(), which refuses to stop or restart a socket unit.
++ */
++async function rebindSocket(exec, serviceUnit, socketUnit, reason) {
++ if (!REBIND_REASONS.has(reason)) throw new SocketGuardError(`refusing to rebind ${socketUnit}: "${reason}" is not a reason to restart a socket unit`);
++ if (!serviceUnit.endsWith('.service') || !socketUnit.endsWith('.socket')) throw new SocketGuardError(`rebind needs a .service and a .socket unit (got ${serviceUnit}, ${socketUnit})`);
++ const stop = await exec.run('systemctl', ['stop', serviceUnit], { privileged: true });
++ const sock = await exec.run('systemctl', ['restart', socketUnit], { privileged: true });
++ const start = await exec.run('systemctl', ['start', serviceUnit], { privileged: true });
++ if (start.code !== 0) throw new Error(`systemctl start ${serviceUnit} failed after rebinding ${socketUnit}: ${(start.stderr || '').trim()}`);
++ return { stopped: stop.code === 0, socketRestarted: sock.code === 0 };
++}
++
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()}`);
@@@ -90,4 +132,4 @@ async function reloadNginx(exec) {
if (r.code !== 0) throw new Error(`systemctl reload nginx failed: ${r.stderr.trim()}`);
}
--module.exports = { systemctl, show, instances, restart, ensureSocketActive, daemonReload, reloadNginx, SocketGuardError };
++module.exports = { systemctl, show, instances, restart, ensureSocketActive, daemonReload, reloadNginx, listUnitFiles, enable, socketHeld, rebindSocket, SocketGuardError };
ddiff --git a/package-lock.json b/package-lock.json
iindex 8179f6b..302aaf1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-host",
-- "version": "0.2.0",
++ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-host",
-- "version": "0.2.0",
++ "version": "0.3.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@aws-sdk/client-s3": "^3.1138.0",
ddiff --git a/package.json b/package.json
iindex 7b0f54f..20e80e5 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-host",
-- "version": "0.2.0",
++ "version": "0.3.0",
"private": true,
"description": "OpenVibe.Host: the operator plane (Stage A, the ovhost CLI) and tenant static hosting (Stage B, the Host API service on port 4910): immutable content-addressed deploys, per-site domains, quotas and rollback.",
"main": "lib/index.js",
ddiff --git a/test/cli-inventory.test.js b/test/cli-inventory.test.js
iindex 424952c..66c9eb1 100644
---- a/test/cli-inventory.test.js
++++ b/test/cli-inventory.test.js
@@@ -22,11 +22,17 @@ runTests([
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);
-- // Live's release layout (C-76) is root-owned worktrees; its service and drills still run as ubuntu.
-- for (const s of Object.values(inv.services)) assert.strictEqual(s.owner, s.id === 'live' ? 'root' : 'ubuntu', `${s.id} checkout owner`);
++ // Every service on the host is deployed by a strategy (WS-N task 11): Games by pnpm-build.
++ assert.ok(Object.values(inv.services).every((s) => s.managed), 'every service is managed');
++ assert.deepStrictEqual(Object.fromEntries(['live', 'tools', 'sites', 'games', 'openre', 'network'].map((id) => [id, inv.services[id].strategy])), { live: 'release-layout', tools: 'multi-app', sites: 'static-build', games: 'pnpm-build', openre: 'release-layout', network: 'git-checkout' });
++ // Live's and OpenRe's release layouts are root-owned clones (git as root); their services and drills run as ubuntu.
++ for (const s of Object.values(inv.services)) assert.strictEqual(s.owner, ['live', 'openre'].includes(s.id) ? 'root' : 'ubuntu', `${s.id} checkout owner`);
assert.strictEqual(inv.services.live.runAs, 'ubuntu');
-- assert.strictEqual(inv.services.live.repo, '/opt/openvibe.live/current');
++ assert.strictEqual(inv.services.openre.runAs, 'ubuntu');
++ assert.strictEqual(inv.services.live.repo, '/opt/openvibe.live');
++ assert.strictEqual(inv.services.live.codeDir, '/opt/openvibe.live/current');
++ assert.strictEqual(inv.services.live.release.git, '/opt/openvibe.live/repo');
++ assert.strictEqual(inv.services.openre.release.git, '/opt/openre.stream/repo');
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');
}),
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 775fbde..103e5be 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -30,45 +30,75 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
onRestart: null, // (unit) -> void
onSystemdRun: null, // (spec) -> undefined | { code, stderr } ; spec = { unit, uid, cwd, props, argv, pid }
onKill: null, // (pid, signal) -> false to ignore the signal
++ onNode: null, // (args, opts) -> undefined | { code, stdout, stderr } ; `node` otherwise exits 0
++ pnpm: null, // (cwd, argv, as) -> undefined | { code, stderr } ; default installs every workspace package's deps
++ onBuild: null, // (cwd, argv, as) -> undefined | { code, stderr } ; `pnpm build`
systemdRuns: [],
nextPid: 40000,
streamPiece: 4096, // readStream() yields files in pieces of this size
};
// ── filesystem ──
-- function ensureDir(p, owner = 'root', mode = 0o755) {
++ // Every path component that is a symlink is followed (like the kernel does), so a file read or
++ // written through /opt/x/current/… lands in the release `current` points at.
++ function resolveLinks(p) {
const parts = path.resolve(p).split('/').filter(Boolean);
++ let cur = '/';
++ for (const part of parts) {
++ let next = path.join(cur, part);
++ let e = files.get(next);
++ let hops = 0;
++ while (e && e.type === 'symlink' && hops++ < 10) { next = path.resolve(path.dirname(next), e.target); e = files.get(next); }
++ cur = next;
++ }
++ return cur;
++ }
++ /** The path of an entry itself (lstat): the parent resolved, the last component not followed. */
++ function lpath(p) { const abs = path.resolve(p); return abs === '/' ? abs : path.join(resolveLinks(path.dirname(abs)), path.basename(abs)); }
++ function ensureDir(p, owner = 'root', mode = 0o755) {
++ const parts = resolveLinks(p).split('/').filter(Boolean);
let cur = '';
for (const part of parts) {
cur += `/${part}`;
-- if (!files.has(cur)) files.set(cur, { type: 'dir', mode, owner });
++ if (!files.has(cur)) files.set(cur, { type: 'dir', mode, owner, mtime: clock });
}
}
function put(p, content, { mode = 0o644, owner = 'root' } = {}) {
ensureDir(path.dirname(p), owner);
-- files.set(path.resolve(p), { type: 'file', content, mode, owner });
++ files.set(lpath(p), { type: 'file', content, mode, owner, mtime: clock });
}
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;
++ return files.get(resolveLinks(p)) || null;
}
function rmTree(p) {
-- const abs = path.resolve(p);
++ const abs = lpath(p);
for (const k of [...files.keys()]) if (k === abs || k.startsWith(`${abs}/`)) files.delete(k);
}
++ function copyTree(src, dest, owner) {
++ const from = resolveLinks(src);
++ const to = lpath(dest);
++ const e = files.get(from);
++ if (!e) return false;
++ for (const [k, v] of [...files.entries()]) {
++ if (k !== from && !k.startsWith(`${from}/`)) continue;
++ files.set(to + k.slice(from.length), { ...v, owner: owner || v.owner, mtime: clock });
++ }
++ ensureDir(path.dirname(to));
++ return true;
++ }
host.put = put;
host.read = (p) => { const e = get(p); return e && e.type === 'file' ? String(e.content) : null; };
host.ensureDir = ensureDir;
++ host.resolve = resolveLinks;
// ── 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: {} };
++ host.createRepo = (repoPath, { owner = 'ubuntu', branch = 'main', remote = 'origin', worktree = true } = {}) => {
++ const repo = { path: repoPath, owner, branch, remote, commits: new Map(), head: null, remoteRefs: {}, worktrees: new Set() };
repos.set(repoPath, repo);
ensureDir(repoPath, owner);
files.get(path.resolve(repoPath)).owner = owner;
++ if (!worktree) repo.bare = true;
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; }
@@@ -86,6 +116,22 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
};
return repo;
};
++ /** `git worktree add --detach <dir> <sha>`: a checkout sharing the clone's commits. */
++ function addWorktree(parent, dir, sha, owner) {
++ const wt = { path: dir, owner, branch: 'HEAD', remote: parent.remote, commits: parent.commits, head: null, remoteRefs: parent.remoteRefs, worktreeOf: parent.path };
++ wt.checkout = (s2) => {
++ const old = wt.head ? wt.commits.get(wt.head).files : {};
++ const next = wt.commits.get(s2).files;
++ for (const f of Object.keys(old)) if (!(f in next)) files.delete(path.join(dir, f));
++ for (const [f, c] of Object.entries(next)) put(path.join(dir, f), c, { owner });
++ wt.head = s2;
++ };
++ ensureDir(dir, owner);
++ wt.checkout(sha);
++ repos.set(path.resolve(dir), wt);
++ parent.worktrees.add(path.resolve(dir));
++ return wt;
++ }
function isAncestor(repo, anc, sha) {
let cur = sha;
while (cur) { if (cur === anc) return true; cur = repo.commits.get(cur).parent; }
@@@ -108,10 +154,17 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
return matches.length === 1 ? matches[0] : null;
}
function gitCmd(args, opts) {
-- const repo = repos.get(args[1]);
++ const repo = repos.get(args[1]) || repos.get(resolveLinks(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);
++ let i = 2;
++ const config = {};
++ while (args[i] === '-c') { const [k, v] = String(args[i + 1]).split('='); config[k] = v; i += 2; }
++ const dirEntry = files.get(resolveLinks(repo.path));
++ const dirOwner = dirEntry ? dirEntry.owner : repo.owner;
++ const safe = config['safe.directory'] === repo.path || config['safe.directory'] === args[1] || config['safe.directory'] === '*';
++ if (opts.as !== repo.owner && !(safe && (opts.as || 'root') === 'root')) return { code: 128, stdout: '', stderr: `fatal: detected dubious ownership in repository at '${repo.path}' (ran as ${opts.as || 'root'})` };
++ if (dirOwner !== repo.owner && (opts.as || 'root') !== dirOwner && !safe) return { code: 128, stdout: '', stderr: `fatal: detected dubious ownership in repository at '${repo.path}' (owned by ${dirOwner})` };
++ const [sub, ...rest] = args.slice(i);
const ok = (stdout = '') => ({ code: 0, stdout, stderr: '' });
const fail = (stderr) => ({ code: 1, stdout: '', stderr });
switch (sub) {
@@@ -158,10 +211,32 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
}
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 });
++ const list = rest[0] === '--' ? rest.slice(1) : [rest[rest.length - 1]];
++ for (const f of list) put(path.join(repo.path, f), repo.commits.get(repo.head).files[f], { owner: repo.owner });
return ok();
}
++ case 'worktree': {
++ const [op, ...wargs] = rest;
++ if (op === 'add') {
++ const pos = wargs.filter((a) => !a.startsWith('-'));
++ const [dir, ref] = pos;
++ if (files.has(lpath(dir))) return fail(`fatal: '${dir}' already exists`);
++ const sha = resolveRef(repo, ref);
++ if (!sha) return fail(`fatal: invalid reference: ${ref}`);
++ addWorktree(repo, dir, sha, opts.as || 'root');
++ return ok();
++ }
++ if (op === 'remove') {
++ const dir = path.resolve(wargs[wargs.length - 1]);
++ if (!repo.worktrees || !repo.worktrees.has(dir)) return fail(`fatal: '${dir}' is not a working tree`);
++ rmTree(dir);
++ repos.delete(dir);
++ repo.worktrees.delete(dir);
++ return ok();
++ }
++ if (op === 'prune') return ok();
++ return fail(`fake git: worktree ${op}`);
++ }
case 'config': return ok(`https://x-access-token:[email protected]/OpenVibers/${path.basename(repo.path)}.git\n`);
default: return fail(`fake git: unsupported ${sub}`);
}
@@@ -180,6 +255,30 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
return { code: 0, stdout: 'added packages', stderr: '' };
}
++ // ── pnpm (Games: a workspace) ──
++ function pnpmCmd(argv, opts) {
++ if (argv[0] === 'build' || (argv[0] === 'run' && argv[1] === 'build')) {
++ const r = host.onBuild ? host.onBuild(opts.cwd, argv, opts.as) : undefined;
++ return r ? { stdout: '', stderr: '', ...r } : { code: 0, stdout: 'built', stderr: '' };
++ }
++ if (host.pnpm) {
++ const r = host.pnpm(opts.cwd, argv, opts.as);
++ if (r) return { stdout: '', stderr: '', ...r };
++ }
++ const pkgs = ['.'];
++ for (const base of ['apps', 'packages']) {
++ const abs = resolveLinks(path.join(opts.cwd, base));
++ for (const k of files.keys()) if (k.startsWith(`${abs}/`) && k.slice(abs.length + 1).split('/').length === 2 && k.endsWith('/package.json')) pkgs.push(path.join(base, k.slice(abs.length + 1).split('/')[0]));
++ }
++ for (const dir of pkgs) {
++ const text = host.read(path.join(opts.cwd, dir, 'package.json'));
++ if (text == null) continue;
++ const pkg = JSON.parse(text);
++ for (const dep of Object.keys(pkg.dependencies || {})) put(path.join(opts.cwd, dir, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep, version: '1.0.0' }), { owner: opts.as });
++ }
++ return { code: 0, stdout: 'Done', 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 });
@@@ -198,9 +297,22 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
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 'start':
++ if (u) {
++ const was = u.active;
++ u.active = 'active'; u.sub = unit.endsWith('.socket') ? 'listening' : 'running';
++ // A service that starts runs whatever its checkout holds now (like a restart).
++ if (was !== 'active' && !unit.endsWith('.socket') && host.onRestart) host.onRestart(unit, u);
++ }
++ 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 'reload': case 'daemon-reload': case 'enable': return { code: 0, stdout: '', stderr: '' };
++ case 'list-unit-files': {
++ const glob = args[args.length - 1];
++ const re = new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
++ const rows = [...units.entries()].filter(([name, x]) => re.test(name) && !x.transient).map(([name]) => `${name} enabled enabled`);
++ return { code: rows.length ? 0 : 1, stdout: rows.length ? `${rows.join('\n')}\n` : '', stderr: '' };
++ }
case 'is-active': return { code: u && u.active === 'active' ? 0 : 3, stdout: '', stderr: '' };
case 'list-units': {
// systemctl list-units --all --plain --no-legend --no-pager <glob>
@@@ -245,6 +357,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
switch (cmd) {
case 'git': return gitCmd(args, opts);
case 'npm': return npmCmd(args, opts);
++ case 'pnpm': return pnpmCmd(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: '' };
@@@ -259,9 +372,11 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
return { code: 0, stdout: '', stderr: '' };
}
case 'chown': {
-- const e = files.get(path.resolve(args[args.length - 1]));
++ const target = resolveLinks(args[args.length - 1]);
++ const e = files.get(target);
if (!e) return { code: 1, stdout: '', stderr: 'chown: no such file' };
-- e.owner = args.filter((a) => !a.startsWith('-'))[0].split(':')[0];
++ const who = args.filter((a) => !a.startsWith('-'))[0].split(':')[0];
++ if (args.includes('-R')) { for (const [k, v] of files) if (k === target || k.startsWith(`${target}/`)) v.owner = who; } else e.owner = who;
return { code: 0, stdout: '', stderr: '' };
}
case 'chmod': {
@@@ -271,9 +386,12 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
return { code: 0, stdout: '', stderr: '' };
}
case 'mv': {
-- const src = path.resolve(args[args.length - 2]);
-- const dest = path.resolve(args[args.length - 1]);
++ const src = lpath(args[args.length - 2]);
++ const dest = lpath(args[args.length - 1]);
++ const force = args.slice(0, -2).some((a) => /^-[A-Za-z]*f/.test(a));
if (!files.has(src)) return { code: 1, stdout: '', stderr: `mv: cannot stat '${src}'` };
++ // mv -T -f over a symlink (or a file) replaces it in one rename: an atomic switch.
++ if (files.has(dest) && force && files.get(dest).type !== 'dir') files.delete(dest);
if (files.has(dest)) return { code: 1, stdout: '', stderr: `mv: '${dest}' exists` };
ensureDir(path.dirname(dest));
for (const k of [...files.keys()]) {
@@@ -283,12 +401,19 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
}
return { code: 0, stdout: '', stderr: '' };
}
-- case 'node': return { code: 0, stdout: '', stderr: '' };
++ case 'node': {
++ const r = host.onNode ? host.onNode(args, opts) : undefined;
++ return r ? { stdout: '', stderr: '', ...r } : { code: 0, stdout: '', stderr: '' };
++ }
case 'fuser': return { code: (host.openFiles || new Set()).has(path.resolve(args[args.length - 1])) ? 0 : 1, stdout: '', stderr: '' };
case 'systemd-run': return systemdRunCmd(args);
case 'cp': {
const src = args[args.length - 2];
const dest = args[args.length - 1];
++ if (args.some((a) => /^-[A-Za-z]*a/.test(a))) {
++ if (files.has(lpath(dest))) return { code: 1, stdout: '', stderr: `cp: '${dest}' exists` };
++ return copyTree(src, dest, opts.as || user) ? { code: 0, stdout: '', stderr: '' } : { code: 1, stdout: '', stderr: `cp: cannot stat '${src}'` };
++ }
const e = get(src);
if (!e || e.type !== 'file') return { code: 1, stdout: '', stderr: `cp: cannot stat '${src}': No such file or directory` };
put(dest, e.content, { owner: opts.as || user, mode: 0o644 });
@@@ -323,18 +448,21 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
});
},
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 removeFile(p) { files.delete(lpath(p)); },
++ async symlink(target, p) {
++ if (files.has(lpath(p))) throw Object.assign(new Error(`EEXIST: file already exists, symlink '${target}' -> '${p}'`), { code: 'EEXIST' });
++ ensureDir(path.dirname(p)); files.set(lpath(p), { type: 'symlink', target, owner: user, mode: 0o777, mtime: clock });
++ },
++ async readlink(p) { const e = files.get(lpath(p)); return e && e.type === 'symlink' ? e.target : null; },
async stat(p) {
-- const l = files.get(path.resolve(p));
++ const l = files.get(lpath(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, nlink: e.nlink || 1 };
++ 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, nlink: e.nlink || 1, mtime: e.mtime || 0 };
},
async readdir(p) {
-- const abs = path.resolve(p);
++ const abs = resolveLinks(p);
const e = files.get(abs);
if (!e || e.type !== 'dir') return null;
const out = new Map();
@@@ -345,8 +473,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
}
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 mkdir(p, { owner, mode = 0o750 } = {}) { ensureDir(p, owner || user, mode); const e = files.get(resolveLinks(p)); e.owner = owner || e.owner; e.mode = mode; },
++ async createExclusive(p, content) { if (files.has(lpath(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 });
ddiff --git a/test/openre-workers.test.js b/test/openre-workers.test.js
iindex b55a62b..cb370e7 100644
---- a/test/openre-workers.test.js
++++ b/test/openre-workers.test.js
@@@ -85,7 +85,8 @@ runTests([
assert.deepStrictEqual(o.units, ['openre-api.service', 'openre-session-coordinator.service']);
assert.deepStrictEqual(o.workerUnits, ['[email protected]', '[email protected]']);
assert.strictEqual(o.layout, 'release');
-- assert.strictEqual(o.managed, false);
++ assert.strictEqual(o.strategy, 'release-layout');
++ assert.strictEqual(o.managed, true, 'ovhost deploy openre: release + api (WS-N task 11)');
assert.strictEqual(o.drill.supported, true);
assert.strictEqual(o.drill.port, 14500);
assert.deepStrictEqual(o.drill.command, ['/usr/bin/node', 'server/index.js']);
@@@ -112,7 +113,7 @@ runTests([
const host = await openreHost();
const r = await host.cli('status', 'openre');
assert.strictEqual(r.code, 0, r.out);
-- assert.match(r.out, new RegExp(`openre\\s+${RELEASE}\\s+ready\\s+openre-api=active openre-session-coordinator=active workers\\[openre-rtmp-ingest@${RELEASE}=active openre-rtmp-ingest@${OLD}=inactive openre-restream-worker@${RELEASE}=active\\] ingest sessions=0 \\[unmanaged\\]`));
++ assert.match(r.out, new RegExp(`openre\\s+${RELEASE}\\s+ready\\s+openre-api=active openre-session-coordinator=active workers\\[openre-rtmp-ingest@${RELEASE}=active openre-rtmp-ingest@${OLD}=inactive openre-restream-worker@${RELEASE}=active\\] ingest sessions=0$`, 'm'));
const json = JSON.parse((await host.cli('status', 'openre', '--json')).out);
assert.deepStrictEqual(json[0].workers.map((w) => [w.unit, w.active]), [[`openre-rtmp-ingest@${RELEASE}.service`, 'active'], [`openre-rtmp-ingest@${OLD}.service`, 'inactive'], [`openre-restream-worker@${RELEASE}.service`, 'active']]);
const lists = host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'list-units');
ddiff --git a/test/strategy-hosts.js b/test/strategy-hosts.js
nnew file mode 100644
iindex 0000000..bbc0ceb
---- /dev/null
++++ b/test/strategy-hosts.js
@@@ -0,0 +1,387 @@
++'use strict';
++/**
++ * Fake hosts for the deploy strategies (docs/deploy-strategies.md), each shaped like the service on
++ * the real host and driven through the CLI:
++ * live release-layout: /opt/openvibe.live/{repo, releases/<time>-<sha8>, current, shared/data},
++ * the socket unit held by pid 1 on :3000, /api/streams, /release.json from the running release
++ * openre release-layout: releases/<sha12>, npm ci into every release, chown to ubuntu, two units,
++ * worker instances that run from a release
++ * tools multi-app: apps/gateway, img, maps and the apps/_shared package, several units
++ * sites static-build: tracked dist/ rebuilt by node build.js, repo vhosts, no units
++ * games pnpm-build: a pnpm workspace with tracked dist-types/, one unit, players online (report)
++ * network git-checkout with installUnits
++ * Nothing touches the real machine (test/fake-host.js).
++ */
++const path = require('path');
++const { createFakeHost } = require('./fake-host');
++const { main } = require('../lib/cli');
++const { normalise } = require('../lib/inventory');
++const { pkgJson, lifecycleDoc } = require('./helpers');
++
++const SECRET = 'sk_live_THIS_VALUE_MUST_NEVER_APPEAR_4f9a2c';
++
++function base(services) {
++ return { host: 'fake-host', stateDir: '/var/lib/openvibe-host', backupDir: '/var/backups/openvibe', defaults: { owner: 'ubuntu', branch: 'main' }, services };
++}
++
++/** CLI, logs and helpers every strategy host shares. */
++function finish(host, doc) {
++ host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
++ host.inv = normalise(doc);
++ host.ensureDir('/etc/nginx/sites-available');
++ host.ensureDir('/etc/nginx/sites-enabled');
++ host.badShas = new Set();
++ 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') };
++ };
++ host.ctx = () => ({ exec: host.exec, inv: host.inv, log: () => {} });
++ host.freeze = (id) => host.put(`/var/lib/openvibe-host/freeze/${id}.json`, JSON.stringify({ service: id, reason: 'incident drill', at: '2026-09-22T11:00:00.000Z' }), { mode: 0o640 });
++ host.nodeChecks = () => host.calls.filter((c) => c.cmd === 'node');
++ return host;
++}
++
++// ── Live: release layout ─────────────────────────────────────────────────────
++
++const LIVE_UNIT = '[Service]\nWorkingDirectory=/opt/openvibe.live/current\nExecStart=/usr/bin/env node /opt/openvibe.live/current/server/index.js\n';
++const LIVE_SOCKET = '[Socket]\nListenStream=127.0.0.1:3000\nBacklog=511\n';
++const LIVE_DROPIN = '[Unit]\nRequires=openvibe-live.socket\n[Service]\nNonBlocking=true\n';
++
++function liveEntry(overrides = {}) {
++ return {
++ repo: '/opt/openvibe.live',
++ owner: 'root',
++ runAs: 'ubuntu',
++ strategy: 'release-layout',
++ release: { links: { data: '../../shared/data' }, keep: 3, settleSeconds: 3 },
++ units: ['openvibe-live.service'],
++ socketUnit: 'openvibe-live.socket',
++ unitSources: {
++ 'openvibe-live.service': 'deploy/systemd/release/openvibe-live.service',
++ 'openvibe-live.socket': 'deploy/systemd/release/openvibe-live.socket',
++ 'openvibe-live.service.d/socket.conf': 'deploy/systemd/release/openvibe-live.service.d/socket.conf',
++ },
++ envFile: '/etc/openvibe/live.env',
++ env: { required: ['JWT_SECRET'] },
++ port: 3000,
++ ready: { url: 'http://127.0.0.1:3000/api/ready', timeoutSeconds: 30, release: true },
++ 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/shared/data/live.db' }],
++ backupOnChange: ['server/db/schema.sql'],
++ preflight: { syntaxCheck: true },
++ nginx: { vhost: 'openvibe.live.conf', repoVhost: 'deploy/nginx/openvibe.live.conf', variant: 'websocket' },
++ lifecycle: lifecycleDoc(),
++ ...overrides,
++ };
++}
++
++async function releaseLayoutHost({ id, entry, files, initialId, unitFiles = {}, owner = 'root', modules = true }) {
++ const host = createFakeHost();
++ const doc = base({ [id]: entry });
++ finish(host, doc);
++ const svc = host.inv.services[id];
++ const clone = host.createRepo(svc.release.git, { owner, worktree: false });
++ const sha = clone.commit(files, { message: 'initial' });
++ clone.publish(sha);
++ host.clone = clone;
++ const first = path.join(svc.release.releasesDir, initialId(sha));
++ const r = await host.exec.run('git', ['-C', svc.release.git, 'worktree', 'add', '--detach', '--quiet', first, sha], { as: owner });
++ if (r.code !== 0) throw new Error(r.stderr);
++ if (modules) {
++ const pkg = JSON.parse(files['package.json']);
++ for (const dep of Object.keys(pkg.dependencies || {})) host.put(path.join(first, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep, lock: files['package-lock.json'] }), { owner });
++ }
++ for (const [name, target] of Object.entries(svc.release.links)) await host.exec.symlink(target, path.join(first, name));
++ await host.exec.symlink(first, svc.release.current);
++ for (const [unit, src] of Object.entries(svc.unitSources)) if (files[src] != null) host.put(path.join('/etc/systemd/system', unit), files[src]);
++ for (const [unit, text] of Object.entries(unitFiles)) host.put(path.join('/etc/systemd/system', unit), text);
++ for (const u of svc.units) host.addUnit(u, { runningSha: sha });
++ host.firstRelease = path.basename(first);
++ host.releaseDir = (rid) => path.join(svc.release.releasesDir, rid);
++ host.current = () => path.basename(host.resolve(svc.release.current));
++ host.currentSha = () => host.repos.get(host.resolve(svc.release.current)).head;
++ host.releaseIds = async () => ((await host.exec.readdir(svc.release.releasesDir)) || []).map((e) => e.name).sort();
++ host.push = (changes, message = 'change') => { const s = clone.commit(changes, { message }); clone.publish(s); return s; };
++ host.npmRewritesLockfile = false; // npm ci never rewrites the lockfile
++ // A restarted (or started) unit runs whatever `current` points at at that moment.
++ host.onRestart = (unit, u) => {
++ if (unit.endsWith('.socket')) { if (host.onSocketRestart) host.onSocketRestart(unit); return; }
++ const wt = host.repos.get(host.resolve(svc.release.current));
++ u.runningSha = wt ? wt.head : null;
++ };
++ host.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' } };
++ };
++ return host;
++}
++
++const LIVE_FILES = {
++ 'package.json': pkgJson('openvibe-live', ['express', 'better-sqlite3']),
++ '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/release/openvibe-live.service': LIVE_UNIT,
++ 'deploy/systemd/release/openvibe-live.socket': LIVE_SOCKET,
++ 'deploy/systemd/release/openvibe-live.service.d/socket.conf': LIVE_DROPIN,
++ 'deploy/nginx/openvibe.live.conf': 'server { listen 443; }\n',
++ '.env.example': 'JWT_SECRET=\n',
++};
++
++async function liveHost(overrides = {}) {
++ const host = await releaseLayoutHost({ id: 'live', entry: liveEntry(overrides), files: LIVE_FILES, initialId: (sha) => `20260922-110000-${sha.slice(0, 8)}` });
++ host.addUnit('openvibe-live.socket', { sub: 'listening', mainPid: 0 });
++ host.listeners.set(3000, [{ pid: 1, process: 'systemd' }]);
++ host.put('/etc/openvibe/live.env', `JWT_SECRET=${SECRET}\n`, { mode: 0o600 });
++ host.put('/opt/openvibe.live/shared/data/live.db', 'sqlite', { owner: 'ubuntu' });
++ host.liveStreams = [];
++ host.http.set('http://127.0.0.1:3000/api/ready', host.readyFor('openvibe-live.service'));
++ host.http.set('http://127.0.0.1:3000/api/streams', () => ({ status: 200, body: { streams: host.liveStreams } }));
++ // Live names its release after the directory it runs from: <time>-<sha8>.
++ host.http.set('http://127.0.0.1:3000/release.json', () => {
++ const u = host.units.get('openvibe-live.service');
++ return { status: 200, body: { service: 'live', release: String(u.runningSha).slice(0, 8) } };
++ });
++ host.calls.length = 0;
++ return host;
++}
++
++// ── OpenRe: release layout, sha12 ids, workers ───────────────────────────────
++
++function openreEntry(overrides = {}) {
++ return {
++ repo: '/opt/openre.stream',
++ owner: 'root',
++ runAs: 'ubuntu',
++ strategy: 'release-layout',
++ release: { id: 'sha12', reuseModules: false, chown: 'ubuntu:ubuntu', keep: 2 },
++ units: ['openre-api.service', 'openre-session-coordinator.service'],
++ workerUnits: ['[email protected]', '[email protected]'],
++ envFile: '/etc/openvibe/openre.env',
++ port: 4500,
++ ready: { url: 'http://127.0.0.1:4500/api/ready', timeoutSeconds: 60 },
++ noRestartPaths: ['docs/', 'test/', '**/*.md'],
++ databases: [{ name: 'openre', path: '/var/lib/openre/openre.db' }],
++ protected: { kind: 'sqlite-count', db: '/var/lib/openre/openre.db', sql: "SELECT count(*) AS n FROM ingest_sessions WHERE state IN ('starting', 'live', 'ending')", label: 'ingest sessions' },
++ drain: { policy: 'refuse' },
++ lifecycle: lifecycleDoc(),
++ ...overrides,
++ };
++}
++
++const OPENRE_FILES = {
++ 'package.json': pkgJson('openre-stream', ['express', 'better-sqlite3']),
++ 'package-lock.json': '{"lockfileVersion":3,"v":1}',
++ 'server/index.js': 'api();',
++ 'docs/README.md': '# OpenRe',
++};
++
++async function openreHost(overrides = {}) {
++ const host = await releaseLayoutHost({ id: 'openre', entry: openreEntry(overrides), files: OPENRE_FILES, initialId: (sha) => sha.slice(0, 12) });
++ host.put('/etc/openvibe/openre.env', `OV_OAUTH_CLIENT_SECRET=${SECRET}\n`, { mode: 0o600 });
++ host.put('/var/lib/openre/openre.db', 'sqlite', { owner: 'ubuntu' });
++ host.addUnit(`openre-rtmp-ingest@${host.firstRelease}.service`, { mainPid: 3102 });
++ host.addUnit(`openre-restream-worker@${host.firstRelease}.service`, { mainPid: 3103 });
++ host.sessions = 0;
++ host.sqliteHandler = () => [{ n: host.sessions }];
++ host.http.set('http://127.0.0.1:4500/api/ready', host.readyFor('openre-api.service'));
++ host.calls.length = 0;
++ return host;
++}
++
++// ── in-place strategies ──────────────────────────────────────────────────────
++
++function inPlace(host, id, files, packages = ['.']) {
++ const svc = host.inv.services[id];
++ const repo = host.createRepo(svc.repo, { owner: svc.owner });
++ const sha = repo.commit(files, { message: 'initial' });
++ repo.publish(sha);
++ repo.checkout(sha);
++ for (const dir of packages) {
++ const pkg = JSON.parse(files[path.join(dir, 'package.json')]);
++ for (const dep of Object.keys(pkg.dependencies || {})) host.put(path.join(svc.repo, dir, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep }), { owner: 'ubuntu' });
++ }
++ for (const u of svc.units) host.addUnit(u, { runningSha: sha });
++ host.repo = repo;
++ host.push = (changes, message = 'change') => { const s = repo.commit(changes, { message }); repo.publish(s); return s; };
++ host.onRestart = (unit, u) => { u.runningSha = repo.head; };
++ host.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' } };
++ };
++ return repo;
++}
++
++// Tools: multi-app.
++const TOOLS_UNITS = ['openvibe-tools.service', 'openvibe-tools-img.service', 'openvibe-tools-maps.service'];
++function toolsEntry(overrides = {}) {
++ return {
++ repo: '/opt/openvibe.tools',
++ strategy: 'multi-app',
++ units: TOOLS_UNITS,
++ unitsMatch: 'openvibe-tools*.service',
++ port: 4001,
++ ready: { url: 'http://127.0.0.1:4001/api/ready', headers: { Host: 'openvibe.tools' }, timeoutSeconds: 60, release: true, allUnits: true },
++ noRestartPaths: ['**/*.md'],
++ preflight: {
++ dirs: ['data'],
++ checks: [
++ { label: 'jobs runtime loads', packages: ['apps/img'], argv: ['node', '-e', "const D=require('better-sqlite3'); new D(':memory:').close(); require('openvibe-contracts'); require('openvibe-sdk'); require('../_shared/jobs')"] },
++ { label: 'guard loads', packages: '*', argv: ['node', '-e', "const D=require('better-sqlite3'); new D(':memory:').close(); require('../_shared/guard')"] },
++ ],
++ },
++ protected: { kind: 'sum', label: 'running tool jobs', probes: [{ kind: 'sqlite-count', db: '/opt/openvibe.tools/apps/img/data/jobs.db', sql: "SELECT count(*) AS n FROM tool_jobs WHERE state = 'running'" }] },
++ drain: { policy: 'report', waitMaxSeconds: 900, pollSeconds: 60, quietChecks: 2 },
++ lifecycle: lifecycleDoc(),
++ ...overrides,
++ };
++}
++
++const TOOLS_FILES = {
++ 'apps/gateway/package.json': pkgJson('tools-gateway', ['express', 'better-sqlite3']),
++ 'apps/gateway/package-lock.json': '{"v":1}',
++ 'apps/gateway/server/index.js': 'gw();',
++ 'apps/img/package.json': pkgJson('tools-img', ['express', 'better-sqlite3', 'openvibe-contracts', 'openvibe-sdk']),
++ 'apps/img/package-lock.json': '{"v":1}',
++ 'apps/img/server/index.js': 'img();',
++ 'apps/maps/package.json': pkgJson('tools-maps', ['express', 'better-sqlite3']),
++ 'apps/maps/server/index.js': 'maps();',
++ 'apps/_shared/package.json': JSON.stringify({ name: 'openvibe-tools-shared', version: '1.1.0', private: true }),
++ 'apps/_shared/guard/index.js': 'guard();',
++ 'apps/_shared/jobs/index.js': 'jobs();',
++ 'README.md': '# Tools',
++};
++
++function toolsHost(overrides = {}) {
++ const host = finish(createFakeHost(), base({ tools: toolsEntry(overrides) }));
++ inPlace(host, 'tools', TOOLS_FILES, ['apps/gateway', 'apps/img', 'apps/maps']);
++ host.http.set('http://127.0.0.1:4001/api/ready', host.readyFor('openvibe-tools.service'));
++ host.gatewayRelease = null; // override what the gateway's /release.json names
++ host.http.set('http://127.0.0.1:4001/release.json', ({ headers }) => {
++ if (headers.Host !== 'openvibe.tools') return { status: 404 };
++ const u = host.units.get('openvibe-tools.service');
++ return { status: 200, body: { service: 'tools', release: host.gatewayRelease || String(u.runningSha).slice(0, 12) } };
++ });
++ host.jobs = 0;
++ host.sqliteHandler = () => [{ n: host.jobs }];
++ return host;
++}
++
++// Sites: static-build.
++function sitesEntry(overrides = {}) {
++ return { repo: '/opt/openvibe.sites', strategy: 'static-build', units: [], lifecycle: lifecycleDoc({ liveness: { none: 'static files served by nginx' }, shutdown: { none: 'no process of its own' }, startupRecovery: { none: 'nothing in flight' } }), ...overrides };
++}
++
++const SITES_FILES = {
++ 'package.json': pkgJson('openvibe-sites', ['openvibe-shared']),
++ 'package-lock.json': '{"v":1}',
++ 'build.js': 'build();',
++ 'sites.json': '{"sites":["openvibe.news","openvibe.tips"]}',
++ 'dist/openvibe.news/index.html': '<h1>News</h1> 2026-09-21',
++ 'dist/openvibe.news/release.json': JSON.stringify({ service: 'news', release: 'aaaaaaaaaaaa' }),
++ 'dist/openvibe.tips/index.html': '<h1>Tips</h1> 2026-09-21',
++ 'dist/openvibe.tips/release.json': JSON.stringify({ service: 'tips', release: 'bbbbbbbbbbbb' }),
++ 'deploy/nginx/openvibe.news.conf': 'server { listen 443; server_name openvibe.news; }\n',
++ 'deploy/nginx/openvibe.tips.conf': 'server { listen 443; server_name openvibe.tips; }\n',
++};
++
++function sitesHost(overrides = {}) {
++ const host = finish(createFakeHost(), base({ sites: sitesEntry(overrides) }));
++ inPlace(host, 'sites', SITES_FILES, ['.']);
++ // node build.js on the host rewrites the tracked dist/ with today's date: a build leaves it dirty.
++ host.onNode = (args, opts) => {
++ if (args[0] !== 'build.js') return undefined;
++ host.builds = (host.builds || 0) + 1;
++ if (host.buildFails && host.repo.head === host.buildFails) return { code: 1, stderr: 'build.js: sites.json is not valid' };
++ for (const f of ['dist/openvibe.news/index.html', 'dist/openvibe.tips/index.html']) {
++ const cur = host.read(path.join(opts.cwd, f));
++ if (cur != null) host.put(path.join(opts.cwd, f), cur.replace(/\d{4}-\d{2}-\d{2}/, '2026-09-22'), { owner: opts.as });
++ }
++ return { code: 0 };
++ };
++ return host;
++}
++
++// Games: pnpm-build.
++function gamesEntry(overrides = {}) {
++ return {
++ repo: '/opt/openvibe.games',
++ strategy: 'pnpm-build',
++ units: ['openvibe-games.service'],
++ port: 8000,
++ ready: { url: 'http://127.0.0.1:8000/api/ready', timeoutSeconds: 60 },
++ generated: ['apps/client/dist-types/', 'apps/server/dist-types/'],
++ preflight: { checks: [{ label: 'better-sqlite3 loads under this Node', packages: ['apps/server'], argv: ['node', '-e', "new (require('better-sqlite3'))(':memory:').close()"] }] },
++ protected: { kind: 'http-json-count', url: 'http://127.0.0.1:8000/api/ready', field: 'checks.sessions.detail.online', label: 'players online' },
++ drain: { policy: 'report', waitMaxSeconds: 600, pollSeconds: 60, quietChecks: 2 },
++ databases: [{ name: 'world', path: '/opt/openvibe.games/data/world.db' }],
++ lifecycle: lifecycleDoc(),
++ ...overrides,
++ };
++}
++
++const GAMES_FILES = {
++ 'package.json': JSON.stringify({ name: 'openvibe-games', private: true, scripts: { build: 'pnpm -r build' } }),
++ 'pnpm-lock.yaml': 'lockfileVersion: 9.0\nv: 1\n',
++ 'pnpm-workspace.yaml': 'packages:\n - packages/*\n - apps/*\n',
++ 'apps/server/package.json': pkgJson('@openvibe/server', ['@openvibe/shared', 'better-sqlite3', 'tsx']),
++ 'apps/server/src/main.ts': 'main();',
++ 'apps/server/dist-types/main.d.ts': 'export {};\n',
++ 'apps/client/package.json': pkgJson('@openvibe/client', ['@openvibe/shared']),
++ 'apps/client/src/main.ts': 'client();',
++ 'apps/client/dist-types/main.d.ts': 'export {};\n',
++ 'packages/shared/package.json': JSON.stringify({ name: '@openvibe/shared', version: '0.1.0' }),
++ 'packages/shared/src/index.ts': 'export const x = 1;',
++};
++
++function gamesHost(overrides = {}) {
++ const host = finish(createFakeHost(), base({ games: gamesEntry(overrides) }));
++ inPlace(host, 'games', GAMES_FILES, ['apps/server', 'apps/client']);
++ host.players = 0;
++ host.http.set('http://127.0.0.1:8000/api/ready', () => {
++ const r = host.readyFor('openvibe-games.service')();
++ return r.status === 200 ? { status: 200, body: { status: 'ready', checks: { sessions: { detail: { online: host.players } } } } } : r;
++ });
++ // pnpm build writes the client bundle that is served and rewrites the tracked dist-types.
++ host.onBuild = (cwd, argv, as) => {
++ host.builds = (host.builds || 0) + 1;
++ if (host.buildFails && host.repo.head === host.buildFails) return { code: 1, stderr: 'tsc: error TS2322' };
++ host.put(path.join(cwd, 'apps/client/dist/index.html'), `client built from ${host.repo.head}`, { owner: as });
++ host.put(path.join(cwd, 'apps/client/dist-types/main.d.ts'), `export {}; // built ${host.repo.head.slice(0, 7)}\n`, { owner: as });
++ return { code: 0 };
++ };
++ host.put('/opt/openvibe.games/data/world.db', 'sqlite', { owner: 'ubuntu' });
++ return host;
++}
++
++// Network: git-checkout, unit files installed on every deploy.
++function networkHost() {
++ const entry = {
++ repo: '/opt/openvibe.network',
++ units: ['openvibe-network.service'],
++ unitSources: { 'openvibe-network.service': 'deploy/systemd/openvibe-network.service' },
++ installUnits: true,
++ port: 4000,
++ ready: { url: 'http://127.0.0.1:4000/api/ready', timeoutSeconds: 30 },
++ noRestartPaths: ['docs/', '**/*.md'],
++ lifecycle: lifecycleDoc(),
++ };
++ const host = finish(createFakeHost(), base({ network: entry }));
++ inPlace(host, 'network', {
++ 'package.json': pkgJson('openvibe-network', ['express']),
++ 'package-lock.json': '{"v":1}',
++ 'server/index.js': 'net();',
++ 'deploy/systemd/openvibe-network.service': '[Service]\nExecStart=node server/index.js\n',
++ }, ['.']);
++ host.put('/etc/systemd/system/openvibe-network.service', '[Service]\nExecStart=node server/index.js\n');
++ host.http.set('http://127.0.0.1:4000/api/ready', host.readyFor('openvibe-network.service'));
++ return host;
++}
++
++module.exports = { liveHost, liveEntry, openreHost, toolsHost, sitesHost, gamesHost, networkHost, LIVE_SOCKET, SECRET };
ddiff --git a/test/strategy-in-place.test.js b/test/strategy-in-place.test.js
nnew file mode 100644
iindex 0000000..8649969
---- /dev/null
++++ b/test/strategy-in-place.test.js
@@@ -0,0 +1,340 @@
++'use strict';
++/**
++ * The in-place strategies against the fake host: multi-app (Tools' deploy.sh), static-build (Sites'
++ * deploy.sh), pnpm-build (Games' manual deploy) and git-checkout with installUnits (Network's
++ * deploy.sh), plus `ovhost capabilities` and `--version`, which the per-repository wrappers probe.
++ */
++const assert = require('assert');
++const path = require('path');
++const { test, runTests } = require('./helpers');
++const { toolsHost, sitesHost, gamesHost, networkHost } = require('./strategy-hosts');
++const releases = require('../lib/releases');
++
++const lastRecord = async (host, id) => (await releases.list(host.exec, host.inv, id)).pop();
++const HOST_ENV = '/etc/openvibe/host.env';
++
++/** Host's credentials and a stand-in Events, for the per-placeholder notifications (Sites). */
++function withEvents(host) {
++ host.put(HOST_ENV, 'OV_OAUTH_CLIENT_ID=host\nOV_OAUTH_CLIENT_SECRET="s3cr3t-value"\nEVENTS_URL=http://127.0.0.1:4300\nOV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000\n', { mode: 0o600 });
++ host.published = [];
++ host.http.set('http://127.0.0.1:4000/oauth/token', () => ({ status: 200, body: { access_token: 'tok', token_type: 'Bearer' } }));
++ host.http.set('http://127.0.0.1:4300/api/v1/events', ({ body }) => { const e = JSON.parse(body); host.published.push(e); return { status: 201, body: { event_id: e.event_id, seq: host.published.length } }; });
++ return host;
++}
++
++runTests([
++ // ── multi-app (Tools) ──
++ test('tools: per-app installs skip apps/_shared, preflight runs in each app, every unit restarts, the gateway serves the new sha', async () => {
++ const host = toolsHost();
++ host.addUnit('openvibe-tools-docs.service', { runningSha: host.repo.head });
++ const to = host.push({ 'apps/img/package.json': JSON.stringify({ name: 'tools-img', version: '1.0.1', dependencies: { express: '^1', 'better-sqlite3': '^1', 'openvibe-contracts': '^1', 'openvibe-sdk': '^1', sharp: '^1' } }), 'apps/_shared/guard/index.js': 'guard2();' }, 'img: sharp');
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(host.repo.head, to);
++ const npm = host.calls.filter((c) => c.cmd === 'npm');
++ assert.deepStrictEqual(npm.map((c) => c.cwd), ['/opt/openvibe.tools/apps/img'], 'only the app whose dependencies changed');
++ assert.ok(npm.every((c) => c.as === 'ubuntu'));
++ assert.ok(!host.calls.some((c) => c.cwd && c.cwd.includes('apps/_shared')), 'apps/_shared is a package, not an app: nothing runs there');
++ // Preflight: the jobs runtime in img, the guard in every app (as the service user), data/ made first.
++ const checks = host.nodeChecks().map((c) => `${path.basename(c.cwd)}:${/jobs/.test(c.args[1]) ? 'jobs' : 'guard'}:${c.as}`);
++ assert.deepStrictEqual(checks, ['img:jobs:ubuntu', 'gateway:guard:ubuntu', 'img:guard:ubuntu', 'maps:guard:ubuntu']);
++ for (const app of ['gateway', 'img', 'maps']) assert.strictEqual(host.files.get(`/opt/openvibe.tools/apps/${app}/data`).owner, 'ubuntu');
++ const firstCheck = host.calls.findIndex((c) => c.cmd === 'node');
++ assert.ok(firstCheck < host.calls.findIndex((c) => c.cmd === 'systemctl' && c.args[0] === 'restart'));
++ // Every unit, including one on the host the inventory does not list (unitsMatch), named loudly.
++ assert.deepStrictEqual(host.restarts(), ['openvibe-tools.service', 'openvibe-tools-img.service', 'openvibe-tools-maps.service', 'openvibe-tools-docs.service']);
++ assert.match(r.out, /openvibe-tools-docs\.service match openvibe-tools\*\.service but are not in the inventory's units/);
++ const rec = await lastRecord(host, 'tools');
++ assert.strictEqual(rec.result, 'deployed');
++ assert.strictEqual(rec.strategy, 'multi-app');
++ assert.deepStrictEqual(rec.preflight, { ok: true, checks: 4, syntaxChecked: 0 });
++ // Host: openvibe.tools reached both the ready URL and /release.json.
++ assert.ok(host.calls.some((c) => c.cmd === 'curl' && c.args[0] === 'http://127.0.0.1:4001/release.json'));
++ }),
++
++ test('tools: nothing new does nothing', async () => {
++ const host = toolsHost();
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /nothing to do/);
++ assert.deepStrictEqual(host.restarts(), []);
++ assert.ok(!host.calls.some((c) => c.cmd === 'npm' || c.cmd === 'node'));
++ }),
++
++ test('tools: an untracked lockfile the release tracks is removed before the merge (as the owner)', async () => {
++ const host = toolsHost();
++ host.put('/opt/openvibe.tools/apps/maps/package-lock.json', '{"generated":"by npm on the host"}', { owner: 'ubuntu' });
++ host.push({ 'apps/maps/package-lock.json': '{"v":1}' }, 'maps: track the lockfile');
++ const plan = await host.cli('plan', 'tools');
++ assert.match(plan.out, /untracked +apps\/maps\/package-lock\.json is removed before the merge/);
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 0, r.out);
++ const rm = host.calls.findIndex((c) => c.cmd === 'rm' && c.args.includes('/opt/openvibe.tools/apps/maps/package-lock.json'));
++ const merge = host.calls.findIndex((c) => c.cmd === 'git' && c.args.includes('merge'));
++ assert.ok(rm >= 0 && rm < merge, 'removed before the merge');
++ assert.strictEqual(host.calls[rm].as, 'ubuntu');
++ assert.strictEqual(host.read('/opt/openvibe.tools/apps/maps/package-lock.json'), '{"v":1}');
++ assert.deepStrictEqual((await lastRecord(host, 'tools')).removedUntracked, ['apps/maps/package-lock.json']);
++ }),
++
++ test('tools: a preflight that fails (the jobs runtime does not load) aborts before any restart and restores the checkout', async () => {
++ const host = toolsHost();
++ const from = host.repo.head;
++ host.push({ 'apps/img/server/index.js': 'img2();' }, 'img');
++ host.onNode = (args, opts) => (/jobs/.test(args[1]) && opts.cwd.endsWith('apps/img') ? { code: 1, stderr: "Error: The module 'better_sqlite3.node' was compiled against a different Node.js version" } : undefined);
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /preflight failed: jobs runtime loads failed in apps\/img: Error: The module 'better_sqlite3\.node'/);
++ assert.match(r.out, /checkout restored/);
++ assert.strictEqual(host.repo.head, from);
++ assert.deepStrictEqual(host.restarts(), []);
++ assert.strictEqual((await lastRecord(host, 'tools')).preflight.ok, false);
++ }),
++
++ test('tools: readiness requires the new sha on /release.json and every unit active; else it rolls back (exit 3)', async () => {
++ const host = toolsHost();
++ const from = host.repo.head;
++ host.push({ 'apps/gateway/server/index.js': 'gw2();' }, 'gw');
++ // A satellite that crash-loops after the restart.
++ const orig = host.onRestart;
++ host.onRestart = (unit, u) => { orig(unit, u); if (unit === 'openvibe-tools-maps.service') u.active = host.repo.head === from ? 'active' : 'activating'; };
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 3, r.out);
++ assert.match(r.out, /not active: openvibe-tools-maps\.service \(activating\)/);
++ assert.strictEqual(host.repo.head, from);
++ assert.strictEqual((await lastRecord(host, 'tools')).result, 'failed-rolled-back');
++
++ const host2 = toolsHost();
++ host2.push({ 'apps/gateway/server/index.js': 'gw2();' }, 'gw');
++ host2.gatewayRelease = '0123456789ab';
++ const r2 = await host2.cli('deploy', 'tools', '--ready-timeout', '5');
++ assert.match(r2.out, /\/release\.json names 0123456789ab/);
++ assert.notStrictEqual(r2.code, 0);
++ }),
++
++ test('tools: running jobs are reported (drain policy report); --wait-idle holds until they finish', async () => {
++ const host = toolsHost();
++ host.push({ 'apps/img/server/index.js': 'img2();' }, 'img');
++ host.jobs = 2;
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /2 running tool jobs; drain policy "report"/);
++
++ const host2 = toolsHost();
++ host2.push({ 'apps/img/server/index.js': 'img2();' }, 'img');
++ let polls = 0;
++ host2.sqliteHandler = () => { polls += 1; return [{ n: polls < 3 ? 1 : 0 }]; };
++ const w = await host2.cli('deploy', 'tools', '--wait-idle');
++ assert.strictEqual(w.code, 0, w.out);
++ assert.match(w.out, /--wait-idle: holding the restart/);
++ assert.match(w.out, /idle for 2 checks — proceeding/);
++ }),
++
++ test('tools: a freeze refuses the deploy (exit 6) and moves nothing', async () => {
++ const host = toolsHost();
++ const from = host.repo.head;
++ host.push({ 'apps/img/server/index.js': 'img2();' }, 'img');
++ host.freeze('all');
++ const r = await host.cli('deploy', 'tools');
++ assert.strictEqual(r.code, 6, r.out);
++ assert.match(r.out, /tools is frozen \(all services\)/);
++ assert.strictEqual(host.repo.head, from);
++ assert.deepStrictEqual(host.restarts(), []);
++ }),
++
++ // ── static-build (Sites) ──
++ test('sites: build output from the last build is restored first, npm ci + build, vhosts installed behind nginx -t, one notification per placeholder', async () => {
++ const host = withEvents(sitesHost());
++ // The previous deploy's build left the tracked dist/ dirty (today's date): that never blocks a deploy.
++ host.put('/opt/openvibe.sites/dist/openvibe.news/index.html', '<h1>News</h1> 2026-09-20', { owner: 'ubuntu' });
++ host.push({ 'dist/openvibe.news/index.html': '<h1>News!</h1> 2026-09-21', 'dist/openvibe.news/release.json': JSON.stringify({ service: 'news', release: 'cccccccccccc' }), 'deploy/nginx/openvibe.news.conf': 'server { listen 443; server_name openvibe.news; } # v2\n' }, 'news copy');
++ const plan = await host.cli('plan', 'sites');
++ assert.match(plan.out, /tracked changes +none \(1 build output file\(s\) restored from git first\)/);
++ assert.match(plan.out, /announce +one release notification per dist\/\*\/release\.json/);
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 0, r.out);
++ const order = host.calls.filter((c) => ['git', 'npm', 'node', 'nginx'].includes(c.cmd) || (c.cmd === 'systemctl' && c.args[0] === 'reload')).map((c) => (c.cmd === 'git' ? `git ${c.args[2]}` : c.cmd === 'systemctl' ? 'reload nginx' : `${c.cmd} ${c.args[0]}`));
++ const idx = (x) => order.indexOf(x);
++ assert.ok(idx('git checkout') < idx('git merge') && idx('git merge') < idx('npm ci') && idx('npm ci') < idx('node build.js') && idx('node build.js') < idx('nginx -t') && idx('nginx -t') < idx('reload nginx'), order.join(', '));
++ assert.match(host.read('/etc/nginx/sites-available/openvibe.news.conf'), /# v2/);
++ assert.ok(host.files.get('/etc/nginx/sites-enabled/openvibe.news.conf'), 'enabled');
++ assert.deepStrictEqual(host.restarts(), [], 'Sites has no process');
++ // One release notification per placeholder, with its own release and origin.
++ assert.deepStrictEqual(host.published.map((e) => [e.payload.service, e.payload.release, e.payload.origin]), [['news', 'cccccccccccc', 'https://openvibe.news'], ['tips', 'bbbbbbbbbbbb', 'https://openvibe.tips']]);
++ assert.ok(!r.out.includes('s3cr3t-value'));
++ // The build rewrote the tracked dist/ again; the next deploy is not blocked by it.
++ host.push({ 'sites.json': '{"sites":["openvibe.news","openvibe.tips","openvibe.vip"]}' }, 'vip');
++ const again = await host.cli('deploy', 'sites');
++ assert.strictEqual(again.code, 0, again.out);
++ }),
++
++ test('sites: nothing new does nothing', async () => {
++ const host = sitesHost();
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /nothing to do/);
++ assert.ok(!host.calls.some((c) => c.cmd === 'npm' || c.cmd === 'nginx'));
++ }),
++
++ test('sites: nginx -t failing never reloads nginx: the previous vhosts, checkout and build come back (exit 2)', async () => {
++ const host = sitesHost();
++ assert.strictEqual((await host.cli('deploy', 'sites', '--restart')).code, 0);
++ host.put('/etc/nginx/sites-available/openvibe.news.conf', 'server { listen 443; server_name openvibe.news; }\n');
++ const from = host.repo.head;
++ host.calls.length = 0;
++ host.push({ 'deploy/nginx/openvibe.news.conf': 'server { broken\n', 'dist/openvibe.news/index.html': '<h1>Broken</h1>' }, 'broken vhost');
++ host.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] unexpected end of file' });
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /nginx -t failed; the previous vhost files were restored/);
++ assert.ok(!host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'), 'nginx is never reloaded after a failed nginx -t');
++ assert.strictEqual(host.read('/etc/nginx/sites-available/openvibe.news.conf'), 'server { listen 443; server_name openvibe.news; }\n');
++ assert.strictEqual(host.repo.head, from);
++ assert.match(host.read('/opt/openvibe.sites/dist/openvibe.news/index.html'), /<h1>News<\/h1>/, 'what nginx serves is the previous release again');
++ assert.strictEqual(host.calls.filter((c) => c.cmd === 'node' && c.args[0] === 'build.js').length, 2, 'built, then rebuilt for the restored checkout');
++ }),
++
++ test('sites: a failing build restores the checkout; a vhost that left the repo is named, never removed', async () => {
++ const host = sitesHost();
++ const from = host.repo.head;
++ host.buildFails = host.push({ 'build.js': 'build2();' }, 'build');
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /build step "node build\.js" failed/);
++ assert.strictEqual(host.repo.head, from);
++
++ const host2 = sitesHost();
++ host2.put('/etc/nginx/sites-available/openvibe.tips.conf', 'server { listen 443; server_name openvibe.tips; }\n');
++ host2.push({ 'deploy/nginx/openvibe.tips.conf': null, 'dist/openvibe.tips/index.html': null, 'dist/openvibe.tips/release.json': null }, 'tips launched as its own service');
++ const d = await host2.cli('deploy', 'sites');
++ assert.strictEqual(d.code, 0, d.out);
++ assert.match(d.out, /openvibe\.tips\.conf is no longer in the repository and stays installed/);
++ assert.ok(host2.read('/etc/nginx/sites-available/openvibe.tips.conf'), 'the installed vhost is left alone');
++ }),
++
++ test('sites: a freeze refuses the deploy (exit 6)', async () => {
++ const host = sitesHost();
++ host.push({ 'build.js': 'build2();' }, 'build');
++ host.freeze('sites');
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 6, r.out);
++ assert.ok(!host.calls.some((c) => c.cmd === 'npm'));
++ }),
++
++ // ── pnpm-build (Games) ──
++ test('games: pnpm install --frozen-lockfile and pnpm build as the owner, the preflight, one restart; tracked dist-types never block', async () => {
++ const host = gamesHost();
++ host.put('/opt/openvibe.games/apps/server/dist-types/main.d.ts', 'export {}; // built by an earlier deploy\n', { owner: 'ubuntu' });
++ const to = host.push({ 'apps/server/src/main.ts': 'main2();' }, 'server change');
++ const r = await host.cli('deploy', 'games');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(host.repo.head, to);
++ const pnpm = host.calls.filter((c) => c.cmd === 'pnpm');
++ assert.deepStrictEqual(pnpm.map((c) => c.args.join(' ')), ['install --frozen-lockfile', 'build']);
++ assert.ok(pnpm.every((c) => c.as === 'ubuntu' && c.cwd === '/opt/openvibe.games'));
++ assert.ok(host.calls.filter((c) => c.cmd === 'git').every((c) => c.as === 'ubuntu'), 'git as the checkout owner, never root');
++ const check = host.nodeChecks()[0];
++ assert.strictEqual(check.cwd, '/opt/openvibe.games/apps/server');
++ assert.deepStrictEqual(host.restarts(), ['openvibe-games.service']);
++ assert.match(host.read('/opt/openvibe.games/apps/client/dist/index.html'), new RegExp(to));
++ assert.match(r.out, /every dependency resolves \(apps\/client, apps\/server, packages\/shared\)/);
++ // The build rewrote tracked dist-types: validate says so without calling it a local change.
++ const v = JSON.parse((await host.cli('validate', 'games', '--json')).out);
++ assert.ok(v.findings.some((f) => f.area === 'checkout' && f.level === 'info' && /build output/.test(f.message)));
++ assert.ok(!v.findings.some((f) => f.area === 'checkout' && f.level === 'error'), JSON.stringify(v.findings));
++ }),
++
++ test('games: nothing new does nothing', async () => {
++ const host = gamesHost();
++ const r = await host.cli('deploy', 'games');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.ok(!host.calls.some((c) => c.cmd === 'pnpm'));
++ assert.deepStrictEqual(host.restarts(), []);
++ }),
++
++ test('games: a failing preflight aborts before the restart; the checkout is restored and rebuilt', async () => {
++ const host = gamesHost();
++ const from = host.repo.head;
++ host.push({ 'apps/server/src/main.ts': 'main2();' }, 'server change');
++ host.onNode = () => ({ code: 1, stderr: 'Error: Could not locate the bindings file' });
++ const r = await host.cli('deploy', 'games');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /better-sqlite3 loads under this Node failed in apps\/server/);
++ assert.strictEqual(host.repo.head, from);
++ assert.deepStrictEqual(host.restarts(), []);
++ assert.strictEqual(host.builds, 2, 'built for the new sha, rebuilt for the restored one');
++ assert.match(host.read('/opt/openvibe.games/apps/client/dist/index.html'), new RegExp(from), 'the served client is the running release again');
++ }),
++
++ test('games: not ready after the restart rolls back: reinstall, rebuild, restart (exit 3)', async () => {
++ const host = gamesHost();
++ const from = host.repo.head;
++ host.badShas.add(host.push({ 'apps/server/src/main.ts': 'broken();' }, 'broken'));
++ const r = await host.cli('deploy', 'games');
++ assert.strictEqual(r.code, 3, r.out);
++ assert.strictEqual(host.repo.head, from);
++ assert.deepStrictEqual(host.restarts(), ['openvibe-games.service', 'openvibe-games.service']);
++ assert.deepStrictEqual(host.calls.filter((c) => c.cmd === 'pnpm').map((c) => c.args[0]), ['install', 'build', 'install', 'build']);
++ assert.match(host.read('/opt/openvibe.games/apps/client/dist/index.html'), new RegExp(from));
++ assert.strictEqual((await lastRecord(host, 'games')).result, 'failed-rolled-back');
++ }),
++
++ test('games: players online are reported (drain policy report); --wait-idle holds until nobody plays; a freeze refuses', async () => {
++ const host = gamesHost();
++ host.players = 3;
++ host.push({ 'apps/server/src/main.ts': 'main2();' }, 'server change');
++ const r = await host.cli('deploy', 'games');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /3 players online; drain policy "report": they reconnect after the restart/);
++
++ const host2 = gamesHost();
++ host2.push({ 'apps/server/src/main.ts': 'main2();' }, 'server change');
++ let polls = 0;
++ host2.http.set('http://127.0.0.1:8000/api/ready', () => { polls += 1; return { status: 200, body: { status: 'ready', checks: { sessions: { detail: { online: polls < 4 ? 2 : 0 } } } } }; });
++ const w = await host2.cli('deploy', 'games', '--wait-idle');
++ assert.strictEqual(w.code, 0, w.out);
++ assert.match(w.out, /2 players online; --wait-idle: holding the restart/);
++
++ const host3 = gamesHost();
++ host3.push({ 'apps/server/src/main.ts': 'main2();' }, 'server change');
++ host3.freeze('games');
++ assert.strictEqual((await host3.cli('deploy', 'games')).code, 6);
++ assert.ok(!host3.calls.some((c) => c.cmd === 'pnpm'));
++ }),
++
++ // ── git-checkout (Network): unit files installed on every deploy ──
++ test('network: installUnits installs a changed unit file on deploy (daemon-reload) without --install-units', async () => {
++ const host = networkHost();
++ host.push({ 'deploy/systemd/openvibe-network.service': '[Service]\nExecStart=node server/index.js\nTimeoutStopSec=20\n', 'server/index.js': 'net2();' }, 'unit');
++ const r = await host.cli('deploy', 'network');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(host.read('/etc/systemd/system/openvibe-network.service'), /TimeoutStopSec=20/);
++ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'daemon-reload'));
++ assert.deepStrictEqual((await lastRecord(host, 'network')).unitsInstalled, ['openvibe-network.service']);
++ assert.strictEqual((await lastRecord(host, 'network')).strategy, 'git-checkout');
++ }),
++
++ // ── what the wrappers probe ──
++ test('capabilities and --version: what a deploy wrapper checks before handing over to ovhost', async () => {
++ const host = gamesHost();
++ const v = await host.cli('--version');
++ assert.strictEqual(v.code, 0);
++ assert.match(v.out, /^ovhost \d+\.\d+\.\d+$/);
++ const c = await host.cli('capabilities', 'games');
++ assert.strictEqual(c.code, 0, c.out);
++ assert.match(c.out, /^deploy-api=1$/m);
++ assert.match(c.out, /^strategies=git-checkout,multi-app,static-build,pnpm-build,release-layout$/m);
++ assert.match(c.out, /^strategy=pnpm-build$/m);
++ assert.match(c.out, /^managed=yes$/m);
++ const unknown = await host.cli('capabilities', 'nope');
++ assert.strictEqual(unknown.code, 1, 'an unknown service: the wrapper falls back');
++ const json = JSON.parse((await host.cli('capabilities', 'games', '--json')).out);
++ assert.strictEqual(json.strategy, 'pnpm-build');
++ const bare = await host.cli('capabilities', '--inventory', '/nope.json');
++ assert.strictEqual(bare.code, 1);
++ host.push({ 'apps/server/src/main.ts': 'main2();' }, 'server change');
++ const prep = await host.cli('deploy', 'games', '--prepare-only');
++ assert.strictEqual(prep.code, 1, prep.out);
++ assert.match(prep.out, /--prepare-only needs a release-layout service; games is pnpm-build/);
++ }),
++]);
ddiff --git a/test/strategy-release-layout.test.js b/test/strategy-release-layout.test.js
nnew file mode 100644
iindex 0000000..c365384
---- /dev/null
++++ b/test/strategy-release-layout.test.js
@@@ -0,0 +1,406 @@
++'use strict';
++/**
++ * The release-layout strategy (lib/release-layout.js) against the fake host, as Live and OpenRe run on
++ * the real one: every rule of Live's deploy/scripts/deploy.sh (release layout) and OpenRe's
++ * deploy.sh release + api, driven through `ovhost plan|deploy|rollback`.
++ */
++const assert = require('assert');
++const path = require('path');
++const { test, runTests } = require('./helpers');
++const { liveHost, openreHost, LIVE_SOCKET, SECRET } = require('./strategy-hosts');
++const releases = require('../lib/releases');
++
++const lastRecord = async (host, id) => (await releases.list(host.exec, host.inv, id)).pop();
++const socketCalls = (host) => host.calls.filter((c) => c.cmd === 'systemctl' && String(c.args[1]).endsWith('.socket') && !['show', 'is-active', 'is-enabled', 'list-units', 'list-unit-files', 'enable'].includes(c.args[0]));
++const worktreeAdds = (host) => host.calls.filter((c) => c.cmd === 'git' && c.args.includes('worktree') && c.args.includes('add'));
++
++runTests([
++ // ── Live ──
++ test('live: a server change is prepared as a new release while the old one serves, then switched and restarted', async () => {
++ const host = await liveHost();
++ const first = host.firstRelease;
++ const to = host.push({ 'server/index.js': 'console.log(2);' }, 'server change');
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ const now = host.current();
++ assert.notStrictEqual(now, first);
++ assert.match(now, new RegExp(`^\\d{8}-\\d{6}-${to.slice(0, 8)}$`), 'release id is <time>-<sha8>');
++ assert.strictEqual(host.currentSha(), to);
++ // node_modules hard-linked from the running release (same lockfile), the data link made.
++ assert.ok(host.calls.some((c) => c.cmd === 'cp' && c.args[0] === '-al' && c.args[1].endsWith(`${first}/node_modules`)), 'cp -al of node_modules');
++ assert.ok(!host.calls.some((c) => c.cmd === 'npm'), 'no install without a dependency change');
++ assert.strictEqual(await host.exec.readlink(`/opt/openvibe.live/releases/${now}/data`), '../../shared/data');
++ assert.strictEqual(host.read(`/opt/openvibe.live/current/data/live.db`), 'sqlite', 'shared data reached through the new release');
++ // Live's syntax check: every changed .js file, in the new release, before anything restarts.
++ const check = host.nodeChecks().find((c) => c.args[0] === '--check');
++ assert.ok(check && check.args[1] === `/opt/openvibe.live/releases/${now}/server/index.js`);
++ const checkIdx = host.calls.indexOf(check);
++ const restartIdx = host.calls.findIndex((c) => c.cmd === 'systemctl' && c.args[0] === 'restart');
++ assert.ok(checkIdx < restartIdx);
++ // Only the service restarted; the socket was never stopped or restarted; git ran as root (the clone's owner).
++ assert.deepStrictEqual(host.restarts(), ['openvibe-live.service']);
++ assert.deepStrictEqual(socketCalls(host), []);
++ assert.match(r.out, /openvibe-live\.socket holds the listener — new HTTP connections queue/);
++ for (const c of host.calls.filter((x) => x.cmd === 'git')) assert.strictEqual(c.as, 'root', c.args.join(' '));
++ const rec = await lastRecord(host, 'live');
++ assert.strictEqual(rec.result, 'deployed');
++ assert.strictEqual(rec.strategy, 'release-layout');
++ assert.strictEqual(rec.fromRelease, first);
++ assert.strictEqual(rec.toRelease, now);
++ assert.strictEqual(rec.ready, true);
++ assert.strictEqual(rec.socketHeld, true);
++ assert.ok(!r.out.includes(SECRET));
++ }),
++
++ test('live: nothing new is a no-op: nothing prepared, nothing restarted, nothing logged', async () => {
++ const host = await liveHost();
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /nothing to deploy/);
++ assert.deepStrictEqual(worktreeAdds(host), []);
++ assert.deepStrictEqual(host.restarts(), []);
++ assert.deepStrictEqual(await releases.list(host.exec, host.inv, 'live'), []);
++ }),
++
++ test('live: a public/-only change switches current without a restart, even with streams live', async () => {
++ const host = await liveHost();
++ host.liveStreams = [{ is_live: 1 }];
++ host.push({ 'public/app.js': 'app2();' }, 'static');
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /switched to .* without a restart/);
++ assert.deepStrictEqual(host.restarts(), []);
++ assert.strictEqual(host.read('/opt/openvibe.live/current/public/app.js'), 'app2();');
++ const rec = await lastRecord(host, 'live');
++ assert.strictEqual(rec.result, 'deployed');
++ assert.strictEqual(rec.restarted, false);
++ assert.strictEqual(rec.switched, true);
++ }),
++
++ test('live: a static switch the site does not survive is switched back (exit 3)', async () => {
++ const host = await liveHost();
++ const first = host.firstRelease;
++ host.push({ 'public/app.js': 'app2();' }, 'static');
++ let probes = 0;
++ host.http.set('http://127.0.0.1:3000/api/ready', () => { probes += 1; return probes === 1 ? { status: 500 } : { status: 200, body: {} }; });
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 3, r.out);
++ assert.strictEqual(host.current(), first);
++ assert.strictEqual((await lastRecord(host, 'live')).result, 'failed-rolled-back');
++ }),
++
++ test('live: a lockfile change installs into the new release only; rollback returns to the old release and its own node_modules', async () => {
++ const host = await liveHost();
++ const first = host.firstRelease;
++ const firstSha = host.currentSha();
++ host.push({ 'package-lock.json': '{"lockfileVersion":3,"v":2}', 'server/index.js': 'v2();' }, 'deps');
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ const second = host.current();
++ const npm = host.calls.filter((c) => c.cmd === 'npm');
++ assert.strictEqual(npm.length, 1);
++ assert.deepStrictEqual(npm[0].args.slice(0, 2), ['ci', '--omit=dev']);
++ assert.strictEqual(npm[0].cwd, `/opt/openvibe.live/releases/${second}`);
++ assert.match(r.out, /installing dependencies in the new release \(lockfile changed\) — nothing interrupted yet/);
++ assert.strictEqual(JSON.parse(host.read(`/opt/openvibe.live/releases/${first}/node_modules/express/package.json`)).lock, '{"lockfileVersion":3,"v":1}', 'the old release keeps its own node_modules');
++
++ const rb = await host.cli('rollback', 'live');
++ assert.strictEqual(rb.code, 0, rb.out);
++ assert.match(rb.out, new RegExp(`rolling back to ${first} \\(the release ${second} replaced, from the release log\\)`));
++ assert.strictEqual(host.current(), first);
++ assert.strictEqual(host.units.get('openvibe-live.service').runningSha, firstSha);
++ assert.strictEqual(host.calls.filter((c) => c.cmd === 'npm').length, 1, 'a rollback installs nothing');
++ const rec = await lastRecord(host, 'live');
++ assert.strictEqual(rec.action, 'rollback');
++ assert.strictEqual(rec.result, 'rolled-back');
++ assert.strictEqual(rec.fromRelease, second);
++ assert.strictEqual(rec.toRelease, first);
++ // A rollback is never frozen.
++ host.freeze('live');
++ const again = await host.cli('rollback', 'live', '--to', second);
++ assert.strictEqual(again.code, 0, again.out);
++ assert.strictEqual(host.current(), second);
++ }),
++
++ test('live: a failing preflight (syntax error) removes the new release and restarts nothing (exit 2)', async () => {
++ const host = await liveHost();
++ const first = host.firstRelease;
++ host.push({ 'server/index.js': 'console.log(2;' }, 'broken syntax');
++ host.onNode = (args) => (args[0] === '--check' ? { code: 1, stderr: 'SyntaxError: missing ) after argument list' } : undefined);
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /preflight failed: syntax error in server\/index\.js: SyntaxError/);
++ assert.match(r.out, new RegExp(`${first} untouched; nothing restarted`));
++ assert.strictEqual(host.current(), first);
++ assert.deepStrictEqual(await host.releaseIds(), [first], 'the prepared release is removed again');
++ assert.deepStrictEqual(host.restarts(), []);
++ const rec = await lastRecord(host, 'live');
++ assert.strictEqual(rec.result, 'failed');
++ assert.strictEqual(rec.preflight.ok, false);
++ }),
++
++ test('live: not ready after the restart: current switches back, the old units come back, exit 3', async () => {
++ const host = await liveHost();
++ const first = host.firstRelease;
++ const firstSha = host.currentSha();
++ const bad = host.push({ 'server/index.js': 'broken();', 'deploy/systemd/release/openvibe-live.service': `${'[Service]\nWorkingDirectory=/opt/openvibe.live/current\n'}ExecStart=/usr/bin/env node --bad server/index.js\n` }, 'bad release');
++ host.badShas.add(bad);
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 3, r.out);
++ assert.strictEqual(host.current(), first);
++ assert.strictEqual(host.units.get('openvibe-live.service').runningSha, firstSha);
++ assert.deepStrictEqual(host.restarts(), ['openvibe-live.service', 'openvibe-live.service']);
++ assert.strictEqual(host.read('/etc/systemd/system/openvibe-live.service'), host.read(`/opt/openvibe.live/releases/${first}/deploy/systemd/release/openvibe-live.service`), 'the previous release\'s unit file is back');
++ assert.deepStrictEqual(socketCalls(host), []);
++ const rec = await lastRecord(host, 'live');
++ assert.strictEqual(rec.result, 'failed-rolled-back');
++ assert.strictEqual(rec.autoRollback.ready, true);
++ assert.ok((await host.releaseIds()).includes(rec.toRelease), 'the failed release is kept for inspection');
++ }),
++
++ test('live: /release.json must name the new sha (ready.release); a survivor answering is not ready', async () => {
++ const host = await liveHost();
++ host.push({ 'server/index.js': 'v2();' }, 'v2');
++ host.http.set('http://127.0.0.1:3000/release.json', () => ({ status: 200, body: { service: 'live', release: 'deadbeef' } }));
++ const r = await host.cli('deploy', 'live', '--ready-timeout', '5');
++ assert.strictEqual(r.code, 4, r.out);
++ assert.match(r.out, /\/release\.json names deadbeef/);
++ assert.match(r.out, /MANUAL INTERVENTION/);
++ }),
++
++ test('live: exit 4 when the previous release is not ready either', async () => {
++ const host = await liveHost();
++ host.badShas.add(host.currentSha());
++ host.badShas.add(host.push({ 'server/index.js': 'x();' }));
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 4, r.out);
++ assert.strictEqual((await lastRecord(host, 'live')).result, 'rollback-failed');
++ }),
++
++ test('live: systemd not holding :3000 (the socket lost its descriptor) is the one case the socket is restarted: stop, restart socket, start', async () => {
++ const host = await liveHost();
++ host.listeners.set(3000, [{ pid: 4242, process: 'node' }]);
++ host.onSocketRestart = () => host.listeners.set(3000, [{ pid: 1, process: 'systemd' }]);
++ host.push({ 'server/index.js': 'v2();' }, 'v2');
++ const plan = await host.cli('plan', 'live');
++ assert.match(plan.out, /socket +NOT held by systemd on :3000 — a restart rebinds it/);
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /openvibe-live\.socket is not holding its listener \(pid 1 does not hold :3000\) — stopping openvibe-live\.service, restarting the socket/);
++ const seq = host.calls.filter((c) => c.cmd === 'systemctl' && ['stop', 'restart', 'start'].includes(c.args[0])).map((c) => `${c.args[0]} ${c.args[1]}`);
++ assert.deepStrictEqual(seq, ['stop openvibe-live.service', 'restart openvibe-live.socket', 'start openvibe-live.service']);
++ const rec = await lastRecord(host, 'live');
++ assert.strictEqual(rec.socketRebind, 'socket-not-held');
++ assert.strictEqual(rec.socketHeld, true);
++ assert.strictEqual(host.units.get('openvibe-live.service').runningSha, host.currentSha());
++ }),
++
++ test('live: a changed socket unit file is installed and rebound; a listener still not systemd\'s afterwards is reported', async () => {
++ const host = await liveHost();
++ host.push({ 'deploy/systemd/release/openvibe-live.socket': LIVE_SOCKET.replace('Backlog=511', 'Backlog=1024') }, 'socket backlog');
++ // The rebind does not bring pid 1 back on the port (the 2026-09-25 state): said loudly.
++ host.onSocketRestart = () => host.listeners.set(3000, [{ pid: 4242, process: 'node' }]);
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(host.read('/etc/systemd/system/openvibe-live.socket'), /Backlog=1024/);
++ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'daemon-reload'));
++ assert.match(r.out, /openvibe-live\.socket changed — stopping openvibe-live\.service, restarting the socket/);
++ assert.match(r.out, /✗ socket activation is not in effect: systemd does not hold :3000/);
++ const rec = await lastRecord(host, 'live');
++ assert.strictEqual(rec.socketRebind, 'socket-changed');
++ assert.strictEqual(rec.socketHeld, false);
++ assert.deepStrictEqual(rec.unitsInstalled, ['openvibe-live.socket']);
++ }),
++
++ test('live: streams live refuse the restart (exit 5): the prepared release is removed, current never moves', async () => {
++ const host = await liveHost();
++ const first = host.firstRelease;
++ host.liveStreams = [{ is_live: 1 }, { is_live: 0 }];
++ host.push({ 'server/index.js': 'v2();' }, 'v2');
++ 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.strictEqual(host.current(), first);
++ assert.deepStrictEqual(await host.releaseIds(), [first]);
++ assert.deepStrictEqual(host.restarts(), []);
++ assert.strictEqual((await lastRecord(host, 'live')).result, 'refused');
++ }),
++
++ test('live: --wait-idle holds until two idle checks, then deploys; --force drops streams loudly', async () => {
++ const host = await liveHost();
++ const to = host.push({ 'server/index.js': 'v2();' }, 'v2');
++ let polls = 0;
++ host.http.set('http://127.0.0.1:3000/api/streams', () => { polls += 1; return { status: 200, body: { streams: polls < 4 ? [{ is_live: true }] : [] } }; });
++ const r = await host.cli('deploy', 'live', '--wait-idle');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /holding the restart \(before the switch\)/);
++ assert.match(r.out, /idle for 2 checks — proceeding/);
++ assert.strictEqual(host.currentSha(), to);
++
++ const host2 = await liveHost();
++ host2.liveStreams = [{ is_live: true }, { is_live: true }];
++ host2.push({ 'server/index.js': 'v2();' }, 'v2');
++ const f = await host2.cli('deploy', 'live', '--force');
++ assert.strictEqual(f.code, 0, f.out);
++ assert.match(f.out, /2 live streams WILL BE DROPPED/);
++ assert.strictEqual((await lastRecord(host2, 'live')).forced, true);
++ }),
++
++ test('live: a freeze refuses the deploy (exit 6) before anything is prepared; --force goes through loudly', async () => {
++ const host = await liveHost();
++ host.push({ 'server/index.js': 'v2();' }, 'v2');
++ host.freeze('live');
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 6, r.out);
++ assert.match(r.out, /live is frozen/);
++ assert.deepStrictEqual(worktreeAdds(host), []);
++ assert.deepStrictEqual(host.restarts(), []);
++ const f = await host.cli('deploy', 'live', '--force');
++ assert.strictEqual(f.code, 0, f.out);
++ assert.match(f.out, /!!! --force: deploying live through the freeze/);
++ }),
++
++ test('live: a schema change backs up the database before the switch and restart', async () => {
++ const host = await liveHost();
++ host.push({ 'server/db/schema.sql': 'CREATE TABLE b(y);' }, 'schema');
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ const b = host.calls.findIndex((c) => c.cmd === 'sqlite-backup');
++ const mv = host.calls.findIndex((c) => c.cmd === 'mv' && c.args.includes('/opt/openvibe.live/current'));
++ const restart = host.calls.findIndex((c) => c.cmd === 'systemctl' && c.args[0] === 'restart');
++ assert.ok(b >= 0 && b < mv && mv < restart, 'backup, then switch, then restart');
++ assert.strictEqual(host.sqliteCalls.find((c) => c.op === 'backup').as, 'ubuntu');
++ }),
++
++ test('live: plan shows the new release, node_modules reuse, the socket and the restart; changes nothing', async () => {
++ const host = await liveHost();
++ host.liveStreams = [{ is_live: true }];
++ host.push({ 'server/index.js': 'v2();', 'deploy/nginx/openvibe.live.conf': 'server { listen 443; } # v2\n' }, 'v2');
++ const r = await host.cli('plan', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /strategy +release-layout \(release layout, keep 3\)/);
++ assert.match(r.out, new RegExp(`release +${host.firstRelease} → \\d{8}-\\d{6}-[0-9a-f]{8} \\(new, prepared while`));
++ assert.match(r.out, /dependencies unchanged — node_modules hard-linked from/);
++ assert.match(r.out, /preflight +syntax check of changed \.js\/\.json/);
++ assert.match(r.out, /restart +yes: openvibe-live\.service \(socket openvibe-live\.socket stays up\)/);
++ assert.match(r.out, /socket +held by systemd on :3000/);
++ assert.match(r.out, /protected +1 live streams; drain policy refuse/);
++ assert.match(r.out, /the repo vhost changed and is NOT installed by a deploy/);
++ assert.deepStrictEqual(worktreeAdds(host), []);
++ assert.deepStrictEqual(host.restarts(), []);
++ }),
++
++ test('live: old releases are pruned to release.keep, never current or the one just left', async () => {
++ const host = await liveHost();
++ for (let i = 2; i <= 6; i++) {
++ host.exec.sleep(1000);
++ host.push({ 'server/index.js': `v${i}();` }, `v${i}`);
++ const r = await host.cli('deploy', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ }
++ const ids = await host.releaseIds();
++ assert.strictEqual(ids.length, 3, ids.join(' '));
++ assert.ok(ids.includes(host.current()));
++ assert.ok(host.calls.some((c) => c.cmd === 'git' && c.args.includes('remove')), 'pruned through git worktree remove');
++ }),
++
++ test('live: the entry may name the current link as its repo; the release root is its parent', async () => {
++ const { normalise } = require('../lib/inventory');
++ const inv = normalise({ services: { live: { repo: '/opt/openvibe.live/current', owner: 'root', strategy: 'release-layout', units: ['openvibe-live.service'] } } });
++ assert.strictEqual(inv.services.live.repo, '/opt/openvibe.live');
++ assert.strictEqual(inv.services.live.codeDir, '/opt/openvibe.live/current');
++ assert.strictEqual(inv.services.live.release.git, '/opt/openvibe.live/repo');
++ assert.strictEqual(inv.services.live.managed, true);
++ assert.throws(() => normalise({ services: { x: { repo: '/opt/x', owner: 'root', strategy: 'release-layout', release: { links: { data: '../../../../etc' } } } } }), /must point inside/);
++ assert.throws(() => normalise({ services: { x: { repo: '/opt/x', owner: 'root', strategy: 'tarball' } } }), /strategy must be one of/);
++ assert.throws(() => normalise({ services: { x: { repo: '/opt/x', owner: 'root', strategy: 'git-checkout', layout: 'release' } } }), /needs strategy "release-layout"/);
++ const legacy = normalise({ services: { x: { repo: '/opt/x', owner: 'root', layout: 'release' } } });
++ assert.strictEqual(legacy.services.x.managed, false, 'a release layout without the strategy stays with its own script');
++ }),
++
++ // ── OpenRe ──
++ test('openre: release <sha12> with npm ci and chown to ubuntu, API and coordinator restarted, workers never touched', async () => {
++ const host = await openreHost();
++ const first = host.firstRelease;
++ const to = host.push({ 'server/index.js': 'api2();' }, 'api change');
++ const r = await host.cli('deploy', 'openre');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(host.current(), to.slice(0, 12));
++ const npm = host.calls.filter((c) => c.cmd === 'npm');
++ assert.strictEqual(npm.length, 1, 'OpenRe installs into every release (reuseModules false)');
++ assert.strictEqual(npm[0].cwd, `/opt/openre.stream/releases/${to.slice(0, 12)}`);
++ assert.ok(host.calls.some((c) => c.cmd === 'chown' && c.args.join(' ') === `-R ubuntu:ubuntu /opt/openre.stream/releases/${to.slice(0, 12)}`));
++ assert.strictEqual(host.files.get(`/opt/openre.stream/releases/${to.slice(0, 12)}/server/index.js`).owner, 'ubuntu');
++ assert.deepStrictEqual(host.restarts(), ['openre-api.service', 'openre-session-coordinator.service']);
++ assert.ok(!host.calls.some((c) => c.cmd === 'systemctl' && /openre-(rtmp-ingest|restream-worker)@/.test(String(c.args[1])) && c.args[0] !== 'show'), 'no worker unit is started, stopped or restarted');
++ assert.ok((await host.releaseIds()).includes(first), 'the release the workers run from is kept');
++ // A second deploy prunes to keep (2), but never the release a worker instance runs from.
++ host.push({ 'server/index.js': 'api3();' }, 'api3');
++ assert.strictEqual((await host.cli('deploy', 'openre')).code, 0);
++ host.push({ 'server/index.js': 'api4();' }, 'api4');
++ assert.strictEqual((await host.cli('deploy', 'openre')).code, 0);
++ const ids = await host.releaseIds();
++ assert.ok(ids.includes(first), `the workers' release survives pruning (${ids.join(' ')})`);
++ assert.ok(!ids.includes(to.slice(0, 12)), 'an unused old release is pruned');
++ }),
++
++ test('openre: an ingest session refuses the API restart; --to <sha12> rolls back; a docs-only change needs no restart', async () => {
++ const host = await openreHost();
++ const first = host.firstRelease;
++ host.push({ 'server/index.js': 'api2();' }, 'api2');
++ host.sessions = 1;
++ const r = await host.cli('deploy', 'openre');
++ assert.strictEqual(r.code, 5, r.out);
++ assert.match(r.out, /1 ingest sessions — refusing/);
++ assert.strictEqual(host.current(), first);
++ host.sessions = 0;
++ assert.strictEqual((await host.cli('deploy', 'openre')).code, 0);
++ const n = host.restarts().length;
++ host.push({ 'docs/README.md': '# OpenRe v2' }, 'docs');
++ const d = await host.cli('deploy', 'openre');
++ assert.strictEqual(d.code, 0, d.out);
++ assert.strictEqual(host.restarts().length, n, 'no restart for docs');
++ const rb = await host.cli('rollback', 'openre', '--to', first);
++ assert.strictEqual(rb.code, 0, rb.out);
++ assert.strictEqual(host.current(), first);
++ assert.strictEqual(host.restarts().length, n + 2, 'the rollback restarts the API and the coordinator (code differs)');
++ }),
++
++ test('openre: --prepare-only makes the release and stops (deploy.sh release); deploy --to <sha12> switches to it (deploy.sh api)', async () => {
++ const host = await openreHost();
++ const first = host.firstRelease;
++ const to = host.push({ 'server/index.js': 'api2();' }, 'api2');
++ const p = await host.cli('deploy', 'openre', '--prepare-only');
++ assert.strictEqual(p.code, 0, p.out);
++ assert.match(p.out, /is prepared; .* keeps serving \(--prepare-only: nothing switched or restarted\)/);
++ assert.strictEqual(host.current(), first);
++ assert.ok((await host.releaseIds()).includes(to.slice(0, 12)));
++ assert.deepStrictEqual(host.restarts(), []);
++ const npmBefore = host.calls.filter((c) => c.cmd === 'npm').length;
++ const a = await host.cli('deploy', 'openre', '--to', to.slice(0, 12));
++ assert.strictEqual(a.code, 0, a.out);
++ assert.match(a.out, /already exists; checking it again/);
++ assert.strictEqual(host.current(), to.slice(0, 12));
++ assert.strictEqual(host.calls.filter((c) => c.cmd === 'npm').length, npmBefore, 'a prepared release is not installed twice');
++ assert.deepStrictEqual(host.restarts(), ['openre-api.service', 'openre-session-coordinator.service']);
++ assert.deepStrictEqual((await releases.list(host.exec, host.inv, 'openre')).map((r) => r.result), ['prepared', 'deployed']);
++ }),
++
++ test('openre: plan and status read the release root; capabilities say it is managed by release-layout', async () => {
++ const host = await openreHost();
++ const c = await host.cli('capabilities', 'openre');
++ assert.strictEqual(c.code, 0, c.out);
++ assert.match(c.out, /^strategy=release-layout$/m);
++ assert.match(c.out, /^managed=yes$/m);
++ const st = await host.cli('status', 'openre');
++ assert.match(st.out, new RegExp(`openre +${host.firstRelease}`));
++ assert.strictEqual(path.basename(host.resolve('/opt/openre.stream/current')), host.firstRelease);
++ // A deployed release belongs to ubuntu (release.chown): validate accepts it.
++ host.push({ 'server/index.js': 'api2();' }, 'api2');
++ assert.strictEqual((await host.cli('deploy', 'openre')).code, 0);
++ assert.strictEqual(host.files.get(host.resolve('/opt/openre.stream/current')).owner, 'ubuntu');
++ const v = JSON.parse((await host.cli('validate', 'openre', '--json')).out);
++ assert.ok(v.findings.some((f) => f.area === 'checkout' && /releases are made from \/opt\/openre\.stream\/repo/.test(f.message)), JSON.stringify(v.findings));
++ assert.deepStrictEqual(v.findings.filter((f) => f.area === 'checkout' && f.level === 'error'), []);
++ }),
++]);