This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/src/nativeServices/servicePlan.ts
T

541 lines
17 KiB
TypeScript

export type NativeServiceBackendKind = "systemd" | "launchd";
export type NativeServiceMode = "production" | "development";
export type NativeServiceId = "sessiond" | "web" | "uiDev";
export type ProductionNativeServiceId = Extract<NativeServiceId, "sessiond" | "web">;
export type NativeServiceShellName = "bash" | "zsh" | "fish";
export type NativeServiceRestartPolicy = "on-failure" | "never";
export interface NativeServiceBackend {
kind: NativeServiceBackendKind;
label: string;
}
export interface NativeServiceShell {
name: NativeServiceShellName;
executable: string;
source: "detected" | "fallback";
detectedExecutable: string | null;
}
export interface NativeServiceManagerRef {
systemdName: string;
launchdLabel: string;
launchdPlistName: string;
logName: string;
}
export type NativeServiceCommandStrategy =
| {
kind: "configured-override";
command: string;
verification: "unverified";
}
| {
kind: "named-command";
command: string;
selectedBy: "authoritative-backend-probe";
}
| {
kind: "bundled-entrypoint";
command: "node";
entrypointPath: string;
namedCommand: string;
namedCommandFailure: string | null;
}
| {
kind: "development-npm-script";
script: string;
}
| {
kind: "development-npm-script-group";
scripts: readonly string[];
interpreter: "bash";
};
export type NativeServicePrerequisite =
| {
id: string;
kind: "command-available";
command: string;
description: string;
}
| {
id: string;
kind: "node-version";
command: "node";
minimumMajor: number;
description: string;
}
| {
id: string;
kind: "readable-file";
path: string;
description: string;
}
| {
id: string;
kind: "package-scripts";
packageJsonPath: string;
scripts: readonly string[];
description: string;
};
export interface NativeServicePlanService {
id: NativeServiceId;
manager: NativeServiceManagerRef;
description: string;
shellCommand: string;
strategy: NativeServiceCommandStrategy;
restart: NativeServiceRestartPolicy;
environment: Readonly<Record<string, string>>;
workingDirectory: string | null;
after: readonly NativeServiceId[];
wants: readonly NativeServiceId[];
prerequisites: readonly NativeServicePrerequisite[];
}
export interface NativeServicePlan {
mode: NativeServiceMode;
backend: NativeServiceBackend;
shell: NativeServiceShell;
services: readonly NativeServicePlanService[];
}
export interface NativeServiceProbeRequest {
purpose: "executable-selection" | "plan-validation";
backend: NativeServiceBackend;
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
workingDirectory: string | null;
prerequisites: readonly NativeServicePrerequisite[];
}
export interface NativeServicePrerequisiteOutcome {
prerequisiteId: string;
status: "satisfied" | "unsatisfied";
detail: string | null;
}
export type NativeServiceProbeResult =
| {
kind: "completed";
outcomes: readonly NativeServicePrerequisiteOutcome[];
}
| {
kind: "infrastructure-failure";
message: string;
};
/**
* Runs requirements in the real native service-manager context represented by
* the request. Implementations must not treat the caller shell or a simulated
* `env -i` environment as authoritative. Timeouts, manager failures, malformed
* output, and cleanup failures are infrastructure failures; a missing command
* is a completed probe with an unsatisfied outcome.
*/
export interface NativeServiceAuthoritativeProbe {
run(request: NativeServiceProbeRequest): Promise<NativeServiceProbeResult>;
}
export interface ProductionNativeServiceExecutableInput {
configuredCommand: string | undefined;
namedCommand: string;
bundledEntrypointPath: string;
}
export interface ProductionNativeServicePlanInput {
backend: NativeServiceBackend;
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
executables: Readonly<Record<ProductionNativeServiceId, ProductionNativeServiceExecutableInput>>;
}
export interface DevelopmentNativeServicePlanInput {
backend: NativeServiceBackend;
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
workingDirectory: string;
packageJsonPath: string;
}
export interface NativeServicePlanDependencies {
probe: NativeServiceAuthoritativeProbe;
fileExists(path: string): boolean;
}
export type NativeServicePlanFailure =
| {
kind: "probe-infrastructure";
serviceIds: readonly ProductionNativeServiceId[];
message: string;
}
| {
kind: "entrypoint-inspection-failure";
serviceId: ProductionNativeServiceId;
entrypointPath: string;
message: string;
}
| {
kind: "executable-unavailable";
serviceId: ProductionNativeServiceId;
namedCommand: string;
namedCommandFailure: string | null;
bundledEntrypointPath: string;
};
export type NativeServicePlanResolution =
| { ok: true; plan: NativeServicePlan }
| { ok: false; failures: readonly NativeServicePlanFailure[] };
const nativeServiceRefs: Readonly<Record<NativeServiceId, NativeServiceManagerRef>> = {
sessiond: {
systemdName: "pi-web-sessiond.service",
launchdLabel: "com.pi-web.sessiond",
launchdPlistName: "com.pi-web.sessiond.plist",
logName: "sessiond.log",
},
web: {
systemdName: "pi-web.service",
launchdLabel: "com.pi-web.web",
launchdPlistName: "com.pi-web.web.plist",
logName: "web.log",
},
uiDev: {
systemdName: "pi-web-ui-dev.service",
launchdLabel: "com.pi-web.ui-dev",
launchdPlistName: "com.pi-web.ui-dev.plist",
logName: "ui-dev.log",
},
};
const productionServiceIds = ["sessiond", "web"] as const satisfies readonly ProductionNativeServiceId[];
export async function resolveProductionNativeServicePlan(
input: ProductionNativeServicePlanInput,
dependencies: NativeServicePlanDependencies,
): Promise<NativeServicePlanResolution> {
const configuredStrategies = new Map<ProductionNativeServiceId, NativeServiceCommandStrategy>();
const selectionRequirements: NativeServicePrerequisite[] = [];
const serviceIdsToProbe: ProductionNativeServiceId[] = [];
for (const serviceId of productionServiceIds) {
const executable = input.executables[serviceId];
if (hasConfiguredCommand(executable.configuredCommand)) {
configuredStrategies.set(serviceId, {
kind: "configured-override",
command: executable.configuredCommand,
verification: "unverified",
});
continue;
}
serviceIdsToProbe.push(serviceId);
selectionRequirements.push(commandRequirement(serviceId, executable.namedCommand));
}
let outcomes = new Map<string, NativeServicePrerequisiteOutcome>();
if (selectionRequirements.length > 0) {
const probeResult = await runSelectionProbe(input, selectionRequirements, dependencies.probe);
if (probeResult.kind === "infrastructure-failure") {
return {
ok: false,
failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, message: probeResult.message }],
};
}
const parsedOutcomes = probeOutcomes(selectionRequirements, probeResult.outcomes);
if (parsedOutcomes.kind === "infrastructure-failure") {
return {
ok: false,
failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, message: parsedOutcomes.message }],
};
}
outcomes = parsedOutcomes.outcomes;
}
const strategies = new Map(configuredStrategies);
const failures: NativeServicePlanFailure[] = [];
for (const serviceId of serviceIdsToProbe) {
const executable = input.executables[serviceId];
const outcome = outcomes.get(commandRequirementId(serviceId, executable.namedCommand));
if (outcome?.status === "satisfied") {
strategies.set(serviceId, {
kind: "named-command",
command: executable.namedCommand,
selectedBy: "authoritative-backend-probe",
});
continue;
}
let entrypointExists: boolean;
try {
entrypointExists = dependencies.fileExists(executable.bundledEntrypointPath);
} catch (error: unknown) {
failures.push({
kind: "entrypoint-inspection-failure",
serviceId,
entrypointPath: executable.bundledEntrypointPath,
message: errorMessage(error),
});
continue;
}
if (entrypointExists) {
strategies.set(serviceId, {
kind: "bundled-entrypoint",
command: "node",
entrypointPath: executable.bundledEntrypointPath,
namedCommand: executable.namedCommand,
namedCommandFailure: outcome?.detail ?? null,
});
continue;
}
failures.push({
kind: "executable-unavailable",
serviceId,
namedCommand: executable.namedCommand,
namedCommandFailure: outcome?.detail ?? null,
bundledEntrypointPath: executable.bundledEntrypointPath,
});
}
if (failures.length > 0) return { ok: false, failures };
return {
ok: true,
plan: {
mode: "production",
backend: input.backend,
shell: input.shell,
services: productionServiceIds.map((serviceId) => productionService(input, serviceId, requiredStrategy(strategies, serviceId))),
},
};
}
export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServicePlanInput): NativeServicePlan {
const environment = copyEnvironment(input.environment);
const sessiondScripts = ["start:sessiond"] as const;
const uiDevScripts = ["dev:web", "dev:client"] as const;
const uiDevCommand = 'trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait';
return {
mode: "development",
backend: input.backend,
shell: input.shell,
services: [
{
id: "sessiond",
manager: nativeServiceRefs.sessiond,
description: "PI WEB session daemon (dev)",
shellCommand: "exec npm run start:sessiond",
strategy: { kind: "development-npm-script", script: "start:sessiond" },
restart: "never",
environment,
workingDirectory: input.workingDirectory,
after: [],
wants: [],
prerequisites: [
nodeRequirement("sessiond"),
commandRequirement("sessiond", "npm"),
packageScriptsRequirement("sessiond", input.packageJsonPath, sessiondScripts),
],
},
{
id: "uiDev",
manager: nativeServiceRefs.uiDev,
description: "PI WEB UI dev server",
shellCommand: `exec /usr/bin/env bash -c ${shellSingleQuote(input.shell.name, uiDevCommand)}`,
strategy: { kind: "development-npm-script-group", scripts: uiDevScripts, interpreter: "bash" },
restart: "never",
environment,
workingDirectory: input.workingDirectory,
after: ["sessiond"],
wants: ["sessiond"],
prerequisites: [
nodeRequirement("uiDev"),
commandRequirement("uiDev", "npm"),
commandRequirement("uiDev", "bash"),
packageScriptsRequirement("uiDev", input.packageJsonPath, uiDevScripts),
],
},
],
};
}
export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] {
return plan.services.flatMap((service) => service.prerequisites.length === 0 ? [] : [{
purpose: "plan-validation" as const,
backend: plan.backend,
shell: plan.shell,
environment: service.environment,
workingDirectory: service.workingDirectory,
prerequisites: service.prerequisites,
}]);
}
function productionService(
input: ProductionNativeServicePlanInput,
serviceId: ProductionNativeServiceId,
strategy: NativeServiceCommandStrategy,
): NativeServicePlanService {
const isWeb = serviceId === "web";
return {
id: serviceId,
manager: nativeServiceRefs[serviceId],
description: isWeb ? "PI WEB server" : "PI WEB session daemon",
shellCommand: `exec ${strategyCommand(input.shell, strategy)}`,
strategy,
restart: "on-failure",
environment: copyEnvironment(input.environment),
workingDirectory: null,
after: isWeb ? ["sessiond"] : [],
wants: isWeb ? ["sessiond"] : [],
prerequisites: strategyPrerequisites(serviceId, strategy),
};
}
function strategyCommand(shell: NativeServiceShell, strategy: NativeServiceCommandStrategy): string {
switch (strategy.kind) {
case "configured-override":
case "named-command":
return strategy.command;
case "bundled-entrypoint":
return `${strategy.command} ${shellSingleQuote(shell.name, strategy.entrypointPath)}`;
case "development-npm-script":
return `npm run ${strategy.script}`;
case "development-npm-script-group":
throw new Error("Development script groups define their complete service shell command");
}
}
function strategyPrerequisites(serviceId: ProductionNativeServiceId, strategy: NativeServiceCommandStrategy): readonly NativeServicePrerequisite[] {
switch (strategy.kind) {
case "configured-override":
return [];
case "named-command":
return [commandRequirement(serviceId, strategy.command), nodeRequirement(serviceId)];
case "bundled-entrypoint":
return [nodeRequirement(serviceId), readableFileRequirement(serviceId, strategy.entrypointPath)];
case "development-npm-script":
case "development-npm-script-group":
throw new Error(`Unexpected ${strategy.kind} strategy in a production plan`);
}
}
async function runSelectionProbe(
input: ProductionNativeServicePlanInput,
prerequisites: readonly NativeServicePrerequisite[],
probe: NativeServiceAuthoritativeProbe,
): Promise<NativeServiceProbeResult> {
try {
return await probe.run({
purpose: "executable-selection",
backend: input.backend,
shell: input.shell,
environment: copyEnvironment(input.environment),
workingDirectory: null,
prerequisites,
});
} catch (error: unknown) {
return { kind: "infrastructure-failure", message: errorMessage(error) };
}
}
function probeOutcomes(
prerequisites: readonly NativeServicePrerequisite[],
outcomes: readonly NativeServicePrerequisiteOutcome[],
): { kind: "completed"; outcomes: Map<string, NativeServicePrerequisiteOutcome> } | { kind: "infrastructure-failure"; message: string } {
const expectedIds = new Set(prerequisites.map((prerequisite) => prerequisite.id));
const byId = new Map<string, NativeServicePrerequisiteOutcome>();
for (const outcome of outcomes) {
if (!expectedIds.has(outcome.prerequisiteId)) {
return { kind: "infrastructure-failure", message: `Authoritative probe returned unexpected outcome ${outcome.prerequisiteId}.` };
}
if (byId.has(outcome.prerequisiteId)) {
return { kind: "infrastructure-failure", message: `Authoritative probe returned duplicate outcome ${outcome.prerequisiteId}.` };
}
byId.set(outcome.prerequisiteId, outcome);
}
const missing = prerequisites.find((prerequisite) => !byId.has(prerequisite.id));
if (missing !== undefined) {
return { kind: "infrastructure-failure", message: `Authoritative probe returned no outcome for ${missing.id}.` };
}
return { kind: "completed", outcomes: byId };
}
function requiredStrategy(
strategies: ReadonlyMap<ProductionNativeServiceId, NativeServiceCommandStrategy>,
serviceId: ProductionNativeServiceId,
): NativeServiceCommandStrategy {
const strategy = strategies.get(serviceId);
if (strategy === undefined) throw new Error(`Missing executable strategy for ${serviceId}`);
return strategy;
}
function hasConfiguredCommand(command: string | undefined): command is string {
return command !== undefined && command.trim() !== "";
}
function commandRequirementId(serviceId: NativeServiceId, command: string): string {
return `${serviceId}.command.${command}`;
}
function commandRequirement(serviceId: NativeServiceId, command: string): NativeServicePrerequisite {
return {
id: commandRequirementId(serviceId, command),
kind: "command-available",
command,
description: `${command} is available to the service shell`,
};
}
function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite {
return {
id: `${serviceId}.node`,
kind: "node-version",
command: "node",
minimumMajor: 22,
description: "node >= 22 is available to the service shell",
};
}
function readableFileRequirement(serviceId: NativeServiceId, path: string): NativeServicePrerequisite {
return {
id: `${serviceId}.entrypoint`,
kind: "readable-file",
path,
description: `bundled entrypoint is readable: ${path}`,
};
}
function packageScriptsRequirement(
serviceId: NativeServiceId,
packageJsonPath: string,
scripts: readonly string[],
): NativeServicePrerequisite {
return {
id: `${serviceId}.package-scripts`,
kind: "package-scripts",
packageJsonPath,
scripts,
description: `package.json defines scripts: ${scripts.join(", ")}`,
};
}
function shellSingleQuote(shell: NativeServiceShellName, value: string): string {
if (shell === "fish") return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
return `'${value.replaceAll("'", "'\\''")}'`;
}
function copyEnvironment(environment: Readonly<Record<string, string>>): Readonly<Record<string, string>> {
return { ...environment };
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}