diff --git a/src/cli.ts b/src/cli.ts index 00f1409..6783394 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,22 @@ import { fileURLToPath } from "node:url"; import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js"; import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js"; import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; +import { + installNativeServiceCandidate, + type NativeServiceInstallCandidate, + type NativeServiceInstallFailure, +} from "./nativeServices/serviceInstall.js"; +import { + nativeServiceManagerRefs, + productionNativeServiceIds, + type NativeServiceBackend, + type NativeServiceId, + type NativeServiceManagerRef, + type NativeServicePlan, + type NativeServiceShell, +} from "./nativeServices/servicePlan.js"; +import { createNativeServiceAuthoritativeProbe } from "./nativeServices/serviceProbe.js"; +import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; @@ -15,16 +31,10 @@ const systemdServiceDir = join(homedir(), ".config", "systemd", "user"); const launchdServiceDir = join(homedir(), "Library", "LaunchAgents"); const logDir = join(defaultPiWebDataDir(), "logs"); -const sessiondServiceName = "pi-web-sessiond.service"; -const webServiceName = "pi-web.service"; -const uiDevServiceName = "pi-web-ui-dev.service"; - type InstallMode = "production" | "dev"; -type ServiceBackendKind = "systemd" | "launchd"; -type ServiceId = "sessiond" | "web" | "uiDev"; +type ServiceId = NativeServiceId; +type ServiceBackend = NativeServiceBackend; type Check = [string, string[]]; -type SupportedShell = "bash" | "zsh" | "fish"; -type RestartPolicy = "on-failure" | "never"; interface InstallOptions { host: string; @@ -33,34 +43,8 @@ interface InstallOptions { config?: string; } -interface ServiceBackend { - kind: ServiceBackendKind; - label: string; -} - -interface ServiceRef { +interface ServiceRef extends NativeServiceManagerRef { id: ServiceId; - systemdName: string; - launchdLabel: string; - launchdPlistName: string; - logName: string; -} - -interface ServiceDefinition extends ServiceRef { - description: string; - shellCommand: string; - restart: RestartPolicy; - environment: Record; - after?: ServiceId[]; - wants?: ServiceId[]; - workingDirectory?: string; -} - -interface ServiceShell { - name: SupportedShell; - executable: string; - detected?: string; - fallback: boolean; } interface ServiceExecutable { @@ -85,30 +69,12 @@ interface ServiceRuntimeStatus { } const serviceRefs: Record = { - sessiond: { - id: "sessiond", - systemdName: sessiondServiceName, - launchdLabel: "com.pi-web.sessiond", - launchdPlistName: "com.pi-web.sessiond.plist", - logName: "sessiond.log", - }, - web: { - id: "web", - systemdName: webServiceName, - launchdLabel: "com.pi-web.web", - launchdPlistName: "com.pi-web.web.plist", - logName: "web.log", - }, - uiDev: { - id: "uiDev", - systemdName: uiDevServiceName, - launchdLabel: "com.pi-web.ui-dev", - launchdPlistName: "com.pi-web.ui-dev.plist", - logName: "ui-dev.log", - }, + sessiond: { id: "sessiond", ...nativeServiceManagerRefs.sessiond }, + web: { id: "web", ...nativeServiceManagerRefs.web }, + uiDev: { id: "uiDev", ...nativeServiceManagerRefs.uiDev }, }; -const productionServiceIds: ServiceId[] = ["sessiond", "web"]; +const productionServiceIds: ServiceId[] = [...productionNativeServiceIds]; const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"]; const stopServiceOrder: ServiceId[] = ["web", "uiDev", "sessiond"]; // Restart web/UI before sessiond: when `pi-web restart` runs in a pi-web @@ -236,23 +202,6 @@ function fishSingleQuote(value: string): string { return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; } -function systemdEscape(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); -} - -function systemdQuotedValue(value: string): string { - return `"${systemdEscape(value)}"`; -} - -function xmlEscape(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - function packageRootPath(): string { return dirname(dirname(fileURLToPath(import.meta.url))); } @@ -261,15 +210,25 @@ function packageEntrypointPath(name: "server" | "sessiond"): string { return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js"); } -function detectServiceShell(): ServiceShell { +function detectServiceShell(): NativeServiceShell { const userShell = userInfo().shell ?? undefined; const envShell = process.env["SHELL"]?.trim(); const detected = envShell === undefined || envShell === "" ? userShell : envShell; const name = basename(detected ?? "").replace(/^-/, ""); if (name === "bash" || name === "zsh" || name === "fish") { - return { name, executable: detected ?? name, detected: detected ?? name, fallback: false }; + return { + name, + executable: detected ?? name, + source: "detected", + detectedExecutable: detected ?? name, + }; } - return { name: "bash", executable: "bash", ...(detected === undefined ? {} : { detected }), fallback: true }; + return { + name: "bash", + executable: "bash", + source: "fallback", + detectedExecutable: detected ?? null, + }; } function serviceShellCommand(command: string, cwd?: string): string[] { @@ -277,18 +236,10 @@ function serviceShellCommand(command: string, cwd?: string): string[] { return ["/usr/bin/env", detectServiceShell().executable, "-lc", fullCommand]; } -function serviceShellExecPrefix(): string { - return `/usr/bin/env ${detectServiceShell().executable} -lc`; -} - function serviceShellQuote(value: string): string { return detectServiceShell().name === "fish" ? fishSingleQuote(value) : shellSingleQuote(value); } -function systemdServiceShellQuote(value: string): string { - return serviceShellQuote(value.replaceAll("%", "%%").replaceAll("$", "$$")); -} - function checkSucceeds(command: string[]): boolean { const [bin, ...args] = command; return bin !== undefined && capture(bin, args).status === 0; @@ -341,12 +292,12 @@ function resolveServiceExecutables(backend: ServiceBackend): ServiceExecutables function describeServiceShell(): string { const shell = detectServiceShell(); - if (shell.fallback) { - return shell.detected === undefined + if (shell.source === "fallback") { + return shell.detectedExecutable === null ? "could not detect a supported login shell; using bash" - : `detected ${shell.detected}; using bash because PI WEB currently supports bash, zsh, and fish`; + : `detected ${shell.detectedExecutable}; using bash because PI WEB currently supports bash, zsh, and fish`; } - return shell.detected === undefined ? shell.name : `${shell.name} (${shell.detected})`; + return shell.detectedExecutable === null ? shell.name : `${shell.name} (${shell.detectedExecutable})`; } function configEnvironment(options: InstallOptions, configPath: string): Record { @@ -385,28 +336,6 @@ function restartOrder(refs: ServiceRef[]): ServiceRef[] { return orderServiceRefs(refs, restartServiceOrder); } -function productionServiceDefinitions(options: InstallOptions, configPath: string, executables: ServiceExecutables): ServiceDefinition[] { - const environment = configEnvironment(options, configPath); - return [ - { - ...serviceRefs.sessiond, - description: "PI WEB session daemon", - shellCommand: `exec ${executables.sessiond.command}`, - restart: "on-failure", - environment, - }, - { - ...serviceRefs.web, - description: "PI WEB server", - shellCommand: `exec ${executables.web.command}`, - restart: "on-failure", - environment, - after: ["sessiond"], - wants: ["sessiond"], - }, - ]; -} - function devRootPath(): string { return resolve(process.cwd()); } @@ -421,104 +350,21 @@ function validateDevCheckout(root: string): void { if (!isRecord(parsed) || parsed["name"] !== PI_WEB_PACKAGE_NAME) { throw new Error(`Development mode must be installed from a PI WEB checkout. ${packageJsonPath} is not ${PI_WEB_PACKAGE_NAME}.`); } - - const scripts = parsed["scripts"]; - if (!isRecord(scripts)) throw new Error(`Development mode requires npm scripts in ${packageJsonPath}.`); - const requiredScripts = ["start:sessiond", "dev:web", "dev:client"]; - const missing = requiredScripts.filter((script) => typeof scripts[script] !== "string"); - if (missing.length > 0) throw new Error(`Development mode requires missing npm scripts: ${missing.join(", ")}.`); -} - -function devServiceDefinitions(options: InstallOptions, configPath: string, root: string): ServiceDefinition[] { - const environment = configEnvironment(options, configPath); - return [ - { - ...serviceRefs.sessiond, - description: "PI WEB session daemon (dev)", - shellCommand: "exec npm run start:sessiond", - restart: "never", - environment, - workingDirectory: root, - }, - { - ...serviceRefs.uiDev, - description: "PI WEB UI dev server", - shellCommand: `exec /usr/bin/env bash -c ${serviceShellQuote('trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait')}`, - restart: "never", - environment, - after: ["sessiond"], - wants: ["sessiond"], - workingDirectory: root, - }, - ]; -} - -function dependencyLine(name: "After" | "Wants", ids: ServiceId[] | undefined): string { - if (ids === undefined || ids.length === 0) return ""; - return `${name}=${ids.map((id) => serviceRefs[id].systemdName).join(" ")}\n`; -} - -function environmentLines(environment: Record): string { - return Object.entries(environment) - .map(([key, value]) => `Environment="${key}=${systemdEscape(value)}"\n`) - .join(""); -} - -function systemdUnit(service: ServiceDefinition): string { - const workingDirectory = service.workingDirectory === undefined ? "" : `WorkingDirectory=${systemdQuotedValue(service.workingDirectory)}\n`; - const restart = service.restart === "on-failure" ? "Restart=on-failure\nRestartSec=2\n" : "Restart=no\n"; - return `[Unit] -Description=${service.description} -${dependencyLine("After", service.after)}${dependencyLine("Wants", service.wants)} -[Service] -Type=simple -${workingDirectory}${environmentLines(service.environment)}ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(service.shellCommand)} -${restart} -[Install] -WantedBy=default.target -`; -} - -function plistString(key: string, value: string, indent = " "): string { - return `${indent}${xmlEscape(key)}\n${indent}${xmlEscape(value)}\n`; -} - -function plistProgramArguments(service: ServiceDefinition): string { - const args = ["/usr/bin/env", detectServiceShell().executable, "-lc", service.shellCommand]; - return ` ProgramArguments\n \n${args.map((arg) => ` ${xmlEscape(arg)}`).join("\n")}\n \n`; -} - -function plistEnvironment(environment: Record): string { - const entries = Object.entries(environment); - if (entries.length === 0) return ""; - return ` EnvironmentVariables\n \n${entries.map(([key, value]) => plistString(key, value, " ")).join("")} \n`; } function launchdLogPath(ref: ServiceRef): string { return join(logDir, ref.logName); } -function launchdPlist(service: ServiceDefinition): string { - const workingDirectory = service.workingDirectory === undefined ? "" : plistString("WorkingDirectory", service.workingDirectory); - const keepAlive = service.restart === "on-failure" ? " KeepAlive\n \n SuccessfulExit\n \n \n" : ""; - return ` - - - -${plistString("Label", service.launchdLabel)}${plistProgramArguments(service)}${workingDirectory}${plistEnvironment(service.environment)} RunAtLoad - -${keepAlive}${plistString("StandardOutPath", launchdLogPath(service))}${plistString("StandardErrorPath", launchdLogPath(service))} - -`; +function installConfigPath(options: InstallOptions): string { + return options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config); } -async function writeInitialConfig(options: InstallOptions): Promise { - const configPath = options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config); +async function writeInitialConfig(options: InstallOptions, configPath: string): Promise { await mkdir(dirname(configPath), { recursive: true }); if (!existsSync(configPath)) { await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) })); } - return configPath; } function systemdServicePath(ref: ServiceRef): string { @@ -546,8 +392,8 @@ function installedServiceRefs(backend: ServiceBackend): ServiceRef[] { return installed.length === 0 ? productionServiceRefs() : installed; } -async function installSystemdServices(services: ServiceDefinition[]): Promise { - const selected = new Set(services.map((service) => service.id)); +async function installSystemdServices(plan: NativeServicePlan): Promise { + const selected = new Set(plan.services.map((service) => service.id)); const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id))); for (const ref of obsolete) { @@ -556,11 +402,11 @@ async function installSystemdServices(services: ServiceDefinition[]): Promise service.systemdName); + const names = plan.services.map((service) => service.manager.systemdName); run("systemctl", ["--user", "daemon-reload"], { check: true }); run("systemctl", ["--user", "enable", ...names], { check: true }); run("systemctl", ["--user", "restart", ...names], { check: true }); @@ -592,8 +438,8 @@ function launchdStart(ref: ServiceRef): void { run("launchctl", ["kickstart", launchdServiceTarget(ref)], { check: true }); } -async function installLaunchdServices(services: ServiceDefinition[]): Promise { - const selected = new Set(services.map((service) => service.id)); +async function installLaunchdServices(plan: NativeServicePlan): Promise { + const selected = new Set(plan.services.map((service) => service.id)); await mkdir(launchdServiceDir, { recursive: true }); await mkdir(logDir, { recursive: true }); @@ -604,16 +450,21 @@ async function installLaunchdServices(services: ServiceDefinition[]): Promise { - if (backend.kind === "systemd") await installSystemdServices(services); - else await installLaunchdServices(services); +async function installNativeServices(plan: NativeServicePlan): Promise { + if (plan.backend.kind === "systemd") await installSystemdServices(plan); + else await installLaunchdServices(plan); +} + +function serviceRefFromPlan(id: ServiceId, manager: NativeServiceManagerRef): ServiceRef { + return { id, ...manager }; } async function uninstallSystemdServices(): Promise { @@ -759,28 +610,77 @@ function baseShellChecks(backend: ServiceBackend): Check[] { return checks; } -function devInstallChecks(backend: ServiceBackend, root: string): Check[] { - const shell = serviceShellLabel(); - const checks: Check[] = [ - [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"), root)], - [`${shell} can find bash`, serviceShellCommand(commandCheck("bash"), root)], - ]; - if (backend.kind === "systemd") { - checks.push( - [`systemd user ${shell} can find npm`, systemdUserServiceShellCommand(commandCheck("npm"), root)], - [`systemd user ${shell} can find bash`, systemdUserServiceShellCommand(commandCheck("bash"), root)], - ); - } - return checks; +function configuredServiceCommand(name: "PI_WEB_SERVER_EXEC" | "PI_WEB_SESSIOND_EXEC"): string | undefined { + const value = process.env[name]; + return value === undefined || value.trim() === "" ? undefined : value; } -function installPreflightChecks(backend: ServiceBackend, mode: InstallMode, executables: ServiceExecutables | undefined, devRoot: string | undefined): Check[] { - return [ - ...backendAvailabilityChecks(backend), - ...baseShellChecks(backend), - ...(mode === "dev" && devRoot !== undefined ? devInstallChecks(backend, devRoot) : []), - ...(mode === "production" && executables !== undefined ? [...executables.web.checks, ...executables.sessiond.checks] : []), - ]; +function nativeServiceInstallCandidate( + options: InstallOptions, + backend: ServiceBackend, + configPath: string, + devRoot: string | undefined, +): NativeServiceInstallCandidate { + const common = { + backend, + shell: detectServiceShell(), + environment: configEnvironment(options, configPath), + }; + if (options.mode === "production") { + return { + mode: "production", + input: { + ...common, + executables: { + sessiond: { + configuredCommand: configuredServiceCommand("PI_WEB_SESSIOND_EXEC"), + namedCommand: "pi-web-sessiond", + bundledEntrypointPath: packageEntrypointPath("sessiond"), + }, + web: { + configuredCommand: configuredServiceCommand("PI_WEB_SERVER_EXEC"), + namedCommand: "pi-web-server", + bundledEntrypointPath: packageEntrypointPath("server"), + }, + }, + }, + }; + } + + const root = devRoot ?? devRootPath(); + return { + mode: "development", + input: { + ...common, + workingDirectory: root, + packageJsonPath: join(root, "package.json"), + }, + }; +} + +function printNativeServiceInstallFailure(failure: NativeServiceInstallFailure): void { + if (failure.kind === "plan-resolution") { + for (const item of failure.failures) { + if (item.kind === "probe-infrastructure") { + console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`); + } else if (item.kind === "entrypoint-inspection-failure") { + console.log(`✗ Could not inspect bundled ${item.serviceId} entrypoint ${item.entrypointPath}: ${item.message}`); + } else { + console.log(`✗ ${item.namedCommand} is unavailable to the service manager, and bundled entrypoint ${item.bundledEntrypointPath} is missing.`); + if (item.namedCommandFailure !== null) console.log(` ${item.namedCommandFailure}`); + } + } + return; + } + + for (const item of failure.failures) { + if (item.kind === "probe-infrastructure") { + console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`); + } else { + console.log(`✗ ${item.prerequisite.description}`); + if (item.detail !== null && item.detail !== item.prerequisite.description) console.log(` ${item.detail}`); + } + } } async function install(args: string[]): Promise { @@ -788,23 +688,24 @@ async function install(args: string[]): Promise { const options = parseInstallOptions(args); const devRoot = options.mode === "dev" ? devRootPath() : undefined; if (devRoot !== undefined) validateDevCheckout(devRoot); + const configPath = installConfigPath(options); + const candidate = nativeServiceInstallCandidate(options, backend, configPath, devRoot); - const executables = options.mode === "production" ? resolveServiceExecutables(backend) : undefined; console.log(`Running PI WEB ${options.mode} install preflight checks...`); console.log(`Service backend: ${backend.label}`); console.log(`Service shell: ${describeServiceShell()}`); - if (!runChecks(installPreflightChecks(backend, options.mode, executables, devRoot))) { + const result = await installNativeServiceCandidate(candidate, { + probe: createNativeServiceAuthoritativeProbe(), + fileExists: existsSync, + writeInitialConfig: () => writeInitialConfig(options, configPath), + replaceServices: installNativeServices, + }); + if (!result.ok) { + printNativeServiceInstallFailure(result.failure); printPathSetupAdvice(); - throw new Error("Install preflight checks failed. Fix the failed checks above, then run `pi-web doctor` for more detail."); + throw new Error("Install preflight checks failed without changing config or services. Fix the failure above, then run `pi-web doctor` for more detail."); } - const configPath = await writeInitialConfig(options); - const services = options.mode === "dev" - ? devServiceDefinitions(options, configPath, devRoot ?? devRootPath()) - : productionServiceDefinitions(options, configPath, executables ?? resolveServiceExecutables(backend)); - - await installNativeServices(backend, services); - console.log(`\nPI WEB ${options.mode} services are installed and starting.`); console.log(`Config: ${configPath}`); if (options.mode === "dev") { diff --git a/src/nativeServices/serviceInstall.test.ts b/src/nativeServices/serviceInstall.test.ts new file mode 100644 index 0000000..116a9fb --- /dev/null +++ b/src/nativeServices/serviceInstall.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; +import { installNativeServiceCandidate } from "./serviceInstall.js"; +import type { + NativeServiceAuthoritativeProbe, + NativeServicePlan, + NativeServiceProbeRequest, + ProductionNativeServicePlanInput, +} from "./servicePlan.js"; + +const productionInput: ProductionNativeServicePlanInput = { + backend: { kind: "systemd", label: "systemd user services" }, + shell: { + name: "bash", + executable: "/bin/bash", + source: "detected", + detectedExecutable: "/bin/bash", + }, + environment: { PI_WEB_CONFIG: "/home/user/config.json" }, + executables: { + sessiond: { + configuredCommand: undefined, + namedCommand: "pi-web-sessiond", + bundledEntrypointPath: "/package/sessiond.js", + }, + web: { + configuredCommand: undefined, + namedCommand: "pi-web-server", + bundledEntrypointPath: "/package/server.js", + }, + }, +}; + +function successfulProbe(events: string[]): NativeServiceAuthoritativeProbe { + return { + run: (request) => { + events.push(`probe:${request.purpose}`); + return Promise.resolve({ + kind: "completed", + outcomes: request.prerequisites.map((prerequisite) => ({ + prerequisiteId: prerequisite.id, + status: "satisfied" as const, + detail: null, + })), + }); + }, + }; +} + +describe("native service install orchestration", () => { + it("resolves and validates the complete plan before writing config or replacing services", async () => { + const events: string[] = []; + const writeInitialConfig = vi.fn(() => { events.push("write-config"); return Promise.resolve(); }); + const replaceServices = vi.fn((plan: NativeServicePlan) => { + events.push(`replace:${plan.mode}`); + return Promise.resolve(); + }); + + const result = await installNativeServiceCandidate( + { mode: "production", input: productionInput }, + { + probe: successfulProbe(events), + fileExists: () => false, + writeInitialConfig, + replaceServices, + }, + ); + + expect(result.ok).toBe(true); + expect(events).toEqual([ + "probe:executable-selection", + "probe:plan-validation", + "write-config", + "replace:production", + ]); + expect(writeInitialConfig).toHaveBeenCalledOnce(); + expect(replaceServices).toHaveBeenCalledWith(expect.objectContaining({ mode: "production" })); + }); + + it("does not make durable changes when exact plan requirements are unsatisfied", async () => { + const writeInitialConfig = vi.fn<() => Promise>(() => Promise.resolve()); + const replaceServices = vi.fn<() => Promise>(() => Promise.resolve()); + const probe: NativeServiceAuthoritativeProbe = { + run: (request: NativeServiceProbeRequest) => Promise.resolve({ + kind: "completed", + outcomes: request.prerequisites.map((prerequisite) => ({ + prerequisiteId: prerequisite.id, + status: request.purpose === "plan-validation" ? "unsatisfied" : "satisfied", + detail: "not visible in the service manager environment", + })), + }), + }; + + const result = await installNativeServiceCandidate( + { mode: "production", input: productionInput }, + { probe, fileExists: () => false, writeInitialConfig, replaceServices }, + ); + + expect(result).toMatchObject({ ok: false, failure: { kind: "plan-validation" } }); + if (result.ok || result.failure.kind !== "plan-validation") throw new Error("Expected validation failure"); + expect(result.failure.failures).not.toHaveLength(0); + expect(result.failure.failures.every((failure) => failure.kind === "prerequisite-unsatisfied")).toBe(true); + expect(writeInitialConfig).not.toHaveBeenCalled(); + expect(replaceServices).not.toHaveBeenCalled(); + }); + + it("does not mislabel probe infrastructure failures or write anything", async () => { + const writeInitialConfig = vi.fn<() => Promise>(() => Promise.resolve()); + const replaceServices = vi.fn<() => Promise>(() => Promise.resolve()); + + const result = await installNativeServiceCandidate( + { mode: "production", input: productionInput }, + { + probe: { + run: () => Promise.resolve({ + kind: "infrastructure-failure", + reason: "timeout", + message: "service manager probe timed out", + }), + }, + fileExists: () => true, + writeInitialConfig, + replaceServices, + }, + ); + + expect(result).toEqual({ + ok: false, + failure: { + kind: "plan-resolution", + failures: [{ + kind: "probe-infrastructure", + serviceIds: ["sessiond", "web"], + reason: "timeout", + message: "service manager probe timed out", + }], + }, + }); + expect(writeInitialConfig).not.toHaveBeenCalled(); + expect(replaceServices).not.toHaveBeenCalled(); + }); +}); diff --git a/src/nativeServices/serviceInstall.ts b/src/nativeServices/serviceInstall.ts new file mode 100644 index 0000000..108354e --- /dev/null +++ b/src/nativeServices/serviceInstall.ts @@ -0,0 +1,60 @@ +import { + createDevelopmentNativeServicePlan, + resolveProductionNativeServicePlan, + validateNativeServicePlan, + type DevelopmentNativeServicePlanInput, + type NativeServiceAuthoritativeProbe, + type NativeServicePlan, + type NativeServicePlanDependencies, + type NativeServicePlanFailure, + type NativeServicePlanValidationFailure, + type ProductionNativeServicePlanInput, +} from "./servicePlan.js"; + +export type NativeServiceInstallCandidate = + | { mode: "production"; input: ProductionNativeServicePlanInput } + | { mode: "development"; input: DevelopmentNativeServicePlanInput }; + +export interface NativeServiceInstallDependencies extends NativeServicePlanDependencies { + probe: NativeServiceAuthoritativeProbe; + writeInitialConfig(): Promise; + replaceServices(plan: NativeServicePlan): Promise; +} + +export type NativeServiceInstallFailure = + | { kind: "plan-resolution"; failures: readonly NativeServicePlanFailure[] } + | { kind: "plan-validation"; failures: readonly NativeServicePlanValidationFailure[] }; + +export type NativeServiceInstallResult = + | { ok: true; plan: NativeServicePlan } + | { ok: false; failure: NativeServiceInstallFailure }; + +/** + * Keeps preflight effects ahead of durable install effects. The authoritative + * probes may create bounded temporary artifacts, but they must clean those up + * before this function writes config or replaces existing services. + */ +export async function installNativeServiceCandidate( + candidate: NativeServiceInstallCandidate, + dependencies: NativeServiceInstallDependencies, +): Promise { + let plan: NativeServicePlan; + if (candidate.mode === "production") { + const resolution = await resolveProductionNativeServicePlan(candidate.input, dependencies); + if (!resolution.ok) { + return { ok: false, failure: { kind: "plan-resolution", failures: resolution.failures } }; + } + plan = resolution.plan; + } else { + plan = createDevelopmentNativeServicePlan(candidate.input); + } + + const validation = await validateNativeServicePlan(plan, dependencies.probe); + if (!validation.ok) { + return { ok: false, failure: { kind: "plan-validation", failures: validation.failures } }; + } + + await dependencies.writeInitialConfig(); + await dependencies.replaceServices(plan); + return { ok: true, plan }; +} diff --git a/src/nativeServices/servicePlan.test.ts b/src/nativeServices/servicePlan.test.ts index f09f097..4560bf5 100644 --- a/src/nativeServices/servicePlan.test.ts +++ b/src/nativeServices/servicePlan.test.ts @@ -123,14 +123,12 @@ describe("production native service planning", () => { backend, shell, workingDirectory: null, - prerequisites: [{ id: "sessiond.command.pi-web-sessiond" }, { id: "sessiond.node" }], - }, - { - purpose: "plan-validation", - backend, - shell, - workingDirectory: null, - prerequisites: [{ id: "web.command.pi-web-server" }, { id: "web.node" }], + prerequisites: [ + { id: "sessiond.command.pi-web-sessiond" }, + { id: "sessiond.node" }, + { id: "web.command.pi-web-server" }, + { id: "web.node" }, + ], }, ]); }); @@ -253,7 +251,7 @@ describe("production native service planning", () => { const fileExists = vi.fn<(path: string) => boolean>(() => true); const resolution = await resolveProductionNativeServicePlan(productionInput(), { probe: { - run: () => Promise.resolve({ kind: "infrastructure-failure", message: "launchd probe cleanup failed" }), + run: () => Promise.resolve({ kind: "infrastructure-failure", reason: "cleanup", message: "launchd probe cleanup failed" }), }, fileExists, }); @@ -264,6 +262,7 @@ describe("production native service planning", () => { failures: [{ kind: "probe-infrastructure", serviceIds: ["sessiond", "web"], + reason: "cleanup", message: "launchd probe cleanup failed", }], }); @@ -276,7 +275,7 @@ describe("production native service planning", () => { }); expect(thrown).toMatchObject({ ok: false, - failures: [{ kind: "probe-infrastructure", message: "systemd-run failed" }], + failures: [{ kind: "probe-infrastructure", reason: "manager", message: "systemd-run failed" }], }); const malformed = await resolveProductionNativeServicePlan(productionInput(), { @@ -290,7 +289,7 @@ describe("production native service planning", () => { }); expect(malformed).toMatchObject({ ok: false, - failures: [{ kind: "probe-infrastructure", message: "Authoritative probe returned no outcome for web.command.pi-web-server." }], + failures: [{ kind: "probe-infrastructure", reason: "malformed-output", message: "Authoritative probe returned no outcome for web.command.pi-web-server." }], }); }); }); @@ -349,8 +348,19 @@ describe("development native service planning", () => { expect(serviceCommandRequirements).not.toContain("pi-web-sessiond"); expect(planValidationProbeRequests(plan)).toMatchObject([ - { backend: { kind: "launchd" }, workingDirectory: "/checkout with space" }, - { backend: { kind: "launchd" }, workingDirectory: "/checkout with space" }, + { + backend: { kind: "launchd" }, + workingDirectory: "/checkout with space", + prerequisites: [ + { id: "sessiond.node" }, + { id: "sessiond.command.npm" }, + { id: "sessiond.package-scripts" }, + { id: "uiDev.node" }, + { id: "uiDev.command.npm" }, + { id: "uiDev.command.bash" }, + { id: "uiDev.package-scripts" }, + ], + }, ]); }); }); diff --git a/src/nativeServices/servicePlan.ts b/src/nativeServices/servicePlan.ts index f0d3404..0a20a00 100644 --- a/src/nativeServices/servicePlan.ts +++ b/src/nativeServices/servicePlan.ts @@ -4,6 +4,7 @@ export type NativeServiceId = "sessiond" | "web" | "uiDev"; export type ProductionNativeServiceId = Extract; export type NativeServiceShellName = "bash" | "zsh" | "fish"; export type NativeServiceRestartPolicy = "on-failure" | "never"; +export type NativeServiceProbeInfrastructureReason = "manager" | "timeout" | "malformed-output" | "cleanup"; export interface NativeServiceBackend { kind: NativeServiceBackendKind; @@ -123,6 +124,7 @@ export type NativeServiceProbeResult = } | { kind: "infrastructure-failure"; + reason: NativeServiceProbeInfrastructureReason; message: string; }; @@ -167,6 +169,7 @@ export type NativeServicePlanFailure = | { kind: "probe-infrastructure"; serviceIds: readonly ProductionNativeServiceId[]; + reason: NativeServiceProbeInfrastructureReason; message: string; } | { @@ -187,7 +190,23 @@ export type NativeServicePlanResolution = | { ok: true; plan: NativeServicePlan } | { ok: false; failures: readonly NativeServicePlanFailure[] }; -const nativeServiceRefs: Readonly> = { +export type NativeServicePlanValidationFailure = + | { + kind: "prerequisite-unsatisfied"; + prerequisite: NativeServicePrerequisite; + detail: string | null; + } + | { + kind: "probe-infrastructure"; + reason: NativeServiceProbeInfrastructureReason; + message: string; + }; + +export type NativeServicePlanValidation = + | { ok: true } + | { ok: false; failures: readonly NativeServicePlanValidationFailure[] }; + +export const nativeServiceManagerRefs: Readonly> = { sessiond: { systemdName: "pi-web-sessiond.service", launchdLabel: "com.pi-web.sessiond", @@ -208,7 +227,7 @@ const nativeServiceRefs: Readonly productionService(input, serviceId, requiredStrategy(strategies, serviceId))), + services: productionNativeServiceIds.map((serviceId) => productionService(input, serviceId, requiredStrategy(strategies, serviceId))), }, }; } @@ -327,7 +346,7 @@ export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServi services: [ { id: "sessiond", - manager: nativeServiceRefs.sessiond, + manager: nativeServiceManagerRefs.sessiond, description: "PI WEB session daemon (dev)", shellCommand: "exec npm run start:sessiond", strategy: { kind: "development-npm-script", script: "start:sessiond" }, @@ -344,7 +363,7 @@ export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServi }, { id: "uiDev", - manager: nativeServiceRefs.uiDev, + manager: nativeServiceManagerRefs.uiDev, description: "PI WEB UI dev server", shellCommand: `exec /usr/bin/env bash -c ${shellSingleQuote(input.shell.name, uiDevCommand)}`, strategy: { kind: "development-npm-script-group", scripts: uiDevScripts, interpreter: "bash" }, @@ -365,14 +384,64 @@ export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServi } export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] { - return plan.services.flatMap((service) => service.prerequisites.length === 0 ? [] : [{ - purpose: "plan-validation" as const, - backend: plan.backend, - shell: plan.shell, - environment: service.environment, - workingDirectory: service.workingDirectory, - prerequisites: service.prerequisites, - }]); + const requests: (Omit & { prerequisites: NativeServicePrerequisite[] })[] = []; + for (const service of plan.services) { + if (service.prerequisites.length === 0) continue; + const existing = requests.find((request) => + request.workingDirectory === service.workingDirectory + && environmentsEqual(request.environment, service.environment)); + if (existing === undefined) { + requests.push({ + purpose: "plan-validation", + backend: plan.backend, + shell: plan.shell, + environment: service.environment, + workingDirectory: service.workingDirectory, + prerequisites: [...service.prerequisites], + }); + continue; + } + existing.prerequisites.push(...service.prerequisites); + } + return requests; +} + +export async function validateNativeServicePlan( + plan: NativeServicePlan, + probe: NativeServiceAuthoritativeProbe, +): Promise { + const failures: NativeServicePlanValidationFailure[] = []; + for (const request of planValidationProbeRequests(plan)) { + let result: NativeServiceProbeResult; + try { + result = await probe.run(request); + } catch (error: unknown) { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", reason: "manager", message: errorMessage(error) }], + }; + } + if (result.kind === "infrastructure-failure") { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", reason: result.reason, message: result.message }], + }; + } + const parsed = probeOutcomes(request.prerequisites, result.outcomes); + if (parsed.kind === "infrastructure-failure") { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", reason: parsed.reason, message: parsed.message }], + }; + } + for (const prerequisite of request.prerequisites) { + const outcome = parsed.outcomes.get(prerequisite.id); + if (outcome?.status === "unsatisfied") { + failures.push({ kind: "prerequisite-unsatisfied", prerequisite, detail: outcome.detail }); + } + } + } + return failures.length === 0 ? { ok: true } : { ok: false, failures }; } function productionService( @@ -383,7 +452,7 @@ function productionService( const isWeb = serviceId === "web"; return { id: serviceId, - manager: nativeServiceRefs[serviceId], + manager: nativeServiceManagerRefs[serviceId], description: isWeb ? "PI WEB server" : "PI WEB session daemon", shellCommand: `exec ${strategyCommand(input.shell, strategy)}`, strategy, @@ -439,30 +508,30 @@ async function runSelectionProbe( prerequisites, }); } catch (error: unknown) { - return { kind: "infrastructure-failure", message: errorMessage(error) }; + return { kind: "infrastructure-failure", reason: "manager", message: errorMessage(error) }; } } function probeOutcomes( prerequisites: readonly NativeServicePrerequisite[], outcomes: readonly NativeServicePrerequisiteOutcome[], -): { kind: "completed"; outcomes: Map } | { kind: "infrastructure-failure"; message: string } { +): { kind: "completed"; outcomes: Map } | { kind: "infrastructure-failure"; reason: "malformed-output"; message: string } { const expectedIds = new Set(prerequisites.map((prerequisite) => prerequisite.id)); const byId = new Map(); for (const outcome of outcomes) { if (!expectedIds.has(outcome.prerequisiteId)) { - return { kind: "infrastructure-failure", message: `Authoritative probe returned unexpected outcome ${outcome.prerequisiteId}.` }; + return { kind: "infrastructure-failure", reason: "malformed-output", message: `Authoritative probe returned unexpected outcome ${outcome.prerequisiteId}.` }; } if (byId.has(outcome.prerequisiteId)) { - return { kind: "infrastructure-failure", message: `Authoritative probe returned duplicate outcome ${outcome.prerequisiteId}.` }; + return { kind: "infrastructure-failure", reason: "malformed-output", message: `Authoritative probe returned duplicate outcome ${outcome.prerequisiteId}.` }; } byId.set(outcome.prerequisiteId, outcome); } const missing = prerequisites.find((prerequisite) => !byId.has(prerequisite.id)); if (missing !== undefined) { - return { kind: "infrastructure-failure", message: `Authoritative probe returned no outcome for ${missing.id}.` }; + return { kind: "infrastructure-failure", reason: "malformed-output", message: `Authoritative probe returned no outcome for ${missing.id}.` }; } return { kind: "completed", outcomes: byId }; } @@ -535,6 +604,16 @@ function copyEnvironment(environment: Readonly>): Readonl return { ...environment }; } +function environmentsEqual( + left: Readonly>, + right: Readonly>, +): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + return leftEntries.length === rightEntries.length + && leftEntries.every(([key, value]) => right[key] === value); +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/nativeServices/serviceProbe.test.ts b/src/nativeServices/serviceProbe.test.ts new file mode 100644 index 0000000..122e4bb --- /dev/null +++ b/src/nativeServices/serviceProbe.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it, vi } from "vitest"; +import { + LaunchdNativeServiceProbe, + SystemdNativeServiceProbe, + launchdProbePlist, + systemdRunArguments, + type LaunchdProbeFileSystem, + type ProbeCommandResult, + type ProbeCommandRunner, +} from "./serviceProbe.js"; +import type { NativeServiceProbeRequest } from "./servicePlan.js"; + +function request(kind: "systemd" | "launchd" = "systemd"): NativeServiceProbeRequest { + return { + purpose: "plan-validation", + backend: { kind, label: kind }, + shell: { + name: "zsh", + executable: "/bin/zsh", + source: "detected", + detectedExecutable: "/bin/zsh", + }, + environment: { PI_WEB_CONFIG: "/home/user/config with space.json" }, + workingDirectory: "/checkout with space", + prerequisites: [{ + id: "sessiond.command.npm", + kind: "command-available", + command: "npm", + description: "npm is available", + }], + }; +} + +function completed(status = 0, stdout = "", stderr = ""): ProbeCommandResult { + return { kind: "completed", status, stdout, stderr }; +} + +function marker(id: string, status: "satisfied" | "unsatisfied"): string { + return `PI_WEB_PROBE_fixed\t${Buffer.from(id).toString("base64")}\t${status}\n`; +} + +function queuedRunner(results: ProbeCommandResult[]): ProbeCommandRunner & { calls: { command: string; args: readonly string[]; timeoutMs: number }[] } { + const calls: { command: string; args: readonly string[]; timeoutMs: number }[] = []; + return { + calls, + run: (command, args, timeoutMs) => { + calls.push({ command, args, timeoutMs }); + const result = results.shift(); + if (result === undefined) throw new Error(`Unexpected command: ${command} ${args.join(" ")}`); + return Promise.resolve(result); + }, + }; +} + +describe("systemd authoritative native-service probe", () => { + it("runs the exact shell, environment, and cwd in a transient user service", async () => { + const runner = queuedRunner([completed(0, `login banner\n${marker("sessiond.command.npm", "satisfied")}`)]); + const probe = new SystemdNativeServiceProbe({ + commandRunner: runner, + createUniqueId: () => "fixed", + commandTimeoutMs: 3210, + }); + + await expect(probe.run(request())).resolves.toEqual({ + kind: "completed", + outcomes: [{ prerequisiteId: "sessiond.command.npm", status: "satisfied", detail: null }], + }); + expect(runner.calls).toHaveLength(1); + expect(runner.calls[0]).toMatchObject({ command: "systemd-run", timeoutMs: 3210 }); + expect(runner.calls[0]?.args).toEqual([ + "--user", + "--wait", + "--collect", + "--pipe", + "--quiet", + "--unit=pi-web-authoritative-probe-fixed.service", + "--setenv=PI_WEB_CONFIG=/home/user/config with space.json", + "--working-directory=/checkout with space", + "/usr/bin/env", + "/bin/zsh", + "-lc", + expect.stringContaining("command -v 'npm'"), + ]); + }); + + it("reports requirement failures as completed and malformed output as infrastructure", async () => { + const unsatisfiedRunner = queuedRunner([completed(0, marker("sessiond.command.npm", "unsatisfied"))]); + const dependencies = { commandRunner: unsatisfiedRunner, createUniqueId: () => "fixed", commandTimeoutMs: 100 }; + await expect(new SystemdNativeServiceProbe(dependencies).run(request())).resolves.toEqual({ + kind: "completed", + outcomes: [{ + prerequisiteId: "sessiond.command.npm", + status: "unsatisfied", + detail: "npm was not found in the native service environment.", + }], + }); + + const malformedRunner = queuedRunner([completed(0, "no marker here")]); + await expect(new SystemdNativeServiceProbe({ ...dependencies, commandRunner: malformedRunner }).run(request())).resolves.toMatchObject({ + kind: "infrastructure-failure", + reason: "malformed-output", + }); + }); + + it("bounds a hung unit and distinguishes cleanup failure", async () => { + const runner = queuedRunner([ + { kind: "timeout", stdout: "", stderr: "" }, + completed(0), + completed(1, "", "unit still loaded"), + ]); + const probe = new SystemdNativeServiceProbe({ + commandRunner: runner, + createUniqueId: () => "fixed", + commandTimeoutMs: 100, + }); + + const result = await probe.run(request()); + expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "cleanup" }); + expect(result.kind === "infrastructure-failure" && result.message).toContain("unit still loaded"); + expect(runner.calls.map(({ command, args }) => [command, ...args.slice(0, 3)])).toEqual([ + ["systemd-run", "--user", "--wait", "--collect"], + ["systemctl", "--user", "stop", "pi-web-authoritative-probe-fixed.service"], + ["systemctl", "--user", "reset-failed", "pi-web-authoritative-probe-fixed.service"], + ]); + }); +}); + +describe("launchd authoritative native-service probe", () => { + it("bootstraps a uniquely labelled one-shot agent in gui/ and always cleans it up", async () => { + const runner = queuedRunner([ + completed(0), + completed(0, "state = running\n"), + completed(0, "state = not running\nlast exit code = 0\n"), + completed(0), + ]); + const fileSystem = launchdFileSystem({ + "/tmp/probe/stdout.log": marker("sessiond.command.npm", "satisfied"), + "/tmp/probe/stderr.log": "", + }); + let now = 0; + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 501, + createUniqueId: () => "fixed", + now: () => now, + sleep: (milliseconds) => { now += milliseconds; return Promise.resolve(); }, + probeTimeoutMs: 500, + pollIntervalMs: 10, + commandTimeoutMs: 100, + }); + + await expect(probe.run(request("launchd"))).resolves.toMatchObject({ kind: "completed" }); + expect(runner.calls.map(({ command, args }) => [command, ...args])).toEqual([ + ["launchctl", "bootstrap", "gui/501", "/tmp/probe/probe.plist"], + ["launchctl", "print", "gui/501/com.pi-web.authoritative-probe.501.fixed"], + ["launchctl", "print", "gui/501/com.pi-web.authoritative-probe.501.fixed"], + ["launchctl", "bootout", "gui/501/com.pi-web.authoritative-probe.501.fixed"], + ]); + expect(fileSystem.writeFile).toHaveBeenCalledWith( + "/tmp/probe/probe.plist", + expect.stringContaining("/bin/zsh"), + ); + expect(fileSystem.writeFile).toHaveBeenCalledWith( + "/tmp/probe/probe.plist", + expect.stringContaining("WorkingDirectory\n /checkout with space"), + ); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); + + it("times out deterministically, boots out the agent, and removes temporary files", async () => { + const runner = queuedRunner([ + completed(0), + completed(0, "state = running\n"), + completed(0, "state = running\n"), + completed(0), + ]); + const fileSystem = launchdFileSystem({}); + let now = 0; + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 502, + createUniqueId: () => "fixed", + now: () => now, + sleep: (milliseconds) => { now += milliseconds; return Promise.resolve(); }, + probeTimeoutMs: 20, + pollIntervalMs: 10, + commandTimeoutMs: 100, + }); + + await expect(probe.run(request("launchd"))).resolves.toMatchObject({ + kind: "infrastructure-failure", + reason: "timeout", + }); + expect(runner.calls.at(-1)).toMatchObject({ + command: "launchctl", + args: ["bootout", "gui/502/com.pi-web.authoritative-probe.502.fixed"], + }); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); + + it("surfaces cleanup failure instead of returning an otherwise successful probe", async () => { + const runner = queuedRunner([ + completed(0), + completed(0, "state = not running\nlast exit code = 0\n"), + completed(1, "", "bootout denied"), + ]); + const fileSystem = launchdFileSystem({ + "/tmp/probe/stdout.log": marker("sessiond.command.npm", "satisfied"), + "/tmp/probe/stderr.log": "", + }); + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 503, + createUniqueId: () => "fixed", + now: () => 0, + sleep: () => Promise.resolve(), + probeTimeoutMs: 20, + pollIntervalMs: 10, + commandTimeoutMs: 100, + }); + + const result = await probe.run(request("launchd")); + expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "cleanup" }); + expect(result.kind === "infrastructure-failure" && result.message).toContain("bootout denied"); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); + + it("checks and boots out a label when bootstrap itself times out", async () => { + const runner = queuedRunner([ + { kind: "timeout", stdout: "", stderr: "" }, + completed(0, "state = running\n"), + completed(0), + ]); + const fileSystem = launchdFileSystem({}); + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 504, + createUniqueId: () => "fixed", + now: () => 0, + sleep: () => Promise.resolve(), + probeTimeoutMs: 20, + pollIntervalMs: 10, + commandTimeoutMs: 100, + }); + + await expect(probe.run(request("launchd"))).resolves.toMatchObject({ + kind: "infrastructure-failure", + reason: "timeout", + }); + expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "print", "bootout"]); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); + + it("removes temporary files when bootstrap fails without booting out an unloaded label", async () => { + const runner = queuedRunner([completed(1, "", "bootstrap denied")]); + const fileSystem = launchdFileSystem({}); + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 504, + createUniqueId: () => "fixed", + now: () => 0, + sleep: () => Promise.resolve(), + probeTimeoutMs: 20, + pollIntervalMs: 10, + commandTimeoutMs: 100, + }); + + const result = await probe.run(request("launchd")); + expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "manager" }); + expect(result.kind === "infrastructure-failure" && result.message).toContain("bootstrap denied"); + expect(runner.calls).toHaveLength(1); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); +}); + +describe("probe service definitions", () => { + it("renders backend inputs without inheriting the caller PATH", () => { + const probeRequest = request(); + expect(systemdRunArguments(probeRequest, "probe.service", "echo ok")).not.toContain(expect.stringContaining("PATH=")); + const plist = launchdProbePlist(probeRequest, "com.example.probe", "echo ok", "/tmp/out", "/tmp/err"); + expect(plist).not.toContain("PATH"); + expect(plist).toContain("PI_WEB_CONFIG"); + }); +}); + +function launchdFileSystem(contents: Record): LaunchdProbeFileSystem & { + writeFile: ReturnType>; + removeDirectory: ReturnType>; +} { + const writeFileMock = vi.fn(() => Promise.resolve()); + const removeDirectoryMock = vi.fn(() => Promise.resolve()); + return { + createTemporaryDirectory: () => Promise.resolve("/tmp/probe"), + writeFile: writeFileMock, + readFile: (path) => { + const content = contents[path]; + return content === undefined ? Promise.reject(new Error(`missing ${path}`)) : Promise.resolve(content); + }, + removeDirectory: removeDirectoryMock, + }; +} diff --git a/src/nativeServices/serviceProbe.ts b/src/nativeServices/serviceProbe.ts new file mode 100644 index 0000000..3f47624 --- /dev/null +++ b/src/nativeServices/serviceProbe.ts @@ -0,0 +1,519 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir, userInfo } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { + NativeServiceAuthoritativeProbe, + NativeServicePrerequisite, + NativeServicePrerequisiteOutcome, + NativeServiceProbeRequest, + NativeServiceProbeResult, + NativeServiceShellName, +} from "./servicePlan.js"; + +export type ProbeCommandResult = + | { kind: "completed"; status: number; stdout: string; stderr: string } + | { kind: "timeout"; stdout: string; stderr: string } + | { kind: "spawn-failure"; message: string; stdout: string; stderr: string }; + +export interface ProbeCommandRunner { + run(command: string, args: readonly string[], timeoutMs: number): Promise; +} + +export interface LaunchdProbeFileSystem { + createTemporaryDirectory(prefix: string): Promise; + writeFile(path: string, contents: string): Promise; + readFile(path: string): Promise; + removeDirectory(path: string): Promise; +} + +interface CommonProbeDependencies { + commandRunner: ProbeCommandRunner; + createUniqueId(): string; + commandTimeoutMs: number; +} + +export type SystemdProbeDependencies = CommonProbeDependencies; + +export interface LaunchdProbeDependencies extends CommonProbeDependencies { + fileSystem: LaunchdProbeFileSystem; + uid: number; + now(): number; + sleep(milliseconds: number): Promise; + probeTimeoutMs: number; + pollIntervalMs: number; +} + +const defaultCommandTimeoutMs = 15_000; +const defaultProbeTimeoutMs = 15_000; +const defaultPollIntervalMs = 50; + +export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProbe { + public constructor(private readonly dependencies: SystemdProbeDependencies) {} + + public async run(request: NativeServiceProbeRequest): Promise { + if (request.backend.kind !== "systemd") { + return infrastructureFailure("manager", `Systemd probe cannot validate the ${request.backend.kind} backend.`); + } + + const uniqueId = safeUniqueId(this.dependencies.createUniqueId()); + const unitName = `pi-web-authoritative-probe-${uniqueId}.service`; + const outputPrefix = `PI_WEB_PROBE_${uniqueId}`; + const command = prerequisiteProbeCommand(request.shell.name, request.prerequisites, outputPrefix); + const args = systemdRunArguments(request, unitName, command); + const result = await this.dependencies.commandRunner.run("systemd-run", args, this.dependencies.commandTimeoutMs); + + if (result.kind === "timeout") { + const cleanupFailure = await this.cleanupTimedOutUnit(unitName); + return cleanupFailure ?? infrastructureFailure( + "timeout", + `Timed out waiting for transient systemd unit ${unitName}.`, + ); + } + if (result.kind === "spawn-failure") { + return infrastructureFailure("manager", `Could not start systemd-run: ${result.message}`); + } + if (result.status !== 0) { + return infrastructureFailure( + "manager", + `Transient systemd probe ${unitName} failed: ${firstOutput(result.stderr, result.stdout, `exit status ${String(result.status)}`)}`, + ); + } + return parseProbeOutput(result.stdout, request.prerequisites, outputPrefix); + } + + private async cleanupTimedOutUnit(unitName: string): Promise { + const stop = await this.dependencies.commandRunner.run( + "systemctl", + ["--user", "stop", unitName], + this.dependencies.commandTimeoutMs, + ); + if (stop.kind !== "completed" || stop.status !== 0) { + return infrastructureFailure("cleanup", `Could not stop timed-out transient systemd unit ${unitName}: ${commandFailureDetail(stop)}`); + } + const reset = await this.dependencies.commandRunner.run( + "systemctl", + ["--user", "reset-failed", unitName], + this.dependencies.commandTimeoutMs, + ); + if (reset.kind !== "completed" || reset.status !== 0) { + return infrastructureFailure("cleanup", `Could not collect timed-out transient systemd unit ${unitName}: ${commandFailureDetail(reset)}`); + } + return null; + } +} + +export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProbe { + public constructor(private readonly dependencies: LaunchdProbeDependencies) {} + + public async run(request: NativeServiceProbeRequest): Promise { + if (request.backend.kind !== "launchd") { + return infrastructureFailure("manager", `Launchd probe cannot validate the ${request.backend.kind} backend.`); + } + + const uniqueId = safeUniqueId(this.dependencies.createUniqueId()); + const label = `com.pi-web.authoritative-probe.${String(this.dependencies.uid)}.${uniqueId}`; + const domain = `gui/${String(this.dependencies.uid)}`; + const target = `${domain}/${label}`; + const outputPrefix = `PI_WEB_PROBE_${uniqueId}`; + let directory: string | null = null; + let bootstrapState: "not-loaded" | "loaded" | "uncertain" = "not-loaded"; + let result: NativeServiceProbeResult; + + try { + directory = await this.dependencies.fileSystem.createTemporaryDirectory( + join(tmpdir(), "pi-web-launchd-probe-"), + ); + const plistPath = join(directory, "probe.plist"); + const stdoutPath = join(directory, "stdout.log"); + const stderrPath = join(directory, "stderr.log"); + const command = prerequisiteProbeCommand(request.shell.name, request.prerequisites, outputPrefix); + await this.dependencies.fileSystem.writeFile( + plistPath, + launchdProbePlist(request, label, command, stdoutPath, stderrPath), + ); + + const bootstrap = await this.dependencies.commandRunner.run( + "launchctl", + ["bootstrap", domain, plistPath], + this.dependencies.commandTimeoutMs, + ); + if (bootstrap.kind !== "completed" || bootstrap.status !== 0) { + bootstrapState = bootstrap.kind === "timeout" ? "uncertain" : "not-loaded"; + result = commandInfrastructureFailure("bootstrap launchd probe", bootstrap); + } else { + bootstrapState = "loaded"; + result = await this.waitForResult(target, stdoutPath, stderrPath, request.prerequisites, outputPrefix); + } + } catch (error: unknown) { + result = infrastructureFailure("manager", `Could not prepare launchd probe: ${errorMessage(error)}`); + } + + const cleanupFailure = await this.cleanup(target, directory, bootstrapState); + return cleanupFailure ?? result; + } + + private async waitForResult( + target: string, + stdoutPath: string, + stderrPath: string, + prerequisites: readonly NativeServicePrerequisite[], + outputPrefix: string, + ): Promise { + const deadline = this.dependencies.now() + this.dependencies.probeTimeoutMs; + while (this.dependencies.now() < deadline) { + const printed = await this.dependencies.commandRunner.run( + "launchctl", + ["print", target], + this.dependencies.commandTimeoutMs, + ); + if (printed.kind !== "completed" || printed.status !== 0) { + return commandInfrastructureFailure("inspect launchd probe", printed); + } + + const state = launchdField(printed.stdout, "state"); + if (state === undefined) { + return infrastructureFailure("malformed-output", `launchctl returned no state for ${target}.`); + } + const lastExitCode = launchdIntegerField(printed.stdout, "last exit code"); + if (state === "not running" && lastExitCode !== undefined) { + let stdout: string; + let stderr: string; + try { + [stdout, stderr] = await Promise.all([ + this.dependencies.fileSystem.readFile(stdoutPath), + this.dependencies.fileSystem.readFile(stderrPath), + ]); + } catch (error: unknown) { + return infrastructureFailure("manager", `Could not read launchd probe output: ${errorMessage(error)}`); + } + if (lastExitCode !== 0) { + return infrastructureFailure( + "manager", + `Launchd probe service exited with status ${String(lastExitCode)}: ${firstOutput(stderr, stdout, "no output")}`, + ); + } + return parseProbeOutput(stdout, prerequisites, outputPrefix); + } + + await this.dependencies.sleep(this.dependencies.pollIntervalMs); + } + return infrastructureFailure("timeout", `Timed out waiting for launchd probe ${target}.`); + } + + private async cleanup( + target: string, + directory: string | null, + bootstrapState: "not-loaded" | "loaded" | "uncertain", + ): Promise { + const failures: string[] = []; + let shouldBootout = bootstrapState === "loaded"; + if (bootstrapState === "uncertain") { + const inspection = await this.dependencies.commandRunner.run( + "launchctl", + ["print", target], + this.dependencies.commandTimeoutMs, + ); + if (inspection.kind === "completed") { + shouldBootout = inspection.status === 0; + } else { + // If launchctl cannot tell us whether a timed-out bootstrap loaded the + // label, bootout is the only operation that can make cleanup certain. + shouldBootout = true; + } + } + if (shouldBootout) { + const bootout = await this.dependencies.commandRunner.run( + "launchctl", + ["bootout", target], + this.dependencies.commandTimeoutMs, + ); + if (bootout.kind !== "completed" || bootout.status !== 0) { + failures.push(`bootout failed: ${commandFailureDetail(bootout)}`); + } + } + if (directory !== null) { + try { + await this.dependencies.fileSystem.removeDirectory(directory); + } catch (error: unknown) { + failures.push(`temporary-file removal failed: ${errorMessage(error)}`); + } + } + return failures.length === 0 + ? null + : infrastructureFailure("cleanup", `Launchd probe cleanup failed for ${target}: ${failures.join("; ")}`); + } +} + +export function createNativeServiceAuthoritativeProbe(): NativeServiceAuthoritativeProbe { + const commandRunner = new SpawnProbeCommandRunner(); + const common: CommonProbeDependencies = { + commandRunner, + createUniqueId: randomUUID, + commandTimeoutMs: defaultCommandTimeoutMs, + }; + const systemd = new SystemdNativeServiceProbe(common); + const launchd = new LaunchdNativeServiceProbe({ + ...common, + fileSystem: nodeLaunchdProbeFileSystem, + uid: userInfo().uid, + now: Date.now, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + probeTimeoutMs: defaultProbeTimeoutMs, + pollIntervalMs: defaultPollIntervalMs, + }); + return { + run: (request) => request.backend.kind === "systemd" ? systemd.run(request) : launchd.run(request), + }; +} + +export function systemdRunArguments( + request: NativeServiceProbeRequest, + unitName: string, + shellCommand: string, +): readonly string[] { + return [ + "--user", + "--wait", + "--collect", + "--pipe", + "--quiet", + `--unit=${unitName}`, + ...Object.entries(request.environment).map(([key, value]) => `--setenv=${key}=${value}`), + ...(request.workingDirectory === null ? [] : [`--working-directory=${request.workingDirectory}`]), + "/usr/bin/env", + request.shell.executable, + "-lc", + shellCommand, + ]; +} + +export function launchdProbePlist( + request: NativeServiceProbeRequest, + label: string, + shellCommand: string, + stdoutPath: string, + stderrPath: string, +): string { + const argumentsXml = ["/usr/bin/env", request.shell.executable, "-lc", shellCommand] + .map((argument) => ` ${xmlEscape(argument)}`) + .join("\n"); + const environmentEntries = Object.entries(request.environment); + const environmentXml = environmentEntries.length === 0 + ? "" + : ` EnvironmentVariables\n \n${environmentEntries.map(([key, value]) => plistString(key, value, " ")).join("")} \n`; + const workingDirectoryXml = request.workingDirectory === null + ? "" + : plistString("WorkingDirectory", request.workingDirectory); + return ` + + + +${plistString("Label", label)} ProgramArguments + +${argumentsXml} + +${workingDirectoryXml}${environmentXml} RunAtLoad + +${plistString("StandardOutPath", stdoutPath)}${plistString("StandardErrorPath", stderrPath)} + +`; +} + +class SpawnProbeCommandRunner implements ProbeCommandRunner { + public run(command: string, args: readonly string[], timeoutMs: number): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let spawnFailure: string | null = null; + let timedOut = false; + 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.on("error", (error) => { spawnFailure = error.message; }); + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeoutMs); + child.on("close", (status) => { + clearTimeout(timeout); + if (timedOut) { + resolve({ kind: "timeout", stdout, stderr }); + } else if (spawnFailure !== null) { + resolve({ kind: "spawn-failure", message: spawnFailure, stdout, stderr }); + } else { + resolve({ kind: "completed", status: status ?? 1, stdout, stderr }); + } + }); + }); + } +} + +const nodeLaunchdProbeFileSystem: LaunchdProbeFileSystem = { + createTemporaryDirectory: (prefix) => mkdtemp(prefix), + writeFile: (path, contents) => writeFile(path, contents, "utf8"), + readFile: (path) => readFile(path, "utf8"), + removeDirectory: (path) => rm(path, { recursive: true, force: true }), +}; + +function prerequisiteProbeCommand( + shell: NativeServiceShellName, + prerequisites: readonly NativeServicePrerequisite[], + outputPrefix: string, +): string { + return prerequisites.map((prerequisite) => { + const check = prerequisiteCheck(shell, prerequisite); + const encodedId = Buffer.from(prerequisite.id, "utf8").toString("base64"); + const satisfied = markerCommand(shell, outputPrefix, encodedId, "satisfied"); + const unsatisfied = markerCommand(shell, outputPrefix, encodedId, "unsatisfied"); + return `${check} >/dev/null 2>&1 && ${satisfied} || ${unsatisfied}`; + }).join("; ") || ":"; +} + +function prerequisiteCheck(shell: NativeServiceShellName, prerequisite: NativeServicePrerequisite): string { + switch (prerequisite.kind) { + case "command-available": + return `command -v ${shellQuote(shell, prerequisite.command)}`; + case "node-version": { + const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`; + return `node -e ${shellQuote(shell, script)}`; + } + case "readable-file": + return `test -r ${shellQuote(shell, prerequisite.path)}`; + case "package-scripts": { + const script = "const p=require(process.argv[1]);const names=process.argv.slice(2);process.exit(names.every((name)=>typeof p.scripts?.[name]==='string')?0:1)"; + return ["node", "-e", shellQuote(shell, script), shellQuote(shell, prerequisite.packageJsonPath), ...prerequisite.scripts.map((name) => shellQuote(shell, name))].join(" "); + } + } +} + +function markerCommand( + shell: NativeServiceShellName, + outputPrefix: string, + encodedId: string, + status: "satisfied" | "unsatisfied", +): string { + return `printf '%s\\t%s\\t%s\\n' ${shellQuote(shell, outputPrefix)} ${shellQuote(shell, encodedId)} ${shellQuote(shell, status)}`; +} + +function parseProbeOutput( + stdout: string, + prerequisites: readonly NativeServicePrerequisite[], + outputPrefix: string, +): NativeServiceProbeResult { + const expected = new Map(prerequisites.map((prerequisite) => [ + Buffer.from(prerequisite.id, "utf8").toString("base64"), + prerequisite, + ])); + const outcomes = new Map(); + for (const line of stdout.split(/\r?\n/u)) { + if (!line.startsWith(`${outputPrefix}\t`)) continue; + const fields = line.split("\t"); + if (fields.length !== 3) { + return infrastructureFailure("malformed-output", "Authoritative probe returned a malformed result line."); + } + const encodedId = fields[1]; + const status = fields[2]; + const prerequisite = encodedId === undefined ? undefined : expected.get(encodedId); + if (prerequisite === undefined || (status !== "satisfied" && status !== "unsatisfied")) { + return infrastructureFailure("malformed-output", "Authoritative probe returned an unexpected result."); + } + if (outcomes.has(prerequisite.id)) { + return infrastructureFailure("malformed-output", `Authoritative probe returned duplicate outcome ${prerequisite.id}.`); + } + outcomes.set(prerequisite.id, { + prerequisiteId: prerequisite.id, + status, + detail: status === "satisfied" ? null : unsatisfiedDetail(prerequisite), + }); + } + const missing = prerequisites.find((prerequisite) => !outcomes.has(prerequisite.id)); + if (missing !== undefined) { + return infrastructureFailure("malformed-output", `Authoritative probe returned no outcome for ${missing.id}.`); + } + return { kind: "completed", outcomes: [...outcomes.values()] }; +} + +function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string { + switch (prerequisite.kind) { + case "command-available": + return `${prerequisite.command} was not found in the native service environment.`; + case "node-version": + return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`; + case "readable-file": + return `${prerequisite.path} was not readable in the native service environment.`; + case "package-scripts": + return `${prerequisite.packageJsonPath} did not provide scripts ${prerequisite.scripts.join(", ")} in the native service environment.`; + } +} + +function shellQuote(shell: NativeServiceShellName, value: string): string { + return shell === "fish" + ? `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'` + : `'${value.replaceAll("'", "'\\''")}'`; +} + +function safeUniqueId(value: string): string { + const safe = value.toLowerCase().replaceAll(/[^a-z0-9-]/gu, "").slice(0, 48); + return safe === "" ? "probe" : safe; +} + +function launchdField(output: string, field: string): string | undefined { + return new RegExp(`^\\s*${escapeRegExp(field)}\\s*=\\s*(.+)$`, "mu").exec(output)?.[1]?.trim(); +} + +function launchdIntegerField(output: string, field: string): number | undefined { + const value = launchdField(output, field); + if (value === undefined || !/^-?\d+$/u.test(value)) return undefined; + return Number(value); +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function plistString(key: string, value: string, indent = " "): string { + return `${indent}${xmlEscape(key)}\n${indent}${xmlEscape(value)}\n`; +} + +function xmlEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function commandInfrastructureFailure(action: string, result: ProbeCommandResult): NativeServiceProbeResult { + if (result.kind === "timeout") return infrastructureFailure("timeout", `Timed out while trying to ${action}.`); + return infrastructureFailure("manager", `Could not ${action}: ${commandFailureDetail(result)}`); +} + +function commandFailureDetail(result: ProbeCommandResult): string { + if (result.kind === "timeout") return "command timed out"; + if (result.kind === "spawn-failure") return result.message; + return firstOutput(result.stderr, result.stdout, `exit status ${String(result.status)}`); +} + +function infrastructureFailure( + reason: "manager" | "timeout" | "malformed-output" | "cleanup", + message: string, +): NativeServiceProbeResult { + return { kind: "infrastructure-failure", reason, message }; +} + +function firstOutput(...values: string[]): string { + for (const value of values) { + const line = value.trim().split(/\r?\n/u).find((candidate) => candidate.trim() !== ""); + if (line !== undefined) return line.trim(); + } + return "no output"; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/nativeServices/serviceRendering.test.ts b/src/nativeServices/serviceRendering.test.ts new file mode 100644 index 0000000..eea8868 --- /dev/null +++ b/src/nativeServices/serviceRendering.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + createDevelopmentNativeServicePlan, + type NativeServicePlan, + type NativeServicePlanService, +} from "./servicePlan.js"; +import { renderLaunchdPlist, renderSystemdUnit } from "./serviceRendering.js"; + +function developmentPlan(kind: "systemd" | "launchd"): NativeServicePlan { + return createDevelopmentNativeServicePlan({ + backend: { kind, label: kind }, + shell: { + name: "zsh", + executable: "/bin/zsh", + source: "detected", + detectedExecutable: "/bin/zsh", + }, + environment: { PI_WEB_CONFIG: "/home/user/config with \"quote\".json" }, + workingDirectory: "/checkout with space", + packageJsonPath: "/checkout with space/package.json", + }); +} + +function planService(plan: NativeServicePlan, index: number): NativeServicePlanService { + const service = plan.services[index]; + if (service === undefined) throw new Error(`Missing service at index ${String(index)}`); + return service; +} + +describe("native service rendering", () => { + it("renders systemd entirely from the canonical plan", () => { + const plan = developmentPlan("systemd"); + const unit = renderSystemdUnit(plan, planService(plan, 1)); + + expect(unit).toContain("Description=PI WEB UI dev server"); + expect(unit).toContain("After=pi-web-sessiond.service\nWants=pi-web-sessiond.service"); + expect(unit).toContain('WorkingDirectory="/checkout with space"'); + expect(unit).toContain('Environment="PI_WEB_CONFIG=/home/user/config with \\"quote\\".json"'); + expect(unit).toContain("ExecStart=/usr/bin/env /bin/zsh -lc 'exec /usr/bin/env bash -c '\\''trap"); + expect(unit).toContain("Restart=no"); + }); + + it("renders launchd entirely from the canonical plan", () => { + const plan = developmentPlan("launchd"); + const plist = renderLaunchdPlist(plan, planService(plan, 0), "/logs"); + + expect(plist).toContain("com.pi-web.sessiond"); + expect(plist).toContain("/bin/zsh"); + expect(plist).toContain("exec npm run start:sessiond"); + expect(plist).toContain("WorkingDirectory\n /checkout with space"); + expect(plist).toContain("PI_WEB_CONFIG\n /home/user/config with "quote".json"); + expect(plist).toContain("/logs/sessiond.log"); + expect(plist).not.toContain("KeepAlive"); + }); + + it("rejects a service from a different plan", () => { + const first = developmentPlan("systemd"); + const second = developmentPlan("systemd"); + expect(() => renderSystemdUnit(first, planService(second, 0))).toThrow("not a member"); + }); +}); diff --git a/src/nativeServices/serviceRendering.ts b/src/nativeServices/serviceRendering.ts new file mode 100644 index 0000000..39e1d4c --- /dev/null +++ b/src/nativeServices/serviceRendering.ts @@ -0,0 +1,129 @@ +import { join } from "node:path"; +import type { + NativeServiceId, + NativeServicePlan, + NativeServicePlanService, + NativeServiceShellName, +} from "./servicePlan.js"; + +export function renderSystemdUnit( + plan: NativeServicePlan, + service: NativeServicePlanService, +): string { + assertPlanService(plan, service); + assertBackend(plan, "systemd"); + const workingDirectory = service.workingDirectory === null + ? "" + : `WorkingDirectory=${systemdQuotedValue(service.workingDirectory)}\n`; + const restart = service.restart === "on-failure" + ? "Restart=on-failure\nRestartSec=2\n" + : "Restart=no\n"; + return `[Unit] +Description=${service.description} +${systemdDependencyLine(plan, "After", service.after)}${systemdDependencyLine(plan, "Wants", service.wants)}[Service] +Type=simple +${workingDirectory}${systemdEnvironmentLines(service.environment)}ExecStart=/usr/bin/env ${plan.shell.executable} -lc ${systemdServiceShellQuote(plan.shell.name, service.shellCommand)} +${restart} +[Install] +WantedBy=default.target +`; +} + +export function renderLaunchdPlist( + plan: NativeServicePlan, + service: NativeServicePlanService, + logDirectory: string, +): string { + assertPlanService(plan, service); + assertBackend(plan, "launchd"); + const programArguments = ["/usr/bin/env", plan.shell.executable, "-lc", service.shellCommand]; + const workingDirectory = service.workingDirectory === null + ? "" + : plistString("WorkingDirectory", service.workingDirectory); + const keepAlive = service.restart === "on-failure" + ? " KeepAlive\n \n SuccessfulExit\n \n \n" + : ""; + const logPath = join(logDirectory, service.manager.logName); + return ` + + + +${plistString("Label", service.manager.launchdLabel)}${plistProgramArguments(programArguments)}${workingDirectory}${plistEnvironment(service.environment)} RunAtLoad + +${keepAlive}${plistString("StandardOutPath", logPath)}${plistString("StandardErrorPath", logPath)} + +`; +} + +function assertPlanService(plan: NativeServicePlan, service: NativeServicePlanService): void { + if (!plan.services.includes(service)) { + throw new Error(`Cannot render ${service.id}; it is not a member of the supplied native service plan.`); + } +} + +function assertBackend(plan: NativeServicePlan, expected: "systemd" | "launchd"): void { + if (plan.backend.kind !== expected) { + throw new Error(`Cannot render ${expected} service from a ${plan.backend.kind} native service plan.`); + } +} + +function systemdDependencyLine( + plan: NativeServicePlan, + name: "After" | "Wants", + ids: readonly NativeServiceId[], +): string { + if (ids.length === 0) return ""; + const names = ids.map((id) => { + const dependency = plan.services.find((service) => service.id === id); + if (dependency === undefined) throw new Error(`Service ${id} is not present in the native service plan.`); + return dependency.manager.systemdName; + }); + return `${name}=${names.join(" ")}\n`; +} + +function systemdEnvironmentLines(environment: Readonly>): string { + return Object.entries(environment) + .map(([key, value]) => `Environment="${systemdEscape(key)}=${systemdEscape(value)}"\n`) + .join(""); +} + +function systemdServiceShellQuote(shell: NativeServiceShellName, value: string): string { + return shellQuote(shell, value.replaceAll("%", "%%").replaceAll("$", "$$")); +} + +function systemdQuotedValue(value: string): string { + return `"${systemdEscape(value)}"`; +} + +function systemdEscape(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +function plistProgramArguments(arguments_: readonly string[]): string { + return ` ProgramArguments\n \n${arguments_.map((argument) => ` ${xmlEscape(argument)}`).join("\n")}\n \n`; +} + +function plistEnvironment(environment: Readonly>): string { + const entries = Object.entries(environment); + if (entries.length === 0) return ""; + return ` EnvironmentVariables\n \n${entries.map(([key, value]) => plistString(key, value, " ")).join("")} \n`; +} + +function plistString(key: string, value: string, indent = " "): string { + return `${indent}${xmlEscape(key)}\n${indent}${xmlEscape(value)}\n`; +} + +function shellQuote(shell: NativeServiceShellName, value: string): string { + return shell === "fish" + ? `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'` + : `'${value.replaceAll("'", "'\\''")}'`; +} + +function xmlEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +}