# 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//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-.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-.service`, а на диске — `/run/hy2xs/rollback//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-.timer (active/waiting) ``` 5. отказ происходит **до** снятия резервной копии и до первой мутации; 6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине; 7. после срабатывания guard транзиентный таймер выгружается (`RemainAfterElapse=no`), и `repair` проходит. Проверяется наблюдением, а не ожиданием на глаз: ```bash systemctl show hy2xs-fw-rollback-.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`.