blob: 88edefca6520a55ae04b5bf5ebaeaa4ae6c3e2b1 [file]
import { spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { access, readFile, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { resolveXdgConfigHome } from '@maka/storage';
import {
removeRuntimeHostServiceFile,
RuntimeHostServiceManagerError,
type RuntimeHostManagedServiceConfig,
type RuntimeHostServiceBackend,
type RuntimeHostServiceBackendStatus,
type RuntimeHostServiceDeployment,
writeRuntimeHostServiceFile,
} from './runtime-host-service-manager.js';
const SERVICE_MANAGER_COMMAND_TIMEOUT_MS = 30_000;
interface CommandResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
}
interface SystemdUnitContext {
readonly unitName: string;
readonly unitPath: string;
readonly runSystemctl: (args: readonly string[]) => Promise<CommandResult>;
}
export interface SystemdUserServiceOptions {
readonly env?: NodeJS.ProcessEnv;
readonly homeDir?: string;
readonly uid?: number;
readonly runSystemctl?: (args: readonly string[]) => Promise<CommandResult>;
readonly runLoginctl?: (args: readonly string[]) => Promise<CommandResult>;
readonly runJournalctl?: (args: readonly string[]) => Promise<CommandResult>;
}
export function createSystemdUserRuntimeHostService(
serviceId: string,
options: SystemdUserServiceOptions = {},
): RuntimeHostServiceBackend {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const runSystemctl = options.runSystemctl ?? defaultRunSystemctl;
const context: SystemdUnitContext = {
unitName: resolveSystemdUserRuntimeHostServiceName(serviceId),
unitPath: resolveSystemdUserRuntimeHostServicePath(serviceId, env, homeDir),
runSystemctl,
};
const runLoginctl = options.runLoginctl ?? defaultRunLoginctl;
const runJournalctl = options.runJournalctl ?? defaultRunJournalctl;
const uid = options.uid ?? process.getuid?.();
const readStatus = async (): Promise<RuntimeHostServiceBackendStatus> => {
const raw = await readSystemdStatus(context);
return {
manager: 'systemd_user',
installed: raw.loadState !== 'not-found',
enabled: raw.unitFileState === 'enabled' || raw.unitFileState === 'enabled-runtime',
active: raw.activeState === 'active',
state: systemdServiceState(raw.loadState, raw.activeState),
pid: positiveInteger(raw.mainPid),
lastExitCode: nonNegativeInteger(raw.execMainStatus),
};
};
return {
preflightInstall: async () => {
await assertUserSystemd(runSystemctl);
await assertUserLinger(uid, runLoginctl);
},
install: async (config) => {
await validateLaunchFiles(config);
const previous = await captureSystemdDeployment(context.unitPath, readStatus);
try {
await applySystemdDeployment(context, config);
} catch (error) {
await restoreFailedSystemdDeployment(previous, context, error);
}
let rolledBack = false;
return {
rollback: async () => {
if (rolledBack) return;
rolledBack = true;
await restoreSystemdDeployment(previous, context);
},
} satisfies RuntimeHostServiceDeployment;
},
status: readStatus,
start: () => runLifecycleAction(context, 'start'),
stop: () => runLifecycleAction(context, 'stop'),
restart: () => runLifecycleAction(context, 'restart'),
logs: async () => {
const result = await runJournalctl([
'--user-unit',
context.unitName,
'--no-pager',
'--lines=200',
'--output=short-iso',
]).catch((error) => {
throw new RuntimeHostServiceManagerError(
'service_manager_unavailable',
'Unable to read Runtime Host service logs',
{ cause: error },
);
});
if (result.exitCode !== 0) {
throw managerError('Reading Runtime Host service logs failed', result);
}
return result.stdout;
},
uninstall: async () => {
const before = await readSystemdStatus(context);
if (before.loadState !== 'not-found') {
await requireSystemctl(
runSystemctl,
['stop', context.unitName],
'Stopping the Runtime Host service failed',
);
}
if (
before.loadState !== 'not-found' ||
before.unitFileState === 'enabled' ||
before.unitFileState === 'enabled-runtime'
) {
await requireSystemctl(
runSystemctl,
['disable', context.unitName],
'Disabling the Runtime Host service failed',
);
await runSystemctl(['reset-failed', context.unitName]);
}
await removeRuntimeHostServiceFile(context.unitPath, 'systemd unit');
await requireSystemctl(runSystemctl, ['daemon-reload'], 'Reloading systemd failed');
const after = await readStatus();
if (after.installed || after.active || after.enabled) {
throw new RuntimeHostServiceManagerError(
'uninstall_incomplete',
`Runtime Host systemd service still has managed state: ${after.state}`,
);
}
},
};
}
export function resolveSystemdUserRuntimeHostServicePath(
serviceId: string,
env: NodeJS.ProcessEnv = process.env,
homeDir = homedir(),
): string {
const systemdConfigRoot = resolveXdgConfigHome(env, homeDir);
return join(
systemdConfigRoot,
'systemd',
'user',
resolveSystemdUserRuntimeHostServiceName(serviceId),
);
}
function resolveSystemdUserRuntimeHostServiceName(serviceId: string): string {
if (!/^[0-9a-f]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID');
return `maka-runtime-host-${serviceId}.service`;
}
export function renderSystemdUnit(config: RuntimeHostManagedServiceConfig): string {
const args = [
config.launch.nodePath,
config.launch.cliPath,
'runtime-host',
'serve',
'--root',
config.rootPath,
...config.projectDirectoryRoots.flatMap(({ label, path }) => [
'--project-root',
`${label}=${path}`,
]),
'--websocket-host',
config.websocket.host,
'--websocket-port',
String(config.websocket.port),
'--websocket-path',
config.websocket.path,
'--json',
];
return [
'[Unit]',
'Description=Maka Runtime Host',
'After=network.target',
'StartLimitIntervalSec=60s',
'StartLimitBurst=5',
'',
'[Service]',
'Type=simple',
`ExecStart=${args.map(quoteSystemdArgument).join(' ')}`,
// A clean idle exit must not silently remove a configured remote Host.
'Restart=always',
'RestartSec=2s',
'KillMode=mixed',
'TimeoutStopSec=45s',
'UMask=0077',
'',
'[Install]',
'WantedBy=default.target',
'',
].join('\n');
}
interface SystemdStatus {
readonly loadState: string;
readonly activeState: string;
readonly unitFileState: string;
readonly mainPid?: string;
readonly execMainStatus?: string;
}
interface SystemdDeploymentSnapshot {
readonly unit: string | null;
readonly status: RuntimeHostServiceBackendStatus;
}
async function captureSystemdDeployment(
unitPath: string,
readStatus: () => Promise<RuntimeHostServiceBackendStatus>,
): Promise<SystemdDeploymentSnapshot> {
const [unit, status] = await Promise.all([
readFile(unitPath, 'utf8').catch((error: unknown) => {
if (isNodeError(error, 'ENOENT')) return null;
throw error;
}),
readStatus(),
]);
return { unit, status };
}
async function applySystemdDeployment(
context: SystemdUnitContext,
config: RuntimeHostManagedServiceConfig,
): Promise<void> {
await writeRuntimeHostServiceFile(context.unitPath, renderSystemdUnit(config), 0o600);
await requireSystemctl(context.runSystemctl, ['daemon-reload'], 'Reloading systemd failed');
await requireSystemctl(
context.runSystemctl,
['enable', context.unitName],
'Enabling the Runtime Host service failed',
);
await context.runSystemctl(['reset-failed', context.unitName]);
await requireSystemctl(
context.runSystemctl,
['restart', context.unitName],
'Starting the Runtime Host service failed',
);
}
async function restoreFailedSystemdDeployment(
snapshot: SystemdDeploymentSnapshot,
context: SystemdUnitContext,
originalError: unknown,
): Promise<never> {
try {
await restoreSystemdDeployment(snapshot, context);
} catch (rollbackError) {
throw new RuntimeHostServiceManagerError(
'service_manager_operation_failed',
'Updating the Runtime Host service failed and the previous systemd deployment could not be restored',
{ cause: new AggregateError([originalError, rollbackError]) },
);
}
throw originalError;
}
async function restoreSystemdDeployment(
snapshot: SystemdDeploymentSnapshot,
context: SystemdUnitContext,
): Promise<void> {
if (snapshot.unit === null) {
let current: SystemdStatus;
try {
current = await readSystemdStatus(context);
} catch (error) {
await removeRuntimeHostServiceFile(context.unitPath, 'systemd unit');
throw error;
}
if (current.loadState !== 'not-found') {
await requireSystemctl(
context.runSystemctl,
['stop', context.unitName],
'Stopping the replacement Runtime Host service failed',
);
await requireSystemctl(
context.runSystemctl,
['disable', context.unitName],
'Disabling the replacement Runtime Host service failed',
);
}
await removeRuntimeHostServiceFile(context.unitPath, 'systemd unit');
await requireSystemctl(context.runSystemctl, ['daemon-reload'], 'Reloading systemd failed');
await context.runSystemctl(['reset-failed', context.unitName]);
return;
}
await writeRuntimeHostServiceFile(context.unitPath, snapshot.unit, 0o600);
await requireSystemctl(context.runSystemctl, ['daemon-reload'], 'Reloading systemd failed');
await requireSystemctl(
context.runSystemctl,
[snapshot.status.enabled ? 'enable' : 'disable', context.unitName],
'Restoring the Runtime Host service enablement failed',
);
if (snapshot.status.active) {
await context.runSystemctl(['reset-failed', context.unitName]);
}
await requireSystemctl(
context.runSystemctl,
[snapshot.status.active ? 'restart' : 'stop', context.unitName],
'Restoring the Runtime Host service state failed',
);
}
async function readSystemdStatus(context: SystemdUnitContext): Promise<SystemdStatus> {
let result: CommandResult;
try {
result = await context.runSystemctl([
'show',
context.unitName,
'--property=LoadState,ActiveState,SubState,UnitFileState,MainPID,ExecMainStatus',
'--no-pager',
]);
} catch (error) {
throw new RuntimeHostServiceManagerError(
'service_manager_unavailable',
'Unable to query the systemd user service manager',
{ cause: error },
);
}
const properties = parseProperties(result.stdout);
const loadState = properties.get('LoadState');
if (loadState === undefined || (result.exitCode !== 0 && loadState !== 'not-found')) {
throw managerError('Reading Runtime Host service status failed', result);
}
return {
loadState,
activeState: properties.get('ActiveState') ?? 'inactive',
unitFileState: properties.get('UnitFileState') ?? 'disabled',
mainPid: properties.get('MainPID'),
execMainStatus: properties.get('ExecMainStatus'),
};
}
async function validateLaunchFiles(config: RuntimeHostManagedServiceConfig): Promise<void> {
try {
const [nodePath, cliPath] = await Promise.all([
realpath(config.launch.nodePath),
realpath(config.launch.cliPath),
]);
const [node, cli] = await Promise.all([stat(nodePath), stat(cliPath)]);
if (!node.isFile() || !cli.isFile()) throw new Error('Launch path is not a file');
await Promise.all([access(nodePath, constants.X_OK), access(cliPath, constants.R_OK)]);
} catch (error) {
throw new RuntimeHostServiceManagerError(
'invalid_launch',
'The configured Node.js or Maka CLI installation is unavailable',
{ cause: error },
);
}
}
async function assertUserSystemd(
runSystemctl: (args: readonly string[]) => Promise<CommandResult>,
): Promise<void> {
let result: CommandResult;
try {
result = await runSystemctl(['show-environment']);
} catch (error) {
throw new RuntimeHostServiceManagerError(
'service_manager_unavailable',
'systemd --user is unavailable',
{ cause: error },
);
}
if (result.exitCode === 0) return;
throw new RuntimeHostServiceManagerError(
'service_manager_unavailable',
`systemd --user is unavailable${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}`,
);
}
async function assertUserLinger(
uid: number | undefined,
runLoginctl: (args: readonly string[]) => Promise<CommandResult>,
): Promise<void> {
if (uid === undefined) {
throw new RuntimeHostServiceManagerError(
'service_manager_unavailable',
'The current Linux user identity could not be determined',
);
}
let result: CommandResult;
try {
result = await runLoginctl(['show-user', String(uid), '--property=Linger', '--value']);
} catch (error) {
throw new RuntimeHostServiceManagerError(
'service_manager_unavailable',
'Unable to verify systemd user lingering',
{ cause: error },
);
}
if (result.exitCode !== 0) {
throw new RuntimeHostServiceManagerError(
'service_manager_unavailable',
`Unable to verify systemd user lingering${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}`,
);
}
if (result.stdout.trim() !== 'yes') {
throw new RuntimeHostServiceManagerError(
'linger_disabled',
`Persistent user services are disabled. Enable lingering for user ${uid} and retry`,
);
}
}
async function runLifecycleAction(
context: SystemdUnitContext,
action: 'start' | 'stop' | 'restart',
): Promise<void> {
await requireSystemctl(
context.runSystemctl,
[action, context.unitName],
`${actionPresentParticiple(action)} the Runtime Host service failed`,
);
}
async function requireSystemctl(
runSystemctl: (args: readonly string[]) => Promise<CommandResult>,
args: readonly string[],
message: string,
): Promise<void> {
let result: CommandResult;
try {
result = await runSystemctl(args);
} catch (error) {
throw new RuntimeHostServiceManagerError('service_manager_unavailable', message, {
cause: error,
});
}
if (result.exitCode !== 0) throw managerError(message, result);
}
function managerError(message: string, result: CommandResult): RuntimeHostServiceManagerError {
const detail = result.stderr.trim() || result.stdout.trim();
return new RuntimeHostServiceManagerError(
'service_manager_operation_failed',
detail ? `${message}: ${detail}` : message,
);
}
async function defaultRunSystemctl(args: readonly string[]): Promise<CommandResult> {
return runCommand('systemctl', ['--user', ...args]);
}
async function defaultRunLoginctl(args: readonly string[]): Promise<CommandResult> {
return runCommand('loginctl', args);
}
async function defaultRunJournalctl(args: readonly string[]): Promise<CommandResult> {
return runCommand('journalctl', args);
}
async function runCommand(command: string, args: readonly string[]): Promise<CommandResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
timeout: SERVICE_MANAGER_COMMAND_TIMEOUT_MS,
killSignal: 'SIGKILL',
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
stdout += chunk;
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.once('error', reject);
child.once('close', (exitCode) => {
resolveResult({ exitCode: exitCode ?? 1, stdout, stderr });
});
});
}
function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error && error.code === code;
}
function parseProperties(output: string): Map<string, string> {
const properties = new Map<string, string>();
for (const line of output.split('\n')) {
const separator = line.indexOf('=');
if (separator > 0) properties.set(line.slice(0, separator), line.slice(separator + 1));
}
return properties;
}
function systemdServiceState(loadState: string, activeState: string) {
if (loadState === 'not-found') return 'not_installed' as const;
if (activeState === 'active') return 'running' as const;
if (activeState === 'activating' || activeState === 'reloading') return 'starting' as const;
if (activeState === 'failed') return 'failed' as const;
return 'stopped' as const;
}
function positiveInteger(value: string | undefined): number | null {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function nonNegativeInteger(value: string | undefined): number | null {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
}
function quoteSystemdArgument(value: string): string {
if (/[\u0000-\u001f\u007f]/u.test(value)) {
throw new TypeError('systemd arguments cannot contain control characters');
}
return `"${value
.replaceAll('\\', '\\\\')
.replaceAll('"', '\\"')
.replaceAll('%', '%%')
.replaceAll('$', () => '$$')}"`;
}
function actionPresentParticiple(action: 'start' | 'stop' | 'restart'): string {
if (action === 'start') return 'Starting';
if (action === 'stop') return 'Stopping';
return 'Restarting';
}