openvibers/openvibe.host Commits

Require root for ovhost data and stop unitsMatch units on switch (#8)

OP OpenVibers committed 2 Oct 2026 · 2c1fcd6e3b · 8 files, +362 −3

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Host/pull/8

Changes

ddiff --git a/README.md b/README.md
iindex 40cc4e4..2954313 100644
---- a/README.md
++++ b/README.md
@@@ -36,7 +36,7 @@ Stage A adds a few safety rules of its own:
- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, scheduled backups (online SQLite copies, pgBackRest verification and logical PostgreSQL dumps) with retention and encrypted off-host copies, restore drills.
- Stage B: tenant projects (keyed by project id), static sites, immutable content-addressed deploy artifacts, activation and rollback, default and custom domains (DNS TXT verification), per-project quotas, upload/validation logs, the tenant vhosts (`ovhost nginx tenants`).
- Later (Stage C): sandbox profiles, budgets, secret references, outbound policy.
--- Host roles, provisioned from code so any machine can take them: the **data role** (`roles/data/`, ADR-035): PostgreSQL 18, PgBouncer, pgBackRest, Valkey and their exporters, plus `add-service.sh` giving each service its database, roles and Valkey user.
++- Host roles, provisioned from code so any machine can take them: the **data role** (`roles/data/`, ADR-035): PostgreSQL 18, PgBouncer, pgBackRest, Valkey and their exporters, plus `add-service.sh` giving each service its database, roles and Valkey user. `ovhost data provision <svc>` and `ovhost data switch <svc> [--sqlite <file>]` run `add-service.sh` and `switch-service.sh` from the installed CLI, redacting every URL and password: [docs/data-provisioning.md](docs/data-provisioning.md).
## Does not own
@@@ -120,6 +120,8 @@ ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--inst
ovhost rollback <service> [--to <sha|release id>] [--wait-idle] [--force] [--no-announce]
ovhost capabilities [<service>] key=value lines the deploy wrappers probe (deploy-api, strategy, managed)
ovhost self-update [--dry-run] update the CLI install (/usr/local/lib/openvibe-host) itself: fetch origin main, --ff-only merge, npm ci
++ovhost data provision <service> [--dry-run] run roles/data/add-service.sh from the install: PostgreSQL database, roles and Valkey user (names only)
++ovhost data switch <service> [--sqlite <file>] [--dry-run] run roles/data/switch-service.sh: stop, back up, import, deploy (docs/data-provisioning.md)
ovhost --version
ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notification
ovhost releases <service> [--limit <n>]
ddiff --git a/docs/data-provisioning.md b/docs/data-provisioning.md
nnew file mode 100644
iindex 0000000..94d3750
---- /dev/null
++++ b/docs/data-provisioning.md
@@@ -0,0 +1,58 @@
++# Provisioning a service's PostgreSQL database (`ovhost data`)
++
++The data role ([roles/data/README.md](../roles/data/README.md), ADR-035) is configured from code:
++`add-service.sh` gives one service its database, roles and Valkey user, and `switch-service.sh` moves a
++service from SQLite to PostgreSQL. The agent pipeline may run only `ovhost` through its broker, so both
++scripts are reachable from the CLI:
++
++```
++sudo ovhost data provision <service> [--dry-run]
++sudo ovhost data switch <service> [--sqlite <file>] [--dry-run]
++```
++
++Both run the script **from the ovhost install** (`<install>/roles/data/`), exactly as
++`sudo ovhost self-update` maintains it, and **pass the script's own exit code through** (a `switch` that
++gives up on protected sessions exits the same code the script did). `--dry-run` prints what would run and
++changes nothing. A service the inventory does not list is refused before anything runs. Both must run as
++root (`sudo ovhost data …`; `--dry-run` need not): the scripts need it, and an inner `sudo` would reset
++`SWITCH_UNITS`/`SWITCH_DIR` and switch the default unit and directory instead.
++
++- **`data provision`** runs `roles/data/add-service.sh <service>` with no arguments of its own. It is
++ idempotent: passwords are generated once into `/etc/openvibe/data.env`, the roles, database and Valkey
++ user are created if missing, and the four connection settings are replaced in place in the service's
++ env file. The script prints **names only** — `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL`,
++ `VALKEY_PREFIX` — never a value.
++- **`data switch`** runs `roles/data/switch-service.sh <service> [<file>]`. It takes `SWITCH_UNITS` and
++ `SWITCH_DIR` from the inventory entry (`units` plus `workerUnits`, a template worker shown as a glob
++ such as `openre-rtmp-ingest@*.service`, plus the `unitsMatch` glob when set; `repo`), so a release-layout service such as OpenRe needs no
++ hand-set environment. `--sqlite <file>` names the SQLite file when it is not
++ `/var/lib/openvibe-<service>/<service>.db` (Host: `/var/lib/openvibe-host-api/host.db`). Before running
++ it, the service's postgres branch must be merged with green CI and its auto-deploy frozen
++ (`sudo ovhost freeze <service> --reason …`); the script unfreezes and deploys at the end. Its output
++ is collected and printed when the script exits, not streamed: while it waits (up to 12 hours) for
++ protected sessions to go idle, no `[switch] waiting…` line appears, so give the caller (the broker) a
++ timeout longer than that wait, or switch when the service is idle.
++
++**`switch` needs the service's `scripts/migrate-to-postgres.js`.** The script clones `main`, installs it
++and runs that file (`openvibe-sdk` `runSqliteMigration`: migrate, import, verify counts and checksums)
++with the unit's own environment. A service without it cannot be switched this way.
++
++**No URL or password appears in the output.** `add-service.sh` prints names only, and every line either
++command writes out (stdout, stderr, `--json`, and the `--dry-run` command line) passes through a redact
++pass: a `postgres://`, `postgresql://`, `redis://` or `valkey://` URL becomes `<redacted-url>` and a
++`…PASSWORD=value` becomes `…PASSWORD=<redacted>`. This is asserted by `test/data-provision.test.js`.
++
++## Precedent: the 2026-10-02 manual runs
++
++On 2026-10-02 Tools, Chat and Network were provisioned and switched **by hand** with exactly these
++scripts, before the CLI wrappers existed:
++
++| Service | Switch |
++|---|---|
++| Tools | `scripts/migrate-to-postgres.js` over `apps/*/data` |
++| Chat | `scripts/import-sqlite-to-pg.js` |
++| Network | its T2 runbook |
++
++Those runs are the reason `data provision` takes no inventory-specific flags and `data switch` reads the
++units and directory from the inventory: the scripts already encode the per-service rules, and the CLI only
++supplies what the entry knows.
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 8bfe763..63e98ba 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -48,6 +48,15 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
from its own real path): fetch origin main, merge --ff-only,
npm ci --omit=dev; refuses a dirty tree, a branch other than
main, or a non-fast-forward; --dry-run prints the plan (root)
++ data provision <service> [--dry-run] run roles/data/add-service.sh from the ovhost install: the
++ service's PostgreSQL database, roles and Valkey user
++ (idempotent; prints names only, never values)
++ data switch <service> [--sqlite <file>] [--dry-run]
++ run roles/data/switch-service.sh from the ovhost install: stop,
++ back up the SQLite file, import and verify, deploy; SWITCH_UNITS/
++ SWITCH_DIR come from the inventory, and the script's exit code is
++ passed through. Needs the service's scripts/migrate-to-postgres.js
++ (docs/data-provisioning.md)
version | --version ovhost's version
announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run]
publish host.release.published to OpenVibe.Events (public), so open
@@@ -130,7 +139,7 @@ self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirty
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
`;
--const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);
++const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail', 'sqlite']);
function parseArgs(argv) {
const pos = [];
@@@ -225,6 +234,22 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
else if (r.updated) log(`updated ${r.installDir} (${r.lockfileRestored ? 'lockfile restored; ' : ''}nothing restarted)`);
return 0;
}
++ case 'data': {
++ // The data role's own scripts, run from ovhost's install (ADR-035): the agent pipeline
++ // reaches ovhost, not the shell. Output is redacted and the script's exit code passes through.
++ const sub = pos[1];
++ if (sub !== 'provision' && sub !== 'switch') throw new UsageError('usage: ovhost data provision <service> [--dry-run] | ovhost data switch <service> [--sqlite <file>] [--dry-run]');
++ const id = pos[2];
++ if (!id) throw new UsageError(`data ${sub} needs a <service>`);
++ const o = { selfPath: selfPath || process.argv[1], dryRun: !!opts['dry-run'], sqlite: opts.sqlite };
++ const r = sub === 'provision' ? await lib.data.provision(ctx, id, o) : await lib.data.switch(ctx, id, o);
++ if (json) { emit(r); return r.code === null ? 0 : r.code; }
++ if (r.dryRun) { out(`would run: ${r.command}`); return 0; }
++ for (const l of String(r.stdout).split('\n')) if (l) out(l);
++ for (const l of String(r.stderr).split('\n')) if (l) out(l);
++ if (!r.ok) out(`[ovhost] data ${r.action} ${r.service} failed (exit ${r.code})`);
++ return r.code;
++ }
case 'validate': {
const r = await lib.validate(ctx, needService(), { manifestFile: opts.manifest });
if (json) emit(r);
ddiff --git a/lib/commands/data.js b/lib/commands/data.js
nnew file mode 100644
iindex 0000000..5a172cd
---- /dev/null
++++ b/lib/commands/data.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * ovhost data provision | switch — run the data role's own scripts from the ovhost install
++ * (roles/data/add-service.sh, roles/data/switch-service.sh; ADR-035). The agent pipeline may run
++ * only ovhost through its broker, so giving a service its PostgreSQL database and switching it
++ * over from SQLite has to go through here too.
++ *
++ * ovhost data provision <service> -> roles/data/add-service.sh <service>
++ * ovhost data switch <service> [--sqlite <file>] -> roles/data/switch-service.sh <service> [<file>],
++ * with SWITCH_UNITS/SWITCH_DIR from the inventory
++ *
++ * add-service.sh prints names only. Whatever the scripts say, their output passes through redact():
++ * a connection URL (postgres://, redis://, valkey://) or a NAME=value whose name carries PASSWORD
++ * never reaches a terminal, a log or --json. The scripts' own exit code is passed through.
++ */
++const path = require('path');
++const { service: inventoryService } = require('../inventory');
++const { OpError, EXIT } = require('../release-ops');
++const { installDirFor } = require('./self-update');
++
++const URL_RE = /\b(?:postgres(?:ql)?|rediss?|valkey):\/\/[^\s"'`]+/gi;
++const PASSWORD_RE = /\b([A-Za-z0-9_]*PASSWORD[A-Za-z0-9_]*=)\S+/gi;
++
++function redact(text) {
++ return String(text == null ? '' : text)
++ .replace(URL_RE, '<redacted-url>')
++ .replace(PASSWORD_RE, '$1<redacted>');
++}
++
++/** <install>/roles/data: from ovhost's own path, or from this file when none is given. */
++function dataDirFor(selfPath) {
++ const dir = selfPath ? installDirFor(selfPath).dir : path.join(__dirname, '..', '..');
++ return path.join(dir, 'roles', 'data');
++}
++
++/** A template worker unit is stopped as a glob: [email protected] -> openre-rtmp-ingest@*.service. */
++function unitPattern(unit) {
++ return unit.endsWith('@.service') ? `${unit.slice(0, -'.service'.length)}*.service` : unit;
++}
++
++/** The SWITCH_UNITS/SWITCH_DIR the script would otherwise default to (roles/data/switch-service.sh). */
++function switchEnv(svc) {
++ const env = {};
++ const units = [...svc.units, ...svc.workerUnits].map(unitPattern);
++ // Deploys restart every unit matching unitsMatch (release-ops), so the switch stops them too.
++ if (svc.unitsMatch && !units.includes(svc.unitsMatch)) units.push(svc.unitsMatch);
++ if (units.length) env.SWITCH_UNITS = units.join(' ');
++ env.SWITCH_DIR = svc.repo;
++ return env;
++}
++
++function printable(script, args, env) {
++ const assigns = Object.entries(env || {}).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ');
++ return redact(`${assigns ? `${assigns} ` : ''}${script}${args.map((a) => ` ${JSON.stringify(a)}`).join('')}`);
++}
++
++async function locate(exec, selfPath, script, action, id) {
++ const p = path.join(dataDirFor(selfPath), script);
++ const st = await exec.stat(p);
++ if (!st || !st.isFile) {
++ throw new OpError(`${p} is not in the ovhost install — cannot ${action} ${id}; run this from the installed ovhost (sudo ovhost self-update updates it)`, EXIT.USAGE);
++ }
++ return p;
++}
++
++async function run(ctx, svc, { action, script, args, env = {}, dryRun = false }) {
++ const result = { service: svc.id, action, script, args, env, dryRun };
++ if (dryRun) return { ...result, command: printable(script, args, env), code: null, ok: true, stdout: '', stderr: '' };
++ // Both scripts need root (add-service.sh exits otherwise; switch uses systemctl, setpriv, chown),
++ // and sudo's env_reset would drop SWITCH_UNITS/SWITCH_DIR, silently switching the wrong units/dir.
++ if (!(await ctx.exec.isRoot())) throw new OpError(`data ${action} must run as root (sudo ovhost data ${action} ${svc.id})`, EXIT.USAGE);
++ const r = await ctx.exec.run(script, args, { privileged: true, env });
++ return { ...result, code: r.code, ok: r.code === 0, stdout: redact(r.stdout), stderr: redact(r.stderr) };
++}
++
++async function provision(ctx, id, { selfPath, dryRun = false } = {}) {
++ const svc = inventoryService(ctx.inv, id);
++ const script = await locate(ctx.exec, selfPath, 'add-service.sh', 'provision', svc.id);
++ return run(ctx, svc, { action: 'provision', script, args: [svc.id], dryRun });
++}
++
++async function switchService(ctx, id, { selfPath, sqlite, dryRun = false } = {}) {
++ const svc = inventoryService(ctx.inv, id);
++ const script = await locate(ctx.exec, selfPath, 'switch-service.sh', 'switch', svc.id);
++ const args = [svc.id];
++ if (sqlite) args.push(sqlite);
++ return run(ctx, svc, { action: 'switch', script, args, env: switchEnv(svc), dryRun });
++}
++
++module.exports = { provision, switch: switchService, redact, dataDirFor, switchEnv, unitPattern };
ddiff --git a/lib/commands/self-update.js b/lib/commands/self-update.js
iindex 20e2f5a..fc9d51e 100644
---- a/lib/commands/self-update.js
++++ b/lib/commands/self-update.js
@@@ -94,4 +94,4 @@ async function selfUpdate(ctx, { selfPath, dryRun = false } = {}) {
return result;
}
--module.exports = { selfUpdate, NPM_CI, REMOTE, BRANCH };
++module.exports = { selfUpdate, installDirFor, NPM_CI, REMOTE, BRANCH };
ddiff --git a/lib/index.js b/lib/index.js
iindex 65ddba1..eacbe7a 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -11,6 +11,7 @@ const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = requ
const offsite = require('./offsite');
const { snapshot } = require('./commands/snapshot');
const { selfUpdate } = require('./commands/self-update');
++const data = require('./commands/data');
const { drill, DrillError } = require('./commands/drill');
const releases = require('./releases');
const certs = require('./certs');
@@@ -45,6 +46,7 @@ module.exports = {
dns: require('./dns'),
snapshot,
selfUpdate,
++ data,
drill,
DrillError,
releases,
ddiff --git a/roles/data/README.md b/roles/data/README.md
iindex 4416b98..c791025 100644
---- a/roles/data/README.md
++++ b/roles/data/README.md
@@@ -21,6 +21,18 @@ sudo roles/data/switch-service.sh news # the production switch from SQLite: st
# (after the postgres branch is merged with green CI and auto-deploy is frozen)
```
++The same two steps from the installed CLI (the agent pipeline may run only `ovhost` through its broker;
++both run these scripts from the ovhost install, pass their exit code through and redact every URL and
++password; [docs/data-provisioning.md](../../docs/data-provisioning.md)):
++
++```bash
++sudo ovhost data provision wiki # == sudo roles/data/add-service.sh wiki
++sudo ovhost data switch news [--sqlite <file>] # == sudo roles/data/switch-service.sh news [<file>]
++```
++
++`switch-service.sh` runs the deployed release's `scripts/migrate-to-postgres.js`: a service without that
++file cannot be switched this way.
++
Generated passwords live only in `/etc/openvibe/data.env` (root, 0600). PostgreSQL receives SCRAM secrets and
Valkey receives SHA-256 hashes, so no plaintext is ever in SQL, logs, a process list or the scripts' output.
The one plaintext outside that file is PgBouncer's own auth user in `/etc/pgbouncer/userlist.txt`
ddiff --git a/test/data-provision.test.js b/test/data-provision.test.js
nnew file mode 100644
iindex 0000000..86a2fe5
---- /dev/null
++++ b/test/data-provision.test.js
@@@ -0,0 +1,170 @@
++'use strict';
++/**
++ * `ovhost data provision|switch`: the data role's scripts (roles/data/add-service.sh,
++ * switch-service.sh) run from the ovhost install, driven against the fake host. The scripts are
++ * stubbed at their install path: nothing here touches PostgreSQL, Valkey, systemd or a real
++ * database, and every invocation is recorded so the tests assert the argv, the env and the exit code.
++ */
++const assert = require('assert');
++const path = require('path');
++const { scenario, test, runTests } = require('./helpers');
++const { main } = require('../lib/cli');
++const { redact } = require('../lib/commands/data');
++
++const DIR = '/opt/ovhost-install';
++const SELF = `${DIR}/bin/ovhost`;
++
++function cli(host, ...argv) {
++ const lines = [];
++ return main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {}, selfPath: SELF })
++ .then((code) => ({ code, out: lines.join('\n') }));
++}
++
++/** Install both scripts at the install path and record every run of them. */
++function installScripts(host, { stdout = '', stderr = '', code = 0 } = {}) {
++ for (const f of ['add-service.sh', 'switch-service.sh']) host.put(`${DIR}/roles/data/${f}`, '#!/usr/bin/env bash\n', { mode: 0o755, owner: 'root' });
++ const runs = [];
++ const orig = host.exec.run;
++ host.exec.run = async (cmd, args, opts = {}) => {
++ if (cmd.startsWith(`${DIR}/roles/data/`)) {
++ runs.push({ script: cmd, args, env: opts.env || {}, as: opts.as || null, privileged: !!opts.privileged });
++ return { code, stdout, stderr };
++ }
++ return orig(cmd, args, opts);
++ };
++ host.scriptRuns = runs;
++ return runs;
++}
++
++runTests([
++ test('redact removes connection URLs and password values, and nothing else', () => {
++ assert.strictEqual(redact('DATABASE_URL=postgresql://ov_live_app:[email protected]:6432/ov_live'), 'DATABASE_URL=<redacted-url>');
++ assert.strictEqual(redact('VALKEY_URL=valkey://u:[email protected]:6379/0'), 'VALKEY_URL=<redacted-url>');
++ assert.strictEqual(redact('a redis://u:p@h/0 b postgres://x'), 'a <redacted-url> b <redacted-url>');
++ assert.strictEqual(redact('PG_LIVE_OWNER_PASSWORD=abc123'), 'PG_LIVE_OWNER_PASSWORD=<redacted>');
++ assert.match(redact('[data] live: database ov_live (owner ov_live), Valkey user ov_svc_live on ov:live:*'), /database ov_live/);
++ }),
++
++ test('provision runs add-service.sh from the install and prints its output, redacted', async () => {
++ const host = scenario();
++ const runs = installScripts(host, { stdout: '[data] live: database ov_live (owner ov_live, runtime ov_live_app via PgBouncer), Valkey user ov_svc_live on ov:live:*; DATABASE_URL, DATABASE_DIRECT_URL, VALKEY_URL, VALKEY_PREFIX set in /etc/openvibe/live.env (values not shown)\nDATABASE_URL=postgresql://ov_live_app:[email protected]:6432/ov_live\nVALKEY_URL=redis://ov_svc_live:[email protected]:6379/0\n' });
++
++ const r = await cli(host, 'data', 'provision', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(runs.length, 1);
++ assert.strictEqual(runs[0].script, `${DIR}/roles/data/add-service.sh`);
++ assert.deepStrictEqual(runs[0].args, ['live']);
++ assert.strictEqual(runs[0].privileged, true);
++ assert.match(r.out, /database ov_live/);
++ assert.doesNotMatch(r.out, /postgres(ql)?:\/\//i);
++ assert.doesNotMatch(r.out, /(valkey|redis):\/\//i);
++ assert.doesNotMatch(r.out, /s3cr3t/);
++ }),
++
++ test('provision refuses a service missing from the inventory and runs nothing', async () => {
++ const host = scenario();
++ const runs = installScripts(host);
++ const r = await cli(host, 'data', 'provision', 'nope');
++ assert.strictEqual(r.code, 1, r.out);
++ assert.match(r.out, /unknown service "nope"/);
++ assert.deepStrictEqual(runs, []);
++ }),
++
++ test('a script missing from the install is refused before anything runs', async () => {
++ const host = scenario();
++ const r = await cli(host, 'data', 'provision', 'live');
++ assert.strictEqual(r.code, 1, r.out);
++ assert.match(r.out, /add-service\.sh is not in the ovhost install/);
++ }),
++
++ test('switch passes the inventory units (worker templates as a glob) and dir, plus --sqlite, and passes the exit code through', async () => {
++ const host = scenario();
++ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
++ raw.services.media.workerUnits = ['[email protected]'];
++ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });
++ const runs = installScripts(host, { code: 3, stderr: '[switch] still busy after 12 hours: not switching\n' });
++
++ const r = await cli(host, 'data', 'switch', 'media', '--sqlite', '/var/lib/openvibe-media/media.db');
++ assert.strictEqual(r.code, 3, r.out);
++ assert.strictEqual(runs.length, 1);
++ assert.strictEqual(runs[0].script, `${DIR}/roles/data/switch-service.sh`);
++ assert.deepStrictEqual(runs[0].args, ['media', '/var/lib/openvibe-media/media.db']);
++ assert.strictEqual(runs[0].env.SWITCH_DIR, '/opt/openvibe.media');
++ assert.strictEqual(runs[0].env.SWITCH_UNITS, 'openvibe-media.service openvibe-media-clip@*.service');
++ assert.match(r.out, /still busy after 12 hours/);
++ }),
++
++ test('switch without --sqlite passes no file and uses the entries for a default service', async () => {
++ const host = scenario();
++ const runs = installScripts(host);
++ const r = await cli(host, 'data', 'switch', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.deepStrictEqual(runs[0].args, ['live']);
++ assert.strictEqual(runs[0].env.SWITCH_DIR, '/opt/openvibe.live');
++ assert.strictEqual(runs[0].env.SWITCH_UNITS, 'openvibe-live.service');
++ }),
++
++ test('switch also stops the units matching unitsMatch', async () => {
++ const host = scenario();
++ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
++ raw.services.live.unitsMatch = 'openvibe-live*.service';
++ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });
++ const runs = installScripts(host);
++ const r = await cli(host, 'data', 'switch', 'live');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(runs[0].env.SWITCH_UNITS, 'openvibe-live.service openvibe-live*.service');
++ }),
++
++ test('without root both are refused and nothing runs (sudo would drop SWITCH_UNITS/SWITCH_DIR)', async () => {
++ const host = scenario();
++ const runs = installScripts(host);
++ host.exec.isRoot = async () => false;
++ for (const action of ['provision', 'switch']) {
++ const r = await cli(host, 'data', action, 'live');
++ assert.strictEqual(r.code, 1, r.out);
++ assert.match(r.out, new RegExp(`data ${action} must run as root`));
++ }
++ assert.deepStrictEqual(runs, []);
++ const dry = await cli(host, 'data', 'switch', 'live', '--dry-run');
++ assert.strictEqual(dry.code, 0, dry.out);
++ }),
++
++ test('--dry-run prints the command and runs nothing', async () => {
++ const host = scenario();
++ const runs = installScripts(host);
++
++ const p = await cli(host, 'data', 'provision', 'live', '--dry-run');
++ assert.strictEqual(p.code, 0, p.out);
++ assert.match(p.out, /^would run: \/opt\/ovhost-install\/roles\/data\/add-service\.sh "live"$/m);
++
++ const s = await cli(host, 'data', 'switch', 'live', '--sqlite', '/var/lib/openvibe-live/live.db', '--dry-run');
++ assert.strictEqual(s.code, 0, s.out);
++ assert.match(s.out, /would run: SWITCH_UNITS="openvibe-live\.service" SWITCH_DIR="\/opt\/openvibe\.live" \/opt\/ovhost-install\/roles\/data\/switch-service\.sh "live" "\/var\/lib\/openvibe-live\/live\.db"/);
++ assert.deepStrictEqual(runs, []);
++ }),
++
++ test('--json reports the run and still carries no URL or password', async () => {
++ const host = scenario();
++ installScripts(host, { stdout: 'DATABASE_URL=postgresql://ov_live_app:[email protected]:6432/ov_live\n' });
++ const r = await cli(host, 'data', 'provision', 'live', '--json');
++ assert.strictEqual(r.code, 0, r.out);
++ const doc = JSON.parse(r.out);
++ assert.strictEqual(doc.service, 'live');
++ assert.strictEqual(doc.action, 'provision');
++ assert.strictEqual(doc.ok, true);
++ assert.strictEqual(doc.code, 0);
++ assert.strictEqual(doc.stdout, 'DATABASE_URL=<redacted-url>\n');
++ assert.doesNotMatch(r.out, /postgres(ql)?:\/\//i);
++ assert.doesNotMatch(r.out, /ov_live_app:pw/);
++ }),
++
++ test('usage: data needs a known subcommand and a service', async () => {
++ const host = scenario();
++ const bad = await cli(host, 'data', 'migrate', 'live');
++ assert.strictEqual(bad.code, 1, bad.out);
++ assert.match(bad.out, /usage: ovhost data provision/);
++ const missing = await cli(host, 'data', 'switch');
++ assert.strictEqual(missing.code, 1, missing.out);
++ assert.match(missing.out, /data switch needs a <service>/);
++ }),
++]);