Files
HY2XS_flamy/docs/08-orchestrator-spec.md
T
founder e84fdedc4b fix(v1): сделать откат неотменяемым, а маркер установки — долговечным
Три дефекта одного класса в failure path install/reconfigure.

1. Запись состояния отказа отменяла откат.

   Обработчик ошибки первым делом писал в install-state фазу отказа обычным
   await и только потом откатывался. Эта запись — mkdir, write и chown в
   /var/lib/hy2xs, то есть она падает ровно там, где откат нужнее всего:
   заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил
   управление наружу, и обязательное восстановление не выполнялось вовсе —
   применённый firewall и развёрнутые сервисы оставались на сервере.

   Необязательная телеметрия состояния стояла перед обязательным
   восстановлением. Для диагностики это уже было закрыто, для записи
   состояния — нет.

2. Откат отменял сам себя.

   Он был написан цепочкой await, а каждая его стадия — systemctl, cp, rm -rf
   и nft, то есть умеет упасть сама. Отказ первой стадии отменял все
   последующие. В reconfigure это означало сервер одновременно с применённым
   сломанным firewall И без восстановленных из /etc/hy2xs/backups конфигов.
   Внутри rollbackCurrentState болезнь та же: единственная команда без
   `|| true` (systemctl daemon-reload) отменяла перезапуск сервисов строкой
   ниже, и восстановленные unit-файлы не применялись.

   Стадии стали независимыми: выполняются все, отказавшие перечисляются в
   журнале, наружу уходит исходная ошибка операции.

3. У маркера установки было два писателя с разными гарантиями.

   install перезаписывал файл на месте (writeText), reconfigure подставлял
   атомарно. Слабейшая гарантия досталась команде, которая этот файл создаёт.
   Перезапись на месте укорачивает файл до нуля и только потом наполняет:
   отказ между этими моментами оставляет половину JSON, который не
   разбирается — reconfigure видит его как отсутствующий, clean-host как
   присутствующий, а хост уже изменён.

   Атомарности при этом мало. rename() без fsync даёт атомарность видимости
   без долговечности: после потери питания ext4 штатно отдаёт по этому пути
   нулевой файл. Для метаданных восстановления это неприемлемо, поэтому
   порядок теперь: права/владелец -> fsync файла -> rename -> fsync каталога.

   Заодно ownership-флаг переименован в stateTouched и взводится ДО записи:
   отказ на chown после успешного write оставлял файл на диске при
   невзведённом флаге, то есть давал fatal_pre_apply («ничего не изменено»)
   при уже существующем маркере установки.

Тесты: rollback-mandatory.test.ts (внедрение отказа в стадию, проводка команд),
atomic-write.test.ts (замена целиком, прежний файл при отказе, отсутствие
временных файлов, права, guard). Приёмка сборки закрепляет порядок шагов
атомарной записи, отсутствие незащищённой записи состояния в обработчиках и
отсутствие отменяемых цепочек в откате.
2026-08-30 18:11:55 +05:00

474 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Install-only orchestrator spec
## Цель документа
Зафиксировать ТЗ на оркестратор с учётом двухслойной архитектуры: builder отдельно, target install отдельно.
## Технологический стек оркестратора
Оркестратор фиксируется как:
- **Bun + TypeScript** по исходникам
- локальная сборка builder layer'ом
- поставка на target в виде **готового install-артефакта**
Это означает:
- на target нет `npm`, `pnpm`, `yarn` или `bun install`
- на target нет transpile/build step
- shell на target допустим только как thin wrapper entrypoint
## Главная роль оркестратора
Оркестратор работает **только на target machine** и умеет:
- выполнить read-only проверку чистоты хоста (`preflight-install`)
- выполнить первичную установку (`install`)
- выполнить явную реконфигурацию (`reconfigure --dry-run|--apply`)
- разложить bundled UI
- скачать Hysteria2 из official upstream
- создать/обновить конфиги
- создать unit-файлы
- применить staged firewall
- создать `post-install.env` и runtime env-файл
## Оркестратор не умеет
- upgrade
- standalone rollback subcommands
- uninstall
- repair старых неизвестных состояний
- target-side build
- target-side git clone исходного кода HY2XS admin
- Telegram-бот / access delivery
## Предусловия
Оркестратор рассчитан только на:
- чистый Debian 13
- root/sudo install context
- один сервер
- одну baseline-схему
Если машина уже «жила своей жизнью», baseline не обещает корректной автоадаптации.
## Двухфазный контракт установки
Установка разделена на две фазы с жёсткой границей между ними:
```text
PHASE 0 — READ ONLY владелец: install.sh
проверка прав
sha256sum -c metadata/checksums.txt
./orchestrator/hy2xs-orchestrator preflight-install --package-dir <распакованный пакет>
├── платформа Debian 13 amd64
├── clean-host контракт
└── валидация конфигурации
↓ ноль persistent writes
PHASE 0 PASSED
↓ exec
PHASE 1 — MUTATION владелец: оркестратор
preflight (clean-host — последний раз за операцию)
bootstrapRuntime: /usr/local/lib/hy2xs, symlink, runtime-пакет
installDeps → filesystem → UI → Hysteria → config → units → firewall → smoke
```
Ключевые свойства:
- `preflight-install` запускается **из распакованного пакета**, а не из
установленного `/usr/local/lib/hy2xs`: до PHASE 1 этого каталога может не
существовать, и создавать его нельзя.
- Граница держится не соглашением, а **read-only guard** (`lib/guard.ts`):
под ним `writeText`/`writeTextAtomic` и мутирующие раннеры `lib/process`
кидают ошибку. Это проверяется тестами.
- Внутри `install` **`preflight()` выполняется раньше первой записи
`install-state.json`**. Отказ на этом этапе означает, что на сервере не
изменено ничего.
### У мутации ровно один владелец
`install.sh` не изменяет на сервере ничего. Он проверяет и делает `exec`.
Раньше PHASE 1 начиналась в shell: установщик сам создавал
`/usr/local/lib/hy2xs`, ставил туда бинарник, вешал symlink и копировал
runtime-пакет, и только после этого запускал оркестратор, который выполнял
собственный preflight. Между двумя фазами возникало окно: если второй preflight
отказывал — сменился DNS, занялся порт, не ответил резолвер, — у оркестратора не
был взведён ни один флаг владения, отказ классифицировался как
`fatal_pre_apply`, и оператор читал «на сервере ничего не изменено». Хост при
этом уже нёс каталог оркестратора, symlink и runtime-пакет, а следующий запуск
упирался в них как в маркеры чужой установки.
Владение мутацией невозможно отследить, пока мутируют двое. Поэтому раскладку
выполняет шаг `steps/bootstrap.ts` под флагом `ownership.bootstrapTouched`, и
эти пути попадают в `owned_paths` install-state наравне со всеми остальными.
Сборка проверяет структурно, что в `install.sh` не осталось ни одной мутирующей
команды.
### clean-host проверяется до первой мутации и только там
`preflight()` принимает `checkCleanHost` явно, без значения по умолчанию.
Причина в том, что clean-host — условие **входа** в операцию, а проверка
возможностей платформы (`systemd-run`, `nftables`, OpenSSL 3) выполняется уже
после `installDeps`, то есть внутри PHASE 1. Пока обе проверки ехали одним
параметром, `install` вызывал preflight дважды и оба раза с включённым
clean-host. Ко второму вызову на диске лежал собственный
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight, — и он
опознавался как маркер посторонней установки. Каждая чистая установка падала
сразу после `apt-get`, получала `fatal_post_apply` и оставляла сервер
наполовину настроенным.
По той же причине у списка маркеров больше нет «мягкой» версии для PHASE 1:
пути, которые раньше приходилось исключать, теперь создаются после проверки.
Полный список маркеров чужой установки и порядок очистки —
[14-legacy-cleanup.md](14-legacy-cleanup.md).
### Раннеры подпроцессов: два набора, а не один
Guard умеет останавливать только то, что через него проходит. Поэтому
универсального раннера в `lib/process.ts` нет — есть два явных набора:
| Набор | Guard | Назначение |
| --- | --- | --- |
| `runReadOnly`, `runReadOnlySecret` | не трогает | наблюдение за системой: `ss`, `systemctl is-active`, `curl`, `getent` |
| `runMutating`, `runMutatingVisible`, `runMutatingHidden`, `runMutatingRaw` | спрашивает разрешение | всё, что может изменить хост |
`*Secret`-варианты не печатают команду в текст ошибки: их аргументы несут
machine token или пароль пира, а сообщение уходит в логи и диагностику.
До разделения существовал один `run`, под которым одинаково жили `ss -ltn` и
`useradd`/`install -d`/`mkdir`. Guard стоял только на части раннеров, поэтому
утверждение «PHASE 0 ничего не пишет» держалось на внимательности автора
следующей правки. Выбор набора теперь — обязательное решение на месте вызова;
возвращение старых имён ломает приёмку сборки.
## Маркер состояния установки
`/var/lib/hy2xs/install-state.json` отвечает на вопрос «эта машина — установка
**текущего поколения** HY2XS, и в каком она состоянии». Поэтому кроме фазы он
несёт идентификацию поколения:
```json
{
"product": "hy2xs",
"release_line": 1,
"config_schema_version": 2,
"product_version": "1.0.0",
"installed": true,
"phase": "installed"
}
```
`reconfigure` и `repair` проверяют `product` / `release_line` /
`config_schema_version` **до** всего остального. Флага `installed: true`
недостаточно: такой же маркер мог остаться от 0.x.
`repair` дополнительно требует явного `--allow-partial-state`, чтобы работать
поверх незавершённой установки. Разрешение не подразумевается: молчаливое
согласие на произвольный partial marker и позволяло «чинить» чужое состояние.
### Запись маркера долговечна и имеет ровно одного владельца
`install` и `reconfigure` пишут маркер через один и тот же
`lib/installStateWriter.ts`. Раньше писателей было два, с разными гарантиями:
`install` перезаписывал файл на месте, `reconfigure` подставлял его атомарно.
Слабейшая гарантия досталась команде, которая этот файл создаёт.
Перезапись на месте укорачивает файл до нуля и только потом наполняет. Любой
отказ между этими моментами — потеря питания, `kill -9`, `ENOSPC` — оставляет на
сервере половину документа:
```json
{
"product": "hy2xs",
"release_line":
```
Такой маркер не разбирается: `reconfigure`/`repair` видят его как отсутствующий,
а clean-host — как присутствующий, причём хост к этому моменту уже изменён.
Атомарности при этом недостаточно, нужна **долговечность**. Порядок записи:
```text
1. запись во временный файл в том же каталоге
2. права и владелец ← до подстановки: иначе есть окно,
в котором файл виден с чужими правами
3. fsync временного файла ← данные на носителе, а не в page cache
4. rename ← атомарная подстановка
5. fsync каталога ← сама запись каталога о новом имени
```
Без шагов 3 и 5 `rename()` даёт атомарность видимости, но после внезапной
перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла.
Для метаданных восстановления это неприемлемо.
## Ownership и rollback
Операция ведёт учёт того, к чему она **могла прикоснуться**:
```text
stateTouched
depsTouched
filesystemTouched
uiTouched
hysteriaTouched
configTouched
unitsTouched
firewallTouched
postInstallTouched
bootstrapSecretTouched
servicesStarted
```
Формулировка выбрана намеренно. Флаг «шаг успешно завершился» отвечает не на
тот вопрос: `apt-get install` умеет распаковать половину пакетов и упасть, и
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
мутирующим вызовом**, а не после него.
`stateTouched` — полноценный участник классификации. `install-state.json`
пишется сразу после успешного preflight, до `installDeps`; пока он в
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
следующую установку по clean-host контракту.
Флаг называется `touched`, а не `written`, и это не косметика. Запись маркера —
три операции (`mkdir`, `write`, `chown`), и отказ последней оставляет файл на
диске. Пока флаг взводился **после** успешной записи, такой отказ давал
классификацию `fatal_pre_apply` — «на сервере ничего не изменено» — при уже
существующем `/var/lib/hy2xs/install-state.json`.
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
Инварианты rollback:
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
случае system rollback не выполняется, `install-state.json` не пишется,
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
`/var/log/hy2xs`).
- `systemctl stop/disable` выполняется **только если текущая операция сама
развернула эти unit-файлы**.
### После операционного отказа откат выполняется целиком
Порядок в обработчике ошибки один и тот же в `install` и `reconfigure`:
```text
запись состояния отказа → best effort
сбор диагностики → best effort
откат → обязателен
```
Обе первые операции пишут на диск (`/var/lib/hy2xs`, `/var/log/hy2xs`), то есть
падают ровно на заполненном диске и read-only ФС — там, где откат нужнее всего.
Пока хотя бы одна из них стояла обычным `await`, её собственный отказ уносил
управление наружу, и восстановление не выполнялось вовсе: применённый firewall и
развёрнутые сервисы оставались на сервере. Для диагностики это было закрыто
раньше, для записи состояния — нет.
Второй инвариант — **стадии отката независимы**:
| Команда | Стадии |
| --- | --- |
| `install` | firewall → stop services → disable services → reset failed services |
| `reconfigure` | firewall → restore configuration |
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
следующие. В `reconfigure` это означало сервер одновременно с применённым
сломанным firewall **и** без восстановленных из `/etc/hy2xs/backups` конфигов —
то есть худший сценарий отказа лишался обеих половин восстановления сразу.
Стадии выполняются последовательно и в объявленном порядке; независимость
означает «отказ не прерывает остальные», а не «выполняется как попало».
Отказавшие стадии перечисляются в журнале, а наружу пробрасывается **исходная**
ошибка операции: проблема внутри отката — это дополнительная информация о том,
что осталось не восстановленным, а не замена диагноза.
## Инвариант публичного endpoint
`preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как
preflight общий для `install`, `reconfigure` и `doctor`, инвариант действует во
всех трёх сценариях.
Алгоритм:
```text
1. локальные публичные IPv4 из node:os networkInterfaces()
(минус 0/8, 10/8, 100.64/10, 127/8, 169.254/16,
172.16/12, 192.168/16, 224/4, 240/4)
2. HY2XS_PUBLIC_HOST
IPv4-литерал → обязан быть в локальном множестве
домен → все A-записи обязаны быть в локальном множестве
3. HY2XS_DOMAIN, если задан и отличается от publicHost → та же проверка
4. AAAA-политика остаётся отдельной
```
Проверяется именно `HY2XS_PUBLIC_HOST`, потому что в `hysteria2://` уезжает он,
а не TLS-домен. По умолчанию они совпадают, но архитектурно это разные
сущности, и до v1 проверялся только `HY2XS_DOMAIN`.
Адрес сервера определяется **локально**. Внешние сервисы определения IP не
используются: они добавили бы `doctor` сетевую зависимость и превратили бы
недоступность стороннего сервиса в ложный отказ установки.
Несколько публичных IPv4 у сервера — норма: достаточно, чтобы DNS указывал на
один из них. Обратное неверно: лишняя A-запись рядом с правильной означает
второй, чужой backend за тем же именем. HY2XS — single-host профиль, поэтому
это ошибка конфигурации DNS, а не балансировка.
Строгость управляется `HY2XS_PUBLIC_ENDPOINT_POLICY`:
| Значение | Поведение |
| --- | --- |
| `strict` (по умолчанию) | расхождение останавливает операцию |
| `warn` | печатается предупреждение, операция продолжается |
| `off` | сравнение не выполняется |
Ослабление предназначено для топологий вне baseline (NAT, floating IP, anycast).
Отсутствие A-записи остаётся фатальным при любом значении: имя без A-записи не
работает ни в какой топологии.
## Что приходит на target
На target должен попадать уже готовый package, содержащий:
- thin install entrypoint
- compiled orchestrator artifact
- bundled HY2XS admin
- templates
- unit files
- docs/examples
- metadata package version / build id
## Логическая модульность
Даже если на target приезжает один собранный артефакт, внутри исходников оркестратор должен быть разложен по шагам:
- preflight
- deps
- filesystem
- hysteria
- ui
- systemd
- firewall
- env
- smoke
## Что делает оркестратор по шагам
1. Проверяет, что ОС — Debian 13, и что хост чист (**до любой мутации**).
2. Проверяет базовые зависимости и install context.
3. Создаёт каталоги установки.
4. Разворачивает bundled HY2XS admin.
5. Скачивает pinned Hysteria2 binary из package metadata, проверяет SHA256 и выполняет install.
6. Генерирует Hysteria config.
7. Создаёт systemd unit для Hysteria.
8. Создаёт systemd unit для HY2XS admin.
9. Применяет nftables baseline.
10. Создаёт `post-install.env`.
11. Запускает сервисы и выполняет smoke-check.
## Модель поставки
Рекомендуемая baseline-модель:
- исходники оркестратора хранятся в `orchestrator/`
- builder выполняет локальную сборку через Bun
- в install package кладётся готовый артефакт, который запускается thin wrapper'ом
Например:
- `package/install.sh` — проверка контекста и вызов оркестратора
- `package/orchestrator/hy2xs-orchestrator` — собранный артефакт
## Логирование и коды возврата
Оркестратор должен:
- печатать понятные step-based сообщения
- завершаться ненулевым кодом при ошибке
- не скрывать первичный источник падения
- разделять preflight/config/runtime ошибки хотя бы на уровне текста
## Политика ошибок
- Любой конфликт неизвестного старого состояния = stop with error.
- Никакой сложной автомиграции.
- Ошибки должны быть текстовыми и пригодными для диагностики.
- Для install/reconfigure допустим bounded rollback при failure-сценариях firewall/systemd/config/smoke.
## CLI baseline
Команды:
- `preflight-install --package-dir <path> [--config <source-env>]`
- `install --package-dir <path> [--config <source-env>]`
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --dry-run`
- `reconfigure --package-dir <path> --config /etc/hy2xs/hy2xs.env --apply`
- `repair --package-dir <path> --config /etc/hy2xs/hy2xs.env [--allow-partial-state]`
- `redact-config --config <path> (--in-place | --out <path>) [--format auto|env|yaml]`
`preflight-install` не принимает `--skip-*`: эти флаги влияют на мутацию, а
PHASE 0 ничего не меняет.
`--allow-partial-state` допустим только для `repair`.
Инварианты:
- только IPv4 bind/listen;
- TLS modes: `acme | file | self_signed_dev`;
- `trafficStats.secret` отдельный от `JWT_SECRET`;
- `HY2XS_CONFIG_SCHEMA_VERSION` — обязательное поле; его отсутствие трактуется
как legacy-конфигурация и отклоняется, а не заменяется значением по умолчанию;
- install flow фиксирует фактически установленную версию Hysteria в snapshot;
- версия/URL/SHA256 Hysteria берутся из metadata install package;
- `reconfigure` не обновляет бинарник Hysteria, только runtime-слой;
- при `reconfigure --apply`: backup -> staged apply -> smoke -> rollback on fail.
## Семантическая проверка сгенерированного конфига
`assertHysteriaConfigMatchesProfile` разбирает YAML и сверяет его с
production-профилем, а не ищет подстроки. Проверяются, в частности:
- `listen`, ровно один подтип `obfs` и его соответствие `obfs.type`;
- размеры пакетов Gecko;
- `bandwidth`, `disableLossCompensation`, `ignoreClientBandwidth`;
- `congestion.type` / `bbrProfile`;
- весь QUIC baseline, **включая `maxIdleTimeout`**;
- `trafficStats.listen` и непустой `secret`;
- `auth.type`, **точный** `auth.http.url` (host/port/path/token) и
`auth.http.insecure`;
- ACME: `type`, `email`, `ca`, `dir`, `listenHost`, первый домен;
- отсутствие посторонних секций верхнего уровня.
Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст
уходит в логи и в diagnostics-бандл.
## Редактирование секретов
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
разбирается и обходится как дерево.
Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в
заголовок mapping'а и оставляло нетронутым вложенный
`auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token
наружу. Значение может лежать где угодно в дереве, поэтому обходить нужно
дерево.
Редактируются:
- поля с секретоподобным именем (`password`, `secret`, `token`, `apiKey`,
`privateKey`, `authorization`, `cookie`, `bearer`, `signature`, …);
- карты, где секретны все значения (`auth.userpass`, `acme.dns.config`);
- учётные данные и секретные query-параметры внутри URL — в том числе в
env-файлах, где имя ключа (`HY2_AUTH_URL`) ни под один маркер не подходит.
Гарантия формулируется честно: **known secrets + secret-shaped unknown
fields**. Обобщённый sanitizer не может пообещать, что под правило попадёт
любой будущий секрет.
## Что не реализовывать
- update subcommands
- rollback subcommands
- uninstall subcommands
- reconcile logic
- выдачу пользовательских ключей или bot workflow