Archived
484 lines
19 KiB
TypeScript
484 lines
19 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
LaunchdNativeServiceProbe,
|
|
SpawnProbeCommandRunner,
|
|
SystemdNativeServiceProbe,
|
|
launchdProbePlist,
|
|
nativeServicePrerequisiteShellCheck,
|
|
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",
|
|
"--property=RuntimeMaxSec=15s",
|
|
"--property=TimeoutStopSec=5s",
|
|
"--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 did not resolve to an external executable 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, cleans it up, and reports the timeout", async () => {
|
|
const runner = queuedRunner([
|
|
{ kind: "timeout", stdout: "", stderr: "" },
|
|
completed(0),
|
|
completed(0, "not-found\n"),
|
|
]);
|
|
const probe = new SystemdNativeServiceProbe({
|
|
commandRunner: runner,
|
|
createUniqueId: () => "fixed",
|
|
commandTimeoutMs: 100,
|
|
});
|
|
|
|
await expect(probe.run(request())).resolves.toMatchObject({
|
|
kind: "infrastructure-failure",
|
|
reason: "timeout",
|
|
});
|
|
expect(runner.calls.map(({ command }) => command)).toEqual(["systemd-run", "systemctl", "systemctl"]);
|
|
});
|
|
|
|
it("bounds a hung unit and distinguishes cleanup failure", async () => {
|
|
const runner = queuedRunner([
|
|
{ kind: "timeout", stdout: "", stderr: "" },
|
|
completed(0),
|
|
completed(0, "loaded\n", "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", "show", "pi-web-authoritative-probe-fixed.service"],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("spawn probe command runner", () => {
|
|
it("bounds captured command output", async () => {
|
|
const runner = new SpawnProbeCommandRunner();
|
|
const result = await runner.run(
|
|
process.execPath,
|
|
["-e", "process.stdout.write('x'.repeat(2 * 1024 * 1024))"],
|
|
5_000,
|
|
);
|
|
|
|
expect(result).toMatchObject({ kind: "output-limit" });
|
|
expect(result.stdout.length).toBeLessThanOrEqual(1024 * 1024);
|
|
});
|
|
|
|
it("settles a timeout without waiting for inherited pipes to close", async () => {
|
|
const runner = new SpawnProbeCommandRunner();
|
|
const childScript = [
|
|
"const { spawn } = require('node:child_process');",
|
|
"const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { stdio: ['ignore', 'inherit', 'inherit'] });",
|
|
"child.unref();",
|
|
].join(" ");
|
|
const startedAt = performance.now();
|
|
|
|
await expect(runner.run(process.execPath, ["-e", childScript], 20)).resolves.toMatchObject({ kind: "timeout" });
|
|
expect(performance.now() - startedAt).toBeLessThan(500);
|
|
});
|
|
});
|
|
|
|
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)]);
|
|
const fileSystem = launchdFileSystem({
|
|
"/tmp/probe/result.log": marker("sessiond.command.npm", "satisfied"),
|
|
});
|
|
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", "bootout", "gui/501/com.pi-web.authoritative-probe.501.fixed"],
|
|
]);
|
|
expect(fileSystem.writeFile).toHaveBeenCalledWith(
|
|
"/tmp/probe/probe.plist",
|
|
expect.stringContaining("<string>/bin/zsh</string>"),
|
|
0o600,
|
|
);
|
|
expect(fileSystem.writeFile).toHaveBeenCalledWith(
|
|
"/tmp/probe/probe.plist",
|
|
expect.stringContaining("<key>WorkingDirectory</key>\n <string>/checkout with space</string>"),
|
|
0o600,
|
|
);
|
|
expect(fileSystem.writeFile).toHaveBeenCalledWith(
|
|
"/tmp/probe/probe.plist",
|
|
expect.stringContaining("/bin/mv '/tmp/probe/result.pending' '/tmp/probe/result.log'"),
|
|
0o600,
|
|
);
|
|
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)]);
|
|
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("bounds a stalled result-file read before cleaning up", async () => {
|
|
const runner = queuedRunner([completed(0), completed(0)]);
|
|
const fileSystem = launchdFileSystem({});
|
|
fileSystem.readOptionalFile.mockReturnValueOnce(new Promise(() => undefined));
|
|
const probe = new LaunchdNativeServiceProbe({
|
|
commandRunner: runner,
|
|
fileSystem,
|
|
uid: 502,
|
|
createUniqueId: () => "fixed",
|
|
now: () => 0,
|
|
sleep: () => Promise.resolve(),
|
|
probeTimeoutMs: 10,
|
|
pollIntervalMs: 1,
|
|
commandTimeoutMs: 100,
|
|
});
|
|
|
|
await expect(probe.run(request("launchd"))).resolves.toMatchObject({
|
|
kind: "infrastructure-failure",
|
|
reason: "timeout",
|
|
});
|
|
expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]);
|
|
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
|
|
});
|
|
|
|
it("surfaces cleanup failure instead of returning an otherwise successful probe", async () => {
|
|
const runner = queuedRunner([completed(0), completed(1, "", "bootout denied")]);
|
|
const fileSystem = launchdFileSystem({
|
|
"/tmp/probe/result.log": marker("sessiond.command.npm", "satisfied"),
|
|
});
|
|
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("boots out a label when bootstrap itself times out", async () => {
|
|
const runner = queuedRunner([
|
|
{ kind: "timeout", stdout: "", stderr: "" },
|
|
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", "bootout"]);
|
|
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
|
|
});
|
|
|
|
it("treats an explicit not-loaded bootout response as successful cleanup after bootstrap fails", async () => {
|
|
const runner = queuedRunner([
|
|
completed(1, "", "bootstrap denied"),
|
|
completed(3, "", "Could not find service in domain"),
|
|
]);
|
|
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.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]);
|
|
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
|
|
});
|
|
|
|
it("cleans a loaded label after malformed private result output", async () => {
|
|
const runner = queuedRunner([completed(0), completed(0)]);
|
|
const fileSystem = launchdFileSystem({ "/tmp/probe/result.log": "malformed result" });
|
|
const probe = new LaunchdNativeServiceProbe({
|
|
commandRunner: runner,
|
|
fileSystem,
|
|
uid: 505,
|
|
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: "malformed-output",
|
|
});
|
|
expect(runner.calls.at(-1)?.args[0]).toBe("bootout");
|
|
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
|
|
});
|
|
|
|
it("cleans a loaded label when the private result cannot be read", async () => {
|
|
const runner = queuedRunner([completed(0), completed(0)]);
|
|
const fileSystem = launchdFileSystem({});
|
|
fileSystem.readOptionalFile.mockRejectedValueOnce(new Error("read denied"));
|
|
const probe = new LaunchdNativeServiceProbe({
|
|
commandRunner: runner,
|
|
fileSystem,
|
|
uid: 506,
|
|
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("Could not read launchd probe result");
|
|
expect(runner.calls.at(-1)?.args[0]).toBe("bootout");
|
|
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
|
|
});
|
|
|
|
it("reports temporary-file cleanup failures", async () => {
|
|
const runner = queuedRunner([
|
|
completed(1, "", "bootstrap denied"),
|
|
completed(3, "", "Could not find service in domain"),
|
|
]);
|
|
const fileSystem = launchdFileSystem({});
|
|
fileSystem.removeDirectory.mockRejectedValueOnce(new Error("rm denied"));
|
|
const probe = new LaunchdNativeServiceProbe({
|
|
commandRunner: runner,
|
|
fileSystem,
|
|
uid: 506,
|
|
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("rm denied");
|
|
});
|
|
});
|
|
|
|
describe("probe service definitions", () => {
|
|
it("requires external executables instead of accepting shell functions or aliases", () => {
|
|
const commandRequirement = request().prerequisites[0];
|
|
if (commandRequirement === undefined) throw new Error("Expected a command prerequisite");
|
|
const bashCheck = nativeServicePrerequisiteShellCheck("bash", commandRequirement);
|
|
expect(bashCheck).toContain("case \"$pi_web_probe_executable\" in */*)");
|
|
expect(bashCheck).toContain("test -f \"$pi_web_probe_executable\"");
|
|
expect(bashCheck).toContain("test -x \"$pi_web_probe_executable\"");
|
|
|
|
const fishCheck = nativeServicePrerequisiteShellCheck("fish", commandRequirement);
|
|
expect(fishCheck).toContain("string match -q '*/*'");
|
|
expect(fishCheck).toContain("test -f $pi_web_probe_executable[1]");
|
|
expect(fishCheck).toContain("test -x $pi_web_probe_executable[1]");
|
|
});
|
|
|
|
it("invokes the resolved external Node executable for version checks", () => {
|
|
const check = nativeServicePrerequisiteShellCheck("zsh", {
|
|
id: "sessiond.node",
|
|
kind: "node-version",
|
|
command: "node",
|
|
minimumMajor: 22,
|
|
description: "node >= 22",
|
|
});
|
|
expect(check).toContain("\"$pi_web_probe_executable\" '-e'");
|
|
expect(check).not.toContain("&& node -e");
|
|
});
|
|
|
|
it("requires bundled entrypoints to be readable regular files", () => {
|
|
const check = nativeServicePrerequisiteShellCheck("bash", {
|
|
id: "sessiond.entrypoint",
|
|
kind: "readable-file",
|
|
path: "/package/server.js",
|
|
description: "entrypoint",
|
|
});
|
|
expect(check).toBe("test -f '/package/server.js' && test -r '/package/server.js'");
|
|
});
|
|
|
|
it("renders backend inputs without inheriting the caller PATH", () => {
|
|
const probeRequest = request();
|
|
const systemdArguments = systemdRunArguments(probeRequest, "probe.service", "echo ok");
|
|
expect(systemdArguments.some((argument) => argument.includes("PATH="))).toBe(false);
|
|
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>");
|
|
expect(plist).toContain("<key>HardResourceLimits</key>");
|
|
});
|
|
|
|
it("escapes manager-side substitutions in the systemd probe payload", () => {
|
|
const args = systemdRunArguments(request(), "probe.service", "test -r '/tmp/$HOME/%h'");
|
|
expect(args.at(-1)).toBe("test -r '/tmp/$$HOME/%h'");
|
|
});
|
|
});
|
|
|
|
function launchdFileSystem(contents: Record<string, string>): LaunchdProbeFileSystem & {
|
|
writeFile: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["writeFile"]>>;
|
|
readOptionalFile: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["readOptionalFile"]>>;
|
|
removeDirectory: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["removeDirectory"]>>;
|
|
} {
|
|
const writeFileMock = vi.fn<LaunchdProbeFileSystem["writeFile"]>(() => Promise.resolve());
|
|
const readOptionalFileMock = vi.fn<LaunchdProbeFileSystem["readOptionalFile"]>((path) => Promise.resolve(contents[path] ?? null));
|
|
const removeDirectoryMock = vi.fn<LaunchdProbeFileSystem["removeDirectory"]>(() => Promise.resolve());
|
|
return {
|
|
createTemporaryDirectory: () => Promise.resolve("/tmp/probe"),
|
|
writeFile: writeFileMock,
|
|
readOptionalFile: readOptionalFileMock,
|
|
removeDirectory: removeDirectoryMock,
|
|
};
|
|
}
|