openvibers/openvibe.host Commits
ovhost env-names --set: list the names a service gets (never values) (#6)
Changes
@@@ -113,6 +113,7 @@ ovhost status [<service>...] unit state, sha, readiness, protected seovhost 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, lifecycleovhost 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 valuesovhost 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]@@@ -24,7 +24,9 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]lifecycle declaration (inventory, --manifest or the installedopenvibe-contracts manifest): every field, the shutdown deadlinewithin 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 emptyshow [<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) => procreturn 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`);@@@ -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 };@@@ -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,@@@ -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');
@@ -113,6 +113,7 @@ ovhost status [<service>...] unit state, sha, readiness, protected se
@@ -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 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 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> 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 show [<service>...] the inventory as ovhost reads it (repo, owner, units, socket, workers, probes)
ovhost plan <service> [--to <sha>] [--no-fetch] [--restart]
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]
ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check] [--no-announce] [--prepare-only]
@@ -24,7 +24,9 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -24,7 +24,9 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
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
within the unit's TimeoutStopSec, contracts.range
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,
show [<service>...] the inventory as ovhost reads it: repo, owner, units, socket,
workers, port, ready URL, protected probe (no values)
workers, port, ready URL, protected probe (no values)
plan <service> [--to <sha>] [--no-fetch]
plan <service> [--to <sha>] [--no-fetch]
@@ -225,6 +227,16 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -225,6 +227,16 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
return 0;
return 0;
}
}
case 'env-names': {
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());
const r = await lib.envNames(ctx, needService());
if (json) emit(r);
if (json) emit(r);
else if (!r.found) out(`${r.file} not found`);
else if (!r.found) out(`${r.file} not found`);
@@ -79,12 +79,7 @@ async function validate(ctx, id, opts = {}) {
@@ -79,12 +79,7 @@ async function validate(ctx, id, opts = {}) {
}
}
// ── env ──
// ── 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) {
if (svc.envFile) {
const st = await exec.stat(svc.envFile);
const st = await exec.stat(svc.envFile);
if (!st) add('error', 'env', `${svc.envFile} does not exist`);
if (!st) add('error', 'env', `${svc.envFile} does not exist`);
@@ -259,6 +254,33 @@ async function checkLifecycle({ exec }, svc, repoStat, add, opts) {
@@ -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. */
/** Names declared in the checkout's .env.example — to help write env.required. Names only. */
async function envNames(ctx, id) {
async function envNames(ctx, id) {
const svc = service(ctx.inv, id);
const svc = service(ctx.inv, id);
@@ -268,4 +290,4 @@ async function envNames(ctx, id) {
@@ -268,4 +290,4 @@ async function envNames(ctx, id) {
return { service: id, file, found: true, ...envfile.parseExample(text) };
return { service: id, file, found: true, ...envfile.parseExample(text) };
}
}
-module.exports = { validate, envNames };
+module.exports = { validate, envNames, envSet };
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
*/
*/
const inventory = require('./inventory');
const inventory = require('./inventory');
const ops = require('./release-ops');
const ops = require('./release-ops');
-const { validate, envNames } = require('./commands/validate');
+const { validate, envNames, envSet } = require('./commands/validate');
const { status } = require('./commands/status');
const { status } = require('./commands/status');
const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = require('./commands/backup');
const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = require('./commands/backup');
const offsite = require('./offsite');
const offsite = require('./offsite');
@@ -23,6 +23,7 @@ module.exports = {
@@ -23,6 +23,7 @@ module.exports = {
inventory,
inventory,
validate,
validate,
envNames,
envNames,
+ envSet,
plan: ops.plan,
plan: ops.plan,
reconcile: (ctx, opts) => require('./reconcile').reconcile(ctx, module.exports, opts),
reconcile: (ctx, opts) => require('./reconcile').reconcile(ctx, module.exports, opts),
deploy: ops.deploy,
deploy: ops.deploy,
@@ -133,6 +133,21 @@ runTests([
@@ -133,6 +133,21 @@ runTests([
assert.deepStrictEqual(envfile.unitEnvironmentNames('Environment=A=1 "B=two words"\nEnvironment=C=3\n'), ['A', 'B', 'C']);
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 () => {
test('env-names lists .env.example names', async () => {
const host = scenario();
const host = scenario();
const r = await host.cli('env-names', 'live');
const r = await host.cli('env-names', 'live');