feat(console-update): systemd restart launcher for Linux supervision

Linux deployments under a systemd unit can now use the online update
panel: MULTI_SIMADMIN_SYSTEMD_UNIT (+ optional _SCOPE=user|system) hands
the restart to systemctl; createPlatformRestartLauncher picks launchd on
macOS and systemd on Linux, degrading to 'unsupported' otherwise.
This commit is contained in:
chick
2026-09-07 01:38:23 +08:00
parent 3c5971e167
commit 236e78327c
2 changed files with 113 additions and 0 deletions
@@ -6,6 +6,8 @@ import {
ConsoleUpdateService,
UpdateError,
createLaunchdRestartLauncher,
createPlatformRestartLauncher,
createSystemdRestartLauncher,
parseManifestVersion,
parseShortStat,
type UpdateCommandResult,
@@ -466,3 +468,73 @@ describe('createLaunchdRestartLauncher', () => {
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 });
});
});
describe('createSystemdRestartLauncher', () => {
it('is unsupported without a unit or with a malformed unit name', () => {
for (const unit of [undefined, '', 'evil; rm -rf /', 'multi-simadmin']) {
const launcher = createSystemdRestartLauncher({
env: unit === undefined ? {} : { MULTI_SIMADMIN_SYSTEMD_UNIT: unit },
runner: { run: async () => ({ code: 0, stdout: '', stderr: '' }) },
});
expect(launcher.supported).toBe(false);
}
});
it('restarts through the configured scope and unit', async () => {
const calls: string[][] = [];
const launcher = createSystemdRestartLauncher({
env: { MULTI_SIMADMIN_SYSTEMD_UNIT: 'multi-simadmin-api.service' },
runner: {
run: async (command, args) => {
calls.push([command, ...args]);
return { code: 0, stdout: '', stderr: '' };
},
},
});
expect(launcher.supported).toBe(true);
await launcher.restart();
expect(calls[0]).toEqual(['systemctl', 'user', 'restart', 'multi-simadmin-api.service']);
});
it('supports the system scope and surfaces systemctl failures', async () => {
const calls: string[][] = [];
const launcher = createSystemdRestartLauncher({
env: { MULTI_SIMADMIN_SYSTEMD_UNIT: 'multi-simadmin-api.service', MULTI_SIMADMIN_SYSTEMD_SCOPE: 'system' },
runner: {
run: async (command, args) => {
calls.push([command, ...args]);
return { code: 1, stdout: '', stderr: 'not logged in' };
},
},
});
await launcher.restart().catch(() => {});
expect(calls[0]).toEqual(['systemctl', 'system', 'restart', 'multi-simadmin-api.service']);
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 });
});
});
describe('createPlatformRestartLauncher', () => {
const runner = { run: async () => ({ code: 0, stdout: '', stderr: '' }) };
it('prefers systemd on Linux when a unit is configured', () => {
const launcher = createPlatformRestartLauncher({
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'linux', MULTI_SIMADMIN_SYSTEMD_UNIT: 'api.service' },
runner,
});
expect(launcher.supported).toBe(true);
});
it('keeps the launchd launcher everywhere else', () => {
const linux = createPlatformRestartLauncher({
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'linux' },
runner,
});
const darwin = createPlatformRestartLauncher({
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'darwin', XPC_SERVICE_NAME: 'x' },
runner,
uid: () => 501,
});
expect(linux.supported).toBe(false);
expect(darwin.supported).toBe(true);
});
});
@@ -241,6 +241,47 @@ export function createLaunchdRestartLauncher(input: {
};
}
/**
* Linux systemd counterpart to the launchd launcher. The unit and scope are
* supplied explicitly through the environment so a supervised deployment can
* hand restarts to its service manager; unset variables keep it unsupported.
*/
export function createSystemdRestartLauncher(input: {
readonly env: Readonly<Record<string, string | undefined>>;
readonly runner: UpdateCommandRunner;
}): RestartLauncher {
const unit = input.env.MULTI_SIMADMIN_SYSTEMD_UNIT ?? '';
const scope = input.env.MULTI_SIMADMIN_SYSTEMD_SCOPE === 'system' ? 'system' : 'user';
const validUnit = /^[A-Za-z0-9@_.\-]+\.service$/.test(unit);
const supported = validUnit;
return {
supported,
async restart() {
if (!supported)
throw new UpdateError('NOT_SUPPORTED', '未配置 systemd 服务单元(MULTI_SIMADMIN_SYSTEMD_UNIT)。', 501);
const result = await input.runner.run('systemctl', [scope, 'restart', unit]);
if (result.code !== 0)
throw new UpdateError(
'COMMAND',
text(result.stderr, 200) || 'systemctl restart 失败。',
500,
);
},
};
}
/** Picks the restart launcher matching the host service manager. */
export function createPlatformRestartLauncher(input: {
readonly env: Readonly<Record<string, string | undefined>>;
readonly runner: UpdateCommandRunner;
readonly uid?: () => number | undefined;
}): RestartLauncher {
const platform = input.env.MULTI_SIMADMIN_HOST_PLATFORM ?? process.platform;
return platform === 'linux' && input.env.MULTI_SIMADMIN_SYSTEMD_UNIT !== undefined
? createSystemdRestartLauncher(input)
: createLaunchdRestartLauncher(input);
}
export interface ConsoleUpdateServiceOptions {
readonly db: Database.Database;
readonly version: string;