Files
HY2XS_flamy/docs/testing/11-4-fault-injection.md
T
founder c0a43ae915 fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00

387 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# D0-D2. Живой сервер и fault injection
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
## D0. Граница установки на живом сервере
Проверяется на хосте, где уже стоит предыдущая установка:
1. `install.sh` завершается отказом на PHASE 0;
2. `/usr/local/lib/hy2xs` **не создан и не изменён**;
3. `/var/lib/hy2xs/install-state.json` не перезаписан;
4. `hysteria-server` и `hy2xs-admin` остались `active`;
5. в тексте отказа перечислены найденные маркеры и указан
`docs/operations/14-legacy-cleanup.md`;
6. после `tools/legacy/purge-v0.sh --apply --yes-i-know` установка проходит.
Пункты 2–4 — прямая регрессия: прежний установщик успевал переписать
`/usr/local/lib/hy2xs` и `install-state.json`, а затем откатом останавливал и
выключал работающие службы старой установки.
## D1. Отказ сразу после успешной PHASE 0 (fault injection)
Проверяется на чистом хосте. Это узкая щель между «PHASE 0 прошла» и «первая
мутирующая операция упала» — место, где установщик раньше врал.
1. PHASE 0 проходит успешно;
2. `installDeps` ломается искусственно (например, недоступный apt-репозиторий
или временно испорченный `/etc/apt/sources.list.d/`);
3. установка завершается отказом;
4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»;
5. `/var/lib/hy2xs/install-state.json` существует и честно показывает
`phase: failed` с текстом ошибки;
6. `owned_paths` в маркере содержит `/usr/local/lib/hy2xs`,
`/usr/local/bin/hy2xs-orchestrator` и `/usr/local/lib/hy2xs/package`
всё, что операция действительно создала;
7. diagnostics-бандл собран;
8. `hy2xs-orchestrator status` не заявляет установку успешной.
До исправления шаги 4–7 давали противоположный результат: `install-state.json`
уже лежал на диске, но отказ классифицировался как pre-apply, обработка
состояния пропускалась, а следующая установка на этой машине отказывалась по
clean-host контракту из-за оставшегося маркера.
Пункт 6 закрывает вторую половину той же щели. Пока раскладку оркестратора и
runtime-пакета выполнял `install.sh`, эти пути не принадлежали никому: они не
попадали в `owned_paths`, а отказ **второго** preflight (сменился DNS, занялся
порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не
изменено» — при уже созданном каталоге оркестратора.
## D1b. Откат при невозможности записать состояние отказа (fault injection)
Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния
больше не стоит перед восстановлением.
**Тайминг здесь — часть сценария, и его легко испортить.**
`tmpfs` НЕЛЬЗЯ монтировать заранее: первая же запись маркера (`preflight_ok`)
получит `ENOSPC`, установка отвалится до firewall, и проверяться будет совсем
другой путь — обычный `fatal_post_apply` на ранней стадии.
Порядок строго такой:
1. запустить установку и дождаться в журнале `step=firewall status=done`;
2. **только теперь**, во втором терминале:
```bash
mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs
dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
```
3. вызвать искусственный отказ следующего шага установки.
Ловить это окно руками неудобно, поэтому тот же сценарий имеет смысл прогнать и
через отказ на более длинном шаге (`smoke`), где времени заметно больше:
дождаться `step=smoke checks`, смонтировать `tmpfs` и остановить один из
сервисов, чтобы smoke не сошёлся.
Сценарий:
1. установка доходит **дальше** шага firewall (то есть `firewallTouched`
взведён, правила применены);
2. следующий шаг ломается искусственно;
3. запись `phase: failed` в маркер падает по `ENOSPC`;
4. в журнале есть `failed to persist failure state, continuing with the
mandatory rollback`;
5. **откат всё равно выполняется**: `rollbackFirewallNow` снимает применённые
правила, `/etc/nftables.conf` возвращается к прежнему состоянию, а
развёрнутые этой операцией юниты останавливаются и выключаются;
6. SSH остаётся доступным;
7. в журнале перечислены отказавшие стадии отката, если они были, и наружу
ушла **исходная** ошибка операции, а не `ENOSPC`.
До исправления шаги 4–6 давали противоположный результат: бросок из записи
состояния уносил управление наружу, и сервер оставался с применённым firewall
неудавшейся установки.
Тот же сценарий повторяется для `reconfigure`, где цена выше: там откат
дополнительно возвращает конфиги из `/etc/hy2xs/backups`, и оба восстановления
отменялись разом.
Дополнительно проверяется независимость стадий: если сделать неработоспособной
первую стадию (например, сделать `/etc/nftables.conf` неперезаписываемым через
`chattr +i` между применением firewall и отказом), восстановление конфигов и
остановка сервисов обязаны выполниться всё равно, а в журнале обязаны появиться
`rollback stage "…" failed, continuing with the remaining stages` и итоговое
`rollback finished with N failed stage(s)`.
## D1c. Данные отката переживают отказ фиксации успеха
Проверяется на чистом хосте. Это второй сценарий того же класса, но на
противоположном конце операции: отказывает не промежуточный шаг, а **запись
успеха**.
1. установка доходит до успешного `smoke`, в маркере появляется
`phase: smoke_ok`;
2. сразу после этого `/var/lib/hy2xs` делается недоступным для записи (тот же
`tmpfs`, смонтированный по появлению `step=smoke checks status=done`);
3. запись `phase: installed` падает;
4. `/run/hy2xs/rollback/<op>/prepared` и обе резервные копии firewall **всё ещё
существуют** — это и есть проверяемое свойство;
5. откат выполняется полностью: `/etc/nftables.conf` возвращается к прежнему
содержимому, развёрнутые юниты останавливаются;
6. в журнале **нет** строки `no HY2XS rollback markers found`.
До исправления пункты 4–6 давали противоположный результат: снятие таймера и
удаление копий выполнял один вызов, стоявший до записи `installed`, поэтому
откат запускался, но откатывать ему было нечем.
Обратная проверка — успешный путь: после нормально завершённой установки
`/run/hy2xs/rollback/` пуст, а `phase: installed` записан.
## D1d. Отказ снятия резервной копии останавливает reconfigure до мутации
Проверяется на рабочей установке.
1. `/etc/hy2xs/backups` делается недоступным для записи (`chattr +i` или
заполненный `tmpfs`);
2. запускается `reconfigure --apply`;
3. операция отказывает на шаге `backup` с сообщением про несозданную копию;
4. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` **не
изменены**, сервисы не перезапускались.
Отдельно проверяется привязка копии к операции: после успешного `reconfigure`
в `/etc/hy2xs/backups/` остаётся ровно один каталог — текущей операции — с
`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: … 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 секунд. Проще всего задержать один
из сервисов — например, добавить в `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 об идущей операции
```
Отдельно проверяется, что замок не переживает своего держателя. **Важно:**
прерывать операцию нужно ДО шага `firewall`, иначе проверяется уже сценарий
D1h, а не этот.
1. `reconfigure --apply` прерывается `Ctrl+C` на шаге `config generation` —
замок снят, следующий `reconfigure` проходит;
2. процесс убивается `kill -9` на том же шаге, после чего следующая операция
сообщает `is held by … which is no longer running; reclaiming it` и
продолжает;
3. `/run/lock/hy2xs-orchestrator.lock` не остаётся после завершения операции.
## D1h. Аварийно умершая операция с вооружённым guard
Проверяется на рабочей установке. Это стык двух защитных механизмов, и до его
закрытия каждый из них по отдельности работал правильно, а вместе они
оставляли дыру.
Замок защищает production paths, пока **жив процесс-держатель**. Rollback guard
firewall — отдельный systemd-объект, который свой процесс переживает. Поэтому:
```text
A берёт замок -> применяет firewall -> вооружает guard на 45 секунд
A аварийно умирает
B берёт замок (снятый обработчиком сигнала либо переиспользованный)
B начинает менять production paths
guard A срабатывает и возвращает firewall, который был ДО A
```
Уникальные `op-id` здесь не помогают: каталоги копий разные, а
`/etc/nftables.conf`, `/etc/nftables.d/hy2xs.nft` и ruleset в ядре — общие.
Сценарий:
1. `reconfigure --apply` доводится до появления в журнале
`firewall rollback guard armed`;
2. процесс убивается `kill -9` (замок остаётся устаревшим) — и, отдельным
прогоном, `kill -TERM` (замок снимается обработчиком, то есть его вообще не
будет; это и есть случай, который проверка живости держателя не ловит);
3. **до истечения 45 секунд** запускается `repair` или `reconfigure --apply`;
4. новая операция обязана отказать:
```text
previous HY2XS operation is no longer running, but its firewall rollback guard
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
```
5. отказ происходит **до** снятия резервной копии и до первой мутации;
6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине;
7. после срабатывания guard транзиентный таймер выгружается
(`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 барьер молчит и ни одну
операцию не задерживает, а `failed` от уже отработавшего guard **не** считается
непокоем — иначе он заблокировал бы `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. Успешная установка не оставляет следов транзакции
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
к D1c и D1e: она ловит противоположную ошибку — данные транзакции, пережившие
её завершение.
После `installed`:
```text
systemctl list-units --all --plain '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. Проход установки не спотыкается о собственный маркер
Проверяется на чистом хосте, обычной успешной установкой.
1. `install.sh` доходит до `preflight capabilities` **после** `apt-get`;
2. установка на этом шаге **не** падает с текстом «обнаружена предыдущая или
посторонняя установка»;
3. установка доходит до `installed`.
Это сценарий, который не воспроизводится ни на одном dry-run: clean-host внутри
`install` проверялся дважды, и ко второму разу на диске уже лежал собственный
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight. Каждая
чистая установка падала сразу после `apt-get`, получала `fatal_post_apply` и
оставляла сервер наполовину настроенным. Структурно закреплено в
`orchestrator/test/install-sequence.test.ts`.
## D2. Устаревший DNS после смены IPv4 провайдером
Проверяется на рабочей установке.
```text
сервер: текущий публичный IPv4 = B
DNS: A-запись = A (старый адрес)
hy2xs-orchestrator doctor
→ FAIL
→ в выводе присутствуют и A, и B
обновить A-запись на B, дождаться TTL
hy2xs-orchestrator doctor
→ PASS
```
Дополнительно: `reconfigure --apply` при устаревшей A-записи тоже обязан
отказать — инвариант живёт в общем `preflight`, а не в одном `doctor`.