Archived
fix(cli): preflight native services in manager context
This commit is contained in:
+141
-240
@@ -8,6 +8,22 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js";
|
import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js";
|
||||||
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
|
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
|
||||||
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.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";
|
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 launchdServiceDir = join(homedir(), "Library", "LaunchAgents");
|
||||||
const logDir = join(defaultPiWebDataDir(), "logs");
|
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 InstallMode = "production" | "dev";
|
||||||
type ServiceBackendKind = "systemd" | "launchd";
|
type ServiceId = NativeServiceId;
|
||||||
type ServiceId = "sessiond" | "web" | "uiDev";
|
type ServiceBackend = NativeServiceBackend;
|
||||||
type Check = [string, string[]];
|
type Check = [string, string[]];
|
||||||
type SupportedShell = "bash" | "zsh" | "fish";
|
|
||||||
type RestartPolicy = "on-failure" | "never";
|
|
||||||
|
|
||||||
interface InstallOptions {
|
interface InstallOptions {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -33,34 +43,8 @@ interface InstallOptions {
|
|||||||
config?: string;
|
config?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceBackend {
|
interface ServiceRef extends NativeServiceManagerRef {
|
||||||
kind: ServiceBackendKind;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceRef {
|
|
||||||
id: ServiceId;
|
id: ServiceId;
|
||||||
systemdName: string;
|
|
||||||
launchdLabel: string;
|
|
||||||
launchdPlistName: string;
|
|
||||||
logName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceDefinition extends ServiceRef {
|
|
||||||
description: string;
|
|
||||||
shellCommand: string;
|
|
||||||
restart: RestartPolicy;
|
|
||||||
environment: Record<string, string>;
|
|
||||||
after?: ServiceId[];
|
|
||||||
wants?: ServiceId[];
|
|
||||||
workingDirectory?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceShell {
|
|
||||||
name: SupportedShell;
|
|
||||||
executable: string;
|
|
||||||
detected?: string;
|
|
||||||
fallback: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceExecutable {
|
interface ServiceExecutable {
|
||||||
@@ -85,30 +69,12 @@ interface ServiceRuntimeStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serviceRefs: Record<ServiceId, ServiceRef> = {
|
const serviceRefs: Record<ServiceId, ServiceRef> = {
|
||||||
sessiond: {
|
sessiond: { id: "sessiond", ...nativeServiceManagerRefs.sessiond },
|
||||||
id: "sessiond",
|
web: { id: "web", ...nativeServiceManagerRefs.web },
|
||||||
systemdName: sessiondServiceName,
|
uiDev: { id: "uiDev", ...nativeServiceManagerRefs.uiDev },
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const productionServiceIds: ServiceId[] = ["sessiond", "web"];
|
const productionServiceIds: ServiceId[] = [...productionNativeServiceIds];
|
||||||
const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"];
|
const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"];
|
||||||
const stopServiceOrder: ServiceId[] = ["web", "uiDev", "sessiond"];
|
const stopServiceOrder: ServiceId[] = ["web", "uiDev", "sessiond"];
|
||||||
// Restart web/UI before sessiond: when `pi-web restart` runs in a pi-web
|
// 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("'", "\\'")}'`;
|
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 {
|
function packageRootPath(): string {
|
||||||
return dirname(dirname(fileURLToPath(import.meta.url)));
|
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");
|
return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js");
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectServiceShell(): ServiceShell {
|
function detectServiceShell(): NativeServiceShell {
|
||||||
const userShell = userInfo().shell ?? undefined;
|
const userShell = userInfo().shell ?? undefined;
|
||||||
const envShell = process.env["SHELL"]?.trim();
|
const envShell = process.env["SHELL"]?.trim();
|
||||||
const detected = envShell === undefined || envShell === "" ? userShell : envShell;
|
const detected = envShell === undefined || envShell === "" ? userShell : envShell;
|
||||||
const name = basename(detected ?? "").replace(/^-/, "");
|
const name = basename(detected ?? "").replace(/^-/, "");
|
||||||
if (name === "bash" || name === "zsh" || name === "fish") {
|
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[] {
|
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];
|
return ["/usr/bin/env", detectServiceShell().executable, "-lc", fullCommand];
|
||||||
}
|
}
|
||||||
|
|
||||||
function serviceShellExecPrefix(): string {
|
|
||||||
return `/usr/bin/env ${detectServiceShell().executable} -lc`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceShellQuote(value: string): string {
|
function serviceShellQuote(value: string): string {
|
||||||
return detectServiceShell().name === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
|
return detectServiceShell().name === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function systemdServiceShellQuote(value: string): string {
|
|
||||||
return serviceShellQuote(value.replaceAll("%", "%%").replaceAll("$", "$$"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkSucceeds(command: string[]): boolean {
|
function checkSucceeds(command: string[]): boolean {
|
||||||
const [bin, ...args] = command;
|
const [bin, ...args] = command;
|
||||||
return bin !== undefined && capture(bin, args).status === 0;
|
return bin !== undefined && capture(bin, args).status === 0;
|
||||||
@@ -341,12 +292,12 @@ function resolveServiceExecutables(backend: ServiceBackend): ServiceExecutables
|
|||||||
|
|
||||||
function describeServiceShell(): string {
|
function describeServiceShell(): string {
|
||||||
const shell = detectServiceShell();
|
const shell = detectServiceShell();
|
||||||
if (shell.fallback) {
|
if (shell.source === "fallback") {
|
||||||
return shell.detected === undefined
|
return shell.detectedExecutable === null
|
||||||
? "could not detect a supported login shell; using bash"
|
? "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<string, string> {
|
function configEnvironment(options: InstallOptions, configPath: string): Record<string, string> {
|
||||||
@@ -385,28 +336,6 @@ function restartOrder(refs: ServiceRef[]): ServiceRef[] {
|
|||||||
return orderServiceRefs(refs, restartServiceOrder);
|
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 {
|
function devRootPath(): string {
|
||||||
return resolve(process.cwd());
|
return resolve(process.cwd());
|
||||||
}
|
}
|
||||||
@@ -421,104 +350,21 @@ function validateDevCheckout(root: string): void {
|
|||||||
if (!isRecord(parsed) || parsed["name"] !== PI_WEB_PACKAGE_NAME) {
|
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}.`);
|
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, string>): 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}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function plistProgramArguments(service: ServiceDefinition): string {
|
|
||||||
const args = ["/usr/bin/env", detectServiceShell().executable, "-lc", service.shellCommand];
|
|
||||||
return ` <key>ProgramArguments</key>\n <array>\n${args.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n")}\n </array>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function plistEnvironment(environment: Record<string, string>): string {
|
|
||||||
const entries = Object.entries(environment);
|
|
||||||
if (entries.length === 0) return "";
|
|
||||||
return ` <key>EnvironmentVariables</key>\n <dict>\n${entries.map(([key, value]) => plistString(key, value, " ")).join("")} </dict>\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function launchdLogPath(ref: ServiceRef): string {
|
function launchdLogPath(ref: ServiceRef): string {
|
||||||
return join(logDir, ref.logName);
|
return join(logDir, ref.logName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function launchdPlist(service: ServiceDefinition): string {
|
function installConfigPath(options: InstallOptions): string {
|
||||||
const workingDirectory = service.workingDirectory === undefined ? "" : plistString("WorkingDirectory", service.workingDirectory);
|
return options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config);
|
||||||
const keepAlive = service.restart === "on-failure" ? " <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n" : "";
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
${plistString("Label", service.launchdLabel)}${plistProgramArguments(service)}${workingDirectory}${plistEnvironment(service.environment)} <key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
${keepAlive}${plistString("StandardOutPath", launchdLogPath(service))}${plistString("StandardErrorPath", launchdLogPath(service))}</dict>
|
|
||||||
</plist>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeInitialConfig(options: InstallOptions): Promise<string> {
|
async function writeInitialConfig(options: InstallOptions, configPath: string): Promise<void> {
|
||||||
const configPath = options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config);
|
|
||||||
await mkdir(dirname(configPath), { recursive: true });
|
await mkdir(dirname(configPath), { recursive: true });
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) }));
|
await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) }));
|
||||||
}
|
}
|
||||||
return configPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function systemdServicePath(ref: ServiceRef): string {
|
function systemdServicePath(ref: ServiceRef): string {
|
||||||
@@ -546,8 +392,8 @@ function installedServiceRefs(backend: ServiceBackend): ServiceRef[] {
|
|||||||
return installed.length === 0 ? productionServiceRefs() : installed;
|
return installed.length === 0 ? productionServiceRefs() : installed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installSystemdServices(services: ServiceDefinition[]): Promise<void> {
|
async function installSystemdServices(plan: NativeServicePlan): Promise<void> {
|
||||||
const selected = new Set<ServiceId>(services.map((service) => service.id));
|
const selected = new Set<ServiceId>(plan.services.map((service) => service.id));
|
||||||
const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id)));
|
const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id)));
|
||||||
|
|
||||||
for (const ref of obsolete) {
|
for (const ref of obsolete) {
|
||||||
@@ -556,11 +402,11 @@ async function installSystemdServices(services: ServiceDefinition[]): Promise<vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(systemdServiceDir, { recursive: true });
|
await mkdir(systemdServiceDir, { recursive: true });
|
||||||
for (const service of services) {
|
for (const service of plan.services) {
|
||||||
await writeFile(systemdServicePath(service), systemdUnit(service));
|
await writeFile(join(systemdServiceDir, service.manager.systemdName), renderSystemdUnit(plan, service));
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = services.map((service) => service.systemdName);
|
const names = plan.services.map((service) => service.manager.systemdName);
|
||||||
run("systemctl", ["--user", "daemon-reload"], { check: true });
|
run("systemctl", ["--user", "daemon-reload"], { check: true });
|
||||||
run("systemctl", ["--user", "enable", ...names], { check: true });
|
run("systemctl", ["--user", "enable", ...names], { check: true });
|
||||||
run("systemctl", ["--user", "restart", ...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 });
|
run("launchctl", ["kickstart", launchdServiceTarget(ref)], { check: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installLaunchdServices(services: ServiceDefinition[]): Promise<void> {
|
async function installLaunchdServices(plan: NativeServicePlan): Promise<void> {
|
||||||
const selected = new Set<ServiceId>(services.map((service) => service.id));
|
const selected = new Set<ServiceId>(plan.services.map((service) => service.id));
|
||||||
|
|
||||||
await mkdir(launchdServiceDir, { recursive: true });
|
await mkdir(launchdServiceDir, { recursive: true });
|
||||||
await mkdir(logDir, { recursive: true });
|
await mkdir(logDir, { recursive: true });
|
||||||
@@ -604,16 +450,21 @@ async function installLaunchdServices(services: ServiceDefinition[]): Promise<vo
|
|||||||
await rm(launchdPlistPath(ref), { force: true });
|
await rm(launchdPlistPath(ref), { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const service of services) {
|
for (const service of plan.services) {
|
||||||
await writeFile(launchdPlistPath(service), launchdPlist(service));
|
const plistPath = join(launchdServiceDir, service.manager.launchdPlistName);
|
||||||
|
await writeFile(plistPath, renderLaunchdPlist(plan, service, logDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const service of services) launchdStart(service);
|
for (const service of plan.services) launchdStart(serviceRefFromPlan(service.id, service.manager));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installNativeServices(backend: ServiceBackend, services: ServiceDefinition[]): Promise<void> {
|
async function installNativeServices(plan: NativeServicePlan): Promise<void> {
|
||||||
if (backend.kind === "systemd") await installSystemdServices(services);
|
if (plan.backend.kind === "systemd") await installSystemdServices(plan);
|
||||||
else await installLaunchdServices(services);
|
else await installLaunchdServices(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceRefFromPlan(id: ServiceId, manager: NativeServiceManagerRef): ServiceRef {
|
||||||
|
return { id, ...manager };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uninstallSystemdServices(): Promise<void> {
|
async function uninstallSystemdServices(): Promise<void> {
|
||||||
@@ -759,28 +610,77 @@ function baseShellChecks(backend: ServiceBackend): Check[] {
|
|||||||
return checks;
|
return checks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function devInstallChecks(backend: ServiceBackend, root: string): Check[] {
|
function configuredServiceCommand(name: "PI_WEB_SERVER_EXEC" | "PI_WEB_SESSIOND_EXEC"): string | undefined {
|
||||||
const shell = serviceShellLabel();
|
const value = process.env[name];
|
||||||
const checks: Check[] = [
|
return value === undefined || value.trim() === "" ? undefined : value;
|
||||||
[`${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 installPreflightChecks(backend: ServiceBackend, mode: InstallMode, executables: ServiceExecutables | undefined, devRoot: string | undefined): Check[] {
|
function nativeServiceInstallCandidate(
|
||||||
return [
|
options: InstallOptions,
|
||||||
...backendAvailabilityChecks(backend),
|
backend: ServiceBackend,
|
||||||
...baseShellChecks(backend),
|
configPath: string,
|
||||||
...(mode === "dev" && devRoot !== undefined ? devInstallChecks(backend, devRoot) : []),
|
devRoot: string | undefined,
|
||||||
...(mode === "production" && executables !== undefined ? [...executables.web.checks, ...executables.sessiond.checks] : []),
|
): 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<void> {
|
async function install(args: string[]): Promise<void> {
|
||||||
@@ -788,23 +688,24 @@ async function install(args: string[]): Promise<void> {
|
|||||||
const options = parseInstallOptions(args);
|
const options = parseInstallOptions(args);
|
||||||
const devRoot = options.mode === "dev" ? devRootPath() : undefined;
|
const devRoot = options.mode === "dev" ? devRootPath() : undefined;
|
||||||
if (devRoot !== undefined) validateDevCheckout(devRoot);
|
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(`Running PI WEB ${options.mode} install preflight checks...`);
|
||||||
console.log(`Service backend: ${backend.label}`);
|
console.log(`Service backend: ${backend.label}`);
|
||||||
console.log(`Service shell: ${describeServiceShell()}`);
|
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();
|
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(`\nPI WEB ${options.mode} services are installed and starting.`);
|
||||||
console.log(`Config: ${configPath}`);
|
console.log(`Config: ${configPath}`);
|
||||||
if (options.mode === "dev") {
|
if (options.mode === "dev") {
|
||||||
|
|||||||
@@ -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<void>>(() => Promise.resolve());
|
||||||
|
const replaceServices = vi.fn<() => Promise<void>>(() => 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<void>>(() => Promise.resolve());
|
||||||
|
const replaceServices = vi.fn<() => Promise<void>>(() => 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void>;
|
||||||
|
replaceServices(plan: NativeServicePlan): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<NativeServiceInstallResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -123,14 +123,12 @@ describe("production native service planning", () => {
|
|||||||
backend,
|
backend,
|
||||||
shell,
|
shell,
|
||||||
workingDirectory: null,
|
workingDirectory: null,
|
||||||
prerequisites: [{ id: "sessiond.command.pi-web-sessiond" }, { id: "sessiond.node" }],
|
prerequisites: [
|
||||||
},
|
{ id: "sessiond.command.pi-web-sessiond" },
|
||||||
{
|
{ id: "sessiond.node" },
|
||||||
purpose: "plan-validation",
|
{ id: "web.command.pi-web-server" },
|
||||||
backend,
|
{ id: "web.node" },
|
||||||
shell,
|
],
|
||||||
workingDirectory: null,
|
|
||||||
prerequisites: [{ 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 fileExists = vi.fn<(path: string) => boolean>(() => true);
|
||||||
const resolution = await resolveProductionNativeServicePlan(productionInput(), {
|
const resolution = await resolveProductionNativeServicePlan(productionInput(), {
|
||||||
probe: {
|
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,
|
fileExists,
|
||||||
});
|
});
|
||||||
@@ -264,6 +262,7 @@ describe("production native service planning", () => {
|
|||||||
failures: [{
|
failures: [{
|
||||||
kind: "probe-infrastructure",
|
kind: "probe-infrastructure",
|
||||||
serviceIds: ["sessiond", "web"],
|
serviceIds: ["sessiond", "web"],
|
||||||
|
reason: "cleanup",
|
||||||
message: "launchd probe cleanup failed",
|
message: "launchd probe cleanup failed",
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
@@ -276,7 +275,7 @@ describe("production native service planning", () => {
|
|||||||
});
|
});
|
||||||
expect(thrown).toMatchObject({
|
expect(thrown).toMatchObject({
|
||||||
ok: false,
|
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(), {
|
const malformed = await resolveProductionNativeServicePlan(productionInput(), {
|
||||||
@@ -290,7 +289,7 @@ describe("production native service planning", () => {
|
|||||||
});
|
});
|
||||||
expect(malformed).toMatchObject({
|
expect(malformed).toMatchObject({
|
||||||
ok: false,
|
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(serviceCommandRequirements).not.toContain("pi-web-sessiond");
|
||||||
|
|
||||||
expect(planValidationProbeRequests(plan)).toMatchObject([
|
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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type NativeServiceId = "sessiond" | "web" | "uiDev";
|
|||||||
export type ProductionNativeServiceId = Extract<NativeServiceId, "sessiond" | "web">;
|
export type ProductionNativeServiceId = Extract<NativeServiceId, "sessiond" | "web">;
|
||||||
export type NativeServiceShellName = "bash" | "zsh" | "fish";
|
export type NativeServiceShellName = "bash" | "zsh" | "fish";
|
||||||
export type NativeServiceRestartPolicy = "on-failure" | "never";
|
export type NativeServiceRestartPolicy = "on-failure" | "never";
|
||||||
|
export type NativeServiceProbeInfrastructureReason = "manager" | "timeout" | "malformed-output" | "cleanup";
|
||||||
|
|
||||||
export interface NativeServiceBackend {
|
export interface NativeServiceBackend {
|
||||||
kind: NativeServiceBackendKind;
|
kind: NativeServiceBackendKind;
|
||||||
@@ -123,6 +124,7 @@ export type NativeServiceProbeResult =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "infrastructure-failure";
|
kind: "infrastructure-failure";
|
||||||
|
reason: NativeServiceProbeInfrastructureReason;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +169,7 @@ export type NativeServicePlanFailure =
|
|||||||
| {
|
| {
|
||||||
kind: "probe-infrastructure";
|
kind: "probe-infrastructure";
|
||||||
serviceIds: readonly ProductionNativeServiceId[];
|
serviceIds: readonly ProductionNativeServiceId[];
|
||||||
|
reason: NativeServiceProbeInfrastructureReason;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -187,7 +190,23 @@ export type NativeServicePlanResolution =
|
|||||||
| { ok: true; plan: NativeServicePlan }
|
| { ok: true; plan: NativeServicePlan }
|
||||||
| { ok: false; failures: readonly NativeServicePlanFailure[] };
|
| { ok: false; failures: readonly NativeServicePlanFailure[] };
|
||||||
|
|
||||||
const nativeServiceRefs: Readonly<Record<NativeServiceId, NativeServiceManagerRef>> = {
|
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<Record<NativeServiceId, NativeServiceManagerRef>> = {
|
||||||
sessiond: {
|
sessiond: {
|
||||||
systemdName: "pi-web-sessiond.service",
|
systemdName: "pi-web-sessiond.service",
|
||||||
launchdLabel: "com.pi-web.sessiond",
|
launchdLabel: "com.pi-web.sessiond",
|
||||||
@@ -208,7 +227,7 @@ const nativeServiceRefs: Readonly<Record<NativeServiceId, NativeServiceManagerRe
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const productionServiceIds = ["sessiond", "web"] as const satisfies readonly ProductionNativeServiceId[];
|
export const productionNativeServiceIds = ["sessiond", "web"] as const satisfies readonly ProductionNativeServiceId[];
|
||||||
|
|
||||||
export async function resolveProductionNativeServicePlan(
|
export async function resolveProductionNativeServicePlan(
|
||||||
input: ProductionNativeServicePlanInput,
|
input: ProductionNativeServicePlanInput,
|
||||||
@@ -218,7 +237,7 @@ export async function resolveProductionNativeServicePlan(
|
|||||||
const selectionRequirements: NativeServicePrerequisite[] = [];
|
const selectionRequirements: NativeServicePrerequisite[] = [];
|
||||||
const serviceIdsToProbe: ProductionNativeServiceId[] = [];
|
const serviceIdsToProbe: ProductionNativeServiceId[] = [];
|
||||||
|
|
||||||
for (const serviceId of productionServiceIds) {
|
for (const serviceId of productionNativeServiceIds) {
|
||||||
const executable = input.executables[serviceId];
|
const executable = input.executables[serviceId];
|
||||||
if (hasConfiguredCommand(executable.configuredCommand)) {
|
if (hasConfiguredCommand(executable.configuredCommand)) {
|
||||||
configuredStrategies.set(serviceId, {
|
configuredStrategies.set(serviceId, {
|
||||||
@@ -239,7 +258,7 @@ export async function resolveProductionNativeServicePlan(
|
|||||||
if (probeResult.kind === "infrastructure-failure") {
|
if (probeResult.kind === "infrastructure-failure") {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, message: probeResult.message }],
|
failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, reason: probeResult.reason, message: probeResult.message }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +266,7 @@ export async function resolveProductionNativeServicePlan(
|
|||||||
if (parsedOutcomes.kind === "infrastructure-failure") {
|
if (parsedOutcomes.kind === "infrastructure-failure") {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, message: parsedOutcomes.message }],
|
failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, reason: parsedOutcomes.reason, message: parsedOutcomes.message }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
outcomes = parsedOutcomes.outcomes;
|
outcomes = parsedOutcomes.outcomes;
|
||||||
@@ -309,7 +328,7 @@ export async function resolveProductionNativeServicePlan(
|
|||||||
mode: "production",
|
mode: "production",
|
||||||
backend: input.backend,
|
backend: input.backend,
|
||||||
shell: input.shell,
|
shell: input.shell,
|
||||||
services: productionServiceIds.map((serviceId) => 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: [
|
services: [
|
||||||
{
|
{
|
||||||
id: "sessiond",
|
id: "sessiond",
|
||||||
manager: nativeServiceRefs.sessiond,
|
manager: nativeServiceManagerRefs.sessiond,
|
||||||
description: "PI WEB session daemon (dev)",
|
description: "PI WEB session daemon (dev)",
|
||||||
shellCommand: "exec npm run start:sessiond",
|
shellCommand: "exec npm run start:sessiond",
|
||||||
strategy: { kind: "development-npm-script", script: "start:sessiond" },
|
strategy: { kind: "development-npm-script", script: "start:sessiond" },
|
||||||
@@ -344,7 +363,7 @@ export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServi
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "uiDev",
|
id: "uiDev",
|
||||||
manager: nativeServiceRefs.uiDev,
|
manager: nativeServiceManagerRefs.uiDev,
|
||||||
description: "PI WEB UI dev server",
|
description: "PI WEB UI dev server",
|
||||||
shellCommand: `exec /usr/bin/env bash -c ${shellSingleQuote(input.shell.name, uiDevCommand)}`,
|
shellCommand: `exec /usr/bin/env bash -c ${shellSingleQuote(input.shell.name, uiDevCommand)}`,
|
||||||
strategy: { kind: "development-npm-script-group", scripts: uiDevScripts, interpreter: "bash" },
|
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[] {
|
export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] {
|
||||||
return plan.services.flatMap((service) => service.prerequisites.length === 0 ? [] : [{
|
const requests: (Omit<NativeServiceProbeRequest, "prerequisites"> & { prerequisites: NativeServicePrerequisite[] })[] = [];
|
||||||
purpose: "plan-validation" as const,
|
for (const service of plan.services) {
|
||||||
backend: plan.backend,
|
if (service.prerequisites.length === 0) continue;
|
||||||
shell: plan.shell,
|
const existing = requests.find((request) =>
|
||||||
environment: service.environment,
|
request.workingDirectory === service.workingDirectory
|
||||||
workingDirectory: service.workingDirectory,
|
&& environmentsEqual(request.environment, service.environment));
|
||||||
prerequisites: service.prerequisites,
|
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<NativeServicePlanValidation> {
|
||||||
|
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(
|
function productionService(
|
||||||
@@ -383,7 +452,7 @@ function productionService(
|
|||||||
const isWeb = serviceId === "web";
|
const isWeb = serviceId === "web";
|
||||||
return {
|
return {
|
||||||
id: serviceId,
|
id: serviceId,
|
||||||
manager: nativeServiceRefs[serviceId],
|
manager: nativeServiceManagerRefs[serviceId],
|
||||||
description: isWeb ? "PI WEB server" : "PI WEB session daemon",
|
description: isWeb ? "PI WEB server" : "PI WEB session daemon",
|
||||||
shellCommand: `exec ${strategyCommand(input.shell, strategy)}`,
|
shellCommand: `exec ${strategyCommand(input.shell, strategy)}`,
|
||||||
strategy,
|
strategy,
|
||||||
@@ -439,30 +508,30 @@ async function runSelectionProbe(
|
|||||||
prerequisites,
|
prerequisites,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return { kind: "infrastructure-failure", message: errorMessage(error) };
|
return { kind: "infrastructure-failure", reason: "manager", message: errorMessage(error) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function probeOutcomes(
|
function probeOutcomes(
|
||||||
prerequisites: readonly NativeServicePrerequisite[],
|
prerequisites: readonly NativeServicePrerequisite[],
|
||||||
outcomes: readonly NativeServicePrerequisiteOutcome[],
|
outcomes: readonly NativeServicePrerequisiteOutcome[],
|
||||||
): { kind: "completed"; outcomes: Map<string, NativeServicePrerequisiteOutcome> } | { kind: "infrastructure-failure"; message: string } {
|
): { kind: "completed"; outcomes: Map<string, NativeServicePrerequisiteOutcome> } | { kind: "infrastructure-failure"; reason: "malformed-output"; message: string } {
|
||||||
const expectedIds = new Set(prerequisites.map((prerequisite) => prerequisite.id));
|
const expectedIds = new Set(prerequisites.map((prerequisite) => prerequisite.id));
|
||||||
const byId = new Map<string, NativeServicePrerequisiteOutcome>();
|
const byId = new Map<string, NativeServicePrerequisiteOutcome>();
|
||||||
|
|
||||||
for (const outcome of outcomes) {
|
for (const outcome of outcomes) {
|
||||||
if (!expectedIds.has(outcome.prerequisiteId)) {
|
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)) {
|
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);
|
byId.set(outcome.prerequisiteId, outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
const missing = prerequisites.find((prerequisite) => !byId.has(prerequisite.id));
|
const missing = prerequisites.find((prerequisite) => !byId.has(prerequisite.id));
|
||||||
if (missing !== undefined) {
|
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 };
|
return { kind: "completed", outcomes: byId };
|
||||||
}
|
}
|
||||||
@@ -535,6 +604,16 @@ function copyEnvironment(environment: Readonly<Record<string, string>>): Readonl
|
|||||||
return { ...environment };
|
return { ...environment };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function environmentsEqual(
|
||||||
|
left: Readonly<Record<string, string>>,
|
||||||
|
right: Readonly<Record<string, string>>,
|
||||||
|
): 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 {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/<uid> 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("<string>/bin/zsh</string>"),
|
||||||
|
);
|
||||||
|
expect(fileSystem.writeFile).toHaveBeenCalledWith(
|
||||||
|
"/tmp/probe/probe.plist",
|
||||||
|
expect.stringContaining("<key>WorkingDirectory</key>\n <string>/checkout with space</string>"),
|
||||||
|
);
|
||||||
|
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("<key>PATH</key>");
|
||||||
|
expect(plist).toContain("<key>PI_WEB_CONFIG</key>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function launchdFileSystem(contents: Record<string, string>): LaunchdProbeFileSystem & {
|
||||||
|
writeFile: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["writeFile"]>>;
|
||||||
|
removeDirectory: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["removeDirectory"]>>;
|
||||||
|
} {
|
||||||
|
const writeFileMock = vi.fn<LaunchdProbeFileSystem["writeFile"]>(() => Promise.resolve());
|
||||||
|
const removeDirectoryMock = vi.fn<LaunchdProbeFileSystem["removeDirectory"]>(() => 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<ProbeCommandResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaunchdProbeFileSystem {
|
||||||
|
createTemporaryDirectory(prefix: string): Promise<string>;
|
||||||
|
writeFile(path: string, contents: string): Promise<void>;
|
||||||
|
readFile(path: string): Promise<string>;
|
||||||
|
removeDirectory(path: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void>;
|
||||||
|
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<NativeServiceProbeResult> {
|
||||||
|
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<NativeServiceProbeResult | null> {
|
||||||
|
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<NativeServiceProbeResult> {
|
||||||
|
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<NativeServiceProbeResult> {
|
||||||
|
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<NativeServiceProbeResult | null> {
|
||||||
|
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) => ` <string>${xmlEscape(argument)}</string>`)
|
||||||
|
.join("\n");
|
||||||
|
const environmentEntries = Object.entries(request.environment);
|
||||||
|
const environmentXml = environmentEntries.length === 0
|
||||||
|
? ""
|
||||||
|
: ` <key>EnvironmentVariables</key>\n <dict>\n${environmentEntries.map(([key, value]) => plistString(key, value, " ")).join("")} </dict>\n`;
|
||||||
|
const workingDirectoryXml = request.workingDirectory === null
|
||||||
|
? ""
|
||||||
|
: plistString("WorkingDirectory", request.workingDirectory);
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
${plistString("Label", label)} <key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
${argumentsXml}
|
||||||
|
</array>
|
||||||
|
${workingDirectoryXml}${environmentXml} <key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
${plistString("StandardOutPath", stdoutPath)}${plistString("StandardErrorPath", stderrPath)}</dict>
|
||||||
|
</plist>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpawnProbeCommandRunner implements ProbeCommandRunner {
|
||||||
|
public run(command: string, args: readonly string[], timeoutMs: number): Promise<ProbeCommandResult> {
|
||||||
|
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<string, NativeServicePrerequisiteOutcome>();
|
||||||
|
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}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\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);
|
||||||
|
}
|
||||||
@@ -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("<string>com.pi-web.sessiond</string>");
|
||||||
|
expect(plist).toContain("<string>/bin/zsh</string>");
|
||||||
|
expect(plist).toContain("<string>exec npm run start:sessiond</string>");
|
||||||
|
expect(plist).toContain("<key>WorkingDirectory</key>\n <string>/checkout with space</string>");
|
||||||
|
expect(plist).toContain("<key>PI_WEB_CONFIG</key>\n <string>/home/user/config with "quote".json</string>");
|
||||||
|
expect(plist).toContain("<string>/logs/sessiond.log</string>");
|
||||||
|
expect(plist).not.toContain("<key>KeepAlive</key>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
? " <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n"
|
||||||
|
: "";
|
||||||
|
const logPath = join(logDirectory, service.manager.logName);
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
${plistString("Label", service.manager.launchdLabel)}${plistProgramArguments(programArguments)}${workingDirectory}${plistEnvironment(service.environment)} <key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
${keepAlive}${plistString("StandardOutPath", logPath)}${plistString("StandardErrorPath", logPath)}</dict>
|
||||||
|
</plist>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<string, string>>): 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 ` <key>ProgramArguments</key>\n <array>\n${arguments_.map((argument) => ` <string>${xmlEscape(argument)}</string>`).join("\n")}\n </array>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plistEnvironment(environment: Readonly<Record<string, string>>): string {
|
||||||
|
const entries = Object.entries(environment);
|
||||||
|
if (entries.length === 0) return "";
|
||||||
|
return ` <key>EnvironmentVariables</key>\n <dict>\n${entries.map(([key, value]) => plistString(key, value, " ")).join("")} </dict>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plistString(key: string, value: string, indent = " "): string {
|
||||||
|
return `${indent}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\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("'", "'");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user