fix(v1): сделать надёжным нижний слой отката, а не только его запуск

Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его substrate этой надёжности не соответствует: откат
гарантированно запускался, но отдельные его шаги могли молча не выполнить
восстановление, отчитаться успехом и уничтожить резервную копию.

1. Данные для отката уничтожались ДО фиксации успеха (commit ordering).

   cancelFirewallRollback снимала таймер автоотката И удаляла резервные копии
   firewall, а вызывалась до долговечной записи phase=installed. Отказ этой
   записи (ENOSPC/EIO/read-only ФС) приводил в обработчик ошибки, обязательный
   откат честно запускался и сообщал "no HY2XS rollback markers found":
   откатывать было нечем. Причём отказ записи маркера — ровно тот сценарий,
   который прошлый проход специально сделал безопасным.

   Разделено на disarmFirewallRollback (снять таймер, копии оставить) и
   cleanupFirewallRollback (удалить копии). Порядок в install и reconfigure:
   smoke_ok -> disarm -> durable installed -> cleanup best-effort.

2. Резервные копии снимались без доказательства.

   И firewall, и reconfigure копировали как `cp ... || true`: отказ
   игнорировался, операция шла менять систему без копии, на которую
   рассчитывает откат. У firewall маркер prepared («данные для отката
   существуют») выставлялся вообще ДО копирования. Копирование строгое, факт
   создания проверяется, маркер ставится после.

3. Копии reconfigure смешивались между операциями.

   Общий набор *.bak в /etc/hy2xs/backups не был привязан к проходу. Если у
   операции B копирование падало, B всё равно менял систему, а его откат
   восстанавливал файлы операции A — сервер возвращался в более старое
   состояние и это выглядело успешным откатом. Копия стала операционной:
   /etc/hy2xs/backups/<op-id>/ с манифестом, где отсутствие файла записано
   явно ("present": false), а не выведено из неудачи cp. Разбор строгий,
   включая проверку opId.

4. Ошибка восстановления скрывалась, и после неё копии удалялись.

   rollbackFirewallNow выполняла cp и nft -f с `|| true`, затем безусловно
   удаляла /run/hy2xs/rollback/<op>. Худшая комбинация: неудача не видна,
   стадия успешна, данные для ручной починки уничтожены. Теперь копии
   удаляются только после подтверждённого успеха, иначе сохраняются с
   сообщением manual recovery data preserved at ...

5. Команды отката глушили собственный код возврата.

   До стадийного раннера `|| true` был единственной защитой от обрыва цепочки;
   после его появления стал маскировкой — стадия не могла сообщить, что
   ничего не сделала. Убран; rollbackCurrentState разбита на семь независимых
   стадий.

6. Долговечность записи каталога маркера.

   writeTextAtomic синхронизирует файл и его каталог, но при первой установке
   /var/lib/hy2xs создаётся тут же, и запись "hy2xs" в /var/lib оставалась
   несинхронизированной. ensureDir сообщает о фактическом создании и
   синхронизирует родителя только тогда.

Отдельно про doctor. Утверждение аудита, что doctor вызывает
UpdatePeerLastConnectionAt через успешную machine-auth, кодом не
подтверждается: проба с действующим паролем ограничена `context.mode ===
"install"`, а doctor работает в режиме reconfigure. Инвариант, однако, ничем не
охранялся — добавлены тест и приёмка. Документация уточнена: guard действует
внутри процесса, а границу «что doctor шлёт по сети» держит состав проб;
единственный остающийся след — записи в журнале админки, и это сказано прямо.

Тесты: backup-integrity.test.ts (манифест, строгий разбор, копия до мутации,
сохранение копий при неудачном восстановлении), commit-ordering.test.ts
(disarm/cleanup разделены, порядок фиксации в обеих командах). Три теста,
закреплявших прежний инвариант «каждая команда отката несёт || true»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
This commit is contained in:
2026-08-30 19:32:43 +05:00
parent df73459ea5
commit 330a63b050
17 changed files with 1534 additions and 98 deletions
+61
View File
@@ -27,6 +27,67 @@ Hardening-проход перед релизом `1.0.0`. Основная те
проверявшие не то, что обещали, и два свойства, которые были описаны, но не проверявшие не то, что обещали, и два свойства, которые были описаны, но не
обеспечены — read-only у `doctor` и различение отказа базы у `reset-admin`. обеспечены — read-only у `doctor` и различение отказа базы у `reset-admin`.
Пятый проход — нижний слой того же механизма. Верхнеуровневый откат стал
надёжным раньше, чем его storage/firewall substrate: откат гарантированно
запускался, но отдельные его шаги могли молча не выполнить восстановление,
отчитаться успехом и уничтожить резервную копию.
### Исправлено — целостность отката
- **Данные для отката уничтожались до фиксации успеха.** Успешный install
заканчивался вызовом, который снимал таймер автоотката **и удалял резервные
копии firewall**, — и стоял этот вызов ДО долговечной записи
`phase: installed`. Если запись падала (`ENOSPC`, `EIO`, read-only ФС),
управление уходило в обработчик ошибки, обязательный откат честно запускался
и сообщал `no HY2XS rollback markers found`. Откат нельзя было отменить, но
откатывать ему было нечем — причём отказ записи маркера это ровно тот
сценарий, который был специально сделан безопасным прошлым проходом.
Операция разделена на `disarmFirewallRollback` (снять таймер, копии
оставить) и `cleanupFirewallRollback` (удалить копии). Порядок теперь:
`smoke_ok` → disarm → durable `installed` → cleanup best-effort. То же в
`reconfigure`.
- **Резервные копии снимались без доказательства.** И firewall, и
`reconfigure` копировали файлы как `cp ... || true`, поэтому отказ
копирования игнорировался, а операция начинала менять систему, не имея
копии, на которую рассчитывает откат. У firewall маркер `prepared`
(«данные для отката существуют») выставлялся вообще до копирования.
Копирование стало строгим, факт создания проверяется, маркер ставится после.
- **Копии `reconfigure` смешивались между операциями.** Они лежали одним общим
набором `*.bak` в `/etc/hy2xs/backups`, не привязанным к проходу. Если у
операции B копирование падало, B всё равно менял систему, а его откат
восстанавливал файлы, сохранённые операцией A: сервер возвращался не в
состояние «до B», а в более старое — и это выглядело успешным откатом.
Копия стала операционной: `/etc/hy2xs/backups/<op-id>/` с манифестом, где
отсутствие файла — записанный факт (`"present": false`), а не вывод из
неудачи `cp`. Разбор манифеста строгий, включая проверку `opId`.
- **Ошибка восстановления скрывалась, а копии после неё удалялись.**
`rollbackFirewallNow` выполняла `cp` и `nft -f` с `|| true`, а затем
безусловно удаляла `/run/hy2xs/rollback/<op>`. Худшая возможная комбинация:
неудача восстановления не видна, стадия отчитывается успешной, а данные, по
которым оператор мог бы поднять firewall вручную, уничтожены. Теперь копии
удаляются только после подтверждённого успеха, иначе сохраняются с
сообщением `manual recovery data preserved at …`.
- **Команды отката глушили собственный код возврата.** `systemctl stop`,
`disable`, `reset-failed`, `cp`, `nft -f`, `daemon-reload`, `restart` — все
несли `|| true`. До появления стадийного раннера это была единственная защита
от обрыва цепочки; после его появления — маскировка: стадия физически не
могла сообщить, что ничего не сделала, и обещание «отказавшие стадии
перечисляются» для них не выполнялось никогда. `|| true` убран,
непрерывность обеспечивает раннер, `rollbackCurrentState` разбита на семь
независимых стадий.
- **Долговечность записи каталога маркера.** `writeTextAtomic` синхронизирует
файл и каталог, в котором файл лежит, но при первой установке
`/var/lib/hy2xs` создаётся тут же, и запись «hy2xs» в `/var/lib` оставалась
несинхронизированной: после потери питания мог исчезнуть весь каталог вместе
с маркером. `ensureDir` сообщает о фактическом создании и синхронизирует
родителя только тогда.
### Изменено — свойства, ставшие инвариантами ### Изменено — свойства, ставшие инвариантами
- **`doctor` read-only по инварианту рантайма, а не по соглашению.** - **`doctor` read-only по инварианту рантайма, а не по соглашению.**
+100
View File
@@ -201,6 +201,14 @@ machine token или пароль пира, а сообщение уходит
перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла. перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла.
Для метаданных восстановления это неприемлемо. Для метаданных восстановления это неприемлемо.
Есть ещё один уровень: при первой установке сам каталог `/var/lib/hy2xs`
создаётся прямо сейчас, и запись «hy2xs» в `/var/lib` тоже обязана быть
долговечной. Иначе возможно состояние, в котором и файл, и его каталог сброшены
на носитель, а каталог из родителя исчез — то есть маркер пропал целиком.
Поэтому `ensureDir` сообщает, был ли каталог **фактически создан**, и при
создании синхронизирует родителя. На последующих обновлениях маркера каталог уже
существует, и лишний `fsync` родителя не выполняется.
## Ownership и rollback ## Ownership и rollback
Операция ведёт учёт того, к чему она **могла прикоснуться**: Операция ведёт учёт того, к чему она **могла прикоснуться**:
@@ -286,6 +294,98 @@ preflight-ошибка со словом `nftables` приводила к отк
ошибка операции: проблема внутри отката — это дополнительная информация о том, ошибка операции: проблема внутри отката — это дополнительная информация о том,
что осталось не восстановленным, а не замена диагноза. что осталось не восстановленным, а не замена диагноза.
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
`cp`, `nft -f`, `systemctl daemon-reload` и `systemctl restart` возвращали ноль
при любом исходе, и «restore configuration» никогда не попадала в список
отказавших. Непрерывность обеспечивает стадийный раннер; подавление кода
возврата после его появления стало не защитой, а маскировкой.
### Порядок фиксации успеха
Данные, по которым выполняется откат, обязаны пережить долговечную запись
успеха:
```text
smoke PASS
durable phase = smoke_ok
disarm автоматического отката по таймеру ← резервные копии ОСТАЮТСЯ
durable phase = installed ← точка фиксации
cleanup резервных копий ← best effort
```
Раньше снятие таймера и удаление копий выполнял один вызов, стоявший **до**
записи `installed`. Отсюда следовал разрыв:
```text
smoke PASS
→ таймер снят, резервные копии УДАЛЕНЫ
→ запись "installed" падает (ENOSPC / EIO / read-only ФС)
→ обработчик ошибки → обязательный откат
→ "firewall rollback skipped: no HY2XS rollback markers found"
```
То есть ровно тот отказ записи маркера, который был специально сделан
безопасным, случался после уничтожения единственных данных для отката: откат
запускался, но откатывать ему было нечем.
Уборка после точки фиксации выполняется best-effort намеренно: невозможность
удалить временные данные в `/run` — мусор, а не причина объявить успешную
установку неуспешной.
### Резервные копии: строгие и привязанные к операции
Две отдельные гарантии, которых раньше не было ни у firewall, ни у
`reconfigure`.
**Копия обязана существовать до первой мутации.** Копирование выполнялось как
`cp ... || true`, поэтому отказ (заполненный `/run`, ошибка ввода-вывода, права)
игнорировался, а операция шла менять систему, не имея того, на что рассчитывает
откат. Теперь копирование строгое, факт создания проверяется, а маркер
готовности `prepared` ставится **после** проверенных копий, а не до них.
**Копия принадлежит конкретной операции.** `reconfigure` хранил копии всех
операций одним общим набором `*.bak` в `/etc/hy2xs/backups`. Отсюда сценарий:
```text
reconfigure A → config.yaml.bak создан
reconfigure B → создание копии упало, ошибка скрыта
→ B меняет конфигурацию
→ B падает → откат восстанавливает копию, снятую операцией A
```
Сервер возвращался не в состояние «до B», а в более старое — и это выглядело
успешным откатом. Теперь копия лежит в `/etc/hy2xs/backups/<op-id>/` с
манифестом:
```json
{
"version": 1,
"opId": "2026-08-30T10-00-00.000Z",
"entries": [
{ "path": "/etc/hysteria/config.yaml", "present": true, "stored": "etc_hysteria_config.yaml" },
{ "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null }
]
}
```
Отсутствие файла — **записанный факт**, а не вывод из неудачи `cp`: по этому
полю откат решает, восстанавливать файл или удалять его. Разбор манифеста
строгий, включая проверку `opId`: восстановление по частично понятому манифесту
или по копии чужой операции опаснее отказа.
**Артефакты восстановления удаляются только после подтверждённого
восстановления.** `rollbackFirewallNow` раньше скрывала ошибки `cp` и `nft`, а
затем безусловно удаляла копии — худшая комбинация, при которой неудача
восстановления не видна, а данные для ручной починки уничтожены. Теперь при
любом отказе стадии копии сохраняются, и в журнале появляется
`manual recovery data preserved at …`.
## Инвариант публичного endpoint ## Инвариант публичного endpoint
`preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как `preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как
+107 -6
View File
@@ -210,6 +210,41 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
`systemctl daemon-reload` отменял перезапуск сервисов строкой ниже, то есть `systemctl daemon-reload` отменял перезапуск сервисов строкой ниже, то есть
восстановленные unit-файлы так и не применялись. восстановленные unit-файлы так и не применялись.
## A5c. Целостность резервных копий (unit)
`orchestrator/test/backup-integrity.test.ts`:
- копия каждой операции адресуется своим каталогом, разные `op-id` не
пересекаются;
- разные пути дают разные имена файлов копии, и имя не выходит за пределы
каталога;
- отсутствовавший файл записан **явно** (`present: false`), а не выведен из
неудачи `cp`;
- манифест переживает сериализацию без потерь;
- разбор строгий: манифест чужой операции, неизвестная версия, битый JSON,
запись без пути, без признака существования или без имени копии —
отклоняются. «Поле не разобралось, будем считать, что файла не было» означало
бы удаление существующего файла при откате;
- копирование в `reconfigure` и в `firewall` не глушит ошибки, факт создания
копии проверяется, а копия снимается **до** первой мутации;
- маркер готовности firewall (`prepared`) ставится после проверенных копий;
- восстановление firewall не глушит ошибки `cp`/`nft`, а резервные копии
удаляются только после подтверждённого успеха — иначе сохраняются вместе с
сообщением `manual recovery data preserved at …`.
## A5d. Порядок фиксации успеха (unit)
`orchestrator/test/commit-ordering.test.ts`:
- снятие таймера автоотката и удаление резервных копий — **разные** операции
(`disarmFirewallRollback` / `cleanupFirewallRollback`), объединённая
`cancelFirewallRollback` не вернулась ни в один вызов;
- `disarm` не удаляет копии;
- порядок в `install` и `reconfigure` одинаков: `disarm` → долговечная запись
`installed``cleanup`;
- успешный smoke фиксируется отдельной фазой до снятия таймера;
- уборка после точки фиксации выполняется best-effort.
## A5b. Долговечная запись маркера (unit) ## A5b. Долговечная запись маркера (unit)
`orchestrator/test/atomic-write.test.ts`: `orchestrator/test/atomic-write.test.ts`:
@@ -227,6 +262,11 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
`/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того `/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того
файла, по которому clean-host принимает решение. файла, по которому clean-host принимает решение.
- `ensureDir` сообщает, был ли каталог **фактически создан**: родитель
синхронизируется только при создании, иначе долговечность записи `hy2xs` в
`/var/lib` осталась бы необеспеченной, и после потери питания мог исчезнуть
весь каталог вместе с маркером.
Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из
пользовательского процесса их не наблюдать, а без них `rename()` даёт пользовательского процесса их не наблюдать, а без них `rename()` даёт
атомарность видимости без долговечности. атомарность видимости без долговечности.
@@ -823,15 +863,29 @@ runtime-пакета выполнял `install.sh`, эти пути не при
Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния
больше не стоит перед восстановлением. больше не стоит перед восстановлением.
Подготовка: `/var/lib/hy2xs` делается недоступным для записи именно к моменту **Тайминг здесь — часть сценария, и его легко испортить.**
обработки ошибки. Практичнее всего смонтировать поверх него крошечный `tmpfs`
и заполнить его до отказа: `tmpfs` НЕЛЬЗЯ монтировать заранее: первая же запись маркера (`preflight_ok`)
получит `ENOSPC`, установка отвалится до firewall, и проверяться будет совсем
другой путь — обычный `fatal_post_apply` на ранней стадии.
Порядок строго такой:
1. запустить установку и дождаться в журнале `step=firewall status=done`;
2. **только теперь**, во втором терминале:
```bash ```bash
mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs 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 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` 1. установка доходит **дальше** шага firewall (то есть `firewallTouched`
@@ -856,9 +910,51 @@ dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
отменялись разом. отменялись разом.
Дополнительно проверяется независимость стадий: если сделать неработоспособной Дополнительно проверяется независимость стадий: если сделать неработоспособной
первую стадию (например, удалить `/run/hy2xs/rollback/<op>` между применением первую стадию (например, сделать `/etc/nftables.conf` неперезаписываемым через
firewall и отказом), восстановление конфигов и остановка сервисов обязаны `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`.
## D1a. Проход установки не спотыкается о собственный маркер ## D1a. Проход установки не спотыкается о собственный маркер
@@ -1017,3 +1113,8 @@ hy2xs-orchestrator doctor
53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле 53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле
54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон 54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон
55. `reset-admin` при недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустому `password_hash` 55. `reset-admin` при недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустому `password_hash`
56. данные для отката переживают долговечную фиксацию успеха: снятие таймера автоотката и удаление резервных копий разделены записью `phase: installed`
57. резервная копия снимается строго и до первой мутации; несозданная копия останавливает операцию, а не игнорируется
58. копия привязана к операции: откат восстанавливает состояние непосредственно перед текущим проходом, а не сохранённое предыдущим
59. ни одна команда отката не глушит свой код возврата; отказавшие стадии перечисляются, а артефакты восстановления удаляются только после подтверждённого успеха
60. `doctor` не выполняет проб, изменяющих данные в админке: авторизация действующим паролем пира ограничена режимом `install`
+26
View File
@@ -270,6 +270,32 @@ hy2xs-admin`, и диагностика подозрения на проблем
auth, `trafficStats`, версия бинаря, семантика `/etc/hysteria/config.yaml` и auth, `trafficStats`, версия бинаря, семантика `/etc/hysteria/config.yaml` и
синтаксис `nft` проверяются полностью. синтаксис `nft` проверяются полностью.
### Где проходит граница read-only
Guard действует внутри процесса оркестратора. Он не способен запретить
побочный эффект, который вызвал бы HTTP-запрос в **другом** процессе, поэтому
эта половина границы держится не им, а составом проб.
Существенный случай — machine-auth. Успешная авторизация пира заставляет админку
выполнить `UPDATE peer.last_connection_at`, то есть диагностика изменила бы
отображаемое «последнее подключение» у `bootstrap-admin-peer`. Поэтому проба с
**действующим** паролем выполняется только в режиме `install`; `doctor` работает
в режиме `reconfigure` и до неё не доходит. Полный happy-path авторизации
проверяют установка и E2E, а не диагностика.
`doctor` отправляет только пробы, которые заведомо не проходят авторизацию
(отсутствующий machine token, неверные учётные данные, некорректный тип поля) и
читающие запросы (`/healthz`, `trafficStats /online`). Ни одна из них не
изменяет данные.
Честная формулировка гарантии:
> `doctor` не изменяет конфигурацию, состояние сервисов, firewall и данные.
Единственный след, который он оставляет, — записи в журнале админки: пробы
проходят через обычный обработчик логирования, как любой запрос. Это не
состояние системы, но и не «совсем ничего», поэтому сказано прямо.
Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного
публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline; публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline;
осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`. осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
+15
View File
@@ -80,6 +80,21 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены | | `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки | | `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью | | `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
Резервные копии не удаляются, пока восстановление не подтверждено, и переживают
долговечную запись `phase: installed`. Поэтому при разборе неудачи всегда
осмысленно посмотреть:
```bash
ls -la /run/hy2xs/rollback/ # копии firewall текущей операции
ls -la /etc/hy2xs/backups/ # копия состояния до последнего reconfigure
cat /etc/hy2xs/backups/*/manifest.json
```
Манифест прямо говорит, какие файлы существовали до операции, а какие нет:
запись `"present": false` означает, что откат обязан был файл **удалить**, а не
восстановить.
Наружу оркестратор всегда пробрасывает **исходную** ошибку операции, а не Наружу оркестратор всегда пробрасывает **исходную** ошибку операции, а не
проблему внутри отката: последняя — это информация о том, что осталось не проблему внутри отката: последняя — это информация о том, что осталось не
+46 -7
View File
@@ -21,7 +21,12 @@ import { deployUi } from "../steps/ui";
import { installHysteria } from "../steps/hysteria"; import { installHysteria } from "../steps/hysteria";
import { generateConfig } from "../steps/config"; import { generateConfig } from "../steps/config";
import { deploySystemd } from "../steps/systemd"; import { deploySystemd } from "../steps/systemd";
import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; import {
applyFirewall,
cleanupFirewallRollback,
disarmFirewallRollback,
rollbackFirewallNow
} from "../steps/firewall";
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke"; import { smoke } from "../steps/smoke";
import { diagnosticsCollect } from "./diagnostics"; import { diagnosticsCollect } from "./diagnostics";
@@ -42,6 +47,7 @@ type InstallPhase =
| "bootstrap_secret_written" | "bootstrap_secret_written"
| "services_started" | "services_started"
| "smoke_running" | "smoke_running"
| "smoke_ok"
| "smoke_failed" | "smoke_failed"
| "failed" | "failed"
| "installed"; | "installed";
@@ -244,23 +250,33 @@ async function rollbackFailedInstall(
} }
if (ownership.unitsTouched) { if (ownership.unitsTouched) {
// Без `|| true`, и это осознанно.
//
// Раньше каждая из этих команд глушила собственный код возврата, поэтому
// стадия физически не могла сообщить runRollbackStages, что systemctl
// ничего не сделал. Обещание «отказавшие стадии перечисляются» для них не
// выполнялось никогда.
//
// Непрерывность отката теперь обеспечивает стадийный раннер, а не
// подавление ошибок в каждой команде: отказ остановки не мешает
// выполниться disable, отказ disable — выполниться reset-failed.
stages.push( stages.push(
{ {
name: "stop services", name: "stop services",
run: async () => { run: async () => {
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`; await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`;
} }
}, },
{ {
name: "disable services", name: "disable services",
run: async () => { run: async () => {
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`; await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`;
} }
}, },
{ {
name: "reset failed services", name: "reset failed services",
run: async () => { run: async () => {
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`; await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin`;
} }
} }
); );
@@ -396,12 +412,35 @@ export async function install(options: InstallOptions): Promise<void> {
phase = "smoke_running"; phase = "smoke_running";
await smoke(context); await smoke(context);
stepDone("smoke checks"); stepDone("smoke checks");
step("finalize firewall rollback guard"); await advanceInstallState(context, ownership, "smoke_ok");
await cancelFirewallRollback(context); phase = "smoke_ok";
stepDone("finalize firewall rollback guard");
// Порядок фиксации успеха.
//
// Раньше здесь стоял один вызов, который снимал таймер автоотката И удалял
// резервные копии firewall, — и стоял он ДО долговечной записи
// `phase: installed`. Отказ этой записи (ENOSPC, EIO, read-only ФС)
// приводил в обработчик ошибки, обязательный откат запускался и сообщал
// «no HY2XS rollback markers found»: единственные данные для отката были
// уничтожены секундой раньше.
//
// Теперь между снятием таймера и удалением копий стоит сама фиксация
// успеха, и до неё откат остаётся выполнимым.
step("disarm firewall rollback timer");
await disarmFirewallRollback(context);
stepDone("disarm firewall rollback timer");
step("mark install successful"); step("mark install successful");
await advanceInstallState(context, ownership, "installed"); await advanceInstallState(context, ownership, "installed");
stepDone("mark install successful"); stepDone("mark install successful");
// Установка уже зафиксирована. Неудача уборки — мусор в /run, а не причина
// объявить успешную установку неуспешной.
try {
await cleanupFirewallRollback(context);
} catch (cleanupError) {
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
info(`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`);
}
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const failureKind = classifyFailure(ownership, phase); const failureKind = classifyFailure(ownership, phase);
+209 -34
View File
@@ -14,7 +14,23 @@ import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight"; import { preflight } from "../steps/preflight";
import { generateConfig } from "../steps/config"; import { generateConfig } from "../steps/config";
import { deploySystemd } from "../steps/systemd"; import { deploySystemd } from "../steps/systemd";
import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; import {
applyFirewall,
cleanupFirewallRollback,
disarmFirewallRollback,
rollbackFirewallNow
} from "../steps/firewall";
import {
BACKUP_ROOT,
backupDir,
backupFilePath,
backupManifestPath,
buildManifest,
parseManifest,
renderManifest,
storedNameFor,
type BackupManifest
} from "../lib/backupManifest";
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke"; import { smoke } from "../steps/smoke";
import { runMutatingVisible } from "../lib/process"; import { runMutatingVisible } from "../lib/process";
@@ -76,8 +92,12 @@ export function classifyReconfigureFailure(ownership: ReconfigureOwnership): Rec
return "reconfigure_failed"; return "reconfigure_failed";
} }
/**
* Ключ операции. Санитизируется так же, как в steps/firewall.ts: он служит
* ИМЕНЕМ КАТАЛОГА резервной копии, а не только полем в маркере.
*/
function operationKey(context: ReconfigureContext): string { function operationKey(context: ReconfigureContext): string {
return context.installDate; return context.installDate.replace(/[^a-zA-Z0-9_.-]/g, "-");
} }
function ownedPaths(context: ReconfigureContext): string[] { function ownedPaths(context: ReconfigureContext): string[] {
@@ -114,38 +134,164 @@ async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, l
await persistInstallState(record); await persistInstallState(record);
} }
async function backupCurrentState(): Promise<void> { /**
await runMutatingVisible`mkdir -p /etc/hy2xs/backups`; * Файлы, которые reconfigure/repair обязаны сохранить до первой мутации.
await runMutatingVisible`cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak 2>/dev/null || true`; *
await runMutatingVisible`cp -a /etc/systemd/system/hy2xs-admin.service /etc/hy2xs/backups/hy2xs-admin.service.bak 2>/dev/null || true`; * Порядок групп задаёт и порядок восстановления: конфиг Hysteria, юниты,
await runMutatingVisible`cp -a /etc/systemd/system/hysteria-server.service /etc/hy2xs/backups/hysteria-server.service.bak 2>/dev/null || true`; * env-артефакты, файлы nftables.
await runMutatingVisible`cp -a /etc/hy2xs/hy2xs.env /etc/hy2xs/backups/hy2xs.env.bak 2>/dev/null || true`; */
await runMutatingVisible`cp -a /etc/hysteria/post-install.env /etc/hy2xs/backups/post-install.env.bak 2>/dev/null || true`; const BACKUP_GROUPS = [
await runMutatingVisible`cp -a /etc/nftables.conf /etc/hy2xs/backups/nftables.conf.bak 2>/dev/null || true`; { stage: "restore hysteria config", paths: ["/etc/hysteria/config.yaml"] },
await runMutatingVisible`cp -a /etc/nftables.d/hy2xs.nft /etc/hy2xs/backups/hy2xs.nft.bak 2>/dev/null || true`; {
stage: "restore systemd units",
paths: ["/etc/systemd/system/hy2xs-admin.service", "/etc/systemd/system/hysteria-server.service"]
},
{ stage: "restore runtime env", paths: ["/etc/hy2xs/hy2xs.env", "/etc/hysteria/post-install.env"] },
{ stage: "restore nftables files", paths: ["/etc/nftables.conf", "/etc/nftables.d/hy2xs.nft"] }
] as const;
await runMutatingVisible`test -f /etc/hy2xs/hy2xs.env && echo 1 > /etc/hy2xs/backups/hy2xs.env.existed || rm -f /etc/hy2xs/backups/hy2xs.env.existed`; const BACKED_UP_PATHS = BACKUP_GROUPS.flatMap((group) => group.paths);
await runMutatingVisible`test -f /etc/hysteria/post-install.env && echo 1 > /etc/hy2xs/backups/post-install.env.existed || rm -f /etc/hy2xs/backups/post-install.env.existed`;
await runMutatingVisible`test -f /etc/nftables.conf && echo 1 > /etc/hy2xs/backups/nftables.conf.existed || rm -f /etc/hy2xs/backups/nftables.conf.existed`; /**
await runMutatingVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > /etc/hy2xs/backups/hy2xs.nft.existed || rm -f /etc/hy2xs/backups/hy2xs.nft.existed`; * Снимает полную резервную копию текущей операции и ДОКАЗЫВАЕТ, что снял.
*
* Копия привязана к op-id: каталог /etc/hy2xs/backups/<op-id>/ и манифест в
* нём. Раньше копии всех операций лежали одним общим набором `*.bak`, и при
* неудачном копировании в операции B откат B восстанавливал файлы, сохранённые
* операцией A, — то есть возвращал сервер в более старое состояние и выглядел
* при этом успешным.
*
* Любая ошибка здесь — отказ ДО первой мутации: у reconfigure ещё ничего не
* изменено, и остановиться дешевле, чем начать менять систему без копии.
*/
async function backupCurrentState(context: ReconfigureContext): Promise<void> {
const opId = operationKey(context);
const dir = backupDir(opId);
await runMutatingVisible`install -d -m 0700 -o root -g root ${dir}`;
await runMutatingVisible`install -d -m 0700 -o root -g root ${dir}/files`;
const files: { path: string; present: boolean }[] = [];
for (const path of BACKED_UP_PATHS) {
const present = await fileExists(path);
files.push({ path, present });
if (!present) {
continue;
} }
async function rollbackCurrentState(): Promise<void> { const target = backupFilePath(opId, storedNameFor(path));
await runMutatingVisible`cp -a /etc/hy2xs/backups/config.yaml.bak /etc/hysteria/config.yaml 2>/dev/null || true`; await runMutatingVisible`cp -a ${path} ${target}`;
await runMutatingVisible`cp -a /etc/hy2xs/backups/hy2xs-admin.service.bak /etc/systemd/system/hy2xs-admin.service 2>/dev/null || true`; if (!(await fileExists(target))) {
await runMutatingVisible`cp -a /etc/hy2xs/backups/hysteria-server.service.bak /etc/systemd/system/hysteria-server.service 2>/dev/null || true`; throw new Error(
`резервная копия ${path} не создана: ${target} отсутствует. ` +
"Reconfigure остановлен до первой мутации: без полной копии откат недостоверен."
);
}
}
await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.env.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.env.bak /etc/hy2xs/hy2xs.env 2>/dev/null || true; else rm -f /etc/hy2xs/hy2xs.env; fi`; const manifest = buildManifest(opId, context.installDate, files);
await runMutatingVisible`if [ -f /etc/hy2xs/backups/post-install.env.existed ]; then cp -a /etc/hy2xs/backups/post-install.env.bak /etc/hysteria/post-install.env 2>/dev/null || true; else rm -f /etc/hysteria/post-install.env; fi`; await writeTextAtomic(backupManifestPath(opId), renderManifest(manifest), {
await runMutatingVisible`if [ -f /etc/hy2xs/backups/nftables.conf.existed ]; then cp -a /etc/hy2xs/backups/nftables.conf.bak /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`; mode: 0o600,
await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`; owner: "root",
group: "root"
});
await runMutatingVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`; info(`backup of the current state is complete: ${dir}`);
// `|| true` здесь по той же причине, что и у остальных команд отката: без }
// него отказ daemon-reload отменял бы перезапуск сервисов строкой ниже, то
// есть восстановленные из backups unit-файлы так и не были бы применены. async function readBackupManifest(opId: string): Promise<BackupManifest> {
await runMutatingVisible`systemctl daemon-reload || true`; const path = backupManifestPath(opId);
await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin || true`; if (!(await fileExists(path))) {
throw new Error(`манифест резервной копии не найден: ${path}`);
}
return parseManifest(await readText(path), opId);
}
/**
* Стадии восстановления состояния до текущей операции.
*
* Каждая стадия независима и НЕ глушит собственные ошибки. Раньше здесь были
* `cp ... || true`, `nft -f ... || true`, `daemon-reload || true` и
* `restart ... || true`, поэтому высокоуровневая стадия «restore
* configuration» физически не могла попасть в список отказавших: откат всегда
* отчитывался успешным, что бы ни произошло.
*
* Непрерывность обеспечивает стадийный раннер, а не подавление ошибок.
*/
function restoreStages(manifest: BackupManifest, opId: string): RollbackStage[] {
const byPath = new Map(manifest.entries.map((entry) => [entry.path, entry]));
const stages: RollbackStage[] = [];
for (const group of BACKUP_GROUPS) {
stages.push({
name: group.stage,
run: async () => {
for (const path of group.paths) {
const entry = byPath.get(path);
if (!entry) {
throw new Error(`манифест резервной копии не описывает ${path}`);
}
if (!entry.present) {
// Файла не существовало до операции: восстановление — удаление.
await runMutatingVisible`rm -f ${path}`;
continue;
}
await runMutatingVisible`cp -a ${backupFilePath(opId, entry.stored as string)} ${path}`;
}
}
});
}
const entrypoint = byPath.get("/etc/nftables.conf");
stages.push({
name: "apply restored ruleset",
run: async () => {
if (entrypoint?.present) {
await runMutatingVisible`nft -f /etc/nftables.conf`;
return;
}
await runMutatingVisible`nft flush ruleset`;
}
});
stages.push({
name: "reload systemd units",
run: async () => {
await runMutatingVisible`systemctl daemon-reload`;
}
});
stages.push({
name: "restart services",
run: async () => {
await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin`;
}
});
return stages;
}
async function rollbackCurrentState(context: ReconfigureContext): Promise<string[]> {
const opId = operationKey(context);
const manifest = await readBackupManifest(opId);
return await runRollbackStages(restoreStages(manifest, opId));
}
/**
* Убирает копии предыдущих операций после долговечной фиксации успеха.
*
* Копия текущей операции остаётся: это состояние «до последнего успешного
* reconfigure», ровно та же семантика, что была у прежнего единственного
* набора `*.bak`, но теперь без смешения операций. Рост каталога при этом
* ограничен — лишние каталоги удаляются.
*/
async function pruneOldBackups(context: ReconfigureContext): Promise<void> {
const opId = operationKey(context);
await runMutatingVisible`find ${BACKUP_ROOT} -mindepth 1 -maxdepth 1 -type d ! -name ${opId} -exec rm -rf {} +`;
// Наследие общего каталога: одиночные *.bak и *.existed от прежней схемы.
await runMutatingVisible`find ${BACKUP_ROOT} -mindepth 1 -maxdepth 1 -type f -name '*.bak' -delete`;
await runMutatingVisible`find ${BACKUP_ROOT} -mindepth 1 -maxdepth 1 -type f -name '*.existed' -delete`;
} }
async function readInstallState(): Promise<InstallStateRecord | null> { async function readInstallState(): Promise<InstallStateRecord | null> {
@@ -239,7 +385,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
} }
step("backup"); step("backup");
await backupCurrentState(); await backupCurrentState(context);
stepDone("backup"); stepDone("backup");
const ownership = newReconfigureOwnership(); const ownership = newReconfigureOwnership();
@@ -277,15 +423,35 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
await smoke(context); await smoke(context);
stepDone("smoke checks"); stepDone("smoke checks");
await markPhase(context, "smoke_ok"); await markPhase(context, "smoke_ok");
step("finalize firewall rollback guard");
await cancelFirewallRollback(context); // Порядок фиксации успеха — тот же, что и в install: снять таймер
stepDone("finalize firewall rollback guard"); // автоотката, ЗАФИКСИРОВАТЬ успех долговечно и только потом удалять данные
// для отката. Раньше единственный вызов снимал таймер и удалял резервные
// копии firewall до записи `installed`, поэтому отказ этой записи приводил
// в обработчик ошибки, где откатывать было уже нечем.
step("disarm firewall rollback timer");
await disarmFirewallRollback(context);
stepDone("disarm firewall rollback timer");
await markPhase(context, "installed"); await markPhase(context, "installed");
const finalState = await readInstallState(); const finalState = await readInstallState();
if (!finalState?.installed || finalState.phase !== "installed") { if (!finalState?.installed || finalState.phase !== "installed") {
throw new Error("deterministic state violation: reconfigure/repair finished without installed phase"); throw new Error("deterministic state violation: reconfigure/repair finished without installed phase");
} }
// Операция зафиксирована. Уборка — best effort: невозможность удалить
// временные данные не делает успешный reconfigure неуспешным.
for (const [what, cleanup] of [
["firewall rollback data", () => cleanupFirewallRollback(context)],
["previous state backups", () => pruneOldBackups(context)]
] as const) {
try {
await cleanup();
} catch (cleanupError) {
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
info(`cleanup of ${what} failed after a successful reconfigure: ${cleanupMessage}`);
}
}
} catch (error) { } catch (error) {
info("reconfigure failed, rollback in progress"); info("reconfigure failed, rollback in progress");
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -333,7 +499,16 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
stages.push({ stages.push({
name: "restore configuration", name: "restore configuration",
run: async () => { run: async () => {
await rollbackCurrentState(); // rollbackCurrentState сама разбита на независимые стадии и возвращает
// список отказавших. Здесь он превращается в отказ внешней стадии,
// чтобы итоговая сводка отката не объявила восстановление успешным.
const restoreFailures = await rollbackCurrentState(context);
if (restoreFailures.length > 0) {
throw new Error(
`восстановление состояния выполнено не полностью: ${restoreFailures.join("; ")}. ` +
`Данные для ручного восстановления сохранены в ${backupDir(operationKey(context))}`
);
}
} }
}); });
+154
View File
@@ -0,0 +1,154 @@
/**
* Манифест резервной копии одной операции reconfigure/repair.
*
* Зачем он вообще нужен.
*
* Резервная копия раньше жила в общем каталоге /etc/hy2xs/backups одним
* набором `*.bak`, не привязанным к операции, и снималась так:
*
* cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak || true
*
* Из этого следовали две разные беды.
*
* 1. Отказ копирования игнорировался, и reconfigure шёл менять систему, не имея
* копии, на которую рассчитывает откат.
*
* 2. Копии от РАЗНЫХ операций смешивались. Если reconfigure A успешно записал
* config.yaml.bak, а у reconfigure B копирование упало, то B всё равно менял
* конфигурацию, а его откат восстанавливал файл, сохранённый операцией A. То
* есть сервер возвращался не в состояние «до B», а в более старое — и это
* выглядело как успешный откат.
*
* Манифест закрывает обе: он привязан к op-id и содержит ЯВНУЮ запись о каждом
* файле, включая отсутствовавшие. «Файла не было» становится записанным фактом,
* а не выводом из неудачи `cp`.
*/
export const BACKUP_ROOT = "/etc/hy2xs/backups";
export const BACKUP_MANIFEST_VERSION = 1;
export type BackupEntry = {
/** Путь на сервере, который эта запись описывает. */
path: string;
/** Существовал ли файл на момент снятия копии. */
present: boolean;
/** Имя файла внутри каталога копии; null, если файла не было. */
stored: string | null;
};
export type BackupManifest = {
version: number;
opId: string;
createdAt: string;
entries: BackupEntry[];
};
export function backupDir(opId: string): string {
return `${BACKUP_ROOT}/${opId}`;
}
export function backupManifestPath(opId: string): string {
return `${backupDir(opId)}/manifest.json`;
}
export function backupFilePath(opId: string, stored: string): string {
return `${backupDir(opId)}/files/${stored}`;
}
/**
* Имя файла внутри копии.
*
* Плоское, потому что каталог копии — не зеркало файловой системы, а набор
* артефактов; при этом имя обязано быть однозначным, иначе
* /etc/nftables.conf и /etc/hy2xs/nftables.conf легли бы в одно место.
*/
export function storedNameFor(path: string): string {
const normalized = path.replace(/^\/+/, "").replace(/[^A-Za-z0-9._-]+/g, "_");
return normalized || "unnamed";
}
export function buildManifest(
opId: string,
createdAt: string,
files: readonly { path: string; present: boolean }[]
): BackupManifest {
return {
version: BACKUP_MANIFEST_VERSION,
opId,
createdAt,
entries: files.map((file) => ({
path: file.path,
present: file.present,
stored: file.present ? storedNameFor(file.path) : null
}))
};
}
export function renderManifest(manifest: BackupManifest): string {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
/**
* Разбор манифеста строгий.
*
* Откат по частично понятому манифесту опаснее отказа: «поле не разобралось,
* будем считать, что файла не было» означает удаление существующего файла.
*/
export function parseManifest(raw: string, expectedOpId: string): BackupManifest {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`манифест резервной копии не разбирается: ${error instanceof Error ? error.message : String(error)}`
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("манифест резервной копии не является объектом");
}
const record = parsed as Record<string, unknown>;
if (record.version !== BACKUP_MANIFEST_VERSION) {
throw new Error(
`неизвестная версия манифеста резервной копии: ${JSON.stringify(record.version)}, ожидалась ${BACKUP_MANIFEST_VERSION}`
);
}
if (record.opId !== expectedOpId) {
throw new Error(
`манифест относится к другой операции: ${JSON.stringify(record.opId)}, ожидалась ${JSON.stringify(expectedOpId)}`
);
}
if (!Array.isArray(record.entries)) {
throw new Error("в манифесте резервной копии нет списка файлов");
}
const entries: BackupEntry[] = record.entries.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new Error(`запись ${index} манифеста не является объектом`);
}
const entry = item as Record<string, unknown>;
if (typeof entry.path !== "string" || entry.path === "") {
throw new Error(`запись ${index} манифеста не содержит пути`);
}
if (typeof entry.present !== "boolean") {
throw new Error(`запись ${index} манифеста не сообщает, существовал ли файл`);
}
if (entry.present && (typeof entry.stored !== "string" || entry.stored === "")) {
throw new Error(`запись ${index} манифеста объявляет файл существующим, но не называет копию`);
}
return {
path: entry.path,
present: entry.present,
stored: entry.present ? (entry.stored as string) : null
};
});
return {
version: BACKUP_MANIFEST_VERSION,
opId: expectedOpId,
createdAt: typeof record.createdAt === "string" ? record.createdAt : "",
entries
};
}
+20 -1
View File
@@ -79,13 +79,32 @@ export async function ensureDir(
owner?: string; owner?: string;
group?: string; group?: string;
} }
): Promise<void> { ): Promise<{ created: boolean }> {
assertMutationAllowed(`ensureDir(${path})`); assertMutationAllowed(`ensureDir(${path})`);
const existed = await dirExists(path);
await mkdir(path, { recursive: true, mode: options.mode }); await mkdir(path, { recursive: true, mode: options.mode });
await chmod(path, options.mode); await chmod(path, options.mode);
if (options.owner && options.group) { if (options.owner && options.group) {
chownByName(path, options.owner, options.group); chownByName(path, options.owner, options.group);
} }
// Долговечность самой записи каталога в РОДИТЕЛЕ.
//
// writeTextAtomic синхронизирует файл и каталог, в котором файл лежит. Но
// при первой установке /var/lib/hy2xs создаётся прямо сейчас, и запись
// «hy2xs» в /var/lib остаётся несинхронизированной. После потери питания
// возможно состояние, в котором install-state.json и его каталог были
// сброшены на носитель, а сам каталог из /var/lib исчез — то есть маркер
// восстановления пропал целиком.
//
// Синхронизируется только при фактическом создании: на каждом обновлении
// маркера каталог уже существует, и лишний fsync родителя ничего не даёт.
if (!existed) {
await syncDirectory(dirname(path));
}
return { created: !existed };
} }
/** /**
+171 -20
View File
@@ -2,6 +2,7 @@ import type { RuntimeContext } from "../types/context";
import { fileExists, readText, renderTemplate, writeText } from "../lib/fs"; import { fileExists, readText, renderTemplate, writeText } from "../lib/fs";
import { fail, info } from "../lib/log"; import { fail, info } from "../lib/log";
import { runMutatingVisible } from "../lib/process"; import { runMutatingVisible } from "../lib/process";
import { runRollbackStages, type RollbackStage } from "../lib/rollback";
type NftEntrypointKind = type NftEntrypointKind =
| "missing" | "missing"
@@ -41,6 +42,60 @@ async function cleanupFirewallBackupFiles(opId: string): Promise<void> {
await runMutatingVisible`rm -rf ${rollbackRoot(opId)}`; await runMutatingVisible`rm -rf ${rollbackRoot(opId)}`;
} }
/**
* Файлы firewall, которые операция обязана сохранить до первой мутации.
*
* Список явный: «скопировать всё, что найдём» и «доказать, что скопировали
* именно то, что нужно» разные утверждения, и rollback опирается на второе.
*/
const FIREWALL_BACKUP_TARGETS = [
{ path: "/etc/nftables.conf", backup: "nftables.conf.bak", marker: "nftables.conf.existed" },
{ path: "/etc/nftables.d/hy2xs.nft", backup: "hy2xs.nft.bak", marker: "hy2xs.nft.existed" }
] as const;
/**
* Снимает резервные копии ДО первой мутации firewall и доказывает, что снял.
*
* Что было:
*
* cp -a /etc/nftables.conf <backup> 2>/dev/null || true
*
* то есть отказ копирования (заполненный /run, ошибка ввода-вывода, права)
* молча игнорировался. Дальше выставлялся маркер `prepared`, и операция
* начинала переписывать firewall уже НЕ имея резервной копии, на которую
* рассчитывает откат. Предпосылка отката нарушалась в самом его основании.
*
* Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он
* означает «данные для отката существуют», и раньше это было неправдой.
*/
async function backupFirewallState(opId: string): Promise<void> {
await ensureRollbackRoot(opId);
for (const target of FIREWALL_BACKUP_TARGETS) {
const backupPath = rollbackBackup(target.backup, opId);
const markerPath = rollbackBackup(target.marker, opId);
if (!(await fileExists(target.path))) {
// Отсутствие файла — законное состояние, но оно обязано быть ЗАПИСАНО, а
// не выведено из неудачи копирования: откат по этому маркеру решает,
// восстанавливать файл или удалять его.
await runMutatingVisible`rm -f ${markerPath} ${backupPath}`;
continue;
}
await runMutatingVisible`cp -a ${target.path} ${backupPath}`;
if (!(await fileExists(backupPath))) {
fail(
`firewall backup was not created for ${target.path}: ${backupPath} is missing. ` +
"Отказ до первой мутации firewall: без резервной копии откат невозможен."
);
}
await runMutatingVisible`printf 1 > ${markerPath}`;
}
await runMutatingVisible`touch ${rollbackMarker(opId)}`;
}
function stripNftComments(content: string): string { function stripNftComments(content: string): string {
return content return content
.split(/\r?\n/) .split(/\r?\n/)
@@ -125,12 +180,9 @@ export async function applyFirewall(context: RuntimeContext): Promise<void> {
fail("foreign nftables.conf detected; use HY2XS_FIREWALL_MODE=takeover|external|off"); fail("foreign nftables.conf detected; use HY2XS_FIREWALL_MODE=takeover|external|off");
} }
await ensureRollbackRoot(opId); // Резервные копии снимаются и ПРОВЕРЯЮТСЯ до первой записи в /etc.
await runMutatingVisible`touch ${rollbackMarker(opId)}`; await backupFirewallState(opId);
await runMutatingVisible`cp -a /etc/nftables.conf ${rollbackBackup("nftables.conf.bak", opId)} 2>/dev/null || true`;
await runMutatingVisible`cp -a /etc/nftables.d/hy2xs.nft ${rollbackBackup("hy2xs.nft.bak", opId)} 2>/dev/null || true`;
await runMutatingVisible`test -f /etc/nftables.conf && echo 1 > ${rollbackBackup("nftables.conf.existed", opId)} || rm -f ${rollbackBackup("nftables.conf.existed", opId)}`;
await runMutatingVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > ${rollbackBackup("hy2xs.nft.existed", opId)} || rm -f ${rollbackBackup("hy2xs.nft.existed", opId)}`;
await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600); await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600);
await runMutatingVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`; await runMutatingVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`;
@@ -171,24 +223,90 @@ include "/etc/nftables.d/hy2xs.nft"
info("firewall applied with rollback guard; guard will be cancelled only after successful smoke checks"); info("firewall applied with rollback guard; guard will be cancelled only after successful smoke checks");
} }
export async function cancelFirewallRollback(context: RuntimeContext): Promise<void> { function firewallRollbackIsInactive(context: RuntimeContext): boolean {
const opId = operationKey(context); return (
if (context.options.skipFirewall || context.config.firewallMode === "off" || context.config.firewallMode === "external") { context.options.skipFirewall ||
return; context.config.firewallMode === "off" ||
context.config.firewallMode === "external"
);
} }
if (context.config.firewallStagedApply) { async function stopRollbackTimer(context: RuntimeContext, opId: string): Promise<void> {
if (!context.config.firewallStagedApply) {
return;
}
const unit = rollbackUnit(opId); const unit = rollbackUnit(opId);
await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`; await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`;
await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`; await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`;
} }
await cleanupFirewallBackupFiles(opId); /**
* Снимает автоматический откат по таймеру, НО сохраняет резервные копии.
*
* Разделение на disarm и cleanup исправление ошибки порядка фиксации.
* Единая `cancelFirewallRollback` делала и то и другое, а вызывалась ДО
* долговечной записи `phase: installed`. Получался разрыв:
*
* smoke PASS
* -> таймер снят, резервные копии УДАЛЕНЫ
* -> запись "installed" падает (ENOSPC/EIO/read-only)
* -> catch -> обязательный откат
* -> "no HY2XS rollback markers found"
*
* То есть ровно тот отказ записи маркера, который был специально сделан
* безопасным, случался после уничтожения единственных данных для отката.
* Откат запускался, но откатывать ему было нечем.
*
* Теперь между disarm и cleanup стоит долговечная фиксация успеха, и до неё
* ручное восстановление остаётся возможным.
*/
export async function disarmFirewallRollback(context: RuntimeContext): Promise<void> {
if (firewallRollbackIsInactive(context)) {
return;
}
await stopRollbackTimer(context, operationKey(context));
info("firewall rollback timer disarmed; backups are kept until the installation is durably committed");
} }
/**
* Удаляет резервные копии firewall. Вызывается ТОЛЬКО после долговечной
* фиксации успеха операции.
*
* Неудача здесь мусор в /run, а не причина объявить успешную установку
* неуспешной, поэтому вызывающий выполняет её best-effort.
*/
export async function cleanupFirewallRollback(context: RuntimeContext): Promise<void> {
if (firewallRollbackIsInactive(context)) {
return;
}
await cleanupFirewallBackupFiles(operationKey(context));
}
/**
* Немедленное восстановление firewall.
*
* Два правила, которых здесь раньше не было.
*
* 1. Ошибки восстановления НЕ скрываются. Было:
*
* cp <backup> /etc/nftables.conf 2>/dev/null || true
* nft -f /etc/nftables.conf >/dev/null 2>&1 || true
*
* то есть неудача копирования или применения правил давала функции
* завершиться успешно, и стадия отката отчитывалась как выполненная.
*
* 2. Резервные копии удаляются ТОЛЬКО после подтверждённого восстановления.
* Было безусловный `rm -rf` в конце: худшая комбинация, при которой
* ошибка восстановления скрыта, а данные, по которым оператор мог бы
* поднять firewall вручную, уничтожены.
*
* Стадии независимы: файл nftables.conf и файл hy2xs.nft восстанавливаются
* порознь, применение правил выполняется в любом случае частично
* восстановленный firewall лучше полностью отсутствующего.
*/
export async function rollbackFirewallNow(context: RuntimeContext): Promise<void> { export async function rollbackFirewallNow(context: RuntimeContext): Promise<void> {
const opId = operationKey(context); const opId = operationKey(context);
if (context.options.skipFirewall || context.config.firewallMode === "off" || context.config.firewallMode === "external") { if (firewallRollbackIsInactive(context)) {
return; return;
} }
@@ -197,14 +315,47 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise<void
return; return;
} }
if (context.config.firewallStagedApply) { await stopRollbackTimer(context, opId);
const unit = rollbackUnit(opId);
await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`; const entrypointExisted = await fileExists(rollbackBackup("nftables.conf.existed", opId));
await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`; const stages: RollbackStage[] = [];
for (const target of FIREWALL_BACKUP_TARGETS) {
const backupPath = rollbackBackup(target.backup, opId);
const markerPath = rollbackBackup(target.marker, opId);
stages.push({
name: `restore ${target.path}`,
run: async () => {
if (await fileExists(markerPath)) {
await runMutatingVisible`cp -a ${backupPath} ${target.path}`;
return;
}
// Файла не было до операции — восстановление означает его удаление.
await runMutatingVisible`rm -f ${target.path}`;
}
});
}
stages.push({
name: "apply restored ruleset",
run: async () => {
if (entrypointExisted) {
await runMutatingVisible`nft -f /etc/nftables.conf`;
return;
}
await runMutatingVisible`nft flush ruleset`;
}
});
const failures = await runRollbackStages(stages);
if (failures.length > 0) {
info(
`firewall rollback did not complete; manual recovery data preserved at ${rollbackRoot(opId)}`
);
return;
} }
await runMutatingVisible`if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then cp -a ${rollbackBackup("nftables.conf.bak", opId)} /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`;
await runMutatingVisible`if [ -f ${rollbackBackup("hy2xs.nft.existed", opId)} ]; then cp -a ${rollbackBackup("hy2xs.nft.bak", opId)} /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`;
await runMutatingVisible`if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then nft -f /etc/nftables.conf >/dev/null 2>&1 || true; else nft flush ruleset >/dev/null 2>&1 || true; fi`;
await cleanupFirewallBackupFiles(opId); await cleanupFirewallBackupFiles(opId);
} }
+11
View File
@@ -135,6 +135,17 @@ describe("ensureDir", () => {
expect(((await stat(target)).mode & 0o777).toString(8)).toBe("755"); expect(((await stat(target)).mode & 0o777).toString(8)).toBe("755");
}); });
// Долговечность записи каталога в РОДИТЕЛЕ имеет смысл только при
// фактическом создании, поэтому ensureDir обязана его различать: на каждом
// обновлении маркера каталог уже существует, и лишний fsync родителя ничего
// не даёт.
test("сообщает, был ли каталог создан", async () => {
const target = join(dir, "hy2xs");
expect((await ensureDir(target, { mode: 0o755 })).created).toBe(true);
expect((await ensureDir(target, { mode: 0o755 })).created).toBe(false);
});
test("создание каталога проходит через read-only guard", async () => { test("создание каталога проходит через read-only guard", async () => {
enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
await expect(ensureDir(join(dir, "hy2xs"), { mode: 0o755 })).rejects.toThrow( await expect(ensureDir(join(dir, "hy2xs"), { mode: 0o755 })).rejects.toThrow(
+265
View File
@@ -0,0 +1,265 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
BACKUP_MANIFEST_VERSION,
backupDir,
backupFilePath,
backupManifestPath,
buildManifest,
parseManifest,
renderManifest,
storedNameFor
} from "../src/lib/backupManifest";
/**
* Целостность резервной копии предпосылка отката, а не его деталь.
*
* Два дефекта, которые здесь закрепляются.
*
* 1. Копии снимались как `cp ... || true`, поэтому операция начинала менять
* систему, не имея копии, на которую рассчитывает откат.
*
* 2. Копии всех операций лежали одним общим набором `*.bak` в
* /etc/hy2xs/backups. Если у операции B копирование падало, её откат
* восстанавливал файл, сохранённый операцией A, то есть возвращал сервер
* в более старое состояние и выглядел при этом успешным.
*
* Манифест отвечает на оба: он привязан к op-id и содержит ЯВНУЮ запись о
* каждом файле, включая отсутствовавшие.
*/
function source(relativeToSrc: string): string {
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
}
describe("манифест резервной копии", () => {
test("копия каждой операции лежит в своём каталоге", () => {
expect(backupDir("2026-08-30T10-00-00.000Z")).toBe("/etc/hy2xs/backups/2026-08-30T10-00-00.000Z");
expect(backupDir("a")).not.toBe(backupDir("b"));
});
test("манифест и файлы адресуются внутри каталога операции", () => {
const opId = "op-1";
expect(backupManifestPath(opId)).toStartWith(backupDir(opId));
expect(backupFilePath(opId, "etc_nftables.conf")).toStartWith(backupDir(opId));
});
// Имя обязано быть однозначным: /etc/nftables.conf и /etc/hy2xs/nftables.conf
// не имеют права лечь в один файл копии.
test("разные пути дают разные имена файлов копии", () => {
const names = new Set(
[
"/etc/nftables.conf",
"/etc/hy2xs/nftables.conf",
"/etc/nftables.d/hy2xs.nft",
"/etc/hysteria/config.yaml",
"/etc/systemd/system/hy2xs-admin.service"
].map(storedNameFor)
);
expect(names.size).toBe(5);
});
test("имя файла копии не выходит за пределы каталога", () => {
for (const path of ["/etc/../../evil", "/etc/hy2xs/../../x", "//etc//x"]) {
const stored = storedNameFor(path);
expect(stored).not.toContain("/");
expect(stored).not.toContain("../");
}
});
// Ключевое: «файла не было» — записанный факт, а не вывод из неудачи `cp`.
test("отсутствовавший файл записывается явно", () => {
const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [
{ path: "/etc/hysteria/config.yaml", present: true },
{ path: "/etc/nftables.d/hy2xs.nft", present: false }
]);
const missing = manifest.entries.find((entry) => entry.path === "/etc/nftables.d/hy2xs.nft");
expect(missing?.present).toBe(false);
expect(missing?.stored).toBeNull();
const kept = manifest.entries.find((entry) => entry.path === "/etc/hysteria/config.yaml");
expect(kept?.present).toBe(true);
expect(kept?.stored).toBe(storedNameFor("/etc/hysteria/config.yaml"));
});
test("манифест переживает сериализацию без потерь", () => {
const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [
{ path: "/etc/hysteria/config.yaml", present: true },
{ path: "/etc/hy2xs/hy2xs.env", present: false }
]);
expect(parseManifest(renderManifest(manifest), "op-1")).toEqual(manifest);
});
});
describe("разбор манифеста строгий", () => {
const valid = renderManifest(
buildManifest("op-1", "2026-08-30T10:00:00.000Z", [{ path: "/etc/hysteria/config.yaml", present: true }])
);
// Откат по манифесту ЧУЖОЙ операции — это ровно тот дефект, ради которого
// копия стала привязанной к op-id.
test("манифест другой операции отклоняется", () => {
expect(() => parseManifest(valid, "op-2")).toThrow(/относится к другой операции/);
});
test("непонятная версия отклоняется", () => {
const foreign = valid.replace(`"version": ${BACKUP_MANIFEST_VERSION}`, '"version": 99');
expect(() => parseManifest(foreign, "op-1")).toThrow(/неизвестная версия/);
});
test("битый JSON отклоняется, а не считается пустой копией", () => {
expect(() => parseManifest("{ не json", "op-1")).toThrow(/не разбирается/);
});
// «Поле не разобралось, будем считать, что файла не было» означает удаление
// существующего файла при откате. Поэтому отказ, а не умолчание.
test("запись без признака существования отклоняется", () => {
const broken = JSON.stringify({
version: BACKUP_MANIFEST_VERSION,
opId: "op-1",
createdAt: "",
entries: [{ path: "/etc/hysteria/config.yaml" }]
});
expect(() => parseManifest(broken, "op-1")).toThrow(/не сообщает, существовал ли файл/);
});
test("существующий файл без имени копии отклоняется", () => {
const broken = JSON.stringify({
version: BACKUP_MANIFEST_VERSION,
opId: "op-1",
createdAt: "",
entries: [{ path: "/etc/hysteria/config.yaml", present: true, stored: "" }]
});
expect(() => parseManifest(broken, "op-1")).toThrow(/не называет копию/);
});
test("запись без пути отклоняется", () => {
const broken = JSON.stringify({
version: BACKUP_MANIFEST_VERSION,
opId: "op-1",
createdAt: "",
entries: [{ present: false }]
});
expect(() => parseManifest(broken, "op-1")).toThrow(/не содержит пути/);
});
});
describe("reconfigure: копия снимается строго и до первой мутации", () => {
const reconfigureSource = source("commands/reconfigure.ts");
test("копирование не глушит ошибки", () => {
const start = reconfigureSource.indexOf("async function backupCurrentState");
const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readBackupManifest"));
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
expect(muted, `копирование снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
// Создание копии обязано быть ДОКАЗАНО, а не предположено по коду возврата
// одного лишь `cp`.
test("существование копии проверяется после копирования", () => {
expect(reconfigureSource).toContain("резервная копия");
expect(reconfigureSource).toContain("await fileExists(target)");
});
test("копия снимается до первой мутации системы", () => {
const backup = reconfigureSource.indexOf("await backupCurrentState(context)");
const generate = reconfigureSource.indexOf("await generateConfig(context)");
const firewall = reconfigureSource.indexOf("await applyFirewall(context)");
expect(backup).toBeGreaterThan(-1);
expect(backup).toBeLessThan(generate);
expect(backup).toBeLessThan(firewall);
});
test("откат читает манифест текущей операции, а не общий каталог", () => {
expect(reconfigureSource).toContain("readBackupManifest(opId)");
expect(reconfigureSource).toContain("parseManifest(await readText(path), opId)");
// Прежняя схема общего набора *.bak не должна вернуться.
expect(reconfigureSource).not.toContain("/etc/hy2xs/backups/config.yaml.bak");
// Решение «восстанавливать или удалять» принимается по манифесту, а не по
// маркерам-файлам прежней схемы. Уборка их наследия в pruneOldBackups
// допустима и проверяется отдельно.
const restoreStart = reconfigureSource.indexOf("function restoreStages(");
const restoreBody = reconfigureSource.slice(
restoreStart,
reconfigureSource.indexOf("async function rollbackCurrentState")
);
expect(restoreBody).not.toContain(".existed");
expect(restoreBody).toContain("entry.present");
});
// Каталог копий не имеет права расти неограниченно, но уборка обязана идти
// ПОСЛЕ долговечной фиксации успеха — иначе это тот же дефект порядка, что и
// с firewall.
test("копии предыдущих операций убираются после фиксации успеха", () => {
const installed = reconfigureSource.indexOf('await markPhase(context, "installed")');
const prune = reconfigureSource.indexOf("pruneOldBackups(context)");
expect(installed).toBeGreaterThan(-1);
expect(prune).toBeGreaterThan(installed);
});
});
describe("firewall: копия снимается строго и переживает неудачный откат", () => {
const firewallSource = source("steps/firewall.ts");
test("копирование firewall не глушит ошибки", () => {
const start = firewallSource.indexOf("async function backupFirewallState");
const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments"));
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
expect(muted, `копирование firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
// Маркер `prepared` означает «данные для отката существуют». Пока он
// ставился ДО копирования, это было неправдой.
test("маркер готовности ставится после проверенных копий", () => {
const start = firewallSource.indexOf("async function backupFirewallState");
const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments"));
const copy = body.indexOf("cp -a ${target.path}");
const verify = body.indexOf("await fileExists(backupPath)");
const marker = body.indexOf("touch ${rollbackMarker(opId)}");
expect(copy).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(copy);
expect(marker).toBeGreaterThan(verify);
});
test("восстановление firewall не глушит ошибки cp и nft", () => {
const start = firewallSource.indexOf("export async function rollbackFirewallNow");
const body = firewallSource.slice(start);
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"))
// Снятие таймера остаётся best-effort: отсутствующий юнит — не отказ
// восстановления, а нормальное состояние при firewallStagedApply=false.
.filter((line) => !line.includes("systemctl"));
expect(muted, `восстановление firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
// Худшая прежняя комбинация: ошибка восстановления скрыта, а данные, по
// которым оператор мог бы поднять firewall вручную, уничтожены.
test("резервные копии удаляются только после успешного восстановления", () => {
const start = firewallSource.indexOf("export async function rollbackFirewallNow");
const body = firewallSource.slice(start);
const failures = body.indexOf("if (failures.length > 0)");
const preserved = body.indexOf("manual recovery data preserved at");
const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)");
expect(failures).toBeGreaterThan(-1);
expect(preserved).toBeGreaterThan(failures);
expect(cleanup).toBeGreaterThan(preserved);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Порядок фиксации успеха операции.
*
* Дефект, который здесь закрепляется, commit ordering. Успешный install
* заканчивался так:
*
* smoke PASS
* -> cancelFirewallRollback() // снимала таймер И УДАЛЯЛА резервные копии
* -> advanceInstallState("installed")
*
* То есть единственные данные для отката firewall (marker, прежний
* nftables.conf, прежний hy2xs.nft) уничтожались ДО долговечной записи успеха.
* Если эта запись падала ENOSPC, EIO, read-only ФС, управление уходило в
* обработчик ошибки, обязательный откат честно запускался и сообщал:
*
* firewall rollback skipped: no HY2XS rollback markers found
*
* Откат нельзя было отменить, но откатывать ему было нечем. Причём падение
* записи маркера ровно тот сценарий, который был специально сделан
* безопасным предыдущим патчем.
*
* Правильный порядок: снять таймер (копии остаются) -> зафиксировать успех
* долговечно -> и только потом убрать копии, best-effort.
*/
function source(relativeToSrc: string): string {
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
}
const firewallSource = source("steps/firewall.ts");
describe("снятие таймера и удаление копий — разные операции", () => {
test("firewall предоставляет disarm и cleanup по отдельности", () => {
expect(firewallSource).toContain("export async function disarmFirewallRollback");
expect(firewallSource).toContain("export async function cleanupFirewallRollback");
});
// Прежнее единое имя не должно вернуться: оно и означало «снять таймер и
// удалить копии одним действием».
test("объединённая cancelFirewallRollback не вернулась", () => {
expect(firewallSource).not.toContain("export async function cancelFirewallRollback");
for (const file of ["commands/install.ts", "commands/reconfigure.ts"]) {
expect(source(file)).not.toContain("cancelFirewallRollback");
}
});
test("disarm не удаляет резервные копии", () => {
const start = firewallSource.indexOf("export async function disarmFirewallRollback");
const body = firewallSource.slice(start, firewallSource.indexOf("export async function cleanupFirewallRollback"));
expect(body).not.toContain("cleanupFirewallBackupFiles");
expect(body).toContain("stopRollbackTimer");
});
test("cleanup удаляет копии и ничего больше", () => {
const start = firewallSource.indexOf("export async function cleanupFirewallRollback");
const body = firewallSource.slice(start, firewallSource.indexOf("export async function rollbackFirewallNow"));
expect(body).toContain("cleanupFirewallBackupFiles");
});
});
describe("install: успех фиксируется до уничтожения данных отката", () => {
const installSource = source("commands/install.ts");
test("порядок: disarm -> durable installed -> cleanup", () => {
const disarm = installSource.indexOf("await disarmFirewallRollback(context)");
const installed = installSource.indexOf('await advanceInstallState(context, ownership, "installed")');
const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)");
expect(disarm).toBeGreaterThan(-1);
expect(installed).toBeGreaterThan(disarm);
expect(cleanup).toBeGreaterThan(installed);
});
test("успешный smoke фиксируется отдельной фазой до снятия таймера", () => {
const smokeOk = installSource.indexOf('await advanceInstallState(context, ownership, "smoke_ok")');
const disarm = installSource.indexOf("await disarmFirewallRollback(context)");
expect(smokeOk).toBeGreaterThan(-1);
expect(smokeOk).toBeLessThan(disarm);
});
// Уборка после фиксации успеха — мусор в /run, а не причина объявить
// успешную установку неуспешной.
test("уборка после фиксации выполняется best-effort", () => {
const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)");
const tryAt = installSource.lastIndexOf("try {", cleanup);
const catchAt = installSource.indexOf("catch (cleanupError)", cleanup);
expect(tryAt).toBeGreaterThan(-1);
expect(tryAt).toBeLessThan(cleanup);
expect(catchAt).toBeGreaterThan(cleanup);
});
});
describe("reconfigure: тот же порядок фиксации", () => {
const reconfigureSource = source("commands/reconfigure.ts");
test("порядок: disarm -> durable installed -> cleanup", () => {
const disarm = reconfigureSource.indexOf("await disarmFirewallRollback(context)");
const installed = reconfigureSource.indexOf('await markPhase(context, "installed")');
const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)");
expect(disarm).toBeGreaterThan(-1);
expect(installed).toBeGreaterThan(disarm);
expect(cleanup).toBeGreaterThan(installed);
});
test("уборка после фиксации выполняется best-effort", () => {
expect(reconfigureSource).toContain("catch (cleanupError)");
const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)");
const catchAt = reconfigureSource.indexOf("catch (cleanupError)", cleanup);
expect(catchAt).toBeGreaterThan(cleanup);
});
});
+33
View File
@@ -130,6 +130,39 @@ describe("read-only — инвариант doctor, а не свойство те
} }
}); });
// Guard защищает ТОЛЬКО процесс оркестратора. Он физически не способен
// запретить побочный эффект, вызванный HTTP-запросом в другой процесс,
// поэтому граница «что doctor имеет право трогать по сети» держится здесь.
//
// Конкретный риск: успешная machine-auth реального пира заставляет админку
// выполнить UPDATE peer.last_connection_at, то есть диагностика изменила бы
// отображаемое «последнее подключение» bootstrap-пира. Проба с ВАЛИДНЫМ
// паролем поэтому ограничена режимом install; doctor работает в режиме
// reconfigure и до неё не доходит.
test("проба с валидными учётными данными выполняется только при install", () => {
const smokeSource = source("steps/smoke.ts");
const guard = smokeSource.indexOf('if (context.mode === "install")');
const validAuth = smokeSource.indexOf('"auth valid credentials"');
// Именно ЧТЕНИЕ значения пароля, а не проверка наличия строки в файле:
// последняя выполняется и в doctor и ничего наружу не отправляет.
const readsSecret = smokeSource.indexOf("cut -d= -f2-");
expect(guard).toBeGreaterThan(-1);
expect(validAuth).toBeGreaterThan(guard);
expect(readsSecret).toBeGreaterThan(guard);
// Ровно одна такая проба, и она внутри блока install: вторая, добавленная
// мимо условия, вернула бы запись в БД в диагностику.
expect(smokeSource.split('"auth valid credentials"').length - 1).toBe(1);
});
test("doctor работает в режиме, который не запускает пробу успешной авторизации", () => {
const doctorSource = source("commands/doctor.ts");
expect(doctorSource).toContain('mode: "reconfigure"');
expect(doctorSource).not.toContain('mode: "install"');
});
// Поведенческая часть: механизм, на который опирается doctor, действительно // Поведенческая часть: механизм, на который опирается doctor, действительно
// отказывает любой мутации и называет причину. // отказывает любой мутации и называет причину.
test("под guard'ом doctor любая мутация отказывает с его причиной", async () => { test("под guard'ом doctor любая мутация отказывает с его причиной", async () => {
+1 -1
View File
@@ -140,7 +140,7 @@ describe("классификация отказа reconfigure/repair", () => {
test("отказ диагностики не отменяет откат firewall и конфигов", () => { test("отказ диагностики не отменяет откат firewall и конфигов", () => {
const diagnosticsAt = reconfigureSource.indexOf("await diagnosticsCollect(options)"); const diagnosticsAt = reconfigureSource.indexOf("await diagnosticsCollect(options)");
const firewallRollbackAt = reconfigureSource.indexOf("await rollbackFirewallNow(context)"); const firewallRollbackAt = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState()"); const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState(context)");
const catchAt = reconfigureSource.indexOf("catch (diagnosticsError)"); const catchAt = reconfigureSource.indexOf("catch (diagnosticsError)");
expect(catchAt).toBeGreaterThan(diagnosticsAt); expect(catchAt).toBeGreaterThan(diagnosticsAt);
+58 -9
View File
@@ -217,22 +217,71 @@ describe("reconfigure: откат обязателен после операци
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком. // Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
test("порядок сохранён: сначала firewall, затем конфиги", () => { test("порядок сохранён: сначала firewall, затем конфиги", () => {
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)"); const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
const restore = reconfigureSource.indexOf("await rollbackCurrentState()"); const restore = reconfigureSource.indexOf("await rollbackCurrentState(context)");
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)"); const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
expect(firewall).toBeGreaterThan(-1); expect(firewall).toBeGreaterThan(-1);
expect(firewall).toBeLessThan(restore); expect(firewall).toBeLessThan(restore);
expect(restore).toBeLessThan(stages); expect(restore).toBeLessThan(stages);
}); });
// Внутри самого восстановления конфигов дефект был тот же: единственная // Инвариант СМЕНИЛСЯ, и это принципиально.
// команда без `|| true` отменяла перезапуск сервисов строкой ниже. //
test("ни одна команда восстановления конфигов не обрывает следующие", () => { // Раньше здесь требовалось, чтобы каждая команда восстановления несла
const start = reconfigureSource.indexOf("async function rollbackCurrentState"); // `|| true`: тогда это была единственная защита от того, что отказ одной
const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readInstallState")); // команды оборвёт следующие. После появления runRollbackStages непрерывность
const offenders = body // обеспечивает раннер, а `|| true` стал ВРЕДЕН: он не даёт стадии сообщить,
// что восстановление на самом деле не выполнилось, и «restore configuration»
// никогда не попадала в список отказавших.
test("команды восстановления не глушат собственные ошибки", () => {
const start = reconfigureSource.indexOf("function restoreStages(");
const body = reconfigureSource.slice(
start,
reconfigureSource.indexOf("async function rollbackCurrentState")
);
const muted = body
.split(/\r?\n/) .split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`")) .filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => !line.includes("|| true") && !line.includes("; fi`")); .filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
expect(offenders, `команды отката без защиты: ${offenders.join("; ")}`).toEqual([]);
expect(muted, `восстановление снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
test("восстановление разбито на независимые стадии", () => {
for (const stage of [
"restore hysteria config",
"restore systemd units",
"restore runtime env",
"restore nftables files",
"apply restored ruleset",
"reload systemd units",
"restart services"
]) {
expect(reconfigureSource).toContain(`"${stage}"`);
}
});
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
test("частичное восстановление не выдаётся за успешное", () => {
expect(reconfigureSource).toContain("restoreFailures.length > 0");
expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью");
});
});
describe("install: команды отката не глушат собственные ошибки", () => {
const installSource = source("commands/install.ts");
// После появления runRollbackStages `|| true` в стадиях отката перестал быть
// защитой и стал маскировкой: стадия не могла сообщить, что systemctl
// реально ничего не сделал, и обещание «отказавшие стадии перечисляются» для
// них не выполнялось никогда.
test("стадии остановки сервисов не подавляют код возврата", () => {
const start = installSource.indexOf("async function rollbackFailedInstall");
const body = installSource.slice(start, installSource.indexOf("export async function install"));
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true"));
expect(muted, `стадии отката снова скрывают ошибки: ${muted.join("; ")}`).toEqual([]);
}); });
}); });
+132 -13
View File
@@ -969,22 +969,141 @@ run_single_owner_acceptance() {
grep -q 'await runRollbackStages(stages)' "$rollback_command" \ grep -q 'await runRollbackStages(stages)' "$rollback_command" \
|| fail "acceptance: откат в $rollback_command снова выполняется отменяемой цепочкой" || fail "acceptance: откат в $rollback_command снова выполняется отменяемой цепочкой"
done done
# Внутри восстановления конфигов дефект был тот же: единственная команда без # Команды восстановления не имеют права ГЛУШИТЬ собственные ошибки.
# `|| true` отменяла перезапуск сервисов строкой ниже. #
# Инвариант здесь обратный тому, что стоял раньше. До появления
# runRollbackStages каждая команда несла `|| true` — это была единственная
# защита от того, что отказ одной оборвёт следующие. Теперь непрерывность
# обеспечивает стадийный раннер, а `|| true` стал маскировкой: стадия не
# могла сообщить, что восстановление на самом деле не выполнилось, и
# «restore configuration» никогда не попадала в список отказавших.
"$BUN_BIN" -e '
const fs = require("node:fs");
const checks = [
["orchestrator/src/commands/reconfigure.ts", "function restoreStages(", "async function rollbackCurrentState"],
["orchestrator/src/commands/reconfigure.ts", "async function backupCurrentState", "async function readBackupManifest"],
["orchestrator/src/commands/install.ts", "async function rollbackFailedInstall", "export async function install"],
["orchestrator/src/steps/firewall.ts", "async function backupFirewallState", "function stripNftComments"]
];
for (const [file, from, to] of checks) {
const source = fs.readFileSync(file, "utf8");
const start = source.indexOf(from);
const end = source.indexOf(to);
if (start < 0 || end < 0) throw new Error("не найдены границы " + from + " в " + file);
const muted = source.slice(start, end).split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
if (muted.length) {
throw new Error(from + " в " + file + " скрывает ошибки:\n" + muted.join("\n"));
}
}
' || fail "acceptance: команды отката и резервного копирования обязаны сообщать о своих отказах"
log_step "Acceptance: rollback artifacts outlive the durable commit"
# Ошибка порядка фиксации. cancelFirewallRollback снимала таймер И удаляла
# резервные копии, а вызывалась ДО долговечной записи `phase: installed`.
# Отказ этой записи приводил в обработчик ошибки, обязательный откат честно
# запускался и сообщал «no HY2XS rollback markers found»: откатывать было
# уже нечем.
grep -q 'export async function disarmFirewallRollback' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: снятие таймера автоотката не отделено от удаления резервных копий"
grep -q 'export async function cleanupFirewallRollback' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: удаление резервных копий firewall не выделено в отдельную операцию"
! grep -rq 'cancelFirewallRollback' orchestrator/src \
|| fail "acceptance: объединённая cancelFirewallRollback вернулась; она удаляла копии до фиксации успеха"
"$BUN_BIN" -e '
const fs = require("node:fs");
for (const [file, installed] of [
["orchestrator/src/commands/install.ts", "await advanceInstallState(context, ownership, \"installed\")"],
["orchestrator/src/commands/reconfigure.ts", "await markPhase(context, \"installed\")"]
]) {
const source = fs.readFileSync(file, "utf8");
const disarm = source.indexOf("disarmFirewallRollback(context)");
const commit = source.indexOf(installed);
const cleanup = source.indexOf("cleanupFirewallRollback(context)");
if (disarm < 0 || commit < 0 || cleanup < 0) throw new Error("не найдены шаги фиксации в " + file);
if (!(disarm < commit && commit < cleanup)) {
throw new Error("порядок обязан быть disarm -> durable installed -> cleanup в " + file);
}
}
' || fail "acceptance: данные отката обязаны переживать долговечную фиксацию успеха"
# Восстановление firewall не имеет права удалить копии, не восстановив.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const body = source.slice(source.indexOf("export async function rollbackFirewallNow"));
const failures = body.indexOf("if (failures.length > 0)");
const preserved = body.indexOf("manual recovery data preserved at");
const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)");
if (failures < 0 || preserved < 0 || cleanup < 0) throw new Error("не найдены ветки восстановления");
if (!(failures < preserved && preserved < cleanup)) {
throw new Error("резервные копии удаляются раньше проверки успеха восстановления");
}
' || fail "acceptance: копии удаляются только после подтверждённого восстановления firewall"
log_step "Acceptance: reconfigure backups are operation-scoped and proven"
# Копии всех операций лежали одним общим набором *.bak. При неудачном
# копировании в операции B её откат восстанавливал файлы, сохранённые
# операцией A, — возвращал сервер в более старое состояние и выглядел
# успешным.
[ -f orchestrator/src/lib/backupManifest.ts ] \
|| fail "acceptance: манифест резервной копии отсутствует"
grep -q 'backupDir(opId)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: копия reconfigure снова не привязана к операции"
grep -q 'parseManifest(await readText(path), opId)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: откат читает копию без проверки принадлежности операции"
! grep -qF '/etc/hy2xs/backups/config.yaml.bak' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: вернулся общий набор *.bak, смешивающий операции"
"$BUN_BIN" -e ' "$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8"); const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8");
const start = source.indexOf("async function rollbackCurrentState"); const backup = source.indexOf("await backupCurrentState(context)");
if (start < 0) throw new Error("rollbackCurrentState отсутствует"); for (const mutation of ["await generateConfig(context)", "await applyFirewall(context)"]) {
const rest = source.slice(start + 1); const at = source.indexOf(mutation);
const end = rest.search(/\n(export )?(async )?function /); if (at < 0) throw new Error("не найден шаг " + mutation);
const body = end < 0 ? rest : rest.slice(0, end); if (backup > at) throw new Error("копия снимается после мутации " + mutation);
const offenders = body.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
if (offenders.length) {
throw new Error("команды восстановления конфигов обрывают следующие:\n" + offenders.join("\n"));
} }
' || fail "acceptance: ни одна команда отката не имеет права отменить остальные" const verify = source.indexOf("await fileExists(target)");
if (verify < 0) throw new Error("создание копии не проверяется");
' || fail "acceptance: полная проверенная копия обязана предшествовать первой мутации"
log_step "Acceptance: the install-state directory entry is durable too"
# writeTextAtomic синхронизирует файл и его каталог. Но при первой установке
# /var/lib/hy2xs создаётся прямо сейчас, и запись «hy2xs» в /var/lib
# остаётся несинхронизированной: после потери питания мог исчезнуть весь
# каталог вместе с маркером.
grep -q 'return { created: !existed }' orchestrator/src/lib/fs.ts \
|| fail "acceptance: ensureDir не сообщает о фактическом создании каталога"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8");
const start = source.indexOf("export async function ensureDir");
const body = source.slice(start, source.indexOf("export async function writeTextAtomic"));
if (!/if \(!existed\) \{\s*\n\s*await syncDirectory\(dirname\(path\)\)/.test(body)) {
throw new Error("родительский каталог не синхронизируется при создании");
}
' || fail "acceptance: создание каталога маркера обязано быть долговечным"
log_step "Acceptance: doctor never triggers a peer state write over HTTP"
# Guard защищает только процесс оркестратора и не способен запретить побочный
# эффект в другом процессе. Успешная machine-auth заставляет админку
# выполнить UPDATE peer.last_connection_at, поэтому проба с ВАЛИДНЫМ паролем
# ограничена режимом install, а doctor работает в режиме reconfigure.
"$BUN_BIN" -e '
const fs = require("node:fs");
const smoke = fs.readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
const guard = smoke.indexOf("if (context.mode === \"install\")");
const validAuth = smoke.indexOf("\"auth valid credentials\"");
const readsSecret = smoke.indexOf("cut -d= -f2-");
if (guard < 0 || validAuth < 0 || readsSecret < 0) throw new Error("не найдена проба успешной авторизации");
if (validAuth < guard || readsSecret < guard) {
throw new Error("проба с валидными учётными данными вышла за пределы режима install");
}
if (smoke.split("\"auth valid credentials\"").length - 1 !== 1) {
throw new Error("проб успешной авторизации больше одной");
}
const doctor = fs.readFileSync("orchestrator/src/commands/doctor.ts", "utf8");
if (!doctor.includes("mode: \"reconfigure\"") || doctor.includes("mode: \"install\"")) {
throw new Error("doctor работает в режиме, который запускает пробу успешной авторизации");
}
' || fail "acceptance: диагностика не имеет права менять состояние пира через HTTP"
log_step "Acceptance: the install-state marker has exactly one durable writer" log_step "Acceptance: the install-state marker has exactly one durable writer"
# Раньше writeText в install и writeTextAtomic в reconfigure давали одному # Раньше writeText в install и writeTextAtomic в reconfigure давали одному