Archived
refactor(cli): define canonical native service plans
This commit is contained in:
@@ -0,0 +1,356 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createDevelopmentNativeServicePlan,
|
||||||
|
planValidationProbeRequests,
|
||||||
|
resolveProductionNativeServicePlan,
|
||||||
|
type NativeServiceAuthoritativeProbe,
|
||||||
|
type NativeServiceProbeRequest,
|
||||||
|
type NativeServiceProbeResult,
|
||||||
|
type ProductionNativeServicePlanInput,
|
||||||
|
} from "./servicePlan.js";
|
||||||
|
|
||||||
|
const backend = { kind: "systemd", label: "systemd user services" } as const;
|
||||||
|
const shell = {
|
||||||
|
name: "zsh",
|
||||||
|
executable: "/bin/zsh",
|
||||||
|
source: "detected",
|
||||||
|
detectedExecutable: "/bin/zsh",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function productionInput(): ProductionNativeServicePlanInput {
|
||||||
|
return {
|
||||||
|
backend,
|
||||||
|
shell,
|
||||||
|
environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" },
|
||||||
|
executables: {
|
||||||
|
sessiond: {
|
||||||
|
configuredCommand: undefined,
|
||||||
|
namedCommand: "pi-web-sessiond",
|
||||||
|
bundledEntrypointPath: "/package/dist/server/sessiond.js",
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
configuredCommand: undefined,
|
||||||
|
namedCommand: "pi-web-server",
|
||||||
|
bundledEntrypointPath: "/package/dist/server/index.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function completedProbe(status: "satisfied" | "unsatisfied", detail: string | null = null): NativeServiceAuthoritativeProbe {
|
||||||
|
return {
|
||||||
|
run: (request) => Promise.resolve({
|
||||||
|
kind: "completed",
|
||||||
|
outcomes: request.prerequisites.map((prerequisite) => ({ prerequisiteId: prerequisite.id, status, detail })),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("production native service planning", () => {
|
||||||
|
it("selects named commands from one authoritative backend probe and carries their exact requirements", async () => {
|
||||||
|
const requests: NativeServiceProbeRequest[] = [];
|
||||||
|
const probe: NativeServiceAuthoritativeProbe = {
|
||||||
|
run: (request) => {
|
||||||
|
requests.push(request);
|
||||||
|
return Promise.resolve({
|
||||||
|
kind: "completed",
|
||||||
|
outcomes: request.prerequisites.map((prerequisite) => ({
|
||||||
|
prerequisiteId: prerequisite.id,
|
||||||
|
status: "satisfied",
|
||||||
|
detail: "/usr/local/bin/example",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolution = await resolveProductionNativeServicePlan(productionInput(), {
|
||||||
|
probe,
|
||||||
|
fileExists: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{
|
||||||
|
purpose: "executable-selection",
|
||||||
|
backend,
|
||||||
|
shell,
|
||||||
|
environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" },
|
||||||
|
workingDirectory: null,
|
||||||
|
prerequisites: [
|
||||||
|
expect.objectContaining({ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" }),
|
||||||
|
expect.objectContaining({ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(resolution.ok).toBe(true);
|
||||||
|
if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures));
|
||||||
|
|
||||||
|
expect(resolution.plan).toMatchObject({
|
||||||
|
mode: "production",
|
||||||
|
backend,
|
||||||
|
shell,
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
id: "sessiond",
|
||||||
|
manager: { systemdName: "pi-web-sessiond.service", launchdLabel: "com.pi-web.sessiond" },
|
||||||
|
shellCommand: "exec pi-web-sessiond",
|
||||||
|
strategy: { kind: "named-command", command: "pi-web-sessiond", selectedBy: "authoritative-backend-probe" },
|
||||||
|
environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" },
|
||||||
|
workingDirectory: null,
|
||||||
|
after: [],
|
||||||
|
wants: [],
|
||||||
|
prerequisites: [
|
||||||
|
{ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" },
|
||||||
|
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "web",
|
||||||
|
shellCommand: "exec pi-web-server",
|
||||||
|
strategy: { kind: "named-command", command: "pi-web-server" },
|
||||||
|
after: ["sessiond"],
|
||||||
|
wants: ["sessiond"],
|
||||||
|
prerequisites: [
|
||||||
|
{ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" },
|
||||||
|
{ id: "web.node", kind: "node-version", command: "node", minimumMajor: 22 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(planValidationProbeRequests(resolution.plan)).toMatchObject([
|
||||||
|
{
|
||||||
|
purpose: "plan-validation",
|
||||||
|
backend,
|
||||||
|
shell,
|
||||||
|
workingDirectory: null,
|
||||||
|
prerequisites: [{ id: "sessiond.command.pi-web-sessiond" }, { id: "sessiond.node" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
purpose: "plan-validation",
|
||||||
|
backend,
|
||||||
|
shell,
|
||||||
|
workingDirectory: null,
|
||||||
|
prerequisites: [{ id: "web.command.pi-web-server" }, { id: "web.node" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves configured overrides verbatim and never probes or executes them", async () => {
|
||||||
|
const input = productionInput();
|
||||||
|
input.executables.sessiond.configuredCommand = " /opt/pi web/run-sessiond --flag ";
|
||||||
|
input.executables.web.configuredCommand = "custom-web --serve";
|
||||||
|
const run = vi.fn<(request: NativeServiceProbeRequest) => Promise<NativeServiceProbeResult>>();
|
||||||
|
const fileExists = vi.fn<(path: string) => boolean>();
|
||||||
|
|
||||||
|
const resolution = await resolveProductionNativeServicePlan(input, { probe: { run }, fileExists });
|
||||||
|
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
expect(fileExists).not.toHaveBeenCalled();
|
||||||
|
expect(resolution.ok).toBe(true);
|
||||||
|
if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures));
|
||||||
|
expect(resolution.plan.services).toMatchObject([
|
||||||
|
{
|
||||||
|
id: "sessiond",
|
||||||
|
shellCommand: "exec /opt/pi web/run-sessiond --flag ",
|
||||||
|
strategy: {
|
||||||
|
kind: "configured-override",
|
||||||
|
command: " /opt/pi web/run-sessiond --flag ",
|
||||||
|
verification: "unverified",
|
||||||
|
},
|
||||||
|
prerequisites: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "web",
|
||||||
|
shellCommand: "exec custom-web --serve",
|
||||||
|
strategy: { kind: "configured-override", command: "custom-web --serve", verification: "unverified" },
|
||||||
|
prerequisites: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(planValidationProbeRequests(resolution.plan)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back per service to bundled entrypoints when named commands are unavailable", async () => {
|
||||||
|
const input = productionInput();
|
||||||
|
input.executables.sessiond.bundledEntrypointPath = "/package with space/sessiond's entry.js";
|
||||||
|
const fileExists = vi.fn<(path: string) => boolean>(() => true);
|
||||||
|
|
||||||
|
const resolution = await resolveProductionNativeServicePlan(input, {
|
||||||
|
probe: completedProbe("unsatisfied", "command not found"),
|
||||||
|
fileExists,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fileExists).toHaveBeenCalledTimes(2);
|
||||||
|
expect(resolution.ok).toBe(true);
|
||||||
|
if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures));
|
||||||
|
expect(resolution.plan.services[0]).toMatchObject({
|
||||||
|
shellCommand: "exec node '/package with space/sessiond'\\''s entry.js'",
|
||||||
|
strategy: {
|
||||||
|
kind: "bundled-entrypoint",
|
||||||
|
command: "node",
|
||||||
|
namedCommand: "pi-web-sessiond",
|
||||||
|
namedCommandFailure: "command not found",
|
||||||
|
},
|
||||||
|
prerequisites: [
|
||||||
|
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
|
||||||
|
{ id: "sessiond.entrypoint", kind: "readable-file", path: "/package with space/sessiond's entry.js" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixes configured, named, and bundled decisions without unrelated checks", async () => {
|
||||||
|
const input = productionInput();
|
||||||
|
input.executables.sessiond.configuredCommand = "custom-sessiond";
|
||||||
|
const requests: NativeServiceProbeRequest[] = [];
|
||||||
|
|
||||||
|
const resolution = await resolveProductionNativeServicePlan(input, {
|
||||||
|
probe: {
|
||||||
|
run: (request) => {
|
||||||
|
requests.push(request);
|
||||||
|
return Promise.resolve({
|
||||||
|
kind: "completed",
|
||||||
|
outcomes: [{ prerequisiteId: "web.command.pi-web-server", status: "unsatisfied", detail: null }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fileExists: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requests[0]?.prerequisites).toMatchObject([{ id: "web.command.pi-web-server", command: "pi-web-server" }]);
|
||||||
|
expect(resolution.ok).toBe(true);
|
||||||
|
if (resolution.ok) {
|
||||||
|
expect(resolution.plan.services.map((service) => service.strategy.kind)).toEqual(["configured-override", "bundled-entrypoint"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns structured failures when neither production executable strategy is viable", async () => {
|
||||||
|
const resolution = await resolveProductionNativeServicePlan(productionInput(), {
|
||||||
|
probe: completedProbe("unsatisfied", "not found in service PATH"),
|
||||||
|
fileExists: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolution).toEqual({
|
||||||
|
ok: false,
|
||||||
|
failures: [
|
||||||
|
{
|
||||||
|
kind: "executable-unavailable",
|
||||||
|
serviceId: "sessiond",
|
||||||
|
namedCommand: "pi-web-sessiond",
|
||||||
|
namedCommandFailure: "not found in service PATH",
|
||||||
|
bundledEntrypointPath: "/package/dist/server/sessiond.js",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "executable-unavailable",
|
||||||
|
serviceId: "web",
|
||||||
|
namedCommand: "pi-web-server",
|
||||||
|
namedCommandFailure: "not found in service PATH",
|
||||||
|
bundledEntrypointPath: "/package/dist/server/index.js",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reinterpret probe infrastructure failures as missing commands", async () => {
|
||||||
|
const fileExists = vi.fn<(path: string) => boolean>(() => true);
|
||||||
|
const resolution = await resolveProductionNativeServicePlan(productionInput(), {
|
||||||
|
probe: {
|
||||||
|
run: () => Promise.resolve({ kind: "infrastructure-failure", message: "launchd probe cleanup failed" }),
|
||||||
|
},
|
||||||
|
fileExists,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fileExists).not.toHaveBeenCalled();
|
||||||
|
expect(resolution).toEqual({
|
||||||
|
ok: false,
|
||||||
|
failures: [{
|
||||||
|
kind: "probe-infrastructure",
|
||||||
|
serviceIds: ["sessiond", "web"],
|
||||||
|
message: "launchd probe cleanup failed",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats thrown and malformed probe results as infrastructure failures", async () => {
|
||||||
|
const thrown = await resolveProductionNativeServicePlan(productionInput(), {
|
||||||
|
probe: { run: () => Promise.reject(new Error("systemd-run failed")) },
|
||||||
|
fileExists: () => true,
|
||||||
|
});
|
||||||
|
expect(thrown).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
failures: [{ kind: "probe-infrastructure", message: "systemd-run failed" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const malformed = await resolveProductionNativeServicePlan(productionInput(), {
|
||||||
|
probe: {
|
||||||
|
run: () => Promise.resolve({
|
||||||
|
kind: "completed",
|
||||||
|
outcomes: [{ prerequisiteId: "sessiond.command.pi-web-sessiond", status: "satisfied", detail: null }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
fileExists: () => true,
|
||||||
|
});
|
||||||
|
expect(malformed).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
failures: [{ kind: "probe-infrastructure", message: "Authoritative probe returned no outcome for web.command.pi-web-server." }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("development native service planning", () => {
|
||||||
|
it("plans only the exact checkout commands and prerequisites", () => {
|
||||||
|
const plan = createDevelopmentNativeServicePlan({
|
||||||
|
backend: { kind: "launchd", label: "LaunchAgents" },
|
||||||
|
shell: { name: "fish", executable: "/opt/homebrew/bin/fish", source: "detected", detectedExecutable: "/opt/homebrew/bin/fish" },
|
||||||
|
environment: { PI_WEB_CONFIG: "/tmp/config.json" },
|
||||||
|
workingDirectory: "/checkout with space",
|
||||||
|
packageJsonPath: "/checkout with space/package.json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan).toMatchObject({
|
||||||
|
mode: "development",
|
||||||
|
backend: { kind: "launchd" },
|
||||||
|
shell: { name: "fish", executable: "/opt/homebrew/bin/fish" },
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
id: "sessiond",
|
||||||
|
shellCommand: "exec npm run start:sessiond",
|
||||||
|
strategy: { kind: "development-npm-script", script: "start:sessiond" },
|
||||||
|
restart: "never",
|
||||||
|
environment: { PI_WEB_CONFIG: "/tmp/config.json" },
|
||||||
|
workingDirectory: "/checkout with space",
|
||||||
|
prerequisites: [
|
||||||
|
{ id: "sessiond.node", kind: "node-version", minimumMajor: 22 },
|
||||||
|
{ id: "sessiond.command.npm", kind: "command-available", command: "npm" },
|
||||||
|
{ id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uiDev",
|
||||||
|
shellCommand: "exec /usr/bin/env bash -c 'trap \"kill 0\" EXIT; npm run dev:web & npm run dev:client & wait'",
|
||||||
|
strategy: { kind: "development-npm-script-group", scripts: ["dev:web", "dev:client"], interpreter: "bash" },
|
||||||
|
restart: "never",
|
||||||
|
workingDirectory: "/checkout with space",
|
||||||
|
after: ["sessiond"],
|
||||||
|
wants: ["sessiond"],
|
||||||
|
prerequisites: [
|
||||||
|
{ id: "uiDev.node", kind: "node-version", minimumMajor: 22 },
|
||||||
|
{ id: "uiDev.command.npm", kind: "command-available", command: "npm" },
|
||||||
|
{ id: "uiDev.command.bash", kind: "command-available", command: "bash" },
|
||||||
|
{ id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const serviceCommandRequirements = plan.services.flatMap((service) => service.prerequisites)
|
||||||
|
.filter((prerequisite) => prerequisite.kind === "command-available")
|
||||||
|
.map((prerequisite) => prerequisite.command);
|
||||||
|
expect(serviceCommandRequirements).toEqual(["npm", "npm", "bash"]);
|
||||||
|
expect(serviceCommandRequirements).not.toContain("pi-web-server");
|
||||||
|
expect(serviceCommandRequirements).not.toContain("pi-web-sessiond");
|
||||||
|
|
||||||
|
expect(planValidationProbeRequests(plan)).toMatchObject([
|
||||||
|
{ backend: { kind: "launchd" }, workingDirectory: "/checkout with space" },
|
||||||
|
{ backend: { kind: "launchd" }, workingDirectory: "/checkout with space" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user