c0a43ae915
Девятый проход, по итогам приёмки 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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
387 lines
25 KiB
Markdown
387 lines
25 KiB
Markdown
# 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`.
|
||
|