330a63b050
Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его 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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
182 lines
11 KiB
TypeScript
182 lines
11 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|