Files
HY2XS_flamy/tools/build/lib/acceptance.sh
T
founder d32811b804 fix(build): отрицательные сканы приёмки проверяют форму кода, а не прозу
Комментарий, объясняющий, почему чего-то больше нет, обязан называть это по
имени. Скан по голой подстроке такой комментарий от кода не отличает и падает
на документации к выполненной им же работе. Найдено три таких гейта, все на
пути ближайшей сборки:

    скан иконок             -> блочный комментарий в SvgIcon/sprite.ts
    скан имён раннеров      -> слово `systemd-run` в прозе firewall.ts
    скан cancelFirewall...  -> комментарий о разделении функции

Второй сломан моим же комментарием из 2259f7c. Третий сломан с момента своего
появления (330a63b) и не падал только потому, что сборка до него не доходила:
её останавливали более ранние гейты.

Исправления по форме, а не удалением комментариев:

- скан прежних имён раннеров требует, чтобы перед именем не стоял дефис. В
  JavaScript идентификатор после дефиса не начинается, поэтому исключение
  точное и ни один настоящий вызов не пропускает;
- скан cancelFirewallRollback ищет имя со скобкой, то есть объявление или
  вызов, а не упоминание;
- литеральный скан по virtual:svg-icons-register удалён: его роль исполняет
  более сильный и более ранний гейт — production `vite build`, где активный
  импорт неразрешимого виртуального модуля роняет сборку bundle. Оставлены две
  точные проверки: отсутствие плагина в package.json и вызов registerSvgIcons
  в main.ts.

В шапке acceptance.sh зафиксировано правило для отрицательных сканов и
ограничение code_without_comments: строчные комментарии он отбрасывает,
блочные — нет, и блок-парсер сознательно не заводится (наивный стриппер
спотыкается о `/*` внутри строк и регулярных выражений, а это ложный PASS).

Все отрицательные сканы приёмки прогнаны по текущему дереву: срабатываний
больше нет; новые шаблоны проверены на синтетическом регрессе — ловят.
2026-09-01 04:44:23 +05:00

1785 lines
129 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
# code_without_comments отдаёт содержимое файла без строк-комментариев.
#
# Приёмка обязана проверять КОД, а не упоминания. Комментарий, объясняющий,
# почему чего-то больше нет, обязан называть это по имени — иначе он бесполезен,
# — и проверка, ищущая просто подстроку, начинает падать ровно на той
# документации, которая описывает выполненную ею же работу.
#
# Урок в этом файле оплачен уже пять раз: скан versions contract ловил сам себя
# на строке '/hui'; dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ
# удалённые маршруты, чтобы доказать их отсутствие; скан иконок падал на
# блочном комментарии, объясняющем замену плагина; скан прежних имён раннеров —
# на слове `systemd-run` в прозе; скан `cancelFirewallRollback` — на
# комментарии, описывающем её разделение.
#
# Отсюда правило для КАЖДОЙ отрицательной проверки по дереву исходников:
#
# формулируй синтаксическую форму, а не подстроку.
#
# Вызов — это имя со скобкой или с обратной кавычкой; импорт — это `import` со
# спецификатором; зависимость — это ключ в package.json. Прозаическое
# упоминание удалённой вещи обязано быть разрешено: комментарий, объясняющий,
# почему чего-то больше нет, называет это по имени — иначе он бесполезен.
#
# Ограничение помощника, о котором важно помнить: он отбрасывает СТРОЧНЫЕ
# комментарии (`//`, `#`), но НЕ блочные `/* ... */`. Блок-парсер здесь
# сознательно не заводится: наивный стриппер спотыкается о `/*` внутри строк и
# регулярных выражений и может вычистить настоящий код, а это ложный PASS —
# отказ хуже болезни. Для файлов с блочными комментариями формулируйте
# синтаксическую форму либо опирайтесь на более сильный гейт (сборка bundle,
# компилятор, тесты).
#
# Вид комментария выбирается по расширению файла.
#
# Раньше отбрасывались только строки, начинающиеся с `//`, поэтому сканы по
# tools/build/*.sh не работали вовсе: там комментарии начинаются с решётки, и
# объяснение «почему этого больше нет» считалось за код.
#
# Решётка при этом отбрасывается ТОЛЬКО в shell. В шаблоне Vue строка вполне
# может начинаться с `#default="scope"` — это сокращение для v-slot, а не
# комментарий, и общий фильтр молча выбрасывал бы её из скана.
#
# Несуществующий путь — ОТКАЗ, а не пустой вывод. Прежнее `2>/dev/null || true`
# превращало опечатку в имени файла в пустую строку, а пустая строка для
# проверок вида «запрещённой конструкции здесь нет» означает успех. Скан,
# который молча проверяет пустоту, хуже отсутствующего: он отчитывается зелёным.
code_without_comments() {
[ -f "$1" ] || fail "code_without_comments: файл не найден: $1"
case "$1" in
# grep -v возвращает 1, когда КАЖДАЯ строка файла — комментарий. Это
# законный результат, а не отказ, поэтому статус здесь гасится.
*.sh) grep -vE '^[[:space:]]*#' "$1" || true ;;
*) grep -v '^[[:space:]]*//' "$1" || true ;;
esac
}
# code_has / code_lacks — поиск по КОДУ файла без пайплайна.
#
# Пайплайн здесь был ловушкой. Поиск с флагом -q прекращает чтение на первом
# совпадении, продюсер получает SIGPIPE, и под `pipefail` статус всей
# конструкции становится 141 — то есть НЕНУЛЕВЫМ именно тогда, когда совпадение
# НАЙДЕНО. Для отрицательных проверок («такой конструкции в коде нет») это
# давало бы ложный PASS: найденная запрещённая конструкция читалась бы как её
# отсутствие. Здесь беду удерживало `|| true` внутри помощника, гасившее 141, —
# то есть корректность гейта держалась на побочном эффекте подавления ошибок,
# а не на чём-то заявленном.
#
# Отказ чтения проверяется ЯВНО, а не через errexit. В контексте `! code_has …`
# bash отключает errexit на весь вызов, поэтому неудачное чтение прошло бы
# незамеченным и снова дало бы ложный PASS. `fail` выполняется в самой функции,
# а не в подоболочке подстановки, поэтому его `exit` останавливает сборку.
code_has() {
local file="$1"
shift
local code
code="$(code_without_comments "$file")" \
|| fail "acceptance: не удалось прочитать код файла $file"
grep -q "$@" <<<"$code"
}
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
code_mentions_in() {
local needle="$1"
shift
local file hits=""
while IFS= read -r file; do
[ -n "$file" ] || continue
if code_has "$file" -F -- "$needle"; then
hits="${hits}${hits:+ }${file}"
fi
done <<EOF
$(grep -rlF --exclude='*_test.go' -- "$needle" "$@" 2>/dev/null || true)
EOF
printf '%s' "$hits"
}
run_fix20_acceptance_subset() {
local package_dir="$1"
[ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir"
log_step "Acceptance: package layout sanity"
[ -x "$package_dir/install.sh" ] || fail "acceptance: install.sh is missing or not executable"
[ -x "$package_dir/orchestrator/hy2xs-orchestrator" ] || fail "acceptance: orchestrator artifact is missing"
log_step "Acceptance: project license is shipped with the package"
[ -f "$package_dir/LICENSE" ] || fail "acceptance: LICENSE is missing from the package"
grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' "$package_dir/LICENSE" \
|| fail "acceptance: packaged LICENSE must be AGPL-3.0-only"
grep -q '^license=AGPL-3.0-only$' "$package_dir/metadata/package.env" \
|| fail "acceptance: package metadata must declare license=AGPL-3.0-only"
log_step "Acceptance: orchestrator CLI help path"
"$package_dir/orchestrator/hy2xs-orchestrator" diagnostics collect --package-dir "$package_dir" >/dev/null 2>&1 || true
log_step "Acceptance: firewall mode defaults in config"
grep -q '^HY2XS_FIREWALL_MODE=' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FIREWALL_MODE missing in runtime config"
log_step "Acceptance: baseline domain/public host/ssh defaults"
grep -q '^HY2XS_DOMAIN=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_DOMAIN must default to fi.api.withen.pro"
grep -q '^HY2XS_PUBLIC_HOST=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_PUBLIC_HOST must default to fi.api.withen.pro"
grep -q '^HY2XS_SSH_PORT=2323$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_SSH_PORT must default to 2323"
log_step "Acceptance: force password change production default"
grep -q '^HY2XS_FORCE_PASSWORD_CHANGE=false$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FORCE_PASSWORD_CHANGE must default to false"
log_step "Acceptance: config schema version is declared"
# Значение берётся из versions.env, а не пишется числом: захардкоженная
# двойка означала бы, что при переходе на schema 3 нужно помнить ещё и про
# эту строку. Источник истины у схемы ровно один.
grep -q "^HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}\$" "$package_dir/config/hy2xs.env" \
|| fail "acceptance: packaged baseline must declare HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}"
log_step "Acceptance: public endpoint policy is declared and strict by default"
grep -q '^HY2XS_PUBLIC_ENDPOINT_POLICY=strict$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: packaged baseline must default to HY2XS_PUBLIC_ENDPOINT_POLICY=strict"
log_step "Acceptance: fresh install defaults to Gecko obfuscation"
grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \
|| fail "acceptance: fresh installations must default to HY2XS_HYSTERIA_OBFS_TYPE=gecko"
log_step "Acceptance: obfs type is not hardcoded in the Hysteria template"
grep -q '{{OBFS_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must render the obfs block from the orchestrator"
! grep -Eq '^\s*type:\s*(gecko|salamander)\s*$' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: hysteria template must not hardcode an obfs type"
log_step "Acceptance: modern server baseline is present in the template"
grep -q 'disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: bandwidth.disableLossCompensation missing from hysteria template"
grep -q '{{CONGESTION_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: congestion block missing from hysteria template"
grep -q '{{QUIC_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \
|| fail "acceptance: quic block missing from hysteria template"
log_step "Acceptance: post-install env derives obfs type from resolved context"
grep -q '^HY2_OBFS_TYPE={{OBFS_TYPE}}$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must render the resolved obfs type, not a second set of defaults"
! grep -Eq '^HY2_OBFS_TYPE=(gecko|salamander)$' "$package_dir/templates/env/post-install.env.tpl" \
|| fail "acceptance: post-install env must not hardcode an obfs type"
log_step "Acceptance: production defaults are declared in exactly one module"
grep -q 'DEFAULT_HYSTERIA_OBFS_TYPE' orchestrator/src/config/profile.ts \
|| fail "acceptance: the default obfs type must be declared in orchestrator/src/config/profile.ts"
local default_declarations
default_declarations="$(grep -rl 'DEFAULT_HYSTERIA_OBFS_TYPE\s*[:=]' orchestrator/src \
| grep -v 'orchestrator/src/config/profile.ts' || true)"
[ -z "$default_declarations" ] \
|| fail "acceptance: the default obfs type must not be re-declared in: $default_declarations"
log_step "Acceptance: runtime env is derived from config, not literals"
! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type"
grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \
|| fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config"
! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \
|| fail "acceptance: env.ts must not carry its own obfs fallback default"
log_step "Acceptance: package metadata records how the Hysteria version was chosen"
grep -q '^hysteria_resolution=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolution"
grep -q '^hysteria_resolved_at=' "$package_dir/metadata/package.env" \
|| fail "acceptance: metadata must record hysteria_resolved_at"
grep -q '^hysteria_compat_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: release packages must be built with the Hysteria compatibility gate enabled"
grep -Eq '^hysteria_artifact_url=https://github\.com/HyNetworks/hysteria/' "$package_dir/metadata/package.env" \
|| fail "acceptance: Hysteria artifact must come from the canonical HyNetworks upstream"
log_step "Acceptance: install-time never resolves a moving latest"
! grep -rq 'api.github.com' orchestrator/src/commands orchestrator/src/steps \
|| fail "acceptance: install-time code must not query the upstream release API"
! grep -rq 'download.hysteria.network' orchestrator/src \
|| fail "acceptance: install-time code must not use the moving latest download URL"
log_step "Acceptance: smoke verifies config semantics, not substrings"
grep -q 'assertHysteriaConfigMatchesProfile' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke must verify the effective config semantically"
log_step "Acceptance: admin export preserves unknown upstream fields and strips secrets"
grep -q 'ExportHysteria2ConfigYaml' apps/controller/config.go \
|| fail "acceptance: hysteria config export must go through the sanitizing raw-YAML path"
grep -q 'GetRawHysteria2Config' apps/service/hysteria2_export.go \
|| fail "acceptance: export must read the raw YAML instead of the typed model"
log_step "Acceptance: frontend ACME registry matches current upstream"
# Ищем именно предлагаемое значение, а не упоминание в комментарии.
! grep -q '"namedotcom"' apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: namedotcom was removed upstream in Hysteria 2.11.0 and must not be offered"
local provider
for provider in cloudflare duckdns gandi godaddy namecheap njalla porkbun vultr; do
grep -q "\"${provider}\"" apps/frontend/src/views/hysteria/list/index.vue \
|| fail "acceptance: ACME DNS provider ${provider} is missing from the UI registry"
done
log_step "Acceptance: systemd unit production env"
grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
log_step "Acceptance: docs matrix markers"
grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing"
log_step "Acceptance: machine auth URL in templates"
grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template"
grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' "$package_dir/templates/env/post-install.env.tpl" || fail "acceptance: machine token missing in post-install HY2_AUTH_URL"
log_step "Acceptance: smoke auth checks are tokenized"
grep -q 'unexpected auth status without machine token' orchestrator/src/steps/smoke.ts || fail "acceptance: missing 403 negative smoke for auth without machine token"
# Проверяется контракт, а не литерал URL.
#
# Регрессия приёмки: здесь стоял grep по строке
# `hysteria2/auth?access_token=${...}`. После переезда machine-auth на
# /internal/hysteria/auth и централизации пути в профиле такой строки в
# smoke.ts не существует — приёмка падала на корректном коде, причём в самом
# конце сборки, внутри verify_archive. Единственный источник истины у пути
# один, поэтому и проверять нужно обращение к нему.
grep -q 'HYSTERIA_MACHINE_AUTH_PATH' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke must take the machine-auth path from the production profile"
grep -q 'hysteriaMachineAuthUrl(' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke auth URL must be built by the production helper (tokenized)"
! grep -Eq 'access_token=' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke must not assemble the machine token into a URL by hand"
log_step "Acceptance: bootstrap peer can pass auth smoke"
grep -q 'quota := int64(-1)' apps/dao/sqlite.go || fail "acceptance: bootstrap peer quota must be unlimited (-1), otherwise install auth smoke fails"
log_step "Acceptance: frontend type checking is a release gate"
# Проверка типов SFC-шаблонов была фикцией: vue-tsc 0.35 не находил ничего, а
# на современном том же коде — 142 ошибки. Инвариант обязан жить в сборке, а
# не в намерении.
"$BUN_BIN" -e '
const pkg = require("./apps/frontend/package.json");
const scripts = pkg.scripts || {};
if (!scripts.typecheck || !scripts.typecheck.includes("vue-tsc")) {
throw new Error("apps/frontend: скрипт typecheck должен запускать vue-tsc");
}
if (scripts["build:prod"].includes("vue-tsc")) {
throw new Error("build:prod снова совмещает сборку и проверку типов: ошибка типов обнаружится только после production bundle");
}
const major = Number((pkg.devDependencies["vue-tsc"] || "").replace(/^\D*/, "").split(".")[0]);
if (!Number.isInteger(major) || major < 3) {
throw new Error("vue-tsc должен быть версии 3 и выше: 0.x проверку шаблонов не выполняет");
}
' || fail "acceptance: контракт проверки типов frontend нарушен"
grep -q '"\$PNPM_BIN" run typecheck' tools/build/lib/package.sh \
|| fail "acceptance: сборка не запускает проверку типов frontend"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
const check = source.indexOf("run typecheck");
const build = source.indexOf("run build:prod");
if (check < 0 || build < 0) throw new Error("не найдены шаги typecheck/build:prod");
if (check > build) throw new Error("проверка типов идёт после сборки bundle");
' || fail "acceptance: проверка типов обязана предшествовать сборке bundle"
log_step "Acceptance: local SVG icons are rendered from an in-repo sprite"
# vite-plugin-svg-icons убран: он не обновлялся с 2022 года и тянул svgo 2.8,
# postcss 5.2.18 и image-size 0.5.5, у последней advisory прямо сообщает
# `Patched versions: <0.0.0` — исправленной версии не существует.
! grep -q '"vite-plugin-svg-icons"' apps/frontend/package.json \
|| fail "acceptance: неподдерживаемый vite-plugin-svg-icons вернулся в зависимости"
grep -q 'registerSvgIcons' apps/frontend/src/main.ts \
|| fail "acceptance: спрайт локальных иконок не регистрируется при старте приложения"
# Литерального скана по `virtual:svg-icons-register` здесь СОЗНАТЕЛЬНО нет.
#
# Он ловил не код, а прозу: единственное вхождение строки в дереве — в
# блочном комментарии `components/SvgIcon/sprite.ts`, который объясняет, что
# именно заменено. Комментарий, называющий удалённую вещь по имени, обязан
# существовать — иначе он бесполезен, — и гейт, падающий на нём, запрещает
# документировать собственную работу.
#
# Утверждение при этом не потеряно, а обеспечено более сильной проверкой:
# production `vite build` выше по сборке уже прошёл. Активный
# `import "virtual:svg-icons-register"` при отсутствующем плагине не
# разрешается резолвером, и сборка bundle падает — то есть проверяется
# исполняемый импорт, а не совпадение подстроки.
# Каждая иконка обязана давать symbol с viewBox.
#
# Без viewBox `<use>` не знает систему координат и рисует иконку в натуральную
# величину, обрезая её по размеру родительского svg. Три иконки из
# семнадцати его не объявляют — для них viewBox синтезируется из width/height,
# как это делал заменённый плагин. Проверка сторожит именно ассеты: иконка,
# добавленная без обоих способов задать координаты, иначе сломала бы
# отрисовку молча.
"$BUN_BIN" -e '
const fs = require("node:fs");
const dir = "apps/frontend/src/assets/icons";
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".svg"));
if (files.length === 0) throw new Error("каталог локальных иконок пуст");
const broken = [];
for (const file of files) {
const raw = fs.readFileSync(`${dir}/${file}`, "utf8");
const openTag = raw.replace(/<\?xml[\s\S]*?\?>/gi, "").replace(/<!DOCTYPE[\s\S]*?>/gi, "").match(/<svg\b[^>]*>/i);
if (!openTag) { broken.push(`${file}: нет корневого <svg>`); continue; }
const hasViewBox = /viewBox="[^"]+"/i.test(openTag[0]);
const hasSize = /width="[\d.]+[a-z%]*"/i.test(openTag[0]) && /height="[\d.]+[a-z%]*"/i.test(openTag[0]);
if (!hasViewBox && !hasSize) broken.push(`${file}: нет ни viewBox, ни пары width/height`);
}
if (broken.length) throw new Error("иконки без системы координат:\n" + broken.join("\n"));
' || fail "acceptance: локальная иконка не даёт корректный <symbol> для спрайта"
log_step "Acceptance: frontend i18n does not touch Pinia at module import"
! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store"
log_step "Acceptance: env rendering maps machine token and fails on unresolved placeholders"
grep -q 'HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret' orchestrator/src/steps/env.ts || fail "acceptance: writePostInstallEnv must pass HYSTERIA_API_SECRET"
grep -q 'template render failed: unresolved placeholders' orchestrator/src/lib/fs.ts || fail "acceptance: renderTemplate must fail on unresolved placeholders"
run_clean_install_acceptance "$package_dir"
}
# Приёмка политики clean-install-only и связанных с ней инвариантов.
run_clean_install_acceptance() {
local package_dir="$1"
log_step "Acceptance: installer runs a read-only preflight before touching the host"
grep -q 'preflight-install' "$package_dir/install.sh" \
|| fail "acceptance: install.sh must run the read-only preflight before mutating the host"
grep -q 'PHASE 0' "$package_dir/install.sh" \
|| fail "acceptance: install.sh must document the read-only phase boundary"
grep -q 'preflightInstall' orchestrator/src/cli.ts \
|| fail "acceptance: orchestrator must expose the preflight-install command"
log_step "Acceptance: the read-only phase is enforced by a guard, not by convention"
grep -q 'enableReadOnlyGuard' orchestrator/src/commands/preflight-install.ts \
|| fail "acceptance: preflight-install must enable the read-only guard"
grep -q 'assertMutationAllowed' orchestrator/src/lib/fs.ts \
|| fail "acceptance: fs writes must be guarded during the read-only phase"
grep -q 'assertMutationAllowed' orchestrator/src/lib/process.ts \
|| fail "acceptance: mutating runners must be guarded during the read-only phase"
log_step "Acceptance: preflight passes before the first install-state write"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const preflight = source.indexOf("await preflight(context");
const state = source.indexOf("await advanceInstallState(");
if (preflight < 0 || state < 0) {
throw new Error("could not locate preflight/advanceInstallState in install.ts");
}
if (preflight > state) {
throw new Error("install writes install-state before preflight");
}
' || fail "acceptance: install must not write install-state before a successful preflight"
log_step "Acceptance: rollback is ownership-aware, not message-driven"
grep -q 'classifyFailure(ownership' orchestrator/src/commands/install.ts \
|| fail "acceptance: failure classification must be driven by ownership, not by the error message"
grep -q 'systemd units were not deployed by this operation' orchestrator/src/commands/install.ts \
|| fail "acceptance: rollback must never stop services it did not deploy"
log_step "Acceptance: a touched install-state already makes the failure post-apply"
# Регрессия: classifyFailure не учитывал маркер установки, поэтому падение
# apt-get объявлялось «на сервере ничего не изменено», rollback пропускался,
# а install-state.json оставался на хосте и ломал следующую установку.
grep -q 'ownership.stateTouched' orchestrator/src/commands/install.ts \
|| fail "acceptance: classifyFailure must account for a touched install-state"
! grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \
|| fail "acceptance: stateWritten вернулся; флаг обязан называться stateTouched и взводиться до записи"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const body = source.slice(source.indexOf("export function classifyFailure"));
const preApply = body.indexOf("return \"fatal_pre_apply\"");
const stateTouched = body.indexOf("ownership.stateTouched");
if (preApply < 0 || stateTouched < 0) {
throw new Error("could not locate classifyFailure branches");
}
if (stateTouched > preApply) {
throw new Error("stateTouched is checked after the fatal_pre_apply fallback");
}
' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was touched"
log_step "Acceptance: the install-state flag is raised before the write, like every other one"
# Запись маркера — это mkdir, write и chown. Отказ последней оставляет файл на
# диске, поэтому флаг обязан отвечать на вопрос «сюда мы могли влезть», а не
# «запись удалась».
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const start = source.indexOf("async function advanceInstallState");
if (start < 0) throw new Error("advanceInstallState is missing");
// Границей тела служит следующее объявление верхнего уровня.
const rest = source.slice(start + 1);
const end = rest.search(/\n(export )?(async )?function /);
const body = end < 0 ? rest : rest.slice(0, end);
const flag = body.indexOf("ownership.stateTouched = true");
const write = body.indexOf("await writeInstallState(");
if (flag < 0 || write < 0) throw new Error("could not locate the flag and the write");
if (flag > write) throw new Error("stateTouched is raised after writeInstallState");
' || fail "acceptance: the install-state ownership flag must be raised before the write"
log_step "Acceptance: mutating ownership flags are raised before the step, not after"
# Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить
# систему и упасть. Каждый флаг обязан стоять ПЕРЕД своим await.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const steps = [
["depsTouched", "await installDeps("],
["filesystemTouched", "await prepareFilesystem("],
["uiTouched", "await deployUi("],
["hysteriaTouched", "await installHysteria("],
["configTouched", "await generateConfig("],
["unitsTouched", "await deploySystemd("],
["firewallTouched", "await applyFirewall("],
["postInstallTouched", "await writePostInstallEnv("],
["bootstrapSecretTouched", "await ensureBootstrapAdminSecret("]
];
for (const [flag, call] of steps) {
const flagAt = source.indexOf("ownership." + flag + " = true");
const callAt = source.indexOf(call);
if (flagAt < 0) throw new Error("missing ownership flag: " + flag);
if (callAt < 0) throw new Error("missing step call: " + call);
if (flagAt > callAt) throw new Error(flag + " is raised after " + call);
}
' || fail "acceptance: ownership flags must be raised before the mutating step they cover"
log_step "Acceptance: the read-only phase has no universal runner to slip through"
# Пока существовал один `run`, под которым жили и `ss -ltn`, и `useradd`,
# guard держался на внимательности автора правки.
grep -q 'export async function runReadOnly' orchestrator/src/lib/process.ts \
|| fail "acceptance: process.ts must expose an explicit read-only runner"
grep -q 'export async function runMutating' orchestrator/src/lib/process.ts \
|| fail "acceptance: process.ts must expose an explicit mutating runner"
# Дефис в списке исключённых предшествующих символов — не косметика.
#
# Скан ищет ВЫЗОВ прежнего раннера: имя, за которым сразу идёт обратная
# кавычка tagged template. Без дефиса под это описание попадала любая проза
# вида `systemd-run` — имя команды, взятое в кавычки в комментарии, — и гейт
# падал на строке документации, не содержащей никакого вызова. В JavaScript
# идентификатор не может начинаться после дефиса, поэтому исключение точное:
# ни один настоящий вызов оно не пропускает.
! grep -rEq '(^|[^-A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \
|| fail "acceptance: the pre-split runner names must not come back"
# Порог считается от числа мутирующих раннеров, а не задан константой: новый
# раннер обязан приносить с собой и проверку guard'а, а не проходить под
# запасом, оставленным предыдущей правкой.
local mutating_runners guarded_runners
mutating_runners="$(grep -c 'export async function runMutating' orchestrator/src/lib/process.ts || true)"
guarded_runners="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)"
[ "$mutating_runners" -ge 5 ] \
|| fail "acceptance: набор мутирующих раннеров неожиданно сократился ($mutating_runners)"
[ "$guarded_runners" -gt "$mutating_runners" ] \
|| fail "acceptance: every mutating runner must ask the read-only guard for permission"
log_step "Acceptance: doctor is read-only by runtime invariant, not by convention"
# Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию.
# Всё остальное в smoke держалось на выборе раннера автором правки: читающие
# команды (`test -s`, `grep -q`, `stat`, `sudo -u ... test`, `nft -c`) шли
# через мутирующий namespace, поэтому настоящая мутация, случайно добавленная
# в smoke, ничем бы от них не отличалась и была бы разрешена в doctor молча.
grep -q 'enableReadOnlyGuard' orchestrator/src/commands/doctor.ts \
|| fail "acceptance: doctor обязан выполняться под read-only guard, а не только выставлять skipServiceStart"
grep -q 'disableReadOnlyGuard' orchestrator/src/commands/doctor.ts \
|| fail "acceptance: doctor обязан снимать guard в finally"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/doctor.ts", "utf8");
const enable = source.indexOf("enableReadOnlyGuard(");
const work = source.indexOf("await readText(options.sourceConfigPath)");
const finallyAt = source.indexOf("} finally {");
const disable = source.indexOf("disableReadOnlyGuard()");
if (enable < 0 || work < 0 || finallyAt < 0 || disable < 0) {
throw new Error("не найдены включение guard, работа и finally");
}
if (enable > work) throw new Error("guard включается после начала работы");
if (disable < finallyAt) throw new Error("guard снимается не в finally");
' || fail "acceptance: guard doctor обязан охватывать весь проход и сниматься в finally"
# В smoke допустим ровно один мутирующий вызов — рестарт под флагом, который
# doctor выставляет принудительно.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
const calls = source.split(/\r?\n/)
.filter((line) => !line.trimStart().startsWith("//"))
.filter((line) => /\brunMutating[A-Za-z]*[`(]/.test(line));
if (calls.length !== 1) {
throw new Error("мутирующих вызовов в smoke: " + calls.length + "\n" + calls.join("\n"));
}
if (!calls[0].includes("systemctl restart hysteria-server hy2xs-admin")) {
throw new Error("единственный мутирующий вызов smoke — не рестарт сервисов: " + calls[0]);
}
' || fail "acceptance: наблюдение в smoke обязано выполняться read-only раннерами"
# Пауза между двумя чтениями не является операцией над хостом.
! grep -Eq 'runMutating[A-Za-z]*`sleep' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: ожидание между попытками снова выполняется мутирующим раннером"
log_step "Acceptance: reset-admin tells a storage failure apart from a missing admin"
# Склейка ErrStorage и ErrAdminUserNotFound уводила команду в ветку СОЗДАНИЯ:
# при транзиентном отказе чтения на сервере появлялась вторая рабочая учётка
# с паролем, уже напечатанным на экран.
grep -q 'errors.Is(err, dao.ErrAdminUserNotFound)' apps/cmd/reset.go \
|| fail "acceptance: reset-admin обязан различать sentinel-ошибки слоя данных"
! code_has apps/cmd/reset.go -F -- 'hash, _ :=' \
|| fail "acceptance: ошибка хеширования пароля снова проглатывается, в password_hash уедет пустая строка"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/cmd/reset.go", "utf8");
const start = source.indexOf("func resetAdminCredentials");
if (start < 0) throw new Error("resetAdminCredentials отсутствует");
const body = source.slice(start);
if (!/default:\s*\n\s*return fmt\.Errorf/.test(body)) {
throw new Error("неизвестная ошибка чтения не приводит к отказу");
}
const save = body.indexOf("dao.SaveAdminUser(");
const notFound = body.indexOf("errors.Is(err, dao.ErrAdminUserNotFound)");
if (save < 0 || notFound < 0) throw new Error("не найдены ветка создания и её условие");
if (notFound > save) throw new Error("создание учётной записи не ограничено веткой «записи нет»");
' || fail "acceptance: создание администратора допустимо только при подтверждённом отсутствии записи"
log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor"
grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \
|| fail "acceptance: preflight must verify that the public endpoint resolves to this server"
[ -f orchestrator/src/steps/networkEndpoint.ts ] \
|| fail "acceptance: the public endpoint invariant module is missing"
! grep -rqE 'ifconfig\.me|ipify|checkip\.amazonaws' orchestrator/src \
|| fail "acceptance: the server address must be resolved locally, not via an external service"
grep -q 'networkInterfaces' orchestrator/src/steps/networkEndpoint.ts \
|| fail "acceptance: local public IPv4 set must come from the host interfaces"
log_step "Acceptance: purge and clean-host describe the same boundary"
local purged_path
for purged_path in /var/lib/hysteria /usr/local/lib/hy2xs /usr/local/bin/hy2xs-orchestrator /var/log/hy2xs; do
grep -qF "$purged_path" tools/legacy/purge-v0.sh \
|| fail "acceptance: purge-v0.sh no longer removes $purged_path"
done
grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker"
# Symlink оркестратора остаётся маркером, но путь объявлен в профиле: его
# создаёт steps/bootstrap.ts, и две копии строки разошлись бы.
grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must treat a leftover orchestrator symlink as a legacy marker"
! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \
|| fail "acceptance: --keep-hysteria-binary contradicts the installer clean-host contract"
log_step "Acceptance: admin secrets never reach a persistent export file"
# Экспорт формируется в памяти: os.Create в /var/lib/hy2xs-admin/export
# оставлял на диске JSON с расшифрованными секретами пиров.
[ ! -f apps/util/export.go ] \
|| fail "acceptance: the file-based export helper came back"
! grep -rq 'ExportPathDir' apps/cmd apps/controller apps/service apps/dao apps/model apps/util \
|| fail "acceptance: the persistent export directory came back"
grep -q 'c.Data(200, "application/octet-stream", payload)' apps/controller/peer.go \
|| fail "acceptance: peer export must be served from memory"
log_step "Acceptance: peer import is validated as strictly as peer creation"
grep -q 'ValidatePeerImportBatch' apps/service/peer.go \
|| fail "acceptance: peer import must validate the whole batch before writing"
grep -q 'DisallowUnknownFields' apps/controller/peer.go \
|| fail "acceptance: peer import must reject unknown fields instead of silently defaulting"
grep -q 'ReservedBootstrapPeerName' apps/service/peer_import.go \
|| fail "acceptance: peer import must refuse to overwrite the installer-owned bootstrap peer"
log_step "Acceptance: missing config schema marker is treated as legacy"
grep -q 'HY2XS_CONFIG_SCHEMA_VERSION отсутствует' orchestrator/src/config/env.ts \
|| fail "acceptance: a missing HY2XS_CONFIG_SCHEMA_VERSION must be rejected as legacy, not defaulted"
log_step "Acceptance: reconfigure/repair verify the installation generation"
grep -q 'assertCurrentGeneration' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure/repair must verify release line and config schema in install-state"
grep -q 'release_line' orchestrator/src/lib/installState.ts \
|| fail "acceptance: install-state must carry the product release line"
log_step "Acceptance: diagnostics redaction is structural"
grep -q 'Bun.YAML.parse' orchestrator/src/lib/redaction.ts \
|| fail "acceptance: YAML redaction must walk the document, not match lines"
# Регрессия: правило `.replace(/(auth:\s*).*/gi, ...)` подставляло маркер в
# заголовок mapping'а и оставляло вложенный auth.http.url с machine token.
! grep -qF 'replace(/(auth:' orchestrator/src/lib/redaction.ts \
|| fail "acceptance: the line-based auth redaction rule leaked nested auth.http.url"
log_step "Acceptance: dead updater/config-write routes stay removed"
# Ищется регистрация маршрута (имя в кавычках), а не любое упоминание:
# комментарий, объясняющий, почему маршрута нет, должен быть разрешён.
#
# Тесты исключены по той же причине, и это не послабление: router_test.go
# ПЕРЕЧИСЛЯЕТ удалённые имена, потому что проверяет их отсутствие в таблице
# маршрутов. Пока `*_test.go` попадал под скан, приёмка падала на собственном
# регрессионном тесте — то есть добавление теста, закрепляющего удаление
# маршрута, ломало сборку.
local dead_route
for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig getConfig; do
! grep -rqF --include='*.go' --exclude='*_test.go' "${dead_route}\"" apps/router apps/controller \
|| fail "acceptance: removed route ${dead_route} came back"
! grep -rqF "${dead_route}\"" apps/frontend/src/api \
|| fail "acceptance: frontend still calls the removed route ${dead_route}"
done
log_step "Acceptance: e2e uses the production share URI generator"
grep -q 'tools/share-uri' tools/test/e2e-hysteria.sh \
|| fail "acceptance: e2e must build the share URI with production code"
! grep -q 'build_share_uri' tools/test/e2e-hysteria.sh \
|| fail "acceptance: the bash share-URI implementation must not come back"
log_step "Acceptance: upstream checksums are verified before the lock is written"
grep -q 'resolve_expected_sha_from_upstream' tools/build/lib/hysteria.sh \
|| fail "acceptance: the Hysteria artifact must be verified against upstream hashes.txt"
log_step "Acceptance: toolchain checksums live in versions.env"
grep -q '^BUN_LINUX_X64_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Bun x64 artifact"
grep -q '^BUN_LINUX_X64_BASELINE_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Bun baseline artifact"
grep -q '^GO_LINUX_AMD64_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Go toolchain archive"
grep -q '^NODE_LINUX_X64_SHA256=' versions.env \
|| fail "acceptance: versions.env must pin the Node.js archive"
! grep -q '^HYSTERIA_VERSION=' versions.env \
|| fail "acceptance: versions.env declares the Hysteria policy, not a pinned version"
log_step "Acceptance: admin version comes from the build contract"
grep -q 'var Version = "dev"' apps/model/constant/system.go \
|| fail "acceptance: admin version must be injected via ldflags, not hardcoded"
grep -q 'constant.Version=v' tools/build/lib/package.sh \
|| fail "acceptance: the build must inject the admin version from versions.env"
log_step "Acceptance: legacy cleanup is a separate, explicit helper"
[ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing"
[ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
! grep -q 'purge-v0' "$package_dir/install.sh" \
|| fail "acceptance: the installer must never run destructive cleanup on its own"
run_transaction_boundary_acceptance
run_single_owner_acceptance "$package_dir"
run_secret_channel_acceptance
run_atomic_import_acceptance
run_legacy_account_acceptance
run_scheduler_lifecycle_acceptance
run_dead_config_acceptance
run_dependency_hygiene_acceptance "$package_dir"
}
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
run_scheduler_lifecycle_acceptance() {
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
[ -f apps/service/cron_scheduler.go ] \
|| fail "acceptance: модуль планировщика отсутствует"
[ ! -f apps/middleware/cron.go ] \
|| fail "acceptance: планировщик вернулся в middleware, где он создавался заново на каждый запуск сервера"
# Ключевая регрессия: смена настройки не имеет права ронять HTTP-сервер.
# Именно этот путь плодил планировщики — StopServer возвращал управление в
# `for { runServer() }`, а InitCron создавал новый cron.New() и терял ссылку.
! code_has apps/controller/config.go -- 'StopServer' \
|| fail "acceptance: смена настройки снова перезапускает HTTP-сервер вместо перепланирования джобы"
grep -q 'RescheduleResetTraffic' apps/controller/config.go \
|| fail "acceptance: расписание должно переноситься на месте, через RescheduleResetTraffic"
# Точка входа не должна крутить сервер в цикле: цикл существовал только ради
# внутрипроцессного перезапуска.
! grep -qE '^[[:space:]]*for[[:space:]]*\{' apps/cmd/cmd.go \
|| fail "acceptance: цикл перезапуска сервера вернулся в точку входа"
# Остановка планировщика обязана существовать и идти РАНЬШЕ закрытия базы.
grep -q 'func StopCron' apps/service/cron_scheduler.go \
|| fail "acceptance: у планировщика нет остановки, джобы переживут закрытие SQLite"
grep -q 'defer service.StopCron()' apps/cmd/server.go \
|| fail "acceptance: планировщик не останавливается при завершении сервиса"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/cmd/server.go", "utf8");
const release = source.indexOf("defer releaseResource()");
const stopCron = source.indexOf("defer service.StopCron()");
if (release < 0 || stopCron < 0) throw new Error("не найдены defer releaseResource/StopCron");
// defer выполняются в обратном порядке регистрации: releaseResource должен
// быть зарегистрирован РАНЬШЕ, чтобы отработать ПОЗЖЕ StopCron.
if (release > stopCron) throw new Error("SQLite закроется раньше остановки планировщика");
' || fail "acceptance: порядок остановки должен глушить планировщик до закрытия базы"
# Сигналы: SIGTERM от systemd обязан приводить к штатному завершению.
grep -q 'signal.NotifyContext' apps/cmd/server.go \
|| fail "acceptance: сервис не обрабатывает сигнал завершения от systemd"
grep -q 'syscall.SIGTERM' apps/cmd/server.go \
|| fail "acceptance: SIGTERM не входит в набор сигналов завершения"
log_step "Acceptance: cron expression is validated by the runtime parser before it is stored"
grep -q 'cron.ParseStandard' apps/service/cron_scheduler.go \
|| fail "acceptance: выражение должно проверяться тем же парсером, которым его разбирает планировщик"
grep -q 'ValidateConfigValue' apps/controller/config.go \
|| fail "acceptance: значения настроек должны проверяться до записи в базу"
log_step "Acceptance: config batch is validated fully, then applied in one transaction"
grep -q 'func WithConfigTx' apps/dao/config.go \
|| fail "acceptance: у записи настроек нет транзакционной границы"
grep -q 'sqliteDB.Transaction' apps/dao/config.go \
|| fail "acceptance: WithConfigTx должен открывать настоящую транзакцию"
grep -q 'service.UpdateConfigs(updates)' apps/controller/config.go \
|| fail "acceptance: партия настроек должна применяться одним вызовом, а не по элементу"
# Проверка обязана завершиться ДО первой записи.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/controller/config.go", "utf8");
const validate = source.indexOf("service.ValidateConfigValue(");
const apply = source.indexOf("service.UpdateConfigs(updates)");
if (validate < 0 || apply < 0) throw new Error("не найдены проверка и применение партии");
if (validate > apply) throw new Error("партия применяется раньше, чем проверяется");
' || fail "acceptance: партия настроек обязана проверяться целиком до первой записи"
}
# Мёртвые ключи таблицы `config` не должны вернуться.
run_dead_config_acceptance() {
log_step "Acceptance: dead config keys stay removed"
# Проверяется КОД: тесты перечисляют удалённые ключи, чтобы доказать их
# отсутствие, а комментарии объясняют, почему ключей больше нет. И то и
# другое обязано называть их по имени.
local dead_key hits
for dead_key in HYSTERIA2_ENABLE HYSTERIA2_CONFIG HYSTERIA2_TRAFFIC_TIME HYSTERIA2_CONFIG_REMARK; do
hits="$(code_mentions_in "$dead_key" \
apps/model apps/router apps/controller apps/service apps/util apps/frontend/src)"
[ -z "$hits" ] \
|| fail "acceptance: удалённый ключ $dead_key вернулся в: $hits"
done
grep -q '006_drop_dead_config_keys' apps/dao/sqlite.go \
|| fail "acceptance: миграция удаления мёртвых ключей пропала"
# Серверный конфиг Hysteria читается только из файла: ветка «сначала SQLite»
# была вторым источником истины и читалась ПЕРВОЙ.
! code_has apps/service/config.go -- 'Hysteria2Config)' \
|| fail "acceptance: чтение серверного конфига снова обращается к таблице config"
! code_has apps/service/hysteria2_export.go -- 'Hysteria2Config)' \
|| fail "acceptance: выгрузка серверного конфига снова обращается к таблице config"
log_step "Acceptance: share URI remark is derived, not stored as dead state"
grep -q 'func shareRemark' apps/service/hysteria2_api.go \
|| fail "acceptance: имя профиля в share URI должно выводиться детерминированно"
}
# Гигиена зависимостей и bootstrap-учётных данных.
run_dependency_hygiene_acceptance() {
local package_dir="$1"
log_step "Acceptance: bootstrap credentials are never invented or logged"
# Ключевой канал утечки: сгенерированный пароль печатался в
# /var/log/hy2xs/hy2xs-admin.log, а этот файл отдаётся кнопкой выгрузки.
! code_has apps/dao/sqlite.go -- 'Initial admin password' \
|| fail "acceptance: bootstrap-пароль снова пишется в журнал"
grep -q 'HY2XS_ADMIN_INITIAL_PASSWORD не задан' apps/dao/sqlite.go \
|| fail "acceptance: отсутствие HY2XS_ADMIN_INITIAL_PASSWORD должно быть отказом старта"
grep -q 'HY2XS_ADMIN_CON_PASS не задан' apps/dao/sqlite.go \
|| fail "acceptance: отсутствие HY2XS_ADMIN_CON_PASS должно быть отказом создания bootstrap-пира"
log_step "Acceptance: the admin log is sanitized on export, like the Hysteria one"
grep -q 'func ExportAdminLog' apps/service/journal.go \
|| fail "acceptance: журнал админки выгружается без санитайза"
! code_has apps/controller/log.go -- 'c.File(' \
|| fail "acceptance: журнал снова отдаётся сырым файлом мимо санитайза"
log_step "Acceptance: JWT stack is the maintained major line, with a fixed algorithm"
local jwt_v3_hits
jwt_v3_hits="$(code_mentions_in '"github.com/golang-jwt/jwt"' \
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util)"
[ -z "$jwt_v3_hits" ] \
|| fail "acceptance: вернулась ветка golang-jwt/jwt v3 (GO-2025-3553 без исправленной версии) в: $jwt_v3_hits"
grep -q 'golang-jwt/jwt/v5' apps/go.mod \
|| fail "acceptance: golang-jwt/jwt/v5 отсутствует в графе зависимостей"
grep -q 'jwt.WithValidMethods' apps/service/jwt.go \
|| fail "acceptance: разбор токена должен ограничивать набор алгоритмов подписи"
log_step "Acceptance: password storage has exactly one format"
! code_has apps/util/encrypt.go -- 'SHA224String' \
|| fail "acceptance: вернулась проверка пароля по несолёному SHA-224"
grep -q 'if !IsBcryptHash(storedHash)' apps/util/encrypt.go \
|| fail "acceptance: VerifyPassword обязан отклонять всё, кроме bcrypt"
log_step "Acceptance: the release is built with a dependency vulnerability gate"
[ -f tools/build/lib/security.sh ] \
|| fail "acceptance: модуль проверки зависимостей отсутствует"
grep -q 'run_dependency_security_gate' tools/build/build.sh \
|| fail "acceptance: сборка не запускает проверку зависимостей"
grep -q '^GOVULNCHECK_VERSION=' versions.env \
|| fail "acceptance: версия govulncheck должна быть частью контракта сборки"
# По готовому tarball должно быть видно, что он проверялся — ровно как
# hysteria_compat_gate.
grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей"
log_step "Acceptance: the npm gate covers the whole lock graph, not only production deps"
# `--prod` не показывал уязвимости в vite/rollup, хотя они исполняются на
# build-машине и порождают production-бандл. Ровно такой класс и был найден
# (DOM clobbering в Rollup затрагивал генерируемый бандл).
! code_has tools/build/lib/security.sh -- 'audit --prod' \
|| fail "acceptance: гейт зависимостей снова проверяет только production-подграф npm"
code_has tools/build/lib/security.sh -- 'audit --audit-level "\$PNPM_AUDIT_LEVEL"' \
|| fail "acceptance: гейт зависимостей обязан проверять весь lock-граф frontend"
log_step "Acceptance: release gates never pipe into an early-exiting matcher"
# Поиск с флагом -q прекращает чтение на ПЕРВОМ совпадении и закрывает свой
# конец канала. Продюсер, у которого осталось что писать, получает SIGPIPE и
# завершается кодом 141, а `set -o pipefail` делает 141 статусом всей
# конструкции. Получается инверсия смысла: «совпадение найдено» становится
# ненулевым кодом.
#
# Для утвердительных проверок это ложный FAIL — гейт отвергает корректный
# артефакт. Для отрицательных («такой конструкции в коде нет») — ЛОЖНЫЙ PASS:
# найденная запрещённая конструкция читается как её отсутствие, и гейт
# отчитывается зелёным ровно тогда, когда обязан был остановить сборку.
#
# Порог не гипотетический: пока весь вывод продюсера помещается в буфер канала
# (64 KiB на Linux), он успевает записать всё и не блокируется, поэтому такая
# проверка годами выглядит исправной. Первый же источник крупнее буфера
# переворачивает её результат — а в этом репозитории такие файлы уже есть.
#
# Замена — here-string: `matcher PATTERN <<<"$content"`. Пайплайна нет, статус
# у конструкции ровно один, и pipefail к нему не применяется.
local piped_matcher
piped_matcher="$(grep -rn -E '\|[[:space:]]*grep[[:space:]]+-[A-Za-z]*q' \
tools/build/lib tools/build/build.sh tools/test tools/legacy package || true)"
[ -z "$piped_matcher" ] \
|| fail "acceptance: пайплайн в grep -q под pipefail вернулся; используйте here-string:
$piped_matcher"
log_step "Acceptance: the release is built with a mandatory test gate"
# SKIP_TESTS=true доходил до конца сборки и выдавал внешне неотличимый
# production-артефакт: ни metadata, ни финальная приёмка не проверяли, что
# тесты запускались. Глушил он при этом и `tsc --noEmit`, и `go vet`.
local test_bypass_file
for test_bypass_file in \
tools/build/build.sh \
tools/build/lib/package.sh \
tools/build/lib/security.sh \
tools/build/lib/versions.sh \
tools/build/lib/deps.sh \
tools/build/lib/verify.sh
do
[ -z "$(code_without_comments "$test_bypass_file" | grep -F -- 'SKIP_TESTS' || true)" ] \
|| fail "acceptance: обход тестов SKIP_TESTS вернулся в $test_bypass_file"
done
! grep -rq 'SKIP_TESTS' README.md docs tools/build/README.md \
|| fail "acceptance: README/docs снова описывают обход тестов, которого нет в сборке; место для истории — CHANGELOG.md"
grep -q '^tests_gate=true$' "$package_dir/metadata/package.env" \
|| fail "acceptance: релизный пакет обязан собираться с пройденными тестами"
# Утверждение обязано опираться на результат, а не на намерение.
grep -q 'ORCHESTRATOR_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон тестов админки не фиксируется результатом"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
for (const [fn, flag] of [
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""]
]) {
const start = source.indexOf(fn);
if (start < 0) throw new Error("не найдена функция " + fn);
const rest = source.slice(start);
// Границей тела служит закрывающая скобка в первой позиции строки.
// Поиск регуляркой, а не подстрокой: файл может быть выгружен с CRLF.
const end = rest.search(/\n\}[\r\n]/);
const body = end < 0 ? rest : rest.slice(0, end);
const set = body.indexOf(flag);
const run = body.lastIndexOf("|| fail");
if (set < 0) throw new Error(fn + ": результат прогона не фиксируется");
if (set < run) throw new Error(fn + ": результат объявляется раньше, чем получен");
}
' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном"
# У шага не должно быть обходов — ни объявленных, ни забытых.
#
# Раньше их было два: ALLOW_VULNERABLE_DEPENDENCIES=true писал в metadata
# `accepted-risk`, SKIP_SECURITY_SCAN=true — `skipped`. Оба документировались
# README и docs/02 как способ выпустить релиз, зная об уязвимости, и оба
# гарантированно падали здесь же, строкой выше: приёмка требует буквально
# `true`. Сборка проходила весь цикл и умирала на последнем шаге ради
# операции, которую продукт в итоге запрещает.
#
# Противоречие закрыто в пользу строгой политики, и теперь это проверяется, а
# не только описано. Ищется КОД: комментарий выше объясняет, почему обходов
# нет, и обязан называть их по имени.
#
# Сканируются модули, которые могли бы обход вернуть, а НЕ весь tools/build.
# Причина ровно та, из-за которой в этом файле дважды падала сборка на
# корректном коде: скан по всему каталогу находил сам себя — строку
# `for bypass_var in ALLOW_VULNERABLE_DEPENDENCIES SKIP_SECURITY_SCAN` ниже.
# Это код, а не комментарий, поэтому code_without_comments тут не спасает.
local bypass_var bypass_scan_file bypass_hits
for bypass_var in ALLOW_VULNERABLE_DEPENDENCIES SKIP_SECURITY_SCAN; do
for bypass_scan_file in \
tools/build/build.sh \
tools/build/lib/security.sh \
tools/build/lib/package.sh \
tools/build/lib/versions.sh \
tools/build/lib/deps.sh \
tools/build/lib/verify.sh
do
bypass_hits="$(code_without_comments "$bypass_scan_file" | grep -F -- "$bypass_var" || true)"
[ -z "$bypass_hits" ] \
|| fail "acceptance: обход проверки зависимостей $bypass_var вернулся в $bypass_scan_file: $bypass_hits"
done
done
# И в документации тоже: описанный, но нерабочий аварийный выход — хуже, чем
# его отсутствие. Именно из-за такого описания противоречие и прожило до
# приёмки.
#
# У markdown нет строк-комментариев, поэтому code_without_comments здесь не
# применим и правило простое: место для истории — CHANGELOG.md, он под скан не
# попадает. README и docs описывают текущую политику, а не отменённую.
! grep -rq 'ALLOW_VULNERABLE_DEPENDENCIES\|SKIP_SECURITY_SCAN' README.md docs \
|| fail "acceptance: README/docs снова описывают обход проверки зависимостей, которого нет в сборке; место для истории — CHANGELOG.md"
# Единственное значение поля — true. `accepted-risk`/`skipped` не должны
# вернуться даже как строковые литералы: их некому произвести.
local dead_gate_value
for dead_gate_value in accepted-risk skipped; do
! grep -rqF "DEPENDENCY_SECURITY_GATE=\"${dead_gate_value}\"" tools/build \
|| fail "acceptance: у dependency_security_gate снова появилось значение ${dead_gate_value}"
done
log_step "Acceptance: versions.env is a developer contract, not only a build contract"
# Сборка соблюдала versions.env, а машина разработчика не проверялась никак:
# локальный Go 1.25 собирал проект успешно, пока релизный бинарь собирался на
# 1.26.7 и наследовал ЕЁ stdlib. Проверялся не тот код, который уезжает в
# production.
local doctor_script
for doctor_script in tools/dev/doctor.sh tools/dev/doctor.ps1; do
[ -f "$doctor_script" ] \
|| fail "acceptance: проверка среды разработки $doctor_script отсутствует"
grep -q 'versions.env' "$doctor_script" \
|| fail "acceptance: $doctor_script обязан читать контракт из versions.env, а не носить свои значения"
done
[ -x tools/dev/doctor.sh ] \
|| fail "acceptance: tools/dev/doctor.sh не исполняемый"
# Собственных версий у doctor быть не должно: второй список версий неизбежно
# разъедется с versions.env.
! grep -qE '(GO|NODE|PNPM|BUN)_VERSION[[:space:]]*=[[:space:]]*[0-9]' tools/dev/doctor.sh \
|| fail "acceptance: tools/dev/doctor.sh завёл собственные значения версий"
log_step "Acceptance: the Go toolchain is on a supported line"
# Go компилирует hy2xs-admin, поэтому его stdlib уезжает в production-бинарь.
# На 1.21 govulncheck находил 17 вызываемых уязвимостей в одной только stdlib.
local go_minor
go_minor="$(grep -E '^GO_VERSION=' versions.env | head -n1 | cut -d= -f2 | cut -d. -f2)"
[ "$go_minor" -ge 26 ] \
|| fail "acceptance: GO_VERSION 1.${go_minor} вне поддерживаемой линии Go; production-бинарь унаследует её stdlib"
}
# PHASE 1 принадлежит оркестратору целиком.
run_single_owner_acceptance() {
local package_dir="$1"
log_step "Acceptance: install.sh does not mutate the host at all"
local mutation_hits
mutation_hits="$(grep -nE '^[[:space:]]*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)[[:space:]]' \
"$package_dir/install.sh" || true)"
[ -z "$mutation_hits" ] \
|| fail "acceptance: install.sh must not mutate the host; PHASE 1 belongs to the orchestrator alone: $mutation_hits"
grep -q 'exec .*install --package-dir' "$package_dir/install.sh" \
|| fail "acceptance: install.sh must hand the mutating phase over via exec"
log_step "Acceptance: the orchestrator owns its own bootstrap"
[ -f orchestrator/src/steps/bootstrap.ts ] \
|| fail "acceptance: the bootstrap step module is missing"
grep -q 'ownership.bootstrapTouched' orchestrator/src/commands/install.ts \
|| fail "acceptance: bootstrap must be covered by an ownership flag"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const flagAt = source.indexOf("ownership.bootstrapTouched = true");
const callAt = source.indexOf("await bootstrapRuntime(");
const depsAt = source.indexOf("await installDeps(");
if (flagAt < 0 || callAt < 0) throw new Error("bootstrap step is not wired into install");
if (flagAt > callAt) throw new Error("bootstrapTouched is raised after bootstrapRuntime");
if (callAt > depsAt) throw new Error("bootstrap must run before installDeps");
' || fail "acceptance: bootstrap must be the first owned mutating step"
log_step "Acceptance: bootstrap paths are declared once and are clean-host markers"
grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must take bootstrap paths from the production profile"
grep -qF '/usr/local/bin/hy2xs-orchestrator' orchestrator/src/config/profile.ts \
|| fail "acceptance: the orchestrator symlink path must be declared in the profile"
grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \
|| fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker"
log_step "Acceptance: clean-host is checked once, before the first mutation"
# Регрессия: preflight вызывался дважды и оба раза проверял clean-host.
# Ко второму разу на диске лежал собственный install-state.json, и каждая
# чистая установка падала сразу после apt-get.
! grep -rq 'cleanHostPhase' orchestrator/src \
|| fail "acceptance: the two-tier clean-host phase hack must not come back"
grep -q 'checkCleanHost' orchestrator/src/steps/preflight.ts \
|| fail "acceptance: preflight must take clean-host as an explicit decision"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const enabled = source.split("checkCleanHost: true").length - 1;
const disabled = source.split("checkCleanHost: false").length - 1;
if (enabled !== 1) throw new Error("clean-host must be requested exactly once, got " + enabled);
if (disabled !== 1) throw new Error("the capabilities pass must opt out explicitly");
const at = source.indexOf("checkCleanHost: true");
const state = source.indexOf("await advanceInstallState(");
if (at > state) throw new Error("clean-host is checked after the first install-state write");
' || fail "acceptance: clean-host must be an entry condition, checked exactly once"
log_step "Acceptance: diagnostics never block rollback"
local command_file
for command_file in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
grep -q 'catch (diagnosticsError)' "$command_file" \
|| fail "acceptance: diagnostics must be best effort in $command_file"
done
"$BUN_BIN" -e '
const fs = require("node:fs");
for (const file of [
["orchestrator/src/commands/install.ts", "await rollbackFailedInstall("],
["orchestrator/src/commands/reconfigure.ts", "await rollbackCurrentState()"]
]) {
const source = fs.readFileSync(file[0], "utf8");
const diagnostics = source.indexOf("await diagnosticsCollect(options)");
const guard = source.indexOf("catch (diagnosticsError)");
const rollback = source.indexOf(file[1]);
if (diagnostics < 0 || guard < 0 || rollback < 0) throw new Error("missing markers in " + file[0]);
if (!(diagnostics < guard && guard < rollback)) {
throw new Error("diagnostics is not guarded before rollback in " + file[0]);
}
}
' || fail "acceptance: a diagnostics failure must never cancel the rollback"
log_step "Acceptance: persisting the failure state never blocks the rollback"
# Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше.
# Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она
# падает ровно на заполненном диске и read-only ФС — там, где откат нужнее
# всего. Пока она стояла обычным await, её отказ уносил управление наружу
# мимо снятия firewall и остановки развёрнутых сервисов.
[ -f orchestrator/src/lib/rollback.ts ] \
|| fail "acceptance: модуль обязательного отката отсутствует"
local rollback_command
for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
grep -q 'persistFailureState(' "$rollback_command" \
|| fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort"
done
"$BUN_BIN" -e '
const fs = require("node:fs");
for (const [file, write] of [
["orchestrator/src/commands/install.ts", "await advanceInstallState("],
["orchestrator/src/commands/reconfigure.ts", "await markPhase("]
]) {
const source = fs.readFileSync(file, "utf8");
const handler = source.slice(source.indexOf("} catch (error) {"));
if (handler.length === 0) throw new Error("не найден обработчик ошибки в " + file);
if (handler.includes(write)) {
throw new Error("незащищённая запись состояния отказа в обработчике " + file);
}
const guarded = handler.indexOf("await persistFailureState(");
if (guarded < 0) throw new Error("состояние отказа не обёрнуто в " + file);
}
' || fail "acceptance: отказ записи состояния обязан продолжать откат, а не отменять его"
log_step "Acceptance: rollback stages are independent, not a cancellable chain"
# Каждая стадия отката — systemctl/cp/rm/nft, то есть умеет упасть сама.
# Цепочка `await` означала, что отказ первой отменяет все следующие: в
# reconfigure сервер оставался и с применённым сломанным firewall, и без
# восстановленных из /etc/hy2xs/backups конфигов одновременно.
grep -q 'export async function runRollbackStages' orchestrator/src/lib/rollback.ts \
|| fail "acceptance: у отката нет механизма независимых стадий"
for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
grep -q 'await runRollbackStages(stages)' "$rollback_command" \
|| fail "acceptance: откат в $rollback_command снова выполняется отменяемой цепочкой"
done
# Команды восстановления не имеют права ГЛУШИТЬ собственные ошибки.
#
# Инвариант здесь обратный тому, что стоял раньше. До появления
# 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", "async function readNftablesServiceState"],
// Остановка rollback guard — та же категория. Скан её не покрывал,
// поэтому `systemctl stop ... || true` внутри stopRollbackTimer прожил
// дольше всех остальных заглушённых ошибок отката.
["orchestrator/src/steps/firewall.ts", "async function stopRollbackGuard", "export async function disarmFirewallRollback"],
// null как правая граница означает «до конца файла»: rollbackFirewallNow
// объявлена последней, и придумывать ей искусственный якорь означало бы
// ломать скан при каждой перестановке функций.
["orchestrator/src/steps/firewall.ts", "export async function rollbackFirewallNow", null]
];
for (const [file, from, to] of checks) {
const source = fs.readFileSync(file, "utf8");
const start = source.indexOf(from);
const end = to === null ? source.length : 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 не выделено в отдельную операцию"
# Ищется ОБЪЯВЛЕНИЕ или ВЫЗОВ, то есть имя со скобкой, а не упоминание.
#
# Скан по голой подстроке был сломан с самого своего появления: комментарий у
# `disarmFirewallRollback` объясняет разделение, называя прежнюю функцию по
# имени, и гейт падал бы на нём. Не падал он только потому, что до этого шага
# сборка не доходила — более ранние гейты останавливали её раньше.
! grep -rEq 'cancelFirewallRollback[[:space:]]*\(' 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 '
const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8");
const backup = source.indexOf("await backupCurrentState(context)");
for (const mutation of ["await generateConfig(context)", "await applyFirewall(context)"]) {
const at = source.indexOf(mutation);
if (at < 0) throw new Error("не найден шаг " + mutation);
if (backup > at) throw new Error("копия снимается после мутации " + mutation);
}
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"
# Раньше writeText в install и writeTextAtomic в reconfigure давали одному
# файлу две разные гарантии, причём слабейшую — команде, которая его создаёт.
[ -f orchestrator/src/lib/installStateWriter.ts ] \
|| fail "acceptance: модуль записи маркера установки отсутствует"
for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
grep -q 'await persistInstallState(record)' "$rollback_command" \
|| fail "acceptance: $rollback_command пишет маркер установки мимо единственного писателя"
! grep -q 'writeText(INSTALL_STATE_PATH' "$rollback_command" \
|| fail "acceptance: неатомарная перезапись маркера установки вернулась в $rollback_command"
done
# Долговечность, а не только атомарность: rename без fsync после потери
# питания штатно отдаёт нулевой файл, а маркер — это метаданные восстановления.
grep -q 'await handle.sync()' orchestrator/src/lib/fs.ts \
|| fail "acceptance: временный файл подставляется без fsync данных"
grep -q 'async function syncDirectory' orchestrator/src/lib/fs.ts \
|| fail "acceptance: каталог не синхронизируется после подстановки"
# Владелец обязан выставляться ДО подстановки: иначе существует окно, в
# котором файл уже виден по целевому пути с чужими правами.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8");
const start = source.indexOf("export async function writeTextAtomic");
if (start < 0) throw new Error("writeTextAtomic отсутствует");
const body = source.slice(start);
const chown = body.indexOf("chownByName(tmp");
const sync = body.indexOf("await handle.sync()");
const rename = body.indexOf("await rename(tmp, path)");
if (chown < 0 || sync < 0 || rename < 0) throw new Error("не найдены шаги атомарной записи");
if (!(chown < sync && sync < rename)) {
throw new Error("порядок обязан быть: права/владелец -> fsync -> rename");
}
' || fail "acceptance: атомарная запись обязана выставлять владельца и синхронизировать до подстановки"
# Каждый production-вызов обязан объявлять владельца явно: параметр
# необязателен только ради тестов, которые пишут во временный каталог.
"$BUN_BIN" -e '
const fs = require("node:fs");
const path = require("node:path");
const offenders = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
if (!entry.name.endsWith(".ts")) continue;
const source = fs.readFileSync(full, "utf8");
// Ищутся ВЫЗОВЫ, а не объявление: у самой функции параметр owner
// необязателен, потому что тесты пишут во временный каталог, где
// выставить root:root нельзя.
let at = source.indexOf("await writeTextAtomic(");
while (at >= 0) {
const call = source.slice(at, at + 400);
if (!call.includes("owner:")) offenders.push(full);
at = source.indexOf("await writeTextAtomic(", at + 1);
}
}
};
walk("orchestrator/src");
if (offenders.length) {
throw new Error("вызовы без владельца: " + [...new Set(offenders)].join(", "));
}
' || fail "acceptance: production-запись обязана объявлять владельца файла"
log_step "Acceptance: reconfigure classifies by ownership, not by message text"
! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure must not classify failures by matching the error text"
grep -q 'classifyReconfigureFailure(ownership)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure failure classification must be ownership-driven"
}
# Каналы утечки секретов: Config API и журналы.
run_secret_channel_acceptance() {
log_step "Acceptance: config API is an allowlist, not a denylist"
grep -q 'IsPublicReadableConfigKey' apps/controller/config.go \
|| fail "acceptance: config reads must go through the allowlist"
grep -q 'IsPublicWritableConfigKey' apps/controller/config.go \
|| fail "acceptance: config writes must go through the allowlist"
# Ищется регистрация и вызов, а не имя: router_test.go обязан УПОМИНАТЬ
# getConfig — он проверяет, что маршрут не вернулся.
! grep -rq 'controller.GetConfig' apps/router \
|| fail "acceptance: the arbitrary-key getConfig route came back"
! grep -q 'func GetConfig(' apps/controller/config.go \
|| fail "acceptance: the arbitrary-key getConfig handler came back"
! grep -rq 'export function getConfigApi' apps/frontend/src \
|| fail "acceptance: the frontend client for getConfig came back"
! grep -rq 'getConfigApi(' apps/frontend/src \
|| fail "acceptance: something still calls the removed getConfig client"
local secret_key
for secret_key in JwtSecret PeerSecretKey PeerSecretEncryptionKey Hysteria2TrafficStatsSecret; do
grep -q "${secret_key}," apps/model/constant/config.go \
|| fail "acceptance: ${secret_key} must be declared an internal config key"
done
# Секреты не имеют права оказаться в allowlist ни на чтение, ни на запись.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/model/constant/config.go", "utf8");
const start = source.indexOf("publicReadableConfigKeys");
const end = source.indexOf("func IsPublicReadableConfigKey");
const body = source.slice(start, end);
for (const key of ["JwtSecret", "PeerSecretKey", "PeerSecretEncryptionKey", "Hysteria2TrafficStatsSecret", "Hysteria2Config"]) {
if (body.includes(key + ":")) throw new Error(key + " is in the public allowlist");
}
' || fail "acceptance: no secret key may appear in the public config allowlist"
log_step "Acceptance: request logging never carries query values"
# Проверяется КОД, а не упоминание: комментарий, объясняющий, почему логгер
# больше не пишет RequestURI, обязан быть разрешён. Поэтому строки
# комментариев отбрасываются до поиска.
local log_code server_code
log_code="$(grep -v '^[[:space:]]*//' apps/middleware/log.go || true)"
server_code="$(grep -v '^[[:space:]]*//' apps/cmd/server.go || true)"
grep -q 'c.Request.URL.Path' <<<"$log_code" \
|| fail "acceptance: the request logger must log the path, not RequestURI"
! grep -q 'RequestURI' <<<"$log_code" \
|| fail "acceptance: RequestURI carries the machine token in its query string"
! grep -rq 'ReqUri' apps/model/vo apps/frontend/src/api \
|| fail "acceptance: the reqUri log field came back"
log_step "Acceptance: the admin has exactly one HTTP log channel"
! grep -q 'gin.Default()' <<<"$server_code" \
|| fail "acceptance: gin.Default() logs the query string to stdout and then to journald"
grep -q 'gin.New()' <<<"$server_code" \
|| fail "acceptance: the admin engine must be built with gin.New()"
grep -q 'gin.Recovery()' <<<"$server_code" \
|| fail "acceptance: gin.New() must still install the recovery middleware"
log_step "Acceptance: exported logs are sanitized on both sides"
grep -q 'SanitizeLogText' apps/service/journal.go \
|| fail "acceptance: exported Hysteria journal must be sanitized"
grep -q 'redactLogText' orchestrator/src/commands/diagnostics.ts \
|| fail "acceptance: diagnostics bundle must sanitize collected journals"
grep -q 'journal-admin.log' orchestrator/src/commands/diagnostics.ts \
|| fail "acceptance: the admin journal must be part of the sanitized set"
log_step "Acceptance: machine token comparison is constant time"
grep -q 'subtle.ConstantTimeCompare' apps/middleware/machine_auth.go \
|| fail "acceptance: the machine token must be compared in constant time"
}
# Импорт пиров: одна транзакция и ровно один JSON-документ.
run_atomic_import_acceptance() {
log_step "Acceptance: peer import is a single database transaction"
grep -q 'WithPeerTx' apps/service/peer.go \
|| fail "acceptance: peer import must apply the whole batch in one transaction"
grep -q 'func WithPeerTx' apps/dao/peer.go \
|| fail "acceptance: the peer transaction boundary is missing from the dao layer"
grep -q 'sqliteDB.Transaction' apps/dao/peer.go \
|| fail "acceptance: WithPeerTx must open a real database transaction"
# Применение обязано идти по tx, а не по глобальному соединению.
#
# Регрессия приёмки. Здесь стояло `source.slice(start)` — то есть «весь файл
# от начала applyPeerImportEntry и ДО КОНЦА». В этот кусок попадали все
# функции, объявленные ниже, а среди них ExistPeerName (dao.GetPeer) и
# UpdatePeerLastConnectionAt (dao.UpdatePeer) — обычные операции вне импорта,
# которым глобальное соединение положено. Проверка гарантированно падала на
# корректном коде и не была замечена только потому, что сборка до неё не
# доходила: раньше неё падал versions contract на собственном скане '/hui'.
#
# Границей тела функции служит следующее объявление верхнего уровня.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
const start = source.indexOf("func applyPeerImportEntry");
if (start < 0) throw new Error("applyPeerImportEntry is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = end < 0 ? rest : rest.slice(0, end);
for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) {
if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call);
}
' || fail "acceptance: peer import must not bypass the transaction"
log_step "Acceptance: peer import accepts exactly one JSON document"
grep -q 'io.EOF' apps/controller/peer.go \
|| fail "acceptance: peer import must verify that nothing follows the JSON document"
grep -q 'exactly one JSON document' apps/controller/peer.go \
|| fail "acceptance: the trailing-document refusal must be explicit"
log_step "Acceptance: peer export offers both modes in the UI"
grep -q 'includeSecrets' apps/frontend/src/api/peer/index.ts \
|| fail "acceptance: the UI must be able to request a secrets-bearing backup"
grep -q 'handleExportBackup' apps/frontend/src/views/peer/list/index.vue \
|| fail "acceptance: the backup export button is missing"
grep -q 'exportBackupConfirm' apps/frontend/src/views/peer/list/index.vue \
|| fail "acceptance: a secrets-bearing export must require explicit confirmation"
local locale
for locale in ru en; do
grep -q 'exportBackupConfirm' "apps/frontend/src/lang/package/${locale}.ts" \
|| fail "acceptance: the backup warning is missing from the ${locale} locale"
done
}
# Границы транзакции операции: снятие guard'а и взаимное исключение операций.
run_transaction_boundary_acceptance() {
log_step "Acceptance: disarming the firewall guard is proven, not announced"
# Снятие автоматического отката было утверждением, а не фактом:
#
# systemctl stop <unit>.timer <unit>.service || true
# -> "firewall rollback timer disarmed"
# -> phase=installed
#
# Отказ остановки стирался, и взведённый таймер мог вернуть прежний firewall
# уже после долговечной записи успеха.
grep -q 'export function buildAutoRollbackScript' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: скрипт автоотката снова собирается на месте вместо отдельной проверяемой функции"
grep -q 'auto-rollback-fired' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: маркер срабатывания guard'а отсутствует"
! grep -q 'stopRollbackTimer' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: вернулась stopRollbackTimer, снимавшая guard без доказательства"
grep -q 'export async function runMutatingStatus' orchestrator/src/lib/process.ts \
|| fail "acceptance: нет раннера, возвращающего код возврата вместо броска; снятие guard'а снова требует '|| true'"
# Скрипт автоотката проверяется как ТЕКСТ: скан заглушённых ошибок смотрит
# только на строки с runMutatingVisible и до содержимого скрипта не достаёт.
"$BUN_BIN" -e '
const { buildAutoRollbackScript } = await import("./orchestrator/src/steps/firewall.ts");
const script = buildAutoRollbackScript("2026-01-01T00-00-00.000Z");
const marker = script.indexOf("auto-rollback-fired");
const prepared = script.indexOf("$root/prepared");
if (marker < 0 || prepared < 0) throw new Error("в скрипте нет маркера срабатывания или проверки prepared");
if (marker > prepared) throw new Error("маркер срабатывания создаётся не первым действием");
for (const masked of ["|| true", "2>/dev/null", ">/dev/null 2>&1"]) {
if (script.includes(masked)) throw new Error("скрипт автоотката снова маскирует ошибки: " + masked);
}
if (!script.includes("exit \"$rc\"")) throw new Error("скрипт автоотката не возвращает накопленный код");
const failures = script.split("auto-rollback: failed").length - 1;
const raised = script.split("rc=1").length - 1;
if (failures === 0 || failures !== raised) {
throw new Error("не каждый сообщённый отказ поднимает код возврата: " + failures + " != " + raised);
}
// Создание маркера обязано входить в учёт rc. Инвариант фиксации
// "маркера нет и юниты inactive => guard не сработал" верен только при
// условии, что guard способен маркер записать. Пока rc=0 стояло ПОСЛЕ
// создания маркера, отказ записи не влиял ни на что: скрипт восстанавливал
// firewall, завершался нулём, и операция фиксировала успех после реально
// сработавшего отката.
const rcInit = script.indexOf("rc=0");
if (rcInit < 0 || rcInit > marker) throw new Error("rc объявляется после создания маркера");
if (script.includes("exit 0")) throw new Error("ранний выход теряет накопленный код возврата");
if (!script.includes("exit \"$rc\"")) throw new Error("ранний выход не возвращает rc");
// touch, а не `: >file`: двоеточие — special builtin POSIX, и ошибка
// перенаправления на нём обязана завершить неинтерактивный shell целиком.
// В dash, который на Debian и есть /bin/sh, скрипт умер бы ДО
// восстановления firewall.
if (script.includes(": >\"$root/auto-rollback-fired\"")) {
throw new Error("маркер создаётся перенаправлением на special builtin");
}
// ExecStop у nftables.service делает `nft flush ruleset`: остановка сервиса
// внутри guard стёрла бы только что восстановленные правила.
if (script.includes("systemctl")) throw new Error("guard трогает состояние nftables.service");
' || fail "acceptance: скрипт автоматического отката firewall нарушает свой контракт"
# Снятие guard'а обязано опираться на наблюдаемое состояние юнитов и на
# маркер, а не на код возврата systemctl: для уже отработавшего транзиентного
# юнита `systemctl stop` возвращает 5 — исход, неотличимый от успеха.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const start = source.indexOf("async function stopRollbackGuard");
const end = source.indexOf("export async function disarmFirewallRollback");
if (start < 0 || end < 0) throw new Error("не найдена функция снятия guard'"'"'а");
const body = source.slice(start, end);
const before = body.indexOf("before stopping the rollback guard");
const stop = body.indexOf("runMutatingStatus`systemctl stop");
const after = body.indexOf("after stopping the rollback guard");
if (before < 0 || stop < 0 || after < 0) throw new Error("маркер проверяется не с обеих сторон остановки");
if (!(before < stop && stop < after)) throw new Error("порядок обязан быть маркер -> stop -> маркер");
if (!body.includes("readUnitProperty(target, \"ActiveState\")")) {
throw new Error("снятие guard'"'"'а не подтверждается состоянием юнитов");
}
if (!source.includes("const GUARD_STOPPED_STATES_FOR_COMMIT = [\"inactive\"] as const")) {
throw new Error("на пути фиксации успеха допустимо не только inactive");
}
' || fail "acceptance: снятие guard'а обязано быть доказуемым"
log_step "Acceptance: a fired guard forbids the durable commit"
# Классификация по ТИПУ ошибки, а не по её тексту: разбор сообщения
# регулярным выражением уже убирали и из install, и из reconfigure.
grep -q 'export class FirewallGuardFiredError' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: сработавший guard не имеет собственного типа ошибки"
grep -q 'error instanceof FirewallGuardFiredError' orchestrator/src/commands/install.ts \
|| fail "acceptance: install не отличает сработавший guard от прочих отказов"
grep -q 'error instanceof FirewallGuardFiredError' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure не отличает сработавший guard от прочих отказов"
grep -q 'classifyFailure(ownership, phase, error)' orchestrator/src/commands/install.ts \
|| fail "acceptance: классификация отказа install не получает саму ошибку"
log_step "Acceptance: smoke verifies the effective firewall, not only its syntax"
# `nft -c` разбирает текущий файл, каким бы он ни был. Откатившийся прежний
# ruleset проходил эту проверку зелёным.
grep -q 'assertEffectiveFirewallIsOurs' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: smoke не сверяет эффективный firewall с конфигурацией операции"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
const syntax = source.indexOf("nft -c -f /etc/nftables.conf");
const semantic = source.indexOf("assertEffectiveFirewallIsOurs(context)");
if (syntax < 0 || semantic < 0) throw new Error("не найдены проверки firewall в smoke");
if (semantic < syntax) throw new Error("семантическая проверка идёт раньше разбора файла");
const firewall = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const start = firewall.indexOf("export async function assertEffectiveFirewallIsOurs");
const end = firewall.indexOf("function firewallRollbackIsInactive");
const body = firewall.slice(start, end);
for (const claim of ["await renderHy2xsNft(context)", "hy2xs-managed", "nft list table inet hy2xs"]) {
if (!body.includes(claim)) throw new Error("проверка эффективного firewall не сверяет: " + claim);
}
// Проверка выполняется и в doctor, то есть под read-only guard.
if (/runMutating[A-Za-z]*[`(]/.test(body)) throw new Error("проверка эффективного firewall мутирует систему");
' || fail "acceptance: проверка эффективного firewall нарушает свой контракт"
log_step "Acceptance: firewall candidates and nftables.service state do not outlive the operation"
grep -q 'async function cleanupFirewallCandidates' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: candidate-файлы firewall снова остаются на диске навсегда"
grep -q 'renderNftablesServiceState' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: состояние nftables.service не сохраняется и не восстанавливается откатом"
"$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 unitFile = body.indexOf("restore nftables.service unit file state");
const inactive = body.indexOf("restore nftables.service inactive state");
const ruleset = body.indexOf("apply restored ruleset");
if (unitFile < 0 || inactive < 0 || ruleset < 0) throw new Error("не найдены стадии восстановления сервиса");
// ExecStop у nftables.service делает `nft flush ruleset`, поэтому
// восстановление состояния сервиса обязано идти ДО применения ruleset.
if (!(unitFile < inactive && inactive < ruleset)) {
throw new Error("остановка сервиса идёт после применения ruleset и сотрёт его");
}
' || fail "acceptance: порядок восстановления firewall стирает восстановленные правила"
log_step "Acceptance: lifecycle operations are serialized by an exclusive lock"
# install-state.json замком не является: два одновременных reconfigure писали
# одни и те же production paths, а уникальные op-id разделяли только копии.
[ -f orchestrator/src/lib/operationLock.ts ] \
|| fail "acceptance: модуль взаимного исключения операций отсутствует"
grep -q '/run/lock/hy2xs-orchestrator.lock' orchestrator/src/lib/operationLock.ts \
|| fail "acceptance: замок операций не объявлен в /run/lock"
grep -q 'assertMutationAllowed' orchestrator/src/lib/operationLock.ts \
|| fail "acceptance: захват замка не спрашивает разрешения у read-only guard'а"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/cli.ts", "utf8");
for (const command of ["install", "reconfigure", "repair", "doctor"]) {
if (!source.includes("await withOperationLock(\"" + command + "\", () => " + command + "(options))")) {
throw new Error("команда выполняется без замка: " + command);
}
if (source.includes("await " + command + "(options)")) {
throw new Error("команда вызывается мимо замка: " + command);
}
}
// Отказ обязан произойти до первой проверки PHASE 0, а не после exec.
const assertAt = source.indexOf("await assertNoOperationInProgress(\"install preflight\")");
const preflightAt = source.indexOf("await preflightInstall(options)");
if (assertAt < 0 || preflightAt < 0) throw new Error("PHASE 0 не проверяет идущую операцию");
if (assertAt > preflightAt) throw new Error("PHASE 0 проверяет замок после собственных проверок");
// status и diagnostics нужны в том числе во время долгой операции.
for (const observer of ["status", "diagnostics"]) {
if (source.includes("withOperationLock(\"" + observer + "\"")) {
throw new Error("наблюдающая команда берёт эксклюзивный замок: " + observer);
}
}
if (!source.includes("await describeOperationInProgress()")) {
throw new Error("наблюдающие команды не сообщают об идущей операции");
}
' || fail "acceptance: политика взаимного исключения операций нарушена"
# Замок обязан сниматься при любом завершении: обрыв SSH (SIGHUP) не имеет
# права заблокировать сервер до перезагрузки.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/lib/operationLock.ts", "utf8");
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
if (!source.includes(signal)) throw new Error("замок не снимается по сигналу " + signal);
}
if (!source.includes("process.on(\"exit\"")) throw new Error("замок не снимается при выходе процесса");
if (!source.includes("} finally {")) throw new Error("замок снимается не в finally");
// Снятие замка мёртвого держателя обязано идти через увод файла со сверкой
// nonce: unlink на месте способен снять уже живой замок.
if (!source.includes("async function reclaimStaleLock")) {
throw new Error("нет безопасного переиспользования замка мёртвого держателя");
}
if (!source.includes("stolen.nonce !== observed.nonce")) {
throw new Error("переиспользование замка не сверяет, что уводит именно мёртвый замок");
}
' || fail "acceptance: жизненный цикл замка операций нарушен"
log_step "Acceptance: a lifecycle operation waits for the previous one to become quiescent"
# Стык двух защитных механизмов. Замок защищает production paths, пока жив
# процесс-держатель; rollback guard firewall — отдельный systemd-объект,
# переживающий свой процесс. Аварийно умершая операция оставляла вооружённый
# guard, который возвращал прежний firewall уже посреди следующей операции.
# Уникальные op-id тут не помогают: каталоги копий разные, а
# /etc/nftables.conf и ruleset в ядре общие.
grep -q 'export async function assertNoPendingRollbackGuard' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: нет барьера покоя перед новой операцией жизненного цикла"
grep -q 'export class PendingRecoveryError' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: у отказа по незавершённому восстановлению нет собственного типа"
# «Guard вооружён» и «состояние guard'а неизвестно» — разные утверждения, и
# оператору по ним нужны разные действия: подождать окно отката либо чинить
# systemd. Один тип на оба означал бы, что различие существует только в тексте.
grep -q 'export class GuardStateUnknownError' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: у недоказуемого состояния guard'а нет собственного типа"
"$BUN_BIN" -e '
const fs = require("node:fs");
const cli = fs.readFileSync("orchestrator/src/cli.ts", "utf8");
if (!cli.includes("{ barrier: assertNoPendingRollbackGuard }")) {
throw new Error("захват замка идёт без барьера покоя");
}
// Единственное употребление withOperationLock — внутри
// runLifecycleOperation: иначе появился бы путь захвата мимо барьера.
const direct = cli.split("withOperationLock(").length - 1;
if (direct !== 1) throw new Error("замок берётся мимо runLifecycleOperation: " + direct + " употреблений");
if (!cli.includes("await assertNoPendingRollbackGuard()")) {
throw new Error("PHASE 0 не проверяет вооружённый guard предыдущей операции");
}
// Барьер обязан вызываться ДО захвата и ещё раз ПОСЛЕ: между этими
// моментами умирающая предыдущая операция успевает вооружить guard.
const lock = fs.readFileSync("orchestrator/src/lib/operationLock.ts", "utf8");
const acquire = lock.slice(lock.indexOf("export async function acquireOperationLock"));
const before = acquire.indexOf("await options.barrier?.()");
const write = acquire.indexOf("await writeLockFile(path, record)");
const after = acquire.indexOf("await options.barrier()");
if (before < 0 || write < 0 || after < 0) throw new Error("барьер проверяется не с обеих сторон захвата");
if (!(before < write && write < after)) throw new Error("порядок обязан быть барьер -> захват -> барьер");
if (!acquire.slice(after).includes("releaseSync(path, record.nonce)")) {
throw new Error("отказ второй проверки оставляет замок за собой");
}
// Покой перечисляется БЕЛЫМ списком. Чёрный объявлял безопасным любое
// состояние, которого автор не назвал, — включая те, которых он не знал:
// systemd 257 знает maintenance и refreshing помимо перечислявшихся.
const firewall = fs.readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
if (!firewall.includes("const GUARD_QUIESCENT_ACTIVE_STATES = [\"inactive\", \"failed\"] as const")) {
throw new Error("политика покоя guard изменилась без пересмотра барьера");
}
if (firewall.includes("GUARD_PENDING_STATES")) {
throw new Error("вернулся чёрный список состояний: неизвестное состояние снова считается покоем");
}
// Отказ запроса к systemd — отсутствие наблюдения, а не наблюдение покоя.
// Прежний код возвращал пустой список, то есть принимал невозможность
// получить доказательство за положительный результат.
const inspectStart = firewall.indexOf("export async function inspectRollbackGuard");
const inspectEnd = firewall.indexOf("export async function assertNoPendingRollbackGuard");
if (inspectStart < 0 || inspectEnd < inspectStart) {
throw new Error("нет единого наблюдателя состояния guard перед барьером");
}
const inspect = firewall.slice(inspectStart, inspectEnd);
if (inspect.includes("return [];")) {
throw new Error("барьер снова fail-open при отказе systemctl");
}
for (const outcome of ["kind: \"unknown\"", "kind: \"pending\"", "kind: \"quiescent\""]) {
if (!inspect.includes(outcome)) throw new Error("наблюдение не различает исход: " + outcome);
}
// Второй копии листинга быть не должно: status расходился с барьером по
// --plain, по `|| true` и по трактовке failed-юнита.
const status = fs.readFileSync("orchestrator/src/commands/status.ts", "utf8");
if (status.includes("list-units")) {
throw new Error("status снова листит guard-юниты сам, мимо барьерного наблюдателя");
}
if (!status.includes("await inspectRollbackGuard()")) {
throw new Error("status не берёт состояние guard у общего наблюдателя");
}
' || fail "acceptance: барьер покоя между операциями нарушен"
log_step "Acceptance: the transient guard timer states its window and its cleanup"
# `OnActiveSec=` не означает «ровно через столько»: systemd.timer разрешает
# себе сработать в окне [цель; цель + AccuracySec], а умолчание — 1min. Guard,
# про который README и docs говорят «45 секунд», без явного значения имел
# контракт «от 45 до 105». RemainAfterElapse задаётся по другой причине: от
# выгрузки отработавшего таймера зависит право барьера считать его отсутствие
# покоем, и держать этот инвариант на чужом умолчании нельзя.
"$BUN_BIN" -e '
const { buildArmGuardArgv } = await import("./orchestrator/src/steps/firewall.ts");
const argv = buildArmGuardArgv("2026-01-01T00-00-00.000Z");
for (const expected of [
"--unit=hy2xs-fw-rollback-2026-01-01T00-00-00.000Z.service",
"--on-active=45s",
"--timer-property=RemainAfterElapse=no",
"--timer-property=AccuracySec=1s"
]) {
if (!argv.includes(expected)) throw new Error("взведение guard не задаёт " + expected);
}
// Ключ операции подставляется в имя юнита и в путь скрипта; shell в этой
// команде не участвует, поэтому единственная защита — отказ.
let rejected = false;
try { buildArmGuardArgv("op id"); } catch { rejected = true; }
if (!rejected) throw new Error("небезопасный ключ операции принят взведением guard");
' || fail "acceptance: контракт транзиентного таймера guard нарушен"
log_step "Acceptance: nftables.service restore claims only what it can guarantee"
# `enable --runtime` не удаляет постоянную ссылку, поэтому "восстановление"
# enabled-runtime таким вызовом обещало точность, которой не давало.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const body = source.slice(source.indexOf("restore nftables.service unit file state"));
const end = body.indexOf("restore nftables.service inactive state");
const stage = body.slice(0, end);
for (const guessed of ["enable --runtime", "systemctl mask"]) {
if (stage.includes(guessed)) {
throw new Error("восстановление UnitFileState снова обещает недостижимую точность: " + guessed);
}
}
if (!stage.includes("case \"enabled\":") || !stage.includes("case \"disabled\":")) {
throw new Error("не восстанавливаются состояния, которые операция реально меняет");
}
if (!stage.includes("is left as is")) {
throw new Error("невосстановимое состояние не называется оператору явно");
}
' || fail "acceptance: восстановление состояния nftables.service нарушает свой контракт"
log_step "Acceptance: the operation key has a single source"
# install писал в маркер сырой ISO-timestamp с двоеточиями, а каталог отката
# назывался санитизированным ключом: путь из runbook не существовал.
grep -q 'export function operationKeyFor' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: ключ операции не объявлен единственной функцией"
grep -q 'opId: operationKeyFor(context.installDate)' orchestrator/src/commands/install.ts \
|| fail "acceptance: install снова пишет в маркер собственный вариант ключа операции"
# Формула живёт ровно в одном файле — там, где объявлена operationKeyFor.
local key_formula
key_formula="$(grep -rl 'installDate.replace(' orchestrator/src \
| grep -v 'orchestrator/src/steps/firewall.ts' || true)"
[ -z "$key_formula" ] \
|| fail "acceptance: формула ключа операции продублирована в: $key_formula"
}
# Compatibility-слой предыдущего поколения не должен пережить 1.0.
run_legacy_account_acceptance() {
log_step "Acceptance: the legacy account layer is gone from the runtime"
[ ! -f apps/model/entity/account.go ] \
|| fail "acceptance: the LegacyAccount entity came back"
# Каталоги перечислены явно: рекурсия по apps захватила бы
# apps/frontend/node_modules, который к этому шагу сборки уже существует.
! grep -rq 'LegacyAccount' apps/dao apps/model apps/service apps/controller apps/cmd \
|| fail "acceptance: legacy account migration code came back"
# Ищется запись в списке миграций (имя в кавычках), а не упоминание:
# комментарий, объясняющий, почему миграции удалены, должен быть разрешён.
local dead_migration
for dead_migration in 002_migrate_legacy_accounts 003_archive_legacy_account; do
! grep -qF "\"${dead_migration}\"" apps/dao/sqlite.go \
|| fail "acceptance: legacy migration ${dead_migration} came back"
done
# Номера оставшихся миграций не перенумеровываются: они уже записаны в
# schema_migrations на установленных машинах.
local kept_migration
for kept_migration in 000_base_config 001_admin_peer_split 004_traffic_samples_and_aggregates 005_metric_sample; do
grep -qF "\"${kept_migration}\"" apps/dao/sqlite.go \
|| fail "acceptance: migration ${kept_migration} disappeared or was renumbered"
done
log_step "Acceptance: v1 docs carry no previous-generation vocabulary"
# docs/14 — единственное место, где эти имена обозначают реальные объекты
# для удаления. В обычных docs их быть не должно.
local doc
for doc in docs/*.md; do
case "$doc" in
docs/14-legacy-cleanup.md) continue ;;
esac
! grep -q 'H_UI_' "$doc" \
|| fail "acceptance: previous-generation config keys leaked into $doc"
done
}