fix(web): update foreground systemd services safely (#2542)
* fix(web): update foreground systemd services safely * clarify desktop and remote updates * Revert "clarify desktop and remote updates" This reverts commit aaf4516277de003e132d549d5281811fae96cfd5. * test-web-systemd-updates
This commit is contained in:
@@ -343,6 +343,9 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
|||||||
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
|
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
|
||||||
- `GET /api/openchamber/update-check`
|
- `GET /api/openchamber/update-check`
|
||||||
- `POST /api/openchamber/update-install`
|
- `POST /api/openchamber/update-install`
|
||||||
|
- Foreground servers running under a systemd user unit queue installation in
|
||||||
|
a separate transient unit and restart the configured service afterwards.
|
||||||
|
`OPENCHAMBER_SYSTEMD_UNIT` overrides the default `openchamber.service`.
|
||||||
- `GET /api/openchamber/models-metadata`
|
- `GET /api/openchamber/models-metadata`
|
||||||
- `GET /api/zen/models`
|
- `GET /api/zen/models`
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
const SYSTEMD_SERVICE_UNIT_PATTERN = /^[A-Za-z0-9:_.@-]+\.service$/;
|
||||||
|
|
||||||
|
function resolveSystemdServiceUnit(environment) {
|
||||||
|
if (!environment.INVOCATION_ID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredUnit = typeof environment.OPENCHAMBER_SYSTEMD_UNIT === 'string'
|
||||||
|
? environment.OPENCHAMBER_SYSTEMD_UNIT.trim()
|
||||||
|
: '';
|
||||||
|
const unit = configuredUnit || 'openchamber.service';
|
||||||
|
return SYSTEMD_SERVICE_UNIT_PATTERN.test(unit) ? unit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotePosixShell(value) {
|
||||||
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
export const registerOpenChamberRoutes = (app, dependencies) => {
|
export const registerOpenChamberRoutes = (app, dependencies) => {
|
||||||
const {
|
const {
|
||||||
fs,
|
fs,
|
||||||
@@ -54,7 +72,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|||||||
|
|
||||||
app.post('/api/openchamber/update-install', async (_req, res) => {
|
app.post('/api/openchamber/update-install', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { spawn: spawnChild } = await import('child_process');
|
const { spawn: spawnChild, spawnSync } = await import('child_process');
|
||||||
const {
|
const {
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
getUpdateCommand,
|
getUpdateCommand,
|
||||||
@@ -110,6 +128,55 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
const launchMode = storedOptions.launchMode === 'foreground' ? 'foreground' : 'daemon';
|
const launchMode = storedOptions.launchMode === 'foreground' ? 'foreground' : 'daemon';
|
||||||
const isForegroundService = launchMode === 'foreground';
|
const isForegroundService = launchMode === 'foreground';
|
||||||
|
const systemdServiceUnit = isForegroundService ? resolveSystemdServiceUnit(process.env) : null;
|
||||||
|
|
||||||
|
if (isForegroundService) {
|
||||||
|
if (!systemdServiceUnit) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateJobName = `openchamber-update-${Date.now()}`;
|
||||||
|
const updateLogPath = `journalctl --user-unit ${updateJobName}.service`;
|
||||||
|
const updateScript = [
|
||||||
|
'set -eu',
|
||||||
|
updateCmd,
|
||||||
|
`systemctl --user restart ${quotePosixShell(systemdServiceUnit)}`,
|
||||||
|
].join('\n');
|
||||||
|
const systemdRun = spawnSync('systemd-run', [
|
||||||
|
'--user',
|
||||||
|
`--unit=${updateJobName}`,
|
||||||
|
'--collect',
|
||||||
|
'--service-type=exec',
|
||||||
|
`--setenv=PATH=${process.env.PATH || ''}`,
|
||||||
|
'/bin/sh',
|
||||||
|
'-c',
|
||||||
|
updateScript,
|
||||||
|
], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (systemdRun.status !== 0) {
|
||||||
|
const detail = (systemdRun.stderr || systemdRun.stdout || '').trim();
|
||||||
|
return res.status(409).json({
|
||||||
|
error: detail || `Could not queue update job for ${systemdServiceUnit}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Update queued; OpenChamber will restart after installation completes',
|
||||||
|
version: updateInfo.version,
|
||||||
|
packageManager: pm,
|
||||||
|
autoRestart: true,
|
||||||
|
restartManager: 'systemd',
|
||||||
|
jobId: updateJobName,
|
||||||
|
logPath: updateLogPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
|
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import path from 'node:path';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
vi.mock('child_process', () => ({
|
||||||
|
spawn: vi.fn(),
|
||||||
|
spawnSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../package-manager.js', () => ({
|
||||||
|
checkForUpdates: vi.fn(),
|
||||||
|
getUpdateCommand: vi.fn(),
|
||||||
|
detectPackageManagerDetails: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const childProcess = await import('child_process');
|
||||||
|
const packageManager = await import('../package-manager.js');
|
||||||
|
const { registerOpenChamberRoutes } = await import('./openchamber-routes.js');
|
||||||
|
|
||||||
|
const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
|
||||||
|
const app = express();
|
||||||
|
const dependencies = {
|
||||||
|
fs: {
|
||||||
|
existsSync: vi.fn(() => false),
|
||||||
|
promises: {
|
||||||
|
readFile: vi.fn(async () => JSON.stringify({
|
||||||
|
launchMode: 'foreground',
|
||||||
|
port: 7897,
|
||||||
|
...storedOptions,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
path,
|
||||||
|
process: {
|
||||||
|
env: environment,
|
||||||
|
platform: 'linux',
|
||||||
|
execPath: '/usr/bin/node',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
address: () => ({ port: 7897 }),
|
||||||
|
},
|
||||||
|
__dirname: '/opt/openchamber/server',
|
||||||
|
openchamberDataDir: '/tmp/openchamber',
|
||||||
|
modelsDevApiUrl: 'https://models.example.test',
|
||||||
|
modelsMetadataCacheTtl: 0,
|
||||||
|
readSettingsFromDiskMigrated: vi.fn(),
|
||||||
|
fetchFreeZenModels: vi.fn(),
|
||||||
|
getCachedZenModels: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
registerOpenChamberRoutes(app, dependencies);
|
||||||
|
return { app, dependencies };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
packageManager.checkForUpdates.mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
version: '1.17.1',
|
||||||
|
});
|
||||||
|
packageManager.detectPackageManagerDetails.mockReturnValue({
|
||||||
|
packageManager: 'npm',
|
||||||
|
});
|
||||||
|
packageManager.getUpdateCommand.mockReturnValue('npm install -g @openchamber/web@latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OpenChamber foreground update route', () => {
|
||||||
|
it('rejects a foreground update when the server is not owned by systemd', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/openchamber/update-install')
|
||||||
|
.expect(409, {
|
||||||
|
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(childProcess.spawnSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsafe systemd unit override before starting an update job', async () => {
|
||||||
|
const { app } = createApp({
|
||||||
|
environment: {
|
||||||
|
INVOCATION_ID: 'systemd-invocation',
|
||||||
|
OPENCHAMBER_SYSTEMD_UNIT: 'openchamber.service; rm -rf /',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/openchamber/update-install')
|
||||||
|
.expect(409, {
|
||||||
|
error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(childProcess.spawnSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues the install in a transient systemd unit and returns its job identifier', async () => {
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
|
||||||
|
childProcess.spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
|
||||||
|
const { app } = createApp({
|
||||||
|
environment: {
|
||||||
|
INVOCATION_ID: 'systemd-invocation',
|
||||||
|
OPENCHAMBER_SYSTEMD_UNIT: 'openchamber@wsl.service',
|
||||||
|
PATH: '/home/syu/.npm-global/bin:/usr/bin:/bin',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/openchamber/update-install')
|
||||||
|
.expect(200, {
|
||||||
|
success: true,
|
||||||
|
message: 'Update queued; OpenChamber will restart after installation completes',
|
||||||
|
version: '1.17.1',
|
||||||
|
packageManager: 'npm',
|
||||||
|
autoRestart: true,
|
||||||
|
restartManager: 'systemd',
|
||||||
|
jobId: 'openchamber-update-1700000000000',
|
||||||
|
logPath: 'journalctl --user-unit openchamber-update-1700000000000.service',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(childProcess.spawnSync).toHaveBeenCalledWith('systemd-run', [
|
||||||
|
'--user',
|
||||||
|
'--unit=openchamber-update-1700000000000',
|
||||||
|
'--collect',
|
||||||
|
'--service-type=exec',
|
||||||
|
'--setenv=PATH=/home/syu/.npm-global/bin:/usr/bin:/bin',
|
||||||
|
'/bin/sh',
|
||||||
|
'-c',
|
||||||
|
"set -eu\nnpm install -g @openchamber/web@latest\nsystemctl --user restart 'openchamber@wsl.service'",
|
||||||
|
], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user