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)
Changes
@@@ -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.@@@ -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 criteovhost status [<service>...] unit state, sha, readiness, protected sessionsovhost validate <service> env NAMES, unit files, port, vhost + nginx -t, depsovhost 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]nnew file mode 100644@@@ -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.@@@ -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, depsenv-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);nnew file mode 100755@@@ -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"nnew file mode 100644@@@ -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(); }++ }),++]);@@@ -92,7 +92,15 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', streturn 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', stif (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}`);
@@ -15,7 +15,7 @@ jobs:
@@ -15,7 +15,7 @@ jobs:
- run: npm ci --no-audit --no-fund
- run: npm ci --no-audit --no-fund
- run: npm test
- run: npm test
- name: syntax check
- 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
# 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;
# 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.
# until then the check reports them as undefined. Make this step blocking again with that release.
@@ -85,7 +85,7 @@ What the tests demonstrate (`npm test`, every system call made against `test/fak
@@ -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.
- `restore-download` never overwrites and refuses any directory near a database or checkout.
- No secret value appears in any output, summary, request or manifest.
- 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
## Using ovhost
@@ -93,6 +93,7 @@ Not demonstrated yet: any of this on the production host. The Wave 21 exit crite
@@ -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 status [<service>...] unit state, sha, readiness, protected sessions
ovhost validate <service> env NAMES, unit files, port, vhost + nginx -t, deps
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 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 plan <service> [--to <sha>] [--no-fetch]
ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
ovhost rollback <service> [--to <sha>] [--wait-idle] [--force]
ovhost rollback <service> [--to <sha>] [--wait-idle] [--force]
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -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.
@@ -15,6 +15,8 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -15,6 +15,8 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
status [<service>...] unit state, sha, readiness, protected sessions (all services)
status [<service>...] unit state, sha, readiness, protected sessions (all services)
validate <service> env names, unit files, port, nginx vhost + nginx -t, deps
validate <service> env names, unit files, port, nginx vhost + nginx -t, deps
env-names <service> variable NAMES declared in the checkout's .env.example
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]
plan <service> [--to <sha>] [--no-fetch]
what a deploy would do (fetches the remote; changes nothing else)
what a deploy would do (fetches the remote; changes nothing else)
deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
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
@@ -123,6 +125,15 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
}
}
return r.ok ? 0 : 2;
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': {
case 'env-names': {
const r = await lib.envNames(ctx, needService());
const r = await lib.envNames(ctx, needService());
if (json) emit(r);
if (json) emit(r);
new file mode 100755
new file mode 100755
@@ -0,0 +1,278 @@
@@ -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"
new file mode 100644
new file mode 100644
@@ -0,0 +1,263 @@
@@ -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(); }
+ }),
+]);
@@ -92,7 +92,15 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -92,7 +92,15 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
return false;
return false;
}
}
function resolveRef(repo, ref) {
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 (r === 'HEAD') return repo.head;
if (repo.remoteRefs[r]) return repo.remoteRefs[r];
if (repo.remoteRefs[r]) return repo.remoteRefs[r];
if (repo.commits.has(r)) return r;
if (repo.commits.has(r)) return r;
@@ -112,6 +120,12 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -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]})`); }
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`);
return ok(`${resolveRef(repo, rest[0])}\n`);
case 'cat-file': return resolveRef(repo, rest[1]) ? ok() : fail('bad object');
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': {
case 'status': {
const head = repo.commits.get(repo.head).files;
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}`);
const dirty = Object.entries(head).filter(([f, c]) => host.read(path.join(repo.path, f)) !== c).map(([f]) => ` M ${f}`);