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:
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { persistFailureState, runRollbackStages } from "../src/lib/rollback";
|
||||
|
||||
/**
|
||||
* Откат после операционного отказа обязан выполниться ЦЕЛИКОМ.
|
||||
*
|
||||
* Здесь закрепляются два дефекта одного класса, оба найдены в failure path
|
||||
* install и reconfigure:
|
||||
*
|
||||
* 1. запись состояния отказа стояла перед откатом обычным `await`, поэтому её
|
||||
* собственное падение (заполненный диск, read-only ФС) отменяло откат
|
||||
* целиком;
|
||||
* 2. сам откат был цепочкой `await`, поэтому падение первой стадии отменяло
|
||||
* все последующие. В reconfigure это означало сервер одновременно и с
|
||||
* применённым сломанным firewall, и без восстановленных конфигов.
|
||||
*
|
||||
* Механизм проверяется поведением — настоящим внедрением отказа в стадию.
|
||||
* Проводка команд к этому механизму проверяется разбором исходника: поднять
|
||||
* настоящие systemd-юниты и nftables в этой среде нельзя, а утверждение при
|
||||
* этом остаётся точным.
|
||||
*/
|
||||
|
||||
// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня
|
||||
// репозитория (сборка), и из orchestrator/ (разработчик).
|
||||
function source(relativeToSrc: string): string {
|
||||
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
|
||||
}
|
||||
|
||||
describe("стадии отката независимы", () => {
|
||||
test("все стадии выполняются, когда все успешны", async () => {
|
||||
const executed: string[] = [];
|
||||
const failures = await runRollbackStages([
|
||||
{ name: "firewall", run: async () => void executed.push("firewall") },
|
||||
{ name: "services", run: async () => void executed.push("services") }
|
||||
]);
|
||||
|
||||
expect(executed).toEqual(["firewall", "services"]);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
// Ключевой инвариант патча: упавшая стадия не отменяет остальные.
|
||||
test("падение первой стадии не отменяет последующие", async () => {
|
||||
const executed: string[] = [];
|
||||
const failures = await runRollbackStages([
|
||||
{
|
||||
name: "firewall",
|
||||
run: async () => {
|
||||
executed.push("firewall");
|
||||
throw new Error("nft: command failed");
|
||||
}
|
||||
},
|
||||
{ name: "restore configuration", run: async () => void executed.push("restore") },
|
||||
{ name: "stop services", run: async () => void executed.push("stop") }
|
||||
]);
|
||||
|
||||
expect(executed).toEqual(["firewall", "restore", "stop"]);
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toContain("firewall");
|
||||
expect(failures[0]).toContain("nft: command failed");
|
||||
});
|
||||
|
||||
test("падение каждой стадии учитывается отдельно и в порядке объявления", async () => {
|
||||
const failures = await runRollbackStages([
|
||||
{
|
||||
name: "firewall",
|
||||
run: async () => {
|
||||
throw new Error("first");
|
||||
}
|
||||
},
|
||||
{ name: "healthy", run: async () => undefined },
|
||||
{
|
||||
name: "restore configuration",
|
||||
run: async () => {
|
||||
throw new Error("second");
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
expect(failures).toHaveLength(2);
|
||||
expect(failures[0]).toStartWith("firewall: ");
|
||||
expect(failures[1]).toStartWith("restore configuration: ");
|
||||
});
|
||||
|
||||
// Причиной отказа операции остаётся исходная ошибка: откат сообщает о своих
|
||||
// проблемах возвратом, а не броском, иначе бы он подменил собой диагноз.
|
||||
test("откат не бросает даже при отказе всех стадий", async () => {
|
||||
const failures = await runRollbackStages([
|
||||
{
|
||||
name: "a",
|
||||
run: async () => {
|
||||
throw new Error("boom");
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "b",
|
||||
run: async () => {
|
||||
throw new Error("boom");
|
||||
}
|
||||
}
|
||||
]);
|
||||
expect(failures).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("не-Error причина не роняет откат", async () => {
|
||||
const failures = await runRollbackStages([
|
||||
{
|
||||
name: "weird",
|
||||
run: async () => {
|
||||
throw "строковая ошибка";
|
||||
}
|
||||
}
|
||||
]);
|
||||
expect(failures[0]).toContain("строковая ошибка");
|
||||
});
|
||||
|
||||
test("пустой набор стадий допустим", async () => {
|
||||
expect(await runRollbackStages([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("состояние отказа пишется best effort", () => {
|
||||
test("успешная запись выполняется", async () => {
|
||||
let written = false;
|
||||
await persistFailureState(async () => {
|
||||
written = true;
|
||||
});
|
||||
expect(written).toBe(true);
|
||||
});
|
||||
|
||||
// Регрессия: это и был P0. Падение записи маркера уносило управление наружу
|
||||
// мимо обязательного отката.
|
||||
test("падение записи не пробрасывается наружу", async () => {
|
||||
await expect(
|
||||
persistFailureState(async () => {
|
||||
throw new Error("ENOSPC: no space left on device");
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("install: откат обязателен после операционного отказа", () => {
|
||||
const installSource = source("commands/install.ts");
|
||||
|
||||
test("запись состояния отказа обёрнута в persistFailureState", () => {
|
||||
expect(installSource).toContain("await persistFailureState(() =>");
|
||||
const guarded = installSource.indexOf("await persistFailureState(() =>");
|
||||
const advance = installSource.indexOf("advanceInstallState(", guarded);
|
||||
// Обёрнута именно запись маркера, а не что-то другое рядом.
|
||||
expect(advance).toBeGreaterThan(guarded);
|
||||
expect(advance - guarded).toBeLessThan(80);
|
||||
});
|
||||
|
||||
test("состояние отказа пишется раньше отката, но не может его отменить", () => {
|
||||
const guarded = installSource.indexOf("await persistFailureState(");
|
||||
const rollback = installSource.indexOf("await rollbackFailedInstall(");
|
||||
expect(guarded).toBeGreaterThan(-1);
|
||||
expect(rollback).toBeGreaterThan(guarded);
|
||||
});
|
||||
|
||||
// В обработчике не должно остаться ни одной незащищённой записи состояния:
|
||||
// единственный голый advanceInstallState в catch и был дефектом.
|
||||
test("в обработчике ошибки нет незащищённой записи маркера", () => {
|
||||
const catchAt = installSource.indexOf("} catch (error) {");
|
||||
expect(catchAt).toBeGreaterThan(-1);
|
||||
const handler = installSource.slice(catchAt);
|
||||
expect(handler).not.toContain("await advanceInstallState(");
|
||||
});
|
||||
|
||||
test("откат идёт через независимые стадии", () => {
|
||||
expect(installSource).toContain("await runRollbackStages(stages)");
|
||||
// Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно:
|
||||
// именно они и образовывали отменяемую цепочку.
|
||||
const start = installSource.indexOf("async function rollbackFailedInstall");
|
||||
const body = installSource.slice(start, installSource.indexOf("export async function install"));
|
||||
expect(body).toContain("await rollbackFirewallNow(context)");
|
||||
// Вызов существует только внутри стадии.
|
||||
const firewallAt = body.indexOf("await rollbackFirewallNow(context)");
|
||||
const stageAt = body.lastIndexOf('name: "firewall"', firewallAt);
|
||||
expect(stageAt).toBeGreaterThan(-1);
|
||||
});
|
||||
|
||||
test("остановка сервисов остаётся отдельными стадиями", () => {
|
||||
for (const stage of ["stop services", "disable services", "reset failed services"]) {
|
||||
expect(installSource).toContain(`name: "${stage}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test("чужие сервисы по-прежнему не трогаются", () => {
|
||||
expect(installSource).toContain(
|
||||
"systemd units were not deployed by this operation, leaving services untouched"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconfigure: откат обязателен после операционного отказа", () => {
|
||||
const reconfigureSource = source("commands/reconfigure.ts");
|
||||
|
||||
test("запись состояния отказа обёрнута в persistFailureState", () => {
|
||||
expect(reconfigureSource).toContain("await persistFailureState(() => markPhase(context");
|
||||
});
|
||||
|
||||
test("в обработчике ошибки нет незащищённого markPhase", () => {
|
||||
const catchAt = reconfigureSource.indexOf("} catch (error) {");
|
||||
expect(catchAt).toBeGreaterThan(-1);
|
||||
const handler = reconfigureSource.slice(catchAt);
|
||||
expect(handler).not.toContain("await markPhase(");
|
||||
});
|
||||
|
||||
test("firewall и восстановление конфигов — независимые стадии", () => {
|
||||
expect(reconfigureSource).toContain('name: "firewall"');
|
||||
expect(reconfigureSource).toContain('name: "restore configuration"');
|
||||
expect(reconfigureSource).toContain("await runRollbackStages(stages)");
|
||||
});
|
||||
|
||||
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
|
||||
test("порядок сохранён: сначала firewall, затем конфиги", () => {
|
||||
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
|
||||
const restore = reconfigureSource.indexOf("await rollbackCurrentState()");
|
||||
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
|
||||
expect(firewall).toBeGreaterThan(-1);
|
||||
expect(firewall).toBeLessThan(restore);
|
||||
expect(restore).toBeLessThan(stages);
|
||||
});
|
||||
|
||||
// Внутри самого восстановления конфигов дефект был тот же: единственная
|
||||
// команда без `|| true` отменяла перезапуск сервисов строкой ниже.
|
||||
test("ни одна команда восстановления конфигов не обрывает следующие", () => {
|
||||
const start = reconfigureSource.indexOf("async function rollbackCurrentState");
|
||||
const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readInstallState"));
|
||||
const offenders = body
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("runMutatingVisible`"))
|
||||
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
|
||||
expect(offenders, `команды отката без защиты: ${offenders.join("; ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user