Files
founder 330a63b050 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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
2026-08-30 19:32:43 +05:00

182 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { disableReadOnlyGuard, enableReadOnlyGuard, isReadOnlyGuardActive } from "../src/lib/guard";
import { writeText } from "../src/lib/fs";
import { runMutating, runMutatingHidden, runMutatingVisible } from "../src/lib/process";
/**
* doctor обязан диагностировать установку, не изменяя её.
*
* Первая регрессия. doctor собирал контекст с `skipServiceStart` по умолчанию
* (false) и звал общий smoke, а тот первым же действием выполняет
* `systemctl restart hysteria-server hy2xs-admin`. Команда, которую runbook
* предлагает запускать при подозрении на проблему, обрывала все живые
* VPN-соединения на сервере — в том числе когда с сервисом всё в порядке.
*
* Вторая — уровнем глубже. Принудительный `skipServiceStart` закрывал ровно
* одну ИЗВЕСТНУЮ мутацию. Всё остальное в smoke держалось на выборе раннера
* автором правки, а читающие команды (`test -s`, `grep -q`, `stat`,
* `sudo -u ... test`, `nft -c`) шли через мутирующий namespace. То есть
* настоящая мутация, случайно добавленная в smoke, ничем бы от них не
* отличалась и была бы разрешена в doctor молча.
*
* Часть утверждений проверяется разбором исходника: поднять настоящие
* systemd-юниты и nftables в этой среде нельзя. Утверждения при этом точные —
* рестарт в smoke стоит ровно под одним условием, и мутирующий раннер в smoke
* ровно один.
*/
afterEach(() => {
disableReadOnlyGuard();
});
// Пути считаются от самого файла теста, а не от cwd: `bun test` запускается и
// из корня репозитория, и из orchestrator/ — сборка делает первое, разработчик
// обычно второе.
function source(relativeToSrc: string): string {
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
}
describe("doctor не перезапускает сервисы", () => {
test("контекст doctor принудительно выставляет skipServiceStart", () => {
const doctorSource = source("commands/doctor.ts");
expect(doctorSource).toContain("skipServiceStart: true");
});
test("skipServiceStart выставляется ПОСЛЕ раскрытия options и не может быть перекрыт", () => {
const doctorSource = source("commands/doctor.ts");
const spread = doctorSource.indexOf("...options");
const forced = doctorSource.indexOf("skipServiceStart: true");
expect(spread).toBeGreaterThan(-1);
expect(forced).toBeGreaterThan(-1);
// Порядок в объектном литерале решает: `{ ...options, skipServiceStart: true }`
// игнорирует значение из командной строки, обратный порядок — нет.
expect(forced).toBeGreaterThan(spread);
});
test("smoke по-прежнему рестартует сервисы только под флагом", () => {
const smokeSource = source("steps/smoke.ts");
const restart = "systemctl restart hysteria-server hy2xs-admin";
expect(smokeSource).toContain(restart);
// Ровно одно место рестарта, и оно под проверкой флага: иначе выставление
// skipServiceStart в doctor ничего бы не гарантировало.
expect(smokeSource.split(restart).length - 1).toBe(1);
const guard = smokeSource.indexOf("if (!context.options.skipServiceStart)");
const restartAt = smokeSource.indexOf(restart);
expect(guard).toBeGreaterThan(-1);
expect(guard).toBeLessThan(restartAt);
});
test("doctor не глушит остальные проверки smoke", () => {
const doctorSource = source("commands/doctor.ts");
// skipSmoke приходит из options и по умолчанию false: doctor сужает только
// рестарт, а не диагностику целиком.
expect(doctorSource).not.toContain("skipSmoke: true");
expect(doctorSource).toContain("await smoke(context)");
});
});
describe("read-only — инвариант doctor, а не свойство текущего кода", () => {
test("doctor включает read-only guard и снимает его в finally", () => {
const doctorSource = source("commands/doctor.ts");
expect(doctorSource).toContain("enableReadOnlyGuard(");
expect(doctorSource).toContain("disableReadOnlyGuard()");
// Guard обязан включаться ДО первой работы и сниматься в finally: иначе
// отказ любой проверки оставил бы глобальное состояние взведённым.
const enable = doctorSource.indexOf("enableReadOnlyGuard(");
const body = doctorSource.indexOf("await readText(options.sourceConfigPath)");
const finallyAt = doctorSource.indexOf("} finally {");
const disable = doctorSource.indexOf("disableReadOnlyGuard()");
expect(enable).toBeLessThan(body);
expect(finallyAt).toBeGreaterThan(-1);
expect(disable).toBeGreaterThan(finallyAt);
});
// Ровно один мутирующий вызов на весь smoke, и он под флагом, который doctor
// выставляет принудительно. Всё остальное классифицировано как наблюдение.
test("в smoke не осталось мутирующих раннеров, кроме рестарта под флагом", () => {
const smokeSource = source("steps/smoke.ts");
const calls = smokeSource
.split(/\r?\n/)
.filter((line) => !line.trimStart().startsWith("//"))
.filter((line) => /\brunMutating[A-Za-z]*`/.test(line));
expect(calls, `лишние мутирующие вызовы в smoke: ${calls.join("; ")}`).toHaveLength(1);
expect(calls[0]).toContain("systemctl restart hysteria-server hy2xs-admin");
});
// Регрессия: ожидание между двумя чтениями выполнялось подпроцессом `sleep`
// через мутирующий раннер, то есть пауза объявлялась изменением системы.
test("ожидание между попытками не является операцией над хостом", () => {
const smokeSource = source("steps/smoke.ts");
expect(smokeSource).not.toMatch(/runMutating[A-Za-z]*`sleep/);
expect(smokeSource).toContain("await Bun.sleep(delayMs)");
});
test("проверки прав и синтаксиса классифицированы как наблюдение", () => {
const smokeSource = source("steps/smoke.ts");
for (const observation of [
"runReadOnly`test -s /etc/hysteria/config.yaml`",
"runReadOnly`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`",
"runReadOnly`nft -c -f /etc/nftables.conf`"
]) {
expect(smokeSource).toContain(observation);
}
});
// 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 () => {
enableReadOnlyGuard("hy2xs-orchestrator doctor (диагностика не изменяет систему)");
expect(isReadOnlyGuardActive()).toBe(true);
for (const call of [
() => runMutating`systemctl restart hysteria-server`,
() => runMutatingVisible`nft -f /etc/nftables.conf`,
() => runMutatingHidden`install -d /var/lib/hy2xs`,
() => writeText("/etc/hysteria/config.yaml", "x")
]) {
await expect(call()).rejects.toThrow(/read-only guard violation.*doctor/s);
}
});
});