openvibers/openvibe.host Commits
Require root for ovhost data and stop unitsMatch units on switch (#8)
Changes
@@@ -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>] [--instovhost 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 --versionovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notificationovhost releases <service> [--limit <n>]nnew file mode 100644@@@ -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.@@@ -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 thanmain, 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 versionannounce <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, dirtybackup --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) => procelse 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);nnew file mode 100644@@@ -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 };@@@ -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 };@@@ -11,6 +11,7 @@ const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = requconst 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,@@@ -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 andValkey 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`nnew file mode 100644@@@ -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>/);++ }),++]);
@@ -36,7 +36,7 @@ Stage A adds a few safety rules of its own:
@@ -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 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`).
- 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.
- 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
## Does not own
@@ -120,6 +120,8 @@ ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--inst
@@ -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 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 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 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 --version
ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notification
ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notification
ovhost releases <service> [--limit <n>]
ovhost releases <service> [--limit <n>]
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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.
@@ -48,6 +48,15 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -48,6 +48,15 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
from its own real path): fetch origin main, merge --ff-only,
from its own real path): fetch origin main, merge --ff-only,
npm ci --omit=dev; refuses a dirty tree, a branch other than
npm ci --omit=dev; refuses a dirty tree, a branch other than
main, or a non-fast-forward; --dry-run prints the plan (root)
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
version | --version ovhost's version
announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run]
announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run]
publish host.release.published to OpenVibe.Events (public), so open
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
@@ -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
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) {
function parseArgs(argv) {
const pos = [];
const pos = [];
@@ -225,6 +234,22 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -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)`);
else if (r.updated) log(`updated ${r.installDir} (${r.lockfileRestored ? 'lockfile restored; ' : ''}nothing restarted)`);
return 0;
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': {
case 'validate': {
const r = await lib.validate(ctx, needService(), { manifestFile: opts.manifest });
const r = await lib.validate(ctx, needService(), { manifestFile: opts.manifest });
if (json) emit(r);
if (json) emit(r);
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -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 };
@@ -94,4 +94,4 @@ async function selfUpdate(ctx, { selfPath, dryRun = false } = {}) {
@@ -94,4 +94,4 @@ async function selfUpdate(ctx, { selfPath, dryRun = false } = {}) {
return result;
return result;
}
}
-module.exports = { selfUpdate, NPM_CI, REMOTE, BRANCH };
+module.exports = { selfUpdate, installDirFor, NPM_CI, REMOTE, BRANCH };
@@ -11,6 +11,7 @@ const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = requ
@@ -11,6 +11,7 @@ const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = requ
const offsite = require('./offsite');
const offsite = require('./offsite');
const { snapshot } = require('./commands/snapshot');
const { snapshot } = require('./commands/snapshot');
const { selfUpdate } = require('./commands/self-update');
const { selfUpdate } = require('./commands/self-update');
+const data = require('./commands/data');
const { drill, DrillError } = require('./commands/drill');
const { drill, DrillError } = require('./commands/drill');
const releases = require('./releases');
const releases = require('./releases');
const certs = require('./certs');
const certs = require('./certs');
@@ -45,6 +46,7 @@ module.exports = {
@@ -45,6 +46,7 @@ module.exports = {
dns: require('./dns'),
dns: require('./dns'),
snapshot,
snapshot,
selfUpdate,
selfUpdate,
+ data,
drill,
drill,
DrillError,
DrillError,
releases,
releases,
@@ -21,6 +21,18 @@ sudo roles/data/switch-service.sh news # the production switch from SQLite: st
@@ -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)
# (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
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.
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`
The one plaintext outside that file is PgBouncer's own auth user in `/etc/pgbouncer/userlist.txt`
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -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>/);
+ }),
+]);