feat(tools): purge-v0 и acceptance-проверки политики clean-install-only

Раз v1 принципиально не мигрирует состояние 0.x, политика должна быть
операционно завершённой: у оператора обязан быть явный способ привести
сервер в состояние, которое установщик примет.

tools/legacy/purge-v0.sh делает это отдельной осознанной операцией:

- по умолчанию печатает план и НЕ меняет ничего;
- выполнение требует --apply вместе с --yes-i-know;
- снимает таймеры отката firewall hy2xs-fw-rollback-*, которые
  переживают неудачную установку и иначе продолжили бы менять ruleset
  уже после очистки;
- из /etc/nftables.conf убирает только include HY2XS: остальной
  ruleset принадлежит оператору;
- в конце проверяет чистоту хоста по тому же контракту, что и
  установщик.

Из install.sh он не вызывается никогда: встроенная очистка вернула бы
destructive migration logic обратно в путь свежей установки - ровно то,
от чего мы ушли.

Acceptance-набор дополнен проверками, которые не дают инвариантам
тихо развалиться: порядок фаз в install.sh, наличие read-only guard,
preflight раньше первой записи install-state, ownership-aware rollback,
отказ по отсутствующей схеме, проверка поколения в reconfigure/repair,
структурная редакция, отсутствие удалённых маршрутов, e2e на
production-генераторе, сверка с upstream hashes.txt, контрольные суммы
в versions.env, версия админки из контракта.
This commit is contained in:
2026-08-27 12:16:22 +05:00
parent 920a78fdae
commit 42db78c6a0
2 changed files with 353 additions and 0 deletions
+105
View File
@@ -134,4 +134,109 @@ run_fix20_acceptance_subset() {
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: 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"
# Ищется регистрация маршрута (имя в кавычках), а не любое упоминание:
# комментарий, объясняющий, почему маршрута нет, должен быть разрешён.
local dead_route
for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath; do
! grep -rqF "${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"
}
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env bash
set -euo pipefail
# Очистка сервера от установки HY2XS предыдущего поколения (0.x) и от
# незавершённой установки v1.
#
# Почему это отдельный скрипт, а не шаг установщика:
#
# HY2XS v1 принципиально не поддерживает миграцию. Встроенная в installer
# очистка вернула бы destructive migration logic обратно в путь свежей
# установки — ровно то, от чего мы ушли. Установщик обязан обнаружить старую
# установку, НИЧЕГО не изменить и отправить оператора сюда.
#
# Скрипт разрушительный. По умолчанию он ничего не делает: показывает план.
#
# sudo ./purge-v0.sh # план (dry-run)
# sudo ./purge-v0.sh --apply --yes-i-know # выполнить
#
# ВАЖНО: скрипт удаляет базу админки вместе с учётными записями пиров.
# Ссылки, выданные пользователям, перестанут работать.
APPLY="false"
CONFIRMED="false"
KEEP_HYSTERIA_BINARY="false"
log() { printf '[hy2xs-purge] %s\n' "$*"; }
warn() { printf '[hy2xs-purge] WARNING: %s\n' "$*" >&2; }
fail() { printf '[hy2xs-purge] ERROR: %s\n' "$*" >&2; exit 1; }
usage() {
cat >&2 <<'EOF'
Usage: purge-v0.sh [--apply --yes-i-know] [--keep-hysteria-binary]
(без флагов) показать план очистки и выйти
--apply выполнить очистку
--yes-i-know подтверждение: обязателен вместе с --apply
--keep-hysteria-binary не удалять /usr/local/bin/hysteria
Скрипт удаляет службы, приложение, конфигурацию и БАЗУ ДАННЫХ админки.
EOF
exit 2
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--apply) APPLY="true"; shift ;;
--yes-i-know) CONFIRMED="true"; shift ;;
--keep-hysteria-binary) KEEP_HYSTERIA_BINARY="true"; shift ;;
-h|--help) usage ;;
*) fail "unknown argument: $1" ;;
esac
done
if [ "$APPLY" = "true" ] && [ "$CONFIRMED" != "true" ]; then
fail "--apply requires an explicit --yes-i-know confirmation"
fi
}
UNITS=(
hysteria-server.service
hy2xs-admin.service
h-ui.service
)
UNIT_FILES=(
/etc/systemd/system/hysteria-server.service
/etc/systemd/system/hy2xs-admin.service
/etc/systemd/system/h-ui.service
)
PATHS=(
/etc/hy2xs
/etc/hysteria
/var/lib/hy2xs
/var/lib/hy2xs-admin
/var/lib/hysteria
/var/log/hy2xs
/opt/hy2xs-admin
/usr/local/lib/hy2xs
/usr/local/h-ui
/usr/local/bin/hy2xs-orchestrator
)
NFT_FRAGMENT=/etc/nftables.d/hy2xs.nft
require_root() {
[ "$(id -u)" = "0" ] || fail "purge-v0.sh must run as root"
}
# Таймеры отката firewall переживают неудачную установку и продолжат менять
# ruleset уже после очистки, если их не снять.
rollback_guard_units() {
systemctl list-units --all --no-legend 'hy2xs-fw-rollback-*.timer' 'hy2xs-fw-rollback-*.service' 2>/dev/null \
| awk '{print $1}' \
| grep -E '^hy2xs-fw-rollback-' || true
}
show_plan() {
log "будут остановлены и выключены службы:"
local unit
for unit in "${UNITS[@]}"; do
printf ' - %s\n' "$unit"
done
local guard
guard="$(rollback_guard_units)"
if [ -n "$guard" ]; then
log "будут сняты таймеры отката firewall:"
printf ' - %s\n' $guard
fi
log "будут удалены пути:"
local path
for path in "${UNIT_FILES[@]}" "${PATHS[@]}" "$NFT_FRAGMENT"; do
if [ -e "$path" ]; then
printf ' - %s (существует)\n' "$path"
else
printf ' - %s (отсутствует)\n' "$path"
fi
done
if [ "$KEEP_HYSTERIA_BINARY" = "true" ]; then
log "/usr/local/bin/hysteria будет сохранён (--keep-hysteria-binary)"
else
log "будет удалён /usr/local/bin/hysteria"
fi
log "из /etc/nftables.conf будет убрана строка include для $NFT_FRAGMENT"
warn "БАЗА ДАННЫХ админки (/var/lib/hy2xs-admin) удаляется: пиры и их ссылки будут потеряны"
warn "sshd, сеть и правила firewall вне фрагмента HY2XS не изменяются"
}
stop_units() {
local unit
for unit in "${UNITS[@]}"; do
systemctl stop "$unit" >/dev/null 2>&1 || true
systemctl disable "$unit" >/dev/null 2>&1 || true
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
done
local guard
guard="$(rollback_guard_units)"
if [ -n "$guard" ]; then
local guard_unit
for guard_unit in $guard; do
systemctl stop "$guard_unit" >/dev/null 2>&1 || true
systemctl disable "$guard_unit" >/dev/null 2>&1 || true
systemctl reset-failed "$guard_unit" >/dev/null 2>&1 || true
rm -f "/etc/systemd/system/$guard_unit"
done
fi
}
remove_paths() {
local path
for path in "${UNIT_FILES[@]}"; do
rm -f "$path"
done
systemctl daemon-reload || true
for path in "${PATHS[@]}"; do
rm -rf "$path"
done
if [ "$KEEP_HYSTERIA_BINARY" != "true" ]; then
rm -f /usr/local/bin/hysteria
fi
}
# Из /etc/nftables.conf убирается только include HY2XS: остальной ruleset
# принадлежит оператору и удалению не подлежит.
detach_nftables_fragment() {
rm -f "$NFT_FRAGMENT"
if [ -f /etc/nftables.conf ] && grep -q 'nftables.d/hy2xs.nft' /etc/nftables.conf; then
local tmp
tmp="$(mktemp)"
grep -v 'nftables.d/hy2xs.nft' /etc/nftables.conf >"$tmp"
cat "$tmp" >/etc/nftables.conf
rm -f "$tmp"
log "убрана строка include HY2XS из /etc/nftables.conf"
fi
if command -v nft >/dev/null 2>&1 && [ -f /etc/nftables.conf ]; then
if nft -c -f /etc/nftables.conf >/dev/null 2>&1; then
nft -f /etc/nftables.conf >/dev/null 2>&1 || warn "не удалось перезагрузить /etc/nftables.conf"
else
warn "/etc/nftables.conf не проходит проверку nft -c; ruleset не перезагружен"
fi
fi
}
# Финальная проверка повторяет clean-host контракт установщика: если она не
# прошла, следующая установка всё равно откажется, и лучше узнать об этом здесь.
verify_clean_host() {
local leftovers=()
local path
for path in "${UNIT_FILES[@]}" "${PATHS[@]}" "$NFT_FRAGMENT"; do
[ -e "$path" ] && leftovers+=("$path")
done
if [ "$KEEP_HYSTERIA_BINARY" != "true" ] && [ -e /usr/local/bin/hysteria ]; then
leftovers+=(/usr/local/bin/hysteria)
fi
local unit
for unit in "${UNITS[@]}"; do
if systemctl list-unit-files --no-legend "$unit" 2>/dev/null | grep -q .; then
leftovers+=("systemd unit: $unit")
fi
done
if [ "${#leftovers[@]}" -gt 0 ]; then
warn "после очистки остались объекты:"
for path in "${leftovers[@]}"; do
printf ' - %s\n' "$path" >&2
done
fail "хост не является чистым; удалите перечисленное вручную"
fi
log "проверка пройдена: хост чист для установки HY2XS v1"
}
main() {
parse_args "$@"
require_root
show_plan
if [ "$APPLY" != "true" ]; then
log "это был dry-run; для выполнения запустите: --apply --yes-i-know"
return 0
fi
log "выполняю очистку"
stop_units
detach_nftables_fragment
remove_paths
systemctl daemon-reload || true
verify_clean_host
log "готово. Теперь можно устанавливать HY2XS v1 с нуля."
}
main "$@"