openvibers/openvibe.host Commits

Lifecycle declarations in ovhost validate (WS-P task 1): lib/lifecycle.js reads each service's lifecycle from the inventory entry (it replaces the manifest's on this host), validate --manifest <file>, or the manifest in the installed openvibe-contracts (0.55.0 and later), and validate fails naming the service and the field when one is missing (live: lifecycle.shutdown.deadlineSeconds is missing), when a running service declares no liveness or shutdown, when shutdown.deadlineSeconds exceeds a unit's stop timeout (systemd's effective TimeoutStopUSec, else TimeoutStopSec in the unit source) or the unit's KillSignal is another signal, and when the checkout's installed openvibe-contracts is outside contracts.range; worker units that drain longer than their stop timeout warn (ovhost never stops them); systemd show reports TimeoutStopUSec and KillSignal; test/lifecycle.test.js (11 cases), fixtures carry a lifecycle; README and host.example.json say where the block comes from and that the v0.49.0 pin has none yet

OP OpenVibers committed 26 Sept 2026 · 70fc2eaa2d · 12 files, +529 −12


Changes

ddiff --git a/README.md b/README.md
iindex 255d62b..153e660 100644
---- a/README.md
++++ b/README.md
@@@ -91,7 +91,7 @@ Not demonstrated yet: any of this on the production host. The Wave 21 exit crite
```
ovhost status [<service>...] unit state, sha, readiness, protected sessions
--ovhost validate <service> env NAMES, unit files, port, vhost + nginx -t, deps
++ovhost validate <service> [--manifest <file>] env NAMES, unit files, port, vhost + nginx -t, deps, lifecycle
ovhost env-names <service> names declared in the checkout's .env.example
ovhost show [<service>...] the inventory as ovhost reads it (repo, owner, units, socket, workers, probes)
ovhost plan <service> [--to <sha>] [--no-fetch]
@@@ -159,6 +159,8 @@ The sandbox blocks writes outside the drill directory and addresses beyond loopb
`host.example.json` describes every service on the host. It records the checkout, owner/`runAs`, units and socket unit, `unitSources` (repo unit files), env file, `env.required` (names, or `"from-example"`), port, loopback `ready` URL (plus headers, since Tools needs `Host: openvibe.tools`), `packages` (`"apps/*"` for Tools), install command, `build` hooks, `noRestartPaths`, the `protected` probe (`http-json-count`, `sqlite-count`, or `sum` of several: Live streams, Media recordings, Events SSE connections, Chat sockets, Games players online, OpenRe ingest sessions, Tools running jobs), the `drain` policy, `databases`, `backupOnChange`, `nginx`, `layout` (`release` for a `releases/<id>` + `current` service deployed by its own script, which ovhost does not manage) and `workerUnits`. `workerUnits` are units, or templates such as OpenRe's `[email protected]`, that belong to the service but that ovhost **never starts, stops or restarts**: they drain and exit on their own. `status` and `validate` list their instances read-only, `validate` warns when none runs, and the inventory refuses a worker unit, or an instance of one, in `units`. The real file is `/etc/openvibe/host.json` and is **never committed**. When `ovhost` runs as root it refuses an inventory that is not root-owned or that anyone else can write, and it rejects relative paths, non-loopback probe URLs, non-SELECT probe queries and vhost names that contain a path.
++**Lifecycle** (roadmap WS-P task 1, `lib/lifecycle.js`). Each service's lifecycle is the `lifecycle` block of its manifest in openvibe-contracts (≥ 0.55.0): liveness, shutdown (signal, `deadlineSeconds`, drains, workers), startupRecovery, rollback, contracts and leases. A service entry may carry its own `lifecycle` block, which replaces the manifest's on this host, and `validate --manifest <file>` reads another manifest file. `ovhost validate` fails when no block is found or a field is missing, naming the service and the field (`live: lifecycle.shutdown.deadlineSeconds is missing`). It also fails when `deadlineSeconds` exceeds a unit's stop timeout (systemd's effective `TimeoutStopUSec`, else `TimeoutStopSec` in the unit source) or the unit's `KillSignal` is another signal, and when the checkout's installed openvibe-contracts is outside `contracts.range`. Worker units that drain longer than their stop timeout are a warning, since ovhost never stops them. This repository still pins openvibe-contracts v0.49.0, which has no lifecycle blocks, so until that pin moves to v0.55.0 or later `validate` reports every service as undeclared.
++
`games` is listed as `managed: false`. It is a pnpm workspace with a TypeScript build, and those steps are not encoded yet. `status`, `validate`, `snapshot` and `backup` work for it; `deploy` and `rollback` refuse it.
## Installing on the host (for the operator)
ddiff --git a/host.example.json b/host.example.json
iindex 4068346..3ccc100 100644
---- a/host.example.json
++++ b/host.example.json
@@@ -6,6 +6,7 @@
"backupStagingDir": "/var/backups/openvibe.staging",
"drillDir": "/var/lib/openvibe-drills",
"_drill": "Restore drills (ovhost drill <service>): each service with a database has a drill block. The drill port is the service port + 10000 on 127.0.0.1. http://127.0.0.1:9 is a closed port, used where an empty value would fall back to a production service on loopback. An empty OV_OAUTH_CLIENT_SECRET stops a service from minting service tokens, so it cannot call other services with its identity. Loopback stays reachable from the drill sandbox (services need Network's public keys), so these overrides are what keep a drill away from production. _notes explain what is left on and why.",
++ "_lifecycle": "Lifecycle (ovhost validate, WS-P task 1): each service's lifecycle comes from its manifest in openvibe-contracts (the service's `manifest` id, default its key), from openvibe-contracts 0.55.0 on. A `lifecycle` block in a service entry replaces the manifest's on this host; none is needed today. validate compares shutdown.deadlineSeconds with each unit's effective TimeoutStopSec and KillSignal, and the checkout's installed openvibe-contracts with contracts.range.",
"nginx": {
"sitesAvailable": "/etc/nginx/sites-available",
"sitesEnabled": "/etc/nginx/sites-enabled",
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 4116944..5953fad 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -13,7 +13,10 @@ 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)
-- validate <service> env names, unit files, port, nginx vhost + nginx -t, deps
++ 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
++ within the unit's TimeoutStopSec, contracts.range
env-names <service> variable NAMES declared in the checkout's .env.example
show [<service>...] the inventory as ovhost reads it: repo, owner, units, socket,
workers, port, ready URL, protected probe (no values)
@@@ -119,7 +122,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
const ctx = { exec, inv, log };
switch (cmd) {
case 'validate': {
-- const r = await lib.validate(ctx, needService());
++ const r = await lib.validate(ctx, needService(), { manifestFile: opts.manifest });
if (json) emit(r);
else {
for (const f of r.findings) out(`${ICON[f.level]} ${f.area.padEnd(8)} ${f.message}`);
ddiff --git a/lib/commands/validate.js b/lib/commands/validate.js
iindex e0a383e..33b4fcb 100644
---- a/lib/commands/validate.js
++++ b/lib/commands/validate.js
@@@ -12,6 +12,13 @@
* nginx the vhost is in sites-available and linked in sites-enabled; `nginx -t` is clean.
* checkout owned by the declared owner, on the declared branch, no tracked changes.
* deps every dependency resolves (the Tools rule).
++ * lifecycle the service's lifecycle declaration (WS-P task 1; lib/lifecycle.js): from the inventory,
++ * --manifest <file> or the installed openvibe-contracts manifest. Every required field is
++ * present (the finding names the service and the field); the shutdown deadline is within
++ * each unit's stop timeout (systemd's effective TimeoutStopUSec, else TimeoutStopSec in the
++ * unit source) and the unit's KillSignal is the declared signal; worker units that drain
++ * longer than their stop timeout are a warning (ovhost never stops them); the checkout's
++ * installed openvibe-contracts is inside contracts.range.
*
* Every finding is { level: error|warn|ok|info, area, message }. Exit 2 if any error.
*/
@@@ -21,9 +28,12 @@ const envfile = require('../envfile');
const systemd = require('../systemd');
const nginx = require('../nginx');
const deps = require('../deps');
++const lifecycle = require('../lifecycle');
const { git } = require('../git');
--async function validate(ctx, id) {
++const secs = (n) => (n === Infinity ? 'infinity' : `${n} s`);
++
++async function validate(ctx, id, opts = {}) {
const { exec, inv } = ctx;
const svc = service(inv, id);
const findings = [];
@@@ -121,6 +131,9 @@ async function validate(ctx, id) {
if (have !== want) add('warn', 'units', `${unit} differs from ${src} (deploy --install-units installs it)`);
}
++ // ── lifecycle ──
++ await checkLifecycle(ctx, svc, repoStat, add, opts);
++
// ── port ──
if (svc.port) {
try {
@@@ -169,6 +182,70 @@ async function validate(ctx, id) {
return { service: id, ok: errors === 0, errors, warnings: findings.filter((f) => f.level === 'warn').length, findings };
}
++async function checkLifecycle({ exec }, svc, repoStat, add, opts) {
++ const { lifecycle: lc, source } = lifecycle.resolve(svc, { manifestFile: opts.manifestFile, contracts: opts.contracts });
++ if (!lc) {
++ add('error', 'lifecycle', `${svc.id}: no lifecycle declared (looked at ${source}); the manifest needs its lifecycle block (openvibe-contracts ≥ 0.55.0), or set services.${svc.id}.lifecycle`);
++ return;
++ }
++ const problems = lifecycle.check(lc, { runs: svc.units.length > 0 });
++ for (const p of problems) add('error', 'lifecycle', `${svc.id}: ${p.field} ${p.problem} (from ${source})`);
++ if (!problems.length) add('ok', 'lifecycle', `${svc.id}: liveness, shutdown, startupRecovery, rollback, contracts and leases declared by ${source}`);
++
++ const sd = lc.shutdown;
++ if (sd && !lifecycle.isNone(sd) && typeof sd.deadlineSeconds === 'number') {
++ for (const u of svc.units) {
++ const st = await systemd.show(exec, u);
++ let timeout = null;
++ let from = null;
++ if (st.load === 'loaded' && st.timeoutStop) {
++ timeout = lifecycle.parseSeconds(st.timeoutStop);
++ from = `systemd TimeoutStopUSec=${st.timeoutStop}`;
++ }
++ if (timeout == null && svc.unitSources[u]) {
++ const t = lifecycle.unitFileTimeout(await exec.readFile(path.join(svc.repo, svc.unitSources[u])));
++ if (t != null) { timeout = t; from = `TimeoutStopSec in ${svc.unitSources[u]}`; }
++ }
++ if (timeout == null) add('info', 'lifecycle', `${u}: stop timeout unknown (unit not loaded, no TimeoutStopSec in a unit source); deadlineSeconds ${sd.deadlineSeconds} not compared`);
++ else if (sd.deadlineSeconds > timeout) add('error', 'lifecycle', `${svc.id}: lifecycle.shutdown.deadlineSeconds ${sd.deadlineSeconds} exceeds the ${u} stop timeout of ${secs(timeout)} (${from}); systemd would kill it mid-drain`);
++ else add('ok', 'lifecycle', `${u}: shutdown deadline ${sd.deadlineSeconds} s within its stop timeout of ${secs(timeout)}`);
++ const sig = st.load === 'loaded' ? lifecycle.signalName(st.killSignal) : null;
++ if (sig && sd.signal && sig !== sd.signal) add('error', 'lifecycle', `${svc.id}: ${u} stops with ${sig} (KillSignal) but lifecycle.shutdown.signal is ${sd.signal}`);
++ }
++ if (sd.workers && typeof sd.workers.deadlineSeconds === 'number') {
++ for (const w of svc.workerUnits) {
++ let list = [];
++ try { list = await systemd.instances(exec, w); } catch { list = []; }
++ for (const i of list) {
++ const st = await systemd.show(exec, i.unit);
++ const timeout = lifecycle.parseSeconds(st.timeoutStop);
++ if (timeout != null && sd.workers.deadlineSeconds > timeout) {
++ add('warn', 'lifecycle', `${i.unit}: workers drain for up to ${sd.workers.deadlineSeconds} s (lifecycle.shutdown.workers) but the unit stops them after ${secs(timeout)}; an explicit stop or a host shutdown kills a worker that is still draining (ovhost never stops worker units)`);
++ }
++ }
++ }
++ }
++ }
++
++ const c = lc.contracts;
++ if (c && !lifecycle.isNone(c) && typeof c.range === 'string' && repoStat && repoStat.isDir) {
++ const root = svc.layout === 'release' ? path.join(svc.repo, 'current') : svc.repo;
++ const pkgDirs = await deps.expandPackages(exec, { ...svc, repo: root });
++ let found = 0;
++ for (const d of pkgDirs) {
++ const text = await exec.readFile(path.join(root, d, 'node_modules', 'openvibe-contracts', 'package.json'));
++ if (text == null) continue;
++ found += 1;
++ let version = null;
++ try { version = JSON.parse(text).version; } catch { /* reported below */ }
++ if (!version) add('error', 'lifecycle', `${d}: node_modules/openvibe-contracts/package.json has no version`);
++ else if (!lifecycle.satisfies(version, c.range)) add('error', 'lifecycle', `${svc.id}: ${d === '.' ? 'the checkout' : d} has openvibe-contracts ${version}, outside lifecycle.contracts.range ${c.range}`);
++ else add('ok', 'lifecycle', `${d === '.' ? 'the checkout' : d}: openvibe-contracts ${version} within ${c.range}`);
++ }
++ if (!found) add('info', 'lifecycle', `openvibe-contracts is not installed in the checkout; contracts.range ${c.range} not compared`);
++ }
++}
++
/** Names declared in the checkout's .env.example — to help write env.required. Names only. */
async function envNames(ctx, id) {
const svc = service(ctx.inv, id);
ddiff --git a/lib/inventory.js b/lib/inventory.js
iindex 4e6b56e..38ba6ca 100644
---- a/lib/inventory.js
++++ b/lib/inventory.js
@@@ -79,6 +79,9 @@ function normaliseService(id, raw, defaults) {
}),
backupOnChange: s.backupOnChange || [],
nginx: s.nginx || null,
++ // Lifecycle (WS-P task 1): normally the service manifest's `lifecycle` block; an inventory
++ // `lifecycle` replaces it for this host (lib/lifecycle.js resolve(), checked by validate).
++ lifecycle: s.lifecycle == null ? null : s.lifecycle,
drill: null,
};
if (typeof out.owner !== 'string' || !/^[a-z_][a-z0-9_-]*$/.test(out.owner)) throw new InventoryError(`${where}.owner must be a user name`);
@@@ -135,6 +138,7 @@ function normaliseService(id, raw, defaults) {
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`);
}
++ if (out.lifecycle !== null && (typeof out.lifecycle !== 'object' || Array.isArray(out.lifecycle))) throw new InventoryError(`${where}.lifecycle must be an object (the manifest's lifecycle block)`);
for (const p of out.backupOnChange) rel(p, `${where}.backupOnChange`);
if (s.drill) out.drill = normaliseDrill(s.drill, out, where);
return out;
ddiff --git a/lib/lifecycle.js b/lib/lifecycle.js
nnew file mode 100644
iindex 0000000..e3181e0
---- /dev/null
++++ b/lib/lifecycle.js
@@@ -0,0 +1,187 @@
++'use strict';
++/**
++ * Lifecycle declarations (roadmap WS-P task 1): how a service starts, stops, recovers and is rolled
++ * back, as its manifest's `lifecycle` block says (openvibe-contracts ≥ 0.55.0,
++ * registry.service-manifest@1). `ovhost validate` checks the declaration against the host.
++ *
++ * resolve() where the block comes from: the inventory entry's own `lifecycle` (the host's
++ * word wins), else `--manifest <file>`, else the service manifest in the installed
++ * openvibe-contracts (the inventory's `manifest` id)
++ * check() every required field, named: `lifecycle.shutdown.deadlineSeconds is missing`
++ * parseSeconds() a systemd time span ("1min 30s", "15", "30min", "infinity") in seconds
++ * satisfies() "^x.y.z" / ">=x.y.z <a.b.c" / "x.y.z", the range forms the manifests use
++ *
++ * A part that does not apply is { none: "<why>" }. A service with units runs processes, so its
++ * liveness and shutdown can never be none.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const PARTS = ['liveness', 'shutdown', 'startupRecovery', 'rollback', 'contracts', 'leases'];
++const RECOVERY_KINDS = ['outbox', 'jobs', 'sessions', 'consumer', 'schedule', 'state'];
++const STOP_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGQUIT', 'SIGHUP'];
++// systemctl show prints KillSignal= as a number.
++const SIGNAL_NUMBERS = { 1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 9: 'SIGKILL', 10: 'SIGUSR1', 12: 'SIGUSR2', 15: 'SIGTERM' };
++
++const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
++const isNone = (v) => isObj(v) && Object.keys(v).length === 1 && typeof v.none === 'string';
++const text = (v) => typeof v === 'string' && v.trim().length > 0;
++
++/**
++ * -> { lifecycle, source } — lifecycle is null when nothing declares one. `contracts` is injectable
++ * for tests; by default the installed openvibe-contracts package.
++ */
++function resolve(svc, { manifestFile = null, contracts = null } = {}) {
++ if (svc.lifecycle) return { lifecycle: svc.lifecycle, source: `the inventory (services.${svc.id}.lifecycle)` };
++ if (manifestFile) {
++ let manifest;
++ try { manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')); } catch (err) { return { lifecycle: null, source: `${manifestFile} (unreadable: ${err.message})` }; }
++ return { lifecycle: manifest.lifecycle || null, source: `manifest file ${path.basename(manifestFile)}` };
++ }
++ let lib = contracts;
++ let version = lib && lib.version ? lib.version : '?';
++ if (!lib) {
++ try {
++ lib = require('openvibe-contracts');
++ version = require('openvibe-contracts/package.json').version;
++ } catch (err) { return { lifecycle: null, source: `openvibe-contracts (not installed: ${err.message})` }; }
++ }
++ const manifest = lib.services && lib.services.get(svc.manifest);
++ if (!manifest) return { lifecycle: null, source: `openvibe-contracts v${version} (no "${svc.manifest}" manifest)` };
++ return { lifecycle: manifest.lifecycle || null, source: `the "${svc.manifest}" manifest in openvibe-contracts v${version}` };
++}
++
++/**
++ * Every missing or malformed field -> [{ field, problem }]. `runs`: the service has units, so
++ * liveness and shutdown must be real.
++ */
++function check(lc, { runs = false } = {}) {
++ const out = [];
++ const miss = (field, problem = 'is missing') => out.push({ field: `lifecycle.${field}`, problem });
++ if (!isObj(lc)) { out.push({ field: 'lifecycle', problem: 'is missing' }); return out; }
++ for (const part of PARTS) {
++ const v = lc[part];
++ if (v == null) { miss(part); continue; }
++ if (!isObj(v)) { miss(part, 'must be an object'); continue; }
++ if ('none' in v) {
++ if (!isNone(v) || !text(v.none)) miss(`${part}.none`, 'must be the only key, with a reason');
++ else if (runs && (part === 'liveness' || part === 'shutdown')) miss(part, `is none ("${v.none}"), but the service runs units`);
++ continue;
++ }
++ if (part === 'liveness') {
++ if (!text(v.endpoint)) miss('liveness.endpoint');
++ else if (!v.endpoint.startsWith('/')) miss('liveness.endpoint', 'must be a path');
++ if (!text(v.means)) miss('liveness.means');
++ } else if (part === 'shutdown') {
++ if (v.signal == null) miss('shutdown.signal');
++ else if (!STOP_SIGNALS.includes(v.signal)) miss('shutdown.signal', `must be one of ${STOP_SIGNALS.join(', ')}`);
++ if (v.deadlineSeconds == null) miss('shutdown.deadlineSeconds');
++ else if (!(typeof v.deadlineSeconds === 'number' && v.deadlineSeconds >= 0)) miss('shutdown.deadlineSeconds', 'must be a number of seconds ≥ 0');
++ if (!Array.isArray(v.drains) || !v.drains.length) miss('shutdown.drains', v.drains == null ? 'is missing' : 'must list what it drains');
++ if (v.workers != null) {
++ if (!isObj(v.workers)) miss('shutdown.workers', 'must be an object');
++ else {
++ if (!(typeof v.workers.deadlineSeconds === 'number' && v.workers.deadlineSeconds >= 0)) miss('shutdown.workers.deadlineSeconds', v.workers.deadlineSeconds == null ? 'is missing' : 'must be a number of seconds ≥ 0');
++ if (!Array.isArray(v.workers.drains) || !v.workers.drains.length) miss('shutdown.workers.drains');
++ }
++ }
++ } else if (part === 'startupRecovery') {
++ if (!Array.isArray(v.resumes) || !v.resumes.length) miss('startupRecovery.resumes');
++ else v.resumes.forEach((r, i) => {
++ if (!isObj(r) || !RECOVERY_KINDS.includes(r.kind)) miss(`startupRecovery.resumes[${i}].kind`, `must be one of ${RECOVERY_KINDS.join(', ')}`);
++ if (!isObj(r) || !text(r.what)) miss(`startupRecovery.resumes[${i}].what`);
++ });
++ } else if (part === 'rollback') {
++ if (!Array.isArray(v.conditions) || !v.conditions.length) miss('rollback.conditions');
++ if (!text(v.window)) miss('rollback.window');
++ if (v.blockers == null) miss('rollback.blockers');
++ else if (!(isNone(v.blockers) || (Array.isArray(v.blockers) && v.blockers.length && v.blockers.every(text)))) miss('rollback.blockers', 'must list blockers or be { none: reason }');
++ } else if (part === 'contracts') {
++ if (!text(v.range)) miss('contracts.range');
++ else if (!parseRange(v.range)) miss('contracts.range', `"${v.range}" is not a version range`);
++ } else if (part === 'leases') {
++ if (!Array.isArray(v.claims) || !v.claims.length) miss('leases.claims');
++ else v.claims.forEach((c, i) => {
++ for (const k of ['what', 'holder', 'expires', 'fencing']) if (!isObj(c) || !text(c[k])) miss(`leases.claims[${i}].${k}`);
++ });
++ }
++ }
++ return out;
++}
++
++const SPAN_UNITS = {
++ us: 1e-6, usec: 1e-6, ms: 1e-3, msec: 1e-3,
++ s: 1, sec: 1, second: 1, seconds: 1,
++ m: 60, min: 60, minute: 60, minutes: 60,
++ h: 3600, hr: 3600, hour: 3600, hours: 3600,
++ d: 86400, day: 86400, days: 86400,
++};
++
++/** A systemd time span -> seconds (Infinity for "infinity"), or null when it is not one. */
++function parseSeconds(value) {
++ const s = String(value == null ? '' : value).trim();
++ if (!s) return null;
++ if (s === 'infinity') return Infinity;
++ if (/^\d+(\.\d+)?$/.test(s)) return Number(s);
++ let total = 0;
++ let rest = s;
++ const re = /^\s*(\d+(?:\.\d+)?)\s*([a-z]+)/;
++ while (rest.trim()) {
++ const m = re.exec(rest);
++ if (!m || !(m[2] in SPAN_UNITS)) return null;
++ total += Number(m[1]) * SPAN_UNITS[m[2]];
++ rest = rest.slice(m[0].length);
++ }
++ return Math.round(total * 1e6) / 1e6;
++}
++
++/** `TimeoutStopSec=` in a unit file's text (the last one wins, like systemd), or null. */
++function unitFileTimeout(text) {
++ let found = null;
++ for (const line of String(text || '').split('\n')) {
++ const m = /^\s*TimeoutStopSec\s*=\s*(.*?)\s*$/.exec(line);
++ if (m) found = m[1];
++ }
++ return found == null ? null : parseSeconds(found);
++}
++
++function signalName(value) {
++ if (value == null || value === '') return null;
++ if (/^\d+$/.test(String(value))) return SIGNAL_NUMBERS[Number(value)] || `signal ${value}`;
++ const s = String(value).toUpperCase();
++ return s.startsWith('SIG') ? s : `SIG${s}`;
++}
++
++function cmp(a, b) {
++ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
++ return 0;
++}
++
++const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
++
++/** -> [{ op, v }] per alternative, or null. */
++function parseRange(range) {
++ const alts = String(range).split('||').map((alt) => alt.trim().split(/\s+/).map((part) => {
++ const m = /^(\^|>=|<=|>|<|=)?(\d+\.\d+\.\d+)$/.exec(part);
++ return m ? { op: m[1] || '=', v: VERSION_RE.exec(m[2]).slice(1).map(Number) } : null;
++ }));
++ return alts.every((a) => a.every(Boolean)) ? alts : null;
++}
++
++function satisfies(version, range) {
++ const m = VERSION_RE.exec(String(version || '').replace(/^v/, ''));
++ const alts = parseRange(range);
++ if (!m || !alts) return false;
++ const v = m.slice(1).map(Number);
++ return alts.some((parts) => parts.every(({ op, v: w }) => {
++ const c = cmp(v, w);
++ if (op === '^') return c >= 0 && (w[0] > 0 ? v[0] === w[0] : w[1] > 0 ? v[0] === 0 && v[1] === w[1] : cmp(v, w) === 0);
++ if (op === '>=') return c >= 0;
++ if (op === '>') return c > 0;
++ if (op === '<=') return c <= 0;
++ if (op === '<') return c < 0;
++ return c === 0;
++ }));
++}
++
++module.exports = { PARTS, RECOVERY_KINDS, resolve, check, parseSeconds, unitFileTimeout, signalName, satisfies, isNone };
ddiff --git a/lib/systemd.js b/lib/systemd.js
iindex 5150ca5..b94f3e4 100644
---- a/lib/systemd.js
++++ b/lib/systemd.js
@@@ -8,7 +8,7 @@
const SOCKET_SAFE_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'cat', 'status', 'list-units']);
const ALLOWED_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'restart', 'reload', 'cat', 'status', 'daemon-reload', 'list-units']);
const READ_ONLY_ACTIONS = ['show', 'is-active', 'is-enabled', 'cat', 'status', 'list-units'];
--const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts'];
++const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts', 'TimeoutStopUSec', 'KillSignal'];
class SocketGuardError extends Error {}
@@@ -43,6 +43,10 @@ async function show(exec, unit) {
dropIns: (props.DropInPaths || '').split(/\s+/).filter(Boolean),
partOf: (props.PartOf || '').split(/\s+/).filter(Boolean),
restarts: Number(props.NRestarts || 0),
++ // The effective stop timeout ("1min 30s", "infinity") and stop signal (a number), unit file,
++ // drop-ins and systemd's defaults applied; '' when systemctl does not report them.
++ timeoutStop: props.TimeoutStopUSec || '',
++ killSignal: props.KillSignal || '',
};
}
ddiff --git a/test/d41-proof.test.js b/test/d41-proof.test.js
iindex 09ebe62..1a2c2b1 100644
---- a/test/d41-proof.test.js
++++ b/test/d41-proof.test.js
@@@ -19,7 +19,7 @@ const http = require('http');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
--const { scenario, test, runTests } = require('./helpers');
++const { scenario, test, runTests, lifecycleDoc } = require('./helpers');
const { main } = require('../lib/cli');
const SCRIPT = path.join(__dirname, '..', 'scripts', 'd41-proof.sh');
@@@ -29,8 +29,8 @@ const UNIT = 'openvibe-sources.service';
function sourcesHost({ lockfileChange = false } = {}) {
const host = scenario();
const doc = JSON.parse(host.read('/etc/openvibe/host.json'));
-- doc.services.sources = { repo: REPO, units: [UNIT], port: 4720, ready: { url: 'http://127.0.0.1:4720/api/ready', timeoutSeconds: 30 }, noRestartPaths: ['docs/', '**/*.md'] };
-- doc.services.chat = { repo: '/opt/openvibe.chat', units: ['openvibe-chat.service'], port: 4400, ready: { url: 'http://127.0.0.1:4400/ready', timeoutSeconds: 30 } };
++ doc.services.sources = { repo: REPO, units: [UNIT], port: 4720, ready: { url: 'http://127.0.0.1:4720/api/ready', timeoutSeconds: 30 }, noRestartPaths: ['docs/', '**/*.md'], lifecycle: lifecycleDoc() };
++ doc.services.chat = { repo: '/opt/openvibe.chat', units: ['openvibe-chat.service'], port: 4400, ready: { url: 'http://127.0.0.1:4400/ready', timeoutSeconds: 30 }, lifecycle: lifecycleDoc() };
host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
const repo = host.createRepo(REPO, { owner: 'ubuntu' });
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 6a03212..9329bfa 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -191,7 +191,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
switch (action) {
case 'show': {
if (!u) return { code: 0, stdout: 'LoadState=not-found\nActiveState=inactive\nSubState=dead\nMainPID=0\nFragmentPath=\n', stderr: '' };
-- return { code: 0, stdout: [`LoadState=${u.load}`, `ActiveState=${u.active}`, `SubState=${u.sub}`, `MainPID=${u.active === 'active' ? u.mainPid : 0}`, `FragmentPath=${u.fragmentPath}`, `UnitFileState=enabled`, `DropInPaths=${u.dropIns.join(' ')}`, `PartOf=${u.partOf.join(' ')}`, `NRestarts=${u.restarts}`].join('\n'), stderr: '' };
++ return { code: 0, stdout: [`LoadState=${u.load}`, `ActiveState=${u.active}`, `SubState=${u.sub}`, `MainPID=${u.active === 'active' ? u.mainPid : 0}`, `FragmentPath=${u.fragmentPath}`, `UnitFileState=enabled`, `DropInPaths=${u.dropIns.join(' ')}`, `PartOf=${u.partOf.join(' ')}`, `NRestarts=${u.restarts}`, `TimeoutStopUSec=${u.timeoutStop || '1min 30s'}`, `KillSignal=${u.killSignal || 15}`].join('\n'), stderr: '' };
}
case 'restart':
if (!u) return { code: 5, stdout: '', stderr: `Unit ${unit} not found.` };
ddiff --git a/test/helpers.js b/test/helpers.js
iindex cbf4131..987a17b 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -15,6 +15,20 @@ function pkgJson(name, deps) {
return JSON.stringify({ name, version: '1.0.0', dependencies: Object.fromEntries(deps.map((d) => [d, '^1.0.0'])) }, null, 2);
}
++// A complete lifecycle declaration (the manifest block, WS-P task 1), carried inline because the
++// openvibe-contracts this repository pins predates it.
++function lifecycleDoc(overrides = {}) {
++ return {
++ liveness: { endpoint: '/api/health', means: 'the process answers HTTP; nothing else is checked' },
++ shutdown: { signal: 'SIGTERM', deadlineSeconds: 5, drains: ['HTTP server closed', 'database closed'] },
++ startupRecovery: { resumes: [{ kind: 'outbox', what: 'unsent events go to OpenVibe.Events' }] },
++ rollback: { conditions: ['automatic: not ready within ready.timeoutSeconds'], window: 'while ovhost waits for readiness', blockers: { none: 'the schema only adds' } },
++ contracts: { range: '>=0.2.0 <1.0.0' },
++ leases: { none: 'one process owns its database' },
++ ...overrides,
++ };
++}
++
function inventoryDoc() {
return {
host: 'fake-host',
@@@ -37,6 +51,7 @@ function inventoryDoc() {
databases: [{ name: 'live', path: '/opt/openvibe.live/data/live.db' }],
backupOnChange: ['server/db/schema.sql'],
nginx: { vhost: 'openvibe.live.conf', variant: 'websocket', wsPaths: ['/ws/'] },
++ lifecycle: lifecycleDoc(),
},
media: {
repo: '/opt/openvibe.media',
@@@ -47,6 +62,7 @@ function inventoryDoc() {
ready: { url: 'http://127.0.0.1:4100/healthz', timeoutSeconds: 30 },
protected: { kind: 'sqlite-count', db: '/opt/openvibe.media/data/media.db', sql: 'SELECT count(*) AS n FROM vods WHERE is_recording = 1', label: 'recordings in progress' },
databases: [{ name: 'media', path: '/opt/openvibe.media/data/media.db' }],
++ lifecycle: lifecycleDoc({ liveness: { endpoint: '/healthz', means: 'the process answers HTTP and reads its database' }, shutdown: { signal: 'SIGTERM', deadlineSeconds: 70, drains: ['recordings stopped', 'HTTP server closed'] } }),
},
tools: {
repo: '/opt/openvibe.tools',
@@@ -54,6 +70,7 @@ function inventoryDoc() {
units: ['openvibe-tools.service', 'openvibe-tools-maps.service'],
port: 4001,
ready: { url: 'http://127.0.0.1:4001/api/health', headers: { Host: 'openvibe.tools' }, timeoutSeconds: 30 },
++ lifecycle: lifecycleDoc(),
},
sites: {
repo: '/opt/openvibe.sites',
@@@ -61,6 +78,7 @@ function inventoryDoc() {
install: { command: ['npm', 'ci', '--omit=dev', '--no-audit', '--no-fund'], always: true },
build: [['node', 'build.js']],
nginx: { repoVhosts: 'deploy/nginx/*.conf', installOnDeploy: true },
++ lifecycle: lifecycleDoc({ liveness: { none: 'static files served by nginx' }, shutdown: { none: 'no process of its own' }, startupRecovery: { none: 'nothing in flight' } }),
},
events: {
repo: '/opt/openvibe.events',
@@@ -70,6 +88,7 @@ function inventoryDoc() {
protected: { kind: 'http-json-count', url: 'http://127.0.0.1:4300/api/ready', field: 'realtime_connections', label: 'SSE connections' },
drain: { policy: 'report' },
nginx: { variant: 'sse', ssePaths: ['/realtime/stream'] },
++ lifecycle: lifecycleDoc({ shutdown: { signal: 'SIGTERM', deadlineSeconds: 10, drains: ['SSE streams stopped', 'deliveries in flight awaited'] } }),
},
},
};
@@@ -205,4 +224,4 @@ async function runTests(tests) {
if (failed) { console.log(`${failed} failed`); process.exit(1); }
}
--module.exports = { scenario, push, test, runTests, SECRET, pkgJson };
++module.exports = { scenario, push, test, runTests, SECRET, pkgJson, lifecycleDoc };
ddiff --git a/test/lifecycle.test.js b/test/lifecycle.test.js
nnew file mode 100644
iindex 0000000..6f52f81
---- /dev/null
++++ b/test/lifecycle.test.js
@@@ -0,0 +1,220 @@
++'use strict';
++/**
++ * Lifecycle declarations (roadmap WS-P task 1) in `ovhost validate`: every required field present
++ * (the finding names the service and the field), the shutdown deadline within each unit's stop
++ * timeout, the unit's stop signal the declared one, worker drains against their units, and the
++ * checkout's openvibe-contracts inside contracts.range. The block comes from the inventory, from
++ * --manifest <file>, or from the manifest in the installed openvibe-contracts.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { scenario, push, test, runTests, lifecycleDoc } = require('./helpers');
++const lifecycle = require('../lib/lifecycle');
++const { normalise, InventoryError } = require('../lib/inventory');
++const { validate } = require('../lib/commands/validate');
++
++/** Rewrite the inventory file (what the CLI reads) through fn(raw). */
++function edit(host, fn) {
++ const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
++ fn(raw);
++ host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640, owner: 'root' });
++}
++
++async function findings(host, id, ...extra) {
++ const r = await host.cli('validate', id, '--json', ...extra);
++ const res = JSON.parse(r.out);
++ return { code: r.code, all: res.findings, lc: res.findings.filter((f) => f.area === 'lifecycle') };
++}
++
++const errors = (list) => list.filter((f) => f.level === 'error').map((f) => f.message);
++
++runTests([
++ test('systemd time spans, unit-file timeouts, signal names and contract ranges', async () => {
++ const P = lifecycle.parseSeconds;
++ assert.strictEqual(P('1min 30s'), 90);
++ assert.strictEqual(P('15s'), 15);
++ assert.strictEqual(P('15'), 15);
++ assert.strictEqual(P('30min'), 1800);
++ assert.strictEqual(P('1h 2min'), 3720);
++ assert.strictEqual(P('500ms'), 0.5);
++ assert.strictEqual(P('2min 5s 300ms'), 125.3);
++ assert.strictEqual(P('infinity'), Infinity);
++ assert.strictEqual(P(''), null);
++ assert.strictEqual(P('soon'), null);
++ assert.strictEqual(lifecycle.unitFileTimeout('[Service]\nTimeoutStopSec=15\nKillSignal=SIGTERM\n'), 15);
++ assert.strictEqual(lifecycle.unitFileTimeout('[Service]\nTimeoutStopSec=10\nTimeoutStopSec=30min\n'), 1800, 'the last one wins');
++ assert.strictEqual(lifecycle.unitFileTimeout('[Service]\nExecStart=node x.js\n'), null);
++ assert.strictEqual(lifecycle.signalName('15'), 'SIGTERM');
++ assert.strictEqual(lifecycle.signalName('2'), 'SIGINT');
++ assert.strictEqual(lifecycle.signalName('term'), 'SIGTERM');
++ const S = lifecycle.satisfies;
++ assert.ok(S('0.55.0', '>=0.2.0 <1.0.0') && !S('1.0.0', '>=0.2.0 <1.0.0') && !S('0.1.9', '>=0.2.0 <1.0.0'));
++ assert.ok(S('0.1.4', '^0.1.0') && !S('0.2.0', '^0.1.0') && S('1.4.0', '^1.2.0') && !S('2.0.0', '^1.2.0'));
++ assert.ok(S('0.49.0', '0.49.0') && !S('0.49.1', '0.49.0') && !S('x', '>=0.1.0') && !S('0.5.0', 'latest'));
++ }),
++
++ test('check() names each missing or malformed field; none needs a reason and is refused for liveness and shutdown of a running service', async () => {
++ assert.deepStrictEqual(lifecycle.check(lifecycleDoc(), { runs: true }), []);
++ assert.deepStrictEqual(lifecycle.check(null), [{ field: 'lifecycle', problem: 'is missing' }]);
++ const lc = lifecycleDoc();
++ delete lc.shutdown.deadlineSeconds;
++ delete lc.leases;
++ lc.startupRecovery.resumes.push({ kind: 'magic', what: 'x' });
++ lc.contracts = { range: 'latest' };
++ const fields = lifecycle.check(lc, { runs: true }).map((p) => `${p.field} ${p.problem}`);
++ assert.ok(fields.includes('lifecycle.shutdown.deadlineSeconds is missing'), fields.join('\n'));
++ assert.ok(fields.includes('lifecycle.leases is missing'));
++ assert.ok(fields.some((f) => f.startsWith('lifecycle.startupRecovery.resumes[1].kind must be one of')));
++ assert.ok(fields.includes('lifecycle.contracts.range "latest" is not a version range'));
++ const none = lifecycleDoc({ shutdown: { none: 'static' }, leases: { none: '' } });
++ const p = lifecycle.check(none, { runs: true }).map((x) => `${x.field} ${x.problem}`);
++ assert.ok(p.includes('lifecycle.shutdown is none ("static"), but the service runs units'), p.join('\n'));
++ assert.ok(p.includes('lifecycle.leases.none must be the only key, with a reason'));
++ assert.deepStrictEqual(lifecycle.check(lifecycleDoc({ shutdown: { none: 'static files' }, liveness: { none: 'static files' } }), { runs: false }), [], 'no units: none is fine');
++ assert.ok(lifecycle.check(lifecycleDoc({ shutdown: { signal: 'SIGKILL', deadlineSeconds: 5, drains: ['x'] } }), { runs: true }).some((x) => x.field === 'lifecycle.shutdown.signal'));
++ }),
++
++ test('a complete declaration passes: every field, the deadline within the stop timeout, the signal systemd sends', async () => {
++ const host = scenario();
++ host.units.get('openvibe-live.service').timeoutStop = '15s';
++ const r = await findings(host, 'live');
++ assert.deepStrictEqual(errors(r.lc), []);
++ assert.ok(r.lc.some((f) => f.level === 'ok' && /live: liveness, shutdown, startupRecovery, rollback, contracts and leases declared by the inventory/.test(f.message)));
++ assert.ok(r.lc.some((f) => f.level === 'ok' && f.message === 'openvibe-live.service: shutdown deadline 5 s within its stop timeout of 15 s'));
++ // systemd's default when the unit sets none (DefaultTimeoutStopSec, 90 s stock).
++ const m = await findings(host, 'media');
++ assert.ok(m.lc.some((f) => f.message === 'openvibe-media.service: shutdown deadline 70 s within its stop timeout of 90 s'));
++ // A static site: liveness and shutdown are none, and nothing is compared.
++ const s = await findings(host, 'sites');
++ assert.deepStrictEqual(errors(s.lc), []);
++ }),
++
++ test('no declaration anywhere fails, naming the service and where it looked', async () => {
++ const host = scenario();
++ // A manifest id the installed openvibe-contracts does not have (whatever version is pinned).
++ edit(host, (raw) => { delete raw.services.live.lifecycle; raw.services.live.manifest = 'not-a-manifest'; });
++ const r = await findings(host, 'live');
++ assert.strictEqual(r.code, 2);
++ const msg = errors(r.lc).join('\n');
++ assert.match(msg, /^live: no lifecycle declared \(looked at openvibe-contracts v\d+\.\d+\.\d+ \(no "not-a-manifest" manifest\)\)/);
++ assert.match(msg, /set services\.live\.lifecycle/);
++ const text = (await host.cli('validate', 'live')).out;
++ assert.match(text, /FAIL +lifecycle live: no lifecycle declared/);
++ assert.match(text, /live: NOT valid/);
++ }),
++
++ test('a missing field fails with a message naming the service and the field', async () => {
++ const host = scenario();
++ edit(host, (raw) => {
++ delete raw.services.live.lifecycle.shutdown.deadlineSeconds;
++ delete raw.services.live.lifecycle.leases;
++ delete raw.services.live.lifecycle.rollback.window;
++ });
++ const r = await findings(host, 'live');
++ assert.strictEqual(r.code, 2);
++ const msgs = errors(r.lc);
++ assert.ok(msgs.includes('live: lifecycle.shutdown.deadlineSeconds is missing (from the inventory (services.live.lifecycle))'), msgs.join('\n'));
++ assert.ok(msgs.includes('live: lifecycle.leases is missing (from the inventory (services.live.lifecycle))'));
++ assert.ok(msgs.includes('live: lifecycle.rollback.window is missing (from the inventory (services.live.lifecycle))'));
++ // A running service cannot declare no shutdown.
++ edit(host, (raw) => { raw.services.events.lifecycle.shutdown = { none: 'nothing to do' }; });
++ const e = await findings(host, 'events');
++ assert.ok(errors(e.lc).some((m) => m.startsWith('events: lifecycle.shutdown is none ("nothing to do"), but the service runs units')));
++ }),
++
++ test('a deadline longer than the unit\'s stop timeout fails; so does a different stop signal', async () => {
++ const host = scenario();
++ host.units.get('openvibe-media.service').timeoutStop = '1min';
++ let r = await findings(host, 'media');
++ assert.strictEqual(r.code, 2);
++ assert.ok(errors(r.lc).includes('media: lifecycle.shutdown.deadlineSeconds 70 exceeds the openvibe-media.service stop timeout of 60 s (systemd TimeoutStopUSec=1min); systemd would kill it mid-drain'), errors(r.lc).join('\n'));
++ host.units.get('openvibe-media.service').timeoutStop = '1min 20s';
++ host.units.get('openvibe-media.service').killSignal = 2;
++ r = await findings(host, 'media');
++ assert.deepStrictEqual(errors(r.lc), ['media: openvibe-media.service stops with SIGINT (KillSignal) but lifecycle.shutdown.signal is SIGTERM']);
++ host.units.get('openvibe-media.service').killSignal = 15;
++ r = await findings(host, 'media');
++ assert.deepStrictEqual(errors(r.lc), []);
++ }),
++
++ test('a unit that is not loaded is compared through TimeoutStopSec in its unit source', async () => {
++ const host = scenario();
++ const sha = push(host, 'live', { 'deploy/systemd/openvibe-live.service': '[Service]\nExecStart=node server/index.js\nTimeoutStopSec=3\n' });
++ host.repo('live').checkout(sha);
++ host.units.get('openvibe-live.service').load = 'not-found';
++ const r = await findings(host, 'live');
++ assert.ok(errors(r.lc).includes('live: lifecycle.shutdown.deadlineSeconds 5 exceeds the openvibe-live.service stop timeout of 3 s (TimeoutStopSec in deploy/systemd/openvibe-live.service); systemd would kill it mid-drain'), errors(r.lc).join('\n'));
++ // No TimeoutStopSec in the source either: said, not guessed.
++ const sha2 = push(host, 'live', { 'deploy/systemd/openvibe-live.service': '[Service]\nExecStart=node server/index.js\n' });
++ host.repo('live').checkout(sha2);
++ const r2 = await findings(host, 'live');
++ assert.deepStrictEqual(errors(r2.lc), []);
++ assert.ok(r2.lc.some((f) => f.level === 'info' && /openvibe-live\.service: stop timeout unknown/.test(f.message)));
++ }),
++
++ test('worker units that drain longer than their stop timeout are a warning, never an error', async () => {
++ const host = scenario();
++ edit(host, (raw) => {
++ raw.services.events.workerUnits = ['[email protected]'];
++ raw.services.events.lifecycle.shutdown.workers = { deadlineSeconds: 86400, drains: ['sessions kept until they end'] };
++ });
++ host.addUnit('[email protected]', { timeoutStop: '30min' });
++ const r = await findings(host, 'events');
++ assert.deepStrictEqual(errors(r.lc), []);
++ assert.ok(r.lc.some((f) => f.level === 'warn' && /^events-worker@r1\.service: workers drain for up to 86400 s .* stops them after 1800 s/.test(f.message)));
++ }),
++
++ test('the checkout\'s installed openvibe-contracts must be inside contracts.range', async () => {
++ const host = scenario();
++ host.put('/opt/openvibe.live/node_modules/openvibe-contracts/package.json', JSON.stringify({ name: 'openvibe-contracts', version: '0.1.9' }), { owner: 'ubuntu' });
++ let r = await findings(host, 'live');
++ assert.ok(errors(r.lc).includes('live: the checkout has openvibe-contracts 0.1.9, outside lifecycle.contracts.range >=0.2.0 <1.0.0'), errors(r.lc).join('\n'));
++ host.put('/opt/openvibe.live/node_modules/openvibe-contracts/package.json', JSON.stringify({ name: 'openvibe-contracts', version: '0.55.0' }), { owner: 'ubuntu' });
++ r = await findings(host, 'live');
++ assert.deepStrictEqual(errors(r.lc), []);
++ assert.ok(r.lc.some((f) => f.level === 'ok' && f.message === 'the checkout: openvibe-contracts 0.55.0 within >=0.2.0 <1.0.0'));
++ // Tools: each package dir is checked.
++ host.put('/opt/openvibe.tools/apps/maps/node_modules/openvibe-contracts/package.json', JSON.stringify({ version: '1.2.0' }), { owner: 'ubuntu' });
++ const t = await findings(host, 'tools');
++ assert.ok(errors(t.lc).includes('tools: apps/maps has openvibe-contracts 1.2.0, outside lifecycle.contracts.range >=0.2.0 <1.0.0'));
++ }),
++
++ test('the declaration is read from --manifest <file> or the installed manifest when the inventory carries none', async () => {
++ const host = scenario();
++ edit(host, (raw) => { delete raw.services.live.lifecycle; });
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ovhost-lifecycle-'));
++ try {
++ const file = path.join(dir, 'live.json');
++ fs.writeFileSync(file, JSON.stringify({ id: 'live', lifecycle: lifecycleDoc() }));
++ let r = await findings(host, 'live', '--manifest', file);
++ assert.deepStrictEqual(errors(r.lc), []);
++ assert.ok(r.lc.some((f) => /declared by manifest file live\.json/.test(f.message)));
++ fs.writeFileSync(file, JSON.stringify({ id: 'live' }));
++ r = await findings(host, 'live', '--manifest', file);
++ assert.ok(errors(r.lc).some((m) => m.startsWith('live: no lifecycle declared (looked at manifest file live.json)')));
++ } finally {
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++ // The installed package (injected here): the inventory's manifest id picks the manifest.
++ const ctx = host.ctx();
++ const inv = normalise(JSON.parse(host.read('/etc/openvibe/host.json')));
++ const bare = lifecycleDoc();
++ delete bare.contracts;
++ const contracts = { version: '0.55.0', services: { get: (id) => (id === 'live' ? { id, lifecycle: bare } : null) } };
++ const res = await validate({ ...ctx, inv }, 'live', { contracts });
++ const lc = res.findings.filter((f) => f.area === 'lifecycle');
++ assert.deepStrictEqual(errors(lc), ['live: lifecycle.contracts is missing (from the "live" manifest in openvibe-contracts v0.55.0)']);
++ // The inventory's own block wins over the manifest.
++ const withInline = normalise({ ...JSON.parse(host.read('/etc/openvibe/host.json')), services: { live: { ...JSON.parse(host.read('/etc/openvibe/host.json')).services.live, lifecycle: lifecycleDoc() } } });
++ const res2 = await validate({ ...ctx, inv: withInline }, 'live', { contracts });
++ assert.deepStrictEqual(errors(res2.findings.filter((f) => f.area === 'lifecycle')), []);
++ }),
++
++ test('the inventory refuses a lifecycle that is not an object', async () => {
++ const doc = JSON.parse(scenario().read('/etc/openvibe/host.json'));
++ doc.services.live.lifecycle = ['liveness'];
++ assert.throws(() => normalise(doc), (e) => e instanceof InventoryError && /services\.live\.lifecycle must be an object/.test(e.message));
++ }),
++]);
ddiff --git a/test/validate.test.js b/test/validate.test.js
iindex 5c726df..76cc8d8 100644
---- a/test/validate.test.js
++++ b/test/validate.test.js
@@@ -1,6 +1,6 @@
'use strict';
const assert = require('assert');
--const { scenario, test, runTests, SECRET } = require('./helpers');
++const { scenario, test, runTests, SECRET, lifecycleDoc } = require('./helpers');
const envfile = require('../lib/envfile');
function noSecrets(text) {
@@@ -12,7 +12,7 @@ runTests([
test('a release-layout service is checked by its current release, not as a git checkout, and is never managed', async () => {
const host = scenario();
const raw = JSON.parse(host.read('/etc/openvibe/host.json'));
-- raw.services.rel = { repo: '/opt/rel.stream', layout: 'release', units: [] };
++ raw.services.rel = { repo: '/opt/rel.stream', layout: 'release', units: [], lifecycle: lifecycleDoc() };
host.put('/etc/openvibe/host.json', JSON.stringify(raw), { mode: 0o640 });
host.put('/opt/rel.stream/releases/655b98a10aaa/package.json', '{}');
await host.exec.symlink('/opt/rel.stream/releases/655b98a10aaa', '/opt/rel.stream/current');