Files
HY2XS_flamy/orchestrator/test/install-boundary.test.ts
T
founder a88268b0cd fix(install): сделать границу «хост изменён» настоящим инвариантом
fatal_pre_apply мог означать «хост уже изменён». install-state.json пишется
сразу после успешного preflight, до установки пакетов, но классификация
отказа его не учитывала. Падение apt-get объявлялось как «на сервере ничего
не изменено»: откат и обработка состояния пропускались, а маркер оставался на
диске и ломал следующую установку по clean-host контракту.

Ownership-флаги переформулированы с «шаг успешно завершился» на «операция
могла начать менять систему» и взводятся перед мутирующим вызовом: apt-get
умеет изменить систему и упасть. fatal_pre_apply теперь недостижим ни при
одном взведённом флаге, включая stateWritten.

Read-only guard PHASE 0 можно было обойти. Guard стоял на writeText,
writeTextAtomic, runVisible, runHidden и runRawVisible, но не на универсальном
run, через который в коде проходили и наблюдение (ss, systemctl is-active), и
настоящие мутации (useradd, install -d, mkdir, cp -a, tar). Универсального
раннера больше нет: runReadOnly/runReadOnlySecret без guard'а и runMutating*
под guard'ом, выбор — явное решение на месте вызова.

clean-host не замечал часть того, что удаляет purge. /var/lib/hysteria с
ACME-состоянием Hysteria, /var/log/hy2xs, /usr/local/lib/hy2xs и
/usr/local/bin/hy2xs-orchestrator не были маркерами: сервер, где остался
только старый runtime-state Hysteria, проходил проверку и получал свежую
установку поверх чужого состояния. Пути, которые install.sh создаёт между
фазами, помечены как созданные установщиком, иначе PHASE 1 отказала бы на
собственном оркестраторе.

purge-v0.sh --keep-hysteria-binary противоречил установщику: скрипт сохранял
/usr/local/bin/hysteria и сообщал «хост чист для установки HY2XS v1», хотя
clean-host считает этот бинарник legacy-маркером. Флаг удалён.

DNS проверялся на существование A-записи, но не на то, куда она ведёт. После
принудительной смены IPv4 провайдером doctor отвечал успехом, хотя клиентская
ссылка отправляла людей на чужую машину. Проверялся при этом HY2XS_DOMAIN,
тогда как в hysteria2:// уезжает HY2XS_PUBLIC_HOST.

Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать
множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка
живёт в общем preflight, поэтому действует в install, reconfigure и doctor.
Адрес определяется локально, без внешних сервисов определения IP. Строгость
управляется HY2XS_PUBLIC_ENDPOINT_POLICY (strict по умолчанию); отсутствие
A-записи фатально при любом значении.

TS-санитайзер приведён к той же формулировке, что и Go: URL-значение
определяется по самому значению, а не по имени ключа.
2026-08-27 20:49:38 +05:00

197 lines
8.6 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 {
stateWritten: 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({ stateWritten: true }), "preflight_ok")).toBe("fatal_post_apply");
});
test("падение installDeps после записи состояния — post-apply", () => {
expect(
classifyFailure(ownership({ stateWritten: 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");
}
});
});