Files
HY2XS_flamy/tools/build/lib/versions.sh
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

320 lines
15 KiB
Bash
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.
#!/usr/bin/env bash
set -euo pipefail
# Загрузка и проверка контракта versions.env.
#
# versions.env отвечает на один вопрос: «какую версию продукта, на какой
# платформе и каким toolchain мы воспроизводимо собираем». Всё остальное
# (прикладные зависимости, конкретная версия Hysteria) живёт в собственных
# lock-механизмах.
#
# Подход — проверка, а не генерация. Генерируемые исходники ломают чистый
# чекаут: `bun test`, `tsc` и `go test` должны работать до запуска сборки.
# Поэтому profile.ts / package.json / hy2xs.env остаются обычными файлами,
# а verify_versions_contract роняет сборку при рассинхроне.
VERSIONS_ENV_FILE="${VERSIONS_ENV_FILE:-$ROOT_DIR/versions.env}"
REQUIRED_CONTRACT_VARS=(
HY2XS_VERSION
HY2XS_RELEASE_LINE
HY2XS_CONFIG_SCHEMA_VERSION
HY2XS_BUILD_OS
HY2XS_BUILD_OS_VERSION
HY2XS_BUILD_ARCH
HY2XS_TARGET_OS
HY2XS_TARGET_OS_VERSION
HY2XS_TARGET_ARCH
GO_VERSION
GO_LINUX_AMD64_SHA256
BUN_VERSION
BUN_LINUX_X64_SHA256
BUN_LINUX_X64_BASELINE_SHA256
NODE_VERSION
NODE_LINUX_X64_SHA256
PNPM_VERSION
HYSTERIA_CHANNEL
GOVULNCHECK_VERSION
PNPM_AUDIT_LEVEL
)
validate_sha256_value() {
local name="$1"
local value="$2"
grep -Eq '^[a-f0-9]{64}$' <<<"$value" \
|| fail "versions.env: $name must be a 64-char lowercase hex SHA256, got: $value"
}
load_versions_contract() {
[ -f "$VERSIONS_ENV_FILE" ] || fail "missing versions contract: $VERSIONS_ENV_FILE"
# HYSTERIA_CHANNEL допускает override из окружения: офлайн-сборка из
# закоммиченного lock не должна требовать правки versions.env.
local preset_channel="${HYSTERIA_CHANNEL:-}"
# shellcheck disable=SC1090
. "$VERSIONS_ENV_FILE"
if [ -n "$preset_channel" ]; then
HYSTERIA_CHANNEL="$preset_channel"
fi
local var value
for var in "${REQUIRED_CONTRACT_VARS[@]}"; do
value="${!var:-}"
[ -n "$value" ] || fail "versions.env: missing required $var"
done
validate_sha256_value GO_LINUX_AMD64_SHA256 "$GO_LINUX_AMD64_SHA256"
validate_sha256_value BUN_LINUX_X64_SHA256 "$BUN_LINUX_X64_SHA256"
validate_sha256_value BUN_LINUX_X64_BASELINE_SHA256 "$BUN_LINUX_X64_BASELINE_SHA256"
validate_sha256_value NODE_LINUX_X64_SHA256 "$NODE_LINUX_X64_SHA256"
grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"$HY2XS_VERSION" \
|| fail "versions.env: HY2XS_VERSION must be X.Y.Z, got: $HY2XS_VERSION"
grep -Eq '^[0-9]+$' <<<"$HY2XS_RELEASE_LINE" \
|| fail "versions.env: HY2XS_RELEASE_LINE must be an integer, got: $HY2XS_RELEASE_LINE"
grep -Eq '^[0-9]+$' <<<"$HY2XS_CONFIG_SCHEMA_VERSION" \
|| fail "versions.env: HY2XS_CONFIG_SCHEMA_VERSION must be an integer"
# HY2XS_TARGET_OS — это дистрибутив (debian), а GOOS/`target_os` в metadata
# оперируют ядром. Производная величина, а не второй источник истины.
case "$HY2XS_TARGET_OS" in
debian) HY2XS_TARGET_GOOS=linux ;;
*) fail "versions.env: unsupported HY2XS_TARGET_OS: $HY2XS_TARGET_OS" ;;
esac
export HY2XS_TARGET_GOOS
# Мост к deps.sh: там больше нет собственных значений по умолчанию.
GO_REQUIRED="$GO_VERSION"
BUN_REQUIRED="$BUN_VERSION"
NODE_REQUIRED="$NODE_VERSION"
PNPM_REQUIRED="$PNPM_VERSION"
GO_ARCHIVE_SHA256="$GO_LINUX_AMD64_SHA256"
NODE_ARCHIVE_SHA256="$NODE_LINUX_X64_SHA256"
export HY2XS_VERSION HY2XS_RELEASE_LINE HY2XS_CONFIG_SCHEMA_VERSION
export HY2XS_BUILD_OS HY2XS_BUILD_OS_VERSION HY2XS_BUILD_ARCH
export HY2XS_TARGET_OS HY2XS_TARGET_OS_VERSION HY2XS_TARGET_ARCH
export GO_VERSION GO_LINUX_AMD64_SHA256
export BUN_VERSION BUN_LINUX_X64_SHA256 BUN_LINUX_X64_BASELINE_SHA256
export NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL
export GO_REQUIRED BUN_REQUIRED NODE_REQUIRED PNPM_REQUIRED
export GO_ARCHIVE_SHA256 NODE_ARCHIVE_SHA256
export GOVULNCHECK_VERSION PNPM_AUDIT_LEVEL
}
# Ожидаемая контрольная сумма Bun зависит от выбранного артефакта: на CPU без
# AVX2 скачивается bun-linux-x64-baseline, и одного digest'а не хватает.
expected_bun_sha256() {
case "${BUN_ARTIFACT:-}" in
bun-linux-x64)
printf '%s' "$BUN_LINUX_X64_SHA256"
;;
bun-linux-x64-baseline)
printf '%s' "$BUN_LINUX_X64_BASELINE_SHA256"
;;
*)
fail "expected_bun_sha256: unknown BUN_ARTIFACT: ${BUN_ARTIFACT:-<unset>}"
;;
esac
}
read_json_string_field() {
local file="$1"
local field="$2"
grep -Eo "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" \
| head -n1 \
| sed -E "s/.*:[[:space:]]*\"([^\"]*)\"/\1/"
}
read_env_field() {
local file="$1"
local key="$2"
grep -E "^${key}=" "$file" | head -n1 | cut -d= -f2-
}
expect_equal() {
local label="$1"
local actual="$2"
local expected="$3"
[ "$actual" = "$expected" ] \
|| fail "versions contract mismatch: $label is '$actual', versions.env declares '$expected'"
}
# Сравнение идёт с константами, СКОМПИЛИРОВАННЫМИ в оркестратор, а не с
# текстом исходников: grep по файлу не доказывает, что в бинарь попало то же.
verify_orchestrator_contract() {
local output
output="$(cd orchestrator && "$BUN_BIN" run tools/print-contract.ts)" \
|| fail "versions contract: orchestrator contract dump failed"
local line key value
while IFS= read -r line; do
[ -n "$line" ] || continue
key="${line%%=*}"
value="${line#*=}"
case "$key" in
HY2XS_CONFIG_SCHEMA_VERSION)
expect_equal "orchestrator HY2XS_CONFIG_SCHEMA_VERSION" "$value" "$HY2XS_CONFIG_SCHEMA_VERSION"
;;
HY2XS_RELEASE_LINE)
expect_equal "orchestrator HY2XS_RELEASE_LINE" "$value" "$HY2XS_RELEASE_LINE"
;;
HY2XS_TARGET_OS_VERSION)
expect_equal "orchestrator target OS version" "$value" "$HY2XS_TARGET_OS_VERSION"
;;
HY2XS_TARGET_ARCH)
expect_equal "orchestrator target arch" "$value" "$HY2XS_TARGET_ARCH"
;;
HY2XS_ADMIN_API_BASE)
HY2XS_ADMIN_API_BASE="$value"
;;
HY2XS_HYSTERIA_MACHINE_AUTH_PATH)
HY2XS_HYSTERIA_MACHINE_AUTH_PATH="$value"
;;
*)
fail "versions contract: unexpected orchestrator contract line: $line"
;;
esac
done <<<"$output"
[ -n "${HY2XS_ADMIN_API_BASE:-}" ] \
|| fail "versions contract: orchestrator did not report HY2XS_ADMIN_API_BASE"
[ -n "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH:-}" ] \
|| fail "versions contract: orchestrator did not report HY2XS_HYSTERIA_MACHINE_AUTH_PATH"
export HY2XS_ADMIN_API_BASE HY2XS_HYSTERIA_MACHINE_AUTH_PATH
}
# API namespace — это runtime-контракт между тремя компонентами.
#
# Путь machine-auth уезжает в /etc/hysteria/config.yaml и в post-install.env,
# то есть по нему Hysteria обращается к админке. Пока строка была размазана по
# шаблонам, smoke, тестам и e2e, любое расхождение обнаруживалось только на
# живом сервере. Здесь она сверяется во всех местах сразу — против константы,
# скомпилированной в оркестратор.
verify_api_namespace_contract() {
local go_auth_path go_api_base
go_auth_path="$(grep -Eo 'HysteriaMachineAuthPath[[:space:]]*=[[:space:]]*"[^"]*"' apps/model/constant/api.go \
| head -n1 | sed -E 's/.*"([^"]*)"/\1/')"
go_api_base="$(grep -Eo 'AdminAPIBase[[:space:]]*=[[:space:]]*"[^"]*"' apps/model/constant/api.go \
| head -n1 | sed -E 's/.*"([^"]*)"/\1/')"
expect_equal "apps constant.HysteriaMachineAuthPath" "$go_auth_path" "$HY2XS_HYSTERIA_MACHINE_AUTH_PATH"
expect_equal "apps constant.AdminAPIBase" "$go_api_base" "$HY2XS_ADMIN_API_BASE"
local frontend_base
frontend_base="$(grep -Eo 'const API_BASE = "[^"]*"' apps/frontend/src/utils/request.ts \
| head -n1 | sed -E 's/.*"([^"]*)"/\1/')"
expect_equal "frontend API_BASE" "$frontend_base" "$HY2XS_ADMIN_API_BASE"
grep -qF "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}?access_token={{HYSTERIA_API_SECRET}}" \
package/templates/hysteria/config.yaml.tpl \
|| fail "versions contract: hysteria template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}"
grep -qF "${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}?access_token={{HYSTERIA_API_SECRET}}" \
package/templates/env/post-install.env.tpl \
|| fail "versions contract: post-install env template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}"
# Старое пространство имён не имеет права вернуться ни в один компонент.
# Историческое имя допустимо только в docs/operations/14-legacy-cleanup.md и в
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
#
# Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет.
# Файлы, задача которых — доказать отсутствие legacy-пространства, из скана
# исключены: они обязаны называть его по имени, иначе проверять нечего.
#
# Регрессия приёмки. Раньше исключений не было, а в список каталогов входили
# tools/build и orchestrator/test. Контракт ловил сам себя: grep возвращал
# apps/router/router_test.go (регрессионный тест на отсутствие маршрута) и
# этот самый файл, где строка '/hui' стоит в тексте проверки. Сборка падала
# на шаге versions contract — то есть добавление теста, закрепляющего
# очистку, гарантированно ломало сборку.
#
# Гарантия при этом не ослабла, а переехала на слой, где она сильнее: скан
# исходников доказывает отсутствие СТРОКИ, а TestRouterHasNoLegacyNamespace
# проверяет ТАБЛИЦУ МАРШРУТОВ собранного роутера. Поэтому существование
# этого теста тоже входит в контракт и проверяется ниже.
local legacy_hits
legacy_hits="$(grep -rlF --exclude='*_test.go' '/hui' \
apps/model apps/router apps/controller apps/service apps/middleware \
apps/cmd apps/dao apps/util apps/frontend/src \
orchestrator/src package/templates tools/test 2>/dev/null || true)"
[ -z "$legacy_hits" ] \
|| fail "versions contract: legacy /hui namespace came back in: $legacy_hits"
grep -q 'func TestRouterHasNoLegacyNamespace' apps/router/router_test.go \
|| fail "versions contract: the legacy-namespace route test disappeared from apps/router/router_test.go"
}
verify_go_toolchain_contract() {
local declared_go_line contract_minor go_minor declared_toolchain
declared_go_line="$(grep -E '^go[[:space:]]+[0-9]' apps/go.mod | head -n1 | awk '{print $2}')"
[ -n "$declared_go_line" ] || fail "versions contract: apps/go.mod has no 'go' directive"
contract_minor="$(printf '%s' "$GO_VERSION" | cut -d. -f2)"
go_minor="$(printf '%s' "$declared_go_line" | cut -d. -f2)"
[ "$(printf '%s' "$GO_VERSION" | cut -d. -f1)" = "$(printf '%s' "$declared_go_line" | cut -d. -f1)" ] \
|| fail "versions contract: apps/go.mod major version $declared_go_line is incompatible with GO_VERSION $GO_VERSION"
[ "$go_minor" -le "$contract_minor" ] \
|| fail "versions contract: apps/go.mod requires go $declared_go_line, but GO_VERSION is $GO_VERSION"
# Директива `go` задаёт языковой baseline и НЕ выбирает компилятор: с ней
# одной локальная сборка на другой минорной линии проходит успешно, и
# разработчик проверяет не ту stdlib, которая уедет в production-бинарь.
# `toolchain` называет компилятор явно и обязан совпадать с versions.env
# точно, а не «быть не выше».
declared_toolchain="$(grep -E '^toolchain[[:space:]]+go[0-9]' apps/go.mod | head -n1 | awk '{print $2}')"
[ -n "$declared_toolchain" ] \
|| fail "versions contract: apps/go.mod has no 'toolchain' directive; add 'toolchain go${GO_VERSION}'"
expect_equal "apps/go.mod toolchain" "$declared_toolchain" "go${GO_VERSION}"
}
verify_versions_contract() {
log_step "Verifying versions contract (versions.env)"
expect_equal "PACKAGE_VERSION" "${PACKAGE_VERSION:-}" "$HY2XS_VERSION"
expect_equal \
"orchestrator/package.json packageManager" \
"$(read_json_string_field orchestrator/package.json packageManager)" \
"bun@$BUN_VERSION"
expect_equal \
"apps/frontend/package.json packageManager" \
"$(read_json_string_field apps/frontend/package.json packageManager)" \
"pnpm@$PNPM_VERSION"
expect_equal \
"package/config/hy2xs.env HY2XS_CONFIG_SCHEMA_VERSION" \
"$(read_env_field package/config/hy2xs.env HY2XS_CONFIG_SCHEMA_VERSION)" \
"$HY2XS_CONFIG_SCHEMA_VERSION"
verify_orchestrator_contract
verify_api_namespace_contract
verify_go_toolchain_contract
expect_equal "build host OS" "$HY2XS_BUILD_OS" "debian"
expect_equal "build host arch" "$HY2XS_BUILD_ARCH" "amd64"
expect_equal "target OS" "$HY2XS_TARGET_OS" "debian"
expect_equal "target arch" "$HY2XS_TARGET_ARCH" "amd64"
log_info "versions contract is consistent: HY2XS $HY2XS_VERSION (release line $HY2XS_RELEASE_LINE, config schema $HY2XS_CONFIG_SCHEMA_VERSION)"
}
# Проверяется уже собранный бинарь: это единственное доказательство, что
# ldflags действительно доехали до артефакта.
verify_admin_version_contract() {
local binary="$1"
local reported expected
[ -x "$binary" ] || fail "versions contract: admin binary not found: $binary"
expected="v$HY2XS_VERSION"
reported="$("$binary" version 2>/dev/null | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
[ -n "$reported" ] || fail "versions contract: admin binary did not report a version"
expect_equal "hy2xs-admin reported version" "$reported" "$expected"
}