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:
@@ -37,6 +37,87 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
|||||||
допущения, на которых держалась операция целиком: что снятие защиты от отката
|
допущения, на которых держалась операция целиком: что снятие защиты от отката
|
||||||
действительно произошло и что операция на сервере ровно одна.
|
действительно произошло и что операция на сервере ровно одна.
|
||||||
|
|
||||||
|
Седьмой проход — приведение барьера покоя к реальной семантике systemd 257,
|
||||||
|
который стоит на Debian 13. Механизм, обязанный **доказать** отсутствие
|
||||||
|
асинхронного исполнителя, в трёх местах принимал за доказательство отсутствие
|
||||||
|
наблюдения.
|
||||||
|
|
||||||
|
### Исправлено — барьер покоя и контракт транзиентного таймера
|
||||||
|
|
||||||
|
- **Отказ запроса к systemd выдавался за отсутствие guard'а.** Листинг
|
||||||
|
guard-юнитов при исключении возвращал пустой список, и пустой список означал
|
||||||
|
«можно начинать»:
|
||||||
|
|
||||||
|
```text
|
||||||
|
systemd жив, старый rollback timer взведён
|
||||||
|
-> запрос к systemctl/D-Bus временно отказывает
|
||||||
|
-> список guard'ов пуст
|
||||||
|
-> барьер считает систему спокойной
|
||||||
|
-> новая операция начинает менять firewall
|
||||||
|
-> старый таймер срабатывает поверх неё
|
||||||
|
```
|
||||||
|
|
||||||
|
Обоснование в комментарии («без systemd не может быть и транзиентного
|
||||||
|
таймера») доказывало не то: отказ запроса не означает, что systemd нет.
|
||||||
|
Барьер стал fail-closed и получил отдельный тип отказа
|
||||||
|
`GuardStateUnknownError` — «guard вооружён» и «спросить не удалось» требуют
|
||||||
|
от оператора разных действий. Практического выигрыша у прежнего поведения не
|
||||||
|
было: `systemd-run` требуется в preflight, поэтому без работающего systemd
|
||||||
|
операция всё равно отказывала — просто позже и менее внятно.
|
||||||
|
|
||||||
|
- **Покой перечислялся чёрным списком.** Непокойными считались `active`,
|
||||||
|
`activating`, `deactivating`, `reloading`, а покоем — «всё остальное», то
|
||||||
|
есть любое состояние, которого автор не назвал. systemd 257 знает ещё
|
||||||
|
`maintenance` и `refreshing`. Политика инвертирована: покой — это `inactive`
|
||||||
|
и `failed`, всё прочее блокирует операцию.
|
||||||
|
|
||||||
|
- **Обещанные «45 секунд» не были контрактом systemd.** `OnActiveSec=45s` не
|
||||||
|
означает «ровно через 45 секунд»: таймер вправе сработать в окне
|
||||||
|
`[цель; цель + AccuracySec]`, а умолчание `AccuracySec=` — одна минута. То
|
||||||
|
есть README, docs и текст отказа обещали 45 секунд, а контракт допускал 105.
|
||||||
|
Теперь точность задаётся явно (`AccuracySec=1s`), и реальное окно — 45–46
|
||||||
|
секунд.
|
||||||
|
|
||||||
|
- **Выгрузка отработавшего таймера держалась на чужом умолчании.** Право
|
||||||
|
барьера считать исчезновение юнита покоем опирается на
|
||||||
|
`RemainAfterElapse=no`. `systemd-run` выставляет это свойство транзиентным
|
||||||
|
таймерам сам, но инвариант, который нигде не записан и ничем не проверяется,
|
||||||
|
инвариантом не является. Свойство задаётся явно, а барьер дополнительно
|
||||||
|
опознаёт `SubState=elapsed` у `*.timer` как покой: `TIMER_ELAPSED` в systemd
|
||||||
|
отображается в `UNIT_ACTIVE`, и без этой ветки отработавший таймер,
|
||||||
|
созданный не нами, блокировал бы `repair` навсегда.
|
||||||
|
|
||||||
|
- **`status` расходился с барьером в трактовке того же самого guard'а.** У него
|
||||||
|
была своя копия листинга — без `--plain` (у `failed`-юнита первой колонкой
|
||||||
|
идёт маркер `●`), с `|| true` (отказ systemd превращался в «guard'ов нет») и
|
||||||
|
без разбора состояний: вооружённым считался любой найденный юнит. Аварийно
|
||||||
|
сработавший guard оставляет `failed`-сервис загруженным до `reset-failed`,
|
||||||
|
поэтому `status` вечно показывал `firewall_state: guard_active`, пока барьер
|
||||||
|
тот же юнит считал покоем и разрешал `repair`. Копия убрана: отчёт берёт
|
||||||
|
состояние у барьерного наблюдателя и сообщает `rollback_guard_state`
|
||||||
|
(`quiescent` / `pending` / `unknown`) с `active_state` и `sub_state` каждого
|
||||||
|
юнита.
|
||||||
|
|
||||||
|
- **`purge-v0.sh` не снимал именно аварийно сработавший guard.** Разбор
|
||||||
|
`awk '{print $1}' | grep -E '^hy2xs-fw-rollback-'` отбрасывал строку целиком,
|
||||||
|
когда первой колонкой стоял маркер `●`, то есть пропускал `failed`-юниты —
|
||||||
|
единственные, ради которых эта проверка написана.
|
||||||
|
|
||||||
|
- **Контракт команды взведения проверялся грепом по исходнику.** Команда
|
||||||
|
собиралась интерполяцией в shell-строку и существовала только в момент
|
||||||
|
запуска. Теперь её строит чистая `buildArmGuardArgv`, а выполняет
|
||||||
|
`runMutatingArgv` — без shell вообще, что заодно убирает вопрос о
|
||||||
|
квотировании из команды, создающей systemd-юнит с именем из данных операции.
|
||||||
|
Свойства таймера стали обычным значением, которое сравнивает обычный тест.
|
||||||
|
|
||||||
|
- **Барьер не имел ни одного поведенческого теста.** Все проверки были грепами
|
||||||
|
по тексту функций, и один из них закреплял как раз небезопасное поведение:
|
||||||
|
тест утверждал, что в теле есть `return [];`. Строка была на месте — а
|
||||||
|
решение при этом стало неверным. Наблюдение за systemd вынесено в
|
||||||
|
подставляемый `SystemdUnitProbe`, и сценарии («systemd не ответил», «таймер
|
||||||
|
взведён», «сервис упал», «незнакомое состояние», «таймер отработал»)
|
||||||
|
проверяются поведением, без systemd и без Linux.
|
||||||
|
|
||||||
### Исправлено — границы транзакции
|
### Исправлено — границы транзакции
|
||||||
|
|
||||||
- **Снятие rollback guard было утверждением, а не фактом.** Порядок фиксации
|
- **Снятие rollback guard было утверждением, а не фактом.** Порядок фиксации
|
||||||
|
|||||||
@@ -675,6 +675,8 @@ hy2xs-orchestrator status \
|
|||||||
|
|
||||||
При `HY2XS_FIREWALL_STAGED_APPLY=true` (значение по умолчанию) перед применением новых правил HY2XS взводит rollback guard — транзиентный systemd‑юнит с окном 45 секунд. Если операция не снимет его вовремя, guard вернёт прежний firewall, и SSH останется доступным.
|
При `HY2XS_FIREWALL_STAGED_APPLY=true` (значение по умолчанию) перед применением новых правил HY2XS взводит rollback guard — транзиентный systemd‑юнит с окном 45 секунд. Если операция не снимет его вовремя, guard вернёт прежний firewall, и SSH останется доступным.
|
||||||
|
|
||||||
|
Таймеру явно задаётся `AccuracySec=1s`, поэтому «45 секунд» — это реальный контракт, а не приблизительный: по умолчанию `systemd.timer` разрешает себе сработать в окне `[цель; цель + AccuracySec]`, где `AccuracySec` — одна минута, и обещанное окно превращалось бы в 45–105 секунд. Вторым свойством задаётся `RemainAfterElapse=no`: отработавший таймер обязан выгрузиться, иначе он навсегда блокировал бы следующую операцию (см. [«Одна операция за раз»](#одна-операция-за-раз)).
|
||||||
|
|
||||||
Окно намеренно короткое и **не** обязано покрывать smoke‑checks: на медленном сервере они идут дольше. Вместо этого guard оставляет за собой факт срабатывания в `/run/hy2xs/rollback/<op-id>/auto-rollback-fired`, и операция не имеет права объявить себя успешной, если этот файл появился, — сервер в такой момент работает на прежнем firewall, а не на том, который она сгенерировала. Установка завершится отказом с `phase: firewall_guard_fired`, и её нужно повторить после устранения причины медленного прохода.
|
Окно намеренно короткое и **не** обязано покрывать smoke‑checks: на медленном сервере они идут дольше. Вместо этого guard оставляет за собой факт срабатывания в `/run/hy2xs/rollback/<op-id>/auto-rollback-fired`, и операция не имеет права объявить себя успешной, если этот файл появился, — сервер в такой момент работает на прежнем firewall, а не на том, который она сгенерировала. Установка завершится отказом с `phase: firewall_guard_fired`, и её нужно повторить после устранения причины медленного прохода.
|
||||||
|
|
||||||
Дополнительно smoke сверяет, что действующий firewall — именно тот, который сгенерирован для текущей конфигурации: разбора `/etc/nftables.conf` для этого недостаточно, потому что прежний ruleset тоже валиден.
|
Дополнительно smoke сверяет, что действующий firewall — именно тот, который сгенерирован для текущей конфигурации: разбора `/etc/nftables.conf` для этого недостаточно, потому что прежний ruleset тоже валиден.
|
||||||
@@ -697,10 +699,21 @@ another HY2XS operation is already in progress: reconfigure (pid 4242, started a
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
previous HY2XS operation is no longer running, but its firewall rollback guard
|
previous HY2XS operation is no longer running, but its firewall rollback guard
|
||||||
is still armed: hy2xs-fw-rollback-<op-id>.timer (active)
|
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
|
||||||
```
|
```
|
||||||
|
|
||||||
Ждать в этом случае нужно не дольше 45 секунд с момента применения firewall.
|
Ждать в этом случае нужно не дольше 45–46 секунд с момента применения firewall.
|
||||||
|
|
||||||
|
Покоем считаются ровно два состояния юнита — `inactive` и `failed`: отработавший guard больше ничего не сделает, а отказ по `failed` заблокировал бы `repair`, которым чинят последствия. Всё остальное, включая незнакомые барьеру состояния systemd, операцию запрещает.
|
||||||
|
|
||||||
|
Отдельный случай — когда состояние guard'а вообще не удалось выяснить:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unable to verify firewall rollback guard state; systemd query failed,
|
||||||
|
refusing to start a lifecycle operation
|
||||||
|
```
|
||||||
|
|
||||||
|
Здесь ждать нечего: отсутствие ответа systemd — это отсутствие доказательства, а не доказательство покоя, и разбираться нужно с systemd. Барьер обязан **доказать**, что у предыдущей операции не осталось исполнителей, способных изменить firewall; молчаливое «наверное, всё в порядке» однажды означало бы срабатывание старого таймера поверх новой операции.
|
||||||
|
|
||||||
## Реконфигурация
|
## Реконфигурация
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ IPv4-only policy:
|
|||||||
3. Подставить candidate в production-пути.
|
3. Подставить candidate в production-пути.
|
||||||
4. Взвести rollback guard: транзиентный юнит `hy2xs-fw-rollback-<op-id>`
|
4. Взвести rollback guard: транзиентный юнит `hy2xs-fw-rollback-<op-id>`
|
||||||
с окном 45 секунд.
|
с окном 45 секунд.
|
||||||
|
```bash
|
||||||
|
systemd-run \
|
||||||
|
--unit=hy2xs-fw-rollback-<op-id>.service \
|
||||||
|
--on-active=45s \
|
||||||
|
--timer-property=RemainAfterElapse=no \
|
||||||
|
--timer-property=AccuracySec=1s \
|
||||||
|
/bin/sh /run/hy2xs/rollback/<op-id>/auto-rollback.sh
|
||||||
|
```
|
||||||
5. Применить ruleset и проверить SSH/Hysteria/UI.
|
5. Применить ruleset и проверить SSH/Hysteria/UI.
|
||||||
6. Снять guard и **доказать**, что он снят (см. ниже).
|
6. Снять guard и **доказать**, что он снят (см. ниже).
|
||||||
7. Долговечно зафиксировать успех.
|
7. Долговечно зафиксировать успех.
|
||||||
@@ -109,6 +117,29 @@ Guard — это защита от потери доступа к серверу
|
|||||||
smoke: smoke на медленном, но исправном сервере может идти заметно дольше.
|
smoke: smoke на медленном, но исправном сервере может идти заметно дольше.
|
||||||
Увеличение окна лечило бы гонку расширением, а не устранением.
|
Увеличение окна лечило бы гонку расширением, а не устранением.
|
||||||
|
|
||||||
|
#### Почему у таймера заданы `AccuracySec` и `RemainAfterElapse`
|
||||||
|
|
||||||
|
`OnActiveSec=45s` сам по себе **не** означает «ровно через 45 секунд».
|
||||||
|
`systemd.timer` разрешает себе сработать в окне
|
||||||
|
|
||||||
|
```text
|
||||||
|
цель ... цель + AccuracySec
|
||||||
|
```
|
||||||
|
|
||||||
|
объединяя пробуждения ради экономии энергии, и умолчание `AccuracySec=` —
|
||||||
|
`1min`. То есть без явного значения guard, про который эта страница и текст
|
||||||
|
отказа говорят «45 секунд», по контракту systemd мог сработать и через 105.
|
||||||
|
Поэтому точность задаётся явно: `AccuracySec=1s`, и реальное окно — **45–46
|
||||||
|
секунд**. Коалесценция пробуждений аварийному guard'у не нужна: он взводится
|
||||||
|
один раз за операцию и почти всегда снимается, не сработав.
|
||||||
|
|
||||||
|
`RemainAfterElapse=no` задаётся по другой причине. Отработавший одноразовый
|
||||||
|
таймер обязан выгрузиться — на этом стоит право барьера покоя считать
|
||||||
|
отсутствие юнита доказательством того, что откатывать firewall больше некому.
|
||||||
|
`systemd-run` выставляет это свойство транзиентным таймерам сам, но инвариант,
|
||||||
|
который держится на чужом умолчании, нигде не записан и ничем не проверяется;
|
||||||
|
в явном виде он попадает и в journal, и в тест.
|
||||||
|
|
||||||
Вместо этого guard оставляет за собой факт:
|
Вместо этого guard оставляет за собой факт:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -158,18 +189,81 @@ Guard — объект systemd, а не часть процесса оркест
|
|||||||
|
|
||||||
Поэтому условие начала новой операции — не «PID предыдущей мёртв», а «у
|
Поэтому условие начала новой операции — не «PID предыдущей мёртв», а «у
|
||||||
предыдущей не осталось исполнителей, способных изменить систему». Каждый захват
|
предыдущей не осталось исполнителей, способных изменить систему». Каждый захват
|
||||||
замка проходит через барьер покоя: если хоть один `hy2xs-fw-rollback-*` находится
|
замка проходит через барьер покоя.
|
||||||
в состоянии `active`, `activating`, `deactivating` или `reloading`, операция
|
|
||||||
отказывает.
|
|
||||||
|
|
||||||
`inactive` и `failed` считаются покоем: отработавший guard больше ничего не
|
#### Покой перечисляется белым списком
|
||||||
сделает, а отказ по `failed` заблокировал бы `repair` — ровно тот инструмент,
|
|
||||||
которым чинят последствия.
|
Покой — это `inactive` и `failed`, и **только** они. Отработавший guard больше
|
||||||
|
ничего не сделает, а отказ по `failed` заблокировал бы `repair` — ровно тот
|
||||||
|
инструмент, которым чинят последствия. Всё остальное считается непокоем и
|
||||||
|
запрещает операцию.
|
||||||
|
|
||||||
|
Список именно белый, а не чёрный. Перечисление непокойных состояний
|
||||||
|
(`active`, `activating`, `deactivating`, `reloading`) объявляло бы безопасным
|
||||||
|
любое состояние, которого автор не назвал, — включая те, которых он не знал:
|
||||||
|
systemd 257 знает ещё `maintenance` и `refreshing`, и список может пополниться
|
||||||
|
снова. Незнакомое состояние systemd обязано блокировать операцию, а не
|
||||||
|
проходить молча.
|
||||||
|
|
||||||
|
Единственное исключение — `*.timer` в `SubState=elapsed` или `dead`. `ActiveState`
|
||||||
|
таймера отвечает на вопрос «юнит загружен и в строю», а не «он ещё может
|
||||||
|
сработать»: в systemd `TIMER_ELAPSED` отображается в `UNIT_ACTIVE` так же, как
|
||||||
|
`TIMER_WAITING`, и различает их только `SubState`. У наших guard'ов такого не
|
||||||
|
бывает (`RemainAfterElapse=no`), но инвариант «барьер не залипает» не должен
|
||||||
|
зависеть от того, чем именно создан таймер: иначе отработавший таймер запрещал
|
||||||
|
бы install/reconfigure/repair/doctor навсегда — и запрещал бы ради отката,
|
||||||
|
который уже произошёл. Триггернутый сервис при этом виден барьеру отдельным
|
||||||
|
юнитом и остаётся непокоем, пока выполняется.
|
||||||
|
|
||||||
|
#### Отказ запроса к systemd — это отказ операции
|
||||||
|
|
||||||
|
Если `systemctl` не ответил, барьер **не** считает систему спокойной:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unable to verify firewall rollback guard state;
|
||||||
|
systemd query failed, refusing to start a lifecycle operation
|
||||||
|
```
|
||||||
|
|
||||||
|
Отсутствие ответа — отсутствие наблюдения, а не наблюдение покоя. Обратная
|
||||||
|
трактовка давала реальный сценарий потери firewall:
|
||||||
|
|
||||||
|
```text
|
||||||
|
systemd жив, старый rollback timer взведён
|
||||||
|
-> запрос к systemctl/D-Bus временно отказывает
|
||||||
|
-> список guard'ов пуст
|
||||||
|
-> барьер считает систему спокойной
|
||||||
|
-> новая операция начинает менять firewall
|
||||||
|
-> старый таймер срабатывает поверх неё
|
||||||
|
```
|
||||||
|
|
||||||
|
Механизм, обязанный **доказать** отсутствие асинхронного исполнителя, принимал
|
||||||
|
невозможность получить доказательство за положительный результат. Это прямо
|
||||||
|
противоположно политике замка операций, где сомнение трактуется в пользу
|
||||||
|
отказа.
|
||||||
|
|
||||||
|
Практического выигрыша у прежнего поведения не было: `systemd-run` требуется в
|
||||||
|
preflight, поэтому без работающего systemd операция всё равно откажет — просто
|
||||||
|
позже и с менее внятной диагностикой.
|
||||||
|
|
||||||
Состояния `hysteria-server`, `hy2xs-admin` и `nftables.service` барьер
|
Состояния `hysteria-server`, `hy2xs-admin` и `nftables.service` барьер
|
||||||
сознательно не проверяет: незавершённый `systemctl restart` ничего не
|
сознательно не проверяет: незавершённый `systemctl restart` ничего не
|
||||||
откатывает, он лишь повторяет то, что новая операция сделает сама.
|
откатывает, он лишь повторяет то, что новая операция сделает сама.
|
||||||
|
|
||||||
|
#### Один наблюдатель на барьер и на отчёт
|
||||||
|
|
||||||
|
Состояние guard'ов читает одна функция, и `hy2xs-orchestrator status` берёт его
|
||||||
|
у неё же. Раньше у status была своя копия листинга, и она расходилась с
|
||||||
|
барьером по трём пунктам сразу: без `--plain` (у `failed`-юнита первой колонкой
|
||||||
|
идёт маркер `●`), с `|| true` (отказ systemd превращался в «guard'ов нет») и
|
||||||
|
без разбора состояний — вооружённым считался любой найденный юнит. На практике
|
||||||
|
это означало, что аварийно сработавший guard оставлял `failed`-сервис
|
||||||
|
загруженным до `reset-failed`, status вечно показывал `firewall_state:
|
||||||
|
guard_active`, а барьер тот же самый юнит считал покоем и разрешал `repair`.
|
||||||
|
|
||||||
|
`status` при этом остаётся отчётом: он не берёт замок и существует в том числе
|
||||||
|
для сломанного хоста, поэтому «спросить не удалось» попадает в JSON значением
|
||||||
|
`rollback_guard_state: "unknown"`, а не отказом команды.
|
||||||
|
|
||||||
### Проверка эффективного firewall
|
### Проверка эффективного firewall
|
||||||
|
|
||||||
`nft -c -f /etc/nftables.conf` разбирает текущий файл, каким бы он ни был, и
|
`nft -c -f /etc/nftables.conf` разбирает текущий файл, каким бы он ни был, и
|
||||||
|
|||||||
@@ -288,7 +288,33 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
стадиями, идущими **до** применения ruleset;
|
стадиями, идущими **до** применения ruleset;
|
||||||
- candidate-файлы убираются после успеха и best-effort при откате;
|
- candidate-файлы убираются после успеха и best-effort при откате;
|
||||||
- ключ операции считается одной функцией: install писал в маркер сырой
|
- ключ операции считается одной функцией: install писал в маркер сырой
|
||||||
ISO-timestamp, и путь `/run/hy2xs/rollback/<op_id>` из runbook не существовал.
|
ISO-timestamp, и путь `/run/hy2xs/rollback/<op_id>` из runbook не существовал;
|
||||||
|
- команда взведения guard проверяется как **значение**, а не грепом по
|
||||||
|
исходнику: `buildArmGuardArgv` возвращает готовый argv, и тест сверяет его
|
||||||
|
целиком — имя юнита с явным суффиксом, `--on-active=45s`,
|
||||||
|
`--timer-property=AccuracySec=1s`, `--timer-property=RemainAfterElapse=no`.
|
||||||
|
Небезопасный ключ операции отвергается до запуска: shell в этой команде не
|
||||||
|
участвует, поэтому единственная защита — отказ;
|
||||||
|
- барьер покоя проверяется **поведенчески**, с подставляемым наблюдателем
|
||||||
|
systemd (`SystemdUnitProbe`), без systemd и без Linux:
|
||||||
|
- взведённый таймер (`active/waiting`) и идущий прямо сейчас откат
|
||||||
|
(`activating`) запрещают операцию с `PendingRecoveryError`;
|
||||||
|
- отказ `list-units` **и** отказ `show` на любом отдельном юните дают
|
||||||
|
`GuardStateUnknownError`: отсутствие ответа systemd — отсутствие
|
||||||
|
наблюдения, а не наблюдение покоя. Прежний тест закреплял обратное
|
||||||
|
(`return [];` в тексте функции) и потому пережил инверсию смысла: строка
|
||||||
|
была на месте, а решение стало неверным;
|
||||||
|
- оба отказа имеют общего предка `OperationBarrierError`;
|
||||||
|
- покой — ровно `inactive` и `failed`; `maintenance`, `refreshing` и любое
|
||||||
|
незнакомое состояние блокируют операцию, потому что политика перечисляет
|
||||||
|
безопасные состояния, а не опасные;
|
||||||
|
- `*.timer` в `SubState=elapsed` считается покоем: `TIMER_ELAPSED` в systemd
|
||||||
|
отображается в `UNIT_ACTIVE`, и без этой ветки отработавший таймер
|
||||||
|
блокировал бы `repair` навсегда. У сервиса тот же `SubState` ничего не
|
||||||
|
значит;
|
||||||
|
- разбор вывода `list-units` находит имя юнита и когда первой колонкой идёт
|
||||||
|
маркер `●` (состояние `failed`) — иначе терялся бы именно аварийно
|
||||||
|
сработавший guard.
|
||||||
|
|
||||||
## A5f. Взаимное исключение операций (unit)
|
## A5f. Взаимное исключение операций (unit)
|
||||||
|
|
||||||
@@ -1040,7 +1066,19 @@ firewall и успешным smoke.
|
|||||||
Сценарий:
|
Сценарий:
|
||||||
|
|
||||||
1. установка доходит до шага `firewall`, в журнале появляется
|
1. установка доходит до шага `firewall`, в журнале появляется
|
||||||
`firewall rollback guard armed`;
|
`firewall rollback guard armed: … fires in 45s (timer accuracy 1s)`.
|
||||||
|
Пока guard ждёт, свойства таймера проверяются напрямую — обещанное окно
|
||||||
|
обязано быть контрактом systemd, а не намерением:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl show hy2xs-fw-rollback-<op-id>.timer \
|
||||||
|
-p ActiveState -p SubState -p AccuracyUSec -p RemainAfterElapse
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается `ActiveState=active`, `SubState=waiting`, `AccuracyUSec=1s`,
|
||||||
|
`RemainAfterElapse=no`. Без явной точности systemd вправе сработать в окне
|
||||||
|
`[45s; 45s + AccuracySec]`, а умолчание `AccuracySec=` — одна минута, то
|
||||||
|
есть реальное окно было бы 45–105 секунд;
|
||||||
2. smoke искусственно замедляется дольше 45 секунд. Проще всего задержать один
|
2. smoke искусственно замедляется дольше 45 секунд. Проще всего задержать один
|
||||||
из сервисов — например, добавить в `hy2xs-admin.service` временный
|
из сервисов — например, добавить в `hy2xs-admin.service` временный
|
||||||
`ExecStartPre=/bin/sleep 60` и выполнить `systemctl daemon-reload` до запуска
|
`ExecStartPre=/bin/sleep 60` и выполнить `systemctl daemon-reload` до запуска
|
||||||
@@ -1142,18 +1180,56 @@ guard A срабатывает и возвращает firewall, который
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
previous HY2XS operation is no longer running, but its firewall rollback guard
|
previous HY2XS operation is no longer running, but its firewall rollback guard
|
||||||
is still armed: hy2xs-fw-rollback-<op-id>.timer (active)
|
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
|
||||||
```
|
```
|
||||||
|
|
||||||
5. отказ происходит **до** снятия резервной копии и до первой мутации;
|
5. отказ происходит **до** снятия резервной копии и до первой мутации;
|
||||||
6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине;
|
6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине;
|
||||||
7. после срабатывания guard (`hy2xs-fw-rollback-*` больше не `active`)
|
7. после срабатывания guard транзиентный таймер выгружается
|
||||||
`repair` проходит.
|
(`RemainAfterElapse=no`), и `repair` проходит. Проверяется наблюдением, а не
|
||||||
|
ожиданием на глаз:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl show hy2xs-fw-rollback-<op-id>.timer -p LoadState -p ActiveState
|
||||||
|
systemctl list-units --all --plain 'hy2xs-fw-rollback-*'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается, что таймера в списке больше нет; оставшийся `.service` в
|
||||||
|
состоянии `failed` (частичное восстановление) операцию не блокирует.
|
||||||
|
|
||||||
Обратная проверка: на сервере без вооружённого guard барьер молчит и ни одну
|
Обратная проверка: на сервере без вооружённого guard барьер молчит и ни одну
|
||||||
операцию не задерживает, а `failed` от уже отработавшего guard **не** считается
|
операцию не задерживает, а `failed` от уже отработавшего guard **не** считается
|
||||||
непокоем — иначе он заблокировал бы `repair`, которым и чинят последствия.
|
непокоем — иначе он заблокировал бы `repair`, которым и чинят последствия.
|
||||||
|
|
||||||
|
Отдельная проверка того же барьера — недоказуемое состояние. Барьер обязан
|
||||||
|
различать «guard вооружён» и «спросить не удалось»: это разные утверждения, и
|
||||||
|
оператору по ним нужны разные действия.
|
||||||
|
|
||||||
|
1. на рабочей установке без вооружённого guard делается недоступным запрос к
|
||||||
|
systemd — проще всего временно подложить в `PATH` оркестратора `systemctl`,
|
||||||
|
завершающийся ненулевым кодом;
|
||||||
|
2. любая операция жизненного цикла (`repair`, `reconfigure --apply`, `doctor`,
|
||||||
|
`install.sh` на PHASE 0) обязана отказать:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unable to verify firewall rollback guard state; systemd query failed,
|
||||||
|
refusing to start a lifecycle operation
|
||||||
|
```
|
||||||
|
|
||||||
|
3. отказ происходит **до** первой мутации, и тип ошибки —
|
||||||
|
`GuardStateUnknownError`, а не `PendingRecoveryError`: ждать окна отката
|
||||||
|
здесь бессмысленно;
|
||||||
|
4. `hy2xs-orchestrator status` при этом **не** падает: он замок не берёт и
|
||||||
|
существует в том числе для сломанного хоста, поэтому сообщает
|
||||||
|
`rollback_guard_state: "unknown"` и `firewall_state: "guard_unknown"`;
|
||||||
|
5. после возврата рабочего `systemctl` операция проходит без дополнительных
|
||||||
|
действий.
|
||||||
|
|
||||||
|
Смысл проверки — в том, что прежнее поведение было противоположным: отказ
|
||||||
|
запроса давал пустой список guard'ов, барьер считал систему спокойной и
|
||||||
|
пропускал операцию, а взведённый таймер предыдущей операции срабатывал уже
|
||||||
|
посреди неё.
|
||||||
|
|
||||||
## D1g. Успешная установка не оставляет следов транзакции
|
## D1g. Успешная установка не оставляет следов транзакции
|
||||||
|
|
||||||
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
|
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
|
||||||
@@ -1163,7 +1239,7 @@ is still armed: hy2xs-fw-rollback-<op-id>.timer (active)
|
|||||||
После `installed`:
|
После `installed`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
systemctl list-units --all 'hy2xs-fw-rollback-*' → пусто
|
systemctl list-units --all --plain 'hy2xs-fw-rollback-*' → пусто
|
||||||
ls /run/hy2xs/rollback/ → пусто
|
ls /run/hy2xs/rollback/ → пусто
|
||||||
ls /run/lock/hy2xs-orchestrator.lock → отсутствует
|
ls /run/lock/hy2xs-orchestrator.lock → отсутствует
|
||||||
ls /etc/nftables.conf.candidate → отсутствует
|
ls /etc/nftables.conf.candidate → отсутствует
|
||||||
@@ -1366,3 +1442,8 @@ hy2xs-orchestrator doctor
|
|||||||
68. новая операция не начинается, пока у предыдущей остаётся вооружённый rollback guard: условие старта — «у предыдущей нет исполнителей, способных изменить систему», а не «её PID мёртв»
|
68. новая операция не начинается, пока у предыдущей остаётся вооружённый rollback guard: условие старта — «у предыдущей нет исполнителей, способных изменить систему», а не «её PID мёртв»
|
||||||
69. отказ записи маркера `auto-rollback-fired` не может привести к фиксации успеха: он переводит юнит guard в `failed`, а `failed` фиксацию запрещает
|
69. отказ записи маркера `auto-rollback-fired` не может привести к фиксации успеха: он переводит юнит guard в `failed`, а `failed` фиксацию запрещает
|
||||||
70. восстановление `UnitFileState` у `nftables.service` не обещает точности, которой не даёт: восстанавливаются `enabled`/`disabled`, остальные состояния называются оператору и не трогаются
|
70. восстановление `UnitFileState` у `nftables.service` не обещает точности, которой не даёт: восстанавливаются `enabled`/`disabled`, остальные состояния называются оператору и не трогаются
|
||||||
|
71. отказ запроса к systemd не выдаётся за покой: барьер обязан **доказать** отсутствие исполнителей предыдущей операции, а при невозможности получить доказательство отказывает с `GuardStateUnknownError`, а не разрешает операцию
|
||||||
|
72. покой guard перечисляется белым списком (`inactive`, `failed`): незнакомое состояние systemd блокирует операцию, а не проходит молча по принципу «его нет в списке опасных»
|
||||||
|
73. отработавший таймер не блокирует операцию навсегда: `RemainAfterElapse=no` выгружает его, а барьер дополнительно опознаёт `SubState=elapsed` у `*.timer` как покой
|
||||||
|
74. обещанное окно отката — контракт systemd, а не намерение: у транзиентного таймера явно задан `AccuracySec=1s`, иначе умолчание `AccuracySec=1min` превращало «45 секунд» в 45–105
|
||||||
|
75. состояние guard читает один наблюдатель: `status` берёт его у того же кода, что и барьер, и сообщает `unknown` вместо тихого «guard'ов нет» при отказе systemd
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ operation lock … exists but is not a valid HY2XS lock record
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
previous HY2XS operation is no longer running, but its firewall rollback guard
|
previous HY2XS operation is no longer running, but its firewall rollback guard
|
||||||
is still armed: hy2xs-fw-rollback-<op-id>.timer (active)
|
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
|
||||||
```
|
```
|
||||||
|
|
||||||
Предыдущая операция умерла аварийно **после** применения firewall. Её процесса
|
Предыдущая операция умерла аварийно **после** применения firewall. Её процесса
|
||||||
@@ -211,18 +211,50 @@ systemd, и он переживает свой процесс. Если нача
|
|||||||
операции.
|
операции.
|
||||||
|
|
||||||
Ничего делать не нужно, кроме как подождать: окно guard — 45 секунд с момента
|
Ничего делать не нужно, кроме как подождать: окно guard — 45 секунд с момента
|
||||||
применения firewall.
|
применения firewall плюс точность таймера (`AccuracySec=1s`), то есть не больше
|
||||||
|
46 секунд.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# сколько ещё ждать и что именно висит
|
# сколько ещё ждать и что именно висит
|
||||||
systemctl list-units --all 'hy2xs-fw-rollback-*'
|
systemctl list-units --all --plain 'hy2xs-fw-rollback-*'
|
||||||
hy2xs-orchestrator status --package-dir /usr/local/lib/hy2xs/package
|
hy2xs-orchestrator status --package-dir /usr/local/lib/hy2xs/package
|
||||||
```
|
```
|
||||||
|
|
||||||
Когда guard сработает, юнит перестанет быть `active`, и операция пройдёт.
|
`--plain` здесь не для красоты: у юнита в состоянии `failed` systemctl печатает
|
||||||
Состояние `failed` у него покою не мешает: оно означает, что откат отработал
|
первой колонкой маркер `●`, и без флага его легко не заметить в списке.
|
||||||
не полностью, и это как раз повод запустить `repair`, а не ждать дальше —
|
|
||||||
подробности в `journalctl -u 'hy2xs-fw-rollback-*'`.
|
Когда guard сработает, отработавший таймер выгрузится (`RemainAfterElapse=no`),
|
||||||
|
и операция пройдёт. Состояние `failed` у сервиса покою не мешает: оно означает,
|
||||||
|
что откат отработал не полностью, и это как раз повод запустить `repair`, а не
|
||||||
|
ждать дальше — подробности в `journalctl -u 'hy2xs-fw-rollback-*'`. Такой
|
||||||
|
`failed`-юнит остаётся загруженным до `systemctl reset-failed`, но операцию не
|
||||||
|
блокирует.
|
||||||
|
|
||||||
|
### Операция отказывает: состояние guard'а не удалось выяснить
|
||||||
|
|
||||||
|
```text
|
||||||
|
unable to verify firewall rollback guard state; systemd query failed,
|
||||||
|
refusing to start a lifecycle operation: systemctl list-units failed: …
|
||||||
|
```
|
||||||
|
|
||||||
|
Это **не** «guard вооружён», и ждать здесь нечего. Барьер обязан доказать, что у
|
||||||
|
предыдущей операции не осталось исполнителей, способных изменить firewall;
|
||||||
|
`systemctl` не ответил, доказательства нет, и операция отказывает до первой
|
||||||
|
мутации.
|
||||||
|
|
||||||
|
Отсутствие ответа не равно отсутствию guard'а: systemd мог быть жив, а взведённый
|
||||||
|
таймер — существовать. Разрешить операцию в этой ситуации означало бы допустить
|
||||||
|
срабатывание старого таймера поверх новой операции.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status
|
||||||
|
systemctl list-units --all --plain 'hy2xs-fw-rollback-*'
|
||||||
|
journalctl -u 'hy2xs-fw-rollback-*' --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Разбирайтесь с systemd/D-Bus и повторяйте операцию. Отдельно ускорять ничего не
|
||||||
|
нужно: `systemd-run` требуется в preflight, поэтому без работающего systemd
|
||||||
|
операция всё равно не прошла бы — барьер лишь сообщает об этом раньше и точнее.
|
||||||
|
|
||||||
### Установка отказала с `firewall_guard_fired`
|
### Установка отказала с `firewall_guard_fired`
|
||||||
|
|
||||||
@@ -237,8 +269,8 @@ phase: firewall_guard_fired
|
|||||||
даже если smoke успел сойтись, — иначе сервер считался бы настроенным с чужими
|
даже если smoke успел сойтись, — иначе сервер считался бы настроенным с чужими
|
||||||
правилами, что особенно дорого при смене порта Hysteria, SSH или ACME.
|
правилами, что особенно дорого при смене порта Hysteria, SSH или ACME.
|
||||||
|
|
||||||
Окно guard — 45 секунд, и оно не обязано покрывать smoke. Причину ищите в том,
|
Окно guard — 45 секунд (плюс точность таймера, `AccuracySec=1s`), и оно не
|
||||||
почему проход в него не уложился:
|
обязано покрывать smoke. Причину ищите в том, почему проход в него не уложился:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
journalctl -u 'hy2xs-fw-rollback-*' --no-pager
|
journalctl -u 'hy2xs-fw-rollback-*' --no-pager
|
||||||
|
|||||||
@@ -81,10 +81,11 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
|
|||||||
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
|
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
|
||||||
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
|
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
|
||||||
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
||||||
| `firewall rollback guard armed: … fires in 45s` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
||||||
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
||||||
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
|
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
|
||||||
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
|
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
|
||||||
|
| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать |
|
||||||
|
|
||||||
Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая
|
Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая
|
||||||
smoke и не обязано его покрывать: доказательством служит не время, а маркер
|
smoke и не обязано его покрывать: доказательством служит не время, а маркер
|
||||||
@@ -169,14 +170,26 @@ operation lock … is held by install (pid 1234), which is no longer running; re
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
previous HY2XS operation is no longer running, but its firewall rollback guard
|
previous HY2XS operation is no longer running, but its firewall rollback guard
|
||||||
is still armed: hy2xs-fw-rollback-<op-id>.timer (active)
|
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
|
||||||
```
|
```
|
||||||
|
|
||||||
Условие старта — не «PID предыдущей мёртв», а «у предыдущей не осталось
|
Условие старта — не «PID предыдущей мёртв», а «у предыдущей не осталось
|
||||||
исполнителей, способных изменить систему». Ждать нужно не больше 45 секунд с
|
исполнителей, способных изменить систему». Ждать нужно не больше 45–46 секунд с
|
||||||
момента применения firewall; `failed` у guard покою не мешает и означает, что
|
момента применения firewall; `failed` у guard покою не мешает и означает, что
|
||||||
пора смотреть `journalctl -u 'hy2xs-fw-rollback-*'` и запускать `repair`.
|
пора смотреть `journalctl -u 'hy2xs-fw-rollback-*'` и запускать `repair`.
|
||||||
|
|
||||||
|
Второй отказ того же барьера выглядит иначе и требует другого действия:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unable to verify firewall rollback guard state; systemd query failed,
|
||||||
|
refusing to start a lifecycle operation
|
||||||
|
```
|
||||||
|
|
||||||
|
Здесь ждать бессмысленно. Барьер обязан **доказать** покой, а не предположить
|
||||||
|
его: отсутствие ответа systemd — это отсутствие наблюдения, а не наблюдение
|
||||||
|
отсутствия guard'а. Смотрите `systemctl status` и повторяйте операцию после того,
|
||||||
|
как systemd отвечает.
|
||||||
|
|
||||||
`/run/lock` — это tmpfs, поэтому перезагрузка снимает замок в любом случае.
|
`/run/lock` — это tmpfs, поэтому перезагрузка снимает замок в любом случае.
|
||||||
Удалять файл руками нужно только если в нём оказалось непонятное содержимое:
|
Удалять файл руками нужно только если в нём оказалось непонятное содержимое:
|
||||||
такой замок сознательно не переиспользуется автоматически — непонятый файл не
|
такой замок сознательно не переиспользуется автоматически — непонятый файл не
|
||||||
|
|||||||
@@ -186,10 +186,14 @@ sudo systemctl stop hysteria-server hy2xs-admin h-ui
|
|||||||
sudo systemctl disable hysteria-server hy2xs-admin h-ui
|
sudo systemctl disable hysteria-server hy2xs-admin h-ui
|
||||||
sudo systemctl reset-failed hysteria-server hy2xs-admin h-ui
|
sudo systemctl reset-failed hysteria-server hy2xs-admin h-ui
|
||||||
|
|
||||||
# таймеры отката firewall от незавершённой установки
|
# таймеры отката firewall от незавершённой установки.
|
||||||
sudo systemctl list-units --all 'hy2xs-fw-rollback-*'
|
# --plain обязателен: у юнита в состоянии failed первой колонкой идёт маркер `●`,
|
||||||
|
# и без флага такой юнит легко пропустить — а это ровно те, что остались после
|
||||||
|
# аварийной установки.
|
||||||
|
sudo systemctl list-units --all --plain 'hy2xs-fw-rollback-*'
|
||||||
# для каждого найденного юнита:
|
# для каждого найденного юнита:
|
||||||
# sudo systemctl stop <unit> && sudo systemctl disable <unit>
|
# sudo systemctl stop <unit> && sudo systemctl disable <unit>
|
||||||
|
# sudo systemctl reset-failed <unit>
|
||||||
# sudo rm -f /etc/systemd/system/<unit>
|
# sudo rm -f /etc/systemd/system/<unit>
|
||||||
|
|
||||||
sudo rm -f /etc/systemd/system/hysteria-server.service \
|
sudo rm -f /etc/systemd/system/hysteria-server.service \
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { fileExists, readText } from "../lib/fs";
|
|||||||
import { info, setOperationContext } from "../lib/log";
|
import { info, setOperationContext } from "../lib/log";
|
||||||
import { runReadOnly } from "../lib/process";
|
import { runReadOnly } from "../lib/process";
|
||||||
import { getPlatformProfile } from "../platform/profile";
|
import { getPlatformProfile } from "../platform/profile";
|
||||||
import { detectFirewallEntrypointKind } from "../steps/firewall";
|
import { detectFirewallEntrypointKind, inspectRollbackGuard } from "../steps/firewall";
|
||||||
import { INSTALL_STATE_PATH, detectGenerationProblems } from "../lib/installState";
|
import { INSTALL_STATE_PATH, detectGenerationProblems } from "../lib/installState";
|
||||||
import { describeOperationInProgress } from "../lib/operationLock";
|
import { describeOperationInProgress } from "../lib/operationLock";
|
||||||
|
|
||||||
@@ -51,7 +51,20 @@ export async function status(_options: CommonOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rollbackGuardUnits = (await runReadOnly`sh -c 'systemctl list-units --all --no-legend "hy2xs-fw-rollback-*.timer" "hy2xs-fw-rollback-*.service" 2>/dev/null || true'`).trim();
|
// Состояние guard'а берётся у того же наблюдателя, что и у барьера покоя.
|
||||||
|
//
|
||||||
|
// Здесь стояла вторая копия листинга, и она расходилась с барьером по трём
|
||||||
|
// пунктам сразу: без `--plain` (у `failed`-юнита первой колонкой идёт `●`),
|
||||||
|
// с `|| true` (отказ systemd превращался в «guard'ов нет») и без разбора
|
||||||
|
// состояний — вооружённым считался любой найденный юнит. Практический эффект:
|
||||||
|
// аварийно сработавший guard оставляет `failed`-сервис загруженным до
|
||||||
|
// `reset-failed`, и status вечно показывал `firewall_state: guard_active`,
|
||||||
|
// пока барьер тот же самый юнит считал покоем и разрешал `repair`.
|
||||||
|
//
|
||||||
|
// Отчёт при этом остаётся отчётом: status замок не берёт и существует в том
|
||||||
|
// числе для сломанного хоста, поэтому «спросить не удалось» попадает в JSON
|
||||||
|
// значением `unknown`, а не отказом команды.
|
||||||
|
const rollbackGuard = await inspectRollbackGuard();
|
||||||
|
|
||||||
// Идущая операция обязана быть видна в отчёте: без неё оператор разбирает
|
// Идущая операция обязана быть видна в отчёте: без неё оператор разбирает
|
||||||
// промежуточное состояние транзакции как окончательное — например, читает
|
// промежуточное состояние транзакции как окончательное — например, читает
|
||||||
@@ -66,7 +79,11 @@ export async function status(_options: CommonOptions): Promise<void> {
|
|||||||
const installStateEffective = installState?.installed
|
const installStateEffective = installState?.installed
|
||||||
? "installed"
|
? "installed"
|
||||||
: (installPhase === "unknown" ? "failed" : installPhase);
|
: (installPhase === "unknown" ? "failed" : installPhase);
|
||||||
const rollbackGuardActive = rollbackGuardUnits.length > 0;
|
// «Вооружён» — это ровно `pending`. Отработавший guard (`inactive`/`failed`,
|
||||||
|
// а для таймера ещё и `elapsed`) систему уже не изменит, и объявлять его
|
||||||
|
// активным значит противоречить барьеру, который в этот момент разрешает
|
||||||
|
// операцию.
|
||||||
|
const rollbackGuardActive = rollbackGuard.kind === "pending";
|
||||||
// Отдельное поле: маркер может присутствовать и быть «installed», но
|
// Отдельное поле: маркер может присутствовать и быть «installed», но
|
||||||
// принадлежать другому поколению продукта.
|
// принадлежать другому поколению продукта.
|
||||||
const generationProblems = installState ? detectGenerationProblems(installState) : [];
|
const generationProblems = installState ? detectGenerationProblems(installState) : [];
|
||||||
@@ -99,10 +116,22 @@ export async function status(_options: CommonOptions): Promise<void> {
|
|||||||
install_state_generation_problems: generationProblems,
|
install_state_generation_problems: generationProblems,
|
||||||
operation_in_progress: operationInProgress,
|
operation_in_progress: operationInProgress,
|
||||||
rollback_guard_active: rollbackGuardActive,
|
rollback_guard_active: rollbackGuardActive,
|
||||||
rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [],
|
rollback_guard_state: rollbackGuard.kind,
|
||||||
|
rollback_guard_reason: rollbackGuard.kind === "unknown" ? rollbackGuard.reason : null,
|
||||||
|
rollback_guard_units: rollbackGuard.kind === "unknown"
|
||||||
|
? []
|
||||||
|
: rollbackGuard.units.map((unit) => ({
|
||||||
|
unit: unit.unit,
|
||||||
|
active_state: unit.activeState,
|
||||||
|
sub_state: unit.subState
|
||||||
|
})),
|
||||||
runtime_state: runtimeState,
|
runtime_state: runtimeState,
|
||||||
install_state_effective: installStateEffective,
|
install_state_effective: installStateEffective,
|
||||||
firewall_state: rollbackGuardActive ? "guard_active" : firewall,
|
// Недоказуемое состояние guard'а называется своим именем: «guard'а нет» —
|
||||||
|
// это утверждение, и выдавать за него отсутствие ответа systemd нельзя.
|
||||||
|
firewall_state: rollbackGuardActive
|
||||||
|
? "guard_active"
|
||||||
|
: (rollbackGuard.kind === "unknown" ? "guard_unknown" : firewall),
|
||||||
human_status: humanStatus
|
human_status: humanStatus
|
||||||
};
|
};
|
||||||
info(`status report: ${JSON.stringify(result)}`);
|
info(`status report: ${JSON.stringify(result)}`);
|
||||||
|
|||||||
@@ -12,14 +12,26 @@
|
|||||||
*
|
*
|
||||||
* Поэтому здесь нет универсального раннера. Есть два набора:
|
* Поэтому здесь нет универсального раннера. Есть два набора:
|
||||||
*
|
*
|
||||||
* runReadOnly / runReadOnlySecret
|
* runReadOnly / runReadOnlySecret / runReadOnlyArgv
|
||||||
* наблюдение за системой. Guard не трогает — они разрешены в любой фазе.
|
* наблюдение за системой. Guard не трогает — они разрешены в любой фазе.
|
||||||
*
|
*
|
||||||
* runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw /
|
* runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw /
|
||||||
* runMutatingStatus
|
* runMutatingStatus / runMutatingArgv
|
||||||
* всё, что может изменить хост. Каждый спрашивает разрешения у guard'а.
|
* всё, что может изменить хост. Каждый спрашивает разрешения у guard'а.
|
||||||
*
|
*
|
||||||
* Выбор набора — сознательное решение на месте вызова, а не умолчание.
|
* Выбор набора — сознательное решение на месте вызова, а не умолчание.
|
||||||
|
*
|
||||||
|
* Внутри каждого набора есть две ФОРМЫ, и различие между ними тоже
|
||||||
|
* содержательное. Tagged template собирает строку для `sh -c`: аргументы
|
||||||
|
* проходят через shellQuote, а сама команда остаётся shell-строкой, поэтому
|
||||||
|
* ей доступны конвейеры и перенаправления. Форма `*Argv` shell не запускает
|
||||||
|
* вовсе: argv уходит в exec как есть.
|
||||||
|
*
|
||||||
|
* Вторая форма появилась не ради экономии процесса. Команда, собранная
|
||||||
|
* интерполяцией, существует только в момент запуска, и её контракт («у
|
||||||
|
* транзиентного таймера заданы именно эти свойства») проверяется грепом по
|
||||||
|
* исходнику. Готовый argv строит чистая функция, и тот же контракт становится
|
||||||
|
* обычным тестом на значение.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { assertMutationAllowed } from "./guard";
|
import { assertMutationAllowed } from "./guard";
|
||||||
@@ -80,6 +92,38 @@ export async function runReadOnlySecret(command: TemplateStringsArray, ...args:
|
|||||||
return capture(renderCommand(command, args), false);
|
return capture(renderCommand(command, args), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeArgv(argv: string[]): string {
|
||||||
|
return argv.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertArgv(argv: string[], runner: string): void {
|
||||||
|
if (argv.length === 0) {
|
||||||
|
throw new Error(`${runner}: argv is empty`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Наблюдение готовым argv, без shell.
|
||||||
|
*
|
||||||
|
* Нужно там, где список аргументов вычисляется (набор `--property=` зависит от
|
||||||
|
* того, что именно спрашивают у юнита). Собирать такой список интерполяцией в
|
||||||
|
* shell-строку означало бы либо потерять квотирование, либо склеить весь набор
|
||||||
|
* в один аргумент.
|
||||||
|
*/
|
||||||
|
export async function runReadOnlyArgv(argv: string[]): Promise<string> {
|
||||||
|
assertArgv(argv, "runReadOnlyArgv");
|
||||||
|
const subprocess = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" });
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
new Response(subprocess.stdout).text(),
|
||||||
|
new Response(subprocess.stderr).text(),
|
||||||
|
subprocess.exited
|
||||||
|
]);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error(`command failed (${exitCode}): ${describeArgv(argv)}\n${stderr.trim()}`);
|
||||||
|
}
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/** Мутация с захватом вывода (`mktemp -d`, `install -d`, ...). */
|
/** Мутация с захватом вывода (`mktemp -d`, `install -d`, ...). */
|
||||||
export async function runMutating(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
|
export async function runMutating(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
|
||||||
const rendered = renderCommand(command, args);
|
const rendered = renderCommand(command, args);
|
||||||
@@ -102,6 +146,32 @@ export async function runMutatingVisible(command: TemplateStringsArray, ...args:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутация готовым argv, без shell.
|
||||||
|
*
|
||||||
|
* Существует ради команд, у которых важен ТОЧНЫЙ набор аргументов, а не удобство
|
||||||
|
* записи. Канонический пример — взведение транзиентного guard'а: у него есть
|
||||||
|
* контракт («заданы `RemainAfterElapse` и `AccuracySec`»), от которого зависит
|
||||||
|
* поведение барьера покоя и обещанное оператору окно отката.
|
||||||
|
*
|
||||||
|
* Пока команда собиралась интерполяцией в shell-строку, этот контракт нельзя
|
||||||
|
* было проверить иначе как грепом по исходнику: значения существовали только
|
||||||
|
* внутри вызова. Чистая функция, возвращающая argv, делает его обычным
|
||||||
|
* значением, а отсутствие shell заодно убирает вопрос о квотировании из команды,
|
||||||
|
* которая создаёт systemd-юнит с именем из данных операции.
|
||||||
|
*/
|
||||||
|
export async function runMutatingArgv(argv: string[]): Promise<void> {
|
||||||
|
assertArgv(argv, "runMutatingArgv");
|
||||||
|
const rendered = describeArgv(argv);
|
||||||
|
assertMutationAllowed(`runMutatingArgv(${rendered})`);
|
||||||
|
info(`running: ${rendered}`);
|
||||||
|
const subprocess = Bun.spawn(argv, { stdout: "inherit", stderr: "inherit" });
|
||||||
|
const exitCode = await subprocess.exited;
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error(`command failed (${exitCode}): ${rendered}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Мутация многострочным скриптом (`sh -eu -c`). */
|
/** Мутация многострочным скриптом (`sh -eu -c`). */
|
||||||
export async function runMutatingRaw(command: string): Promise<void> {
|
export async function runMutatingRaw(command: string): Promise<void> {
|
||||||
assertMutationAllowed("runMutatingRaw(...)");
|
assertMutationAllowed("runMutatingRaw(...)");
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { RuntimeContext } from "../types/context";
|
import type { RuntimeContext } from "../types/context";
|
||||||
import { fileExists, readText, renderTemplate, writeText } from "../lib/fs";
|
import { fileExists, readText, renderTemplate, writeText } from "../lib/fs";
|
||||||
import { fail, info } from "../lib/log";
|
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";
|
import { runRollbackStages, type RollbackStage } from "../lib/rollback";
|
||||||
|
|
||||||
type NftEntrypointKind =
|
type NftEntrypointKind =
|
||||||
@@ -29,6 +35,41 @@ const HY2XS_NFT_CANDIDATE = `${HY2XS_NFT_PATH}.candidate`;
|
|||||||
*/
|
*/
|
||||||
const FIREWALL_ROLLBACK_DEADLINE = "45s";
|
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 НАЧАЛ выполняться.
|
* Маркер факта: автоматический откат firewall НАЧАЛ выполняться.
|
||||||
*
|
*
|
||||||
@@ -74,14 +115,57 @@ export class FirewallGuardFiredError extends Error {
|
|||||||
const ROLLBACK_UNIT_PREFIX = "hy2xs-fw-rollback-";
|
const ROLLBACK_UNIT_PREFIX = "hy2xs-fw-rollback-";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Состояния, в которых guard ещё СПОСОБЕН изменить систему.
|
* Состояния, в которых guard заведомо БОЛЬШЕ НИЧЕГО не сделает.
|
||||||
*
|
*
|
||||||
* `failed` и `inactive` сюда не входят намеренно. Guard, который уже отработал
|
* Список именно такой — белый, а не чёрный, и это принципиально. Раньше
|
||||||
* (успешно или нет), больше ничего не сделает, а отказавший юнит — это как раз
|
* перечислялись непокойные состояния (`active`, `activating`, `deactivating`,
|
||||||
* повод запустить `repair`. Барьер, отказывающий по `failed`, блокировал бы
|
* `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) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "PendingRecoveryError";
|
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 {
|
function rollbackRoot(opId: string): string {
|
||||||
return `/run/hy2xs/rollback/${opId}`;
|
return `/run/hy2xs/rollback/${opId}`;
|
||||||
}
|
}
|
||||||
@@ -201,8 +304,64 @@ export function parseNftablesServiceState(raw: string): NftablesServiceState | n
|
|||||||
return { unitFileState, activeState };
|
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}`;
|
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> {
|
async function armRollbackGuard(opId: string): Promise<void> {
|
||||||
assertSafeOperationKey(opId);
|
assertSafeOperationKey(opId);
|
||||||
const scriptPath = autoRollbackScriptPath(opId);
|
await writeText(autoRollbackScriptPath(opId), buildAutoRollbackScript(opId), 0o700);
|
||||||
await writeText(scriptPath, buildAutoRollbackScript(opId), 0o700);
|
|
||||||
|
|
||||||
const unit = rollbackUnit(opId);
|
await runMutatingArgv(buildArmGuardArgv(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}`;
|
|
||||||
info(
|
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 живёт в
|
* список юнитов, и «список пуст» одинаково означало и «guard'ов нет», и «спросить
|
||||||
* preflight, где отказ будет понятнее и точнее.
|
* не удалось». Различие между ними — это различие между доказанным покоем и
|
||||||
|
* отсутствием доказательства, то есть ровно то, ради чего барьер существует.
|
||||||
*/
|
*/
|
||||||
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;
|
let listed: string;
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
info(
|
return { kind: "unknown", reason: `systemctl list-units failed: ${describeError(error)}` };
|
||||||
`unable to list firewall rollback guard units: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return listed
|
const units: GuardUnitState[] = [];
|
||||||
.split("\n")
|
for (const unit of parseRollbackGuardUnitNames(listed)) {
|
||||||
.map((line) => line.trim().split(/\s+/)[0] ?? "")
|
let properties: Record<string, string>;
|
||||||
.filter((unit) => unit.startsWith(ROLLBACK_UNIT_PREFIX));
|
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` ровно тогда, когда он нужен.
|
* переходным состояниям заблокировал бы `repair` ровно тогда, когда он нужен.
|
||||||
*/
|
*/
|
||||||
export async function assertNoPendingRollbackGuard(): Promise<void> {
|
export async function assertNoPendingRollbackGuard(
|
||||||
const pending: string[] = [];
|
probe: SystemdUnitProbe = defaultSystemdUnitProbe
|
||||||
|
): Promise<void> {
|
||||||
|
const inspection = await inspectRollbackGuard(probe);
|
||||||
|
|
||||||
for (const unit of await listRollbackGuardUnits()) {
|
if (inspection.kind === "quiescent") {
|
||||||
const state = await readUnitProperty(unit, "ActiveState");
|
|
||||||
if ((GUARD_PENDING_STATES as readonly string[]).includes(state)) {
|
|
||||||
pending.push(`${unit} (${state})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pending.length === 0) {
|
|
||||||
return;
|
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(
|
throw new PendingRecoveryError(
|
||||||
"previous HY2XS operation is no longer running, but its firewall rollback guard is still armed: " +
|
"previous HY2XS operation is no longer running, but its firewall rollback guard is still armed: " +
|
||||||
`${pending.join(", ")}. ` +
|
`${describeGuardUnits(inspection.units)}. ` +
|
||||||
"Такой guard способен вернуть прежний firewall уже посреди новой операции. " +
|
"Такой guard способен вернуть прежний firewall уже посреди новой операции. " +
|
||||||
"Дождитесь его завершения (окно — 45 секунд с момента применения firewall) и повторите; " +
|
`Дождитесь его завершения (окно — ${FIREWALL_ROLLBACK_DEADLINE} с момента применения firewall, ` +
|
||||||
|
`точность таймера — ${FIREWALL_ROLLBACK_ACCURACY}) и повторите; ` +
|
||||||
"состояние guard видно в `hy2xs-orchestrator status` и в `journalctl -u 'hy2xs-fw-rollback-*'`."
|
"состояние guard видно в `hy2xs-orchestrator status` и в `journalctl -u 'hy2xs-fw-rollback-*'`."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,19 @@ import { delimiter, dirname, join } from "node:path";
|
|||||||
import { classifyFailure } from "../src/commands/install";
|
import { classifyFailure } from "../src/commands/install";
|
||||||
import {
|
import {
|
||||||
FirewallGuardFiredError,
|
FirewallGuardFiredError,
|
||||||
|
GuardStateUnknownError,
|
||||||
|
OperationBarrierError,
|
||||||
|
PendingRecoveryError,
|
||||||
|
type SystemdUnitProbe,
|
||||||
|
assertNoPendingRollbackGuard,
|
||||||
|
buildArmGuardArgv,
|
||||||
buildAutoRollbackScript,
|
buildAutoRollbackScript,
|
||||||
|
describeGuardUnits,
|
||||||
|
guardUnitIsQuiescent,
|
||||||
|
inspectRollbackGuard,
|
||||||
operationKeyFor,
|
operationKeyFor,
|
||||||
parseNftablesServiceState,
|
parseNftablesServiceState,
|
||||||
|
parseRollbackGuardUnitNames,
|
||||||
renderNftablesEntrypoint,
|
renderNftablesEntrypoint,
|
||||||
renderNftablesServiceState
|
renderNftablesServiceState
|
||||||
} from "../src/steps/firewall";
|
} from "../src/steps/firewall";
|
||||||
@@ -447,7 +457,7 @@ describe("disarm доказывает снятие guard'а, а не сообщ
|
|||||||
* и корректность зависела бы от того, что `.789Z` ни с чем не совпало.
|
* и корректность зависела бы от того, что `.789Z` ни с чем не совпало.
|
||||||
*/
|
*/
|
||||||
test("имя юнита guard'а не зависит от мангления systemd", () => {
|
test("имя юнита guard'а не зависит от мангления systemd", () => {
|
||||||
expect(firewallSource).toContain("systemd-run --unit ${`${unit}.service`}");
|
expect(buildArmGuardArgv(OP_ID)).toContain(`--unit=hy2xs-fw-rollback-${OP_ID}.service`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("остановка guard'а стала стадией отката с отчётом", () => {
|
test("остановка guard'а стала стадией отката с отчётом", () => {
|
||||||
@@ -457,39 +467,249 @@ describe("disarm доказывает снятие guard'а, а не сообщ
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("взведение guard'а задаёт свойства таймера явно", () => {
|
||||||
|
/**
|
||||||
|
* У этой команды есть контракт, от которого зависят два чужих утверждения, и
|
||||||
|
* оба до сих пор проверялись грепом по исходнику.
|
||||||
|
*
|
||||||
|
* `AccuracySec` — обещанное оператору окно. `systemd.timer` разрешает себе
|
||||||
|
* сработать в интервале `[цель; цель + AccuracySec]`, а умолчание — `1min`.
|
||||||
|
* То есть guard, про который README, docs и текст отказа барьера говорят «45
|
||||||
|
* секунд», по контракту systemd мог сработать через 105.
|
||||||
|
*
|
||||||
|
* `RemainAfterElapse` — право барьера считать исчезновение юнита покоем.
|
||||||
|
* systemd-run выставляет `false` сам, но инвариант, который держится на чужом
|
||||||
|
* умолчании, нигде не записан и ничем не проверяется.
|
||||||
|
*/
|
||||||
|
const argv = buildArmGuardArgv(OP_ID);
|
||||||
|
|
||||||
|
test("окно отката задано вместе с точностью таймера", () => {
|
||||||
|
expect(argv).toContain("--on-active=45s");
|
||||||
|
expect(argv).toContain("--timer-property=AccuracySec=1s");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("отработавший таймер обязан выгрузиться", () => {
|
||||||
|
expect(argv).toContain("--timer-property=RemainAfterElapse=no");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("команда собрана целиком, а не по кускам", () => {
|
||||||
|
expect(argv).toEqual([
|
||||||
|
"systemd-run",
|
||||||
|
`--unit=hy2xs-fw-rollback-${OP_ID}.service`,
|
||||||
|
"--on-active=45s",
|
||||||
|
"--timer-property=RemainAfterElapse=no",
|
||||||
|
"--timer-property=AccuracySec=1s",
|
||||||
|
"/bin/sh",
|
||||||
|
`/run/hy2xs/rollback/${OP_ID}/auto-rollback.sh`
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ключ операции подставляется в имя systemd-юнита и в путь скрипта. Без shell
|
||||||
|
// квотирование не спасает — спасает отказ.
|
||||||
|
test("небезопасный ключ операции отвергается до запуска", () => {
|
||||||
|
expect(() => buildArmGuardArgv("op id")).toThrow(/unsafe operation key/);
|
||||||
|
expect(() => buildArmGuardArgv("op'; rm -rf /")).toThrow(/unsafe operation key/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Готовый argv уходит в exec как есть: shell в этой команде не участвует.
|
||||||
|
test("взведение идёт без shell", () => {
|
||||||
|
const arm = firewallSource.slice(
|
||||||
|
firewallSource.indexOf("async function armRollbackGuard"),
|
||||||
|
firewallSource.indexOf("export async function applyFirewall")
|
||||||
|
);
|
||||||
|
expect(arm).toContain("runMutatingArgv(buildArmGuardArgv(opId))");
|
||||||
|
expect(arm).not.toMatch(/runMutatingVisible`/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("барьер покоя между операциями", () => {
|
describe("барьер покоя между операциями", () => {
|
||||||
/**
|
/**
|
||||||
* Стык двух защитных механизмов. Замок защищает production paths, пока жив
|
* Стык двух защитных механизмов. Замок защищает production paths, пока жив
|
||||||
* процесс-держатель; rollback guard — отдельный systemd-объект, переживающий
|
* процесс-держатель; rollback guard — отдельный systemd-объект, переживающий
|
||||||
* свой процесс. Аварийно умершая операция оставляет вооружённый guard,
|
* свой процесс. Аварийно умершая операция оставляет вооружённый guard,
|
||||||
* который возвращает прежний firewall уже посреди следующей операции.
|
* который возвращает прежний firewall уже посреди следующей операции.
|
||||||
|
*
|
||||||
|
* Проверки здесь поведенческие. Прежние сверяли ТЕКСТ функции, и именно
|
||||||
|
* поэтому пропустили инверсию смысла: тест утверждал, что в теле есть
|
||||||
|
* `return [];`, строка была на месте, а решение при этом стало неверным.
|
||||||
*/
|
*/
|
||||||
const body = firewallSource.slice(
|
const TIMER = "hy2xs-fw-rollback-2026-08-30T12-34-56.789Z.timer";
|
||||||
firewallSource.indexOf("export async function assertNoPendingRollbackGuard"),
|
const SERVICE = "hy2xs-fw-rollback-2026-08-30T12-34-56.789Z.service";
|
||||||
firewallSource.indexOf("function firewallRollbackIsInactive")
|
|
||||||
);
|
|
||||||
|
|
||||||
test("вооружённый guard предыдущей операции запрещает новую", () => {
|
/** Probe, отвечающий заранее заданными состояниями. */
|
||||||
expect(body).toContain("PendingRecoveryError");
|
function probeWith(states: Record<string, { ActiveState: string; SubState: string }>): SystemdUnitProbe {
|
||||||
expect(body).toContain("readUnitProperty(unit, \"ActiveState\")");
|
return {
|
||||||
|
async listGuardUnits() {
|
||||||
|
return Object.keys(states)
|
||||||
|
.map((unit) => `${unit} loaded active running HY2XS firewall rollback guard`)
|
||||||
|
.join("\n");
|
||||||
|
},
|
||||||
|
async showProperties(unit) {
|
||||||
|
const state = states[unit];
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`unexpected unit: ${unit}`);
|
||||||
|
}
|
||||||
|
return { ...state };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const failingProbe: SystemdUnitProbe = {
|
||||||
|
async listGuardUnits(): Promise<string> {
|
||||||
|
throw new Error("Failed to connect to bus: No such file or directory");
|
||||||
|
},
|
||||||
|
async showProperties(): Promise<Record<string, string>> {
|
||||||
|
throw new Error("Failed to connect to bus: No such file or directory");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test("guard'ов нет — операция разрешена", async () => {
|
||||||
|
const inspection = await inspectRollbackGuard(probeWith({}));
|
||||||
|
expect(inspection.kind).toBe("quiescent");
|
||||||
|
await expect(assertNoPendingRollbackGuard(probeWith({}))).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// `failed` и `inactive` — покой: guard уже отработал и больше ничего не
|
test("взведённый таймер предыдущей операции запрещает новую", async () => {
|
||||||
// сделает. Отказ по `failed` заблокировал бы `repair` ровно тогда, когда он
|
const probe = probeWith({ [TIMER]: { ActiveState: "active", SubState: "waiting" } });
|
||||||
// нужен для устранения последствий.
|
|
||||||
test("покоем считаются inactive и failed, а не только inactive", () => {
|
const inspection = await inspectRollbackGuard(probe);
|
||||||
expect(firewallSource).toContain(
|
expect(inspection.kind).toBe("pending");
|
||||||
'const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const'
|
|
||||||
);
|
const error = await assertNoPendingRollbackGuard(probe).catch((caught: unknown) => caught);
|
||||||
|
expect(error).toBeInstanceOf(PendingRecoveryError);
|
||||||
|
expect((error as Error).message).toContain(TIMER);
|
||||||
|
expect((error as Error).message).toContain("active/waiting");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("отказ запроса к systemd не выдаётся за наличие guard", () => {
|
test("выполняющийся прямо сейчас откат — тоже непокой", async () => {
|
||||||
const listing = firewallSource.slice(
|
const probe = probeWith({
|
||||||
firewallSource.indexOf("export async function listRollbackGuardUnits"),
|
[TIMER]: { ActiveState: "active", SubState: "running" },
|
||||||
firewallSource.indexOf("export async function assertNoPendingRollbackGuard")
|
[SERVICE]: { ActiveState: "activating", SubState: "start" }
|
||||||
|
});
|
||||||
|
await expect(assertNoPendingRollbackGuard(probe)).rejects.toBeInstanceOf(PendingRecoveryError);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отказ запроса к systemd — ОТСУТСТВИЕ наблюдения, а не наблюдение покоя.
|
||||||
|
*
|
||||||
|
* Прежний код возвращал пустой список и тем самым принимал невозможность
|
||||||
|
* получить доказательство за положительный результат:
|
||||||
|
*
|
||||||
|
* systemd жив, старый таймер взведён
|
||||||
|
* -> systemctl временно отказывает
|
||||||
|
* -> список пуст -> барьер считает систему спокойной
|
||||||
|
* -> новая операция меняет firewall, старый таймер срабатывает поверх
|
||||||
|
*/
|
||||||
|
test("отказ запроса к systemd запрещает операцию, а не разрешает её", async () => {
|
||||||
|
const inspection = await inspectRollbackGuard(failingProbe);
|
||||||
|
expect(inspection.kind).toBe("unknown");
|
||||||
|
|
||||||
|
const error = await assertNoPendingRollbackGuard(failingProbe).catch((caught: unknown) => caught);
|
||||||
|
expect(error).toBeInstanceOf(GuardStateUnknownError);
|
||||||
|
expect((error as Error).message).toContain("refusing to start a lifecycle operation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("отказ на одном юните тоже делает картину неполной", async () => {
|
||||||
|
const probe: SystemdUnitProbe = {
|
||||||
|
async listGuardUnits() {
|
||||||
|
return `${TIMER} loaded active waiting guard\n${SERVICE} loaded inactive dead guard`;
|
||||||
|
},
|
||||||
|
async showProperties(unit) {
|
||||||
|
if (unit === SERVICE) {
|
||||||
|
throw new Error("Connection timed out");
|
||||||
|
}
|
||||||
|
return { ActiveState: "inactive", SubState: "dead" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(assertNoPendingRollbackGuard(probe)).rejects.toBeInstanceOf(GuardStateUnknownError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обе причины отказа — про то, что операцию нельзя начинать, и вызывающий
|
||||||
|
// вправе не различать их по конкретному типу.
|
||||||
|
test("оба отказа барьера имеют общего предка", async () => {
|
||||||
|
await expect(assertNoPendingRollbackGuard(failingProbe)).rejects.toBeInstanceOf(OperationBarrierError);
|
||||||
|
await expect(
|
||||||
|
assertNoPendingRollbackGuard(probeWith({ [TIMER]: { ActiveState: "active", SubState: "waiting" } }))
|
||||||
|
).rejects.toBeInstanceOf(OperationBarrierError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отработавший guard больше ничего не сделает. Отказ по `failed` заблокировал
|
||||||
|
// бы `repair` ровно тогда, когда им чинят последствия.
|
||||||
|
test("покой — это inactive и failed", async () => {
|
||||||
|
await expect(
|
||||||
|
assertNoPendingRollbackGuard(
|
||||||
|
probeWith({
|
||||||
|
[TIMER]: { ActiveState: "inactive", SubState: "dead" },
|
||||||
|
[SERVICE]: { ActiveState: "failed", SubState: "failed" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Политика покоя — белый список, а не чёрный.
|
||||||
|
*
|
||||||
|
* Прежняя перечисляла непокойные состояния, то есть объявляла безопасным
|
||||||
|
* любое, которого автор не назвал. systemd 257 знает `maintenance` и
|
||||||
|
* `refreshing` помимо перечисленных, и список может пополниться снова.
|
||||||
|
*/
|
||||||
|
test("незнакомое состояние systemd блокирует операцию, а не проходит молча", async () => {
|
||||||
|
for (const activeState of ["maintenance", "refreshing", "some-future-state"]) {
|
||||||
|
await expect(
|
||||||
|
assertNoPendingRollbackGuard(probeWith({ [SERVICE]: { ActiveState: activeState, SubState: "x" } }))
|
||||||
|
).rejects.toBeInstanceOf(PendingRecoveryError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отработавший таймер не должен блокировать операцию навсегда.
|
||||||
|
*
|
||||||
|
* `TIMER_ELAPSED` в systemd отображается в `UNIT_ACTIVE` так же, как
|
||||||
|
* `TIMER_WAITING`, — различает их только SubState. У наших guard'ов такого не
|
||||||
|
* бывает (`RemainAfterElapse=no`), но инвариант «барьер не залипает» не
|
||||||
|
* должен зависеть от того, чем именно создан таймер.
|
||||||
|
*/
|
||||||
|
test("таймер в elapsed — покой, а не вечная блокировка", async () => {
|
||||||
|
await expect(
|
||||||
|
assertNoPendingRollbackGuard(probeWith({ [TIMER]: { ActiveState: "active", SubState: "elapsed" } }))
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Исключение узкое: у сервиса тот же SubState ничего не значит.
|
||||||
|
test("исключение по SubState действует только для таймера", async () => {
|
||||||
|
expect(guardUnitIsQuiescent({ unit: TIMER, activeState: "active", subState: "elapsed" })).toBe(true);
|
||||||
|
expect(guardUnitIsQuiescent({ unit: SERVICE, activeState: "active", subState: "elapsed" })).toBe(false);
|
||||||
|
expect(guardUnitIsQuiescent({ unit: TIMER, activeState: "active", subState: "waiting" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разбор вывода `systemctl list-units`.
|
||||||
|
*
|
||||||
|
* У юнита в состоянии `failed` первой колонкой идёт маркер `●`, поэтому
|
||||||
|
* «имя юнита — первое поле строки» теряло бы именно аварийно сработавший
|
||||||
|
* guard. Ищется первое поле, начинающееся с префикса.
|
||||||
|
*/
|
||||||
|
test("имя юнита находится и при маркере failed в первой колонке", () => {
|
||||||
|
const listed = [
|
||||||
|
`${TIMER} loaded active waiting HY2XS firewall rollback guard`,
|
||||||
|
`● ${SERVICE} loaded failed failed HY2XS firewall rollback guard`,
|
||||||
|
"",
|
||||||
|
"hysteria-server.service loaded active running Hysteria"
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(parseRollbackGuardUnitNames(listed)).toEqual([TIMER, SERVICE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("посторонние юниты и пустой вывод не попадают в разбор", () => {
|
||||||
|
expect(parseRollbackGuardUnitNames("")).toEqual([]);
|
||||||
|
expect(parseRollbackGuardUnitNames("nftables.service loaded active exited nftables\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("описание юнита называет и состояние, и подсостояние", () => {
|
||||||
|
expect(describeGuardUnits([{ unit: TIMER, activeState: "active", subState: "waiting" }])).toBe(
|
||||||
|
`${TIMER} (active/waiting)`
|
||||||
);
|
);
|
||||||
expect(listing).toContain("unable to list firewall rollback guard units");
|
|
||||||
expect(listing).toContain("return [];");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("барьер проверяется при любом захвате замка, а не только при устаревшем", () => {
|
test("барьер проверяется при любом захвате замка, а не только при устаревшем", () => {
|
||||||
|
|||||||
@@ -380,9 +380,15 @@ run_clean_install_acceptance() {
|
|||||||
|| fail "acceptance: process.ts must expose an explicit mutating runner"
|
|| fail "acceptance: process.ts must expose an explicit mutating runner"
|
||||||
! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \
|
! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \
|
||||||
|| fail "acceptance: the pre-split runner names must not come back"
|
|| fail "acceptance: the pre-split runner names must not come back"
|
||||||
local unguarded_runner
|
# Порог считается от числа мутирующих раннеров, а не задан константой: новый
|
||||||
unguarded_runner="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)"
|
# раннер обязан приносить с собой и проверку guard'а, а не проходить под
|
||||||
[ "$unguarded_runner" -ge 4 ] \
|
# запасом, оставленным предыдущей правкой.
|
||||||
|
local mutating_runners guarded_runners
|
||||||
|
mutating_runners="$(grep -c 'export async function runMutating' orchestrator/src/lib/process.ts || true)"
|
||||||
|
guarded_runners="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)"
|
||||||
|
[ "$mutating_runners" -ge 5 ] \
|
||||||
|
|| fail "acceptance: набор мутирующих раннеров неожиданно сократился ($mutating_runners)"
|
||||||
|
[ "$guarded_runners" -gt "$mutating_runners" ] \
|
||||||
|| fail "acceptance: every mutating runner must ask the read-only guard for permission"
|
|| fail "acceptance: every mutating runner must ask the read-only guard for permission"
|
||||||
|
|
||||||
log_step "Acceptance: doctor is read-only by runtime invariant, not by convention"
|
log_step "Acceptance: doctor is read-only by runtime invariant, not by convention"
|
||||||
@@ -413,7 +419,7 @@ run_clean_install_acceptance() {
|
|||||||
const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
|
const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
|
||||||
const calls = source.split(/\r?\n/)
|
const calls = source.split(/\r?\n/)
|
||||||
.filter((line) => !line.trimStart().startsWith("//"))
|
.filter((line) => !line.trimStart().startsWith("//"))
|
||||||
.filter((line) => /\brunMutating[A-Za-z]*`/.test(line));
|
.filter((line) => /\brunMutating[A-Za-z]*[`(]/.test(line));
|
||||||
if (calls.length !== 1) {
|
if (calls.length !== 1) {
|
||||||
throw new Error("мутирующих вызовов в smoke: " + calls.length + "\n" + calls.join("\n"));
|
throw new Error("мутирующих вызовов в smoke: " + calls.length + "\n" + calls.join("\n"));
|
||||||
}
|
}
|
||||||
@@ -1421,7 +1427,7 @@ run_transaction_boundary_acceptance() {
|
|||||||
if (!body.includes(claim)) throw new Error("проверка эффективного firewall не сверяет: " + claim);
|
if (!body.includes(claim)) throw new Error("проверка эффективного firewall не сверяет: " + claim);
|
||||||
}
|
}
|
||||||
// Проверка выполняется и в doctor, то есть под read-only guard.
|
// Проверка выполняется и в doctor, то есть под read-only guard.
|
||||||
if (/runMutating[A-Za-z]*`/.test(body)) throw new Error("проверка эффективного firewall мутирует систему");
|
if (/runMutating[A-Za-z]*[`(]/.test(body)) throw new Error("проверка эффективного firewall мутирует систему");
|
||||||
' || fail "acceptance: проверка эффективного firewall нарушает свой контракт"
|
' || fail "acceptance: проверка эффективного firewall нарушает свой контракт"
|
||||||
|
|
||||||
log_step "Acceptance: firewall candidates and nftables.service state do not outlive the operation"
|
log_step "Acceptance: firewall candidates and nftables.service state do not outlive the operation"
|
||||||
@@ -1508,6 +1514,11 @@ run_transaction_boundary_acceptance() {
|
|||||||
|| fail "acceptance: нет барьера покоя перед новой операцией жизненного цикла"
|
|| fail "acceptance: нет барьера покоя перед новой операцией жизненного цикла"
|
||||||
grep -q 'export class PendingRecoveryError' orchestrator/src/steps/firewall.ts \
|
grep -q 'export class PendingRecoveryError' orchestrator/src/steps/firewall.ts \
|
||||||
|| fail "acceptance: у отказа по незавершённому восстановлению нет собственного типа"
|
|| fail "acceptance: у отказа по незавершённому восстановлению нет собственного типа"
|
||||||
|
# «Guard вооружён» и «состояние guard'а неизвестно» — разные утверждения, и
|
||||||
|
# оператору по ним нужны разные действия: подождать окно отката либо чинить
|
||||||
|
# systemd. Один тип на оба означал бы, что различие существует только в тексте.
|
||||||
|
grep -q 'export class GuardStateUnknownError' orchestrator/src/steps/firewall.ts \
|
||||||
|
|| fail "acceptance: у недоказуемого состояния guard'а нет собственного типа"
|
||||||
"$BUN_BIN" -e '
|
"$BUN_BIN" -e '
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const cli = fs.readFileSync("orchestrator/src/cli.ts", "utf8");
|
const cli = fs.readFileSync("orchestrator/src/cli.ts", "utf8");
|
||||||
@@ -1535,15 +1546,69 @@ run_transaction_boundary_acceptance() {
|
|||||||
throw new Error("отказ второй проверки оставляет замок за собой");
|
throw new Error("отказ второй проверки оставляет замок за собой");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Покой — это inactive и failed: отработавший guard больше ничего не
|
// Покой перечисляется БЕЛЫМ списком. Чёрный объявлял безопасным любое
|
||||||
// сделает, а отказ по failed заблокировал бы repair, которым чинят
|
// состояние, которого автор не назвал, — включая те, которых он не знал:
|
||||||
// последствия.
|
// systemd 257 знает maintenance и refreshing помимо перечислявшихся.
|
||||||
const firewall = fs.readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
|
const firewall = fs.readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
|
||||||
if (!firewall.includes("const GUARD_PENDING_STATES = [\"active\", \"activating\", \"deactivating\", \"reloading\"] as const")) {
|
if (!firewall.includes("const GUARD_QUIESCENT_ACTIVE_STATES = [\"inactive\", \"failed\"] as const")) {
|
||||||
throw new Error("набор непокойных состояний guard изменился без пересмотра барьера");
|
throw new Error("политика покоя guard изменилась без пересмотра барьера");
|
||||||
|
}
|
||||||
|
if (firewall.includes("GUARD_PENDING_STATES")) {
|
||||||
|
throw new Error("вернулся чёрный список состояний: неизвестное состояние снова считается покоем");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ запроса к systemd — отсутствие наблюдения, а не наблюдение покоя.
|
||||||
|
// Прежний код возвращал пустой список, то есть принимал невозможность
|
||||||
|
// получить доказательство за положительный результат.
|
||||||
|
const inspectStart = firewall.indexOf("export async function inspectRollbackGuard");
|
||||||
|
const inspectEnd = firewall.indexOf("export async function assertNoPendingRollbackGuard");
|
||||||
|
if (inspectStart < 0 || inspectEnd < inspectStart) {
|
||||||
|
throw new Error("нет единого наблюдателя состояния guard перед барьером");
|
||||||
|
}
|
||||||
|
const inspect = firewall.slice(inspectStart, inspectEnd);
|
||||||
|
if (inspect.includes("return [];")) {
|
||||||
|
throw new Error("барьер снова fail-open при отказе systemctl");
|
||||||
|
}
|
||||||
|
for (const outcome of ["kind: \"unknown\"", "kind: \"pending\"", "kind: \"quiescent\""]) {
|
||||||
|
if (!inspect.includes(outcome)) throw new Error("наблюдение не различает исход: " + outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Второй копии листинга быть не должно: status расходился с барьером по
|
||||||
|
// --plain, по `|| true` и по трактовке failed-юнита.
|
||||||
|
const status = fs.readFileSync("orchestrator/src/commands/status.ts", "utf8");
|
||||||
|
if (status.includes("list-units")) {
|
||||||
|
throw new Error("status снова листит guard-юниты сам, мимо барьерного наблюдателя");
|
||||||
|
}
|
||||||
|
if (!status.includes("await inspectRollbackGuard()")) {
|
||||||
|
throw new Error("status не берёт состояние guard у общего наблюдателя");
|
||||||
}
|
}
|
||||||
' || fail "acceptance: барьер покоя между операциями нарушен"
|
' || fail "acceptance: барьер покоя между операциями нарушен"
|
||||||
|
|
||||||
|
log_step "Acceptance: the transient guard timer states its window and its cleanup"
|
||||||
|
# `OnActiveSec=` не означает «ровно через столько»: systemd.timer разрешает
|
||||||
|
# себе сработать в окне [цель; цель + AccuracySec], а умолчание — 1min. Guard,
|
||||||
|
# про который README и docs говорят «45 секунд», без явного значения имел
|
||||||
|
# контракт «от 45 до 105». RemainAfterElapse задаётся по другой причине: от
|
||||||
|
# выгрузки отработавшего таймера зависит право барьера считать его отсутствие
|
||||||
|
# покоем, и держать этот инвариант на чужом умолчании нельзя.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const { buildArmGuardArgv } = await import("./orchestrator/src/steps/firewall.ts");
|
||||||
|
const argv = buildArmGuardArgv("2026-01-01T00-00-00.000Z");
|
||||||
|
for (const expected of [
|
||||||
|
"--unit=hy2xs-fw-rollback-2026-01-01T00-00-00.000Z.service",
|
||||||
|
"--on-active=45s",
|
||||||
|
"--timer-property=RemainAfterElapse=no",
|
||||||
|
"--timer-property=AccuracySec=1s"
|
||||||
|
]) {
|
||||||
|
if (!argv.includes(expected)) throw new Error("взведение guard не задаёт " + expected);
|
||||||
|
}
|
||||||
|
// Ключ операции подставляется в имя юнита и в путь скрипта; shell в этой
|
||||||
|
// команде не участвует, поэтому единственная защита — отказ.
|
||||||
|
let rejected = false;
|
||||||
|
try { buildArmGuardArgv("op id"); } catch { rejected = true; }
|
||||||
|
if (!rejected) throw new Error("небезопасный ключ операции принят взведением guard");
|
||||||
|
' || fail "acceptance: контракт транзиентного таймера guard нарушен"
|
||||||
|
|
||||||
log_step "Acceptance: nftables.service restore claims only what it can guarantee"
|
log_step "Acceptance: nftables.service restore claims only what it can guarantee"
|
||||||
# `enable --runtime` не удаляет постоянную ссылку, поэтому "восстановление"
|
# `enable --runtime` не удаляет постоянную ссылку, поэтому "восстановление"
|
||||||
# enabled-runtime таким вызовом обещало точность, которой не давало.
|
# enabled-runtime таким вызовом обещало точность, которой не давало.
|
||||||
|
|||||||
@@ -111,10 +111,17 @@ require_root() {
|
|||||||
|
|
||||||
# Таймеры отката firewall переживают неудачную установку и продолжат менять
|
# Таймеры отката firewall переживают неудачную установку и продолжат менять
|
||||||
# ruleset уже после очистки, если их не снять.
|
# ruleset уже после очистки, если их не снять.
|
||||||
|
#
|
||||||
|
# `--plain` обязателен, а разбор идёт по ЛЮБОМУ полю строки, а не по первому.
|
||||||
|
# У юнита в состоянии `failed` systemctl печатает первой колонкой маркер `●`,
|
||||||
|
# поэтому прежний `awk '{print $1}' | grep -E '^hy2xs-fw-rollback-'` отбрасывал
|
||||||
|
# строку целиком — и purge не снимал именно аварийно сработавший guard, тот
|
||||||
|
# единственный, ради которого эта функция написана.
|
||||||
rollback_guard_units() {
|
rollback_guard_units() {
|
||||||
systemctl list-units --all --no-legend 'hy2xs-fw-rollback-*.timer' 'hy2xs-fw-rollback-*.service' 2>/dev/null \
|
systemctl list-units --all --plain --no-legend 'hy2xs-fw-rollback-*.timer' 'hy2xs-fw-rollback-*.service' 2>/dev/null \
|
||||||
| awk '{print $1}' \
|
| tr -s '[:space:]' '\n' \
|
||||||
| grep -E '^hy2xs-fw-rollback-' || true
|
| grep -E '^hy2xs-fw-rollback-.+\.(timer|service)$' \
|
||||||
|
| sort -u || true
|
||||||
}
|
}
|
||||||
|
|
||||||
show_plan() {
|
show_plan() {
|
||||||
|
|||||||
+5
-1
@@ -86,7 +86,11 @@ PNPM_VERSION=9.15.9
|
|||||||
# нужно: пин версии инструмента не должен превращаться в пин знаний о мире.
|
# нужно: пин версии инструмента не должен превращаться в пин знаний о мире.
|
||||||
GOVULNCHECK_VERSION=v1.7.0
|
GOVULNCHECK_VERSION=v1.7.0
|
||||||
|
|
||||||
# Порог pnpm audit для production-зависимостей frontend.
|
# Порог pnpm audit по ВСЕМУ lock-графу frontend, а не только по production-
|
||||||
|
# зависимостям. `--prod` здесь стоял с обоснованием «devDependencies в артефакт
|
||||||
|
# не попадают», и для frontend build tooling это обоснование неверно: vite и
|
||||||
|
# rollup формируют production-бандл, который уезжает в артефакт, поэтому
|
||||||
|
# уязвимость в них — уязвимость в том, что мы выпускаем.
|
||||||
# Значения: low | moderate | high | critical.
|
# Значения: low | moderate | high | critical.
|
||||||
PNPM_AUDIT_LEVEL=high
|
PNPM_AUDIT_LEVEL=high
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user