mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 21:41:17 +00:00
162 lines
4.9 KiB
TypeScript
162 lines
4.9 KiB
TypeScript
/** Renders and parses systemd unit snippets for managed gateway services. */
|
|
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
|
import { splitArgsPreservingQuotes } from "./arg-split.js";
|
|
import type { GatewayServiceRenderArgs } from "./service-types.js";
|
|
|
|
const SYSTEMD_LINE_BREAKS = /[\r\n]/;
|
|
|
|
function assertNoSystemdLineBreaks(value: string, label: string): void {
|
|
if (SYSTEMD_LINE_BREAKS.test(value)) {
|
|
throw new Error(`${label} cannot contain CR or LF characters.`);
|
|
}
|
|
}
|
|
|
|
function systemdEscapeArg(value: string): string {
|
|
assertNoSystemdLineBreaks(value, "Systemd unit values");
|
|
if (!/[\s"\\]/.test(value)) {
|
|
return value;
|
|
}
|
|
// systemd ExecStart/Environment parsing honors backslash escapes inside
|
|
// quotes; match that contract for round-trip parser tests.
|
|
return `"${value.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"')}"`;
|
|
}
|
|
|
|
function renderEnvLines(env: Record<string, string | undefined> | undefined): string[] {
|
|
if (!env) {
|
|
return [];
|
|
}
|
|
const entries = Object.entries(env).filter(
|
|
([, value]) => typeof value === "string" && value.trim(),
|
|
);
|
|
if (entries.length === 0) {
|
|
return [];
|
|
}
|
|
return entries.map(([key, value]) => {
|
|
const rawValue = value ?? "";
|
|
assertNoSystemdLineBreaks(key, "Systemd environment variable names");
|
|
assertNoSystemdLineBreaks(rawValue, "Systemd environment variable values");
|
|
return `Environment=${systemdEscapeArg(`${key}=${rawValue.trim()}`)}`;
|
|
});
|
|
}
|
|
|
|
function renderEnvironmentFileLines(environmentFiles: string[] | undefined): string[] {
|
|
if (!environmentFiles) {
|
|
return [];
|
|
}
|
|
return normalizeStringEntries(environmentFiles).map((entry) => {
|
|
assertNoSystemdLineBreaks(entry, "Systemd EnvironmentFile values");
|
|
return `EnvironmentFile=-${systemdEscapeArg(entry)}`;
|
|
});
|
|
}
|
|
|
|
export function buildSystemdUnit({
|
|
description,
|
|
programArguments,
|
|
workingDirectory,
|
|
environment,
|
|
environmentFiles,
|
|
}: GatewayServiceRenderArgs): string {
|
|
const execStart = programArguments.map(systemdEscapeArg).join(" ");
|
|
const descriptionValue = description?.trim() || "OpenClaw Gateway";
|
|
assertNoSystemdLineBreaks(descriptionValue, "Systemd Description");
|
|
const descriptionLine = `Description=${descriptionValue}`;
|
|
const workingDirLine = workingDirectory
|
|
? `WorkingDirectory=${systemdEscapeArg(workingDirectory)}`
|
|
: null;
|
|
const envLines = renderEnvLines(environment);
|
|
const environmentFileLines = renderEnvironmentFileLines(environmentFiles);
|
|
return [
|
|
"[Unit]",
|
|
descriptionLine,
|
|
"After=network-online.target",
|
|
"Wants=network-online.target",
|
|
"StartLimitBurst=5",
|
|
"StartLimitIntervalSec=60",
|
|
"",
|
|
"[Service]",
|
|
`ExecStart=${execStart}`,
|
|
"Restart=always",
|
|
"RestartSec=5",
|
|
"RestartPreventExitStatus=78",
|
|
"TimeoutStopSec=30",
|
|
"TimeoutStartSec=30",
|
|
"SuccessExitStatus=0 143",
|
|
// Transient child processes may be selected by the OOM killer before the
|
|
// gateway. Keep the service running when that happens; the child surface is
|
|
// already responsible for reporting the failed command/session.
|
|
"OOMPolicy=continue",
|
|
// Keep service children in the same lifecycle so restarts do not leave
|
|
// orphan ACP/runtime workers behind.
|
|
"KillMode=control-group",
|
|
workingDirLine,
|
|
...environmentFileLines,
|
|
...envLines,
|
|
"",
|
|
"[Install]",
|
|
"WantedBy=default.target",
|
|
"",
|
|
]
|
|
.filter((line) => line !== null)
|
|
.join("\n");
|
|
}
|
|
|
|
export function parseSystemdExecStart(value: string): string[] {
|
|
return splitArgsPreservingQuotes(value, { escapeMode: "backslash" });
|
|
}
|
|
|
|
export function parseSystemdEnvAssignment(raw: string): { key: string; value: string } | null {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const unquoted = (() => {
|
|
const quote = trimmed[0];
|
|
if (!((quote === '"' || quote === "'") && trimmed.endsWith(quote))) {
|
|
return trimmed;
|
|
}
|
|
let out = "";
|
|
let escapeNext = false;
|
|
// systemd quote parsing consumes one backslash before the next character.
|
|
for (const ch of trimmed.slice(1, -1)) {
|
|
if (escapeNext) {
|
|
out += ch;
|
|
escapeNext = false;
|
|
continue;
|
|
}
|
|
if (ch === "\\\\") {
|
|
escapeNext = true;
|
|
continue;
|
|
}
|
|
out += ch;
|
|
}
|
|
return out;
|
|
})();
|
|
|
|
const eq = unquoted.indexOf("=");
|
|
if (eq <= 0) {
|
|
return null;
|
|
}
|
|
const key = unquoted.slice(0, eq).trim();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
const value = unquoted.slice(eq + 1);
|
|
return { key, value };
|
|
}
|
|
|
|
export function parseSystemdEnvAssignments(raw: string): Array<{ key: string; value: string }> {
|
|
return splitArgsPreservingQuotes(raw, {
|
|
escapeMode: "backslash",
|
|
quoteChars: ['"', "'"],
|
|
quoteStart: "item-start",
|
|
}).flatMap((entry) => {
|
|
const parsed = parseSystemdEnvAssignment(entry);
|
|
return parsed ? [parsed] : [];
|
|
});
|
|
}
|
|
|
|
export function renderSystemdEnvAssignment(key: string, value: string): string {
|
|
return systemdEscapeArg(`${key}=${value}`);
|
|
}
|