openvibers/openvibe.host Commits
multi-app: a oneshot unit the unitsMatch glob catches is a job, not a service (found in the WS-N task 11 cutover, 2026-09-27). openvibe-tools*.service also matches openvibe-toolsjob.service, the job proof its timer runs (Type=oneshot, inactive between runs): the first ovhost deploy of Tools restarted it, then waited for it to stay active, so the deploy read not ready and so did its rollback (Tools itself was healthy throughout). Units found by the glob are now asked their Type (systemd show gains Type); oneshot ones are named in the plan ('jobs ... not restarted, not waited on') and left alone. test/strategy-in-place.test.js adds such a unit and checks it is not restarted.
Changes
@@@ -239,6 +239,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procif (p.preflight && p.preflight.length) out(` preflight ${p.preflight.join('; ')}`);out(` restart ${p.restartNeeded ? `yes: ${p.units.join(', ')}${p.socketUnit ? ` (socket ${p.socketUnit} stays up)` : ''}` : p.layout === 'release' && !p.upToDate ? 'no — current switches, the process keeps running' : 'no'}`);if (p.extraUnits && p.extraUnits.length) out(` units ${p.extraUnits.join(', ')} are not in the inventory but are restarted too (unitsMatch)`);++ if (p.skippedJobs && p.skippedJobs.length) out(` jobs ${p.skippedJobs.join(', ')} match unitsMatch but are oneshot jobs: not restarted, not waited on`);if (p.socket) out(` socket ${p.socket.held ? `held by systemd on :${p.socket.port}` : p.socket.held === false ? `NOT held by systemd on :${p.socket.port} — a restart rebinds it (stop, restart the socket, start)` : 'unknown'}${p.socket.changed ? '; the socket unit file changed — a restart rebinds it' : ''}`);if (p.protectedSessions) {const s = p.protectedSessions;@@@ -134,10 +134,20 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,const codeChanged = changed.filter((f) => !matchesPath(f, svc.noRestartPaths));// unitsMatch (Tools: openvibe-tools*.service): a unit file on the host that the inventory does not-- // list is restarted too, as the script did, and named so the inventory can be corrected.++ // list is restarted too, as the script did, and named so the inventory can be corrected. A oneshot++ // unit the glob catches is a job, not a service (openvibe-toolsjob.service, the job proof a timer runs;++ // 2026-09-27 it was restarted and then waited on as if it should stay active, so the deploy and its++ // rollback both read "not ready"): it is named and left alone.let extraUnits = [];++ const skippedJobs = [];if (svc.unitsMatch) {-- try { extraUnits = (await systemd.listUnitFiles(exec, svc.unitsMatch)).filter((u) => !svc.units.includes(u) && !svc.workerUnits.includes(u)); } catch { extraUnits = []; }++ let found = [];++ try { found = (await systemd.listUnitFiles(exec, svc.unitsMatch)).filter((u) => !svc.units.includes(u) && !svc.workerUnits.includes(u)); } catch { found = []; }++ for (const u of found) {++ let type = '';++ try { type = (await systemd.show(exec, u)).type; } catch { /* unknown: treat as a service */ }++ if (type === 'oneshot') skippedJobs.push(u); else extraUnits.push(u);++ }}const units = [...svc.units, ...extraUnits];const restartNeeded = units.length > 0 && (restart || codeChanged.length > 0);@@@ -186,6 +196,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,restartNeeded,units,extraUnits,++ skippedJobs,socketUnit: svc.socketUnit,unitDrift,installUnits: svc.installUnits,@@@ -8,7 +8,7 @@const SOCKET_SAFE_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'cat', 'status', 'list-units', 'list-unit-files']);const ALLOWED_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'restart', 'reload', 'cat', 'status', 'daemon-reload', 'list-units', 'list-unit-files']);const READ_ONLY_ACTIONS = ['show', 'is-active', 'is-enabled', 'cat', 'status', 'list-units', 'list-unit-files'];--const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts', 'TimeoutStopUSec', 'KillSignal'];++const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts', 'TimeoutStopUSec', 'KillSignal', 'Type'];class SocketGuardError extends Error {}@@@ -48,6 +48,7 @@ async function show(exec, unit) {// drop-ins and systemd's defaults applied; '' when systemctl does not report them.timeoutStop: props.TimeoutStopUSec || '',killSignal: props.KillSignal || '',++ type: props.Type || '', // simple, notify, oneshot, … ('' when not reported)};}@@@ -290,7 +290,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', stswitch (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}`, `TimeoutStopUSec=${u.timeoutStop || '1min 30s'}`, `KillSignal=${u.killSignal || 15}`].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}`, `Type=${u.type || 'simple'}`].join('\n'), stderr: '' };}case 'restart':if (!u) return { code: 5, stdout: '', stderr: `Unit ${unit} not found.` };@@@ -27,6 +27,8 @@ runTests([test('tools: per-app installs skip apps/_shared, preflight runs in each app, every unit restarts, the gateway serves the new sha', async () => {const host = toolsHost();host.addUnit('openvibe-tools-docs.service', { runningSha: host.repo.head });++ // A timer's oneshot job the glob also matches (production's openvibe-toolsjob.service): never restarted or waited on.++ host.addUnit('openvibe-toolsjob.service', { type: 'oneshot', active: 'inactive', sub: 'dead' });const to = host.push({ 'apps/img/package.json': JSON.stringify({ name: 'tools-img', version: '1.0.1', dependencies: { express: '^1', 'better-sqlite3': '^1', 'openvibe-contracts': '^1', 'openvibe-sdk': '^1', sharp: '^1' } }), 'apps/_shared/guard/index.js': 'guard2();' }, 'img: sharp');const r = await host.cli('deploy', 'tools');assert.strictEqual(r.code, 0, r.out);@@@ -44,6 +46,7 @@ runTests([// Every unit, including one on the host the inventory does not list (unitsMatch), named loudly.assert.deepStrictEqual(host.restarts(), ['openvibe-tools.service', 'openvibe-tools-img.service', 'openvibe-tools-maps.service', 'openvibe-tools-docs.service']);assert.match(r.out, /openvibe-tools-docs\.service match openvibe-tools\*\.service but are not in the inventory's units/);++ assert.ok(!host.restarts().includes('openvibe-toolsjob.service'), 'a oneshot job is not restarted');const rec = await lastRecord(host, 'tools');assert.strictEqual(rec.result, 'deployed');assert.strictEqual(rec.strategy, 'multi-app');
@@ -239,6 +239,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -239,6 +239,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
if (p.preflight && p.preflight.length) out(` preflight ${p.preflight.join('; ')}`);
if (p.preflight && p.preflight.length) out(` preflight ${p.preflight.join('; ')}`);
out(` restart ${p.restartNeeded ? `yes: ${p.units.join(', ')}${p.socketUnit ? ` (socket ${p.socketUnit} stays up)` : ''}` : p.layout === 'release' && !p.upToDate ? 'no — current switches, the process keeps running' : 'no'}`);
out(` restart ${p.restartNeeded ? `yes: ${p.units.join(', ')}${p.socketUnit ? ` (socket ${p.socketUnit} stays up)` : ''}` : p.layout === 'release' && !p.upToDate ? 'no — current switches, the process keeps running' : 'no'}`);
if (p.extraUnits && p.extraUnits.length) out(` units ${p.extraUnits.join(', ')} are not in the inventory but are restarted too (unitsMatch)`);
if (p.extraUnits && p.extraUnits.length) out(` units ${p.extraUnits.join(', ')} are not in the inventory but are restarted too (unitsMatch)`);
+ if (p.skippedJobs && p.skippedJobs.length) out(` jobs ${p.skippedJobs.join(', ')} match unitsMatch but are oneshot jobs: not restarted, not waited on`);
if (p.socket) out(` socket ${p.socket.held ? `held by systemd on :${p.socket.port}` : p.socket.held === false ? `NOT held by systemd on :${p.socket.port} — a restart rebinds it (stop, restart the socket, start)` : 'unknown'}${p.socket.changed ? '; the socket unit file changed — a restart rebinds it' : ''}`);
if (p.socket) out(` socket ${p.socket.held ? `held by systemd on :${p.socket.port}` : p.socket.held === false ? `NOT held by systemd on :${p.socket.port} — a restart rebinds it (stop, restart the socket, start)` : 'unknown'}${p.socket.changed ? '; the socket unit file changed — a restart rebinds it' : ''}`);
if (p.protectedSessions) {
if (p.protectedSessions) {
const s = p.protectedSessions;
const s = p.protectedSessions;
@@ -134,10 +134,20 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
@@ -134,10 +134,20 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
const codeChanged = changed.filter((f) => !matchesPath(f, svc.noRestartPaths));
const codeChanged = changed.filter((f) => !matchesPath(f, svc.noRestartPaths));
// unitsMatch (Tools: openvibe-tools*.service): a unit file on the host that the inventory does not
// unitsMatch (Tools: openvibe-tools*.service): a unit file on the host that the inventory does not
- // list is restarted too, as the script did, and named so the inventory can be corrected.
+ // list is restarted too, as the script did, and named so the inventory can be corrected. A oneshot
+ // unit the glob catches is a job, not a service (openvibe-toolsjob.service, the job proof a timer runs;
+ // 2026-09-27 it was restarted and then waited on as if it should stay active, so the deploy and its
+ // rollback both read "not ready"): it is named and left alone.
let extraUnits = [];
let extraUnits = [];
+ const skippedJobs = [];
if (svc.unitsMatch) {
if (svc.unitsMatch) {
- try { extraUnits = (await systemd.listUnitFiles(exec, svc.unitsMatch)).filter((u) => !svc.units.includes(u) && !svc.workerUnits.includes(u)); } catch { extraUnits = []; }
+ let found = [];
+ try { found = (await systemd.listUnitFiles(exec, svc.unitsMatch)).filter((u) => !svc.units.includes(u) && !svc.workerUnits.includes(u)); } catch { found = []; }
+ for (const u of found) {
+ let type = '';
+ try { type = (await systemd.show(exec, u)).type; } catch { /* unknown: treat as a service */ }
+ if (type === 'oneshot') skippedJobs.push(u); else extraUnits.push(u);
+ }
}
}
const units = [...svc.units, ...extraUnits];
const units = [...svc.units, ...extraUnits];
const restartNeeded = units.length > 0 && (restart || codeChanged.length > 0);
const restartNeeded = units.length > 0 && (restart || codeChanged.length > 0);
@@ -186,6 +196,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
@@ -186,6 +196,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
restartNeeded,
restartNeeded,
units,
units,
extraUnits,
extraUnits,
+ skippedJobs,
socketUnit: svc.socketUnit,
socketUnit: svc.socketUnit,
unitDrift,
unitDrift,
installUnits: svc.installUnits,
installUnits: svc.installUnits,
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
const SOCKET_SAFE_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'cat', 'status', 'list-units', 'list-unit-files']);
const SOCKET_SAFE_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'cat', 'status', 'list-units', 'list-unit-files']);
const ALLOWED_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'restart', 'reload', 'cat', 'status', 'daemon-reload', 'list-units', 'list-unit-files']);
const ALLOWED_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'restart', 'reload', 'cat', 'status', 'daemon-reload', 'list-units', 'list-unit-files']);
const READ_ONLY_ACTIONS = ['show', 'is-active', 'is-enabled', 'cat', 'status', 'list-units', 'list-unit-files'];
const READ_ONLY_ACTIONS = ['show', 'is-active', 'is-enabled', 'cat', 'status', 'list-units', 'list-unit-files'];
-const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts', 'TimeoutStopUSec', 'KillSignal'];
+const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts', 'TimeoutStopUSec', 'KillSignal', 'Type'];
class SocketGuardError extends Error {}
class SocketGuardError extends Error {}
@@ -48,6 +48,7 @@ async function show(exec, unit) {
@@ -48,6 +48,7 @@ async function show(exec, unit) {
// drop-ins and systemd's defaults applied; '' when systemctl does not report them.
// drop-ins and systemd's defaults applied; '' when systemctl does not report them.
timeoutStop: props.TimeoutStopUSec || '',
timeoutStop: props.TimeoutStopUSec || '',
killSignal: props.KillSignal || '',
killSignal: props.KillSignal || '',
+ type: props.Type || '', // simple, notify, oneshot, … ('' when not reported)
};
};
}
}
@@ -290,7 +290,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -290,7 +290,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
switch (action) {
switch (action) {
case 'show': {
case 'show': {
if (!u) return { code: 0, stdout: 'LoadState=not-found\nActiveState=inactive\nSubState=dead\nMainPID=0\nFragmentPath=\n', stderr: '' };
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}`, `TimeoutStopUSec=${u.timeoutStop || '1min 30s'}`, `KillSignal=${u.killSignal || 15}`].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}`, `Type=${u.type || 'simple'}`].join('\n'), stderr: '' };
}
}
case 'restart':
case 'restart':
if (!u) return { code: 5, stdout: '', stderr: `Unit ${unit} not found.` };
if (!u) return { code: 5, stdout: '', stderr: `Unit ${unit} not found.` };
@@ -27,6 +27,8 @@ runTests([
@@ -27,6 +27,8 @@ runTests([
test('tools: per-app installs skip apps/_shared, preflight runs in each app, every unit restarts, the gateway serves the new sha', async () => {
test('tools: per-app installs skip apps/_shared, preflight runs in each app, every unit restarts, the gateway serves the new sha', async () => {
const host = toolsHost();
const host = toolsHost();
host.addUnit('openvibe-tools-docs.service', { runningSha: host.repo.head });
host.addUnit('openvibe-tools-docs.service', { runningSha: host.repo.head });
+ // A timer's oneshot job the glob also matches (production's openvibe-toolsjob.service): never restarted or waited on.
+ host.addUnit('openvibe-toolsjob.service', { type: 'oneshot', active: 'inactive', sub: 'dead' });
const to = host.push({ 'apps/img/package.json': JSON.stringify({ name: 'tools-img', version: '1.0.1', dependencies: { express: '^1', 'better-sqlite3': '^1', 'openvibe-contracts': '^1', 'openvibe-sdk': '^1', sharp: '^1' } }), 'apps/_shared/guard/index.js': 'guard2();' }, 'img: sharp');
const to = host.push({ 'apps/img/package.json': JSON.stringify({ name: 'tools-img', version: '1.0.1', dependencies: { express: '^1', 'better-sqlite3': '^1', 'openvibe-contracts': '^1', 'openvibe-sdk': '^1', sharp: '^1' } }), 'apps/_shared/guard/index.js': 'guard2();' }, 'img: sharp');
const r = await host.cli('deploy', 'tools');
const r = await host.cli('deploy', 'tools');
assert.strictEqual(r.code, 0, r.out);
assert.strictEqual(r.code, 0, r.out);
@@ -44,6 +46,7 @@ runTests([
@@ -44,6 +46,7 @@ runTests([
// Every unit, including one on the host the inventory does not list (unitsMatch), named loudly.
// Every unit, including one on the host the inventory does not list (unitsMatch), named loudly.
assert.deepStrictEqual(host.restarts(), ['openvibe-tools.service', 'openvibe-tools-img.service', 'openvibe-tools-maps.service', 'openvibe-tools-docs.service']);
assert.deepStrictEqual(host.restarts(), ['openvibe-tools.service', 'openvibe-tools-img.service', 'openvibe-tools-maps.service', 'openvibe-tools-docs.service']);
assert.match(r.out, /openvibe-tools-docs\.service match openvibe-tools\*\.service but are not in the inventory's units/);
assert.match(r.out, /openvibe-tools-docs\.service match openvibe-tools\*\.service but are not in the inventory's units/);
+ assert.ok(!host.restarts().includes('openvibe-toolsjob.service'), 'a oneshot job is not restarted');
const rec = await lastRecord(host, 'tools');
const rec = await lastRecord(host, 'tools');
assert.strictEqual(rec.result, 'deployed');
assert.strictEqual(rec.result, 'deployed');
assert.strictEqual(rec.strategy, 'multi-app');
assert.strictEqual(rec.strategy, 'multi-app');