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

27 KiB
Raw Blame History

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 не обещает корректной автоадаптации.

Двухфазный контракт установки

Установка разделена на две фазы с жёсткой границей между ними:

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.

Раннеры подпроцессов: два набора, а не один

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, и в каком она состоянии». Поэтому кроме фазы он несёт идентификацию поколения:

{
  "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 — оставляет на сервере половину документа:

{
  "product": "hy2xs",
  "release_line":

Такой маркер не разбирается: reconfigure/repair видят его как отсутствующий, а clean-host — как присутствующий, причём хост к этому моменту уже изменён.

Атомарности при этом недостаточно, нужна долговечность. Порядок записи:

1. запись во временный файл в том же каталоге
2. права и владелец            ← до подстановки: иначе есть окно,
                                 в котором файл виден с чужими правами
3. fsync временного файла      ← данные на носителе, а не в page cache
4. rename                      ← атомарная подстановка
5. fsync каталога              ← сама запись каталога о новом имени

Без шагов 3 и 5 rename() даёт атомарность видимости, но после внезапной перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла. Для метаданных восстановления это неприемлемо.

Ownership и rollback

Операция ведёт учёт того, к чему она могла прикоснуться:

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:

запись состояния отказа   → 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, инвариант действует во всех трёх сценариях.

Алгоритм:

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