Files
HY2XS_flamy/orchestrator/test/install-sequence.test.ts
founder 4e7f54b9ff fix(orchestrator): сделать staged firewall guard транзакционным
Снятие автоматического отката firewall было утверждением, а не фактом:

    systemctl stop <unit>.timer <unit>.service || true
    -> "firewall rollback timer disarmed"
    -> phase=installed

Отказ остановки стирался через `|| true`, и взведённый таймер мог вернуть
прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать `|| true`
нельзя: для транзиентного юнита, уже убранного systemd, `systemctl stop`
возвращает 5 — законный исход, неотличимый от успеха.

Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke.
Окно 45 секунд короче худшего случая smoke, а единственной проверкой firewall
был `nft -c` — разбор текущего файла, каким бы он ни был. Откатившийся прежний
ruleset проходил её зелёным, и сервер объявлялся настроенным с firewall,
который операция же и заменила.

Оба закрываются маркером /run/hy2xs/rollback/<op>/auto-rollback-fired, который
rollback-скрипт создаёт первым действием. Инвариант стал детерминированным:

    маркер отсутствует И timer/service inactive => можно фиксировать успех

Остальное в том же проходе:

- auto-rollback переехал из однострочного `sh -c` в сгенерированный скрипт.
  Прежний держался на склейке соседних кавычек и на том, что op-id не содержит
  пробелов; теперь ключ проверяется, а скрипт покрыт тестом и shell-парсером;
- скрипт накапливает rc и уходит в failed вместо молчаливого 0 при частичном
  восстановлении. Состояние nftables.service он сознательно не трогает:
  ExecStop у него делает `nft flush ruleset`;
- smoke сверяет ЭФФЕКТИВНЫЙ firewall: фрагмент на диске против отрендеренного,
  принадлежность entrypoint и загруженность таблицы inet hy2xs;
- откат восстанавливает enabled/active nftables.service — стадиями, идущими до
  применения ruleset;
- остановка guard'а в rollbackFirewallNow стала стадией с отчётом, а не вызовом
  с `|| true` внутри;
- `*.candidate` больше не остаются на диске навсегда;
- стадии восстановления reconfigure независимы по ОТДЕЛЬНОМУ ФАЙЛУ, а не по
  группе;
- ключ операции считается одной функцией: install писал в маркер сырой
  ISO-timestamp, и путь /run/hy2xs/rollback/<op_id> из runbook не существовал.
2026-08-30 22:54:57 +05:00

196 lines
10 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 { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { preflight } from "../src/steps/preflight";
import { classifyReconfigureFailure } from "../src/commands/reconfigure";
import { FirewallGuardFiredError } from "../src/steps/firewall";
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, error)");
});
/**
* Сработавший guard классифицируется по ТИПУ ошибки.
*
* Разница с прежним разбором сообщения принципиальна, и проверяется она
* именно так: ошибка, в тексте которой есть все нужные слова, но у которой
* другой тип, обязана классифицироваться по владению, как и раньше.
*/
test("сработавший guard опознаётся по типу ошибки, а не по её тексту", () => {
const late = ownership({ firewallTouched: true, servicesRestarted: true });
expect(classifyReconfigureFailure(late, new FirewallGuardFiredError("guard"))).toBe(
"firewall_guard_fired"
);
expect(
classifyReconfigureFailure(late, new Error("automatic firewall rollback has already fired"))
).toBe("smoke_failed");
expect(classifyReconfigureFailure(late)).toBe("smoke_failed");
});
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=");
});
});