openvibers/openvibe.host Commits

ovhost env-names --set: list the names a service gets (never values) (#6)

OP OpenVibers committed 2 Oct 2026 · 0de73c0045 · 5 files, +60 −9

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Host/pull/6

Changes

ddiff --git a/README.md b/README.md
iindex 4c10278..3852d47 100644
---- a/README.md
++++ b/README.md
@@@ -113,6 +113,7 @@ ovhost status [<service>...] unit state, sha, readiness, protected se
ovhost logs <service> [--tail <n>] the service's units' journal, newest last (default 200, max 2000)
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 env-names <service> --set names the service gets (env file + unit Environment=), never values
ovhost show [<service>...] the inventory as ovhost reads it (repo, owner, units, socket, workers, probes)
ovhost plan <service> [--to <sha>] [--no-fetch] [--restart]
ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check] [--no-announce] [--prepare-only]
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 913193d..8e8ff60 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -24,7 +24,9 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
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
++ env-names <service> [--set] variable NAMES declared in the checkout's .env.example; --set: the
++ names the service gets (non-empty in its env file or set by its
++ units' Environment=) and the names its env file leaves empty
show [<service>...] the inventory as ovhost reads it: repo, owner, units, socket,
workers, port, ready URL, protected probe (no values)
plan <service> [--to <sha>] [--no-fetch]
@@@ -225,6 +227,16 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
return 0;
}
case 'env-names': {
++ if (opts.set) {
++ const r = await lib.envSet(ctx, needService());
++ if (json) emit(r);
++ else {
++ out(`${r.file || 'no env file declared'}${r.file && !r.found ? ' not found' : ''}: ${r.set.length} name(s) set`);
++ if (r.set.length) out(r.set.join('\n'));
++ if (r.empty.length) out(`empty: ${r.empty.join(', ')}`);
++ }
++ return !r.file || r.found ? 0 : 1;
++ }
const r = await lib.envNames(ctx, needService());
if (json) emit(r);
else if (!r.found) out(`${r.file} not found`);
ddiff --git a/lib/commands/validate.js b/lib/commands/validate.js
iindex 993f49d..6d0db4f 100644
---- a/lib/commands/validate.js
++++ b/lib/commands/validate.js
@@@ -79,12 +79,7 @@ async function validate(ctx, id, opts = {}) {
}
// ── env ──
-- let unitEnvNames = [];
-- for (const u of svc.units) {
-- const st = await systemd.show(exec, u);
-- if (st.fragmentPath) unitEnvNames = unitEnvNames.concat(envfile.unitEnvironmentNames(await exec.readFile(st.fragmentPath, { privileged: true })));
-- for (const d of st.dropIns) unitEnvNames = unitEnvNames.concat(envfile.unitEnvironmentNames(await exec.readFile(d, { privileged: true })));
-- }
++ const unitEnvNames = await unitEnvironmentNames(exec, svc);
if (svc.envFile) {
const st = await exec.stat(svc.envFile);
if (!st) add('error', 'env', `${svc.envFile} does not exist`);
@@@ -259,6 +254,33 @@ async function checkLifecycle({ exec }, svc, repoStat, add, opts) {
}
}
++/** Names the service's units set with Environment= (unit files and drop-ins). Names only. */
++async function unitEnvironmentNames(exec, svc) {
++ let names = [];
++ for (const u of svc.units) {
++ const st = await systemd.show(exec, u);
++ if (st.fragmentPath) names = names.concat(envfile.unitEnvironmentNames(await exec.readFile(st.fragmentPath, { privileged: true })));
++ for (const d of st.dropIns) names = names.concat(envfile.unitEnvironmentNames(await exec.readFile(d, { privileged: true })));
++ }
++ return names;
++}
++
++/**
++ * Names the running service actually gets: set non-empty in its env file or by its units' Environment=
++ * lines, and names its env file leaves empty. Names only — a deploy wrapper compares them with what an
++ * incoming release requires (DATABASE_URL before a PostgreSQL cutover) before anything restarts.
++ */
++async function envSet(ctx, id) {
++ const svc = service(ctx.inv, id);
++ const file = svc.envFile || null;
++ const text = file ? await ctx.exec.readFile(file, { privileged: true }) : null;
++ const entries = envfile.parseNames(text);
++ const units = [...new Set(await unitEnvironmentNames(ctx.exec, svc))];
++ const set = [...new Set([...entries.filter((e) => !e.empty).map((e) => e.name), ...units])].sort();
++ const empty = entries.filter((e) => e.empty && !set.includes(e.name)).map((e) => e.name).sort();
++ return { service: id, file, found: text != null, set, empty, fromUnits: units.sort() };
++}
++
/** 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);
@@@ -268,4 +290,4 @@ async function envNames(ctx, id) {
return { service: id, file, found: true, ...envfile.parseExample(text) };
}
--module.exports = { validate, envNames };
++module.exports = { validate, envNames, envSet };
ddiff --git a/lib/index.js b/lib/index.js
iindex 80c7677..1f4ea7e 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -5,7 +5,7 @@
*/
const inventory = require('./inventory');
const ops = require('./release-ops');
--const { validate, envNames } = require('./commands/validate');
++const { validate, envNames, envSet } = require('./commands/validate');
const { status } = require('./commands/status');
const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = require('./commands/backup');
const offsite = require('./offsite');
@@@ -23,6 +23,7 @@ module.exports = {
inventory,
validate,
envNames,
++ envSet,
plan: ops.plan,
reconcile: (ctx, opts) => require('./reconcile').reconcile(ctx, module.exports, opts),
deploy: ops.deploy,
ddiff --git a/test/validate.test.js b/test/validate.test.js
iindex 467cfc0..24049b1 100644
---- a/test/validate.test.js
++++ b/test/validate.test.js
@@@ -133,6 +133,21 @@ runTests([
assert.deepStrictEqual(envfile.unitEnvironmentNames('Environment=A=1 "B=two words"\nEnvironment=C=3\n'), ['A', 'B', 'C']);
}),
++ test('env-names --set lists the names the service gets — env file and unit Environment= — never a value', async () => {
++ const host = scenario();
++ host.put('/etc/openvibe/live.env', `JWT_SECRET=${SECRET}\nBASE_URL=\nPAYPAL_CLIENT_SECRET=""\n`, { mode: 0o600 });
++ host.put('/etc/systemd/system/openvibe-live.service', host.repo('live').commits.get(host.repo('live').head).files['deploy/systemd/openvibe-live.service'].replace('\n', '\nEnvironment=NODE_ENV=production\n'));
++ const t = await host.cli('env-names', 'live', '--set');
++ assert.strictEqual(t.code, 0, t.out);
++ assert.match(t.out, /live\.env: 2 name\(s\) set\nJWT_SECRET\nNODE_ENV\nempty: BASE_URL, PAYPAL_CLIENT_SECRET/);
++ const j = JSON.parse((await host.cli('env-names', 'live', '--set', '--json')).out);
++ assert.deepStrictEqual([j.set, j.empty, j.fromUnits, j.found], [['JWT_SECRET', 'NODE_ENV'], ['BASE_URL', 'PAYPAL_CLIENT_SECRET'], ['NODE_ENV'], true]);
++ noSecrets(t.out + JSON.stringify(j));
++ host.files.delete('/etc/openvibe/live.env');
++ const gone = await host.cli('env-names', 'live', '--set');
++ assert.strictEqual(gone.code, 1); assert.match(gone.out, /live\.env not found: 1 name\(s\) set/);
++ }),
++
test('env-names lists .env.example names', async () => {
const host = scenario();
const r = await host.cli('env-names', 'live');