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:
@@ -135,6 +135,17 @@ describe("ensureDir", () => {
|
||||
expect(((await stat(target)).mode & 0o777).toString(8)).toBe("755");
|
||||
});
|
||||
|
||||
// Долговечность записи каталога в РОДИТЕЛЕ имеет смысл только при
|
||||
// фактическом создании, поэтому ensureDir обязана его различать: на каждом
|
||||
// обновлении маркера каталог уже существует, и лишний fsync родителя ничего
|
||||
// не даёт.
|
||||
test("сообщает, был ли каталог создан", async () => {
|
||||
const target = join(dir, "hy2xs");
|
||||
|
||||
expect((await ensureDir(target, { mode: 0o755 })).created).toBe(true);
|
||||
expect((await ensureDir(target, { mode: 0o755 })).created).toBe(false);
|
||||
});
|
||||
|
||||
test("создание каталога проходит через read-only guard", async () => {
|
||||
enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
|
||||
await expect(ensureDir(join(dir, "hy2xs"), { mode: 0o755 })).rejects.toThrow(
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
BACKUP_MANIFEST_VERSION,
|
||||
backupDir,
|
||||
backupFilePath,
|
||||
backupManifestPath,
|
||||
buildManifest,
|
||||
parseManifest,
|
||||
renderManifest,
|
||||
storedNameFor
|
||||
} from "../src/lib/backupManifest";
|
||||
|
||||
/**
|
||||
* Целостность резервной копии — предпосылка отката, а не его деталь.
|
||||
*
|
||||
* Два дефекта, которые здесь закрепляются.
|
||||
*
|
||||
* 1. Копии снимались как `cp ... || true`, поэтому операция начинала менять
|
||||
* систему, не имея копии, на которую рассчитывает откат.
|
||||
*
|
||||
* 2. Копии всех операций лежали одним общим набором `*.bak` в
|
||||
* /etc/hy2xs/backups. Если у операции B копирование падало, её откат
|
||||
* восстанавливал файл, сохранённый операцией A, — то есть возвращал сервер
|
||||
* в более старое состояние и выглядел при этом успешным.
|
||||
*
|
||||
* Манифест отвечает на оба: он привязан к op-id и содержит ЯВНУЮ запись о
|
||||
* каждом файле, включая отсутствовавшие.
|
||||
*/
|
||||
|
||||
function source(relativeToSrc: string): string {
|
||||
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
|
||||
}
|
||||
|
||||
describe("манифест резервной копии", () => {
|
||||
test("копия каждой операции лежит в своём каталоге", () => {
|
||||
expect(backupDir("2026-08-30T10-00-00.000Z")).toBe("/etc/hy2xs/backups/2026-08-30T10-00-00.000Z");
|
||||
expect(backupDir("a")).not.toBe(backupDir("b"));
|
||||
});
|
||||
|
||||
test("манифест и файлы адресуются внутри каталога операции", () => {
|
||||
const opId = "op-1";
|
||||
expect(backupManifestPath(opId)).toStartWith(backupDir(opId));
|
||||
expect(backupFilePath(opId, "etc_nftables.conf")).toStartWith(backupDir(opId));
|
||||
});
|
||||
|
||||
// Имя обязано быть однозначным: /etc/nftables.conf и /etc/hy2xs/nftables.conf
|
||||
// не имеют права лечь в один файл копии.
|
||||
test("разные пути дают разные имена файлов копии", () => {
|
||||
const names = new Set(
|
||||
[
|
||||
"/etc/nftables.conf",
|
||||
"/etc/hy2xs/nftables.conf",
|
||||
"/etc/nftables.d/hy2xs.nft",
|
||||
"/etc/hysteria/config.yaml",
|
||||
"/etc/systemd/system/hy2xs-admin.service"
|
||||
].map(storedNameFor)
|
||||
);
|
||||
expect(names.size).toBe(5);
|
||||
});
|
||||
|
||||
test("имя файла копии не выходит за пределы каталога", () => {
|
||||
for (const path of ["/etc/../../evil", "/etc/hy2xs/../../x", "//etc//x"]) {
|
||||
const stored = storedNameFor(path);
|
||||
expect(stored).not.toContain("/");
|
||||
expect(stored).not.toContain("../");
|
||||
}
|
||||
});
|
||||
|
||||
// Ключевое: «файла не было» — записанный факт, а не вывод из неудачи `cp`.
|
||||
test("отсутствовавший файл записывается явно", () => {
|
||||
const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [
|
||||
{ path: "/etc/hysteria/config.yaml", present: true },
|
||||
{ path: "/etc/nftables.d/hy2xs.nft", present: false }
|
||||
]);
|
||||
|
||||
const missing = manifest.entries.find((entry) => entry.path === "/etc/nftables.d/hy2xs.nft");
|
||||
expect(missing?.present).toBe(false);
|
||||
expect(missing?.stored).toBeNull();
|
||||
|
||||
const kept = manifest.entries.find((entry) => entry.path === "/etc/hysteria/config.yaml");
|
||||
expect(kept?.present).toBe(true);
|
||||
expect(kept?.stored).toBe(storedNameFor("/etc/hysteria/config.yaml"));
|
||||
});
|
||||
|
||||
test("манифест переживает сериализацию без потерь", () => {
|
||||
const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [
|
||||
{ path: "/etc/hysteria/config.yaml", present: true },
|
||||
{ path: "/etc/hy2xs/hy2xs.env", present: false }
|
||||
]);
|
||||
|
||||
expect(parseManifest(renderManifest(manifest), "op-1")).toEqual(manifest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("разбор манифеста строгий", () => {
|
||||
const valid = renderManifest(
|
||||
buildManifest("op-1", "2026-08-30T10:00:00.000Z", [{ path: "/etc/hysteria/config.yaml", present: true }])
|
||||
);
|
||||
|
||||
// Откат по манифесту ЧУЖОЙ операции — это ровно тот дефект, ради которого
|
||||
// копия стала привязанной к op-id.
|
||||
test("манифест другой операции отклоняется", () => {
|
||||
expect(() => parseManifest(valid, "op-2")).toThrow(/относится к другой операции/);
|
||||
});
|
||||
|
||||
test("непонятная версия отклоняется", () => {
|
||||
const foreign = valid.replace(`"version": ${BACKUP_MANIFEST_VERSION}`, '"version": 99');
|
||||
expect(() => parseManifest(foreign, "op-1")).toThrow(/неизвестная версия/);
|
||||
});
|
||||
|
||||
test("битый JSON отклоняется, а не считается пустой копией", () => {
|
||||
expect(() => parseManifest("{ не json", "op-1")).toThrow(/не разбирается/);
|
||||
});
|
||||
|
||||
// «Поле не разобралось, будем считать, что файла не было» означает удаление
|
||||
// существующего файла при откате. Поэтому отказ, а не умолчание.
|
||||
test("запись без признака существования отклоняется", () => {
|
||||
const broken = JSON.stringify({
|
||||
version: BACKUP_MANIFEST_VERSION,
|
||||
opId: "op-1",
|
||||
createdAt: "",
|
||||
entries: [{ path: "/etc/hysteria/config.yaml" }]
|
||||
});
|
||||
expect(() => parseManifest(broken, "op-1")).toThrow(/не сообщает, существовал ли файл/);
|
||||
});
|
||||
|
||||
test("существующий файл без имени копии отклоняется", () => {
|
||||
const broken = JSON.stringify({
|
||||
version: BACKUP_MANIFEST_VERSION,
|
||||
opId: "op-1",
|
||||
createdAt: "",
|
||||
entries: [{ path: "/etc/hysteria/config.yaml", present: true, stored: "" }]
|
||||
});
|
||||
expect(() => parseManifest(broken, "op-1")).toThrow(/не называет копию/);
|
||||
});
|
||||
|
||||
test("запись без пути отклоняется", () => {
|
||||
const broken = JSON.stringify({
|
||||
version: BACKUP_MANIFEST_VERSION,
|
||||
opId: "op-1",
|
||||
createdAt: "",
|
||||
entries: [{ present: false }]
|
||||
});
|
||||
expect(() => parseManifest(broken, "op-1")).toThrow(/не содержит пути/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconfigure: копия снимается строго и до первой мутации", () => {
|
||||
const reconfigureSource = source("commands/reconfigure.ts");
|
||||
|
||||
test("копирование не глушит ошибки", () => {
|
||||
const start = reconfigureSource.indexOf("async function backupCurrentState");
|
||||
const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readBackupManifest"));
|
||||
const muted = body
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("runMutatingVisible`"))
|
||||
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
|
||||
|
||||
expect(muted, `копирование снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
// Создание копии обязано быть ДОКАЗАНО, а не предположено по коду возврата
|
||||
// одного лишь `cp`.
|
||||
test("существование копии проверяется после копирования", () => {
|
||||
expect(reconfigureSource).toContain("резервная копия");
|
||||
expect(reconfigureSource).toContain("await fileExists(target)");
|
||||
});
|
||||
|
||||
test("копия снимается до первой мутации системы", () => {
|
||||
const backup = reconfigureSource.indexOf("await backupCurrentState(context)");
|
||||
const generate = reconfigureSource.indexOf("await generateConfig(context)");
|
||||
const firewall = reconfigureSource.indexOf("await applyFirewall(context)");
|
||||
|
||||
expect(backup).toBeGreaterThan(-1);
|
||||
expect(backup).toBeLessThan(generate);
|
||||
expect(backup).toBeLessThan(firewall);
|
||||
});
|
||||
|
||||
test("откат читает манифест текущей операции, а не общий каталог", () => {
|
||||
expect(reconfigureSource).toContain("readBackupManifest(opId)");
|
||||
expect(reconfigureSource).toContain("parseManifest(await readText(path), opId)");
|
||||
// Прежняя схема общего набора *.bak не должна вернуться.
|
||||
expect(reconfigureSource).not.toContain("/etc/hy2xs/backups/config.yaml.bak");
|
||||
|
||||
// Решение «восстанавливать или удалять» принимается по манифесту, а не по
|
||||
// маркерам-файлам прежней схемы. Уборка их наследия в pruneOldBackups
|
||||
// допустима и проверяется отдельно.
|
||||
const restoreStart = reconfigureSource.indexOf("function restoreStages(");
|
||||
const restoreBody = reconfigureSource.slice(
|
||||
restoreStart,
|
||||
reconfigureSource.indexOf("async function rollbackCurrentState")
|
||||
);
|
||||
expect(restoreBody).not.toContain(".existed");
|
||||
expect(restoreBody).toContain("entry.present");
|
||||
});
|
||||
|
||||
// Каталог копий не имеет права расти неограниченно, но уборка обязана идти
|
||||
// ПОСЛЕ долговечной фиксации успеха — иначе это тот же дефект порядка, что и
|
||||
// с firewall.
|
||||
test("копии предыдущих операций убираются после фиксации успеха", () => {
|
||||
const installed = reconfigureSource.indexOf('await markPhase(context, "installed")');
|
||||
const prune = reconfigureSource.indexOf("pruneOldBackups(context)");
|
||||
expect(installed).toBeGreaterThan(-1);
|
||||
expect(prune).toBeGreaterThan(installed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firewall: копия снимается строго и переживает неудачный откат", () => {
|
||||
const firewallSource = source("steps/firewall.ts");
|
||||
|
||||
test("копирование firewall не глушит ошибки", () => {
|
||||
const start = firewallSource.indexOf("async function backupFirewallState");
|
||||
const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments"));
|
||||
const muted = body
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("runMutatingVisible`"))
|
||||
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
|
||||
|
||||
expect(muted, `копирование firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
// Маркер `prepared` означает «данные для отката существуют». Пока он
|
||||
// ставился ДО копирования, это было неправдой.
|
||||
test("маркер готовности ставится после проверенных копий", () => {
|
||||
const start = firewallSource.indexOf("async function backupFirewallState");
|
||||
const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments"));
|
||||
const copy = body.indexOf("cp -a ${target.path}");
|
||||
const verify = body.indexOf("await fileExists(backupPath)");
|
||||
const marker = body.indexOf("touch ${rollbackMarker(opId)}");
|
||||
|
||||
expect(copy).toBeGreaterThan(-1);
|
||||
expect(verify).toBeGreaterThan(copy);
|
||||
expect(marker).toBeGreaterThan(verify);
|
||||
});
|
||||
|
||||
test("восстановление firewall не глушит ошибки cp и nft", () => {
|
||||
const start = firewallSource.indexOf("export async function rollbackFirewallNow");
|
||||
const body = firewallSource.slice(start);
|
||||
const muted = body
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("runMutatingVisible`"))
|
||||
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"))
|
||||
// Снятие таймера остаётся best-effort: отсутствующий юнит — не отказ
|
||||
// восстановления, а нормальное состояние при firewallStagedApply=false.
|
||||
.filter((line) => !line.includes("systemctl"));
|
||||
|
||||
expect(muted, `восстановление firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
// Худшая прежняя комбинация: ошибка восстановления скрыта, а данные, по
|
||||
// которым оператор мог бы поднять firewall вручную, уничтожены.
|
||||
test("резервные копии удаляются только после успешного восстановления", () => {
|
||||
const start = firewallSource.indexOf("export async function rollbackFirewallNow");
|
||||
const body = firewallSource.slice(start);
|
||||
const failures = body.indexOf("if (failures.length > 0)");
|
||||
const preserved = body.indexOf("manual recovery data preserved at");
|
||||
const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)");
|
||||
|
||||
expect(failures).toBeGreaterThan(-1);
|
||||
expect(preserved).toBeGreaterThan(failures);
|
||||
expect(cleanup).toBeGreaterThan(preserved);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Порядок фиксации успеха операции.
|
||||
*
|
||||
* Дефект, который здесь закрепляется, — commit ordering. Успешный install
|
||||
* заканчивался так:
|
||||
*
|
||||
* smoke PASS
|
||||
* -> cancelFirewallRollback() // снимала таймер И УДАЛЯЛА резервные копии
|
||||
* -> advanceInstallState("installed")
|
||||
*
|
||||
* То есть единственные данные для отката firewall (marker, прежний
|
||||
* nftables.conf, прежний hy2xs.nft) уничтожались ДО долговечной записи успеха.
|
||||
* Если эта запись падала — ENOSPC, EIO, read-only ФС, — управление уходило в
|
||||
* обработчик ошибки, обязательный откат честно запускался и сообщал:
|
||||
*
|
||||
* firewall rollback skipped: no HY2XS rollback markers found
|
||||
*
|
||||
* Откат нельзя было отменить, но откатывать ему было нечем. Причём падение
|
||||
* записи маркера — ровно тот сценарий, который был специально сделан
|
||||
* безопасным предыдущим патчем.
|
||||
*
|
||||
* Правильный порядок: снять таймер (копии остаются) -> зафиксировать успех
|
||||
* долговечно -> и только потом убрать копии, best-effort.
|
||||
*/
|
||||
|
||||
function source(relativeToSrc: string): string {
|
||||
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
|
||||
}
|
||||
|
||||
const firewallSource = source("steps/firewall.ts");
|
||||
|
||||
describe("снятие таймера и удаление копий — разные операции", () => {
|
||||
test("firewall предоставляет disarm и cleanup по отдельности", () => {
|
||||
expect(firewallSource).toContain("export async function disarmFirewallRollback");
|
||||
expect(firewallSource).toContain("export async function cleanupFirewallRollback");
|
||||
});
|
||||
|
||||
// Прежнее единое имя не должно вернуться: оно и означало «снять таймер и
|
||||
// удалить копии одним действием».
|
||||
test("объединённая cancelFirewallRollback не вернулась", () => {
|
||||
expect(firewallSource).not.toContain("export async function cancelFirewallRollback");
|
||||
for (const file of ["commands/install.ts", "commands/reconfigure.ts"]) {
|
||||
expect(source(file)).not.toContain("cancelFirewallRollback");
|
||||
}
|
||||
});
|
||||
|
||||
test("disarm не удаляет резервные копии", () => {
|
||||
const start = firewallSource.indexOf("export async function disarmFirewallRollback");
|
||||
const body = firewallSource.slice(start, firewallSource.indexOf("export async function cleanupFirewallRollback"));
|
||||
expect(body).not.toContain("cleanupFirewallBackupFiles");
|
||||
expect(body).toContain("stopRollbackTimer");
|
||||
});
|
||||
|
||||
test("cleanup удаляет копии и ничего больше", () => {
|
||||
const start = firewallSource.indexOf("export async function cleanupFirewallRollback");
|
||||
const body = firewallSource.slice(start, firewallSource.indexOf("export async function rollbackFirewallNow"));
|
||||
expect(body).toContain("cleanupFirewallBackupFiles");
|
||||
});
|
||||
});
|
||||
|
||||
describe("install: успех фиксируется до уничтожения данных отката", () => {
|
||||
const installSource = source("commands/install.ts");
|
||||
|
||||
test("порядок: disarm -> durable installed -> cleanup", () => {
|
||||
const disarm = installSource.indexOf("await disarmFirewallRollback(context)");
|
||||
const installed = installSource.indexOf('await advanceInstallState(context, ownership, "installed")');
|
||||
const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)");
|
||||
|
||||
expect(disarm).toBeGreaterThan(-1);
|
||||
expect(installed).toBeGreaterThan(disarm);
|
||||
expect(cleanup).toBeGreaterThan(installed);
|
||||
});
|
||||
|
||||
test("успешный smoke фиксируется отдельной фазой до снятия таймера", () => {
|
||||
const smokeOk = installSource.indexOf('await advanceInstallState(context, ownership, "smoke_ok")');
|
||||
const disarm = installSource.indexOf("await disarmFirewallRollback(context)");
|
||||
|
||||
expect(smokeOk).toBeGreaterThan(-1);
|
||||
expect(smokeOk).toBeLessThan(disarm);
|
||||
});
|
||||
|
||||
// Уборка после фиксации успеха — мусор в /run, а не причина объявить
|
||||
// успешную установку неуспешной.
|
||||
test("уборка после фиксации выполняется best-effort", () => {
|
||||
const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)");
|
||||
const tryAt = installSource.lastIndexOf("try {", cleanup);
|
||||
const catchAt = installSource.indexOf("catch (cleanupError)", cleanup);
|
||||
|
||||
expect(tryAt).toBeGreaterThan(-1);
|
||||
expect(tryAt).toBeLessThan(cleanup);
|
||||
expect(catchAt).toBeGreaterThan(cleanup);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconfigure: тот же порядок фиксации", () => {
|
||||
const reconfigureSource = source("commands/reconfigure.ts");
|
||||
|
||||
test("порядок: disarm -> durable installed -> cleanup", () => {
|
||||
const disarm = reconfigureSource.indexOf("await disarmFirewallRollback(context)");
|
||||
const installed = reconfigureSource.indexOf('await markPhase(context, "installed")');
|
||||
const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)");
|
||||
|
||||
expect(disarm).toBeGreaterThan(-1);
|
||||
expect(installed).toBeGreaterThan(disarm);
|
||||
expect(cleanup).toBeGreaterThan(installed);
|
||||
});
|
||||
|
||||
test("уборка после фиксации выполняется best-effort", () => {
|
||||
expect(reconfigureSource).toContain("catch (cleanupError)");
|
||||
const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)");
|
||||
const catchAt = reconfigureSource.indexOf("catch (cleanupError)", cleanup);
|
||||
expect(catchAt).toBeGreaterThan(cleanup);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("классификация отказа reconfigure/repair", () => {
|
||||
test("отказ диагностики не отменяет откат firewall и конфигов", () => {
|
||||
const diagnosticsAt = reconfigureSource.indexOf("await diagnosticsCollect(options)");
|
||||
const firewallRollbackAt = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
|
||||
const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState()");
|
||||
const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState(context)");
|
||||
const catchAt = reconfigureSource.indexOf("catch (diagnosticsError)");
|
||||
|
||||
expect(catchAt).toBeGreaterThan(diagnosticsAt);
|
||||
|
||||
@@ -217,22 +217,71 @@ describe("reconfigure: откат обязателен после операци
|
||||
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
|
||||
test("порядок сохранён: сначала firewall, затем конфиги", () => {
|
||||
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
|
||||
const restore = reconfigureSource.indexOf("await rollbackCurrentState()");
|
||||
const restore = reconfigureSource.indexOf("await rollbackCurrentState(context)");
|
||||
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
|
||||
// Инвариант СМЕНИЛСЯ, и это принципиально.
|
||||
//
|
||||
// Раньше здесь требовалось, чтобы каждая команда восстановления несла
|
||||
// `|| true`: тогда это была единственная защита от того, что отказ одной
|
||||
// команды оборвёт следующие. После появления runRollbackStages непрерывность
|
||||
// обеспечивает раннер, а `|| true` стал ВРЕДЕН: он не даёт стадии сообщить,
|
||||
// что восстановление на самом деле не выполнилось, и «restore configuration»
|
||||
// никогда не попадала в список отказавших.
|
||||
test("команды восстановления не глушат собственные ошибки", () => {
|
||||
const start = reconfigureSource.indexOf("function restoreStages(");
|
||||
const body = reconfigureSource.slice(
|
||||
start,
|
||||
reconfigureSource.indexOf("async function rollbackCurrentState")
|
||||
);
|
||||
const muted = body
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("runMutatingVisible`"))
|
||||
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
|
||||
expect(offenders, `команды отката без защиты: ${offenders.join("; ")}`).toEqual([]);
|
||||
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
|
||||
|
||||
expect(muted, `восстановление снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
test("восстановление разбито на независимые стадии", () => {
|
||||
for (const stage of [
|
||||
"restore hysteria config",
|
||||
"restore systemd units",
|
||||
"restore runtime env",
|
||||
"restore nftables files",
|
||||
"apply restored ruleset",
|
||||
"reload systemd units",
|
||||
"restart services"
|
||||
]) {
|
||||
expect(reconfigureSource).toContain(`"${stage}"`);
|
||||
}
|
||||
});
|
||||
|
||||
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
|
||||
test("частичное восстановление не выдаётся за успешное", () => {
|
||||
expect(reconfigureSource).toContain("restoreFailures.length > 0");
|
||||
expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью");
|
||||
});
|
||||
});
|
||||
|
||||
describe("install: команды отката не глушат собственные ошибки", () => {
|
||||
const installSource = source("commands/install.ts");
|
||||
|
||||
// После появления runRollbackStages `|| true` в стадиях отката перестал быть
|
||||
// защитой и стал маскировкой: стадия не могла сообщить, что systemctl
|
||||
// реально ничего не сделал, и обещание «отказавшие стадии перечисляются» для
|
||||
// них не выполнялось никогда.
|
||||
test("стадии остановки сервисов не подавляют код возврата", () => {
|
||||
const start = installSource.indexOf("async function rollbackFailedInstall");
|
||||
const body = installSource.slice(start, installSource.indexOf("export async function install"));
|
||||
const muted = body
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes("runMutatingVisible`"))
|
||||
.filter((line) => line.includes("|| true"));
|
||||
|
||||
expect(muted, `стадии отката снова скрывают ошибки: ${muted.join("; ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user