Девятый проход, по итогам приёмки 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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
25 KiB
D0-D2. Живой сервер и fault injection
Часть набора проверок HY2XS. Карта всех частей — docs/testing/README.md.
D0. Граница установки на живом сервере
Проверяется на хосте, где уже стоит предыдущая установка:
install.shзавершается отказом на PHASE 0;/usr/local/lib/hy2xsне создан и не изменён;/var/lib/hy2xs/install-state.jsonне перезаписан;hysteria-serverиhy2xs-adminосталисьactive;- в тексте отказа перечислены найденные маркеры и указан
docs/operations/14-legacy-cleanup.md; - после
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 прошла» и «первая мутирующая операция упала» — место, где установщик раньше врал.
- PHASE 0 проходит успешно;
installDepsломается искусственно (например, недоступный apt-репозиторий или временно испорченный/etc/apt/sources.list.d/);- установка завершается отказом;
- в выводе нет
fatal_pre_applyи нет фразы про «ничего не применялось»; /var/lib/hy2xs/install-state.jsonсуществует и честно показываетphase: failedс текстом ошибки;owned_pathsв маркере содержит/usr/local/lib/hy2xs,/usr/local/bin/hy2xs-orchestratorи/usr/local/lib/hy2xs/package— всё, что операция действительно создала;- diagnostics-бандл собран;
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 на ранней стадии.
Порядок строго такой:
- запустить установку и дождаться в журнале
step=firewall status=done; - только теперь, во втором терминале:
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
- вызвать искусственный отказ следующего шага установки.
Ловить это окно руками неудобно, поэтому тот же сценарий имеет смысл прогнать и
через отказ на более длинном шаге (smoke), где времени заметно больше:
дождаться step=smoke checks, смонтировать tmpfs и остановить один из
сервисов, чтобы smoke не сошёлся.
Сценарий:
- установка доходит дальше шага firewall (то есть
firewallTouchedвзведён, правила применены); - следующий шаг ломается искусственно;
- запись
phase: failedв маркер падает поENOSPC; - в журнале есть
failed to persist failure state, continuing with the mandatory rollback; - откат всё равно выполняется:
rollbackFirewallNowснимает применённые правила,/etc/nftables.confвозвращается к прежнему состоянию, а развёрнутые этой операцией юниты останавливаются и выключаются; - SSH остаётся доступным;
- в журнале перечислены отказавшие стадии отката, если они были, и наружу
ушла исходная ошибка операции, а не
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. Данные отката переживают отказ фиксации успеха
Проверяется на чистом хосте. Это второй сценарий того же класса, но на противоположном конце операции: отказывает не промежуточный шаг, а запись успеха.
- установка доходит до успешного
smoke, в маркере появляетсяphase: smoke_ok; - сразу после этого
/var/lib/hy2xsделается недоступным для записи (тот жеtmpfs, смонтированный по появлениюstep=smoke checks status=done); - запись
phase: installedпадает; /run/hy2xs/rollback/<op>/preparedи обе резервные копии firewall всё ещё существуют — это и есть проверяемое свойство;- откат выполняется полностью:
/etc/nftables.confвозвращается к прежнему содержимому, развёрнутые юниты останавливаются; - в журнале нет строки
no HY2XS rollback markers found.
До исправления пункты 4–6 давали противоположный результат: снятие таймера и
удаление копий выполнял один вызов, стоявший до записи installed, поэтому
откат запускался, но откатывать ему было нечем.
Обратная проверка — успешный путь: после нормально завершённой установки
/run/hy2xs/rollback/ пуст, а phase: installed записан.
D1d. Отказ снятия резервной копии останавливает reconfigure до мутации
Проверяется на рабочей установке.
/etc/hy2xs/backupsделается недоступным для записи (chattr +iили заполненныйtmpfs);- запускается
reconfigure --apply; - операция отказывает на шаге
backupс сообщением про несозданную копию; /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.
Сценарий:
-
установка доходит до шага
firewall, в журнале появляетсяfirewall rollback guard armed: … fires in 45s (timer accuracy 1s). Пока guard ждёт, свойства таймера проверяются напрямую — обещанное окно обязано быть контрактом systemd, а не намерением: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 секунд; -
smoke искусственно замедляется дольше 45 секунд. Проще всего задержать один из сервисов — например, добавить в
hy2xs-admin.serviceвременныйExecStartPre=/bin/sleep 60и выполнитьsystemctl daemon-reloadдо запуска установки; -
guard срабатывает: в journal появляется юнит
hy2xs-fw-rollback-<op-id>.service, а на диске —/run/hy2xs/rollback/<op-id>/auto-rollback-fired; -
установка обязана завершиться отказом, даже если smoke успел сойтись;
-
в маркере установки стоит
phase: firewall_guard_fired, а неinstalled, и неsmoke_failed; -
installed: trueне записан; -
выполняется обычный откат операции: firewall возвращается к прежнему состоянию, развёрнутые этой операцией юниты останавливаются;
-
SSH остаётся доступным.
Отдельно проверяется вторая половина того же дефекта — семантический smoke.
Если на рабочей установке подменить /etc/nftables.d/hy2xs.nft на прежний
валидный ruleset и выполнить hy2xs-orchestrator doctor, диагностика обязана
отказать с сообщением про несовпадение эффективного firewall, а не пройти по
nft -c.
D1f. Конкурентная операция отказывает до первой мутации
Проверяется на рабочей установке. Проверяемое свойство — отказ происходит до снятия резервной копии и до первой мутации, а не в середине транзакции.
- запускается длинный
reconfigure --apply(например, с задержкой вExecStartPre, как в D1e); - во втором терминале, пока первый идёт, запускается второй
reconfigure --apply; - второй отказывает сразу, с текстом
another HY2XS operation is already in progress: reconfigure (pid …); /etc/hy2xs/backups/не пополнился каталогом второй операции;/etc/hysteria/config.yaml, unit-файлы и/etc/nftables.confизменены ровно один раз — первой операцией;/run/hy2xs/rollback/содержит каталог только первой операции.
Те же проверки для пар:
install идёт -> doctor отказывает
install идёт -> install.sh отказывает на PHASE 0, до собственных проверок
reconfigure идёт -> repair отказывает
И обратная проверка — наблюдающие команды не блокируются:
reconfigure идёт -> hy2xs-orchestrator status
→ выполняется
→ в отчёте operation_in_progress = "reconfigure (pid …)"
→ human_status предупреждает, что это снимок незавершённой транзакции
reconfigure идёт -> diagnostics collect
→ выполняется
→ в stderr есть note об идущей операции
Отдельно проверяется, что замок не переживает своего держателя. Важно:
прерывать операцию нужно ДО шага firewall, иначе проверяется уже сценарий
D1h, а не этот.
reconfigure --applyпрерываетсяCtrl+Cна шагеconfig generation— замок снят, следующийreconfigureпроходит;- процесс убивается
kill -9на том же шаге, после чего следующая операция сообщаетis held by … which is no longer running; reclaiming itи продолжает; /run/lock/hy2xs-orchestrator.lockне остаётся после завершения операции.
D1h. Аварийно умершая операция с вооружённым guard
Проверяется на рабочей установке. Это стык двух защитных механизмов, и до его закрытия каждый из них по отдельности работал правильно, а вместе они оставляли дыру.
Замок защищает production paths, пока жив процесс-держатель. Rollback guard firewall — отдельный systemd-объект, который свой процесс переживает. Поэтому:
A берёт замок -> применяет firewall -> вооружает guard на 45 секунд
A аварийно умирает
B берёт замок (снятый обработчиком сигнала либо переиспользованный)
B начинает менять production paths
guard A срабатывает и возвращает firewall, который был ДО A
Уникальные op-id здесь не помогают: каталоги копий разные, а
/etc/nftables.conf, /etc/nftables.d/hy2xs.nft и ruleset в ядре — общие.
Сценарий:
reconfigure --applyдоводится до появления в журналеfirewall rollback guard armed;- процесс убивается
kill -9(замок остаётся устаревшим) — и, отдельным прогоном,kill -TERM(замок снимается обработчиком, то есть его вообще не будет; это и есть случай, который проверка живости держателя не ловит); - до истечения 45 секунд запускается
repairилиreconfigure --apply; - новая операция обязана отказать:
previous HY2XS operation is no longer running, but its firewall rollback guard
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
-
отказ происходит до снятия резервной копии и до первой мутации;
-
install.shв том же окне отказывает на PHASE 0 по той же причине; -
после срабатывания guard транзиентный таймер выгружается (
RemainAfterElapse=no), иrepairпроходит. Проверяется наблюдением, а не ожиданием на глаз: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 вооружён» и «спросить не удалось»: это разные утверждения, и оператору по ним нужны разные действия.
- на рабочей установке без вооружённого guard делается недоступным запрос к
systemd — проще всего временно подложить в
PATHоркестратораsystemctl, завершающийся ненулевым кодом; - любая операция жизненного цикла (
repair,reconfigure --apply,doctor,install.shна PHASE 0) обязана отказать:
unable to verify firewall rollback guard state; systemd query failed,
refusing to start a lifecycle operation
- отказ происходит до первой мутации, и тип ошибки —
GuardStateUnknownError, а неPendingRecoveryError: ждать окна отката здесь бессмысленно; hy2xs-orchestrator statusпри этом не падает: он замок не берёт и существует в том числе для сломанного хоста, поэтому сообщаетrollback_guard_state: "unknown"иfirewall_state: "guard_unknown";- после возврата рабочего
systemctlоперация проходит без дополнительных действий.
Смысл проверки — в том, что прежнее поведение было противоположным: отказ запроса давал пустой список guard'ов, барьер считал систему спокойной и пропускал операцию, а взведённый таймер предыдущей операции срабатывал уже посреди неё.
D1g. Успешная установка не оставляет следов транзакции
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка к D1c и D1e: она ловит противоположную ошибку — данные транзакции, пережившие её завершение.
После installed:
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. Проход установки не спотыкается о собственный маркер
Проверяется на чистом хосте, обычной успешной установкой.
install.shдоходит доpreflight capabilitiesпослеapt-get;- установка на этом шаге не падает с текстом «обнаружена предыдущая или посторонняя установка»;
- установка доходит до
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 провайдером
Проверяется на рабочей установке.
сервер: текущий публичный IPv4 = B
DNS: A-запись = A (старый адрес)
hy2xs-orchestrator doctor
→ FAIL
→ в выводе присутствуют и A, и B
обновить A-запись на B, дождаться TTL
hy2xs-orchestrator doctor
→ PASS
Дополнительно: reconfigure --apply при устаревшей A-записи тоже обязан
отказать — инвариант живёт в общем preflight, а не в одном doctor.