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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user