From 283d19b5295bd71777c15600b6c7faf0f699ccf6 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 3 Sep 2026 16:09:46 +0800 Subject: [PATCH 1/2] feat(runtime-host): add OpenRC lifecycle provider Generated-by: OpenAI Codex --- .../runtime-host-openrc-service.test.ts | 149 +++++ .../src/__tests__/runtime-host-setup.test.ts | 100 ++- .../runtime-host-managed-lifecycle-manager.ts | 2 +- .../cli/src/runtime-host-openrc-service.ts | 625 ++++++++++++++++++ ...runtime-host-service-management-command.ts | 80 ++- .../cli/src/runtime-host-service-manager.ts | 8 +- .../cli/src/runtime-host-setup-command.ts | 74 ++- 7 files changed, 953 insertions(+), 85 deletions(-) create mode 100644 packages/cli/src/__tests__/runtime-host-openrc-service.test.ts create mode 100644 packages/cli/src/runtime-host-openrc-service.ts diff --git a/packages/cli/src/__tests__/runtime-host-openrc-service.test.ts b/packages/cli/src/__tests__/runtime-host-openrc-service.test.ts new file mode 100644 index 0000000000..70ced3dff2 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-openrc-service.test.ts @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { lstat, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { test } from 'node:test'; +import { createOpenRcRuntimeHostLifecycleProvider } from '../runtime-host-openrc-service.js'; + +const SERVICE_ID = 'a'.repeat(64); +const SERVICE_NAME = `maka-runtime-host-${SERVICE_ID}`; +const UPDATE_NAME = `${SERVICE_NAME}-update`; + +test('OpenRC provider owns one supervised Host and reconciliation loop', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka openrc provider-')); + t.after(() => rm(root, { recursive: true, force: true })); + const paths = { + initDirectory: join(root, 'init.d'), + runlevelDirectory: join(root, 'runlevels', 'default'), + artifactDirectory: join(root, 'artifacts'), + logDirectory: join(root, 'logs'), + stateDirectory: join(root, 'state'), + }; + const active = new Set(); + const calls: [string, readonly string[]][] = []; + const runCommand = async (command: string, args: readonly string[]) => { + calls.push([command, args]); + if (command === 'supervise-daemon' && args[0] === '--help') { + return { exitCode: 1, stdout: '', stderr: 'usage' }; + } + if (command === 'rc-service' && args[0] !== '--help') { + const [name, action] = args; + if (action === 'status') { + return { exitCode: active.has(name!) ? 0 : 3, stdout: '', stderr: '' }; + } + if (action === 'start') { + active.add(name!); + const state = join(paths.stateDirectory, 'options', name!); + await mkdir(state, { recursive: true }); + await writeFile(join(state, 'child_pid'), name === SERVICE_NAME ? '4242\n' : '4343\n'); + } else if (action === 'stop') { + active.delete(name!); + } + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (command === 'rc-update' && args[0] !== 'show') { + const [action, name] = args; + const link = join(paths.runlevelDirectory, name!); + if (action === 'add') { + await mkdir(paths.runlevelDirectory, { recursive: true }); + await symlink(join(paths.initDirectory, name!), link); + } else if (action === 'del') { + await unlink(link); + } + } + return { exitCode: 0, stdout: '', stderr: '' }; + }; + const provider = createOpenRcRuntimeHostLifecycleProvider(SERVICE_ID, 'openrc_system', { + uid: 0, + paths, + runCommand, + }); + const supervisor = { + command: [ + process.execPath, + '/tmp/maka cli.js', + "quote'value", + '$(printf injected)', + '*.js', + ] as const, + }; + const reconciliation = { + command: ['/tmp/maka operator', 'reconcile-update', '--framed'] as const, + }; + + await provider.supervisor.preflight(); + await provider.supervisor.converge(supervisor); + await provider.reconciliationTrigger.converge(reconciliation); + await provider.supervisor.verify(supervisor); + await provider.reconciliationTrigger.verify(reconciliation); + const servicePath = join(paths.initDirectory, SERVICE_NAME); + const evaluated = await promisify(execFile)('/bin/sh', [ + '-c', + '. "$1"; eval "set -- --stdout $output_log --stderr $error_log $command -- $command_args"; printf "%s\\n" "$@"', + 'sh', + servicePath, + ]); + assert.deepEqual(evaluated.stdout.trimEnd().split('\n'), [ + '--stdout', + join(paths.logDirectory, 'host.stdout.log'), + '--stderr', + join(paths.logDirectory, 'host.stderr.log'), + process.execPath, + '--', + '/tmp/maka cli.js', + "quote'value", + '$(printf injected)', + '*.js', + ]); + await provider.supervisor.activate(); + await provider.reconciliationTrigger.activate(); + + assert.deepEqual(await provider.supervisor.status(), { + provider: 'openrc_system', + installed: true, + enabled: true, + active: true, + state: 'running', + pid: 4242, + lastExitCode: null, + }); + assert.deepEqual(await provider.reconciliationTrigger.status(), { + installed: true, + active: true, + }); + assert.match(await readFile(servicePath, 'utf8'), /retry=TERM\/45\/KILL\/5[\s\S]*respawn_max=0/u); + assert.match( + await readFile(join(paths.artifactDirectory, 'update'), 'utf8'), + /reconcile-update[\s\S]*sleep 86400/u, + ); + await writeFile(join(paths.logDirectory, 'host.stdout.log'), 'host output\n'); + assert.match(await provider.supervisor.logs(), /host output/u); + + await provider.reconciliationTrigger.uninstall(); + await provider.supervisor.uninstall(); + assert.equal(active.size, 0); + await assert.rejects(lstat(join(paths.initDirectory, SERVICE_NAME)), { code: 'ENOENT' }); + await assert.rejects(lstat(join(paths.initDirectory, UPDATE_NAME)), { code: 'ENOENT' }); + assert.ok(calls.some(([command, args]) => command === 'rc-update' && args[0] === 'add')); +}); diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index 6cd05c8cf2..773952b251 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -63,13 +63,9 @@ import { runRuntimeHostSetupCli } from '../runtime-host-setup-command.js'; import { RuntimeHostAccessUnavailableError } from '../runtime-host-access-command.js'; import { replaceRuntimeHostLifecycle } from '../runtime-host-lifecycle-transaction.js'; import { manageRuntimeHostManagedLifecycle } from '../runtime-host-managed-lifecycle-manager.js'; -import { - resolveRuntimeHostLifecycleProvider, - selectRuntimeHostLifecycleProvider, -} from '../runtime-host-service-management-command.js'; +import { resolveRuntimeHostLifecycleProvider } from '../runtime-host-service-management-command.js'; import { resolveRuntimeHostManagedServiceId, - RuntimeHostServiceManagerError, type RuntimeHostServiceBackend, } from '../runtime-host-service-manager.js'; @@ -337,6 +333,82 @@ test('on-demand setup installs one exact deployment without a service backend', assert.equal(uninstalled.retirement.kind, 'stopped'); }); +test('fresh supervised setup discovers its provider before constructing a legacy backend', async (t) => { + const base = await realpath(await mkdtemp(join(tmpdir(), 'maka-runtime-host-supervised-setup-'))); + const stateRoot = join(base, 'state'); + const clientDataRoot = join(base, 'client'); + let rootId = ''; + t.after(async () => { + await Promise.all([ + rm(base, { recursive: true, force: true }), + rootId + ? rm(join(resolveRootControlNamespace(), rootId), { recursive: true, force: true }) + : Promise.resolve(), + rootId + ? rm(join(resolveRootOwnershipNamespace(), `${rootId}.lock`), { force: true }) + : Promise.resolve(), + ]); + }); + + const result = await runRuntimeHostSetupCli( + { + json: true, + lifecycle: 'supervised', + clientDataRoot, + defaultRootPath: stateRoot, + sourcePackageRoot: base, + version: '1.2.3', + principalId: 'desktop:client-1', + preset: 'desktop-client', + }, + { + createBackend: () => assert.fail('a fresh canonical setup has no legacy backend'), + discoverLifecycleProvider: async (discoveredRootId) => { + rootId = discoveredRootId; + return { + provider: resolveRuntimeHostLifecycleProvider(rootId, 'openrc_user'), + availability: 'session', + }; + }, + resolveRegistryCandidate: async () => ({ + kind: 'npm_registry', + version: '1.2.3', + integrity: PACKAGE_INTEGRITY, + }), + withRegistryPackage: async (_candidate, use) => use('/verified/package'), + prepareDeployment: async ({ serviceId }) => ({ + version: '1.2.3', + root: join(base, 'deployment'), + cliPath: '/verified/package/dist/cli.js', + operatorPath: '/opt/maka/operator', + activate: async () => undefined, + cleanup: async () => undefined, + rollback: async () => undefined, + }), + allocateLoopbackPort: async () => 43_210, + replaceLifecycle: async ({ desired }) => { + assert.equal(desired.lifecycle.mode, 'supervised'); + assert.equal(desired.lifecycle.provider, 'openrc_user'); + return { kind: 'replaced', config: desired }; + }, + prunePackages: async () => undefined, + replaceCredential: async () => ({ + rootId, + credential: 'secret-token', + credentialId: 'credential-1', + principalKind: 'remote_owner', + principalId: 'desktop:client-1', + operationGrants: [], + canPublishClientCapabilities: false, + canUseHostPaths: false, + }), + verifyCredential: async () => undefined, + writeOutput: () => undefined, + }, + ); + assert.equal(result, 0); +}); + test('managed setup frames reject malformed machine output', () => { assert.equal( decodeRuntimeHostSetupFrame( @@ -368,20 +440,10 @@ test('managed setup frames reject malformed machine output', () => { ); }); -test('lifecycle discovery records environment scope and persisted providers are never reselected', () => { - assert.deepEqual( - selectRuntimeHostLifecycleProvider({ - platform: 'linux', - environment: { WSL_DISTRO_NAME: 'Ubuntu' }, - }), - { provider: 'systemd_user', availability: 'environment' }, - ); - assert.throws( - () => resolveRuntimeHostLifecycleProvider('a'.repeat(64), 'openrc_user'), - (error: unknown) => - error instanceof RuntimeHostServiceManagerError && - error.code === 'service_manager_unavailable', - ); +test('persisted OpenRC providers resolve without reselecting the platform default', () => { + const openRc = resolveRuntimeHostLifecycleProvider('a'.repeat(64), 'openrc_user'); + assert.equal(openRc.supervisor.provider, 'openrc_user'); + assert.equal(openRc.reconciliationTrigger.provider, 'openrc_supervised_loop'); }); test('registry package identity avoids local content and recovers an interrupted removal', async (t) => { diff --git a/packages/cli/src/runtime-host-managed-lifecycle-manager.ts b/packages/cli/src/runtime-host-managed-lifecycle-manager.ts index e9091b9160..419a40cdee 100644 --- a/packages/cli/src/runtime-host-managed-lifecycle-manager.ts +++ b/packages/cli/src/runtime-host-managed-lifecycle-manager.ts @@ -365,7 +365,7 @@ function presentationManager( config: RuntimeHostManagedDeploymentConfig, ): Exclude { if (config.lifecycle.mode === 'on_demand') return 'on_demand'; - return config.lifecycle.provider === 'systemd_user' ? 'systemd_user' : 'launch_agent'; + return config.lifecycle.provider; } function projectLegacyConfig( diff --git a/packages/cli/src/runtime-host-openrc-service.ts b/packages/cli/src/runtime-host-openrc-service.ts new file mode 100644 index 0000000000..52fe95d573 --- /dev/null +++ b/packages/cli/src/runtime-host-openrc-service.ts @@ -0,0 +1,625 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { lstat, mkdir, open, readFile, readdir, realpath, stat } from 'node:fs/promises'; +import { homedir, userInfo } from 'node:os'; +import { dirname, isAbsolute, join } from 'node:path'; +import { + RUNTIME_HOST_SERVICE_LOG_MAX_BYTES, + type RuntimeHostSupervisorProvider, +} from '@maka/runtime-host/operator'; +import { resolveXdgConfigHome } from '@maka/storage/workspace-root'; +import { readStableBoundedFile } from '@maka/storage/stable-storage'; +import { + formatRuntimeHostServiceLogs, + removeRuntimeHostServiceFile, + RuntimeHostServiceManagerError, + writeRuntimeHostServiceFile, +} from './runtime-host-service-manager.js'; +import { + RUNTIME_HOST_UPDATE_INITIAL_DELAY_SECONDS, + RUNTIME_HOST_UPDATE_INTERVAL_SECONDS, + RUNTIME_HOST_UPDATE_RANDOM_DELAY_SECONDS, +} from './runtime-host-service-launch.js'; +import { + runRuntimeHostServiceManagerCommand, + type RuntimeHostServiceManagerCommandResult, +} from './runtime-host-service-manager-process.js'; +import { + assertRuntimeHostProviderDefinition, + type RuntimeHostLifecycleProvider, + type RuntimeHostProviderDefinition, + type RuntimeHostSupervisorStatus, +} from './runtime-host-lifecycle-provider.js'; + +type OpenRcProvider = Extract; +type OpenRcCommand = 'rc-service' | 'rc-status' | 'rc-update' | 'supervise-daemon'; +type OpenRcRunner = ( + command: OpenRcCommand, + args: readonly string[], +) => Promise; + +interface OpenRcServiceContext { + readonly provider: OpenRcProvider; + readonly name: string; + readonly servicePath: string; + readonly commandPath: string; + readonly runlevelPath: string; + readonly pidPath?: string; + readonly stdoutPath: string; + readonly stderrPath: string; + readonly run: OpenRcRunner; +} + +export interface OpenRcRuntimeHostLifecycleProviderOptions { + readonly env?: NodeJS.ProcessEnv; + readonly homeDir?: string; + readonly uid?: number; + readonly runCommand?: OpenRcRunner; + /** Used by tests and administrator-controlled embeddings; normal discovery probes the OS. */ + readonly hasUserSessionActivation?: () => Promise; + readonly paths?: { + readonly initDirectory: string; + readonly runlevelDirectory: string; + readonly artifactDirectory: string; + readonly logDirectory: string; + readonly stateDirectory?: string; + }; +} + +export function createOpenRcRuntimeHostLifecycleProvider( + serviceId: string, + provider: OpenRcProvider, + options: OpenRcRuntimeHostLifecycleProviderOptions = {}, +): RuntimeHostLifecycleProvider { + assertServiceId(serviceId); + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? homedir(); + const paths = options.paths ?? defaultPaths(provider, serviceId, env, homeDir); + const run = options.runCommand ?? defaultRunOpenRcCommand; + const host = createContext(provider, serviceId, '', 'host', paths, run); + const update = createContext(provider, serviceId, '-update', 'update', paths, run); + const uid = options.uid ?? process.getuid?.(); + const username = currentUsername(); + const userSessionActivation = + options.hasUserSessionActivation ?? (() => detectOpenRcUserSessionActivation(username)); + + const preflight = async (): Promise => { + if (provider === 'openrc_system' && uid !== 0) { + throw unavailable( + 'OpenRC system services require an explicit root session; sudo is not used', + ); + } + if (provider === 'openrc_user') { + const runtimeDirectory = env.XDG_RUNTIME_DIR; + if (!runtimeDirectory || !isAbsolute(runtimeDirectory)) { + throw unavailable('OpenRC user services require an absolute XDG_RUNTIME_DIR'); + } + const runtime = await stat(runtimeDirectory).catch(() => undefined); + if (!runtime?.isDirectory()) { + throw unavailable('OpenRC user services require an active XDG_RUNTIME_DIR'); + } + if (!(await userSessionActivation())) { + throw unavailable( + 'OpenRC user services are not configured for automatic session or boot activation; use an on-demand Runtime Host', + ); + } + } + await requireProbe(run, 'supervise-daemon', ['--help'], false); + await requireProbe(run, 'rc-service', ['--help'], false); + await requireProbe(run, 'rc-status', [...scopeArgs(provider), '--runlevel']); + await requireProbe(run, 'rc-update', [...scopeArgs(provider), 'show', 'default']); + }; + + return { + supervisor: { + provider, + preflight, + converge: (definition) => convergeOpenRcService(host, definition, false), + verify: (definition) => verifyOpenRcService(host, definition, false), + status: () => readOpenRcSupervisorStatus(host), + activate: () => startOpenRcService(host), + retire: () => stopOpenRcService(host), + logs: () => readOpenRcLogs(host), + uninstall: () => uninstallOpenRcService(host), + }, + reconciliationTrigger: { + provider: 'openrc_supervised_loop', + converge: (definition) => convergeOpenRcService(update, definition, true), + verify: (definition) => verifyOpenRcService(update, definition, true), + status: async () => { + const observed = await readOpenRcStatus(update); + return { installed: observed.installed, active: observed.active }; + }, + activate: () => startOpenRcService(update), + logs: () => readOpenRcLogs(update), + uninstall: () => uninstallOpenRcService(update), + }, + }; +} + +function renderOpenRcReconciliationLoop(definition: RuntimeHostProviderDefinition): string { + assertRuntimeHostProviderDefinition(definition); + const command = definition.command.map(quoteShellWord).join(' '); + return [ + '#!/bin/sh', + 'umask 077', + `initial_delay=${String(RUNTIME_HOST_UPDATE_INITIAL_DELAY_SECONDS)}`, + `random_delay=${String(RUNTIME_HOST_UPDATE_RANDOM_DELAY_SECONDS)}`, + "now=$(date +%s 2>/dev/null || printf '0')", + 'sleep "$((initial_delay + now % (random_delay + 1)))"', + 'while :; do', + ` ${command} || :`, + ` sleep ${String(RUNTIME_HOST_UPDATE_INTERVAL_SECONDS)}`, + 'done', + '', + ].join('\n'); +} + +function renderOpenRcService( + context: OpenRcServiceContext, + definition: RuntimeHostProviderDefinition, + periodic: boolean, +): string { + const command = periodic ? '/bin/sh' : definition.command[0]; + const commandArguments = periodic ? [context.commandPath] : definition.command.slice(1); + return [ + '#!/sbin/openrc-run', + `description=${quoteShellWord(context.name)}`, + 'supervisor=supervise-daemon', + `command=${quoteDoubleQuotedShellValue(quoteShellWord(command))}`, + `command_args=${quoteDoubleQuotedShellValue(commandArguments.map(quoteShellWord).join(' '))}`, + `output_log=${quoteDoubleQuotedShellValue(quoteShellWord(context.stdoutPath))}`, + `error_log=${quoteDoubleQuotedShellValue(quoteShellWord(context.stderrPath))}`, + 'retry=TERM/45/KILL/5', + 'respawn_delay=2', + 'respawn_max=0', + '', + ].join('\n'); +} + +async function convergeOpenRcService( + context: OpenRcServiceContext, + definition: RuntimeHostProviderDefinition, + periodic: boolean, +): Promise { + assertRuntimeHostProviderDefinition(definition); + await stopOpenRcService(context); + await mkdir(dirname(context.stdoutPath), { recursive: true, mode: 0o700 }); + await writeRuntimeHostServiceFile( + context.servicePath, + renderOpenRcService(context, definition, periodic), + 0o700, + ); + if (periodic) { + await writeRuntimeHostServiceFile( + context.commandPath, + renderOpenRcReconciliationLoop(definition), + 0o700, + ); + } + await requireOpenRc( + context, + 'rc-update', + [...scopeArgs(context.provider), 'add', context.name, 'default'], + 'Enabling the Runtime Host OpenRC service failed', + ); +} + +async function verifyOpenRcService( + context: OpenRcServiceContext, + definition: RuntimeHostProviderDefinition, + periodic: boolean, +): Promise { + assertRuntimeHostProviderDefinition(definition); + const expectedService = renderOpenRcService(context, definition, periodic); + const [service, enabled] = await Promise.all([ + readManagedFile(context.servicePath, expectedService), + isEnabled(context), + ]); + const expectedCommand = periodic ? renderOpenRcReconciliationLoop(definition) : undefined; + const command = expectedCommand + ? await readManagedFile(context.commandPath, expectedCommand) + : undefined; + if (service !== expectedService || command !== expectedCommand || !enabled) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + `The ${context.provider} service does not match its managed deployment`, + ); + } +} + +async function readOpenRcSupervisorStatus( + context: OpenRcServiceContext, +): Promise { + const observed = await readOpenRcStatus(context); + return { provider: context.provider, ...observed }; +} + +async function readOpenRcStatus(context: OpenRcServiceContext): Promise<{ + readonly installed: boolean; + readonly enabled: boolean; + readonly active: boolean; + readonly state: RuntimeHostSupervisorStatus['state']; + readonly pid: number | null; + readonly lastExitCode: null; +}> { + const installed = await isRegularFile(context.servicePath); + const enabled = await isEnabled(context); + if (!installed) { + return { + installed: false, + enabled, + active: false, + state: 'not_installed', + pid: null, + lastExitCode: null, + }; + } + let result: RuntimeHostServiceManagerCommandResult; + try { + result = await context.run('rc-service', [ + ...scopeArgs(context.provider), + context.name, + 'status', + ]); + } catch (error) { + throw unavailable('Unable to query the OpenRC service manager', error); + } + const active = result.exitCode === 0; + return { + installed, + enabled, + active, + state: + result.exitCode === 0 + ? 'running' + : result.exitCode === 8 || result.exitCode === 4 + ? 'starting' + : result.exitCode === 3 || result.exitCode === 16 + ? 'stopped' + : 'failed', + pid: active ? await readOpenRcPid(context.pidPath) : null, + lastExitCode: null, + }; +} + +async function startOpenRcService(context: OpenRcServiceContext): Promise { + if (!(await isRegularFile(context.servicePath))) return; + const status = await readOpenRcStatus(context); + if (status.active) return; + await requireOpenRc( + context, + 'rc-service', + [...scopeArgs(context.provider), context.name, 'start'], + 'Starting the Runtime Host OpenRC service failed', + ); +} + +async function stopOpenRcService(context: OpenRcServiceContext): Promise { + if (!(await isRegularFile(context.servicePath))) return; + const status = await readOpenRcStatus(context); + if (status.state === 'stopped') return; + await requireOpenRc( + context, + 'rc-service', + [...scopeArgs(context.provider), context.name, 'stop'], + 'Stopping the Runtime Host OpenRC service failed', + ); +} + +async function uninstallOpenRcService(context: OpenRcServiceContext): Promise { + await stopOpenRcService(context); + if (await pathExists(context.runlevelPath)) { + await requireOpenRc( + context, + 'rc-update', + [...scopeArgs(context.provider), 'del', context.name, 'default'], + 'Disabling the Runtime Host OpenRC service failed', + ); + } + await Promise.all([ + removeRuntimeHostServiceFile(context.servicePath, 'OpenRC service'), + removeRuntimeHostServiceFile(context.commandPath, 'OpenRC command'), + ]); + const status = await readOpenRcStatus(context); + if ( + status.installed || + status.enabled || + status.active || + (await pathExists(context.runlevelPath)) + ) { + throw new RuntimeHostServiceManagerError( + 'uninstall_incomplete', + 'The Runtime Host OpenRC service still has managed state', + ); + } +} + +async function readOpenRcLogs(context: OpenRcServiceContext): Promise { + const [stdout, stderr] = await Promise.all([ + readLogTail(context.stdoutPath), + readLogTail(context.stderrPath), + ]); + return formatRuntimeHostServiceLogs([ + { label: 'stdout', logs: stdout }, + { label: 'stderr', logs: stderr }, + ]); +} + +function createContext( + provider: OpenRcProvider, + serviceId: string, + suffix: string, + artifact: string, + paths: NonNullable, + run: OpenRcRunner, +): OpenRcServiceContext { + const name = `maka-runtime-host-${serviceId}${suffix}`; + return { + provider, + name, + servicePath: join(paths.initDirectory, name), + commandPath: join(paths.artifactDirectory, artifact), + runlevelPath: join(paths.runlevelDirectory, name), + ...(paths.stateDirectory + ? { pidPath: join(paths.stateDirectory, 'options', name, 'child_pid') } + : {}), + stdoutPath: join(paths.logDirectory, `${artifact}.stdout.log`), + stderrPath: join(paths.logDirectory, `${artifact}.stderr.log`), + run, + }; +} + +function defaultPaths( + provider: OpenRcProvider, + serviceId: string, + env: NodeJS.ProcessEnv, + homeDir: string, +): NonNullable { + if (provider === 'openrc_system') { + return { + initDirectory: '/etc/init.d', + runlevelDirectory: '/etc/runlevels/default', + artifactDirectory: join('/etc/maka/runtime-host', serviceId), + logDirectory: join('/var/log/maka/runtime-host', serviceId), + stateDirectory: '/run/openrc', + }; + } + const config = resolveXdgConfigHome(env, homeDir); + const stateHome = + env.XDG_STATE_HOME && isAbsolute(env.XDG_STATE_HOME) + ? env.XDG_STATE_HOME + : join(homeDir, '.local', 'state'); + const runtime = env.XDG_RUNTIME_DIR; + return { + initDirectory: join(config, 'rc', 'init.d'), + runlevelDirectory: join(config, 'rc', 'runlevels', 'default'), + artifactDirectory: join(config, 'maka', 'runtime-host', serviceId, 'openrc'), + logDirectory: join(stateHome, 'maka', 'runtime-host', serviceId), + ...(runtime && isAbsolute(runtime) ? { stateDirectory: join(runtime, 'openrc') } : {}), + }; +} + +function scopeArgs(provider: OpenRcProvider): readonly string[] { + return provider === 'openrc_user' ? ['--user'] : []; +} + +async function requireProbe( + run: OpenRcRunner, + command: OpenRcCommand, + args: readonly string[], + requireSuccess = true, +): Promise { + let result: RuntimeHostServiceManagerCommandResult; + try { + result = await run(command, args); + } catch (error) { + throw unavailable(`${command} is unavailable`, error); + } + if (requireSuccess && result.exitCode !== 0) { + throw unavailable(`${command} is unavailable${commandDetail(result)}`); + } +} + +async function requireOpenRc( + context: OpenRcServiceContext, + command: OpenRcCommand, + args: readonly string[], + message: string, +): Promise { + let result: RuntimeHostServiceManagerCommandResult; + try { + result = await context.run(command, args); + } catch (error) { + throw unavailable(message, error); + } + if (result.exitCode !== 0) { + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + `${message}${commandDetail(result)}`, + ); + } +} + +function commandDetail(result: RuntimeHostServiceManagerCommandResult): string { + const detail = result.stderr.trim() || result.stdout.trim(); + return detail ? `: ${detail}` : ''; +} + +async function isEnabled(context: OpenRcServiceContext): Promise { + try { + const [target, service] = await Promise.all([ + realpath(context.runlevelPath), + realpath(context.servicePath), + ]); + return target === service; + } catch (error) { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + } +} + +async function isRegularFile(path: string): Promise { + try { + return (await lstat(path)).isFile(); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + } +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + } +} + +async function readManagedFile(path: string, expected: string): Promise { + return readStableBoundedFile({ + path, + maxBytes: Buffer.byteLength(expected), + invalidFile: () => + new RuntimeHostServiceManagerError( + 'target_mismatch', + 'A managed OpenRC artifact is not a stable regular file', + ), + }) + .then((bytes) => { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'A managed OpenRC artifact is not valid UTF-8', + ); + } + }) + .catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return null; + throw error; + }); +} + +async function readOpenRcPid(path: string | undefined): Promise { + if (!path) return null; + try { + const value = ( + await readStableBoundedFile({ + path, + maxBytes: 32, + invalidFile: () => new Error('Invalid OpenRC process state'), + }) + ) + .toString('utf8') + .trim(); + const pid = Number(value); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; + } catch (error) { + if (isNodeError(error, 'ENOENT')) return null; + return null; + } +} + +async function readLogTail(path: string): Promise { + let file; + try { + file = await open(path, 'r'); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return ''; + throw error; + } + try { + const size = (await file.stat()).size; + const length = Math.min(size, Math.floor(RUNTIME_HOST_SERVICE_LOG_MAX_BYTES / 2)); + if (length === 0) return ''; + const buffer = Buffer.alloc(length); + await file.read(buffer, 0, length, size - length); + return buffer.toString('utf8'); + } finally { + await file.close(); + } +} + +async function detectOpenRcUserSessionActivation(username: string | undefined): Promise { + if (username) { + try { + await realpath(join('/etc/runlevels/default', `user.${username}`)); + return true; + } catch (error) { + if (!isNodeError(error, 'ENOENT')) throw error; + } + } + let entries; + try { + entries = await readdir('/etc/pam.d', { withFileTypes: true }); + } catch (error) { + if (isNodeError(error, 'ENOENT') || isNodeError(error, 'EACCES')) return false; + throw error; + } + for (const entry of entries.slice(0, 256)) { + if (!entry.isFile()) continue; + const contents = await readFile(join('/etc/pam.d', entry.name), 'utf8').catch(() => ''); + if (/^[^#\n]*\bpam_openrc\.so\b/mu.test(contents)) return true; + } + return false; +} + +function quoteShellWord(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function quoteDoubleQuotedShellValue(value: string): string { + return `"${value.replace(/[\\"$`]/gu, '\\$&')}"`; +} + +function currentUsername(): string | undefined { + try { + return userInfo().username; + } catch { + return undefined; + } +} + +function unavailable(message: string, cause?: unknown): RuntimeHostServiceManagerError { + return new RuntimeHostServiceManagerError( + 'service_manager_unavailable', + message, + cause === undefined ? undefined : { cause }, + ); +} + +async function defaultRunOpenRcCommand( + command: OpenRcCommand, + args: readonly string[], +): Promise { + return runRuntimeHostServiceManagerCommand(command, args); +} + +function assertServiceId(serviceId: string): void { + if (!/^[a-f0-9]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID'); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts index bad614cee7..140dcc695c 100644 --- a/packages/cli/src/runtime-host-service-management-command.ts +++ b/packages/cli/src/runtime-host-service-management-command.ts @@ -59,6 +59,7 @@ import { createSystemdUserRuntimeHostLifecycleProvider, createSystemdUserRuntimeHostService, } from './runtime-host-systemd-service.js'; +import { createOpenRcRuntimeHostLifecycleProvider } from './runtime-host-openrc-service.js'; import type { RuntimeHostLifecycleProvider, RuntimeHostLifecycleProviderOffer, @@ -490,35 +491,61 @@ export async function discoverRuntimeHostLifecycleProvider( readonly environment?: NodeJS.ProcessEnv; } = {}, ): Promise { - const selection = selectRuntimeHostLifecycleProvider({ - platform: options.platform ?? process.platform, - environment: options.environment ?? process.env, - }); - const provider = resolveRuntimeHostLifecycleProvider(rootId, selection.provider); - await provider.supervisor.preflight(); - return { provider, availability: selection.availability }; -} + const platform = options.platform ?? process.platform; + const environment = options.environment ?? process.env; + if (platform === 'darwin') { + const provider = createLaunchAgentRuntimeHostLifecycleProvider(rootId); + await provider.supervisor.preflight(); + return { provider, availability: 'session' }; + } + if (platform !== 'linux') { + throw new RuntimeHostServiceManagerError( + 'unsupported_platform', + 'Supervised Runtime Host deployments currently require Linux or macOS', + ); + } -export function selectRuntimeHostLifecycleProvider(options: { - readonly platform: NodeJS.Platform; - readonly environment: NodeJS.ProcessEnv; -}): { - readonly provider: RuntimeHostSupervisorProvider; - readonly availability: RuntimeHostLifecycleProviderOffer['availability']; -} { - if (options.platform === 'linux') { + const systemd = createSystemdUserRuntimeHostLifecycleProvider(rootId, { + env: environment, + }); + let systemdError: unknown; + try { + await systemd.supervisor.preflight(); return { - provider: 'systemd_user', - availability: isWslEnvironment(options.environment) ? 'environment' : 'machine', + provider: systemd, + availability: isWslEnvironment(environment) ? 'environment' : 'machine', }; + } catch (error) { + systemdError = error; } - if (options.platform === 'darwin') { - return { provider: 'launch_agent', availability: 'session' }; + + const openRcProvider = process.getuid?.() === 0 ? 'openrc_system' : 'openrc_user'; + const openRc = createOpenRcRuntimeHostLifecycleProvider(rootId, openRcProvider, { + env: environment, + }); + try { + await openRc.supervisor.preflight(); + return { + provider: openRc, + availability: isWslEnvironment(environment) + ? 'environment' + : openRcProvider === 'openrc_system' + ? 'machine' + : 'session', + }; + } catch (openRcError) { + if ( + systemdError instanceof RuntimeHostServiceManagerError && + systemdError.code === 'linger_disabled' + ) { + throw systemdError; + } + throw new RuntimeHostServiceManagerError( + 'service_manager_unavailable', + 'No supervised Linux lifecycle provider is available; configure systemd user lingering or OpenRC activation, or use an on-demand Runtime Host', + { cause: new AggregateError([systemdError, openRcError]) }, + ); } - throw new RuntimeHostServiceManagerError( - 'unsupported_platform', - 'Supervised Runtime Host deployments currently require Linux or macOS', - ); } /** Resolves only the provider identity already persisted by the deployment authority. */ @@ -528,10 +555,7 @@ export function resolveRuntimeHostLifecycleProvider( ): RuntimeHostLifecycleProvider { if (provider === 'systemd_user') return createSystemdUserRuntimeHostLifecycleProvider(rootId, {}); if (provider === 'launch_agent') return createLaunchAgentRuntimeHostLifecycleProvider(rootId); - throw new RuntimeHostServiceManagerError( - 'service_manager_unavailable', - `The persisted Runtime Host provider ${provider} is unavailable`, - ); + return createOpenRcRuntimeHostLifecycleProvider(rootId, provider); } function isWslEnvironment(environment: NodeJS.ProcessEnv): boolean { diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 4ef7e38904..124770222c 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -149,7 +149,13 @@ export interface RuntimeHostServiceDeployment { } export interface RuntimeHostManagedServiceStatus extends RuntimeHostServiceObservedStatus { - readonly manager: 'systemd_user' | 'launch_agent' | 'on_demand' | 'none'; + readonly manager: + | 'systemd_user' + | 'launch_agent' + | 'openrc_user' + | 'openrc_system' + | 'on_demand' + | 'none'; readonly config: RuntimeHostManagedServiceConfig | null; readonly installedVersion: string | null; readonly lifecycle?: { diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index 0dc15ce139..e2a11e0176 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -301,18 +301,7 @@ export async function runRuntimeHostSetupCli( } async function resolveRuntimeHostSetupRootId(options: RuntimeHostSetupCliOptions): Promise { - let legacyRootPath: string | undefined; - try { - legacyRootPath = ( - await readRuntimeHostManagedServiceConfig( - resolveRuntimeHostManagedServiceConfigPath(options.clientDataRoot), - ) - ).rootPath; - } catch (error) { - if (!(error instanceof RuntimeHostServiceManagerError) || error.code !== 'not_installed') { - throw error; - } - } + const legacyRootPath = (await readOptionalLegacyServiceConfig(options.clientDataRoot))?.rootPath; const path = resolve( options.rootPath ?? legacyRootPath ?? @@ -325,6 +314,21 @@ async function resolveRuntimeHostSetupRootId(options: RuntimeHostSetupCliOptions return (await resolveStorageRoot({ path, kind: 'interactive' })).rootId; } +async function readOptionalLegacyServiceConfig( + clientDataRoot: string, +): Promise { + try { + return await readRuntimeHostManagedServiceConfig( + resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), + ); + } catch (error) { + if (error instanceof RuntimeHostServiceManagerError && error.code === 'not_installed') { + return null; + } + throw error; + } +} + async function runRuntimeHostSetupLocked( options: RuntimeHostSetupCliOptions, deps: RuntimeHostSetupDeps, @@ -356,7 +360,10 @@ async function runRuntimeHostSupervisedSetupLocked( }> { emit({ kind: 'progress', phase: 'checking_environment' }); const legacyServiceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); - const legacyBackend = deps.createBackend(legacyServiceId, options.clientDataRoot); + const legacyConfig = await readOptionalLegacyServiceConfig(options.clientDataRoot); + const legacyBackend = legacyConfig + ? deps.createBackend(legacyServiceId, options.clientDataRoot) + : undefined; const legacyCommon = { clientDataRoot: options.clientDataRoot, defaultRootPath: options.defaultRootPath, @@ -366,11 +373,9 @@ async function runRuntimeHostSupervisedSetupLocked( ? { expectedTarget: legacyManagedTarget(options.expectedTarget, legacyServiceId) } : {}), } as const; - const legacyStatus = await deps.manageService( - { ...legacyCommon, action: 'status' }, - legacyBackend, - ); - const legacyConfig = legacyStatus.service.config; + const legacyStatus = legacyBackend + ? await deps.manageService({ ...legacyCommon, action: 'status' }, legacyBackend) + : undefined; const capability = await resolveStorageRoot({ path: resolve( options.rootPath ?? @@ -386,7 +391,7 @@ async function runRuntimeHostSupervisedSetupLocked( deps.convergeOperator(currentConfig, desiredConfig), verifyOperator: deps.verifyOperator, resolveProvider: (provider) => deps.resolveLifecycleProvider(capability.rootId, provider), - ...(legacyConfig + ...(legacyConfig && legacyBackend ? legacyMigrationDeps(legacyConfig, legacyBackend, legacyServiceId, options.clientDataRoot) : {}), }; @@ -394,8 +399,8 @@ async function runRuntimeHostSupervisedSetupLocked( capability.rootId, lifecycleDeps, { - ...(legacyConfig ? { retirementSupervisor: legacyBackend } : {}), - ...(legacyConfig + ...(legacyBackend ? { retirementSupervisor: legacyBackend } : {}), + ...(legacyBackend ? { activatePrevious: () => deps @@ -409,8 +414,10 @@ async function runRuntimeHostSupervisedSetupLocked( const current = recovered.kind === 'active' ? recovered.config : undefined; assertExpectedDeploymentGeneration(options.expectedTarget, current); const legacyToMigrate = current ? null : legacyConfig; - if (current && legacyConfig) await assertLegacyArtifactsAbsent(legacyBackend); - if (legacyToMigrate) await assertCompatibleExistingVersion(legacyStatus, options.version); + if (current && legacyBackend) await assertLegacyArtifactsAbsent(legacyBackend); + if (legacyToMigrate && legacyStatus) { + await assertCompatibleExistingVersion(legacyStatus, options.version); + } if (current && current.launch.package.version !== options.version && !options.updateExisting) { throw new RuntimeHostSetupError( 'version_change_requires_update', @@ -469,7 +476,7 @@ async function runRuntimeHostSupervisedSetupLocked( ); } emit({ kind: 'progress', phase: 'installing_service' }); - if (legacyToMigrate) { + if (legacyToMigrate && legacyBackend) { await legacyBackend.verifyDeployment(legacyToMigrate, { acceptLegacyConfigLaunch: true, }); @@ -486,8 +493,8 @@ async function runRuntimeHostSupervisedSetupLocked( : 'install', ...(current ? { current } : {}), desired, - ...(legacyToMigrate ? { retirementSupervisor: legacyBackend } : {}), - ...(legacyToMigrate + ...(legacyToMigrate && legacyBackend ? { retirementSupervisor: legacyBackend } : {}), + ...(legacyToMigrate && legacyBackend ? { activatePrevious: () => deps @@ -696,15 +703,7 @@ async function runRuntimeHostOnDemandSetupLocked( } emit({ kind: 'progress', phase: 'checking_environment' }); const legacyServiceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); - const legacyConfigPath = resolveRuntimeHostManagedServiceConfigPath(options.clientDataRoot); - let legacyConfig: RuntimeHostManagedServiceConfig | null = null; - try { - legacyConfig = await readRuntimeHostManagedServiceConfig(legacyConfigPath); - } catch (error) { - if (!(error instanceof RuntimeHostServiceManagerError) || error.code !== 'not_installed') { - throw error; - } - } + const legacyConfig = await readOptionalLegacyServiceConfig(options.clientDataRoot); const legacyBackend = legacyConfig ? deps.createBackend(legacyServiceId, options.clientDataRoot) : undefined; @@ -911,7 +910,10 @@ async function runRuntimeHostOnDemandSetupLocked( activation = await deps.activateManaged({ rootId: capability.rootId }); if (legacyConfig) { - await removeRuntimeHostServiceFile(legacyConfigPath, 'legacy service config'); + await removeRuntimeHostServiceFile( + resolveRuntimeHostManagedServiceConfigPath(options.clientDataRoot), + 'legacy service config', + ); if ( legacyConfig.managedDeploymentRoot && resolve(legacyConfig.managedDeploymentRoot) !== resolve(config.deploymentRoot) From 5cb16db229a163606db2e38a5cce1c5124613d29 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Thu, 3 Sep 2026 16:26:11 +0800 Subject: [PATCH 2/2] fix(runtime-host): tighten OpenRC lifecycle guarantees Generated-by: OpenAI Codex --- .../runtime-host-openrc-service.test.ts | 11 ++- .../cli/src/runtime-host-openrc-service.ts | 72 +++++++++++-------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-openrc-service.test.ts b/packages/cli/src/__tests__/runtime-host-openrc-service.test.ts index 70ced3dff2..8ea340b7e7 100644 --- a/packages/cli/src/__tests__/runtime-host-openrc-service.test.ts +++ b/packages/cli/src/__tests__/runtime-host-openrc-service.test.ts @@ -42,6 +42,7 @@ test('OpenRC provider owns one supervised Host and reconciliation loop', async ( }; const active = new Set(); const calls: [string, readonly string[]][] = []; + let runlevel = 'sysinit'; const runCommand = async (command: string, args: readonly string[]) => { calls.push([command, args]); if (command === 'supervise-daemon' && args[0] === '--help') { @@ -72,7 +73,11 @@ test('OpenRC provider owns one supervised Host and reconciliation loop', async ( await unlink(link); } } - return { exitCode: 0, stdout: '', stderr: '' }; + return { + exitCode: 0, + stdout: command === 'rc-status' ? `${runlevel}\n` : '', + stderr: '', + }; }; const provider = createOpenRcRuntimeHostLifecycleProvider(SERVICE_ID, 'openrc_system', { uid: 0, @@ -92,6 +97,8 @@ test('OpenRC provider owns one supervised Host and reconciliation loop', async ( command: ['/tmp/maka operator', 'reconcile-update', '--framed'] as const, }; + await assert.rejects(provider.supervisor.preflight(), { code: 'service_manager_unavailable' }); + runlevel = 'default'; await provider.supervisor.preflight(); await provider.supervisor.converge(supervisor); await provider.reconciliationTrigger.converge(reconciliation); @@ -132,7 +139,7 @@ test('OpenRC provider owns one supervised Host and reconciliation loop', async ( installed: true, active: true, }); - assert.match(await readFile(servicePath, 'utf8'), /retry=TERM\/45\/KILL\/5[\s\S]*respawn_max=0/u); + assert.match(await readFile(servicePath, 'utf8'), /retry=TERM\/20\/KILL\/5[\s\S]*respawn_max=0/u); assert.match( await readFile(join(paths.artifactDirectory, 'update'), 'utf8'), /reconcile-update[\s\S]*sleep 86400/u, diff --git a/packages/cli/src/runtime-host-openrc-service.ts b/packages/cli/src/runtime-host-openrc-service.ts index 52fe95d573..d02b3a204c 100644 --- a/packages/cli/src/runtime-host-openrc-service.ts +++ b/packages/cli/src/runtime-host-openrc-service.ts @@ -59,7 +59,6 @@ interface OpenRcServiceContext { readonly provider: OpenRcProvider; readonly name: string; readonly servicePath: string; - readonly commandPath: string; readonly runlevelPath: string; readonly pidPath?: string; readonly stdoutPath: string; @@ -72,8 +71,6 @@ export interface OpenRcRuntimeHostLifecycleProviderOptions { readonly homeDir?: string; readonly uid?: number; readonly runCommand?: OpenRcRunner; - /** Used by tests and administrator-controlled embeddings; normal discovery probes the OS. */ - readonly hasUserSessionActivation?: () => Promise; readonly paths?: { readonly initDirectory: string; readonly runlevelDirectory: string; @@ -95,10 +92,8 @@ export function createOpenRcRuntimeHostLifecycleProvider( const run = options.runCommand ?? defaultRunOpenRcCommand; const host = createContext(provider, serviceId, '', 'host', paths, run); const update = createContext(provider, serviceId, '-update', 'update', paths, run); + const updateCommandPath = join(paths.artifactDirectory, 'update'); const uid = options.uid ?? process.getuid?.(); - const username = currentUsername(); - const userSessionActivation = - options.hasUserSessionActivation ?? (() => detectOpenRcUserSessionActivation(username)); const preflight = async (): Promise => { if (provider === 'openrc_system' && uid !== 0) { @@ -115,7 +110,7 @@ export function createOpenRcRuntimeHostLifecycleProvider( if (!runtime?.isDirectory()) { throw unavailable('OpenRC user services require an active XDG_RUNTIME_DIR'); } - if (!(await userSessionActivation())) { + if (!(await detectOpenRcUserSessionActivation(currentUsername()))) { throw unavailable( 'OpenRC user services are not configured for automatic session or boot activation; use an on-demand Runtime Host', ); @@ -123,7 +118,10 @@ export function createOpenRcRuntimeHostLifecycleProvider( } await requireProbe(run, 'supervise-daemon', ['--help'], false); await requireProbe(run, 'rc-service', ['--help'], false); - await requireProbe(run, 'rc-status', [...scopeArgs(provider), '--runlevel']); + const runlevel = await requireProbe(run, 'rc-status', [...scopeArgs(provider), '--runlevel']); + if (runlevel.stdout.trim() !== 'default') { + throw unavailable('The OpenRC default runlevel is not active'); + } await requireProbe(run, 'rc-update', [...scopeArgs(provider), 'show', 'default']); }; @@ -131,8 +129,8 @@ export function createOpenRcRuntimeHostLifecycleProvider( supervisor: { provider, preflight, - converge: (definition) => convergeOpenRcService(host, definition, false), - verify: (definition) => verifyOpenRcService(host, definition, false), + converge: (definition) => convergeOpenRcService(host, definition), + verify: (definition) => verifyOpenRcService(host, definition), status: () => readOpenRcSupervisorStatus(host), activate: () => startOpenRcService(host), retire: () => stopOpenRcService(host), @@ -141,15 +139,15 @@ export function createOpenRcRuntimeHostLifecycleProvider( }, reconciliationTrigger: { provider: 'openrc_supervised_loop', - converge: (definition) => convergeOpenRcService(update, definition, true), - verify: (definition) => verifyOpenRcService(update, definition, true), + converge: (definition) => convergeOpenRcService(update, definition, updateCommandPath), + verify: (definition) => verifyOpenRcService(update, definition, updateCommandPath), status: async () => { const observed = await readOpenRcStatus(update); return { installed: observed.installed, active: observed.active }; }, activate: () => startOpenRcService(update), logs: () => readOpenRcLogs(update), - uninstall: () => uninstallOpenRcService(update), + uninstall: () => uninstallOpenRcService(update, updateCommandPath), }, }; } @@ -175,10 +173,12 @@ function renderOpenRcReconciliationLoop(definition: RuntimeHostProviderDefinitio function renderOpenRcService( context: OpenRcServiceContext, definition: RuntimeHostProviderDefinition, - periodic: boolean, + periodicCommandPath?: string, ): string { - const command = periodic ? '/bin/sh' : definition.command[0]; - const commandArguments = periodic ? [context.commandPath] : definition.command.slice(1); + const command = periodicCommandPath ? '/bin/sh' : definition.command[0]; + const commandArguments = periodicCommandPath + ? [periodicCommandPath] + : definition.command.slice(1); return [ '#!/sbin/openrc-run', `description=${quoteShellWord(context.name)}`, @@ -187,7 +187,7 @@ function renderOpenRcService( `command_args=${quoteDoubleQuotedShellValue(commandArguments.map(quoteShellWord).join(' '))}`, `output_log=${quoteDoubleQuotedShellValue(quoteShellWord(context.stdoutPath))}`, `error_log=${quoteDoubleQuotedShellValue(quoteShellWord(context.stderrPath))}`, - 'retry=TERM/45/KILL/5', + 'retry=TERM/20/KILL/5', 'respawn_delay=2', 'respawn_max=0', '', @@ -197,19 +197,19 @@ function renderOpenRcService( async function convergeOpenRcService( context: OpenRcServiceContext, definition: RuntimeHostProviderDefinition, - periodic: boolean, + periodicCommandPath?: string, ): Promise { assertRuntimeHostProviderDefinition(definition); await stopOpenRcService(context); await mkdir(dirname(context.stdoutPath), { recursive: true, mode: 0o700 }); await writeRuntimeHostServiceFile( context.servicePath, - renderOpenRcService(context, definition, periodic), + renderOpenRcService(context, definition, periodicCommandPath), 0o700, ); - if (periodic) { + if (periodicCommandPath) { await writeRuntimeHostServiceFile( - context.commandPath, + periodicCommandPath, renderOpenRcReconciliationLoop(definition), 0o700, ); @@ -225,17 +225,19 @@ async function convergeOpenRcService( async function verifyOpenRcService( context: OpenRcServiceContext, definition: RuntimeHostProviderDefinition, - periodic: boolean, + periodicCommandPath?: string, ): Promise { assertRuntimeHostProviderDefinition(definition); - const expectedService = renderOpenRcService(context, definition, periodic); + const expectedService = renderOpenRcService(context, definition, periodicCommandPath); const [service, enabled] = await Promise.all([ readManagedFile(context.servicePath, expectedService), isEnabled(context), ]); - const expectedCommand = periodic ? renderOpenRcReconciliationLoop(definition) : undefined; + const expectedCommand = periodicCommandPath + ? renderOpenRcReconciliationLoop(definition) + : undefined; const command = expectedCommand - ? await readManagedFile(context.commandPath, expectedCommand) + ? await readManagedFile(periodicCommandPath!, expectedCommand) : undefined; if (service !== expectedService || command !== expectedCommand || !enabled) { throw new RuntimeHostServiceManagerError( @@ -324,7 +326,10 @@ async function stopOpenRcService(context: OpenRcServiceContext): Promise { ); } -async function uninstallOpenRcService(context: OpenRcServiceContext): Promise { +async function uninstallOpenRcService( + context: OpenRcServiceContext, + periodicCommandPath?: string, +): Promise { await stopOpenRcService(context); if (await pathExists(context.runlevelPath)) { await requireOpenRc( @@ -336,7 +341,9 @@ async function uninstallOpenRcService(context: OpenRcServiceContext): Promise { +): Promise { let result: RuntimeHostServiceManagerCommandResult; try { result = await run(command, args); @@ -436,6 +442,7 @@ async function requireProbe( if (requireSuccess && result.exitCode !== 0) { throw unavailable(`${command} is unavailable${commandDetail(result)}`); } + return result; } async function requireOpenRc( @@ -564,8 +571,11 @@ async function readLogTail(path: string): Promise { async function detectOpenRcUserSessionActivation(username: string | undefined): Promise { if (username) { try { - await realpath(join('/etc/runlevels/default', `user.${username}`)); - return true; + const [configured, template] = await Promise.all([ + realpath(join('/etc/runlevels/default', `user.${username}`)), + realpath('/etc/init.d/user'), + ]); + if (configured === template) return true; } catch (error) { if (!isNodeError(error, 'ENOENT')) throw error; }