/** * Запуск подпроцессов. * * API намеренно разделён по namespace'у: read-only и мутирующий. Это не * стилистика, а часть контракта PHASE 0. * * Guard из lib/guard умеет останавливать только то, что через него проходит. * Пока существовал один универсальный раннер, под которым одинаково жили * "ss -ltn" и "useradd", инвариант «PHASE 0 ничего не пишет» держался * исключительно на внимательности автора правки: любой новый вызов с * мутирующей командой обходил guard молча. * * Поэтому здесь нет универсального раннера. Есть два набора: * * runReadOnly / runReadOnlySecret * наблюдение за системой. Guard не трогает — они разрешены в любой фазе. * * runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw / * runMutatingStatus * всё, что может изменить хост. Каждый спрашивает разрешения у guard'а. * * Выбор набора — сознательное решение на месте вызова, а не умолчание. */ import { assertMutationAllowed } from "./guard"; import { info } from "./log"; function shellQuote(value: unknown): string { const text = String(value); if (text.length === 0) { return "''"; } return `'${text.replaceAll("'", "'\\''")}'`; } function renderCommand(strings: TemplateStringsArray, values: unknown[]): string { let command = ""; for (let i = 0; i < strings.length; i += 1) { command += strings[i]; if (i < values.length) { command += shellQuote(values[i]); } } return command; } async function capture(rendered: string, includeCommandInError: boolean): Promise { const process = Bun.spawn(["sh", "-c", rendered], { stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(process.stdout).text(), new Response(process.stderr).text(), process.exited ]); if (exitCode !== 0) { throw new Error( includeCommandInError ? `command failed (${exitCode}): ${rendered}\n${stderr.trim()}` : `command failed (${exitCode}): ${stderr.trim()}` ); } return stdout.trim(); } /** * Наблюдение за системой: вывод возвращается вызывающему, хост не меняется. * Команда попадает в текст ошибки — она не содержит секретов по построению. */ export async function runReadOnly(command: TemplateStringsArray, ...args: unknown[]): Promise { return capture(renderCommand(command, args), true); } /** * То же, но команда НЕ попадает в текст ошибки: аргументы несут секреты * (machine token, пароль пира), а сообщение уходит в логи и диагностику. */ export async function runReadOnlySecret(command: TemplateStringsArray, ...args: unknown[]): Promise { return capture(renderCommand(command, args), false); } /** Мутация с захватом вывода (`mktemp -d`, `install -d`, ...). */ export async function runMutating(command: TemplateStringsArray, ...args: unknown[]): Promise { const rendered = renderCommand(command, args); assertMutationAllowed(`runMutating(${rendered})`); return capture(rendered, true); } /** Мутация с эхом команды в лог и прямым выводом подпроцесса. */ export async function runMutatingVisible(command: TemplateStringsArray, ...args: unknown[]): Promise { const rendered = renderCommand(command, args); assertMutationAllowed(`runMutatingVisible(${rendered})`); info(`running: ${rendered}`); const process = Bun.spawn(["sh", "-c", rendered], { stdout: "inherit", stderr: "inherit" }); const exitCode = await process.exited; if (exitCode !== 0) { throw new Error(`command failed (${exitCode}): ${rendered}`); } } /** Мутация многострочным скриптом (`sh -eu -c`). */ export async function runMutatingRaw(command: string): Promise { assertMutationAllowed("runMutatingRaw(...)"); info(`running script:\n${command}`); const process = Bun.spawn(["sh", "-eu", "-c", command], { stdout: "inherit", stderr: "inherit" }); const exitCode = await process.exited; if (exitCode !== 0) { throw new Error(`script failed (${exitCode})`); } } export type MutationResult = { exitCode: number; stdout: string; stderr: string; }; /** * Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска. * * Существует ровно ради одного класса команд: тех, у которых ненулевой код — * законный исход, а не отказ. Канонический пример — остановка транзиентного * юнита: `systemctl stop hy2xs-fw-rollback-.timer` возвращает 5 («unit not * loaded»), если таймер уже отработал и был убран systemd, и это ровно то же * наблюдаемое состояние, что и успешная остановка. * * Раньше такие места писались как `systemctl stop ... || true`. Разница * принципиальная: `|| true` стирает ВСЕ коды возврата, включая настоящий отказ, * и вызывающий не может отличить «юнита уже нет» от «остановить не удалось». * Именно из-за этого снятие rollback guard рапортовало об успехе, ничего не * сняв. * * Контракт раннера: он ничего не решает сам. Решение — «этот код возврата * допустим» или «состояние надо доказать отдельно» — принимает вызывающий, * на месте, где известен смысл команды. */ export async function runMutatingStatus( command: TemplateStringsArray, ...args: unknown[] ): Promise { const rendered = renderCommand(command, args); assertMutationAllowed(`runMutatingStatus(${rendered})`); info(`running: ${rendered}`); const process = Bun.spawn(["sh", "-c", rendered], { stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(process.stdout).text(), new Response(process.stderr).text(), process.exited ]); return { exitCode, stdout, stderr }; } /** Мутация без эха команды: аргументы могут содержать секреты. */ export async function runMutatingHidden(command: TemplateStringsArray, ...args: unknown[]): Promise { const rendered = renderCommand(command, args); assertMutationAllowed(`runMutatingHidden(${rendered})`); const process = Bun.spawn(["sh", "-c", rendered], { stdout: "inherit", stderr: "inherit" }); const exitCode = await process.exited; if (exitCode !== 0) { throw new Error(`command failed (${exitCode})`); } }