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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user