docs: описать транзакционный guard и взаимное исключение операций
- docs/07: полный порядок staged apply, инвариант снятия guard, объяснение почему окно 45 секунд не обязано покрывать smoke и почему guard не трогает nftables.service, семантическая проверка эффективного firewall; - docs/11: разделы A5e/A5f для новых unit-тестов и серверные сценарии D1e (guard доходит до дедлайна), D1f (конкурентные операции), D1g (успешная операция не оставляет следов транзакции); матрица и acceptance criteria дополнены; - docs/12: разбор отказов "уже выполняется другая операция" и firewall_guard_fired; - docs/13: строки журнала guard в таблице recovery, новый раздел 8a про замок операций; - docs/14 и purge-v0.sh: очистка /run/hy2xs, замка операций и candidate-файлов firewall — /run это tmpfs, но очистка не имеет права требовать перезагрузки; - README: защита от потери доступа при смене firewall и раздел "Одна операция за раз"; - CHANGELOG: шестой проход.
This commit is contained in:
@@ -245,6 +245,59 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
||||
- успешный smoke фиксируется отдельной фазой до снятия таймера;
|
||||
- уборка после точки фиксации выполняется best-effort.
|
||||
|
||||
## A5e. Транзакционность rollback guard (unit)
|
||||
|
||||
`orchestrator/test/firewall-guard.test.ts`:
|
||||
|
||||
- маркер `auto-rollback-fired` создаётся rollback-скриптом **первым действием** —
|
||||
до проверки `prepared` и до первой попытки восстановления, в том числе когда
|
||||
восстанавливать нечего. Без этого «guard сработал» недоказуемо: транзиентные
|
||||
юниты systemd после выполнения исчезают, и `systemctl stop` для них неотличим
|
||||
от успешного снятия взведённого таймера;
|
||||
- скрипт не маскирует ошибки (`|| true`, `2>/dev/null`), не использует `set -e`
|
||||
и возвращает накопленный `rc`: каждый сообщённый отказ поднимает код возврата,
|
||||
поэтому частичное восстановление уходит в `failed`, а не в молчаливый `0`;
|
||||
- скрипт не трогает `nftables.service`: у него `ExecStop=nft flush ruleset`, и
|
||||
остановка сервиса стёрла бы только что восстановленные правила;
|
||||
- скрипт разбирается **настоящим** shell-парсером. Парсер принимается только
|
||||
после двусторонней проверки — он обязан принять заведомо корректный скрипт и
|
||||
отвергнуть заведомо сломанный, иначе тест ничего не проверяет;
|
||||
- небезопасный ключ операции отвергается: он служит именем каталога, именем
|
||||
systemd-юнита и подставляется в текст скрипта;
|
||||
- снятие guard проверяет маркер **с обеих сторон** остановки, подтверждается
|
||||
`ActiveState` обоих юнитов, и на пути фиксации успеха допускает единственное
|
||||
состояние — `inactive`; на пути восстановления `failed` тоже допустим;
|
||||
- сработавший guard опознаётся по **типу** ошибки: ошибка с тем же текстом, но
|
||||
другого типа классифицируется по владению, как и прежде;
|
||||
- эффективный firewall сверяется семантически (фрагмент, entrypoint,
|
||||
загруженная таблица), и проверка выполняется read-only раннерами — та же
|
||||
проверка идёт в `doctor`;
|
||||
- состояние `nftables.service` снимается до первой мутации и восстанавливается
|
||||
стадиями, идущими **до** применения ruleset;
|
||||
- candidate-файлы убираются после успеха и best-effort при откате;
|
||||
- ключ операции считается одной функцией: install писал в маркер сырой
|
||||
ISO-timestamp, и путь `/run/hy2xs/rollback/<op_id>` из runbook не существовал.
|
||||
|
||||
## A5f. Взаимное исключение операций (unit)
|
||||
|
||||
`orchestrator/test/operation-lock.test.ts`:
|
||||
|
||||
- второй захват при живом держателе отказывает, и отказ называет держателя —
|
||||
команду, PID и время начала;
|
||||
- замок снимается в `finally` и после отказа операции: иначе первая же неудачная
|
||||
установка заблокировала бы сервер до перезагрузки;
|
||||
- замок мёртвого держателя переиспользуется, временный файл переиспользования не
|
||||
остаётся на диске;
|
||||
- непонятое содержимое замка **не** снимается автоматически: оно не доказывает
|
||||
отсутствие операции, и сомнение трактуется в пользу отказа;
|
||||
- `readLockHolder` отличает «замка нет» от «замок нечитаем»;
|
||||
- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся
|
||||
до включения guard, и проверка существует, чтобы перенос захвата внутрь
|
||||
читающей фазы отказал громко, а не записал файл молча;
|
||||
- политика CLI закреплена структурно: `install`/`reconfigure`/`repair`/`doctor`
|
||||
вызываются только под замком, `status`/`diagnostics` его не берут, но сообщают
|
||||
об идущей операции, а `preflight-install` отказывает до собственных проверок.
|
||||
|
||||
## A5b. Долговечная запись маркера (unit)
|
||||
|
||||
`orchestrator/test/atomic-write.test.ts`:
|
||||
@@ -956,6 +1009,111 @@ dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
|
||||
`manifest.json`, и в нём перечислены все семь путей, включая отсутствовавшие с
|
||||
`"present": false`.
|
||||
|
||||
## D1e. Guard доходит до дедлайна — фиксация успеха запрещена
|
||||
|
||||
Проверяется на чистом хосте. Это сценарий гонки между автоматическим откатом
|
||||
firewall и успешным smoke.
|
||||
|
||||
Окно guard — 45 секунд, и оно намеренно короче худшего случая smoke: на
|
||||
медленном, но исправном сервере retry-бюджеты дают заметно больше. Раньше это
|
||||
означало, что автоматический откат мог вернуть прежний firewall, пока smoke
|
||||
продолжает идти, а единственной проверкой firewall в smoke был `nft -c` — разбор
|
||||
текущего файла, каким бы он ни был. Прежний валидный ruleset проходил её
|
||||
зелёным, и сервер объявлялся успешно настроенным с **предыдущим** firewall.
|
||||
|
||||
Сценарий:
|
||||
|
||||
1. установка доходит до шага `firewall`, в журнале появляется
|
||||
`firewall rollback guard armed`;
|
||||
2. smoke искусственно замедляется дольше 45 секунд. Проще всего задержать один
|
||||
из сервисов — например, добавить в `hy2xs-admin.service` временный
|
||||
`ExecStartPre=/bin/sleep 60` и выполнить `systemctl daemon-reload` до запуска
|
||||
установки;
|
||||
3. guard срабатывает: в journal появляется юнит
|
||||
`hy2xs-fw-rollback-<op-id>.service`, а на диске —
|
||||
`/run/hy2xs/rollback/<op-id>/auto-rollback-fired`;
|
||||
4. установка **обязана** завершиться отказом, даже если smoke успел сойтись;
|
||||
5. в маркере установки стоит `phase: firewall_guard_fired`, а не
|
||||
`installed`, и не `smoke_failed`;
|
||||
6. `installed: true` не записан;
|
||||
7. выполняется обычный откат операции: firewall возвращается к прежнему
|
||||
состоянию, развёрнутые этой операцией юниты останавливаются;
|
||||
8. SSH остаётся доступным.
|
||||
|
||||
Отдельно проверяется вторая половина того же дефекта — семантический smoke.
|
||||
Если на рабочей установке подменить `/etc/nftables.d/hy2xs.nft` на прежний
|
||||
валидный ruleset и выполнить `hy2xs-orchestrator doctor`, диагностика обязана
|
||||
отказать с сообщением про несовпадение эффективного firewall, а не пройти по
|
||||
`nft -c`.
|
||||
|
||||
## D1f. Конкурентная операция отказывает до первой мутации
|
||||
|
||||
Проверяется на рабочей установке. Проверяемое свойство — отказ происходит
|
||||
**до** снятия резервной копии и до первой мутации, а не в середине транзакции.
|
||||
|
||||
1. запускается длинный `reconfigure --apply` (например, с задержкой в
|
||||
`ExecStartPre`, как в D1e);
|
||||
2. во втором терминале, пока первый идёт, запускается второй
|
||||
`reconfigure --apply`;
|
||||
3. второй отказывает сразу, с текстом
|
||||
`another HY2XS operation is already in progress: reconfigure (pid …)`;
|
||||
4. `/etc/hy2xs/backups/` **не** пополнился каталогом второй операции;
|
||||
5. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` изменены
|
||||
ровно один раз — первой операцией;
|
||||
6. `/run/hy2xs/rollback/` содержит каталог только первой операции.
|
||||
|
||||
Те же проверки для пар:
|
||||
|
||||
```text
|
||||
install идёт -> doctor отказывает
|
||||
install идёт -> install.sh отказывает на PHASE 0, до собственных проверок
|
||||
reconfigure идёт -> repair отказывает
|
||||
```
|
||||
|
||||
И обратная проверка — наблюдающие команды не блокируются:
|
||||
|
||||
```text
|
||||
reconfigure идёт -> hy2xs-orchestrator status
|
||||
→ выполняется
|
||||
→ в отчёте operation_in_progress = "reconfigure (pid …)"
|
||||
→ human_status предупреждает, что это снимок незавершённой транзакции
|
||||
|
||||
reconfigure идёт -> diagnostics collect
|
||||
→ выполняется
|
||||
→ в stderr есть note об идущей операции
|
||||
```
|
||||
|
||||
Отдельно проверяется, что замок не переживает своего держателя:
|
||||
|
||||
1. `reconfigure --apply` прерывается `Ctrl+C` — замок снят, следующий
|
||||
`reconfigure` проходит;
|
||||
2. процесс убивается `kill -9`, после чего следующая операция сообщает
|
||||
`is held by … which is no longer running; reclaiming it` и продолжает;
|
||||
3. `/run/lock/hy2xs-orchestrator.lock` не остаётся после завершения операции.
|
||||
|
||||
## D1g. Успешная установка не оставляет следов транзакции
|
||||
|
||||
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
|
||||
к D1c и D1e: она ловит противоположную ошибку — данные транзакции, пережившие
|
||||
её завершение.
|
||||
|
||||
После `installed`:
|
||||
|
||||
```text
|
||||
systemctl list-units --all 'hy2xs-fw-rollback-*' → пусто
|
||||
ls /run/hy2xs/rollback/ → пусто
|
||||
ls /run/lock/hy2xs-orchestrator.lock → отсутствует
|
||||
ls /etc/nftables.conf.candidate → отсутствует
|
||||
ls /etc/nftables.d/hy2xs.nft.candidate → отсутствует
|
||||
```
|
||||
|
||||
и `/var/lib/hy2xs/install-state.json` содержит `phase: installed`,
|
||||
`installed: true`, а `op_id` в нём совпадает с именем каталога, который лежал в
|
||||
`/run/hy2xs/rollback/` во время установки.
|
||||
|
||||
`/etc/nftables.conf.candidate` — прямая регрессия: он не удалялся вообще, и
|
||||
успешная установка оставляла его на сервере навсегда.
|
||||
|
||||
## D1a. Проход установки не спотыкается о собственный маркер
|
||||
|
||||
Проверяется на чистом хосте, обычной успешной установкой.
|
||||
@@ -1031,8 +1189,20 @@ hy2xs-orchestrator doctor
|
||||
- при `HY2XS_FIREWALL_MODE=managed` install/reconfigure блокируются;
|
||||
- при `HY2XS_FIREWALL_MODE=takeover` создаются backup/rollback guard и apply проходит.
|
||||
|
||||
4. **Rollback guard cleanup**:
|
||||
- после успешного apply/smoke не остаются `hy2xs-fw-rollback-*.timer/.service`.
|
||||
4. **Rollback guard cleanup** (сценарий D1g):
|
||||
- после успешного apply/smoke не остаются `hy2xs-fw-rollback-*.timer/.service`;
|
||||
- `/run/hy2xs/rollback/`, `/run/lock/hy2xs-orchestrator.lock` и
|
||||
`*.candidate` не переживают успешную операцию.
|
||||
|
||||
4a. **Guard доходит до дедлайна** (сценарий D1e):
|
||||
- `auto-rollback-fired` создан, операция завершается отказом с
|
||||
`phase: firewall_guard_fired`;
|
||||
- `installed: true` не записан, даже если smoke успел сойтись.
|
||||
|
||||
4b. **Конкурентные операции** (сценарий D1f):
|
||||
- вторая операция отказывает **до** снятия резервной копии и первой мутации;
|
||||
- `status`/`diagnostics` не блокируются и сообщают об идущей операции;
|
||||
- замок не переживает своего держателя.
|
||||
|
||||
5. **Partial install + repair**:
|
||||
- состояние `install-state` фиксирует промежуточную фазу;
|
||||
@@ -1118,3 +1288,10 @@ hy2xs-orchestrator doctor
|
||||
58. копия привязана к операции: откат восстанавливает состояние непосредственно перед текущим проходом, а не сохранённое предыдущим
|
||||
59. ни одна команда отката не глушит свой код возврата; отказавшие стадии перечисляются, а артефакты восстановления удаляются только после подтверждённого успеха
|
||||
60. `doctor` не выполняет проб, изменяющих данные в админке: авторизация действующим паролем пира ограничена режимом `install`
|
||||
61. снятие rollback guard доказывается, а не объявляется: отсутствие маркера `auto-rollback-fired` и `ActiveState=inactive` обоих юнитов — предусловие записи `phase: installed`
|
||||
62. сработавший guard запрещает фиксацию успеха, каким бы ни был результат smoke, и получает собственную причину отказа `firewall_guard_fired`
|
||||
63. smoke сверяет **эффективный** firewall с конфигурацией операции, а не только разбирает `/etc/nftables.conf`
|
||||
64. автоматический откат firewall сообщает о частичном восстановлении отказом юнита, а не молчаливым кодом 0, и сохраняет данные восстановления
|
||||
65. откат восстанавливает `enabled`/`active` состояние `nftables.service`, а не только файлы правил
|
||||
66. операции жизненного цикла сериализованы эксклюзивным замком: вторая операция отказывает до первой мутации, а `status`/`diagnostics` не блокируются
|
||||
67. замок снимается при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH; замок мёртвого держателя переиспользуется безопасно
|
||||
|
||||
Reference in New Issue
Block a user