openvibers/openvibe.host Commits

Give Bot its vhost and enable its service during deploy

OP OpenVibers committed 2 Oct 2026 · ea013f0b7f · 5 files, +55 −4

Requested by finish-f1. Handed to the local harness by the cloud job pr-OpenVibe.Host-5; done by Codex GPT-6 Sol.

Changes

ddiff --git a/host.example.json b/host.example.json
iindex 4421e4f..875767d 100644
---- a/host.example.json
++++ b/host.example.json
@@@ -930,7 +930,10 @@
],
"nginx": {
"repoVhosts": "deploy/nginx/*.conf",
-- "installOnDeploy": true
++ "installOnDeploy": true,
++ "skipVhosts": [
++ "openvibe.bot.conf"
++ ]
},
"announce": {
"releaseFiles": "dist/*/release.json"
@@@ -2110,8 +2113,10 @@
}
},
"bot": {
-- "_note": "Not deployed yet; unit, env file, port and database from OpenVibe.Bot's deploy/ directory.",
++ "_note": "Not deployed yet. Bot owns openvibe.bot.conf: deploy bot installs its proxy vhost after Sites skips that filename. Before the first deploy, provision bot.env, the database and the existing TLS certificate; use ovhost deploy bot --restart if the checkout is already current so it installs and enables the service unit. Keep Sites on its ovhost deploy path (its legacy fallback still installs every vhost).",
"repo": "/opt/openvibe.bot",
++ "installUnits": true,
++ "enableUnits": true,
"units": [
"openvibe-bot.service"
],
@@@ -2123,6 +2128,11 @@
"required": "from-example"
},
"port": 4630,
++ "nginx": {
++ "vhost": "openvibe.bot.conf",
++ "repoVhost": "deploy/nginx/openvibe.bot.conf",
++ "installOnDeploy": true
++ },
"ready": {
"url": "http://127.0.0.1:4630/api/ready",
"timeoutSeconds": 30
ddiff --git a/lib/inventory.js b/lib/inventory.js
iindex 8bc34c2..996b009 100644
---- a/lib/inventory.js
++++ b/lib/inventory.js
@@@ -185,6 +185,7 @@ function normaliseService(id, raw, defaults) {
generated: s.generated || [],
preflight: normalisePreflight(s.preflight, where),
installUnits: s.installUnits === true,
++ enableUnits: s.enableUnits === true,
unitsMatch: s.unitsMatch || null,
announce: { releaseFiles: (s.announce && s.announce.releaseFiles) || null },
units: s.units || [],
@@@ -310,6 +311,7 @@ function normaliseService(id, raw, defaults) {
if (n.vhost && !FILE_NAME_RE.test(n.vhost)) throw new InventoryError(`${where}.nginx.vhost must be a file name, not a path`);
if (n.repoVhost) rel(n.repoVhost, `${where}.nginx.repoVhost`);
if (n.repoVhosts) rel(n.repoVhosts.replace(/\*\.conf$/, 'x.conf'), `${where}.nginx.repoVhosts`);
++ if (n.skipVhosts != null && (!Array.isArray(n.skipVhosts) || n.skipVhosts.some((name) => !FILE_NAME_RE.test(name)))) throw new InventoryError(`${where}.nginx.skipVhosts must list vhost file names`);
if (n.variant && !['http', 'sse', 'websocket'].includes(n.variant)) throw new InventoryError(`${where}.nginx.variant must be http, sse or websocket`);
if (n.certName && !FILE_NAME_RE.test(n.certName)) throw new InventoryError(`${where}.nginx.certName must be a name`);
// The tenants database may be a PostgreSQL database (the Host API is on PostgreSQL): the
ddiff --git a/lib/release-ops.js b/lib/release-ops.js
iindex 80997f3..ec0e17b 100644
---- a/lib/release-ops.js
++++ b/lib/release-ops.js
@@@ -168,7 +168,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
const vhostsRemoved = [];
if (svc.nginx && svc.nginx.installOnDeploy && svc.nginx.repoVhosts) {
const vdir = path.dirname(svc.nginx.repoVhosts);
-- for (const f of changed) if (f.endsWith('.conf') && path.dirname(f) === vdir && (await g.show(target, f)) == null) vhostsRemoved.push(path.basename(f));
++ for (const f of changed) if (f.endsWith('.conf') && path.dirname(f) === vdir && !svc.nginx.skipVhosts?.includes(path.basename(f)) && (await g.show(target, f)) == null) vhostsRemoved.push(path.basename(f));
}
return {
@@@ -290,7 +290,7 @@ async function installRepoVhosts(ctx, svc) {
let names;
if (pattern.endsWith('*.conf')) {
const dir = path.dirname(pattern);
-- names = ((await ctx.exec.readdir(path.join(svc.repo, dir))) || []).filter((e) => !e.isDir && e.name.endsWith('.conf')).map((e) => path.join(dir, e.name));
++ names = ((await ctx.exec.readdir(path.join(svc.repo, dir))) || []).filter((e) => !e.isDir && e.name.endsWith('.conf') && !n.skipVhosts?.includes(e.name)).map((e) => path.join(dir, e.name));
} else names = [pattern];
const files = [];
for (const rel of names.sort()) files.push({ name: path.basename(rel), text: await ctx.exec.readFile(path.join(svc.repo, rel)) });
@@@ -313,6 +313,12 @@ async function installUnits(ctx, svc, record) {
ctx.log(`installed unit files: ${changed.join(', ')}; daemon-reload`);
}
record.unitsInstalled = changed;
++ if (svc.enableUnits) {
++ for (const unit of svc.units) {
++ const enabled = await systemd.systemctl(ctx.exec, 'enable', unit);
++ if (enabled.code !== 0) throw new Error(`systemctl enable ${unit} failed: ${enabled.stderr.trim()}`);
++ }
++ }
}
async function backupBeforeRestart(ctx, svc, record) {
ddiff --git a/test/cli-inventory.test.js b/test/cli-inventory.test.js
iindex 478e87e..e3dcba9 100644
---- a/test/cli-inventory.test.js
++++ b/test/cli-inventory.test.js
@@@ -34,6 +34,10 @@ runTests([
assert.strictEqual(inv.services.live.codeDir, '/opt/openvibe.live/current');
assert.strictEqual(inv.services.live.release.git, '/opt/openvibe.live/repo');
assert.strictEqual(inv.services.openre.release.git, '/opt/openre.stream/repo');
++ assert.deepStrictEqual(inv.services.sites.nginx.skipVhosts, ['openvibe.bot.conf']);
++ assert.deepStrictEqual([inv.services.bot.nginx.vhost, inv.services.bot.nginx.repoVhost, inv.services.bot.nginx.installOnDeploy], ['openvibe.bot.conf', 'deploy/nginx/openvibe.bot.conf', true]);
++ assert.strictEqual(inv.services.bot.installUnits, true);
++ assert.strictEqual(inv.services.bot.enableUnits, true);
const text = JSON.stringify(raw);
assert.ok(!/(sk_|ghp_|BEGIN [A-Z ]*PRIVATE KEY|password\s*[:=]\s*\S)/i.test(text), 'no secret values in the example');
}),
@@@ -63,6 +67,7 @@ runTests([
const base = { owner: 'ubuntu' };
assert.throws(() => normalise({ services: { x: { ...base, repo: 'opt/x' } } }), /absolute path/);
assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', nginx: { vhost: '../../etc/passwd' } } } }), /file name/);
++ assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', nginx: { skipVhosts: ['../../etc/passwd'] } } } }), /vhost file names/);
assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', ready: { url: 'http://evil.example/ready' } } } }), /loopback/);
assert.throws(() => normalise({ services: { x: { ...base, repo: '/opt/x', protected: { kind: 'sqlite-count', db: '/d.db', sql: 'DELETE FROM vods' } } } }), /SELECT/);
assert.throws(() => normalise({ services: { 'X Y': { ...base, repo: '/opt/x' } } }), /service id/);
ddiff --git a/test/strategy-in-place.test.js b/test/strategy-in-place.test.js
iindex 100adcb..78bf09b 100644
---- a/test/strategy-in-place.test.js
++++ b/test/strategy-in-place.test.js
@@@ -179,6 +179,19 @@ runTests([
assert.ok(!host.calls.some((c) => c.cmd === 'npm' || c.cmd === 'nginx'));
}),
++ test('sites: a vhost assigned to another service is never installed over its proxy', async () => {
++ const host = sitesHost();
++ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
++ raw.services.sites.nginx = { repoVhosts: 'deploy/nginx/*.conf', installOnDeploy: true, skipVhosts: ['openvibe.bot.conf'] };
++ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });
++ host.put('/etc/nginx/sites-available/openvibe.bot.conf', 'server { proxy_pass http://127.0.0.1:4630; }');
++ host.push({ 'deploy/nginx/openvibe.bot.conf': 'server { root /opt/openvibe.sites/dist/openvibe.bot; }' }, 'bot placeholder');
++ const r = await host.cli('deploy', 'sites');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(host.read('/etc/nginx/sites-available/openvibe.bot.conf'), 'server { proxy_pass http://127.0.0.1:4630; }');
++ assert.ok(host.read('/etc/nginx/sites-available/openvibe.news.conf'), 'other Sites vhosts still install');
++ }),
++
test('sites: nginx -t failing never reloads nginx: the previous vhosts, checkout and build come back (exit 2)', async () => {
const host = sitesHost();
assert.strictEqual((await host.cli('deploy', 'sites', '--restart')).code, 0);
@@@ -317,6 +330,21 @@ runTests([
assert.strictEqual((await lastRecord(host, 'network')).strategy, 'git-checkout');
}),
++ test('unit bootstrap enables an installed service before its first restart', async () => {
++ const host = networkHost();
++ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
++ raw.services.network.enableUnits = true;
++ raw.services.network.nginx = { vhost: 'openvibe.bot.conf', repoVhost: 'deploy/nginx/openvibe.bot.conf', installOnDeploy: true };
++ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });
++ host.push({ 'deploy/systemd/openvibe-network.service': '[Service]\nExecStart=node server/index.js\nTimeoutStopSec=20\n', 'deploy/nginx/openvibe.bot.conf': 'server { proxy_pass http://127.0.0.1:4630; }', 'server/index.js': 'net2();' }, 'unit and vhost');
++ const r = await host.cli('deploy', 'network');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(host.read('/etc/nginx/sites-available/openvibe.bot.conf'), 'server { proxy_pass http://127.0.0.1:4630; }');
++ const actions = host.calls.filter((c) => c.cmd === 'systemctl').map((c) => c.args[0]);
++ assert.ok(actions.indexOf('daemon-reload') < actions.indexOf('enable'), actions.join(', '));
++ assert.ok(actions.indexOf('enable') < actions.indexOf('restart'), actions.join(', '));
++ }),
++
// ── what the wrappers probe ──
test('capabilities and --version: what a deploy wrapper checks before handing over to ovhost', async () => {
const host = gamesHost();