Files
HY2XS_flamy/orchestrator/src/lib/process.ts
T
founder 4e7f54b9ff fix(orchestrator): сделать staged firewall guard транзакционным
Снятие автоматического отката firewall было утверждением, а не фактом:

    systemctl stop <unit>.timer <unit>.service || true
    -> "firewall rollback timer disarmed"
    -> phase=installed

Отказ остановки стирался через `|| true`, и взведённый таймер мог вернуть
прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать `|| true`
нельзя: для транзиентного юнита, уже убранного systemd, `systemctl stop`
возвращает 5 — законный исход, неотличимый от успеха.

Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke.
Окно 45 секунд короче худшего случая smoke, а единственной проверкой firewall
был `nft -c` — разбор текущего файла, каким бы он ни был. Откатившийся прежний
ruleset проходил её зелёным, и сервер объявлялся настроенным с firewall,
который операция же и заменила.

Оба закрываются маркером /run/hy2xs/rollback/<op>/auto-rollback-fired, который
rollback-скрипт создаёт первым действием. Инвариант стал детерминированным:

    маркер отсутствует И timer/service inactive => можно фиксировать успех

Остальное в том же проходе:

- auto-rollback переехал из однострочного `sh -c` в сгенерированный скрипт.
  Прежний держался на склейке соседних кавычек и на том, что op-id не содержит
  пробелов; теперь ключ проверяется, а скрипт покрыт тестом и shell-парсером;
- скрипт накапливает rc и уходит в failed вместо молчаливого 0 при частичном
  восстановлении. Состояние nftables.service он сознательно не трогает:
  ExecStop у него делает `nft flush ruleset`;
- smoke сверяет ЭФФЕКТИВНЫЙ firewall: фрагмент на диске против отрендеренного,
  принадлежность entrypoint и загруженность таблицы inet hy2xs;
- откат восстанавливает enabled/active nftables.service — стадиями, идущими до
  применения ruleset;
- остановка guard'а в rollbackFirewallNow стала стадией с отчётом, а не вызовом
  с `|| true` внутри;
- `*.candidate` больше не остаются на диске навсегда;
- стадии восстановления reconfigure независимы по ОТДЕЛЬНОМУ ФАЙЛУ, а не по
  группе;
- ключ операции считается одной функцией: install писал в маркер сырой
  ISO-timestamp, и путь /run/hy2xs/rollback/<op_id> из runbook не существовал.
2026-08-30 22:54:57 +05:00

176 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Запуск подпроцессов.
*
* 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<string> {
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<string> {
return capture(renderCommand(command, args), true);
}
/**
* То же, но команда НЕ попадает в текст ошибки: аргументы несут секреты
* (machine token, пароль пира), а сообщение уходит в логи и диагностику.
*/
export async function runReadOnlySecret(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
return capture(renderCommand(command, args), false);
}
/** Мутация с захватом вывода (`mktemp -d`, `install -d`, ...). */
export async function runMutating(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
const rendered = renderCommand(command, args);
assertMutationAllowed(`runMutating(${rendered})`);
return capture(rendered, true);
}
/** Мутация с эхом команды в лог и прямым выводом подпроцесса. */
export async function runMutatingVisible(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
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<void> {
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-<op>.timer` возвращает 5 («unit not
* loaded»), если таймер уже отработал и был убран systemd, и это ровно то же
* наблюдаемое состояние, что и успешная остановка.
*
* Раньше такие места писались как `systemctl stop ... || true`. Разница
* принципиальная: `|| true` стирает ВСЕ коды возврата, включая настоящий отказ,
* и вызывающий не может отличить «юнита уже нет» от «остановить не удалось».
* Именно из-за этого снятие rollback guard рапортовало об успехе, ничего не
* сняв.
*
* Контракт раннера: он ничего не решает сам. Решение — «этот код возврата
* допустим» или «состояние надо доказать отдельно» — принимает вызывающий,
* на месте, где известен смысл команды.
*/
export async function runMutatingStatus(
command: TemplateStringsArray,
...args: unknown[]
): Promise<MutationResult> {
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<void> {
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})`);
}
}