From 236e78327c008a360ea0e9f0b85b6a4af4becd1f Mon Sep 17 00:00:00 2001 From: chick Date: Mon, 7 Sep 2026 01:38:23 +0800 Subject: [PATCH] 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. --- .../system/console-update-service.test.ts | 72 +++++++++++++++++++ .../system/console-update-service.ts | 41 +++++++++++ 2 files changed, 113 insertions(+) diff --git a/apps/api/src/application/system/console-update-service.test.ts b/apps/api/src/application/system/console-update-service.test.ts index b5e0e59..5388c6a 100644 --- a/apps/api/src/application/system/console-update-service.test.ts +++ b/apps/api/src/application/system/console-update-service.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/application/system/console-update-service.ts b/apps/api/src/application/system/console-update-service.ts index 76aa8c0..20ac80e 100644 --- a/apps/api/src/application/system/console-update-service.ts +++ b/apps/api/src/application/system/console-update-service.ts @@ -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>; + 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>; + 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;