openvibers/openvibe.host Commits
T0 report — ovhost logs <svc [--tail n]
Changes
@@@ -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 thelifecycle declaration (inventory, --manifest or the installedopenvibe-contracts manifest): every field, the shutdown deadline@@@ -120,7 +122,7 @@ drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupportedbackup --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;nnew file mode 100644@@@ -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);++ }),++]);@@@ -54,6 +54,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', streads: [],certbot: null, // (args, opts) -> { code, stdout, stderr } | Error ; default: nothing to docertbotCalls: [], // { args, as, privileged }++ journalctl: null, // (args, opts) -> { code, stdout, stderr } ; default: no entries++ journalctlCalls: [], // { args, as, privileged }onRestart: null, // (unit) -> voidonSystemdRun: 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', stif (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': {
@@ -18,6 +18,8 @@ const USAGE = `ovhost — OpenVibe.Host operator plane (Stage A)
@@ -18,6 +18,8 @@ const USAGE = `ovhost — OpenVibe.Host operator plane (Stage A)
Usage: ovhost <command> [args] [--inventory <file>] [--json]
Usage: ovhost <command> [args] [--inventory <file>] [--json]
status [<service>...] unit state, sha, readiness, protected sessions (all services)
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
validate <service> [--manifest <file>] env names, unit files, port, nginx vhost + nginx -t, deps, and the
lifecycle declaration (inventory, --manifest or the installed
lifecycle declaration (inventory, --manifest or the installed
openvibe-contracts manifest): every field, the shutdown deadline
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
@@ -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
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) {
function parseArgs(argv) {
const pos = [];
const pos = [];
@@ -327,6 +329,23 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -327,6 +329,23 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
}
}
return 0;
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': {
case 'reconcile': {
// The deploy controller, phase 1 (WS-X11): auto services follow main at green CI (lib/reconcile.js).
// 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;
const only = pos.slice(1).length ? pos.slice(1) : null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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);
+ }),
+]);
@@ -54,6 +54,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -54,6 +54,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
reads: [],
reads: [],
certbot: null, // (args, opts) -> { code, stdout, stderr } | Error ; default: nothing to do
certbot: null, // (args, opts) -> { code, stdout, stderr } | Error ; default: nothing to do
certbotCalls: [], // { args, as, privileged }
certbotCalls: [], // { args, as, privileged }
+ journalctl: null, // (args, opts) -> { code, stdout, stderr } ; default: no entries
+ journalctlCalls: [], // { args, as, privileged }
onRestart: null, // (unit) -> void
onRestart: null, // (unit) -> void
onSystemdRun: null, // (spec) -> undefined | { code, stderr } ; spec = { unit, uid, cwd, props, argv, pid }
onSystemdRun: null, // (spec) -> undefined | { code, stderr } ; spec = { unit, uid, cwd, props, argv, pid }
onKill: null, // (pid, signal) -> false to ignore the signal
onKill: null, // (pid, signal) -> false to ignore the signal
@@ -438,6 +440,11 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -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 };
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 || {}) };
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: '' };
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) ──
// ── PostgreSQL tools (lib/dbengine.js runs each of these as the postgres OS user) ──
case 'psql': {
case 'psql': {