fix(v1): сделать откат неотменяемым, а маркер установки — долговечным

Три дефекта одного класса в failure path install/reconfigure.

1. Запись состояния отказа отменяла откат.

   Обработчик ошибки первым делом писал в install-state фазу отказа обычным
   await и только потом откатывался. Эта запись — mkdir, write и chown в
   /var/lib/hy2xs, то есть она падает ровно там, где откат нужнее всего:
   заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил
   управление наружу, и обязательное восстановление не выполнялось вовсе —
   применённый firewall и развёрнутые сервисы оставались на сервере.

   Необязательная телеметрия состояния стояла перед обязательным
   восстановлением. Для диагностики это уже было закрыто, для записи
   состояния — нет.

2. Откат отменял сам себя.

   Он был написан цепочкой await, а каждая его стадия — systemctl, cp, rm -rf
   и nft, то есть умеет упасть сама. Отказ первой стадии отменял все
   последующие. В reconfigure это означало сервер одновременно с применённым
   сломанным firewall И без восстановленных из /etc/hy2xs/backups конфигов.
   Внутри rollbackCurrentState болезнь та же: единственная команда без
   `|| true` (systemctl daemon-reload) отменяла перезапуск сервисов строкой
   ниже, и восстановленные unit-файлы не применялись.

   Стадии стали независимыми: выполняются все, отказавшие перечисляются в
   журнале, наружу уходит исходная ошибка операции.

3. У маркера установки было два писателя с разными гарантиями.

   install перезаписывал файл на месте (writeText), reconfigure подставлял
   атомарно. Слабейшая гарантия досталась команде, которая этот файл создаёт.
   Перезапись на месте укорачивает файл до нуля и только потом наполняет:
   отказ между этими моментами оставляет половину JSON, который не
   разбирается — reconfigure видит его как отсутствующий, clean-host как
   присутствующий, а хост уже изменён.

   Атомарности при этом мало. rename() без fsync даёт атомарность видимости
   без долговечности: после потери питания ext4 штатно отдаёт по этому пути
   нулевой файл. Для метаданных восстановления это неприемлемо, поэтому
   порядок теперь: права/владелец -> fsync файла -> rename -> fsync каталога.

   Заодно ownership-флаг переименован в stateTouched и взводится ДО записи:
   отказ на chown после успешного write оставлял файл на диске при
   невзведённом флаге, то есть давал fatal_pre_apply («ничего не изменено»)
   при уже существующем маркере установки.

Тесты: rollback-mandatory.test.ts (внедрение отказа в стадию, проводка команд),
atomic-write.test.ts (замена целиком, прежний файл при отказе, отсутствие
временных файлов, права, guard). Приёмка сборки закрепляет порядок шагов
атомарной записи, отсутствие незащищённой записи состояния в обработчиках и
отсутствие отменяемых цепочек в откате.
This commit is contained in:
2026-08-30 18:11:55 +05:00
parent 60a1aea85e
commit e84fdedc4b
13 changed files with 1124 additions and 80 deletions
+148 -9
View File
@@ -307,24 +307,44 @@ run_clean_install_acceptance() {
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 written install-state already makes the failure post-apply"
# Регрессия: classifyFailure не учитывал stateWritten, поэтому падение
log_step "Acceptance: a touched install-state already makes the failure post-apply"
# Регрессия: classifyFailure не учитывал маркер установки, поэтому падение
# apt-get объявлялось «на сервере ничего не изменено», rollback пропускался,
# а install-state.json оставался на хосте и ломал следующую установку.
grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \
|| fail "acceptance: classifyFailure must account for a written install-state"
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 stateWritten = body.indexOf("ownership.stateWritten");
if (preApply < 0 || stateWritten < 0) {
const stateTouched = body.indexOf("ownership.stateTouched");
if (preApply < 0 || stateTouched < 0) {
throw new Error("could not locate classifyFailure branches");
}
if (stateWritten > preApply) {
throw new Error("stateWritten is checked after the fatal_pre_apply fallback");
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 written"
' || 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 умеет изменить
@@ -791,6 +811,125 @@ run_single_owner_acceptance() {
}
' || 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
# Внутри восстановления конфигов дефект был тот же: единственная команда без
# `|| true` отменяла перезапуск сервисов строкой ниже.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8");
const start = source.indexOf("async function rollbackCurrentState");
if (start < 0) throw new Error("rollbackCurrentState отсутствует");
const rest = source.slice(start + 1);
const end = rest.search(/\n(export )?(async )?function /);
const body = end < 0 ? rest : rest.slice(0, end);
const offenders = body.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
if (offenders.length) {
throw new Error("команды восстановления конфигов обрывают следующие:\n" + offenders.join("\n"));
}
' || fail "acceptance: ни одна команда отката не имеет права отменить остальные"
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"