Files
HY2XS_flamy/orchestrator/test/install-sequence.test.ts
T
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

176 lines
9.0 KiB
TypeScript
Raw 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 { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { preflight } from "../src/steps/preflight";
import { classifyReconfigureFailure } from "../src/commands/reconfigure";
import { baselineConfig, testContext } from "./fixtures";
/**
* Порядок фаз установки — инвариант, который нельзя проверить ни модульно, ни
* на живом сервере дешевле, чем разбором самой последовательности.
*
* Оба дефекта, которые здесь закреплены, проявлялись ТОЛЬКО на настоящей
* Debian-машине и оба ломали установку полностью:
*
* 1. clean-host проверялся дважды, второй раз — уже после записи
* install-state.json, и опознавал собственный маркер как чужую установку;
* 2. bootstrap (раскладка оркестратора и runtime-пакета) выполнялся вне
* оркестратора, поэтому не имел владельца и не попадал в rollback.
*/
const installSource = readFileSync(new URL("../src/commands/install.ts", import.meta.url), "utf8");
const reconfigureSource = readFileSync(
new URL("../src/commands/reconfigure.ts", import.meta.url),
"utf8"
);
const installerSource = readFileSync(new URL("../../package/install.sh", import.meta.url), "utf8");
function occurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
describe("preflight: clean-host — условие входа, а не проверка возможностей", () => {
const context = testContext(baselineConfig());
test("в режиме install умолчания у checkCleanHost нет", async () => {
await expect(preflight(context)).rejects.toThrow(/checkCleanHost must be stated explicitly/);
await expect(preflight(context, { requireCapabilities: true })).rejects.toThrow(
/checkCleanHost must be stated explicitly/
);
});
test("отказ наступает раньше любой работы с системой", async () => {
// Проверка стоит первой в preflight, поэтому тест проходит и не под root,
// и на не-Debian машине: до assertPlatform дело не доходит.
await expect(preflight(context)).rejects.toThrow(/checkCleanHost/);
});
});
describe("последовательность install", () => {
test("clean-host запрашивается ровно один раз за операцию", () => {
expect(occurrences(installSource, "checkCleanHost: true")).toBe(1);
expect(occurrences(installSource, "checkCleanHost: false")).toBe(1);
});
// Регрессия: повторный clean-host стоял ПОСЛЕ advanceInstallState и падал на
// /var/lib/hy2xs/install-state.json, записанном этой же установкой.
test("clean-host проверяется до первой записи состояния", () => {
const cleanHostAt = installSource.indexOf("checkCleanHost: true");
const firstStateWriteAt = installSource.indexOf("await advanceInstallState(");
expect(cleanHostAt).toBeGreaterThan(-1);
expect(firstStateWriteAt).toBeGreaterThan(-1);
expect(cleanHostAt).toBeLessThan(firstStateWriteAt);
});
test("проход capabilities явно отказывается от clean-host", () => {
const capabilitiesAt = installSource.indexOf("requireCapabilities: true");
const optOutAt = installSource.indexOf("checkCleanHost: false");
expect(capabilitiesAt).toBeGreaterThan(-1);
// Оба флага стоят в одном вызове.
expect(Math.abs(capabilitiesAt - optOutAt)).toBeLessThan(80);
});
test("bootstrap — первый мутирующий шаг и он под флагом владения", () => {
const flagAt = installSource.indexOf("ownership.bootstrapTouched = true");
const callAt = installSource.indexOf("await bootstrapRuntime(");
const depsAt = installSource.indexOf("await installDeps(");
expect(flagAt).toBeGreaterThan(-1);
expect(callAt).toBeGreaterThan(-1);
expect(flagAt).toBeLessThan(callAt);
expect(callAt).toBeLessThan(depsAt);
});
test("установка продолжается от установленного runtime-пакета", () => {
expect(installSource).toContain("context.options.packageDir = await bootstrapRuntime(context)");
});
// Регрессия: diagnosticsCollect стоял перед rollback обычным await, поэтому
// её собственный отказ (нет места, недоступен journalctl) отменял откат.
test("отказ диагностики не отменяет rollback", () => {
const diagnosticsAt = installSource.indexOf("await diagnosticsCollect(options)");
const rollbackAt = installSource.indexOf("await rollbackFailedInstall(");
const tryAt = installSource.lastIndexOf("try {", diagnosticsAt);
const catchAt = installSource.indexOf("catch (diagnosticsError)");
expect(tryAt).toBeLessThan(diagnosticsAt);
expect(catchAt).toBeGreaterThan(diagnosticsAt);
expect(catchAt).toBeLessThan(rollbackAt);
});
});
describe("классификация отказа reconfigure/repair", () => {
function ownership(overrides: Partial<Parameters<typeof classifyReconfigureFailure>[0]> = {}) {
return {
configTouched: false,
unitsTouched: false,
firewallTouched: false,
envTouched: false,
servicesRestarted: false,
...overrides
};
}
// Регрессия: фаза выбиралась регулярным выражением по тексту ошибки. Тот же
// приём уже убрали из install — здесь он остался.
test("до firewall отказ не выдаёт себя за firewall failure", () => {
expect(classifyReconfigureFailure(ownership())).toBe("reconfigure_failed");
expect(classifyReconfigureFailure(ownership({ configTouched: true }))).toBe("reconfigure_failed");
expect(classifyReconfigureFailure(ownership({ unitsTouched: true }))).toBe("reconfigure_failed");
});
test("тронутый firewall даёт firewall failure", () => {
expect(classifyReconfigureFailure(ownership({ unitsTouched: true, firewallTouched: true }))).toBe(
"firewall_connectivity_failure"
);
});
test("после рестарта сервисов отказ относится к smoke", () => {
expect(
classifyReconfigureFailure(ownership({ firewallTouched: true, servicesRestarted: true }))
).toBe("smoke_failed");
});
// Проверяется код, а не упоминание: комментарий, объясняющий, ПОЧЕМУ
// классификация по тексту ошибки убрана, должен быть разрешён.
test("текст ошибки на классификацию не влияет", () => {
expect(reconfigureSource).not.toContain(".test(message)");
expect(reconfigureSource).toContain("classifyReconfigureFailure(ownership)");
});
test("отказ диагностики не отменяет откат firewall и конфигов", () => {
const diagnosticsAt = reconfigureSource.indexOf("await diagnosticsCollect(options)");
const firewallRollbackAt = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState(context)");
const catchAt = reconfigureSource.indexOf("catch (diagnosticsError)");
expect(catchAt).toBeGreaterThan(diagnosticsAt);
expect(catchAt).toBeLessThan(firewallRollbackAt);
expect(firewallRollbackAt).toBeLessThan(stateRollbackAt);
});
});
describe("install.sh остаётся read-only bootstrap", () => {
test("установщик запускает preflight и передаёт мутацию через exec", () => {
expect(installerSource).toContain("preflight-install --package-dir");
expect(installerSource).toMatch(/exec "\$ORCHESTRATOR" install --package-dir/);
});
test("в установщике не осталось ни одной мутирующей команды", () => {
const mutating = /^\s*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)\s/;
const offenders = installerSource
.split(/\r?\n/)
.map((line, index) => ({ line, number: index + 1 }))
.filter((entry) => mutating.test(entry.line));
expect(
offenders,
`install.sh мутирует хост: ${offenders.map((entry) => `${entry.number}: ${entry.line.trim()}`).join("; ")}`
).toEqual([]);
});
test("установщик не раскладывает оркестратор сам", () => {
expect(installerSource).not.toContain("ORCHESTRATOR_INSTALL_PATH=");
expect(installerSource).not.toContain("RUNTIME_PACKAGE_DIR=");
});
});