openvibers/openvibe.host Commits
List unset env names and next setup steps after ovhost service add (#11)
Changes
@@@ -120,6 +120,7 @@ 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 service add <id> [--dry-run] first setup: inventory entry, checkout, 0600 env file, database if DATABASE_URL is declaredovhost 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@@@ -139,6 +140,8 @@ ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passed, `1` refused (not root, port in use, no backup, unsupported), `2` failed. `self-update` exits `0` updated (or already up to date, or `--dry-run`), `1` refused (not root, dirty tree, not on `main`, diverged), `2` the checkout moved but `npm ci` failed. Other exit codes: `0` ok · `1` usage/precondition (including a held lock) · `2` validation failed, nothing restarted · `3` not ready, rolled back and serving · `4` rollback failed, **manual intervention** · `5` protected sessions active (refused, or `--wait-idle` gave up) · `6` frozen (`ovhost freeze`; `--force` goes through, rollbacks are never frozen).++`service add` runs as root against `/etc/openvibe/host.json`. It copies `services.<id>` from the installed CLI's `host.example.json`, drops `_note`, and backs up the live inventory before changing it. The example entry may set `cloneUrl` to a credential-free GitHub HTTPS URL; otherwise the repository is `https://github.com/OpenVibers/OpenVibe.<Capitalized id>.git`. The command refuses an existing entry, occupied checkout or env file. It holds the inventory lock (`<stateDir>/locks/_inventory.lock`) from reading the inventory to the end, and refuses a symlinked `.env.example`, which it reads with the checkout owner's permissions. It clones as the checkout owner, creates the entry's `envFile` (`/etc/openvibe/<id>.env`, 0600) as a copy of the checkout's `.env.example` with production `NODE_ENV`, `HOST` and `PORT`. It sets `BASE_URL` when the inventory vhost, origin or env example supplies a public HTTPS origin; otherwise it leaves an existing empty `BASE_URL` for the operator and does not add one to services without it. An explicitly required `BASE_URL` needs an inventory vhost or origin before setup begins. The tracked `.env.example` is never changed. It runs `data provision` if that example declares `DATABASE_URL`. Output lists env names but never values. `--dry-run` shows the plan without changing files. It then lists the env names still empty (leaving out the production overrides and, after provisioning, `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL`, `VALKEY_PREFIX`). When the example declares `OV_OAUTH_CLIENT_ID` or `OV_OAUTH_CLIENT_SECRET`, it prints one root command for the Network service principal. The command runs `server/setup/service-principal.js` through `systemd-run` with Network's env file (`services.network.envFile`, default `/etc/openvibe/network.env`) in Network's checkout (`services.network.repo`, default `/opt/openvibe.network`). It uses `rotate` when `list` already shows the id and `create` otherwise, so the principal never lands in a development database (the secret is never printed). It ends with `then: ovhost validate <id>; ovhost deploy <id> --restart`; ovhost runs neither. If a later step fails, the inventory backup path is printed; completed setup steps are not automatically undone.++**Deploy strategies** (roadmap WS-N task 11). The inventory entry's `strategy` picks how a service deploys: `git-checkout` (the default: an in-place checkout, every service above), `multi-app` (Tools), `static-build` (Sites), `pnpm-build` (Games) or `release-layout` (Live, OpenRe: `releases/<id>` behind a `current` symlink, prepared while the old release serves, `--prepare-only` to stop after preparing). Each strategy carries the rules of the script it replaces: preflight checks, build output restored from git, untracked lockfiles, `unitsMatch`, `/release.json` naming the new sha, the socket rule. [docs/deploy-strategies.md](docs/deploy-strategies.md) has the engines, every inventory field, the rule-by-rule mapping from each script, what was not ported and why, the production cutover checklist and the proposed inventory diff.`deploy --browser-check` checks the service's public site in headless Chrome after a deploy that went through. It runs [`scripts/browser-check.js`](docs/browser-check.md) for that one site. The check is report only: it never changes the exit code or the release record. It needs Chrome where ovhost runs.@@@ -48,6 +48,9 @@ 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)++ service add <id> [--dry-run] copy the example inventory entry, clone the checkout, create++ its 0600 env file, and provision PostgreSQL when declared;++ print remaining setup (names only, never values)data provision <service> [--dry-run] run roles/data/add-service.sh from the ovhost install: theservice's PostgreSQL database, roles and Valkey user(idempotent; prints names only, never values)@@@ -234,6 +237,20 @@ 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 'service': {++ if (pos[1] !== 'add' || !pos[2] || pos[3]) throw new UsageError('usage: ovhost service add <id> [--dry-run]');++ const r = await lib.serviceAdd.add(ctx, pos[2], { selfPath: selfPath || process.argv[1], dryRun: !!opts['dry-run'] });++ if (json) emit(r);++ else if (r.dryRun) for (const step of r.plan) out(`would: ${step}`);++ else {++ out(`added ${r.service}; inventory backup: ${r.backup}`);++ out(`checkout: ${r.repo}; env file: ${r.envFile} (0600; set ${r.envNames.join(', ')})`);++ if (r.provisioned) out(`data provision ${r.service}: complete`);++ if (r.unset.length) out(`still empty in ${r.envFile}: ${r.unset.join(', ')}`);++ for (const step of r.remaining) out(step);++ }++ 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.nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++/** First-time service setup. The example inventory is read from this CLI install. */++const path = require('path');++const { normalise } = require('../inventory');++const { OpError, EXIT } = require('../release-ops');++const { installDirFor } = require('./self-update');++const data = require('./data');++const lock = require('../lock');++const { parseExample, parseNames } = require('../envfile');++++function refuse(message) { throw new OpError(message, EXIT.USAGE); }++function checkedUrl(raw, id) {++ const url = raw.cloneUrl || `https://github.com/OpenVibers/OpenVibe.${id[0].toUpperCase()}${id.slice(1)}.git`;++ let u;++ try { u = new URL(url); } catch { refuse(`services.${id}.cloneUrl must be a GitHub HTTPS repository URL`); }++ if (u.protocol !== 'https:' || u.hostname !== 'github.com' || u.username || u.password || u.search || u.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(u.pathname)) refuse(`services.${id}.cloneUrl must be a GitHub HTTPS repository URL without credentials`);++ return u.href;++}++function baseUrl(svc, example) {++ const vhost = svc.nginx && svc.nginx.vhost;++ if (vhost && /^[a-z0-9.-]+\.conf$/i.test(vhost)) return `https://${vhost.slice(0, -5)}`;++ if (svc.origin) return svc.origin;++ const match = String(example).match(/^\s*BASE_URL\s*=\s*['"]?(https:\/\/[^\s'"/]+)[^\r\n]*$/m);++ if (match) return match[1];++ return null;++}++function productionEnv(example, svc) {++ const values = { NODE_ENV: 'production', HOST: '127.0.0.1', PORT: String(svc.port) };++ const url = baseUrl(svc, example);++ if (url) values.BASE_URL = url;++ const found = new Set();++ const lines = String(example).split(/\r?\n/).map((line) => {++ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);++ if (!m || !(m[1] in values)) return line;++ found.add(m[1]);++ return `${m[1]}=${values[m[1]]}`;++ });++ for (const [name, value] of Object.entries(values)) if (!found.has(name)) lines.push(`${name}=${value}`);++ return lines.join('\n').replace(/\n*$/, '\n');++}++const OVERRIDES = ['NODE_ENV', 'HOST', 'PORT', 'BASE_URL'];++const PROVISIONED = ['DATABASE_URL', 'DATABASE_DIRECT_URL', 'VALKEY_URL', 'VALKEY_PREFIX'];++/** Names still empty in the new env file, and the commands that finish the setup. Never values. */++function remainingSetup(id, envFile, envText, provisioned, overridden, netRepo, netEnv) {++ const skip = new Set([...overridden, ...(provisioned ? PROVISIONED : [])]);++ const names = parseNames(envText);++ const unset = names.filter((n) => n.empty && !skip.has(n.name)).map((n) => n.name);++ const remaining = [];++ if (names.some((n) => n.name === 'OV_OAUTH_CLIENT_SECRET' || n.name === 'OV_OAUTH_CLIENT_ID')) {++ // systemd-run parses Network's root-only env file the way its unit does: never source it with++ // sh (values with & or $ would break). /usr/bin/env node matches how the units start node.++ const run = `sudo systemd-run --quiet --wait --pipe --collect -p EnvironmentFile=${netEnv} -p WorkingDirectory=${netRepo} /usr/bin/env node server/setup/service-principal.js`;++ remaining.push(`service principal (Network's production env; create, or rotate if Network already has ${id}): if ${run} list | grep -Eq '^${id}[[:space:]]'; then verb=rotate; else verb=create; fi; ${run} "$verb" ${id} --env-file ${envFile} # secret never printed`);++ }++ remaining.push(`then: ovhost validate ${id}; ovhost deploy ${id} --restart`);++ return { unset, remaining };++}++async function add(ctx, id, { selfPath, dryRun = false } = {}) {++ if (dryRun) return setup(ctx, id, { selfPath, dryRun });++ if (!(await ctx.exec.isRoot())) refuse('service add must run as root');++ // One inventory-wide lock across the read and the write: two adds never build on the same old inventory.++ const held = await lock.acquire(ctx.exec, ctx.inv, '_inventory');++ try { return await setup(ctx, id, { selfPath, dryRun }); } finally { await held.release(); }++}++async function setup(ctx, id, { selfPath, dryRun }) {++ const { exec, inv } = ctx;++ if (!/^[a-z][a-z0-9-]{0,30}$/.test(id || '')) refuse('service add needs a valid <id> (lowercase letters, digits, hyphens; max 31)');++ if (inv.file !== '/etc/openvibe/host.json') refuse('service add requires /etc/openvibe/host.json (no --inventory override)');++ const examplePath = path.join(installDirFor(selfPath).dir, 'host.example.json');++ const exampleText = await exec.readFile(examplePath);++ if (exampleText == null) refuse(`${examplePath} is missing from the ovhost install`);++ let example, current;++ try { example = JSON.parse(exampleText); } catch { refuse(`${examplePath} is not valid JSON`); }++ const currentText = await exec.readFile(inv.file, { privileged: true });++ try { current = JSON.parse(currentText); } catch { refuse(`${inv.file} is not valid JSON`); }++ if (Object.hasOwn(current.services || {}, id)) refuse(`services.${id} already exists in ${inv.file}`);++ const source = example.services && example.services[id];++ if (!source) refuse(`services.${id} is absent from ${examplePath}`);++ const { _note, ...entry } = source;++ const candidate = { ...current, services: { ...current.services, [id]: entry } };++ const services = normalise(candidate).services;++ const svc = services[id];++ if (svc.layout !== 'git') refuse(`services.${id} uses a release layout; first setup needs a plain checkout`);++ if (!svc.envFile || !svc.port || !svc.owner) refuse(`services.${id} needs envFile, port and owner`);++ if (svc.envFile !== `/etc/openvibe/${id}.env` && entry.envFile !== `/etc/openvibe/${id}.env`) refuse(`services.${id}.envFile must be /etc/openvibe/${id}.env for data provision`);++ if (Array.isArray(svc.env.required) && svc.env.required.includes('BASE_URL') && !baseUrl(svc, '')) refuse(`services.${id} requires BASE_URL but has no nginx vhost or origin in the example inventory`);++ const net = services.network;++ const netRepo = (net && net.repo) || '/opt/openvibe.network';++ const netEnv = (net && net.envFile) || '/etc/openvibe/network.env';++ const url = checkedUrl(entry, id);++ const checkout = await exec.stat(svc.repo);++ if (checkout && (checkout.isSymlink || !checkout.isDir || (await exec.readdir(svc.repo) || []).length)) refuse(`${svc.repo} is not an empty directory`);++ if (await exec.stat(svc.envFile)) refuse(`${svc.envFile} already exists`);++ const inventoryStat = await exec.stat(inv.file);++ if (!inventoryStat || !inventoryStat.isFile || inventoryStat.isSymlink) refuse(`${inv.file} must be a regular file`);++ const backup = `${inv.file}.bak.${new Date(exec.now()).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`;++ if (await exec.stat(backup)) refuse(`${backup} already exists`);++ if (dryRun) return { service: id, dryRun: true, plan: [`back up ${inv.file} to ${backup} and add services.${id} without _note`, `clone ${url} into ${svc.repo} as ${svc.owner}`, `create ${svc.envFile} (0600) from checkout ${svc.env.example}; set NODE_ENV, HOST, PORT, and BASE_URL when a public origin is known`, `data provision ${id} if ${svc.env.example} declares DATABASE_URL`, `list the env names still empty; when ${svc.env.example} declares OV_OAUTH_CLIENT_ID/SECRET, give the Network service-principal command (run with Network's production env; create, or rotate when it exists); then validate and deploy`] };++ const copy = await exec.run('cp', ['-p', '--', inv.file, backup], { privileged: true });++ if (copy.code !== 0) refuse(`could not back up ${inv.file}`);++ try {++ await exec.writeFile(inv.file, `${JSON.stringify(candidate, null, 2)}\n`, { privileged: true, mode: inventoryStat.mode });++ // writeFile replaces bytes in place. Restore ownership/mode if an executor used a new inode.++ const owner = await exec.run('chown', [`${inventoryStat.uid}:${inventoryStat.gid}`, inv.file], { privileged: true });++ if (owner.code !== 0) throw new Error(`could not restore ownership of ${inv.file}`);++ if (!checkout) await exec.mkdir(svc.repo, { owner: svc.owner, mode: 0o750 });++ else {++ const own = await exec.run('chown', [`${svc.owner}:${svc.owner}`, svc.repo], { privileged: true });++ if (own.code !== 0) throw new Error(`could not give ${svc.owner} the empty checkout directory`);++ }++ const cloned = await exec.run('git', ['clone', '--branch', svc.branch, '--', url, svc.repo], { as: svc.owner, timeoutMs: 600000 });++ if (cloned.code !== 0) throw new Error(`git clone failed for ${id}`);++ // The checkout is untrusted: a symlinked example could point at a root-only secret, so refuse a++ // symlink and read it with the owner's permissions (covers symlinked parent directories too).++ const exampleFile = path.join(svc.repo, svc.env.example);++ const exampleStat = await exec.stat(exampleFile);++ if (!exampleStat || exampleStat.isSymlink || !exampleStat.isFile) throw new Error(`${svc.env.example} must be a regular file in ${svc.repo}`);++ const read = await exec.run('cat', ['--', exampleFile], { as: svc.owner });++ if (read.code !== 0) throw new Error(`could not read ${svc.env.example} as ${svc.owner}`);++ const envExample = read.stdout;++ const envText = productionEnv(envExample, svc);++ const overridden = baseUrl(svc, envExample) ? OVERRIDES : OVERRIDES.slice(0, 3);++ await exec.writeFile(svc.envFile, envText, { privileged: true, mode: 0o600 });++ const provisionDatabase = parseExample(envExample).declared.includes('DATABASE_URL');++ if (provisionDatabase) {++ const r = await data.provision({ ...ctx, inv: { ...inv, services: { ...inv.services, [id]: svc } } }, id, { selfPath });++ if (!r.ok) throw new Error(`data provision ${id} failed (exit ${r.code})`);++ }++ return { service: id, dryRun: false, backup, repo: svc.repo, envFile: svc.envFile, envNames: overridden, provisioned: provisionDatabase, ...remainingSetup(id, svc.envFile, envText, provisionDatabase, overridden, netRepo, netEnv) };++ } catch (err) {++ throw new Error(`${err.message}; setup is incomplete; inventory backup: ${backup}`);++ }++}++++module.exports = { add, productionEnv };@@@ -12,6 +12,7 @@ const offsite = require('./offsite');const { snapshot } = require('./commands/snapshot');const { selfUpdate } = require('./commands/self-update');const data = require('./commands/data');++const serviceAdd = require('./commands/service-add');const { drill, DrillError } = require('./commands/drill');const releases = require('./releases');const certs = require('./certs');@@@ -47,6 +48,7 @@ module.exports = {snapshot,selfUpdate,data,++ serviceAdd,drill,DrillError,releases,@@@ -18,6 +18,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', stconst files = new Map(); // path -> { type: 'file'|'dir'|'symlink', content, mode, owner, target }const calls = [];const repos = new Map(); // repoPath -> repo++ const remoteRepos = new Map(); // URL -> files for a new cloneconst units = new Map(); // unit -> stateconst http = new Map(); // url -> fn({ headers, method, body }) -> { status, body } (request() also passes method and body)const alivePids = new Set();@@@ -25,7 +26,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', stlet clock = start;let seq = 0;const host = {-- files, calls, repos, units, http, alivePids, listeners,++ files, calls, repos, remoteRepos, units, http, alivePids, listeners,nginxTest: () => ({ code: 0, stderr: 'nginx: configuration file /etc/nginx/nginx.conf test is successful' }),npm: null, // (cwd, argv, as) -> undefined | { code, stderr } ; default installs package.json depsnpmRewritesLockfile: true,@@@ -183,6 +184,18 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', streturn matches.length === 1 ? matches[0] : null;}function gitCmd(args, opts) {++ if (args[0] === 'clone') {++ const url = args[args.length - 2];++ const dest = args[args.length - 1];++ const source = remoteRepos.get(url);++ if (!source || (files.has(path.resolve(dest)) && (files.get(path.resolve(dest)).type !== 'dir' || [...files.keys()].some((p) => p.startsWith(`${path.resolve(dest)}/`))))) return { code: 128, stdout: '', stderr: 'clone source missing or destination occupied' };++ const branch = args[args.indexOf('--branch') + 1];++ const repo = host.createRepo(dest, { owner: opts.as || 'root', branch });++ const sha = repo.commit(source);++ repo.publish(sha);++ repo.checkout(sha);++ return { code: 0, stdout: '', stderr: '' };++ }const repo = repos.get(args[1]) || repos.get(resolveLinks(args[1]));if (!repo) return { code: 128, stdout: '', stderr: 'not a git repository' };let i = 2;@@@ -434,6 +447,13 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', stcase 'systemctl': return systemctlCmd(args);case 'nginx': return { stdout: '', ...host.nginxTest() };case 'rm': rmTree(args[args.length - 1]); return { code: 0, stdout: '', stderr: '' };++ case 'cat': {++ // Reads as opts.as: another user's file needs the world-read bit.++ const e = get(args[args.length - 1]);++ if (!e || e.type !== 'file') return { code: 1, stdout: '', stderr: 'cat: No such file or directory' };++ if (opts.as && opts.as !== 'root' && e.owner !== opts.as && !(e.mode & 0o004)) return { code: 1, stdout: '', stderr: 'cat: Permission denied' };++ return { code: 0, stdout: String(e.content), stderr: '' };++ }case 'install': {const src = args[args.length - 2];const dest = args[args.length - 1];@@@ -448,7 +468,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', stconst target = resolveLinks(args[args.length - 1]);const e = files.get(target);if (!e) return { code: 1, stdout: '', stderr: 'chown: no such file' };-- const who = args.filter((a) => !a.startsWith('-'))[0].split(':')[0];++ const whoRaw = args.filter((a) => !a.startsWith('-'))[0].split(':')[0];++ const who = whoRaw === '0' ? 'root' : whoRaw;if (args.includes('-R')) { for (const [k, v] of files) if (k === target || k.startsWith(`${target}/`)) v.owner = who; } else e.owner = who;return { code: 0, stdout: '', stderr: '' };}@@@ -560,7 +581,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st}const e = get(src);if (!e || e.type !== 'file') return { code: 1, stdout: '', stderr: `cp: cannot stat '${src}': No such file or directory` };-- put(dest, e.content, { owner: opts.as || user, mode: 0o644 });++ put(dest, e.content, { owner: args.includes('-p') ? e.owner : opts.as || user, mode: args.includes('-p') ? e.mode : 0o644 });return { code: 0, stdout: '', stderr: '' };}default: return { code: 127, stdout: '', stderr: `fake host: no ${cmd}` };nnew file mode 100644@@@ -0,0 +1,216 @@++'use strict';++const assert = require('assert');++const { scenario, test, runTests, SECRET, lifecycleDoc } = require('./helpers');++const { main } = require('../lib/cli');++++const SELF = '/opt/ovhost-install/bin/ovhost';++const URL = 'https://github.com/OpenVibers/OpenVibe.Bot.git';++++function setup() {++ const host = scenario();++ host.put('/opt/ovhost-install/host.example.json', JSON.stringify({ services: { bot: {++ _note: 'example-only note', repo: '/opt/openvibe.bot', owner: 'ubuntu', units: [],++ envFile: '/etc/openvibe/bot.env', env: { required: 'from-example' }, port: 4630,++ nginx: { vhost: 'openvibe.bot.conf' }, lifecycle: lifecycleDoc(),++ databases: [{ name: 'bot', engine: 'postgresql', database: 'ov_bot' }],++ } } }));++ host.remoteRepos.set(URL, { '.env.example': `NODE_ENV=development\nHOST=0.0.0.0\nPORT=3000\nBASE_URL=https://example.invalid\nDATABASE_URL=\nOV_OAUTH_CLIENT_SECRET=\nEXAMPLE_SECRET=${SECRET}\n` });++ host.put('/opt/ovhost-install/roles/data/add-service.sh', '#!/bin/sh\n', { mode: 0o755 });++ const provision = [];++ const run = host.exec.run;++ host.exec.run = async (cmd, args, opts) => {++ if (cmd.endsWith('/roles/data/add-service.sh')) {++ provision.push({ cmd, args, opts });++ return { code: 0, stdout: 'DATABASE_URL and VALKEY_URL set (values not shown)', stderr: '' };++ }++ return run(cmd, args, opts);++ };++ const cli = async (...argv) => {++ const lines = [];++ const code = await main(argv, { exec: host.exec, out: (line) => lines.push(line), env: {}, selfPath: SELF });++ return { code, out: lines.join('\n') };++ };++ return { host, cli, provision };++}++++runTests([++ test('dry run gives a names-only plan and changes nothing', async () => {++ const { host, cli, provision } = setup();++ const before = host.read('/etc/openvibe/host.json');++ const result = await cli('service', 'add', 'bot', '--dry-run');++ assert.strictEqual(result.code, 0, result.out);++ assert.match(result.out, /clone .*OpenVibe\.Bot\.git/);++ assert.match(result.out, /data provision bot/);++ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);++ assert.strictEqual(host.read('/etc/openvibe/bot.env'), null);++ assert.deepStrictEqual(provision, []);++ assert.doesNotMatch(result.out, /development|example\.invalid|sk_live_/);++ }),++ test('adds inventory, checkout, production env and data with no values in output', async () => {++ const { host, cli, provision } = setup();++ const result = await cli('service', 'add', 'bot', '--json');++ assert.strictEqual(result.code, 0, result.out);++ assert.doesNotMatch(result.out, /NODE_ENV=production|127\.0\.0\.1|4630|https:\/\/openvibe\.bot|sk_live_/);++ const live = JSON.parse(host.read('/etc/openvibe/host.json'));++ assert.ok(live.services.bot);++ assert.ok(!Object.hasOwn(live.services.bot, '_note'));++ const backup = JSON.parse(result.out).backup;++ assert.ok(host.read(backup));++ assert.strictEqual((await host.exec.stat(backup)).mode, 0o640);++ assert.strictEqual((await host.exec.stat('/etc/openvibe/host.json')).mode, 0o640);++ assert.strictEqual((await host.exec.stat('/etc/openvibe/bot.env')).mode, 0o600);++ assert.strictEqual((await host.exec.stat('/opt/openvibe.bot')).owner, 'ubuntu');++ const env = host.read('/etc/openvibe/bot.env');++ assert.match(env, /^NODE_ENV=production$/m);++ assert.match(env, /^HOST=127\.0\.0\.1$/m);++ assert.match(env, /^PORT=4630$/m);++ assert.match(env, /^BASE_URL=https:\/\/openvibe\.bot$/m);++ assert.match(env, new RegExp(SECRET));++ assert.deepStrictEqual(provision.map((r) => r.args), [['bot']]);++ assert.strictEqual(provision[0].opts.privileged, true);++ assert.ok(host.calls.some((c) => c.cmd === 'git' && c.args[0] === 'clone' && c.as === 'ubuntu'));++ const r = JSON.parse(result.out);++ assert.deepStrictEqual(r.unset, ['OV_OAUTH_CLIENT_SECRET']);++ const step = r.remaining.join('\n');++ assert.ok(step.includes('sudo systemd-run'), step);++ assert.ok(step.includes('-p EnvironmentFile=/etc/openvibe/network.env'), step);++ assert.ok(step.includes('-p WorkingDirectory=/opt/openvibe.network'), step);++ assert.ok(step.includes("service-principal.js list | grep -Eq '^bot[[:space:]]'"), step);++ assert.ok(step.includes('then verb=rotate; else verb=create; fi'), step);++ assert.ok(step.includes('"$verb" bot --env-file /etc/openvibe/bot.env'), step);++ assert.match(r.remaining.join('\n'), /ovhost validate bot; ovhost deploy bot --restart/);++ const repeat = await cli('service', 'add', 'bot');++ assert.strictEqual(repeat.code, 1);++ assert.match(repeat.out, /already exists/);++ }),++ test('text output names what is still empty and the remaining steps, never values', async () => {++ const { cli } = setup();++ const result = await cli('service', 'add', 'bot');++ assert.strictEqual(result.code, 0, result.out);++ assert.match(result.out, /^still empty in \/etc\/openvibe\/bot\.env: OV_OAUTH_CLIENT_SECRET$/m);++ assert.ok(result.out.includes('sudo systemd-run'), result.out);++ assert.ok(result.out.includes('-p EnvironmentFile=/etc/openvibe/network.env'), result.out);++ assert.ok(result.out.includes('-p WorkingDirectory=/opt/openvibe.network'), result.out);++ assert.ok(result.out.includes("service-principal.js list | grep -Eq '^bot[[:space:]]'"), result.out);++ assert.ok(result.out.includes('then verb=rotate; else verb=create; fi'), result.out);++ assert.ok(result.out.includes('"$verb" bot --env-file /etc/openvibe/bot.env'), result.out);++ assert.match(result.out, /^then: ovhost validate bot; ovhost deploy bot --restart$/m);++ assert.doesNotMatch(result.out, new RegExp(`NODE_ENV=production|127\\.0\\.0\\.1|4630|https://openvibe\\.bot|${SECRET}`));++ }),++ test("the principal step uses the inventory's Network checkout and env file", async () => {++ const { host, cli } = setup();++ const doc = JSON.parse(host.read('/etc/openvibe/host.json'));++ doc.services.network = { repo: '/srv/net', owner: 'ubuntu', units: [], envFile: '/etc/openvibe/net-prod.env', port: 4000, lifecycle: lifecycleDoc() };++ host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });++ const result = await cli('service', 'add', 'bot');++ assert.strictEqual(result.code, 0, result.out);++ assert.ok(result.out.includes('-p EnvironmentFile=/etc/openvibe/net-prod.env'), result.out);++ assert.ok(result.out.includes('-p WorkingDirectory=/srv/net'), result.out);++ assert.doesNotMatch(result.out, new RegExp(SECRET));++ }),++ test('refuses an occupied checkout before changing the inventory', async () => {++ const { host, cli } = setup();++ host.put('/opt/openvibe.bot/keep.txt', 'keep');++ const before = host.read('/etc/openvibe/host.json');++ const result = await cli('service', 'add', 'bot');++ assert.strictEqual(result.code, 1);++ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);++ assert.strictEqual(host.read('/opt/openvibe.bot/keep.txt'), 'keep');++ }),++ test('a wiki-like service without a public URL adds successfully and omits BASE_URL', async () => {++ const { host, cli, provision } = setup();++ const example = JSON.parse(host.read('/opt/ovhost-install/host.example.json'));++ example.services.wiki = {++ repo: '/opt/openvibe.wiki', owner: 'ubuntu', units: [],++ envFile: '/etc/openvibe/wiki.env', env: { required: 'from-example' }, port: 4800,++ lifecycle: lifecycleDoc(),++ };++ host.put('/opt/ovhost-install/host.example.json', JSON.stringify(example));++ host.remoteRepos.set('https://github.com/OpenVibers/OpenVibe.Wiki.git', {++ '.env.example': 'NODE_ENV=development\nHOST=0.0.0.0\nPORT=3000\nSITE_TITLE=Wiki\n',++ });++ const result = await cli('service', 'add', 'wiki', '--json');++ assert.strictEqual(result.code, 0, result.out);++ assert.deepStrictEqual(JSON.parse(result.out).envNames, ['NODE_ENV', 'HOST', 'PORT']);++ assert.doesNotMatch(host.read('/etc/openvibe/wiki.env'), /^BASE_URL=/m);++ assert.ok(JSON.parse(host.read('/etc/openvibe/host.json')).services.wiki);++ assert.deepStrictEqual(provision, []);++ }),++ test('an unresolved optional BASE_URL remains empty and is listed for setup', async () => {++ const { host, cli } = setup();++ const example = JSON.parse(host.read('/opt/ovhost-install/host.example.json'));++ delete example.services.bot.nginx;++ host.put('/opt/ovhost-install/host.example.json', JSON.stringify(example));++ host.remoteRepos.set(URL, { '.env.example': 'NODE_ENV=development\nBASE_URL=\n' });++ const result = await cli('service', 'add', 'bot', '--json');++ assert.strictEqual(result.code, 0, result.out);++ assert.ok(JSON.parse(result.out).unset.includes('BASE_URL'));++ assert.match(host.read('/etc/openvibe/bot.env'), /^BASE_URL=$/m);++ }),++ test('an explicitly required URL without an inventory origin is refused before changes', async () => {++ const { host, cli } = setup();++ const example = JSON.parse(host.read('/opt/ovhost-install/host.example.json'));++ delete example.services.bot.nginx;++ example.services.bot.env.required = ['BASE_URL'];++ host.put('/opt/ovhost-install/host.example.json', JSON.stringify(example));++ const before = host.read('/etc/openvibe/host.json');++ const result = await cli('service', 'add', 'bot');++ assert.strictEqual(result.code, 1);++ assert.match(result.out, /requires BASE_URL/);++ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);++ assert.strictEqual(host.read('/opt/openvibe.bot/.env.example'), null);++ }),++ test('reports an incomplete setup and its backup when provisioning fails', async () => {++ const { host, cli } = setup();++ const run = host.exec.run;++ host.exec.run = async (cmd, args, opts) => cmd.endsWith('/roles/data/add-service.sh')++ ? { code: 2, stdout: `DATABASE_URL=${SECRET}`, stderr: `password=${SECRET}` }++ : run(cmd, args, opts);++ const result = await cli('service', 'add', 'bot');++ assert.strictEqual(result.code, 2);++ assert.match(result.out, /setup is incomplete; inventory backup:/);++ assert.doesNotMatch(result.out, new RegExp(SECRET));++ assert.ok(JSON.parse(host.read('/etc/openvibe/host.json')).services.bot);++ }),++ test('a symlinked .env.example is refused and its target never reaches the env file', async () => {++ const { host, cli } = setup();++ host.put('/etc/openvibe/network.env', `OV_SECRET=${SECRET}\n`, { mode: 0o600 });++ const run = host.exec.run;++ host.exec.run = async (cmd, args, opts) => {++ const r = await run(cmd, args, opts);++ if (cmd === 'git' && args[0] === 'clone') {++ await host.exec.removeFile('/opt/openvibe.bot/.env.example');++ await host.exec.symlink('/etc/openvibe/network.env', '/opt/openvibe.bot/.env.example');++ }++ return r;++ };++ const result = await cli('service', 'add', 'bot');++ assert.strictEqual(result.code, 2, result.out);++ assert.match(result.out, /must be a regular file/);++ assert.strictEqual(host.read('/etc/openvibe/bot.env'), null);++ assert.doesNotMatch(result.out, new RegExp(SECRET));++ }),++ test('reads .env.example as the checkout owner', async () => {++ const { host, cli } = setup();++ const result = await cli('service', 'add', 'bot');++ assert.strictEqual(result.code, 0, result.out);++ assert.ok(host.calls.some((c) => c.cmd === 'cat' && c.args.at(-1) === '/opt/openvibe.bot/.env.example' && c.as === 'ubuntu'));++ assert.ok(!host.reads.includes('/opt/openvibe.bot/.env.example'));++ }),++ test('a held inventory lock refuses the add and leaves the inventory alone; success releases it', async () => {++ const { host, cli } = setup();++ const lockFile = '/var/lib/openvibe-host/locks/_inventory.lock';++ host.put(lockFile, JSON.stringify({ pid: 4242, at: 'earlier' }));++ host.alivePids.add(4242);++ const before = host.read('/etc/openvibe/host.json');++ const held = await cli('service', 'add', 'bot');++ assert.strictEqual(held.code, 1, held.out);++ assert.match(held.out, /another ovhost operation on _inventory is running/);++ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);++ assert.ok(!host.calls.some((c) => c.cmd === 'git' && c.args[0] === 'clone'));++ host.alivePids.delete(4242);++ const ok = await cli('service', 'add', 'bot');++ assert.strictEqual(ok.code, 0, ok.out);++ assert.strictEqual(host.read(lockFile), null);++ }),++]);
@@ -120,6 +120,7 @@ ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--inst
@@ -120,6 +120,7 @@ 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 service add <id> [--dry-run] first setup: inventory entry, checkout, 0600 env file, database if DATABASE_URL is declared
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 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 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
@@ -139,6 +140,8 @@ ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)
@@ -139,6 +140,8 @@ ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)
Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passed, `1` refused (not root, port in use, no backup, unsupported), `2` failed. `self-update` exits `0` updated (or already up to date, or `--dry-run`), `1` refused (not root, dirty tree, not on `main`, diverged), `2` the checkout moved but `npm ci` failed. Other exit codes: `0` ok · `1` usage/precondition (including a held lock) · `2` validation failed, nothing restarted · `3` not ready, rolled back and serving · `4` rollback failed, **manual intervention** · `5` protected sessions active (refused, or `--wait-idle` gave up) · `6` frozen (`ovhost freeze`; `--force` goes through, rollbacks are never frozen).
Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passed, `1` refused (not root, port in use, no backup, unsupported), `2` failed. `self-update` exits `0` updated (or already up to date, or `--dry-run`), `1` refused (not root, dirty tree, not on `main`, diverged), `2` the checkout moved but `npm ci` failed. Other exit codes: `0` ok · `1` usage/precondition (including a held lock) · `2` validation failed, nothing restarted · `3` not ready, rolled back and serving · `4` rollback failed, **manual intervention** · `5` protected sessions active (refused, or `--wait-idle` gave up) · `6` frozen (`ovhost freeze`; `--force` goes through, rollbacks are never frozen).
+`service add` runs as root against `/etc/openvibe/host.json`. It copies `services.<id>` from the installed CLI's `host.example.json`, drops `_note`, and backs up the live inventory before changing it. The example entry may set `cloneUrl` to a credential-free GitHub HTTPS URL; otherwise the repository is `https://github.com/OpenVibers/OpenVibe.<Capitalized id>.git`. The command refuses an existing entry, occupied checkout or env file. It holds the inventory lock (`<stateDir>/locks/_inventory.lock`) from reading the inventory to the end, and refuses a symlinked `.env.example`, which it reads with the checkout owner's permissions. It clones as the checkout owner, creates the entry's `envFile` (`/etc/openvibe/<id>.env`, 0600) as a copy of the checkout's `.env.example` with production `NODE_ENV`, `HOST` and `PORT`. It sets `BASE_URL` when the inventory vhost, origin or env example supplies a public HTTPS origin; otherwise it leaves an existing empty `BASE_URL` for the operator and does not add one to services without it. An explicitly required `BASE_URL` needs an inventory vhost or origin before setup begins. The tracked `.env.example` is never changed. It runs `data provision` if that example declares `DATABASE_URL`. Output lists env names but never values. `--dry-run` shows the plan without changing files. It then lists the env names still empty (leaving out the production overrides and, after provisioning, `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL`, `VALKEY_PREFIX`). When the example declares `OV_OAUTH_CLIENT_ID` or `OV_OAUTH_CLIENT_SECRET`, it prints one root command for the Network service principal. The command runs `server/setup/service-principal.js` through `systemd-run` with Network's env file (`services.network.envFile`, default `/etc/openvibe/network.env`) in Network's checkout (`services.network.repo`, default `/opt/openvibe.network`). It uses `rotate` when `list` already shows the id and `create` otherwise, so the principal never lands in a development database (the secret is never printed). It ends with `then: ovhost validate <id>; ovhost deploy <id> --restart`; ovhost runs neither. If a later step fails, the inventory backup path is printed; completed setup steps are not automatically undone.
+
**Deploy strategies** (roadmap WS-N task 11). The inventory entry's `strategy` picks how a service deploys: `git-checkout` (the default: an in-place checkout, every service above), `multi-app` (Tools), `static-build` (Sites), `pnpm-build` (Games) or `release-layout` (Live, OpenRe: `releases/<id>` behind a `current` symlink, prepared while the old release serves, `--prepare-only` to stop after preparing). Each strategy carries the rules of the script it replaces: preflight checks, build output restored from git, untracked lockfiles, `unitsMatch`, `/release.json` naming the new sha, the socket rule. [docs/deploy-strategies.md](docs/deploy-strategies.md) has the engines, every inventory field, the rule-by-rule mapping from each script, what was not ported and why, the production cutover checklist and the proposed inventory diff.
**Deploy strategies** (roadmap WS-N task 11). The inventory entry's `strategy` picks how a service deploys: `git-checkout` (the default: an in-place checkout, every service above), `multi-app` (Tools), `static-build` (Sites), `pnpm-build` (Games) or `release-layout` (Live, OpenRe: `releases/<id>` behind a `current` symlink, prepared while the old release serves, `--prepare-only` to stop after preparing). Each strategy carries the rules of the script it replaces: preflight checks, build output restored from git, untracked lockfiles, `unitsMatch`, `/release.json` naming the new sha, the socket rule. [docs/deploy-strategies.md](docs/deploy-strategies.md) has the engines, every inventory field, the rule-by-rule mapping from each script, what was not ported and why, the production cutover checklist and the proposed inventory diff.
`deploy --browser-check` checks the service's public site in headless Chrome after a deploy that went through. It runs [`scripts/browser-check.js`](docs/browser-check.md) for that one site. The check is report only: it never changes the exit code or the release record. It needs Chrome where ovhost runs.
`deploy --browser-check` checks the service's public site in headless Chrome after a deploy that went through. It runs [`scripts/browser-check.js`](docs/browser-check.md) for that one site. The check is report only: it never changes the exit code or the release record. It needs Chrome where ovhost runs.
@@ -48,6 +48,9 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -48,6 +48,9 @@ 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)
+ service add <id> [--dry-run] copy the example inventory entry, clone the checkout, create
+ its 0600 env file, and provision PostgreSQL when declared;
+ print remaining setup (names only, never values)
data provision <service> [--dry-run] run roles/data/add-service.sh from the ovhost install: the
data provision <service> [--dry-run] run roles/data/add-service.sh from the ovhost install: the
service's PostgreSQL database, roles and Valkey user
service's PostgreSQL database, roles and Valkey user
(idempotent; prints names only, never values)
(idempotent; prints names only, never values)
@@ -234,6 +237,20 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -234,6 +237,20 @@ 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 'service': {
+ if (pos[1] !== 'add' || !pos[2] || pos[3]) throw new UsageError('usage: ovhost service add <id> [--dry-run]');
+ const r = await lib.serviceAdd.add(ctx, pos[2], { selfPath: selfPath || process.argv[1], dryRun: !!opts['dry-run'] });
+ if (json) emit(r);
+ else if (r.dryRun) for (const step of r.plan) out(`would: ${step}`);
+ else {
+ out(`added ${r.service}; inventory backup: ${r.backup}`);
+ out(`checkout: ${r.repo}; env file: ${r.envFile} (0600; set ${r.envNames.join(', ')})`);
+ if (r.provisioned) out(`data provision ${r.service}: complete`);
+ if (r.unset.length) out(`still empty in ${r.envFile}: ${r.unset.join(', ')}`);
+ for (const step of r.remaining) out(step);
+ }
+ return 0;
+ }
case 'data': {
case 'data': {
// The data role's own scripts, run from ovhost's install (ADR-035): the agent pipeline
// 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.
// reaches ovhost, not the shell. Output is redacted and the script's exit code passes through.
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+/** First-time service setup. The example inventory is read from this CLI install. */
+const path = require('path');
+const { normalise } = require('../inventory');
+const { OpError, EXIT } = require('../release-ops');
+const { installDirFor } = require('./self-update');
+const data = require('./data');
+const lock = require('../lock');
+const { parseExample, parseNames } = require('../envfile');
+
+function refuse(message) { throw new OpError(message, EXIT.USAGE); }
+function checkedUrl(raw, id) {
+ const url = raw.cloneUrl || `https://github.com/OpenVibers/OpenVibe.${id[0].toUpperCase()}${id.slice(1)}.git`;
+ let u;
+ try { u = new URL(url); } catch { refuse(`services.${id}.cloneUrl must be a GitHub HTTPS repository URL`); }
+ if (u.protocol !== 'https:' || u.hostname !== 'github.com' || u.username || u.password || u.search || u.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(u.pathname)) refuse(`services.${id}.cloneUrl must be a GitHub HTTPS repository URL without credentials`);
+ return u.href;
+}
+function baseUrl(svc, example) {
+ const vhost = svc.nginx && svc.nginx.vhost;
+ if (vhost && /^[a-z0-9.-]+\.conf$/i.test(vhost)) return `https://${vhost.slice(0, -5)}`;
+ if (svc.origin) return svc.origin;
+ const match = String(example).match(/^\s*BASE_URL\s*=\s*['"]?(https:\/\/[^\s'"/]+)[^\r\n]*$/m);
+ if (match) return match[1];
+ return null;
+}
+function productionEnv(example, svc) {
+ const values = { NODE_ENV: 'production', HOST: '127.0.0.1', PORT: String(svc.port) };
+ const url = baseUrl(svc, example);
+ if (url) values.BASE_URL = url;
+ const found = new Set();
+ const lines = String(example).split(/\r?\n/).map((line) => {
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
+ if (!m || !(m[1] in values)) return line;
+ found.add(m[1]);
+ return `${m[1]}=${values[m[1]]}`;
+ });
+ for (const [name, value] of Object.entries(values)) if (!found.has(name)) lines.push(`${name}=${value}`);
+ return lines.join('\n').replace(/\n*$/, '\n');
+}
+const OVERRIDES = ['NODE_ENV', 'HOST', 'PORT', 'BASE_URL'];
+const PROVISIONED = ['DATABASE_URL', 'DATABASE_DIRECT_URL', 'VALKEY_URL', 'VALKEY_PREFIX'];
+/** Names still empty in the new env file, and the commands that finish the setup. Never values. */
+function remainingSetup(id, envFile, envText, provisioned, overridden, netRepo, netEnv) {
+ const skip = new Set([...overridden, ...(provisioned ? PROVISIONED : [])]);
+ const names = parseNames(envText);
+ const unset = names.filter((n) => n.empty && !skip.has(n.name)).map((n) => n.name);
+ const remaining = [];
+ if (names.some((n) => n.name === 'OV_OAUTH_CLIENT_SECRET' || n.name === 'OV_OAUTH_CLIENT_ID')) {
+ // systemd-run parses Network's root-only env file the way its unit does: never source it with
+ // sh (values with & or $ would break). /usr/bin/env node matches how the units start node.
+ const run = `sudo systemd-run --quiet --wait --pipe --collect -p EnvironmentFile=${netEnv} -p WorkingDirectory=${netRepo} /usr/bin/env node server/setup/service-principal.js`;
+ remaining.push(`service principal (Network's production env; create, or rotate if Network already has ${id}): if ${run} list | grep -Eq '^${id}[[:space:]]'; then verb=rotate; else verb=create; fi; ${run} "$verb" ${id} --env-file ${envFile} # secret never printed`);
+ }
+ remaining.push(`then: ovhost validate ${id}; ovhost deploy ${id} --restart`);
+ return { unset, remaining };
+}
+async function add(ctx, id, { selfPath, dryRun = false } = {}) {
+ if (dryRun) return setup(ctx, id, { selfPath, dryRun });
+ if (!(await ctx.exec.isRoot())) refuse('service add must run as root');
+ // One inventory-wide lock across the read and the write: two adds never build on the same old inventory.
+ const held = await lock.acquire(ctx.exec, ctx.inv, '_inventory');
+ try { return await setup(ctx, id, { selfPath, dryRun }); } finally { await held.release(); }
+}
+async function setup(ctx, id, { selfPath, dryRun }) {
+ const { exec, inv } = ctx;
+ if (!/^[a-z][a-z0-9-]{0,30}$/.test(id || '')) refuse('service add needs a valid <id> (lowercase letters, digits, hyphens; max 31)');
+ if (inv.file !== '/etc/openvibe/host.json') refuse('service add requires /etc/openvibe/host.json (no --inventory override)');
+ const examplePath = path.join(installDirFor(selfPath).dir, 'host.example.json');
+ const exampleText = await exec.readFile(examplePath);
+ if (exampleText == null) refuse(`${examplePath} is missing from the ovhost install`);
+ let example, current;
+ try { example = JSON.parse(exampleText); } catch { refuse(`${examplePath} is not valid JSON`); }
+ const currentText = await exec.readFile(inv.file, { privileged: true });
+ try { current = JSON.parse(currentText); } catch { refuse(`${inv.file} is not valid JSON`); }
+ if (Object.hasOwn(current.services || {}, id)) refuse(`services.${id} already exists in ${inv.file}`);
+ const source = example.services && example.services[id];
+ if (!source) refuse(`services.${id} is absent from ${examplePath}`);
+ const { _note, ...entry } = source;
+ const candidate = { ...current, services: { ...current.services, [id]: entry } };
+ const services = normalise(candidate).services;
+ const svc = services[id];
+ if (svc.layout !== 'git') refuse(`services.${id} uses a release layout; first setup needs a plain checkout`);
+ if (!svc.envFile || !svc.port || !svc.owner) refuse(`services.${id} needs envFile, port and owner`);
+ if (svc.envFile !== `/etc/openvibe/${id}.env` && entry.envFile !== `/etc/openvibe/${id}.env`) refuse(`services.${id}.envFile must be /etc/openvibe/${id}.env for data provision`);
+ if (Array.isArray(svc.env.required) && svc.env.required.includes('BASE_URL') && !baseUrl(svc, '')) refuse(`services.${id} requires BASE_URL but has no nginx vhost or origin in the example inventory`);
+ const net = services.network;
+ const netRepo = (net && net.repo) || '/opt/openvibe.network';
+ const netEnv = (net && net.envFile) || '/etc/openvibe/network.env';
+ const url = checkedUrl(entry, id);
+ const checkout = await exec.stat(svc.repo);
+ if (checkout && (checkout.isSymlink || !checkout.isDir || (await exec.readdir(svc.repo) || []).length)) refuse(`${svc.repo} is not an empty directory`);
+ if (await exec.stat(svc.envFile)) refuse(`${svc.envFile} already exists`);
+ const inventoryStat = await exec.stat(inv.file);
+ if (!inventoryStat || !inventoryStat.isFile || inventoryStat.isSymlink) refuse(`${inv.file} must be a regular file`);
+ const backup = `${inv.file}.bak.${new Date(exec.now()).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`;
+ if (await exec.stat(backup)) refuse(`${backup} already exists`);
+ if (dryRun) return { service: id, dryRun: true, plan: [`back up ${inv.file} to ${backup} and add services.${id} without _note`, `clone ${url} into ${svc.repo} as ${svc.owner}`, `create ${svc.envFile} (0600) from checkout ${svc.env.example}; set NODE_ENV, HOST, PORT, and BASE_URL when a public origin is known`, `data provision ${id} if ${svc.env.example} declares DATABASE_URL`, `list the env names still empty; when ${svc.env.example} declares OV_OAUTH_CLIENT_ID/SECRET, give the Network service-principal command (run with Network's production env; create, or rotate when it exists); then validate and deploy`] };
+ const copy = await exec.run('cp', ['-p', '--', inv.file, backup], { privileged: true });
+ if (copy.code !== 0) refuse(`could not back up ${inv.file}`);
+ try {
+ await exec.writeFile(inv.file, `${JSON.stringify(candidate, null, 2)}\n`, { privileged: true, mode: inventoryStat.mode });
+ // writeFile replaces bytes in place. Restore ownership/mode if an executor used a new inode.
+ const owner = await exec.run('chown', [`${inventoryStat.uid}:${inventoryStat.gid}`, inv.file], { privileged: true });
+ if (owner.code !== 0) throw new Error(`could not restore ownership of ${inv.file}`);
+ if (!checkout) await exec.mkdir(svc.repo, { owner: svc.owner, mode: 0o750 });
+ else {
+ const own = await exec.run('chown', [`${svc.owner}:${svc.owner}`, svc.repo], { privileged: true });
+ if (own.code !== 0) throw new Error(`could not give ${svc.owner} the empty checkout directory`);
+ }
+ const cloned = await exec.run('git', ['clone', '--branch', svc.branch, '--', url, svc.repo], { as: svc.owner, timeoutMs: 600000 });
+ if (cloned.code !== 0) throw new Error(`git clone failed for ${id}`);
+ // The checkout is untrusted: a symlinked example could point at a root-only secret, so refuse a
+ // symlink and read it with the owner's permissions (covers symlinked parent directories too).
+ const exampleFile = path.join(svc.repo, svc.env.example);
+ const exampleStat = await exec.stat(exampleFile);
+ if (!exampleStat || exampleStat.isSymlink || !exampleStat.isFile) throw new Error(`${svc.env.example} must be a regular file in ${svc.repo}`);
+ const read = await exec.run('cat', ['--', exampleFile], { as: svc.owner });
+ if (read.code !== 0) throw new Error(`could not read ${svc.env.example} as ${svc.owner}`);
+ const envExample = read.stdout;
+ const envText = productionEnv(envExample, svc);
+ const overridden = baseUrl(svc, envExample) ? OVERRIDES : OVERRIDES.slice(0, 3);
+ await exec.writeFile(svc.envFile, envText, { privileged: true, mode: 0o600 });
+ const provisionDatabase = parseExample(envExample).declared.includes('DATABASE_URL');
+ if (provisionDatabase) {
+ const r = await data.provision({ ...ctx, inv: { ...inv, services: { ...inv.services, [id]: svc } } }, id, { selfPath });
+ if (!r.ok) throw new Error(`data provision ${id} failed (exit ${r.code})`);
+ }
+ return { service: id, dryRun: false, backup, repo: svc.repo, envFile: svc.envFile, envNames: overridden, provisioned: provisionDatabase, ...remainingSetup(id, svc.envFile, envText, provisionDatabase, overridden, netRepo, netEnv) };
+ } catch (err) {
+ throw new Error(`${err.message}; setup is incomplete; inventory backup: ${backup}`);
+ }
+}
+
+module.exports = { add, productionEnv };
@@ -12,6 +12,7 @@ const offsite = require('./offsite');
@@ -12,6 +12,7 @@ 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 data = require('./commands/data');
+const serviceAdd = require('./commands/service-add');
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');
@@ -47,6 +48,7 @@ module.exports = {
@@ -47,6 +48,7 @@ module.exports = {
snapshot,
snapshot,
selfUpdate,
selfUpdate,
data,
data,
+ serviceAdd,
drill,
drill,
DrillError,
DrillError,
releases,
releases,
@@ -18,6 +18,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -18,6 +18,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const files = new Map(); // path -> { type: 'file'|'dir'|'symlink', content, mode, owner, target }
const files = new Map(); // path -> { type: 'file'|'dir'|'symlink', content, mode, owner, target }
const calls = [];
const calls = [];
const repos = new Map(); // repoPath -> repo
const repos = new Map(); // repoPath -> repo
+ const remoteRepos = new Map(); // URL -> files for a new clone
const units = new Map(); // unit -> state
const units = new Map(); // unit -> state
const http = new Map(); // url -> fn({ headers, method, body }) -> { status, body } (request() also passes method and body)
const http = new Map(); // url -> fn({ headers, method, body }) -> { status, body } (request() also passes method and body)
const alivePids = new Set();
const alivePids = new Set();
@@ -25,7 +26,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -25,7 +26,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
let clock = start;
let clock = start;
let seq = 0;
let seq = 0;
const host = {
const host = {
- files, calls, repos, units, http, alivePids, listeners,
+ files, calls, repos, remoteRepos, units, http, alivePids, listeners,
nginxTest: () => ({ code: 0, stderr: 'nginx: configuration file /etc/nginx/nginx.conf test is successful' }),
nginxTest: () => ({ code: 0, stderr: 'nginx: configuration file /etc/nginx/nginx.conf test is successful' }),
npm: null, // (cwd, argv, as) -> undefined | { code, stderr } ; default installs package.json deps
npm: null, // (cwd, argv, as) -> undefined | { code, stderr } ; default installs package.json deps
npmRewritesLockfile: true,
npmRewritesLockfile: true,
@@ -183,6 +184,18 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -183,6 +184,18 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
return matches.length === 1 ? matches[0] : null;
return matches.length === 1 ? matches[0] : null;
}
}
function gitCmd(args, opts) {
function gitCmd(args, opts) {
+ if (args[0] === 'clone') {
+ const url = args[args.length - 2];
+ const dest = args[args.length - 1];
+ const source = remoteRepos.get(url);
+ if (!source || (files.has(path.resolve(dest)) && (files.get(path.resolve(dest)).type !== 'dir' || [...files.keys()].some((p) => p.startsWith(`${path.resolve(dest)}/`))))) return { code: 128, stdout: '', stderr: 'clone source missing or destination occupied' };
+ const branch = args[args.indexOf('--branch') + 1];
+ const repo = host.createRepo(dest, { owner: opts.as || 'root', branch });
+ const sha = repo.commit(source);
+ repo.publish(sha);
+ repo.checkout(sha);
+ return { code: 0, stdout: '', stderr: '' };
+ }
const repo = repos.get(args[1]) || repos.get(resolveLinks(args[1]));
const repo = repos.get(args[1]) || repos.get(resolveLinks(args[1]));
if (!repo) return { code: 128, stdout: '', stderr: 'not a git repository' };
if (!repo) return { code: 128, stdout: '', stderr: 'not a git repository' };
let i = 2;
let i = 2;
@@ -434,6 +447,13 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -434,6 +447,13 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
case 'systemctl': return systemctlCmd(args);
case 'systemctl': return systemctlCmd(args);
case 'nginx': return { stdout: '', ...host.nginxTest() };
case 'nginx': return { stdout: '', ...host.nginxTest() };
case 'rm': rmTree(args[args.length - 1]); return { code: 0, stdout: '', stderr: '' };
case 'rm': rmTree(args[args.length - 1]); return { code: 0, stdout: '', stderr: '' };
+ case 'cat': {
+ // Reads as opts.as: another user's file needs the world-read bit.
+ const e = get(args[args.length - 1]);
+ if (!e || e.type !== 'file') return { code: 1, stdout: '', stderr: 'cat: No such file or directory' };
+ if (opts.as && opts.as !== 'root' && e.owner !== opts.as && !(e.mode & 0o004)) return { code: 1, stdout: '', stderr: 'cat: Permission denied' };
+ return { code: 0, stdout: String(e.content), stderr: '' };
+ }
case 'install': {
case 'install': {
const src = args[args.length - 2];
const src = args[args.length - 2];
const dest = args[args.length - 1];
const dest = args[args.length - 1];
@@ -448,7 +468,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -448,7 +468,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const target = resolveLinks(args[args.length - 1]);
const target = resolveLinks(args[args.length - 1]);
const e = files.get(target);
const e = files.get(target);
if (!e) return { code: 1, stdout: '', stderr: 'chown: no such file' };
if (!e) return { code: 1, stdout: '', stderr: 'chown: no such file' };
- const who = args.filter((a) => !a.startsWith('-'))[0].split(':')[0];
+ const whoRaw = args.filter((a) => !a.startsWith('-'))[0].split(':')[0];
+ const who = whoRaw === '0' ? 'root' : whoRaw;
if (args.includes('-R')) { for (const [k, v] of files) if (k === target || k.startsWith(`${target}/`)) v.owner = who; } else e.owner = who;
if (args.includes('-R')) { for (const [k, v] of files) if (k === target || k.startsWith(`${target}/`)) v.owner = who; } else e.owner = who;
return { code: 0, stdout: '', stderr: '' };
return { code: 0, stdout: '', stderr: '' };
}
}
@@ -560,7 +581,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -560,7 +581,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
}
}
const e = get(src);
const e = get(src);
if (!e || e.type !== 'file') return { code: 1, stdout: '', stderr: `cp: cannot stat '${src}': No such file or directory` };
if (!e || e.type !== 'file') return { code: 1, stdout: '', stderr: `cp: cannot stat '${src}': No such file or directory` };
- put(dest, e.content, { owner: opts.as || user, mode: 0o644 });
+ put(dest, e.content, { owner: args.includes('-p') ? e.owner : opts.as || user, mode: args.includes('-p') ? e.mode : 0o644 });
return { code: 0, stdout: '', stderr: '' };
return { code: 0, stdout: '', stderr: '' };
}
}
default: return { code: 127, stdout: '', stderr: `fake host: no ${cmd}` };
default: return { code: 127, stdout: '', stderr: `fake host: no ${cmd}` };
new file mode 100644
new file mode 100644
@@ -0,0 +1,216 @@
@@ -0,0 +1,216 @@
+'use strict';
+const assert = require('assert');
+const { scenario, test, runTests, SECRET, lifecycleDoc } = require('./helpers');
+const { main } = require('../lib/cli');
+
+const SELF = '/opt/ovhost-install/bin/ovhost';
+const URL = 'https://github.com/OpenVibers/OpenVibe.Bot.git';
+
+function setup() {
+ const host = scenario();
+ host.put('/opt/ovhost-install/host.example.json', JSON.stringify({ services: { bot: {
+ _note: 'example-only note', repo: '/opt/openvibe.bot', owner: 'ubuntu', units: [],
+ envFile: '/etc/openvibe/bot.env', env: { required: 'from-example' }, port: 4630,
+ nginx: { vhost: 'openvibe.bot.conf' }, lifecycle: lifecycleDoc(),
+ databases: [{ name: 'bot', engine: 'postgresql', database: 'ov_bot' }],
+ } } }));
+ host.remoteRepos.set(URL, { '.env.example': `NODE_ENV=development\nHOST=0.0.0.0\nPORT=3000\nBASE_URL=https://example.invalid\nDATABASE_URL=\nOV_OAUTH_CLIENT_SECRET=\nEXAMPLE_SECRET=${SECRET}\n` });
+ host.put('/opt/ovhost-install/roles/data/add-service.sh', '#!/bin/sh\n', { mode: 0o755 });
+ const provision = [];
+ const run = host.exec.run;
+ host.exec.run = async (cmd, args, opts) => {
+ if (cmd.endsWith('/roles/data/add-service.sh')) {
+ provision.push({ cmd, args, opts });
+ return { code: 0, stdout: 'DATABASE_URL and VALKEY_URL set (values not shown)', stderr: '' };
+ }
+ return run(cmd, args, opts);
+ };
+ const cli = async (...argv) => {
+ const lines = [];
+ const code = await main(argv, { exec: host.exec, out: (line) => lines.push(line), env: {}, selfPath: SELF });
+ return { code, out: lines.join('\n') };
+ };
+ return { host, cli, provision };
+}
+
+runTests([
+ test('dry run gives a names-only plan and changes nothing', async () => {
+ const { host, cli, provision } = setup();
+ const before = host.read('/etc/openvibe/host.json');
+ const result = await cli('service', 'add', 'bot', '--dry-run');
+ assert.strictEqual(result.code, 0, result.out);
+ assert.match(result.out, /clone .*OpenVibe\.Bot\.git/);
+ assert.match(result.out, /data provision bot/);
+ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);
+ assert.strictEqual(host.read('/etc/openvibe/bot.env'), null);
+ assert.deepStrictEqual(provision, []);
+ assert.doesNotMatch(result.out, /development|example\.invalid|sk_live_/);
+ }),
+ test('adds inventory, checkout, production env and data with no values in output', async () => {
+ const { host, cli, provision } = setup();
+ const result = await cli('service', 'add', 'bot', '--json');
+ assert.strictEqual(result.code, 0, result.out);
+ assert.doesNotMatch(result.out, /NODE_ENV=production|127\.0\.0\.1|4630|https:\/\/openvibe\.bot|sk_live_/);
+ const live = JSON.parse(host.read('/etc/openvibe/host.json'));
+ assert.ok(live.services.bot);
+ assert.ok(!Object.hasOwn(live.services.bot, '_note'));
+ const backup = JSON.parse(result.out).backup;
+ assert.ok(host.read(backup));
+ assert.strictEqual((await host.exec.stat(backup)).mode, 0o640);
+ assert.strictEqual((await host.exec.stat('/etc/openvibe/host.json')).mode, 0o640);
+ assert.strictEqual((await host.exec.stat('/etc/openvibe/bot.env')).mode, 0o600);
+ assert.strictEqual((await host.exec.stat('/opt/openvibe.bot')).owner, 'ubuntu');
+ const env = host.read('/etc/openvibe/bot.env');
+ assert.match(env, /^NODE_ENV=production$/m);
+ assert.match(env, /^HOST=127\.0\.0\.1$/m);
+ assert.match(env, /^PORT=4630$/m);
+ assert.match(env, /^BASE_URL=https:\/\/openvibe\.bot$/m);
+ assert.match(env, new RegExp(SECRET));
+ assert.deepStrictEqual(provision.map((r) => r.args), [['bot']]);
+ assert.strictEqual(provision[0].opts.privileged, true);
+ assert.ok(host.calls.some((c) => c.cmd === 'git' && c.args[0] === 'clone' && c.as === 'ubuntu'));
+ const r = JSON.parse(result.out);
+ assert.deepStrictEqual(r.unset, ['OV_OAUTH_CLIENT_SECRET']);
+ const step = r.remaining.join('\n');
+ assert.ok(step.includes('sudo systemd-run'), step);
+ assert.ok(step.includes('-p EnvironmentFile=/etc/openvibe/network.env'), step);
+ assert.ok(step.includes('-p WorkingDirectory=/opt/openvibe.network'), step);
+ assert.ok(step.includes("service-principal.js list | grep -Eq '^bot[[:space:]]'"), step);
+ assert.ok(step.includes('then verb=rotate; else verb=create; fi'), step);
+ assert.ok(step.includes('"$verb" bot --env-file /etc/openvibe/bot.env'), step);
+ assert.match(r.remaining.join('\n'), /ovhost validate bot; ovhost deploy bot --restart/);
+ const repeat = await cli('service', 'add', 'bot');
+ assert.strictEqual(repeat.code, 1);
+ assert.match(repeat.out, /already exists/);
+ }),
+ test('text output names what is still empty and the remaining steps, never values', async () => {
+ const { cli } = setup();
+ const result = await cli('service', 'add', 'bot');
+ assert.strictEqual(result.code, 0, result.out);
+ assert.match(result.out, /^still empty in \/etc\/openvibe\/bot\.env: OV_OAUTH_CLIENT_SECRET$/m);
+ assert.ok(result.out.includes('sudo systemd-run'), result.out);
+ assert.ok(result.out.includes('-p EnvironmentFile=/etc/openvibe/network.env'), result.out);
+ assert.ok(result.out.includes('-p WorkingDirectory=/opt/openvibe.network'), result.out);
+ assert.ok(result.out.includes("service-principal.js list | grep -Eq '^bot[[:space:]]'"), result.out);
+ assert.ok(result.out.includes('then verb=rotate; else verb=create; fi'), result.out);
+ assert.ok(result.out.includes('"$verb" bot --env-file /etc/openvibe/bot.env'), result.out);
+ assert.match(result.out, /^then: ovhost validate bot; ovhost deploy bot --restart$/m);
+ assert.doesNotMatch(result.out, new RegExp(`NODE_ENV=production|127\\.0\\.0\\.1|4630|https://openvibe\\.bot|${SECRET}`));
+ }),
+ test("the principal step uses the inventory's Network checkout and env file", async () => {
+ const { host, cli } = setup();
+ const doc = JSON.parse(host.read('/etc/openvibe/host.json'));
+ doc.services.network = { repo: '/srv/net', owner: 'ubuntu', units: [], envFile: '/etc/openvibe/net-prod.env', port: 4000, lifecycle: lifecycleDoc() };
+ host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
+ const result = await cli('service', 'add', 'bot');
+ assert.strictEqual(result.code, 0, result.out);
+ assert.ok(result.out.includes('-p EnvironmentFile=/etc/openvibe/net-prod.env'), result.out);
+ assert.ok(result.out.includes('-p WorkingDirectory=/srv/net'), result.out);
+ assert.doesNotMatch(result.out, new RegExp(SECRET));
+ }),
+ test('refuses an occupied checkout before changing the inventory', async () => {
+ const { host, cli } = setup();
+ host.put('/opt/openvibe.bot/keep.txt', 'keep');
+ const before = host.read('/etc/openvibe/host.json');
+ const result = await cli('service', 'add', 'bot');
+ assert.strictEqual(result.code, 1);
+ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);
+ assert.strictEqual(host.read('/opt/openvibe.bot/keep.txt'), 'keep');
+ }),
+ test('a wiki-like service without a public URL adds successfully and omits BASE_URL', async () => {
+ const { host, cli, provision } = setup();
+ const example = JSON.parse(host.read('/opt/ovhost-install/host.example.json'));
+ example.services.wiki = {
+ repo: '/opt/openvibe.wiki', owner: 'ubuntu', units: [],
+ envFile: '/etc/openvibe/wiki.env', env: { required: 'from-example' }, port: 4800,
+ lifecycle: lifecycleDoc(),
+ };
+ host.put('/opt/ovhost-install/host.example.json', JSON.stringify(example));
+ host.remoteRepos.set('https://github.com/OpenVibers/OpenVibe.Wiki.git', {
+ '.env.example': 'NODE_ENV=development\nHOST=0.0.0.0\nPORT=3000\nSITE_TITLE=Wiki\n',
+ });
+ const result = await cli('service', 'add', 'wiki', '--json');
+ assert.strictEqual(result.code, 0, result.out);
+ assert.deepStrictEqual(JSON.parse(result.out).envNames, ['NODE_ENV', 'HOST', 'PORT']);
+ assert.doesNotMatch(host.read('/etc/openvibe/wiki.env'), /^BASE_URL=/m);
+ assert.ok(JSON.parse(host.read('/etc/openvibe/host.json')).services.wiki);
+ assert.deepStrictEqual(provision, []);
+ }),
+ test('an unresolved optional BASE_URL remains empty and is listed for setup', async () => {
+ const { host, cli } = setup();
+ const example = JSON.parse(host.read('/opt/ovhost-install/host.example.json'));
+ delete example.services.bot.nginx;
+ host.put('/opt/ovhost-install/host.example.json', JSON.stringify(example));
+ host.remoteRepos.set(URL, { '.env.example': 'NODE_ENV=development\nBASE_URL=\n' });
+ const result = await cli('service', 'add', 'bot', '--json');
+ assert.strictEqual(result.code, 0, result.out);
+ assert.ok(JSON.parse(result.out).unset.includes('BASE_URL'));
+ assert.match(host.read('/etc/openvibe/bot.env'), /^BASE_URL=$/m);
+ }),
+ test('an explicitly required URL without an inventory origin is refused before changes', async () => {
+ const { host, cli } = setup();
+ const example = JSON.parse(host.read('/opt/ovhost-install/host.example.json'));
+ delete example.services.bot.nginx;
+ example.services.bot.env.required = ['BASE_URL'];
+ host.put('/opt/ovhost-install/host.example.json', JSON.stringify(example));
+ const before = host.read('/etc/openvibe/host.json');
+ const result = await cli('service', 'add', 'bot');
+ assert.strictEqual(result.code, 1);
+ assert.match(result.out, /requires BASE_URL/);
+ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);
+ assert.strictEqual(host.read('/opt/openvibe.bot/.env.example'), null);
+ }),
+ test('reports an incomplete setup and its backup when provisioning fails', async () => {
+ const { host, cli } = setup();
+ const run = host.exec.run;
+ host.exec.run = async (cmd, args, opts) => cmd.endsWith('/roles/data/add-service.sh')
+ ? { code: 2, stdout: `DATABASE_URL=${SECRET}`, stderr: `password=${SECRET}` }
+ : run(cmd, args, opts);
+ const result = await cli('service', 'add', 'bot');
+ assert.strictEqual(result.code, 2);
+ assert.match(result.out, /setup is incomplete; inventory backup:/);
+ assert.doesNotMatch(result.out, new RegExp(SECRET));
+ assert.ok(JSON.parse(host.read('/etc/openvibe/host.json')).services.bot);
+ }),
+ test('a symlinked .env.example is refused and its target never reaches the env file', async () => {
+ const { host, cli } = setup();
+ host.put('/etc/openvibe/network.env', `OV_SECRET=${SECRET}\n`, { mode: 0o600 });
+ const run = host.exec.run;
+ host.exec.run = async (cmd, args, opts) => {
+ const r = await run(cmd, args, opts);
+ if (cmd === 'git' && args[0] === 'clone') {
+ await host.exec.removeFile('/opt/openvibe.bot/.env.example');
+ await host.exec.symlink('/etc/openvibe/network.env', '/opt/openvibe.bot/.env.example');
+ }
+ return r;
+ };
+ const result = await cli('service', 'add', 'bot');
+ assert.strictEqual(result.code, 2, result.out);
+ assert.match(result.out, /must be a regular file/);
+ assert.strictEqual(host.read('/etc/openvibe/bot.env'), null);
+ assert.doesNotMatch(result.out, new RegExp(SECRET));
+ }),
+ test('reads .env.example as the checkout owner', async () => {
+ const { host, cli } = setup();
+ const result = await cli('service', 'add', 'bot');
+ assert.strictEqual(result.code, 0, result.out);
+ assert.ok(host.calls.some((c) => c.cmd === 'cat' && c.args.at(-1) === '/opt/openvibe.bot/.env.example' && c.as === 'ubuntu'));
+ assert.ok(!host.reads.includes('/opt/openvibe.bot/.env.example'));
+ }),
+ test('a held inventory lock refuses the add and leaves the inventory alone; success releases it', async () => {
+ const { host, cli } = setup();
+ const lockFile = '/var/lib/openvibe-host/locks/_inventory.lock';
+ host.put(lockFile, JSON.stringify({ pid: 4242, at: 'earlier' }));
+ host.alivePids.add(4242);
+ const before = host.read('/etc/openvibe/host.json');
+ const held = await cli('service', 'add', 'bot');
+ assert.strictEqual(held.code, 1, held.out);
+ assert.match(held.out, /another ovhost operation on _inventory is running/);
+ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);
+ assert.ok(!host.calls.some((c) => c.cmd === 'git' && c.args[0] === 'clone'));
+ host.alivePids.delete(4242);
+ const ok = await cli('service', 'add', 'bot');
+ assert.strictEqual(ok.code, 0, ok.out);
+ assert.strictEqual(host.read(lockFile), null);
+ }),
+]);