firewall guard: барьер покоя fail-closed и явный контракт транзиентного таймера
Барьер, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, в трёх местах принимал за доказательство отсутствие наблюдения. - отказ `systemctl` больше не выдаётся за отсутствие guard: вместо `return []` введён единый наблюдатель inspectRollbackGuard с исходами quiescent/pending/ unknown и отдельным типом отказа GuardStateUnknownError; - покой перечисляется белым списком (inactive, failed): maintenance, refreshing и любое незнакомое состояние systemd блокируют операцию; - у транзиентного таймера явно заданы AccuracySec=1s (умолчание 1min превращало обещанные 45 секунд в 45-105) и RemainAfterElapse=no; барьер дополнительно опознаёт SubState=elapsed у *.timer как покой; - команда взведения строится чистой buildArmGuardArgv и выполняется новым runMutatingArgv без shell, поэтому её контракт проверяется значением, а не грепом по исходнику; - status перестал листить guard-юниты своей копией кода: без --plain, с `|| true` и с трактовкой failed как «вооружён» отчёт вечно противоречил барьеру. Добавлены rollback_guard_state и firewall_state=guard_unknown; - purge-v0.sh пропускал failed-юниты из-за маркера в первой колонке. Барьер покрыт поведенческими тестами через подставляемый SystemdUnitProbe: прежние проверки грепом по тексту функции пережили инверсию смысла - строка `return [];` была на месте, а решение стало неверным. Документация (README, docs/07, 11, 12, 13, 14, CHANGELOG) приведена к реальному окну 45-46 секунд и к новому тексту отказа. Отдельно исправлен комментарий PNPM_AUDIT_LEVEL в versions.env: гейт давно проверяет весь lock-граф.
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import type { RuntimeContext } from "../types/context";
|
||||
import { fileExists, readText, renderTemplate, writeText } from "../lib/fs";
|
||||
import { fail, info } from "../lib/log";
|
||||
import { runMutatingStatus, runMutatingVisible, runReadOnly } from "../lib/process";
|
||||
import {
|
||||
runMutatingArgv,
|
||||
runMutatingStatus,
|
||||
runMutatingVisible,
|
||||
runReadOnly,
|
||||
runReadOnlyArgv
|
||||
} from "../lib/process";
|
||||
import { runRollbackStages, type RollbackStage } from "../lib/rollback";
|
||||
|
||||
type NftEntrypointKind =
|
||||
@@ -29,6 +35,41 @@ const HY2XS_NFT_CANDIDATE = `${HY2XS_NFT_PATH}.candidate`;
|
||||
*/
|
||||
const FIREWALL_ROLLBACK_DEADLINE = "45s";
|
||||
|
||||
/**
|
||||
* Точность транзиентного таймера guard'а.
|
||||
*
|
||||
* `OnActiveSec=` НЕ означает «ровно через столько». `systemd.timer` разрешает
|
||||
* себе сработать в окне `[цель; цель + AccuracySec]`, объединяя пробуждения
|
||||
* ради экономии энергии, и умолчание этого параметра — `1min`. То есть guard,
|
||||
* объявленный как «45 секунд», без явного значения имел контракт «от 45 до 105
|
||||
* секунд», а README, docs и текст отказа обещали первое число.
|
||||
*
|
||||
* Для аварийного guard'а коалесценция пробуждений не нужна: он взводится один
|
||||
* раз за операцию и почти всегда снимается, не сработав. `1s` возвращает
|
||||
* обещанному окну смысл — реальный интервал становится 45–46 секунд — и остаётся
|
||||
* достаточно грубым, чтобы не будить ядро ради миллисекунд.
|
||||
*/
|
||||
const FIREWALL_ROLLBACK_ACCURACY = "1s";
|
||||
|
||||
/**
|
||||
* Транзиентный таймер обязан исчезнуть, отработав.
|
||||
*
|
||||
* `systemd-run` выставляет `RemainAfterElapse=false` сам — это его умолчание для
|
||||
* транзиентных таймеров, и на systemd 257 (Debian 13) оно действует. Значение
|
||||
* повторяется здесь ЯВНО не из недоверия к systemd, а потому что от него зависит
|
||||
* чужой инвариант: барьер покоя считает отсутствие юнита доказательством того,
|
||||
* что откатывать firewall больше некому.
|
||||
*
|
||||
* Инвариант, который держится на чужом умолчании, не записан нигде и не
|
||||
* проверяется ничем. С явным свойством он становится частью команды, которую
|
||||
* видно в journal и которую проверяет тест.
|
||||
*
|
||||
* Второе плечо той же защиты — в барьере: отработавший таймер опознаётся ещё и
|
||||
* по `SubState=elapsed`, поэтому даже таймер, созданный не нами, не блокирует
|
||||
* `repair` навсегда.
|
||||
*/
|
||||
const FIREWALL_ROLLBACK_REMAIN_AFTER_ELAPSE = "no";
|
||||
|
||||
/**
|
||||
* Маркер факта: автоматический откат firewall НАЧАЛ выполняться.
|
||||
*
|
||||
@@ -74,14 +115,57 @@ export class FirewallGuardFiredError extends Error {
|
||||
const ROLLBACK_UNIT_PREFIX = "hy2xs-fw-rollback-";
|
||||
|
||||
/**
|
||||
* Состояния, в которых guard ещё СПОСОБЕН изменить систему.
|
||||
* Состояния, в которых guard заведомо БОЛЬШЕ НИЧЕГО не сделает.
|
||||
*
|
||||
* `failed` и `inactive` сюда не входят намеренно. Guard, который уже отработал
|
||||
* (успешно или нет), больше ничего не сделает, а отказавший юнит — это как раз
|
||||
* повод запустить `repair`. Барьер, отказывающий по `failed`, блокировал бы
|
||||
* ровно тот инструмент, которым чинят последствия.
|
||||
* Список именно такой — белый, а не чёрный, и это принципиально. Раньше
|
||||
* перечислялись непокойные состояния (`active`, `activating`, `deactivating`,
|
||||
* `reloading`), а покоем считалось «всё остальное». Такая формулировка
|
||||
* доказывает не то, что нужно: она объявляет безопасным любое состояние,
|
||||
* которого автор не перечислил, — включая те, которых он не знал. systemd 257
|
||||
* знает `maintenance` и `refreshing` помимо перечисленных, и завтра список
|
||||
* может пополниться снова.
|
||||
*
|
||||
* Перечислять же нужно ровно то, что мы УТВЕРЖДАЕМ: покой — это `inactive` и
|
||||
* `failed`. Отработавший guard, успешно или нет, систему больше не меняет, а
|
||||
* отказавший юнит — как раз повод запустить `repair`; барьер, отказывающий по
|
||||
* `failed`, блокировал бы инструмент, которым чинят последствия.
|
||||
*
|
||||
* Всё прочее — непокой, и это соответствует политике, уже применённой к замку
|
||||
* операций: сомнение трактуется в пользу отказа.
|
||||
*/
|
||||
const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const;
|
||||
const GUARD_QUIESCENT_ACTIVE_STATES = ["inactive", "failed"] as const;
|
||||
|
||||
/**
|
||||
* Отработавший таймер — покой, даже если он остался `active`.
|
||||
*
|
||||
* `ActiveState` таймера отвечает на вопрос «юнит загружен и в строю», а не «он
|
||||
* ещё может сработать»: в systemd `TIMER_ELAPSED` отображается в `UNIT_ACTIVE`
|
||||
* так же, как `TIMER_WAITING`. Различает их только `SubState`.
|
||||
*
|
||||
* У наших guard'ов этой ситуации не возникает — `RemainAfterElapse=no`
|
||||
* выгружает таймер сразу, — но инвариант «барьер не залипает» не должен
|
||||
* зависеть от того, чем именно создан таймер. Без этой ветки одноразовый
|
||||
* таймер, оставшийся в `elapsed`, запрещал бы install/reconfigure/repair/doctor
|
||||
* навсегда, пока оператор не остановит его руками, — и запрещал бы ради
|
||||
* отката, который уже произошёл.
|
||||
*
|
||||
* Ветка узкая намеренно: только `*.timer` и только состояния, из которых
|
||||
* следующего срабатывания не будет. Триггернутый сервис при этом виден
|
||||
* барьеру отдельным юнитом и остаётся непокоем, пока выполняется.
|
||||
*/
|
||||
const GUARD_QUIESCENT_TIMER_SUB_STATES = ["dead", "elapsed"] as const;
|
||||
|
||||
/** Свойства, по которым принимается решение о покое. */
|
||||
const GUARD_STATE_PROPERTIES = ["ActiveState", "SubState"] as const;
|
||||
|
||||
/**
|
||||
* Новую операцию начинать нельзя.
|
||||
*
|
||||
* База для двух разных причин отказа. Общий предок нужен потому, что вызывающий
|
||||
* иногда обязан отличать «эта операция ничего не испортила, ей просто нельзя
|
||||
* начинать» от собственных отказов, но не обязан различать конкретную причину.
|
||||
*/
|
||||
export class OperationBarrierError extends Error {}
|
||||
|
||||
/**
|
||||
* Предыдущая операция мертва, но её асинхронный исполнитель ещё жив.
|
||||
@@ -89,13 +173,32 @@ const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading
|
||||
* Отдельный тип, потому что это единственный отказ, который не про текущую
|
||||
* операцию: она не сделала ничего плохого, ей просто нельзя начинать.
|
||||
*/
|
||||
export class PendingRecoveryError extends Error {
|
||||
export class PendingRecoveryError extends OperationBarrierError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PendingRecoveryError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Покой недоказуем: systemd не ответил.
|
||||
*
|
||||
* Отдельный тип от `PendingRecoveryError`, потому что утверждения разные.
|
||||
* «Guard вооружён» — наблюдение. «Состояние guard'а неизвестно» — отсутствие
|
||||
* наблюдения, и оператору нужно другое действие: не подождать 45 секунд, а
|
||||
* разобраться с systemd.
|
||||
*
|
||||
* Отказ здесь ничего не стоит: systemd-run и так требуется в preflight, поэтому
|
||||
* без работающего systemd операция всё равно не пройдёт — просто позже и с
|
||||
* менее внятной диагностикой.
|
||||
*/
|
||||
export class GuardStateUnknownError extends OperationBarrierError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "GuardStateUnknownError";
|
||||
}
|
||||
}
|
||||
|
||||
function rollbackRoot(opId: string): string {
|
||||
return `/run/hy2xs/rollback/${opId}`;
|
||||
}
|
||||
@@ -201,8 +304,64 @@ export function parseNftablesServiceState(raw: string): NftablesServiceState | n
|
||||
return { unitFileState, activeState };
|
||||
}
|
||||
|
||||
async function readUnitProperty(unit: string, property: string): Promise<string> {
|
||||
return (await runReadOnly`systemctl show --property=${property} --value ${unit}`).trim();
|
||||
/**
|
||||
* Наблюдение за systemd, вынесенное в интерфейс.
|
||||
*
|
||||
* Не абстракция ради абстракции. Барьер покоя — единственное место продукта, где
|
||||
* ОТКАЗ наблюдения меняет решение, а не только его обоснование, и до сих пор это
|
||||
* поведение проверялось грепом по исходнику: тест утверждал, что в тексте
|
||||
* функции есть `return [];`. Такой тест закрепляет строку, а не свойство, и
|
||||
* ровно поэтому пережил инверсию смысла — строка была на месте, а решение стало
|
||||
* неверным.
|
||||
*
|
||||
* С подставляемым probe те же сценарии («systemd не ответил», «таймер взведён»,
|
||||
* «сервис упал») становятся обычными тестами на поведение, не требующими ни
|
||||
* systemd, ни Linux.
|
||||
*/
|
||||
export type SystemdUnitProbe = {
|
||||
/** Сырой вывод `systemctl list-units` по шаблонам guard-юнитов. */
|
||||
listGuardUnits(): Promise<string>;
|
||||
/** Значения свойств юнита; о непрочитанном свойстве возвращается пустая строка. */
|
||||
showProperties(unit: string, properties: readonly string[]): Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const defaultSystemdUnitProbe: SystemdUnitProbe = {
|
||||
async listGuardUnits(): Promise<string> {
|
||||
return await runReadOnly`systemctl list-units --all --plain --no-legend ${`${ROLLBACK_UNIT_PREFIX}*.timer`} ${`${ROLLBACK_UNIT_PREFIX}*.service`}`;
|
||||
},
|
||||
|
||||
async showProperties(unit: string, properties: readonly string[]): Promise<Record<string, string>> {
|
||||
// `--value` здесь не используется: при нескольких свойствах он печатает
|
||||
// значения без имён, и разбор начинает зависеть от порядка вывода. Формат
|
||||
// `Свойство=значение` самоописателен.
|
||||
const raw = await runReadOnlyArgv([
|
||||
"systemctl",
|
||||
"show",
|
||||
...properties.map((property) => `--property=${property}`),
|
||||
unit
|
||||
]);
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
for (const property of properties) {
|
||||
values[property] = "";
|
||||
}
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
values[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
};
|
||||
|
||||
async function readUnitProperty(
|
||||
unit: string,
|
||||
property: string,
|
||||
probe: SystemdUnitProbe = defaultSystemdUnitProbe
|
||||
): Promise<string> {
|
||||
return ((await probe.showProperties(unit, [property]))[property] ?? "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -506,23 +665,49 @@ async function cleanupFirewallCandidates(): Promise<void> {
|
||||
await runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Команда взведения guard'а — как значение, а не как момент запуска.
|
||||
*
|
||||
* Чистая функция, потому что у этой команды есть контракт, от которого зависят
|
||||
* два чужих утверждения:
|
||||
*
|
||||
* AccuracySec — обещанное оператору окно отката (README, docs, текст
|
||||
* отказа барьера говорят «45 секунд»);
|
||||
* RemainAfterElapse — право барьера считать отсутствие юнита покоем.
|
||||
*
|
||||
* Пока команда собиралась интерполяцией внутри вызова, оба свойства
|
||||
* существовали только в момент запуска, и проверить их можно было лишь грепом
|
||||
* по исходнику. Здесь они — обычное значение, которое сравнивает обычный тест.
|
||||
*
|
||||
* Имя юнита передаётся С суффиксом `.service`, а не голым. Голое имя systemd-run
|
||||
* пропускает через unit_name_mangle_with_suffix, и тот сначала смотрит, не
|
||||
* заканчивается ли оно уже известным типом юнита. Ключ операции —
|
||||
* санитизированный ISO-timestamp вида `...T12-34-56.789Z`, то есть содержит
|
||||
* точку, и корректность имени зависела бы от того, что `.789Z` случайно не
|
||||
* совпало ни с одним типом. Явный суффикс убирает эту зависимость: systemd-run
|
||||
* берёт имя как есть и создаёт рядом одноимённый `.timer`.
|
||||
*/
|
||||
export function buildArmGuardArgv(opId: string): string[] {
|
||||
assertSafeOperationKey(opId);
|
||||
return [
|
||||
"systemd-run",
|
||||
`--unit=${rollbackUnit(opId)}.service`,
|
||||
`--on-active=${FIREWALL_ROLLBACK_DEADLINE}`,
|
||||
`--timer-property=RemainAfterElapse=${FIREWALL_ROLLBACK_REMAIN_AFTER_ELAPSE}`,
|
||||
`--timer-property=AccuracySec=${FIREWALL_ROLLBACK_ACCURACY}`,
|
||||
"/bin/sh",
|
||||
autoRollbackScriptPath(opId)
|
||||
];
|
||||
}
|
||||
|
||||
async function armRollbackGuard(opId: string): Promise<void> {
|
||||
assertSafeOperationKey(opId);
|
||||
const scriptPath = autoRollbackScriptPath(opId);
|
||||
await writeText(scriptPath, buildAutoRollbackScript(opId), 0o700);
|
||||
await writeText(autoRollbackScriptPath(opId), buildAutoRollbackScript(opId), 0o700);
|
||||
|
||||
const unit = rollbackUnit(opId);
|
||||
// Имя передаётся С суффиксом `.service`, а не голым.
|
||||
//
|
||||
// Голое имя systemd-run пропускает через unit_name_mangle_with_suffix, и тот
|
||||
// сначала смотрит, не заканчивается ли оно уже известным типом юнита. Ключ
|
||||
// операции — санитизированный ISO-timestamp вида `...T12-34-56.789Z`, то есть
|
||||
// содержит точку, и корректность имени зависела бы от того, что `.789Z`
|
||||
// случайно не совпало ни с одним типом. Явный суффикс убирает эту зависимость:
|
||||
// systemd-run берёт имя как есть и создаёт рядом одноимённый `.timer`.
|
||||
await runMutatingVisible`systemd-run --unit ${`${unit}.service`} --on-active=${FIREWALL_ROLLBACK_DEADLINE} /bin/sh ${scriptPath}`;
|
||||
await runMutatingArgv(buildArmGuardArgv(opId));
|
||||
info(
|
||||
`firewall rollback guard armed: ${unit} fires in ${FIREWALL_ROLLBACK_DEADLINE} unless the operation disarms it`
|
||||
`firewall rollback guard armed: ${rollbackUnit(opId)} fires in ${FIREWALL_ROLLBACK_DEADLINE} ` +
|
||||
`(timer accuracy ${FIREWALL_ROLLBACK_ACCURACY}) unless the operation disarms it`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -636,28 +821,121 @@ export async function assertEffectiveFirewallIsOurs(context: RuntimeContext): Pr
|
||||
}
|
||||
}
|
||||
|
||||
/** Наблюдаемое состояние одного guard-юнита. */
|
||||
export type GuardUnitState = {
|
||||
unit: string;
|
||||
activeState: string;
|
||||
subState: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Транзиентные юниты guard, которые сейчас известны systemd.
|
||||
* Что удалось УЗНАТЬ о guard'ах предыдущей операции.
|
||||
*
|
||||
* Отказ самого запроса не считается доказательством наличия guard: без systemd
|
||||
* не может быть и транзиентного таймера, а требование systemd живёт в
|
||||
* preflight, где отказ будет понятнее и точнее.
|
||||
* Три исхода, а не два, и третий здесь главный. Прежняя функция возвращала
|
||||
* список юнитов, и «список пуст» одинаково означало и «guard'ов нет», и «спросить
|
||||
* не удалось». Различие между ними — это различие между доказанным покоем и
|
||||
* отсутствием доказательства, то есть ровно то, ради чего барьер существует.
|
||||
*/
|
||||
export async function listRollbackGuardUnits(): Promise<string[]> {
|
||||
export type GuardInspection =
|
||||
| { kind: "quiescent"; units: GuardUnitState[] }
|
||||
| { kind: "pending"; units: GuardUnitState[] }
|
||||
| { kind: "unknown"; reason: string };
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Имена guard-юнитов из вывода `systemctl list-units`.
|
||||
*
|
||||
* Ищется первое поле строки, НАЧИНАЮЩЕЕСЯ с нашего префикса, а не просто первое
|
||||
* поле. Разница не косметическая: у юнита в состоянии `failed` systemctl
|
||||
* печатает первой колонкой маркер `●`, и разбор «первое поле — имя юнита»
|
||||
* пропускал бы именно аварийно сработавший guard — тот единственный, ради
|
||||
* которого проверка и написана. `--plain` этот маркер убирает, но разбор не
|
||||
* должен зависеть от того, не потеряется ли флаг при следующей правке команды.
|
||||
*/
|
||||
export function parseRollbackGuardUnitNames(listed: string): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const line of listed.split(/\r?\n/)) {
|
||||
for (const field of line.trim().split(/\s+/)) {
|
||||
if (field.startsWith(ROLLBACK_UNIT_PREFIX)) {
|
||||
names.add(field);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/** Юнит уже ничего не изменит: см. GUARD_QUIESCENT_ACTIVE_STATES. */
|
||||
export function guardUnitIsQuiescent(state: GuardUnitState): boolean {
|
||||
if ((GUARD_QUIESCENT_ACTIVE_STATES as readonly string[]).includes(state.activeState)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
state.unit.endsWith(".timer") &&
|
||||
(GUARD_QUIESCENT_TIMER_SUB_STATES as readonly string[]).includes(state.subState)
|
||||
);
|
||||
}
|
||||
|
||||
export function describeGuardUnits(units: readonly GuardUnitState[]): string {
|
||||
return units.map((state) => `${state.unit} (${state.activeState}/${state.subState})`).join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние транзиентных guard'ов, известных systemd.
|
||||
*
|
||||
* Отказ запроса больше НЕ считается покоем, и это исправление, а не смена
|
||||
* умолчания. Прежний комментарий обосновывал `return []` так: «без systemd не
|
||||
* может быть и транзиентного таймера». Утверждение верное, но доказывает не то —
|
||||
* отказ запроса к systemd не означает, что systemd нет:
|
||||
*
|
||||
* systemd жив, старый rollback timer взведён
|
||||
* -> запрос к systemctl/D-Bus временно отказывает
|
||||
* -> список пуст
|
||||
* -> барьер считает систему спокойной
|
||||
* -> новая операция начинает менять firewall
|
||||
* -> таймер срабатывает поверх неё
|
||||
*
|
||||
* То есть механизм, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, при
|
||||
* невозможности получить доказательство принимал результат как положительный.
|
||||
* Это прямо противоположно политике, уже принятой для замка операций, где
|
||||
* сомнение трактуется в пользу отказа.
|
||||
*
|
||||
* Практического выигрыша от прежнего поведения не было: systemd-run требуется в
|
||||
* preflight, поэтому без работающего systemd операция всё равно откажет — просто
|
||||
* позже и с менее внятным сообщением.
|
||||
*/
|
||||
export async function inspectRollbackGuard(
|
||||
probe: SystemdUnitProbe = defaultSystemdUnitProbe
|
||||
): Promise<GuardInspection> {
|
||||
let listed: string;
|
||||
try {
|
||||
listed = await runReadOnly`systemctl list-units --all --plain --no-legend ${`${ROLLBACK_UNIT_PREFIX}*.timer`} ${`${ROLLBACK_UNIT_PREFIX}*.service`}`;
|
||||
listed = await probe.listGuardUnits();
|
||||
} catch (error) {
|
||||
info(
|
||||
`unable to list firewall rollback guard units: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return [];
|
||||
return { kind: "unknown", reason: `systemctl list-units failed: ${describeError(error)}` };
|
||||
}
|
||||
|
||||
return listed
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(/\s+/)[0] ?? "")
|
||||
.filter((unit) => unit.startsWith(ROLLBACK_UNIT_PREFIX));
|
||||
const units: GuardUnitState[] = [];
|
||||
for (const unit of parseRollbackGuardUnitNames(listed)) {
|
||||
let properties: Record<string, string>;
|
||||
try {
|
||||
properties = await probe.showProperties(unit, GUARD_STATE_PROPERTIES);
|
||||
} catch (error) {
|
||||
// Отказ на ОДНОМ юните тоже делает картину неполной: покой — утверждение
|
||||
// обо всех guard'ах сразу, и «про этот не знаем» его опровергает.
|
||||
return { kind: "unknown", reason: `systemctl show ${unit} failed: ${describeError(error)}` };
|
||||
}
|
||||
units.push({
|
||||
unit,
|
||||
activeState: (properties.ActiveState ?? "").trim(),
|
||||
subState: (properties.SubState ?? "").trim()
|
||||
});
|
||||
}
|
||||
|
||||
const pending = units.filter((state) => !guardUnitIsQuiescent(state));
|
||||
return pending.length > 0 ? { kind: "pending", units: pending } : { kind: "quiescent", units };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -688,25 +966,31 @@ export async function listRollbackGuardUnits(): Promise<string[]> {
|
||||
* он лишь повторяет то, что новая операция сделает сама, а отказ по их
|
||||
* переходным состояниям заблокировал бы `repair` ровно тогда, когда он нужен.
|
||||
*/
|
||||
export async function assertNoPendingRollbackGuard(): Promise<void> {
|
||||
const pending: string[] = [];
|
||||
export async function assertNoPendingRollbackGuard(
|
||||
probe: SystemdUnitProbe = defaultSystemdUnitProbe
|
||||
): Promise<void> {
|
||||
const inspection = await inspectRollbackGuard(probe);
|
||||
|
||||
for (const unit of await listRollbackGuardUnits()) {
|
||||
const state = await readUnitProperty(unit, "ActiveState");
|
||||
if ((GUARD_PENDING_STATES as readonly string[]).includes(state)) {
|
||||
pending.push(`${unit} (${state})`);
|
||||
}
|
||||
if (inspection.kind === "quiescent") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
if (inspection.kind === "unknown") {
|
||||
throw new GuardStateUnknownError(
|
||||
"unable to verify firewall rollback guard state; systemd query failed, " +
|
||||
`refusing to start a lifecycle operation: ${inspection.reason}. ` +
|
||||
"Барьер обязан ДОКАЗАТЬ, что у предыдущей операции не осталось исполнителей, " +
|
||||
"способных изменить firewall; без ответа systemd такого доказательства нет. " +
|
||||
"Проверьте systemd (`systemctl status`, `journalctl -u 'hy2xs-fw-rollback-*'`) и повторите."
|
||||
);
|
||||
}
|
||||
|
||||
throw new PendingRecoveryError(
|
||||
"previous HY2XS operation is no longer running, but its firewall rollback guard is still armed: " +
|
||||
`${pending.join(", ")}. ` +
|
||||
`${describeGuardUnits(inspection.units)}. ` +
|
||||
"Такой guard способен вернуть прежний firewall уже посреди новой операции. " +
|
||||
"Дождитесь его завершения (окно — 45 секунд с момента применения firewall) и повторите; " +
|
||||
`Дождитесь его завершения (окно — ${FIREWALL_ROLLBACK_DEADLINE} с момента применения firewall, ` +
|
||||
`точность таймера — ${FIREWALL_ROLLBACK_ACCURACY}) и повторите; ` +
|
||||
"состояние guard видно в `hy2xs-orchestrator status` и в `journalctl -u 'hy2xs-fw-rollback-*'`."
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user