openvibers/openvibe.host Commits
ovhost: release-layout services (root-owned releases/<id> behind current, installed by their own deploy script) are validated by their current release instead of as a git checkout and are never managed; OpenRe uses it
Changes
@@@ -552,6 +552,7 @@"openre": {"_note": "Release layout: the service runs from /opt/openre.stream/current and deploys with its own deploy/deploy.sh (release | api | workers). The templated ingest and restream workers belong to the session coordinator; ovhost never restarts them.","repo": "/opt/openre.stream",++ "layout": "release","units": ["openre-api.service", "openre-session-coordinator.service"],"envFile": "/etc/openvibe/openre.env","env": { "required": "from-example" },@@@ -17,9 +17,15 @@ async function statusOne(ctx, svc) {row.socket = { unit: svc.socketUnit, active: st.active, sub: st.sub };}try {-- const g = git(exec, svc);-- row.sha = await g.head();-- row.branch = await g.branch();++ if (svc.layout === 'release') {++ const target = await exec.readlink(`${svc.repo}/current`);++ row.sha = target ? String(target).split('/').pop() : null;++ row.branch = 'release';++ } else {++ const g = git(exec, svc);++ row.sha = await g.head();++ row.branch = await g.branch();++ }} catch (err) {row.shaError = err.message.split('\n')[0];}@@@ -31,7 +31,16 @@ async function validate(ctx, id) {// ── checkout ──const repoStat = await exec.stat(svc.repo);if (!repoStat || !repoStat.isDir) add('error', 'checkout', `${svc.repo} does not exist`);-- else {++ else if (svc.layout === 'release') {++ // Release layout: <repo>/current -> <repo>/releases/<id>, root-owned so the service user++ // cannot rewrite the code it runs.++ const cur = `${svc.repo}/current`;++ const target = await exec.readlink(cur);++ const st = target ? await exec.stat(cur) : null;++ if (!target || !st || !st.isDir) add('error', 'checkout', `${cur} is not a symlink to a release directory`);++ else if (repoStat.owner !== 'root' || st.owner !== 'root') add('error', 'checkout', `${svc.repo} and its current release must be root-owned (found ${repoStat.owner}/${st.owner})`);++ else add('ok', 'checkout', `release layout: current -> ${target}`);++ } else {if (repoStat.owner !== svc.owner) add('error', 'checkout', `${svc.repo} is owned by ${repoStat.owner}, expected ${svc.owner}`);else add('ok', 'checkout', `${svc.repo} owned by ${svc.owner}`);try {@@@ -43,8 +43,11 @@ function normaliseService(id, raw, defaults) {runAs: s.runAs || s.owner,remote: s.remote || 'origin',branch: s.branch || 'main',-- managed: s.managed !== false,-- unmanagedReason: s.unmanagedReason || null,++ // 'git' (a checkout ovhost can move) or 'release' (root-owned releases/<id> behind a++ // `current` symlink, installed by the service's own deploy script: never managed here).++ layout: s.layout === 'release' ? 'release' : 'git',++ managed: s.layout === 'release' ? false : s.managed !== false,++ unmanagedReason: s.unmanagedReason || (s.layout === 'release' ? 'release layout: deployed by its own deploy script' : null),units: s.units || [],socketUnit: s.socketUnit || null,unitSources: s.unitSources || {},@@@ -9,6 +9,28 @@ function noSecrets(text) {}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: [] };++ 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');++ let res = JSON.parse((await host.cli('validate', 'rel', '--json')).out);++ assert.deepStrictEqual(res.findings.filter((f) => f.level === 'error'), []);++ assert.ok(res.findings.some((f) => f.area === 'checkout' && /current -> \/opt\/rel\.stream\/releases\/655b98a10aaa/.test(f.message)));++ const st = await host.cli('status', '--json');++ assert.match(st.out, /655b98a10aaa/);++ assert.match(st.out, /"managed":\s*false/);++ // A release the service user could rewrite is refused.++ host.put('/opt/rel.stream/releases/bad/package.json', '{}', { owner: 'ubuntu' });++ host.files.get('/opt/rel.stream/releases/bad').owner = 'ubuntu';++ host.files.delete('/opt/rel.stream/current');++ await host.exec.symlink('/opt/rel.stream/releases/bad', '/opt/rel.stream/current');++ res = JSON.parse((await host.cli('validate', 'rel', '--json')).out);++ assert.ok(res.findings.some((f) => f.level === 'error' && /must be root-owned/.test(f.message)));++ }),++test('env validation reports names only — never a value — in text and JSON output', async () => {const host = scenario();host.put('/etc/openvibe/live.env', `JWT_SECRET=${SECRET}\nBASE_URL=\nPAYPAL_CLIENT_SECRET=""\n`, { mode: 0o644 });
@@ -552,6 +552,7 @@
@@ -552,6 +552,7 @@
"openre": {
"openre": {
"_note": "Release layout: the service runs from /opt/openre.stream/current and deploys with its own deploy/deploy.sh (release | api | workers). The templated ingest and restream workers belong to the session coordinator; ovhost never restarts them.",
"_note": "Release layout: the service runs from /opt/openre.stream/current and deploys with its own deploy/deploy.sh (release | api | workers). The templated ingest and restream workers belong to the session coordinator; ovhost never restarts them.",
"repo": "/opt/openre.stream",
"repo": "/opt/openre.stream",
+ "layout": "release",
"units": ["openre-api.service", "openre-session-coordinator.service"],
"units": ["openre-api.service", "openre-session-coordinator.service"],
"envFile": "/etc/openvibe/openre.env",
"envFile": "/etc/openvibe/openre.env",
"env": { "required": "from-example" },
"env": { "required": "from-example" },
@@ -17,9 +17,15 @@ async function statusOne(ctx, svc) {
@@ -17,9 +17,15 @@ async function statusOne(ctx, svc) {
row.socket = { unit: svc.socketUnit, active: st.active, sub: st.sub };
row.socket = { unit: svc.socketUnit, active: st.active, sub: st.sub };
}
}
try {
try {
- const g = git(exec, svc);
- row.sha = await g.head();
- row.branch = await g.branch();
+ if (svc.layout === 'release') {
+ const target = await exec.readlink(`${svc.repo}/current`);
+ row.sha = target ? String(target).split('/').pop() : null;
+ row.branch = 'release';
+ } else {
+ const g = git(exec, svc);
+ row.sha = await g.head();
+ row.branch = await g.branch();
+ }
} catch (err) {
} catch (err) {
row.shaError = err.message.split('\n')[0];
row.shaError = err.message.split('\n')[0];
}
}
@@ -31,7 +31,16 @@ async function validate(ctx, id) {
@@ -31,7 +31,16 @@ async function validate(ctx, id) {
// ── checkout ──
// ── checkout ──
const repoStat = await exec.stat(svc.repo);
const repoStat = await exec.stat(svc.repo);
if (!repoStat || !repoStat.isDir) add('error', 'checkout', `${svc.repo} does not exist`);
if (!repoStat || !repoStat.isDir) add('error', 'checkout', `${svc.repo} does not exist`);
- else {
+ else if (svc.layout === 'release') {
+ // Release layout: <repo>/current -> <repo>/releases/<id>, root-owned so the service user
+ // cannot rewrite the code it runs.
+ const cur = `${svc.repo}/current`;
+ const target = await exec.readlink(cur);
+ const st = target ? await exec.stat(cur) : null;
+ if (!target || !st || !st.isDir) add('error', 'checkout', `${cur} is not a symlink to a release directory`);
+ else if (repoStat.owner !== 'root' || st.owner !== 'root') add('error', 'checkout', `${svc.repo} and its current release must be root-owned (found ${repoStat.owner}/${st.owner})`);
+ else add('ok', 'checkout', `release layout: current -> ${target}`);
+ } else {
if (repoStat.owner !== svc.owner) add('error', 'checkout', `${svc.repo} is owned by ${repoStat.owner}, expected ${svc.owner}`);
if (repoStat.owner !== svc.owner) add('error', 'checkout', `${svc.repo} is owned by ${repoStat.owner}, expected ${svc.owner}`);
else add('ok', 'checkout', `${svc.repo} owned by ${svc.owner}`);
else add('ok', 'checkout', `${svc.repo} owned by ${svc.owner}`);
try {
try {
@@ -43,8 +43,11 @@ function normaliseService(id, raw, defaults) {
@@ -43,8 +43,11 @@ function normaliseService(id, raw, defaults) {
runAs: s.runAs || s.owner,
runAs: s.runAs || s.owner,
remote: s.remote || 'origin',
remote: s.remote || 'origin',
branch: s.branch || 'main',
branch: s.branch || 'main',
- managed: s.managed !== false,
- unmanagedReason: s.unmanagedReason || null,
+ // 'git' (a checkout ovhost can move) or 'release' (root-owned releases/<id> behind a
+ // `current` symlink, installed by the service's own deploy script: never managed here).
+ layout: s.layout === 'release' ? 'release' : 'git',
+ managed: s.layout === 'release' ? false : s.managed !== false,
+ unmanagedReason: s.unmanagedReason || (s.layout === 'release' ? 'release layout: deployed by its own deploy script' : null),
units: s.units || [],
units: s.units || [],
socketUnit: s.socketUnit || null,
socketUnit: s.socketUnit || null,
unitSources: s.unitSources || {},
unitSources: s.unitSources || {},
@@ -9,6 +9,28 @@ function noSecrets(text) {
@@ -9,6 +9,28 @@ function noSecrets(text) {
}
}
runTests([
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: [] };
+ 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');
+ let res = JSON.parse((await host.cli('validate', 'rel', '--json')).out);
+ assert.deepStrictEqual(res.findings.filter((f) => f.level === 'error'), []);
+ assert.ok(res.findings.some((f) => f.area === 'checkout' && /current -> \/opt\/rel\.stream\/releases\/655b98a10aaa/.test(f.message)));
+ const st = await host.cli('status', '--json');
+ assert.match(st.out, /655b98a10aaa/);
+ assert.match(st.out, /"managed":\s*false/);
+ // A release the service user could rewrite is refused.
+ host.put('/opt/rel.stream/releases/bad/package.json', '{}', { owner: 'ubuntu' });
+ host.files.get('/opt/rel.stream/releases/bad').owner = 'ubuntu';
+ host.files.delete('/opt/rel.stream/current');
+ await host.exec.symlink('/opt/rel.stream/releases/bad', '/opt/rel.stream/current');
+ res = JSON.parse((await host.cli('validate', 'rel', '--json')).out);
+ assert.ok(res.findings.some((f) => f.level === 'error' && /must be root-owned/.test(f.message)));
+ }),
+
test('env validation reports names only — never a value — in text and JSON output', async () => {
test('env validation reports names only — never a value — in text and JSON output', async () => {
const host = scenario();
const host = scenario();
host.put('/etc/openvibe/live.env', `JWT_SECRET=${SECRET}\nBASE_URL=\nPAYPAL_CLIENT_SECRET=""\n`, { mode: 0o644 });
host.put('/etc/openvibe/live.env', `JWT_SECRET=${SECRET}\nBASE_URL=\nPAYPAL_CLIENT_SECRET=""\n`, { mode: 0o644 });