openvibers/openvibe.host Commits

D41 proof artifact: scripts/d41-proof.sh deploys, rolls back and redeploys one service through ovhost and proves from systemctl that every other unit kept its process (written and tested, not run on the host)

OP OpenVibers committed 23 Sept 2026 · cd2fc63c17 · 7 files, +722 −3

- scripts/d41-proof.sh <service> (recommended: sources): preflight refuses (exit 1, nothing changed) unless root, managed, git checkout, no socket/protected probe/worker units, not on the refuse list (live media openre chat network events billing games tools host sites), ovhost validate passes, ready, clean checkout, and the rollback target (--prev, default HEAD~1) is an ancestor needing no npm install (--allow-install) and no schema backup. Then A: ovhost deploy --to S0 --restart, B: ovhost rollback --to PREV --restart, C: ovhost deploy --to S0 --restart; the service ends on the commit it started on and nothing newer is deployed.
- Evidence (/var/lib/openvibe-host/d41/<svc>-<stamp>/): systemctl show (MainPID, InvocationID, ActiveEnterTimestamp, NRestarts, ActiveState) for every inventory unit and socket, openvibe-*, openre-*, nginx and always the Live socket/service, Chat and Media, before, after each step and at the end; a new InvocationID/MainPID, the wanted sha and readiness for the target after each step; ovhost status --json before/after (every ready service still ready, protected-session counts); release records; summary.md with a row for docs/d41-proof.md. Exit 2 when anything else changed, 3 when an ovhost step failed (service back on S0), 4 when it could not be returned (manual intervention).
- ovhost show [<service>...] prints the inventory as ovhost reads it (no values); the script uses it.
- test/d41-proof.test.js runs the real script in bash against the fake host through ovhost/systemctl/runuser stubs that call back into the test (the real CLI runs against test/fake-host.js): PASS with exactly three restarts of the target and every other unit untouched, a collateral Chat restart detected (exit 2), a not-ready rollback target rolled back by ovhost (exit 3), refusals and --dry-run change nothing. The fake host's git learns <ref>^/~n and merge-base --is-ancestor.
- docs/d41-proof.md: why sources, the steps, the evidence, how to run and read it, exit codes. CI syntax-checks scripts/*.sh.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex baabac4..aee9ce3 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -15,7 +15,7 @@ jobs:
- run: npm ci --no-audit --no-fund
- run: npm test
- name: syntax check
-- run: for f in bin/ovhost lib/*.js lib/commands/*.js server/*.js server/*/*.js; do node --check "$f"; done
++ run: for f in bin/ovhost lib/*.js lib/commands/*.js server/*.js server/*/*.js; do node --check "$f"; done && for f in scripts/*.sh; do bash -n "$f"; done
# Stage B's capabilities (host.site.manage, host.deploy.create, host.domain.manage) are
# proposals in docs/capabilities-proposal/ until the lead releases them in openvibe-contracts;
# until then the check reports them as undefined. Make this step blocking again with that release.
ddiff --git a/README.md b/README.md
iindex 0ae9353..593c3b2 100644
---- a/README.md
++++ b/README.md
@@@ -85,7 +85,7 @@ What the tests demonstrate (`npm test`, every system call made against `test/fak
- `restore-download` never overwrites and refuses any directory near a database or checkout.
- No secret value appears in any output, summary, request or manifest.
--Not demonstrated yet: any of this on the production host. The Wave 21 exit criterion ("deploy, restart and roll back one service without interrupting unrelated runtimes or protected sessions") still needs a run on the host, with evidence.
++Not demonstrated yet: any of this on the production host. The Wave 21 exit criterion ("deploy, restart and roll back one service without interrupting unrelated runtimes or protected sessions") still needs a run on the host, with evidence. [`scripts/d41-proof.sh`](scripts/d41-proof.sh) is that run, written and tested against the fake host (`test/d41-proof.test.js`) but **not run yet**. It deploys, rolls back and redeploys `sources` through `ovhost`, and proves from `systemctl show` (MainPID, InvocationID, ActiveEnterTimestamp) that every other unit, the Live socket included, kept its process. See [docs/d41-proof.md](docs/d41-proof.md).
## Using ovhost
@@@ -93,6 +93,7 @@ Not demonstrated yet: any of this on the production host. The Wave 21 exit crite
ovhost status [<service>...] unit state, sha, readiness, protected sessions
ovhost validate <service> env NAMES, unit files, port, vhost + nginx -t, deps
ovhost env-names <service> names declared in the checkout's .env.example
++ovhost 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>]
ovhost rollback <service> [--to <sha>] [--wait-idle] [--force]
ddiff --git a/docs/d41-proof.md b/docs/d41-proof.md
nnew file mode 100644
iindex 0000000..9e45c10
---- /dev/null
++++ b/docs/d41-proof.md
@@@ -0,0 +1,152 @@
++# D41 proof: deploy, restart and roll back one service with ovhost, and nothing else moves
++
++Roadmap D41 and the Wave 21 exit criterion say: *deploy, restart and roll back one service without
++interrupting unrelated runtimes or protected sessions.* `ovhost deploy` and `ovhost rollback` do this
++in the tests against the fake host, but no production service has been deployed through ovhost yet.
++[`scripts/d41-proof.sh`](../scripts/d41-proof.sh) is the production artifact. It uses ovhost's own
++commands on one low-risk service and records evidence that every other unit on the host kept its
++process.
++
++**It has not been run on the production host.** The operator runs it after review (below).
++
++## Which service
++
++The service to use is **sources** (OpenVibe.Sources, loopback :4720):
++
++- nothing public points at it (`sources.openvibe.network` serves the admin placeholder);
++- all six seeds are disabled, so no fetch is in flight;
++- it has one unit, no socket, no protected sessions and no worker units;
++- its database is not touched by a code rollback of one commit, which the script checks.
++
++**search** and **codes** also qualify on paper, but they are riskier. Search is queried by other
++services, and Codes (openvibe.codes) is public.
++
++The script refuses these services, whatever the options: `live`, `media`, `openre`, `chat`,
++`network`, `events`, `billing`, `games`, `tools`, `host` and `sites`. It also refuses any service with a
++socket unit, a protected-session probe or worker units.
++
++## What it runs
++
++Let S0 be the commit sources runs now and PREV its first parent (or `--prev`). The service is
++restarted three times, and it ends on S0:
++
++| Step | Command | Proves |
++|---|---|---|
++| A | `ovhost deploy sources --to S0 --restart` | a deploy through ovhost restarts only this unit and polls readiness (the checkout does not move) |
++| B | `ovhost rollback sources --to PREV --restart` | a rollback: `git reset --hard` as the owner, restart, readiness |
++| C | `ovhost deploy sources --to S0 --restart` | a forward deploy (fast-forward PREV → S0), restart, readiness; back where it started |
++
++No commit newer than S0 is ever deployed, even if `origin/main` has moved on. Step C fetches but
++fast-forwards only to S0.
++
++Before step A the script checks the following, and **refuses (exit 1, nothing changed)** if any of
++them fails:
++
++- it runs as root;
++- `ovhost validate sources` passes;
++- sources is ready;
++- the checkout is clean and on its branch;
++- PREV is an ancestor of S0;
++- `ovhost plan sources --to PREV --no-fetch` shows no dependency install (unless `--allow-install`)
++ and no `backupOnChange` file between the two commits.
++
++## Evidence
++
++For each of these units, `systemctl show` records `MainPID`, `InvocationID`, `ActiveEnterTimestamp`,
++`ExecMainStartTimestamp`, `NRestarts`, `ActiveState` and `SubState`:
++
++- every unit and socket in the inventory except sources' own unit;
++- every `openvibe-*` and `openre-*` unit (for example OpenRe's transport worker instances);
++- `nginx.service`;
++- always, even if not loaded: `openvibe-live.socket`, `openvibe-live.service`,
++ `openvibe-chat.service` and `openvibe-media.service`;
++- anything passed with `--watch`.
++
++The records are taken before step A, after each step and at the end. The run **fails (exit 2)** as
++soon as any of these units differs, and the evidence names the unit. `InvocationID` changes on every
++start, restart included, so an identical `InvocationID` and `MainPID` means that unit was not
++restarted.
++
++For sources' own unit, each step must produce a new `InvocationID` and `MainPID`, the commit that
++was asked for, and a ready service.
++
++`ovhost status --json` is taken before and after. Every service that was ready before must still be
++ready. The protected-session counts (Live streams, Media recordings, Events SSE) are recorded before
++and after.
++
++The evidence directory is `/var/lib/openvibe-host/d41/sources-<UTC stamp>/` (root, 0700). It holds:
++
++| File | Contents |
++|---|---|
++| `summary.md` | verdict, steps table, a row for the table below |
++| `proof.log` | every line the script printed |
++| `watch.units` | the units that had to stay untouched |
++| `others-before.txt`, `others-A.txt`, `others-B.txt`, `others-C.txt`, `others-after.txt` | their `systemctl show` values; a `*.diff` when anything changed |
++| `target-0.txt` … `target-3.txt` | the sources unit before and after each step |
++| `step-A.json`, `step-B.json`, `step-C.json` | ovhost's release records; `*.log` holds its stderr |
++| `status-before.json`, `status-after.json` | `ovhost status --json` for every service |
++| `plan-rollback.json`, `validate.txt`, `inventory.json`, `releases.json` | the plan for B, validate output, the inventory as ovhost read it, the last release records |
++
++Nothing in it is secret. ovhost prints no env values, and `systemctl show` is limited to the
++properties above.
++
++If a step fails, the run stops. ovhost has usually already rolled back by itself: exit 3 is "not
++ready, rolled back and serving". Otherwise the script returns sources to S0 with
++`ovhost rollback sources --to S0 --restart`. Exit codes:
++
++| Exit | Meaning |
++|---|---|
++| 0 | PASS |
++| 1 | refused, nothing changed |
++| 2 | another unit changed, or a service stopped being ready |
++| 3 | an ovhost step failed; the service is back on S0 |
++| 4 | the service could not be returned to S0: **manual intervention**, run `sudo ovhost rollback sources --to <S0>` |
++
++## Running it (operator)
++
++```
++# 0. ovhost on the host must include scripts/d41-proof.sh and `ovhost show` (this commit or later)
++sudo git -C /usr/local/lib/openvibe-host -c safe.directory=/usr/local/lib/openvibe-host pull --ff-only
++# 1. look first: preflight and snapshots only, prints the three commands
++sudo /usr/local/lib/openvibe-host/scripts/d41-proof.sh --dry-run sources
++# 2. the proof (about a minute: three restarts, each polled until ready)
++sudo /usr/local/lib/openvibe-host/scripts/d41-proof.sh sources
++# 3. read the verdict and paste the row below
++sudo cat /var/lib/openvibe-host/d41/sources-*/summary.md
++```
++
++Useful options:
++
++| Option | Effect |
++|---|---|
++| `--prev <sha>` | choose the rollback target, if HEAD~1 of sources touches its dependencies or schema |
++| `--watch <unit>` | add a unit to the untouched set |
++| `--ready-timeout <s>` | passed to ovhost |
++| `--out <dir>` | put the evidence somewhere else |
++
++Live, Chat and Media are never restarted, so no live stream, recording or chat socket is affected. A
++reader of Sources during its three restarts would see connection errors for about a second each;
++nothing reads it in production today.
++
++## Results
++
++| Date (UTC) | Service | Releases | Other units / readiness | Evidence | Result |
++|---|---|---|---|---|---|
++
++*(no run yet)*
++
++## Tests
++
++`test/d41-proof.test.js` runs the real script in bash against the fake host. `ovhost`, `systemctl`
++and `runuser` are stubs that call back into the test process, where the real ovhost CLI runs
++against `test/fake-host.js`. The test covers:
++
++- **PASS:** exactly three restarts, all of `openvibe-sources.service`; the Live socket and every
++ other unit are untouched; the service ends on S0; the release log reads deployed, rolled-back,
++ deployed.
++- **Collateral change:** a Chat restart caused by the deploy is detected, named, and fails the run
++ (exit 2).
++- **Readiness failure:** a rollback target that never becomes ready is rolled back by ovhost itself,
++ and the proof exits 3 with sources back on S0.
++- **Refusals:** refuse-listed and protected services, an unknown `--prev`, a dependency change and an
++ unknown service all refuse without restarting anything, and `--dry-run` runs no deploy.
ddiff --git a/lib/cli.js b/lib/cli.js
iindex d278aac..37bf205 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -15,6 +15,8 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
status [<service>...] unit state, sha, readiness, protected sessions (all services)
validate <service> env names, unit files, port, nginx vhost + nginx -t, deps
env-names <service> variable NAMES declared in the checkout's .env.example
++ show [<service>...] the inventory as ovhost reads it: repo, owner, units, socket,
++ 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)
deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
@@@ -123,6 +125,15 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
}
return r.ok ? 0 : 2;
}
++ case 'show': {
++ // The inventory as ovhost reads it (defaults applied). It holds no secret values.
++ const ids = pos.slice(1);
++ const list = ids.length ? ids.map((id) => lib.inventory.service(inv, id)) : Object.values(inv.services);
++ const rows = list.map((s) => ({ id: s.id, managed: s.managed, repo: s.repo, owner: s.owner, runAs: s.runAs, branch: s.branch, units: s.units, socketUnit: s.socketUnit || null, workerUnits: s.workerUnits, port: s.port || null, ready: s.ready ? s.ready.url : null, protected: s.protected ? s.protected.kind : null, drain: s.drain ? s.drain.policy : null, layout: s.layout }));
++ if (json) { emit(ids.length === 1 ? rows[0] : rows); return 0; }
++ for (const r of rows) out(`${r.id.padEnd(10)} ${r.repo} units: ${r.units.join(' ') || '-'}${r.socketUnit ? ` socket: ${r.socketUnit}` : ''}${r.workerUnits.length ? ` workers: ${r.workerUnits.join(' ')}` : ''}${r.protected ? ` protected: ${r.protected}` : ''}${r.managed ? '' : ' [unmanaged]'}`);
++ return 0;
++ }
case 'env-names': {
const r = await lib.envNames(ctx, needService());
if (json) emit(r);
ddiff --git a/scripts/d41-proof.sh b/scripts/d41-proof.sh
nnew file mode 100755
iindex 0000000..41743f2
---- /dev/null
++++ b/scripts/d41-proof.sh
@@@ -0,0 +1,278 @@
++#!/usr/bin/env bash
++# d41-proof.sh: roadmap D41 / Wave 21 exit evidence. Deploy, restart and roll back ONE service with
++# `ovhost deploy` and `ovhost rollback`, and prove that nothing else on the host was touched.
++#
++# sudo scripts/d41-proof.sh [options] <service> (recommended service: sources)
++#
++# What it does (docs/d41-proof.md has the full procedure and how to read the evidence):
++# 0. Preflight: root, the service is managed, has no socket unit and no protected-session probe,
++# is not on the refuse list, `ovhost validate` passes, the checkout is clean, and the rollback
++# target (--prev, default: the first parent of the running commit) needs no npm install and no
++# schema backup. S0 is the commit running now; the service ENDS on S0 again, and no new code is
++# ever deployed.
++# 1. Snapshot every other unit on the host (every inventory unit, socket and worker instance,
++# openvibe-*, openre-*, nginx) with systemctl show: MainPID, InvocationID, ActiveEnterTimestamp,
++# NRestarts, ActiveState. Snapshot `ovhost status --json` (readiness, protected sessions).
++# 2. A: ovhost deploy <svc> --to S0 --restart restart in place on the running commit
++# B: ovhost rollback <svc> --to PREV --restart roll back one release
++# C: ovhost deploy <svc> --to S0 --restart deploy forward again, back where it started
++# After each step: the service's sha, a NEW InvocationID/MainPID for its units, readiness, and
++# every other unit still identical. A failing step stops the run and returns the service to S0.
++# 3. Compare everything again, write the evidence directory, print a verdict and a Markdown row.
++#
++# Options:
++# --prev <sha> rollback target (an ancestor of the running commit); default: its first parent
++# --out <dir> evidence directory (default /var/lib/openvibe-host/d41/<service>-<UTC stamp>)
++# --watch <unit> another unit that must stay untouched (repeatable)
++# --ready-timeout <s> passed to ovhost deploy/rollback
++# --allow-install allow a rollback target whose dependencies differ (npm install on B and C)
++# --dry-run preflight and snapshots only: print the three commands, change nothing
++#
++# Exit codes: 0 PASS · 1 refused (precondition; nothing was changed) · 2 FAIL (another unit changed,
++# or a service stopped being ready) · 3 an ovhost step failed (see the evidence; the service was
++# returned to S0 if possible) · 4 the service could NOT be returned to S0: MANUAL INTERVENTION.
++#
++# The system commands can be replaced for testing (test/d41-proof.test.js runs this script against
++# the fake host): OVHOST, SYSTEMCTL, RUNUSER, NODE, D41_INVENTORY, D41_REQUIRE_ROOT=0, D41_STATE_DIR.
++# Nothing here reads an env file or prints a secret: ovhost and systemctl show carry no values.
++set -euo pipefail
++umask 077
++
++OVHOST=${OVHOST:-ovhost}
++SYSTEMCTL=${SYSTEMCTL:-systemctl}
++RUNUSER=${RUNUSER:-runuser}
++NODE=${NODE:-node}
++D41_STATE_DIR=${D41_STATE_DIR:-/var/lib/openvibe-host/d41}
++# Services the proof never uses: live sessions, recordings, ingest, chat, identity, money, games,
++# the eight-unit Tools, and Host itself.
++REFUSE="live media openre chat network events billing games tools host sites"
++MUST_WATCH="openvibe-live.socket openvibe-live.service openvibe-chat.service openvibe-media.service nginx.service"
++PROPS="Id,LoadState,ActiveState,SubState,MainPID,InvocationID,ActiveEnterTimestamp,ExecMainStartTimestamp,NRestarts"
++
++SERVICE="" PREV="" OUT="" READY_TIMEOUT="" ALLOW_INSTALL=0 DRY_RUN=0
++EXTRA_WATCH=()
++usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; }
++while [ $# -gt 0 ]; do
++ case "$1" in
++ --prev) PREV=${2:?--prev needs a sha}; shift 2 ;;
++ --out) OUT=${2:?--out needs a directory}; shift 2 ;;
++ --watch) EXTRA_WATCH+=("${2:?--watch needs a unit}"); shift 2 ;;
++ --ready-timeout) READY_TIMEOUT=${2:?--ready-timeout needs seconds}; shift 2 ;;
++ --allow-install) ALLOW_INSTALL=1; shift ;;
++ --dry-run) DRY_RUN=1; shift ;;
++ -h|--help) usage; exit 0 ;;
++ -*) echo "d41-proof: unknown option $1" >&2; exit 1 ;;
++ *) [ -z "$SERVICE" ] || { echo "d41-proof: one service only" >&2; exit 1; }; SERVICE=$1; shift ;;
++ esac
++done
++[ -n "$SERVICE" ] || { usage >&2; exit 1; }
++[[ "$SERVICE" =~ ^[a-z][a-z0-9-]*$ ]] || { echo "d41-proof: \"$SERVICE\" is not a service id" >&2; exit 1; }
++[ -z "$PREV" ] || [[ "$PREV" =~ ^[0-9a-f]{7,40}$ ]] || { echo "d41-proof: --prev must be a commit sha" >&2; exit 1; }
++[ -z "$READY_TIMEOUT" ] || [[ "$READY_TIMEOUT" =~ ^[0-9]+$ ]] || { echo "d41-proof: --ready-timeout must be seconds" >&2; exit 1; }
++
++INV_ARGS=()
++[ -z "${D41_INVENTORY:-}" ] || INV_ARGS=(--inventory "$D41_INVENTORY")
++STAMP=$(date -u +%Y%m%d-%H%M%S)
++OUT=${OUT:-$D41_STATE_DIR/$SERVICE-$STAMP}
++
++say() { printf '[d41] %s\n' "$*" | tee -a "$OUT/proof.log" >&2; }
++refuse() { printf '[d41] REFUSED: %s\n' "$*" | tee -a "$OUT/proof.log" >&2; exit 1; }
++ov() { "$OVHOST" "$@" "${INV_ARGS[@]}"; }
++# jsonq <file> <js expression over `j`>: prints the expression's value (strings raw, others as JSON).
++jsonq() { "$NODE" -e 'const fs=require("fs");const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const v=(new Function("j","return ("+process.argv[2]+")"))(j);process.stdout.write(v==null?"":typeof v==="string"?v:JSON.stringify(v));' "$1" "$2"; }
++
++if [ "${D41_REQUIRE_ROOT:-1}" = 1 ] && [ "$(id -u)" != 0 ]; then echo "d41-proof: run as root (sudo): ovhost deploy needs it" >&2; exit 1; fi
++for c in "$OVHOST" "$SYSTEMCTL" "$RUNUSER" "$NODE"; do command -v "$c" >/dev/null || { echo "d41-proof: $c not found" >&2; exit 1; }; done
++[ ! -e "$OUT" ] || { echo "d41-proof: $OUT already exists" >&2; exit 1; }
++mkdir -p "$OUT"
++say "D41 proof for $SERVICE; evidence in $OUT"
++
++# ── 0. preflight ─────────────────────────────────────────────────────────────
++for r in $REFUSE; do [ "$r" != "$SERVICE" ] || refuse "$SERVICE is on the refuse list ($REFUSE): use a loopback service with no sessions, e.g. sources"; done
++ov show --json >"$OUT/inventory.json" || refuse "ovhost show failed"
++jsonq "$OUT/inventory.json" "j.find(s => s.id === '$SERVICE') || null" >"$OUT/service.json"
++[ -s "$OUT/service.json" ] || refuse "$SERVICE is not in the inventory"
++[ "$(jsonq "$OUT/service.json" 'j.managed')" = true ] || refuse "$SERVICE is not managed by ovhost"
++[ "$(jsonq "$OUT/service.json" 'j.layout')" = git ] || refuse "$SERVICE does not run from a git checkout"
++[ -z "$(jsonq "$OUT/service.json" 'j.socketUnit')" ] || refuse "$SERVICE has a socket unit"
++[ -z "$(jsonq "$OUT/service.json" 'j.protected')" ] || refuse "$SERVICE has protected sessions (a probe is declared)"
++[ -z "$(jsonq "$OUT/service.json" 'j.workerUnits.join(" ")')" ] || refuse "$SERVICE has worker units"
++REPO=$(jsonq "$OUT/service.json" 'j.repo')
++OWNER=$(jsonq "$OUT/service.json" 'j.owner')
++TARGET_UNITS=$(jsonq "$OUT/service.json" 'j.units.join(" ")')
++[ -n "$TARGET_UNITS" ] || refuse "$SERVICE has no units to restart"
++[ -n "$(jsonq "$OUT/service.json" 'j.ready')" ] || refuse "$SERVICE declares no ready URL: nothing would prove it came back"
++
++say "ovhost validate $SERVICE"
++if ! ov validate "$SERVICE" >"$OUT/validate.txt" 2>&1; then refuse "ovhost validate $SERVICE failed (see $OUT/validate.txt)"; fi
++
++ov status "$SERVICE" --json >"$OUT/target-status-0.json"
++S0=$(jsonq "$OUT/target-status-0.json" 'j[0].sha')
++[[ "$S0" =~ ^[0-9a-f]{40}$ ]] || refuse "could not read the running commit of $SERVICE"
++[ "$(jsonq "$OUT/target-status-0.json" 'j[0].ready && j[0].ready.ok')" = true ] || refuse "$SERVICE is not ready now"
++git_owner() { "$RUNUSER" -u "$OWNER" -- git -C "$REPO" "$@"; }
++if [ -z "$PREV" ]; then PREV=$(git_owner rev-parse --verify "$S0^" 2>/dev/null) || refuse "$S0 has no parent; pass --prev"; fi
++FULL=$(git_owner rev-parse --verify "$PREV^{commit}" 2>/dev/null) || refuse "--prev $PREV is not a commit in $REPO"
++PREV=$FULL
++[ "$PREV" != "$S0" ] || refuse "--prev is the running commit"
++git_owner merge-base --is-ancestor "$PREV" "$S0" || refuse "$PREV is not an ancestor of $S0: deploying S0 again would not be a fast-forward"
++
++say "ovhost plan $SERVICE --to $PREV --no-fetch"
++ov plan "$SERVICE" --to "$PREV" --no-fetch --json >"$OUT/plan-rollback.json" || refuse "ovhost plan failed"
++[ "$(jsonq "$OUT/plan-rollback.json" 'j.dirty.length')" = 0 ] || refuse "the checkout has tracked local changes"
++[ "$(jsonq "$OUT/plan-rollback.json" 'j.branch === j.expectedBranch')" = true ] || refuse "the checkout is not on its branch"
++[ "$(jsonq "$OUT/plan-rollback.json" 'j.backupNeeded')" = false ] || refuse "PREV..S0 changes schema files (backupOnChange): pick another --prev"
++if [ "$(jsonq "$OUT/plan-rollback.json" 'j.installs.some(i => i.install)')" = true ] && [ "$ALLOW_INSTALL" != 1 ]; then
++ refuse "PREV..S0 changes dependencies (npm install on B and C): pick another --prev or pass --allow-install"
++fi
++say "S0 (running) $S0; PREV (rollback target) $PREV; $(jsonq "$OUT/plan-rollback.json" 'j.changedFiles') file(s) differ"
++
++# ── 1. what must not change ──────────────────────────────────────────────────
++{
++ jsonq "$OUT/inventory.json" "j.filter(s => s.id !== '$SERVICE').flatMap(s => [...s.units, ...(s.socketUnit ? [s.socketUnit] : [])]).join('\n')"; echo
++ for pat in 'openvibe-*' 'openre-*'; do
++ "$SYSTEMCTL" list-units --all --plain --no-legend --no-pager "$pat" | awk '{ print ($1 == "●" ? $2 : $1) }'
++ done
++ printf '%s\n' $MUST_WATCH "${EXTRA_WATCH[@]}"
++} | grep -E '^[A-Za-z0-9@._:-]+\.(service|socket|timer)$' | grep -v '^ovhost-drill-' | sort -u >"$OUT/watch.all"
++: >"$OUT/watch.units"
++while read -r u; do
++ case " $TARGET_UNITS " in *" $u "*) continue ;; esac
++ echo "$u" >>"$OUT/watch.units"
++done <"$OUT/watch.all"
++say "watching $(wc -l <"$OUT/watch.units") other units; restarting only: $TARGET_UNITS"
++
++# snapshot <file> <units file>: one "unit|Key=Value|…" line per unit, sorted.
++snapshot() {
++ : >"$1"
++ while read -r u; do
++ printf '%s|%s\n' "$u" "$("$SYSTEMCTL" show "$u" --property="$PROPS" --no-pager | sort | tr '\n' '|' | sed 's/|$//')" >>"$1"
++ done <"$2"
++}
++printf '%s\n' $TARGET_UNITS >"$OUT/target.units"
++snapshot "$OUT/others-before.txt" "$OUT/watch.units"
++snapshot "$OUT/target-0.txt" "$OUT/target.units"
++ov status --json >"$OUT/status-before.json"
++
++A=(deploy "$SERVICE" --to "$S0" --restart)
++B=(rollback "$SERVICE" --to "$PREV" --restart)
++C=(deploy "$SERVICE" --to "$S0" --restart)
++if [ -n "$READY_TIMEOUT" ]; then A+=(--ready-timeout "$READY_TIMEOUT"); B+=(--ready-timeout "$READY_TIMEOUT"); C+=(--ready-timeout "$READY_TIMEOUT"); fi
++if [ "$DRY_RUN" = 1 ]; then
++ say "dry run: would run, in order:"
++ for s in A B C; do eval "cmd=(\"\${$s[@]}\")"; say " $s: ovhost ${cmd[*]}"; done
++ say "dry run: nothing was changed"
++ exit 0
++fi
++
++# ── 2. deploy, roll back, deploy ─────────────────────────────────────────────
++VERDICT=PASS
++EXIT=0
++PROBLEMS=()
++ROWS=()
++problem() { PROBLEMS+=("$1"); say "PROBLEM: $1"; }
++field() { grep "^$1|" "$2" | tr '|' '\n' | sed -n "s/^$3=//p"; }
++
++# The other units, compared with the snapshot taken before step A.
++check_others() {
++ snapshot "$OUT/others-$1.txt" "$OUT/watch.units"
++ if ! cmp -s "$OUT/others-before.txt" "$OUT/others-$1.txt"; then
++ diff "$OUT/others-before.txt" "$OUT/others-$1.txt" >"$OUT/others-$1.diff" || true
++ problem "after $1, other units changed: $(diff "$OUT/others-before.txt" "$OUT/others-$1.txt" | sed -n 's/^> \([^|]*\)|.*/\1/p' | tr '\n' ' ')(see others-$1.diff)"
++ return 1
++ fi
++}
++
++PREV_SNAP="$OUT/target-0.txt"
++N=0
++for s in A B C; do
++ N=$((N + 1))
++ eval "cmd=(\"\${$s[@]}\")"
++ want=$([ "$s" = B ] && echo "$PREV" || echo "$S0")
++ say "step $s: ovhost ${cmd[*]}"
++ set +e
++ ov "${cmd[@]}" --json >"$OUT/step-$s.json" 2>"$OUT/step-$s.log"
++ code=$?
++ set -e
++ ov status "$SERVICE" --json >"$OUT/target-status-$N.json" || true
++ snapshot "$OUT/target-$N.txt" "$OUT/target.units"
++ sha=$(jsonq "$OUT/target-status-$N.json" 'j[0].sha' 2>/dev/null || true)
++ ready=$(jsonq "$OUT/target-status-$N.json" 'j[0].ready && j[0].ready.ok' 2>/dev/null || true)
++ result=$(jsonq "$OUT/step-$s.json" 'j.result' 2>/dev/null || echo "?")
++ restarted=""
++ for u in $TARGET_UNITS; do
++ before=$(field "$u" "$PREV_SNAP" InvocationID); after=$(field "$u" "$OUT/target-$N.txt" InvocationID)
++ pid=$(field "$u" "$OUT/target-$N.txt" MainPID)
++ if [ -n "$after" ] && [ "$before" != "$after" ]; then restarted+="$u (MainPID $pid) "; else problem "step $s: $u was not restarted (InvocationID unchanged)"; fi
++ done
++ ROWS+=("| $s | \`ovhost ${cmd[*]}\` | $code | $result | ${want:0:12} | ${sha:0:12} | ${ready:-?} | $(r=${restarted% }; echo "${r:-none}") |")
++ PREV_SNAP="$OUT/target-$N.txt"
++ if [ "$code" != 0 ] || [ "$sha" != "$want" ] || [ "$ready" != true ]; then
++ problem "step $s: exit $code, result $result, sha ${sha:-?} (wanted $want), ready ${ready:-?}"
++ VERDICT=FAIL; EXIT=3
++ check_others "$s" || true
++ break
++ fi
++ check_others "$s" || { VERDICT=FAIL; EXIT=2; break; }
++done
++
++# A failed run leaves the service where it started.
++FINAL=$(jsonq "$OUT/target-status-$N.json" 'j[0].sha' 2>/dev/null || true)
++if [ "$FINAL" != "$S0" ]; then
++ say "returning $SERVICE to $S0"
++ set +e
++ ov rollback "$SERVICE" --to "$S0" --restart --json >"$OUT/restore.json" 2>"$OUT/restore.log"
++ rc=$?
++ set -e
++ ov status "$SERVICE" --json >"$OUT/target-status-restore.json" || true
++ if [ "$rc" != 0 ] || [ "$(jsonq "$OUT/target-status-restore.json" 'j[0].sha' 2>/dev/null || true)" != "$S0" ]; then
++ problem "could not return $SERVICE to $S0 (exit $rc): MANUAL INTERVENTION: sudo ovhost rollback $SERVICE --to $S0"
++ VERDICT=FAIL; EXIT=4
++ fi
++fi
++
++# ── 3. after ─────────────────────────────────────────────────────────────────
++snapshot "$OUT/others-after.txt" "$OUT/watch.units"
++if ! cmp -s "$OUT/others-before.txt" "$OUT/others-after.txt"; then
++ diff "$OUT/others-before.txt" "$OUT/others-after.txt" >"$OUT/others-after.diff" || true
++ problem "other units differ from before the run (see others-after.diff)"
++ VERDICT=FAIL; [ "$EXIT" != 0 ] || EXIT=2
++fi
++ov status --json >"$OUT/status-after.json" || true
++jsonq "$OUT/status-before.json" 'j.filter(r => r.ready && r.ready.ok).map(r => r.service).join(" ")' >"$OUT/ready-before.txt"
++STILL=$("$NODE" -e '
++const fs = require("fs");
++const before = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
++const after = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
++const lost = before.filter((r) => r.ready && r.ready.ok).filter((r) => { const a = after.find((x) => x.service === r.service); return !(a && a.ready && a.ready.ok); }).map((r) => r.service);
++process.stdout.write(lost.join(" "));' "$OUT/status-before.json" "$OUT/status-after.json" 2>/dev/null || echo "?")
++if [ -n "$STILL" ]; then problem "no longer ready after the run: $STILL"; VERDICT=FAIL; [ "$EXIT" != 0 ] || EXIT=2; fi
++SESSIONS=$("$NODE" -e '
++const fs = require("fs");
++const f = (p) => JSON.parse(fs.readFileSync(p, "utf8")).filter((r) => r.protected).map((r) => `${r.service} ${r.protected.label}=${r.protected.count == null ? "unknown" : r.protected.count}`);
++const b = f(process.argv[1]); const a = f(process.argv[2]);
++process.stdout.write(b.map((x, i) => `${x} → ${(a[i] || "?").split("=").pop()}`).join("; "));' "$OUT/status-before.json" "$OUT/status-after.json" 2>/dev/null || echo "?")
++ov releases "$SERVICE" --limit 5 --json >"$OUT/releases.json" 2>/dev/null || true
++
++WATCHED=$(wc -l <"$OUT/watch.units")
++{
++ echo "# D41 proof: $SERVICE ($STAMP UTC): $VERDICT"
++ echo
++ echo "Host $(hostname), operator ${SUDO_USER:-$(id -un)}. Running commit S0 \`$S0\`, rollback target PREV \`$PREV\`."
++ echo
++ echo "| Step | Command | Exit | Result | Wanted | Running after | Ready | Restarted (new InvocationID) |"
++ echo "|---|---|---|---|---|---|---|---|"
++ printf '%s\n' "${ROWS[@]}"
++ echo
++ echo "- Other units watched: $WATCHED (every inventory unit and socket, openvibe-*, openre-*, nginx; see watch.units). MainPID, InvocationID, ActiveEnterTimestamp, NRestarts and ActiveState: $([ -e "$OUT/others-after.diff" ] && echo "CHANGED (others-after.diff)" || echo "identical before, after each step and at the end")."
++ echo "- Services ready before and after: $(cat "$OUT/ready-before.txt")${STILL:+ (NOT ready after: $STILL)}."
++ echo "- Protected sessions (before → after): ${SESSIONS:-none declared}."
++ echo "- Release log: releases.json. Problems: ${#PROBLEMS[@]}."
++ for p in "${PROBLEMS[@]}"; do echo " - $p"; done
++ echo
++ echo "Row for docs/d41-proof.md:"
++ echo
++ echo "| $(date -u +'%Y-%m-%d %H:%M') | $SERVICE | \`${S0:0:12}\` → \`${PREV:0:12}\` → \`${S0:0:12}\` (3 restarts) | $WATCHED other units identical: $([ "$VERDICT" = PASS ] && echo yes || echo NO); readiness kept: $([ -z "$STILL" ] && echo yes || echo "NO ($STILL)") | \`$OUT\` | $VERDICT |"
++} >"$OUT/summary.md"
++cat "$OUT/summary.md"
++exit "$EXIT"
ddiff --git a/test/d41-proof.test.js b/test/d41-proof.test.js
nnew file mode 100644
iindex 0000000..09ebe62
---- /dev/null
++++ b/test/d41-proof.test.js
@@@ -0,0 +1,263 @@
++'use strict';
++/**
++ * scripts/d41-proof.sh against the fake host. The real script runs in bash; `ovhost`, `systemctl`
++ * and `runuser` are small stubs that call back into this process, where the real ovhost CLI
++ * (lib/cli.js) runs against test/fake-host.js and systemctl/git answer from the fake host's units and
++ * repositories. Nothing touches the machine except the evidence directory in a temp dir.
++ *
++ * - PASS: deploy (restart in place), rollback one release, deploy forward; only the target's unit
++ * restarts (three new InvocationIDs), every other unit and the Live socket are untouched, the
++ * service ends on the commit it started on, the release log has the three records.
++ * - Another unit changing (collateral restart) is detected: FAIL, exit 2, named in the summary.
++ * - A rollback target that is not ready: ovhost rolls back by itself (exit 3), the proof says so.
++ * - Refusals change nothing: refuse-listed and protected services, dependency changes, dry runs.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { spawn } = require('child_process');
++const { scenario, test, runTests } = require('./helpers');
++const { main } = require('../lib/cli');
++
++const SCRIPT = path.join(__dirname, '..', 'scripts', 'd41-proof.sh');
++const REPO = '/opt/openvibe.sources';
++const UNIT = 'openvibe-sources.service';
++
++function sourcesHost({ lockfileChange = false } = {}) {
++ const host = scenario();
++ const doc = JSON.parse(host.read('/etc/openvibe/host.json'));
++ doc.services.sources = { repo: REPO, units: [UNIT], port: 4720, ready: { url: 'http://127.0.0.1:4720/api/ready', timeoutSeconds: 30 }, noRestartPaths: ['docs/', '**/*.md'] };
++ doc.services.chat = { repo: '/opt/openvibe.chat', units: ['openvibe-chat.service'], port: 4400, ready: { url: 'http://127.0.0.1:4400/ready', timeoutSeconds: 30 } };
++ host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
++
++ const repo = host.createRepo(REPO, { owner: 'ubuntu' });
++ const pkg = JSON.stringify({ name: 'openvibe-sources', version: '1.0.0', dependencies: { express: '^4' } });
++ const prev = repo.commit({ 'package.json': pkg, 'package-lock.json': '{"v":1}', 'server/index.js': 'sources(1);' }, { message: 'fetch schedule' });
++ repo.publish(prev);
++ const s0 = repo.commit({ 'server/index.js': 'sources(2);', ...(lockfileChange ? { 'package-lock.json': '{"v":2}' } : {}) }, { message: 'parse feeds' });
++ repo.publish(s0);
++ repo.checkout(s0);
++ host.put(`${REPO}/node_modules/express/package.json`, JSON.stringify({ name: 'express' }), { owner: 'ubuntu' });
++ host.addUnit(UNIT, { runningSha: s0 });
++ host.unitRepo[UNIT] = repo;
++ host.listeners.set(4720, [{ pid: host.units.get(UNIT).mainPid, process: 'node' }]);
++ const chat = host.createRepo('/opt/openvibe.chat', { owner: 'ubuntu' });
++ const c = chat.commit({ 'package.json': '{"name":"chat"}', 'server/index.js': 'chat();' });
++ chat.publish(c);
++ chat.checkout(c);
++ host.addUnit('openvibe-chat.service', { runningSha: c });
++ host.addUnit('nginx.service');
++
++ // systemd bookkeeping the proof reads: a restart is a new process and a new invocation.
++ for (const [name, u] of host.units) { u.invocationId = crypto.randomBytes(16).toString('hex'); u.activeEnter = `Tue 2026-09-22 12:00:${String(host.units.size).padStart(2, '0')} UTC`; u.name = name; }
++ const base = host.onRestart;
++ host.onRestart = (unit, u) => {
++ base(unit, u);
++ u.mainPid = host.nextPid++;
++ u.invocationId = crypto.randomBytes(16).toString('hex');
++ u.activeEnter = new Date(host.exec.now()).toUTCString();
++ if (host.collateral && host.collateral[unit]) {
++ const other = host.units.get(host.collateral[unit]);
++ other.mainPid = host.nextPid++;
++ other.invocationId = crypto.randomBytes(16).toString('hex');
++ }
++ };
++ const ready = (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' } };
++ };
++ host.http.set('http://127.0.0.1:4720/api/ready', ready(UNIT));
++ host.http.set('http://127.0.0.1:4400/ready', ready('openvibe-chat.service'));
++ host.prev = prev;
++ host.s0 = s0;
++ return host;
++}
++
++/** systemctl as the proof calls it, answered from the fake host. */
++function systemctl(host, argv) {
++ const ok = (stdout) => ({ code: 0, stdout });
++ if (argv[0] === 'list-units') {
++ const glob = argv[argv.length - 1];
++ const re = new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
++ return ok([...host.units.entries()].filter(([n]) => re.test(n)).map(([n, u]) => `${u.active === 'failed' ? '● ' : ''}${n} ${u.load} ${u.active} ${u.sub} ${n}\n`).join(''));
++ }
++ if (argv[0] === 'show') {
++ const unit = argv[1];
++ const props = (argv.find((a) => a.startsWith('--property=')) || '').slice(11).split(',');
++ const u = host.units.get(unit);
++ const v = u ? {
++ Id: unit, LoadState: u.load, ActiveState: u.active, SubState: u.sub, MainPID: u.active === 'active' ? u.mainPid : 0,
++ InvocationID: u.invocationId || '', ActiveEnterTimestamp: u.activeEnter || '', ExecMainStartTimestamp: u.activeEnter || '', NRestarts: 0,
++ } : { Id: unit, LoadState: 'not-found', ActiveState: 'inactive', SubState: 'dead', MainPID: 0, InvocationID: '', ActiveEnterTimestamp: '', ExecMainStartTimestamp: '', NRestarts: 0 };
++ return ok(props.map((p) => `${p}=${v[p]}\n`).join(''));
++ }
++ return { code: 1, stdout: '', stderr: `stub systemctl: ${argv[0]}` };
++}
++
++/** runuser -u <owner> -- git -C <repo> …, answered by the fake host's git as that owner. */
++async function runuser(host, argv) {
++ const [flag, owner, dashdash, cmd, ...rest] = argv;
++ if (flag !== '-u' || dashdash !== '--' || cmd !== 'git') return { code: 1, stdout: '', stderr: 'stub runuser: git only' };
++ const r = await host.exec.run('git', rest, { as: owner });
++ return { code: r.code, stdout: r.stdout, stderr: r.stderr };
++}
++
++async function withRpc(host, fn) {
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', async () => {
++ const { tool, argv } = JSON.parse(Buffer.concat(chunks).toString());
++ let r;
++ try {
++ if (tool === 'ovhost') {
++ const lines = [];
++ const code = await main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {} });
++ host.ovhostCalls.push(argv.join(' '));
++ r = { code, stdout: lines.length ? `${lines.join('\n')}\n` : '' };
++ } else if (tool === 'systemctl') r = systemctl(host, argv);
++ else if (tool === 'runuser') r = await runuser(host, argv);
++ else r = { code: 127, stdout: '', stderr: `no stub for ${tool}` };
++ } catch (err) { r = { code: 2, stdout: '', stderr: err.stack }; }
++ res.end(JSON.stringify(r));
++ });
++ });
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++ host.ovhostCalls = [];
++ try { return await fn(`http://127.0.0.1:${server.address().port}`); } finally { server.close(); }
++}
++
++const STUB = `#!/usr/bin/env node
++const http = require('http');
++const tool = require('path').basename(process.argv[1]);
++const body = JSON.stringify({ tool, argv: process.argv.slice(2) });
++const req = http.request(process.env.D41_RPC, { method: 'POST' }, (res) => {
++ const c = []; res.on('data', (d) => c.push(d));
++ res.on('end', () => { const r = JSON.parse(Buffer.concat(c).toString()); process.stdout.write(r.stdout || ''); process.stderr.write(r.stderr || ''); process.exitCode = r.code; });
++});
++req.end(body);
++`;
++
++async function runProof(host, args) {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-d41-'));
++ const bin = path.join(dir, 'bin');
++ fs.mkdirSync(bin);
++ for (const t of ['ovhost', 'systemctl', 'runuser']) { fs.writeFileSync(path.join(bin, t), STUB); fs.chmodSync(path.join(bin, t), 0o755); }
++ fs.symlinkSync(process.execPath, path.join(bin, 'node'));
++ const state = path.join(dir, 'd41');
++ return withRpc(host, (rpc) => new Promise((resolve) => {
++ const child = spawn('bash', [SCRIPT, ...args], {
++ env: {
++ PATH: `${bin}:${process.env.PATH}`, HOME: dir, D41_RPC: rpc, D41_REQUIRE_ROOT: '0', D41_STATE_DIR: state,
++ OVHOST: path.join(bin, 'ovhost'), SYSTEMCTL: path.join(bin, 'systemctl'), RUNUSER: path.join(bin, 'runuser'), NODE: path.join(bin, 'node'),
++ },
++ });
++ let out = '';
++ child.stdout.on('data', (d) => { out += d; });
++ child.stderr.on('data', (d) => { out += d; });
++ child.on('close', (code) => {
++ const runs = fs.existsSync(state) ? fs.readdirSync(state) : [];
++ const evidence = runs.length ? path.join(state, runs[0]) : null;
++ const read = (f) => (evidence && fs.existsSync(path.join(evidence, f)) ? fs.readFileSync(path.join(evidence, f), 'utf8') : null);
++ resolve({ code, out, evidence, read, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) });
++ });
++ }));
++}
++
++runTests([
++ test('PASS: deploy in place, roll back one release, deploy forward; nothing else touched; ends where it started', async () => {
++ const host = sourcesHost();
++ const pids = Object.fromEntries([...host.units].map(([n, u]) => [n, [u.mainPid, u.invocationId]]));
++ const r = await runProof(host, ['sources']);
++ try {
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /# D41 proof: sources \(.*\): PASS/);
++ // Exactly three restarts, all of the target unit; the Live socket never touched.
++ assert.deepStrictEqual(host.restarts(), [UNIT, UNIT, UNIT]);
++ assert.deepStrictEqual(host.socketViolations(), []);
++ for (const [n, [pid, inv]] of Object.entries(pids)) {
++ if (n === UNIT) continue;
++ assert.deepStrictEqual([host.units.get(n).mainPid, host.units.get(n).invocationId], [pid, inv], `${n} untouched`);
++ }
++ assert.notStrictEqual(host.units.get(UNIT).mainPid, pids[UNIT][0]);
++ // The ovhost commands, in order.
++ const steps = host.ovhostCalls.filter((c) => /^(deploy|rollback) /.test(c));
++ assert.deepStrictEqual(steps, [
++ `deploy sources --to ${host.s0} --restart --json`,
++ `rollback sources --to ${host.prev} --restart --json`,
++ `deploy sources --to ${host.s0} --restart --json`,
++ ]);
++ assert.strictEqual(host.units.get(UNIT).runningSha, host.s0, 'running S0 again');
++ // Evidence.
++ const summary = r.read('summary.md');
++ if (process.env.D41_SHOW) console.log(r.out);
++ assert.match(summary, /\| A \| `ovhost deploy sources --to [0-9a-f]{40} --restart` \| 0 \| deployed \|/);
++ assert.match(summary, /\| B \| `ovhost rollback sources --to [0-9a-f]{40} --restart` \| 0 \| rolled-back \|/);
++ assert.match(summary, /\| C \| `ovhost deploy sources --to [0-9a-f]{40} --restart` \| 0 \| deployed \|/);
++ assert.match(summary, /openvibe-sources\.service \(MainPID \d+\)/);
++ assert.match(summary, /identical before, after each step and at the end/);
++ const watched = r.read('watch.units').trim().split('\n');
++ for (const u of ['openvibe-live.socket', 'openvibe-live.service', 'openvibe-chat.service', 'openvibe-media.service', 'nginx.service', 'openvibe-events.service']) assert.ok(watched.includes(u), `${u} watched`);
++ assert.ok(!watched.includes(UNIT));
++ const releases = JSON.parse(r.read('releases.json'));
++ assert.deepStrictEqual(releases.map((x) => [x.action, x.result]), [['deploy', 'deployed'], ['rollback', 'rolled-back'], ['deploy', 'deployed']]);
++ assert.match(summary, /\| sources \| `[0-9a-f]{12}` → `[0-9a-f]{12}` → `[0-9a-f]{12}` \(3 restarts\) \| \d+ other units identical: yes; readiness kept: yes \|/);
++ } finally { r.cleanup(); }
++ }),
++
++ test('FAIL: a unit that changes as a side effect (here Chat, restarted along with the target) is detected and named', async () => {
++ const host = sourcesHost();
++ host.collateral = { [UNIT]: 'openvibe-chat.service' };
++ const r = await runProof(host, ['sources']);
++ try {
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /after A, other units changed: openvibe-chat\.service/);
++ assert.match(r.read('summary.md'), /: FAIL/);
++ assert.deepStrictEqual(host.restarts(), [UNIT], 'stops after the first step that touched something else');
++ assert.strictEqual(host.units.get(UNIT).runningSha, host.s0);
++ } finally { r.cleanup(); }
++ }),
++
++ test('a rollback target that is not ready: ovhost rolls it back by itself (exit 3) and the proof reports it', async () => {
++ const host = sourcesHost();
++ host.badShas.add(host.prev);
++ const r = await runProof(host, ['sources', '--ready-timeout', '5']);
++ try {
++ assert.strictEqual(r.code, 3, r.out);
++ assert.match(r.out, /step B: exit 3, result failed-rolled-back/);
++ assert.strictEqual(host.units.get(UNIT).runningSha, host.s0, 'back on S0 and serving');
++ assert.ok(!host.ovhostCalls.some((c) => c.startsWith(`deploy sources --to ${host.s0} --restart --json`) && host.ovhostCalls.indexOf(c) > 3), 'step C never ran');
++ } finally { r.cleanup(); }
++ }),
++
++ test('refusals change nothing: refuse-listed, protected or socket services, dependency changes; --dry-run only looks', async () => {
++ for (const [args, re, opts] of [
++ [['live'], /live is on the refuse list/],
++ [['events'], /events is on the refuse list/],
++ [['sources', '--prev', 'deadbeef'], /--prev deadbeef is not a commit/],
++ [['sources'], /changes dependencies \(npm install on B and C\)/, { lockfileChange: true }],
++ [['nope'], /nope is not in the inventory/],
++ ]) {
++ const host = sourcesHost(opts);
++ const r = await runProof(host, args);
++ try {
++ assert.strictEqual(r.code, 1, `${args.join(' ')}: ${r.out}`);
++ assert.match(r.out, re);
++ assert.deepStrictEqual(host.restarts(), []);
++ } finally { r.cleanup(); }
++ }
++ const host = sourcesHost();
++ const dry = await runProof(host, ['sources', '--dry-run']);
++ try {
++ assert.strictEqual(dry.code, 0, dry.out);
++ assert.match(dry.out, /A: ovhost deploy sources --to [0-9a-f]{40} --restart/);
++ assert.match(dry.out, /B: ovhost rollback sources --to [0-9a-f]{40} --restart/);
++ assert.deepStrictEqual(host.restarts(), []);
++ assert.ok(!host.ovhostCalls.some((c) => /^(deploy|rollback) /.test(c)));
++ } finally { dry.cleanup(); }
++ }),
++]);
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 12fd927..6a03212 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -92,7 +92,15 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
return false;
}
function resolveRef(repo, ref) {
-- const r = ref.replace(/\^\{commit\}$/, '');
++ const r0 = ref.replace(/\^\{commit\}$/, '');
++ // <ref>^ and <ref>~<n>: first parents.
++ const up = /^(.*?)(\^+|~(\d+))$/.exec(r0);
++ if (up && up[1]) {
++ let sha = resolveRef(repo, up[1]);
++ for (let n = up[3] ? Number(up[3]) : up[2].length; sha && n > 0; n--) sha = repo.commits.get(sha).parent || null;
++ return sha;
++ }
++ const r = r0;
if (r === 'HEAD') return repo.head;
if (repo.remoteRefs[r]) return repo.remoteRefs[r];
if (repo.commits.has(r)) return r;
@@@ -112,6 +120,12 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
if (rest[0] === '--verify') { const s = resolveRef(repo, rest[1]); return s ? ok(`${s}\n`) : fail(`fatal: Needed a single revision (${rest[1]})`); }
return ok(`${resolveRef(repo, rest[0])}\n`);
case 'cat-file': return resolveRef(repo, rest[1]) ? ok() : fail('bad object');
++ case 'merge-base': {
++ if (rest[0] !== '--is-ancestor') return fail('fake git: merge-base --is-ancestor only');
++ const a = resolveRef(repo, rest[1]);
++ const b = resolveRef(repo, rest[2]);
++ return a && b && isAncestor(repo, a, b) ? ok() : fail('');
++ }
case 'status': {
const head = repo.commits.get(repo.head).files;
const dirty = Object.entries(head).filter(([f, c]) => host.read(path.join(repo.path, f)) !== c).map(([f]) => ` M ${f}`);