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 не существовал.
This commit is contained in:
2026-08-30 22:54:57 +05:00
parent 330a63b050
commit 4e7f54b9ff
9 changed files with 1127 additions and 87 deletions
+46 -1
View File
@@ -15,7 +15,8 @@
* runReadOnly / runReadOnlySecret
* наблюдение за системой. Guard не трогает — они разрешены в любой фазе.
*
* runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw
* runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw /
* runMutatingStatus
* всё, что может изменить хост. Каждый спрашивает разрешения у guard'а.
*
* Выбор набора — сознательное решение на месте вызова, а не умолчание.
@@ -115,6 +116,50 @@ export async function runMutatingRaw(command: string): Promise<void> {
}
}
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);