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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
176 lines
9.0 KiB
TypeScript
176 lines
9.0 KiB
TypeScript
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=");
|
||
});
|
||
});
|