openvibers/openvibe.host Commits

T0 report — ovhost logs <svc [--tail n]

OP OpenVibers committed 1 Oct 2026 · 3e036ca4bc · 3 files, +143 −1

Done by the harness job host-t0-add-ovhost-logs-svc-193714.

Changes

ddiff --git a/lib/cli.js b/lib/cli.js
iindex 4fe0ae3..1731451 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -18,6 +18,8 @@ const USAGE = `ovhost — OpenVibe.Host operator plane (Stage A)
Usage: ovhost <command> [args] [--inventory <file>] [--json]
status [<service>...] unit state, sha, readiness, protected sessions (all services)
++ logs <service> [--tail <n>] the journal of the service's units, newest last
++ (journalctl -u <unit>… -n <n> --no-pager; default 100)
validate <service> [--manifest <file>] env names, unit files, port, nginx vhost + nginx -t, deps, and the
lifecycle declaration (inventory, --manifest or the installed
openvibe-contracts manifest): every field, the shutdown deadline
@@@ -120,7 +122,7 @@ drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported
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']);
++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']);
function parseArgs(argv) {
const pos = [];
@@@ -327,6 +329,23 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
}
return 0;
}
++ case 'logs': {
++ // Read-only: the journal of the service's units (the inventory's
++ // service→unit mapping). journalctl runs with an argv array, never a
++ // shell string, so a service id or tail can inject nothing.
++ const svc = lib.inventory.service(inv, needService());
++ const tail = opts.tail === undefined ? 100 : Number(opts.tail);
++ if (!(Number.isInteger(tail) && tail >= 1 && tail <= 100000)) throw new UsageError('--tail must be a whole number from 1 to 100000');
++ if (!svc.units.length) throw new UsageError(`${svc.id} declares no units — nothing to read from the journal`);
++ const args = [];
++ for (const u of svc.units) args.push('-u', u);
++ args.push('-n', String(tail), '--no-pager');
++ const r = await exec.run('journalctl', args, { privileged: true });
++ if (json) { emit({ service: svc.id, units: svc.units, tail, code: r.code, lines: String(r.stdout || '').split('\n').filter((l) => l.length) }); return r.code === 0 ? 0 : 1; }
++ if (r.code !== 0) { out(`[ovhost] journalctl failed (exit ${r.code}): ${String(r.stderr || '').trim()}`); return 1; }
++ if (r.stdout) out(String(r.stdout).replace(/\n$/, ''));
++ return 0;
++ }
case 'reconcile': {
// The deploy controller, phase 1 (WS-X11): auto services follow main at green CI (lib/reconcile.js).
const only = pos.slice(1).length ? pos.slice(1) : null;
ddiff --git a/test/cli-logs.test.js b/test/cli-logs.test.js
nnew file mode 100644
iindex 0000000..cc7a38a
---- /dev/null
++++ b/test/cli-logs.test.js
@@@ -0,0 +1,116 @@
++'use strict';
++/**
++ * `ovhost logs <service> [--tail n]`: the service's units' journal through
++ * journalctl, driven against the fake host. Everything the command would
++ * execute is recorded in host.journalctlCalls, so the tests assert the exact
++ * argv (no shell, nothing interpolated) and the validation around it.
++ */
++const assert = require('assert');
++const { scenario, test, runTests } = require('./helpers');
++
++runTests([
++ test('logs runs journalctl for every unit of the service, default tail 100', async () => {
++ const host = scenario();
++ host.journalctl = (args) => ({ stdout: `journal of ${args.filter((a) => a.endsWith('.service')).join(' ')}\n` });
++ const r = await host.cli('logs', 'tools');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.deepStrictEqual(host.journalctlCalls, [{
++ args: ['-u', 'openvibe-tools.service', '-u', 'openvibe-tools-maps.service', '-n', '100', '--no-pager'],
++ as: null,
++ privileged: true,
++ }]);
++ assert.match(r.out, /journal of openvibe-tools\.service openvibe-tools-maps\.service/);
++ }),
++
++ test('logs --tail n passes -n n (space and = forms)', async () => {
++ const host = scenario();
++ let r = await host.cli('logs', 'live', '--tail', '5');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.deepStrictEqual(host.journalctlCalls[0].args, ['-u', 'openvibe-live.service', '-n', '5', '--no-pager']);
++ host.journalctlCalls.length = 0;
++ r = await host.cli('logs', 'live', '--tail=42');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.deepStrictEqual(host.journalctlCalls[0].args, ['-u', 'openvibe-live.service', '-n', '42', '--no-pager']);
++ }),
++
++ test('logs prints journal output verbatim, newest last', async () => {
++ const host = scenario();
++ host.journalctl = () => ({ stdout: 'Oct 01 12:00:00 host openvibe-live[1]: started\nOct 01 12:00:01 host openvibe-live[1]: ready\n' });
++ const r = await host.cli('logs', 'live', '--tail', '2');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /^Oct 01 12:00:00 host openvibe-live\[1\]: started$/m);
++ assert.match(r.out, /^Oct 01 12:00:01 host openvibe-live\[1\]: ready$/m);
++ }),
++
++ test('logs --json emits the query and the lines', async () => {
++ const host = scenario();
++ host.journalctl = () => ({ stdout: 'one\ntwo\n' });
++ const r = await host.cli('logs', 'live', '--tail', '3', '--json');
++ assert.strictEqual(r.code, 0, r.out);
++ const j = JSON.parse(r.out);
++ assert.deepStrictEqual(j, { service: 'live', units: ['openvibe-live.service'], tail: 3, code: 0, lines: ['one', 'two'] });
++ }),
++
++ test('logs validates --tail: whole number from 1, journalctl never runs', async () => {
++ const host = scenario();
++ for (const bad of ['0', '-1', 'abc', '1.5', '']) {
++ const r = await host.cli('logs', 'live', '--tail', bad);
++ assert.strictEqual(r.code, 1, `${bad}: ${r.out}`);
++ assert.match(r.out, /--tail must be a whole number from 1 to 100000/);
++ }
++ const r = await host.cli('logs', 'live', '--tail', '100001');
++ assert.strictEqual(r.code, 1, r.out);
++ assert.match(r.out, /--tail must be a whole number from 1 to 100000/);
++ assert.deepStrictEqual(host.journalctlCalls, []);
++ }),
++
++ test('logs needs a service that the inventory knows — nothing is executed for anything else', async () => {
++ const host = scenario();
++ let r = await host.cli('logs');
++ assert.strictEqual(r.code, 1);
++ assert.match(r.out, /logs needs a <service>/);
++ r = await host.cli('logs', 'nope');
++ assert.strictEqual(r.code, 1);
++ assert.match(r.out, /unknown service "nope"/);
++ // A shell metacharacter in the service position is only ever an unknown
++ // service name: journalctl takes an argv array, so nothing interpolates.
++ r = await host.cli('logs', 'live; touch /tmp/pwned', '--tail', '1');
++ assert.strictEqual(r.code, 1);
++ assert.match(r.out, /unknown service/);
++ r = await host.cli('logs', 'live', '--tail', '5; touch /tmp/pwned');
++ assert.strictEqual(r.code, 1);
++ assert.match(r.out, /--tail must be a whole number/);
++ assert.deepStrictEqual(host.journalctlCalls, []);
++ assert.ok(!host.calls.some((c) => c.cmd === 'journalctl'));
++ }),
++
++ test('logs refuses a service with no units instead of dumping the whole journal', async () => {
++ const host = scenario();
++ const r = await host.cli('logs', 'sites');
++ assert.strictEqual(r.code, 1, r.out);
++ assert.match(r.out, /sites declares no units/);
++ assert.deepStrictEqual(host.journalctlCalls, []);
++ }),
++
++ test('logs reports a journalctl failure and exits 1', async () => {
++ const host = scenario();
++ host.journalctl = () => ({ code: 1, stdout: '', stderr: 'Failed to get access to the journal' });
++ const r = await host.cli('logs', 'live');
++ assert.strictEqual(r.code, 1, r.out);
++ assert.match(r.out, /journalctl failed \(exit 1\): Failed to get access to the journal/);
++ const j = JSON.parse((await host.cli('logs', 'live', '--json')).out);
++ assert.strictEqual(j.code, 1);
++ assert.deepStrictEqual(j.lines, []);
++ }),
++
++ test('logs runs nothing but journalctl (read-only, no restart, no write)', async () => {
++ const host = scenario();
++ const before = host.files.size;
++ await host.cli('logs', 'live', '--tail', '10');
++ assert.deepStrictEqual(host.calls.map((c) => c.cmd), ['journalctl']);
++ assert.deepStrictEqual(host.restarts(), []);
++ assert.deepStrictEqual(host.socketViolations(), []);
++ assert.deepStrictEqual(host.reads, ['/etc/openvibe/host.json']); // the inventory load, nothing else
++ assert.strictEqual(host.files.size, before);
++ }),
++]);
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex aff44fe..56233d5 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -54,6 +54,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
reads: [],
certbot: null, // (args, opts) -> { code, stdout, stderr } | Error ; default: nothing to do
certbotCalls: [], // { args, as, privileged }
++ journalctl: null, // (args, opts) -> { code, stdout, stderr } ; default: no entries
++ journalctlCalls: [], // { args, as, privileged }
onRestart: null, // (unit) -> void
onSystemdRun: null, // (spec) -> undefined | { code, stderr } ; spec = { unit, uid, cwd, props, argv, pid }
onKill: null, // (pid, signal) -> false to ignore the signal
@@@ -438,6 +440,11 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
if (r instanceof Error) return { code: 1, stdout: '', stderr: r.message };
return { code: 0, stdout: 'Certificate not yet due for renewal; no action taken.', stderr: '', ...(r || {}) };
}
++ case 'journalctl': {
++ host.journalctlCalls.push({ args, as: opts.as || null, privileged: !!opts.privileged });
++ const r = host.journalctl ? host.journalctl(args, opts) : undefined;
++ return { code: 0, stdout: '', stderr: '', ...(r || {}) };
++ }
case 'fuser': return { code: (host.openFiles || new Set()).has(path.resolve(args[args.length - 1])) ? 0 : 1, stdout: '', stderr: '' };
// ── PostgreSQL tools (lib/dbengine.js runs each of these as the postgres OS user) ──
case 'psql': {