fix(v1): сделать надёжным нижний слой отката, а не только его запуск
Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его substrate этой надёжности не соответствует: откат
гарантированно запускался, но отдельные его шаги могли молча не выполнить
восстановление, отчитаться успехом и уничтожить резервную копию.
1. Данные для отката уничтожались ДО фиксации успеха (commit ordering).
cancelFirewallRollback снимала таймер автоотката И удаляла резервные копии
firewall, а вызывалась до долговечной записи phase=installed. Отказ этой
записи (ENOSPC/EIO/read-only ФС) приводил в обработчик ошибки, обязательный
откат честно запускался и сообщал "no HY2XS rollback markers found":
откатывать было нечем. Причём отказ записи маркера — ровно тот сценарий,
который прошлый проход специально сделал безопасным.
Разделено на disarmFirewallRollback (снять таймер, копии оставить) и
cleanupFirewallRollback (удалить копии). Порядок в install и reconfigure:
smoke_ok -> disarm -> durable installed -> cleanup best-effort.
2. Резервные копии снимались без доказательства.
И firewall, и reconfigure копировали как `cp ... || true`: отказ
игнорировался, операция шла менять систему без копии, на которую
рассчитывает откат. У firewall маркер prepared («данные для отката
существуют») выставлялся вообще ДО копирования. Копирование строгое, факт
создания проверяется, маркер ставится после.
3. Копии reconfigure смешивались между операциями.
Общий набор *.bak в /etc/hy2xs/backups не был привязан к проходу. Если у
операции B копирование падало, B всё равно менял систему, а его откат
восстанавливал файлы операции A — сервер возвращался в более старое
состояние и это выглядело успешным откатом. Копия стала операционной:
/etc/hy2xs/backups/<op-id>/ с манифестом, где отсутствие файла записано
явно ("present": false), а не выведено из неудачи cp. Разбор строгий,
включая проверку opId.
4. Ошибка восстановления скрывалась, и после неё копии удалялись.
rollbackFirewallNow выполняла cp и nft -f с `|| true`, затем безусловно
удаляла /run/hy2xs/rollback/<op>. Худшая комбинация: неудача не видна,
стадия успешна, данные для ручной починки уничтожены. Теперь копии
удаляются только после подтверждённого успеха, иначе сохраняются с
сообщением manual recovery data preserved at ...
5. Команды отката глушили собственный код возврата.
До стадийного раннера `|| true` был единственной защитой от обрыва цепочки;
после его появления стал маскировкой — стадия не могла сообщить, что
ничего не сделала. Убран; rollbackCurrentState разбита на семь независимых
стадий.
6. Долговечность записи каталога маркера.
writeTextAtomic синхронизирует файл и его каталог, но при первой установке
/var/lib/hy2xs создаётся тут же, и запись "hy2xs" в /var/lib оставалась
несинхронизированной. ensureDir сообщает о фактическом создании и
синхронизирует родителя только тогда.
Отдельно про doctor. Утверждение аудита, что doctor вызывает
UpdatePeerLastConnectionAt через успешную machine-auth, кодом не
подтверждается: проба с действующим паролем ограничена `context.mode ===
"install"`, а doctor работает в режиме reconfigure. Инвариант, однако, ничем не
охранялся — добавлены тест и приёмка. Документация уточнена: guard действует
внутри процесса, а границу «что doctor шлёт по сети» держит состав проб;
единственный остающийся след — записи в журнале админки, и это сказано прямо.
Тесты: backup-integrity.test.ts (манифест, строгий разбор, копия до мутации,
сохранение копий при неудачном восстановлении), commit-ordering.test.ts
(disarm/cleanup разделены, порядок фиксации в обеих командах). Три теста,
закреплявших прежний инвариант «каждая команда отката несёт || true»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
This commit is contained in:
@@ -130,6 +130,39 @@ describe("read-only — инвариант doctor, а не свойство те
|
||||
}
|
||||
});
|
||||
|
||||
// Guard защищает ТОЛЬКО процесс оркестратора. Он физически не способен
|
||||
// запретить побочный эффект, вызванный HTTP-запросом в другой процесс,
|
||||
// поэтому граница «что doctor имеет право трогать по сети» держится здесь.
|
||||
//
|
||||
// Конкретный риск: успешная machine-auth реального пира заставляет админку
|
||||
// выполнить UPDATE peer.last_connection_at, то есть диагностика изменила бы
|
||||
// отображаемое «последнее подключение» bootstrap-пира. Проба с ВАЛИДНЫМ
|
||||
// паролем поэтому ограничена режимом install; doctor работает в режиме
|
||||
// reconfigure и до неё не доходит.
|
||||
test("проба с валидными учётными данными выполняется только при install", () => {
|
||||
const smokeSource = source("steps/smoke.ts");
|
||||
|
||||
const guard = smokeSource.indexOf('if (context.mode === "install")');
|
||||
const validAuth = smokeSource.indexOf('"auth valid credentials"');
|
||||
// Именно ЧТЕНИЕ значения пароля, а не проверка наличия строки в файле:
|
||||
// последняя выполняется и в doctor и ничего наружу не отправляет.
|
||||
const readsSecret = smokeSource.indexOf("cut -d= -f2-");
|
||||
|
||||
expect(guard).toBeGreaterThan(-1);
|
||||
expect(validAuth).toBeGreaterThan(guard);
|
||||
expect(readsSecret).toBeGreaterThan(guard);
|
||||
|
||||
// Ровно одна такая проба, и она внутри блока install: вторая, добавленная
|
||||
// мимо условия, вернула бы запись в БД в диагностику.
|
||||
expect(smokeSource.split('"auth valid credentials"').length - 1).toBe(1);
|
||||
});
|
||||
|
||||
test("doctor работает в режиме, который не запускает пробу успешной авторизации", () => {
|
||||
const doctorSource = source("commands/doctor.ts");
|
||||
expect(doctorSource).toContain('mode: "reconfigure"');
|
||||
expect(doctorSource).not.toContain('mode: "install"');
|
||||
});
|
||||
|
||||
// Поведенческая часть: механизм, на который опирается doctor, действительно
|
||||
// отказывает любой мутации и называет причину.
|
||||
test("под guard'ом doctor любая мутация отказывает с его причиной", async () => {
|
||||
|
||||
Reference in New Issue
Block a user