4e7f54b9ff
Снятие автоматического отката 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 не существовал.
176 lines
7.5 KiB
TypeScript
176 lines
7.5 KiB
TypeScript
/**
|
||
* Запуск подпроцессов.
|
||
*
|
||
* 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})`);
|
||
}
|
||
}
|