4e7f54b9ff
Снятие автоматического отката 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 не существовал.
196 lines
10 KiB
TypeScript
196 lines
10 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 { 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=");
|
||
});
|
||
});
|