Files
HY2XS_flamy/docs/build/02-build-layer-and-package.md
T
founder c0a43ae915 fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00

29 KiB
Raw Blame History

Build layer and package

Цель документа

Зафиксировать локальный слой сборки и формат итогового install package.

Базовое решение

В baseline builder остаётся shell-first для packaging-слоя.

То есть:

  • основной packaging pipeline — sh/bash
  • оркестратор при этом пишется на Bun + TypeScript
  • builder локально компилирует оркестратор в готовый install-артефакт
  • target machine не должна сама собирать или доустанавливать JS/TS toolchain

Причина простая: packaging можно держать простым, а оркестратор — typed и модульным.

Где работает builder

Production builder работает на отдельном build host:

  • Debian 13
  • amd64 / x86_64
  • bash
  • доступ к интернету для apt и скачивания toolchain

В текущей production-модели сборка выполняется на Debian 13 amd64, а не на Windows/macOS dev-машине.

Builder не является частью target install flow: на target server приезжает уже готовый install package, без JS/TS/Go build step.

Память build-хоста

Самый требовательный шаг сборки — не компиляция, а govulncheck: он строит граф достижимости по всему модулю вместе со stdlib.

На прогоне v1.0.0-rc1 машина с ~1.9 GiB RAM и нулевым swap получила govulncheck, убитый Linux OOM killer. После подключения временного swap 4 GiB полный security gate прошёл.

4 GiB swap — не формально доказанный минимум, а подтверждённая рабочая конфигурация того прогона; см. отчёт приёмки. Практическое следствие: сборочная машина примерно с 2 GiB RAM без swap может оказаться недостаточной, и отказ выглядит как убитый процесс, а не как внятная ошибка инструмента.

Это требование к сборочной машине, а не к target-серверу: govulncheck в runtime-артефакт не попадает.

Что хранится в репозитории проекта

Минимум:

  • исходники оркестратора на Bun + TypeScript
  • shell packaging scripts
  • шаблоны конфигов
  • systemd unit templates
  • docs
  • исходный код HY2XS admin
  • шаблоны для post-install.env
  • package metadata

Контракт версий: versions.env

Корневой versions.envединственный источник истины для контракта «продукт / платформа / toolchain».

Что в нём есть:

HY2XS_VERSION=1.0.0
HY2XS_RELEASE_LINE=1
HY2XS_CONFIG_SCHEMA_VERSION=2

HY2XS_BUILD_OS=debian
HY2XS_BUILD_OS_VERSION=13
HY2XS_BUILD_ARCH=amd64
HY2XS_TARGET_OS=debian
HY2XS_TARGET_OS_VERSION=13
HY2XS_TARGET_ARCH=amd64

GO_VERSION=1.26.7
GO_LINUX_AMD64_SHA256=<sha256>
BUN_VERSION=1.3.13
BUN_LINUX_X64_SHA256=<sha256>
BUN_LINUX_X64_BASELINE_SHA256=<sha256>
NODE_VERSION=24.20.0
NODE_LINUX_X64_SHA256=<sha256>
PNPM_VERSION=9.15.9

HYSTERIA_CHANNEL=stable

GOVULNCHECK_VERSION=v1.7.0
PNPM_AUDIT_LEVEL=high

Чего в нём нет и быть не должно:

  1. Прикладных зависимостей (Vue, Gin, GORM, npm/Go модули). У них уже есть канонические lock-механизмы: apps/frontend/pnpm-lock.yaml, orchestrator/bun.lock, apps/go.sum. Второй слой неизбежно разъедется с настоящим графом зависимостей.
  2. Конкретной версии Hysteria. Здесь живёт только политика выбора (HYSTERIA_CHANNEL); результат резолва замораживается в tools/build/hysteria-lock.env. Пин версии здесь вернул бы ручное обновление, от которого мы ушли.

Два Bun-артефакта зафиксированы отдельно намеренно: select_bun_artifact() выбирает bun-linux-x64 или bun-linux-x64-baseline по наличию AVX2, поэтому одной контрольной суммы архитектурно недостаточно.

Почему версии toolchain — это вопрос безопасности, а не удобства

Go здесь не просто сборщик: им компилируется hy2xs-admin, и его stdlib целиком попадает в production-бинарь. Поэтому версия выбирается по политике поддержки Go (major поддерживается, пока не вышли две более новые), а не по тому, на чём собиралось раньше.

Цифры, ради которых это записано. На GO_VERSION=1.21.13 — линия, давно вне поддержки — govulncheck ./... находил 21 вызываемую уязвимость, из них 17 в одной только stdlib. После перехода на 1.26.7 и обновления графа зависимостей — ноль.

Node живёт только на build-хосте и в артефакт не попадает, но 20.x достигла EOL, то есть перестала получать security-обновления, а собирает она код, который уезжает в production. Отсюда LTS-линия 24.

Bun обновляется отдельно от остальных: оркестратор собирается через bun build --compile, то есть Bun runtime физически входит в исполняемый файл. Смена его minor-версии — это смена рантайма внутри артефакта, и она требует полного прохода bun test → tsc → compile → приёмка на Debian, а не строки в общем патче.

Проверка типов frontend — обязательный шаг релиза

pnpm run typecheck   → vue-tsc --noEmit   → ОБЯЗАН пройти
pnpm run build:prod  → vite build
pnpm run verify      → typecheck, затем build

bundle_ui() запускает typecheck до сборки bundle: собирать production bundle из кода, который не проходит проверку типов, незачем. Порядок и сам факт наличия шага проверяются приёмкой.

До v1 этой гарантии не было. build:prod означал vite build && vue-tsc --noEmit, но vue-tsc был версии 0.35.0 (2022 год) и шаблоны Vue практически не типизировал: проверка проходила зелёной, не давая гарантии, которую обещает. Хуже того — на Vue 3.5 она ломается сама, потому что не знает vue/jsx-runtime, то есть пережить обновление Vue всё равно не могла.

Современный vue-tsc 3.3 на том же коде дал 142 ошибки: 141 × TS18048 («possibly undefined» при обращении к необязательным секциям конфига Hysteria в шаблоне) и одна TS2322, всё в двух файлах представления Hysteria. Ожидавшегося класса «DefaultRow несовместим с PeerVo» на Element Plus 2.3 не было вовсе — он появился позже, вместе с обновлением Element Plus до 2.14, где слоты таблицы типизированы строже.

Закрыто это не подавлением, а границей: api/config/hysteriaViewModel.ts превращает ответ сервера в модель, где присутствие каждой секции — свойство типа. Подробности — в docs/04.

Контракт теперь читается так:

проверка типов SFC-шаблонов проходит, и это доказывает сборка, а не намерение.

Проверка зависимостей на уязвимости

tools/build/lib/security.sh — обязательный шаг сборки между тестами админки и записью metadata:

Проверка Что покрывает Порог
govulncheck ./... Go-граф и stdlib, с анализом достижимости: уязвимость считается только при наличии пути вызова из нашего кода любая вызываемая
pnpm audit весь lock-граф frontend, включая build tooling, без анализа достижимости PNPM_AUDIT_LEVEL

Про «весь граф» отдельно, потому что здесь стояло --prod с обоснованием «devDependencies в артефакт не попадают».

Для frontend build tooling это обоснование неверно по существу. vite и rollup действительно не копируются на production-сервер как node_modules. Но они исполняются на build-машине, читают наши исходники и порождают тот самый production-бандл, который уезжает в артефакт. Уязвимость в них — это уязвимость в том, что мы выпускаем.

Это не гипотеза: DOM clobbering в Rollup затрагивал именно генерируемый бандл, а проверка по одному production-подграфу его не показывала. По всему графу тот же прогон дал 33 предупреждения против нуля.

Версия govulncheck пиньтся в versions.env, а база уязвимостей подтягивается на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире.

Аварийного выхода у шага нет, и это отличает его от ALLOW_DIRTY_BUILD. Результат уезжает в metadata/package.env полем dependency_security_gate, которое принимает единственное значение true: по готовому tarball видно, что он проверялся, потому что непроверенного tarball не бывает.

Две переменные обхода здесь существовали и были описаны как способ выпустить релиз, зная об уязвимости. Способом они не были: финальная приёмка архива требует буквально dependency_security_gate=true, поэтому сборка с любой из них доходила до конца — компиляция, бандл, тесты, метаданные, tar — и падала на последнем шаге. Продукт документировал операцию, которую сам же запрещал. Противоречие закрыто в пользу строгой политики; отсутствие обходов проверяется приёмкой, а не только описано здесь.

Контракт читается однозначно:

релизный артефакт HY2XS невозможно собрать с непройденной проверкой зависимостей.

Новое advisory чинится обновлением графа (apps/go.sum, apps/frontend/pnpm-lock.yaml) или версии toolchain в versions.env. Для локальной работы обходить нечего: go test ./..., govulncheck ./... и pnpm audit запускаются напрямую и tarball не создают.

Тесты и типы

Та же политика и по той же причине. Аварийного выхода у этого шага нет: переменной, отключающей тесты, не существует.

Проверяется на трёх участках:

Шаг сборки Что запускается
run_orchestrator_tests bun x tsc --noEmit, bun test
bundle_ui pnpm run typecheck (vue-tsc --noEmit) до сборки bundle
run_admin_tests go vet ./..., go test ./...

Готовый пакет объявляет об этом полем tests_gate=true в metadata/package.env — так же, как dependency_security_gate и hysteria_compat_gate. Значение у поля ровно одно, потому что не бывает пакета, собранного с пропущенными тестами: обе функции прогона выставляют свой флаг после успешного завершения, а write_metadata отказывается писать метаданные, если хотя бы один из них не выставлен. То есть поле остаётся утверждением о результате, а не переключателем.

Здесь существовала переменная, описанная как «аварийное отключение тестов; для release-сборок недопустимо». Недопустимость держалась исключительно на этой фразе: ни metadata, ни финальная приёмка архива не проверяли, что тесты запускались, поэтому сборка с ней доходила до конца и выдавала внешне неотличимый production-tarball. Глушила она при этом не только тесты, но и tsc --noEmit с go vet — то есть проверку типов и статический анализ того самого кода, который уезжает в production. История — в CHANGELOG.md.

Проверка, а не генерация

profile.ts, package/config/hy2xs.env и packageManager в двух package.json остаются обычными файлами. Сборка их не генерирует, а сверяет шагом verify_versions_contract.

Причина: генерируемые исходники ломают чистый чекаут — bun test, tsc и go test должны работать до запуска сборки. Проверка даёт тот же инвариант дешевле.

verify_versions_contract сверяет:

Что С чем
PACKAGE_VERSION HY2XS_VERSION
orchestrator/package.jsonpackageManager bun@$BUN_VERSION
apps/frontend/package.jsonpackageManager pnpm@$PNPM_VERSION
package/config/hy2xs.env → схема HY2XS_CONFIG_SCHEMA_VERSION
константы, скомпилированные в оркестратор схема, release line, целевая платформа, API namespace
constant.AdminAPIBase / constant.HysteriaMachineAuthPath (Go) константы оркестратора
API_BASE фронтенда constant.AdminAPIBase
шаблоны Hysteria и post-install.env HYSTERIA_MACHINE_AUTH_PATH
apps/go.mod → директива go GO_VERSION
metadata/package.env версия, release line, схема, target
hy2xs-admin version (готовый бинарь) v$HY2XS_VERSION

Контракт оркестратора сверяется не grep'ом по исходникам, а выводом orchestrator/tools/print-contract.ts: это доказывает, что в бинарь попало то же значение.

API namespace попал в этот список не для красоты. Путь machine-auth записывается в /etc/hysteria/config.yaml и в post-install.env, то есть по нему Hysteria обращается к админке. Пока строка была продублирована в шаблонах, smoke, тестах, приёмке и e2e, расхождение обнаруживалось только на живом сервере.

Версии в package.json — не версия продукта

orchestrator/package.json объявляет version: 0.1.0, а apps/frontend/package.jsonversion: 0.0.0. Это placeholder'ы приватных пакетов, которые никуда не публикуются; единственная версия продукта живёт в versions.env (HY2XS_VERSION) и оттуда доезжает до metadata/package.env, install-state и бинарника админки. Ни одно из этих двух чисел не участвует в контракте версий и не должно восприниматься как release version.

Версия админки приходит в бинарь через ldflags:

-ldflags "-s -w -X 'hy2xs-admin/model/constant.Version=v${HY2XS_VERSION}'"

Собственной константы версии в Go-коде больше нет: она уже успела разъехаться с версией пакета.

Что делает builder

  1. Проверяет структуру проекта.
  2. Загружает и проверяет контракт versions.env.
  3. Прогоняет тесты и типы оркестратора.
  4. Разрешает upstream-версию Hysteria и проходит compatibility gate.
  5. Компилирует оркестратор из Bun/TypeScript в install-артефакт.
  6. Собирает / подготавливает HY2XS admin и сверяет его версию с контрактом.
  7. Прогоняет тесты HY2XS admin (после сборки frontend: go:embed all:dist требует готовых ассетов).
  8. Копирует артефакты UI в package staging directory.
  9. Кладёт entrypoint, templates, docs и service files.
  10. Формирует итоговый install package.
  11. Считает manifest/checksum.
  12. Проверяет архив и прогоняет acceptance-проверки.
  13. Выдаёт один переносимый результат для target machine.

Что builder не делает

  • не ставит Hysteria2 на локальной машине «для продакшена»
  • не превращается в CI/CD платформу
  • не генерирует update pipeline
  • не делает uninstall manifests
  • не готовит миграции между старыми инсталляциями

Рекомендуемая структура

project/
├── tools/
│   └── build/
│       ├── build.sh
│       ├── README.md
│       └── lib/
├── orchestrator/
│   ├── package.json
│   ├── bun.lock
│   ├── tsconfig.json
│   └── src/
├── package/
│   ├── install.sh
│   ├── orchestrator/
│   ├── templates/
│   └── systemd/
├── ui/
│   └── hy2xs-admin/
├── docs/
└── dist/

Формат итогового пакета

Итоговый пакет должен содержать:

  • install-only orchestrator artifact
  • bundled HY2XS admin
  • unit templates
  • config templates
  • docs / examples
  • manifest версии проекта

Итоговый пакет не должен содержать:

  • builder scripts
  • исходную локальную build-среду
  • временные каталоги сборки
  • мусор CI
  • target-side dependency install step для оркестратора

Production builder bootstrap

tools/build/build.sh должен быть самодостаточным для Debian 13 amd64:

  1. Проверяет ОС и архитектуру по versions.env (HY2XS_BUILD_*).
  2. Проверяет структуру репозитория и lock-файлы.
  3. Доставляет отсутствующие системные build-зависимости через apt-get.
  4. Проверяет версии Go, Bun, Node.js и pnpm по versions.env.
  5. При несовпадении версий скачивает управляемый локальный toolchain в .toolchain/ и сверяет каждый архив с контрольной суммой из versions.env.
  6. Собирает только Linux amd64 артефакты.
  7. Записывает версии toolchain в metadata пакета.

Собственных значений по умолчанию у tools/build/lib/deps.sh больше нет: без загруженного контракта сборка падает сразу, а не собирает пакет на неизвестном toolchain.

Отношение к Hysteria2

Сам бинарь Hysteria2 не вендорится в install package как baseline-правило.

Причина:

  • ядро Hysteria рассматривается как stable upstream component;
  • целевая установка скачивает его с official upstream, но строго по замороженным координатам.

Разрешение версии на сборке

SOURCE
    │
    ▼
resolve latest stable  (HyNetworks/hysteria, только теги app/vX.Y.Z)
    │
    ▼
resolve exact release asset  (hysteria-linux-amd64 + hashes.txt)
    │
    ▼
download hashes.txt → ожидаемый SHA-256 от upstream
    │
    ▼
download artifact + сверка с ожидаемым SHA-256
    │
    ▼
compatibility gate  (реальный бинарник принимает канонический конфиг HY2XS)
    │
    ▼
PACKAGE METADATA
    version = vX.Y.Z
    exact_url = <immutable release asset>
    sha256 = <...>
    resolution = latest-stable | pinned | override
    │
    ▼
TARGET SERVER
    скачивает уже конкретный неизменяемый артефакт

Так одновременно выполняются оба требования: «по умолчанию брать последнюю стабильную» и «production-установка должна быть детерминированной и проверяемой».

Переменные builder:

Переменная Значение по умолчанию Назначение
HYSTERIA_CHANNEL stable stable — разрешить последнюю стабильную через upstream API; pinned — взять tools/build/hysteria-lock.env без сети
HYSTERIA_VERSION_OVERRIDE пусто Закрепить конкретную версию vX.Y.Z
HYSTERIA_COMPAT_GATE true Compatibility gate; для release-сборок обязателен
HYSTERIA_WRITE_LOCK false Записать разрешённые значения обратно в tools/build/hysteria-lock.env
HYSTERIA_VERIFY_UPSTREAM_HASHES true Сверять артефакт с upstream hashes.txt; отключение — только break-glass
GITHUB_TOKEN пусто Опционально, чтобы не упереться в anonymous rate limit

Проверка происхождения артефакта

Раньше SHA-256 считался локально от уже скачанного файла. Это защищает target от последующей подмены, но не доказывает, что builder скачал именно ожидаемый upstream artifact: сумма фиксирует то, что пришло, каким бы оно ни было (trust-on-first-use).

Upstream публикует контрольные суммы релиза отдельным ассетом hashes.txt:

6493dfff…f94  build/hysteria-linux-amd64
f24f63be…189  build/hysteria-linux-amd64-avx

Поэтому порядок теперь такой:

download hysteria-linux-amd64
download hashes.txt
        ↓
ожидаемый SHA-256 из upstream
        ↓
сверка скачанного бинарника
        ↓
и только после этого — запись SHA-256 в HY2XS lock и metadata

Сопоставление идёт по базовому имени и строго на равенство: build/ — часть пути, а hysteria-linux-amd64-avx — другой артефакт, который не должен совпасть по префиксу. Разбор вынесен в parseUpstreamHashes и покрыт тестами.

Источник ожидаемой суммы фиксируется в metadata/package.env (hysteria_sha_source=upstream-hashes | hy2xs-lock | local-download).

Дополнительно:

  • версия, URL и SHA256 фиксируются в metadata install package (metadata/hysteria.version, metadata/hysteria.url, metadata/hysteria.sha256);
  • способ выбора версии фиксируется в metadata/hysteria.resolution и metadata/package.env;
  • runtime reconfigure не обновляет и не откатывает бинарник Hysteria2;
  • install flow валидирует SHA256 и фактическую версию установленного бинарника;
  • install-time код не обращается к upstream API и не использует moving latest — это проверяется тестами и acceptance-шагом сборки.

Compatibility gate

Gate защищает от ситуации, когда upstream меняет схему конфигурации, а builder молча собирает неработающий HY2XS.

Порядок:

  1. скачать артефакт и сверить SHA-256 с upstream hashes.txt;
  2. сверить hysteria version с разрешённой версией;
  3. отрендерить канонический конфиг HY2XS тем же кодом, что работает на target (orchestrator/tools/render-canonical-config.ts);
  4. запустить реальный бинарник Hysteria с этим конфигом — для Gecko и для Salamander;
  5. только после этого собирать release package.

При несовместимости ломается сборка:

BUILD FAILED: unsupported Hysteria stable v2.13.0

Это осознанно: ошибка должна проявиться на build machine, а не на сервере оператора.

Инварианты

Система считается правильной, если:

  1. builder запускается на Debian 13 amd64 build host, не как target-side build step
  2. пакет можно перенести на чистый Debian 13
  3. на сервере нет отдельного build step
  4. bundled UI уже находится внутри пакета
  5. оркестратор authored as Bun/TypeScript, но на target приходит как готовый install-артефакт
  6. Hysteria2 подтягивается install layer'ом с upstream по замороженным координатам, а не собирается на target из исходников
  7. выход новой версии Hysteria после сборки не меняет содержимое уже собранного пакета
  8. несовместимый upstream ломает сборку, а не установку у пользователя
  9. контрольная сумма Hysteria подтверждена upstream-ассетом hashes.txt, а не только локальным пересчётом
  10. версии продукта, платформы и toolchain объявлены в одном месте, а рассинхрон роняет сборку до создания tarball