Files
HY2XS_flamy/orchestrator/test/install-boundary.test.ts
T
founder e84fdedc4b fix(v1): сделать откат неотменяемым, а маркер установки — долговечным
Три дефекта одного класса в failure path install/reconfigure.

1. Запись состояния отказа отменяла откат.

   Обработчик ошибки первым делом писал в install-state фазу отказа обычным
   await и только потом откатывался. Эта запись — mkdir, write и chown в
   /var/lib/hy2xs, то есть она падает ровно там, где откат нужнее всего:
   заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил
   управление наружу, и обязательное восстановление не выполнялось вовсе —
   применённый firewall и развёрнутые сервисы оставались на сервере.

   Необязательная телеметрия состояния стояла перед обязательным
   восстановлением. Для диагностики это уже было закрыто, для записи
   состояния — нет.

2. Откат отменял сам себя.

   Он был написан цепочкой await, а каждая его стадия — systemctl, cp, rm -rf
   и nft, то есть умеет упасть сама. Отказ первой стадии отменял все
   последующие. В reconfigure это означало сервер одновременно с применённым
   сломанным firewall И без восстановленных из /etc/hy2xs/backups конфигов.
   Внутри rollbackCurrentState болезнь та же: единственная команда без
   `|| true` (systemctl daemon-reload) отменяла перезапуск сервисов строкой
   ниже, и восстановленные unit-файлы не применялись.

   Стадии стали независимыми: выполняются все, отказавшие перечисляются в
   журнале, наружу уходит исходная ошибка операции.

3. У маркера установки было два писателя с разными гарантиями.

   install перезаписывал файл на месте (writeText), reconfigure подставлял
   атомарно. Слабейшая гарантия досталась команде, которая этот файл создаёт.
   Перезапись на месте укорачивает файл до нуля и только потом наполняет:
   отказ между этими моментами оставляет половину JSON, который не
   разбирается — reconfigure видит его как отсутствующий, clean-host как
   присутствующий, а хост уже изменён.

   Атомарности при этом мало. rename() без fsync даёт атомарность видимости
   без долговечности: после потери питания ext4 штатно отдаёт по этому пути
   нулевой файл. Для метаданных восстановления это неприемлемо, поэтому
   порядок теперь: права/владелец -> fsync файла -> rename -> fsync каталога.

   Заодно ownership-флаг переименован в stateTouched и взводится ДО записи:
   отказ на chown после успешного write оставлял файл на диске при
   невзведённом флаге, то есть давал fatal_pre_apply («ничего не изменено»)
   при уже существующем маркере установки.

Тесты: rollback-mandatory.test.ts (внедрение отказа в стадию, проводка команд),
atomic-write.test.ts (замена целиком, прежний файл при отказе, отсутствие
временных файлов, права, guard). Приёмка сборки закрепляет порядок шагов
атомарной записи, отсутствие незащищённой записи состояния в обработчиках и
отсутствие отменяемых цепочек в откате.
2026-08-30 18:11:55 +05:00

224 lines
10 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 { afterEach, describe, expect, test } from "bun:test";
import { classifyFailure } from "../src/commands/install";
import { disableReadOnlyGuard, enableReadOnlyGuard, isReadOnlyGuardActive } from "../src/lib/guard";
import { writeText, writeTextAtomic } from "../src/lib/fs";
import {
runMutating,
runMutatingHidden,
runMutatingRaw,
runMutatingVisible,
runReadOnly,
runReadOnlySecret
} from "../src/lib/process";
type Ownership = Parameters<typeof classifyFailure>[0];
function ownership(overrides: Partial<Ownership> = {}): Ownership {
return {
stateTouched: false,
bootstrapTouched: false,
depsTouched: false,
filesystemTouched: false,
uiTouched: false,
hysteriaTouched: false,
configTouched: false,
unitsTouched: false,
firewallTouched: false,
postInstallTouched: false,
bootstrapSecretTouched: false,
servicesStarted: false,
...overrides
};
}
afterEach(() => {
disableReadOnlyGuard();
});
describe("read-only guard (PHASE 0)", () => {
test("по умолчанию выключен", () => {
expect(isReadOnlyGuardActive()).toBe(false);
});
test("под guard'ом запись в файл невозможна", async () => {
enableReadOnlyGuard("test phase");
await expect(writeText("/tmp/hy2xs-guard-probe", "x")).rejects.toThrow(/read-only guard violation/);
await expect(
writeTextAtomic("/tmp/hy2xs-guard-probe", "x", { mode: 0o600, owner: "root", group: "root" })
).rejects.toThrow(/read-only guard violation/);
});
test("под guard'ом мутирующие раннеры недоступны", async () => {
enableReadOnlyGuard("test phase");
await expect(runMutating`true`).rejects.toThrow(/read-only guard violation/);
await expect(runMutatingVisible`true`).rejects.toThrow(/read-only guard violation/);
await expect(runMutatingHidden`true`).rejects.toThrow(/read-only guard violation/);
await expect(runMutatingRaw("true")).rejects.toThrow(/read-only guard violation/);
});
// Регрессия: раньше существовал один универсальный `run`, через который
// мутирующие команды (useradd, install -d, mkdir) проходили мимо guard'а.
// Read-only раннеры обязаны работать под guard'ом, мутирующие — нет.
test("read-only раннеры под guard'ом не блокируются", async () => {
enableReadOnlyGuard("test phase");
// Проверяется guard, а не наличие POSIX-shell: на машине разработчика без
// `sh` вызов упадёт по ENOENT, и это тоже доказывает, что guard его
// пропустил. Значение имеет только отсутствие guard violation.
for (const call of [
() => runReadOnly`printf hy2xs`,
() => runReadOnlySecret`printf hy2xs`
]) {
try {
expect(await call()).toBe("hy2xs");
} catch (error) {
expect(String(error)).not.toMatch(/read-only guard violation/);
}
}
});
test("сообщение мутирующего раннера называет операцию", async () => {
enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
await expect(runMutating`mktemp -d`).rejects.toThrow(/runMutating\(mktemp -d\).*PHASE 0/s);
});
test("сообщение называет операцию и фазу", async () => {
enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
await expect(writeText("/tmp/hy2xs-guard-probe", "x")).rejects.toThrow(
/writeText\(\/tmp\/hy2xs-guard-probe\).*PHASE 0/s
);
});
test("guard снимается явно", () => {
enableReadOnlyGuard("test phase");
expect(isReadOnlyGuardActive()).toBe(true);
disableReadOnlyGuard();
expect(isReadOnlyGuardActive()).toBe(false);
});
});
describe("классификация отказа установки", () => {
// Ключевой инвариант: пока операция ничего не применила, отказ обязан быть
// pre-apply, что бы ни было написано в тексте ошибки.
test("до любой мутации отказ — fatal_pre_apply", () => {
expect(classifyFailure(ownership(), "installing")).toBe("fatal_pre_apply");
expect(classifyFailure(ownership(), "preflight_ok")).toBe("fatal_pre_apply");
});
// Регрессия: install-state.json пишется сразу после успешного preflight, до
// installDeps. Пока classifyFailure его не учитывал, падение apt-get
// объявлялось «на сервере ничего не изменено», rollback пропускался, а
// /var/lib/hy2xs/install-state.json оставался на хосте и ломал следующую
// установку по clean-host контракту.
test("тронутый install-state сам по себе делает отказ post-apply", () => {
expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).toBe("fatal_post_apply");
});
// Вторая половина той же регрессии. Флаг назывался stateWritten и взводился
// ПОСЛЕ успешной записи, а запись маркера — три операции (mkdir, write,
// chown). Отказ на chown оставлял /var/lib/hy2xs/install-state.json на диске
// при невзведённом флаге, то есть давал «на сервере ничего не изменено» с
// уже существующим маркером установки.
test("частично выполненная запись маркера уже post-apply", () => {
// Ровно то состояние, которое оставляет упавший на chown advanceInstallState:
// флаг взведён, а фаза ещё preflight_ok.
expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).not.toBe(
"fatal_pre_apply"
);
});
// Регрессия: раскладку оркестратора и runtime-пакета выполнял install.sh,
// поэтому у оркестратора не было флага, которым это можно было бы учесть.
// Отказ сразу после bootstrap выглядел как «хост не тронут», хотя на диске
// уже лежали /usr/local/lib/hy2xs и symlink в /usr/local/bin.
test("начатый bootstrap уже делает отказ post-apply", () => {
expect(classifyFailure(ownership({ bootstrapTouched: true }), "preflight_ok")).toBe(
"fatal_post_apply"
);
expect(
classifyFailure(ownership({ stateTouched: true, bootstrapTouched: true }), "bootstrap_installed")
).toBe("fatal_post_apply");
});
test("падение installDeps после записи состояния — post-apply", () => {
expect(
classifyFailure(ownership({ stateTouched: true, depsTouched: true }), "preflight_ok")
).toBe("fatal_post_apply");
});
// Флаг взводится ПЕРЕД шагом, поэтому частично применённый apt-get уже
// считается изменением хоста, даже если installDeps не завершился.
test("начатая установка пакетов уже делает отказ post-apply", () => {
expect(classifyFailure(ownership({ depsTouched: true }), "preflight_ok")).toBe("fatal_post_apply");
});
test("каждая мутирующая стадия до firewall даёт post-apply", () => {
const stages = [
"filesystemTouched",
"uiTouched",
"hysteriaTouched",
"configTouched",
"unitsTouched"
] as const;
for (const stage of stages) {
expect(classifyFailure(ownership({ [stage]: true }), "installing")).toBe("fatal_post_apply");
}
});
test("тронутый firewall классифицируется как firewall failure", () => {
expect(
classifyFailure(ownership({ unitsTouched: true, firewallTouched: true }), "firewall_applied")
).toBe("firewall_connectivity_failure");
});
test("после записи post-install env отказ — postinstall validation", () => {
expect(
classifyFailure(
ownership({ unitsTouched: true, firewallTouched: true, postInstallTouched: true }),
"postinstall_env_written"
)
).toBe("postinstall_validation_failure");
});
test("отказ на bootstrap-секрете тоже postinstall validation", () => {
expect(
classifyFailure(
ownership({
unitsTouched: true,
firewallTouched: true,
postInstallTouched: true,
bootstrapSecretTouched: true
}),
"postinstall_env_written"
)
).toBe("postinstall_validation_failure");
});
test("после старта сервисов различаются smoke и service failure", () => {
const started = ownership({
unitsTouched: true,
firewallTouched: true,
postInstallTouched: true,
bootstrapSecretTouched: true,
servicesStarted: true
});
expect(classifyFailure(started, "smoke_running")).toBe("smoke_readiness_timeout");
expect(classifyFailure(started, "smoke_failed")).toBe("smoke_readiness_timeout");
expect(classifyFailure(started, "services_started")).toBe("service_start_failure");
});
// Регрессия: раньше классификация шла по подстрокам сообщения, поэтому
// preflight-ошибка со словом "nftables" приводила к откату чужого firewall.
test("текст ошибки не влияет на классификацию", () => {
expect(classifyFailure(ownership(), "installing")).toBe("fatal_pre_apply");
expect(classifyFailure(ownership({ depsTouched: true }), "deps_ok")).toBe("fatal_post_apply");
});
// Единственный способ получить fatal_pre_apply — не тронуть вообще ничего.
test("fatal_pre_apply невозможен ни при одном взведённом флаге", () => {
const empty = ownership();
for (const key of Object.keys(empty) as (keyof typeof empty)[]) {
expect(classifyFailure(ownership({ [key]: true }), "installing")).not.toBe("fatal_pre_apply");
}
});
});