fix(install): двухфазная установка, clean-host контракт и проверка поколения
Установщик мог повредить работающий сервер до того, как откажется его
трогать: install.sh переписывал /usr/local/lib/hy2xs, раскладывал
runtime-пакет и перезаписывал install-state.json, и только потом
запускал clean-host preflight. При ошибочном запуске поверх старой
установки rollback дополнительно делал stop и disable для работающих
hysteria-server и hy2xs-admin.
Установка разделена на две фазы с жёсткой границей:
PHASE 0 - read only: права, checksums пакета, clean-host preflight
из распакованного архива (новая команда preflight-install)
PHASE 1 - mutation: раскладка оркестратора и сама установка
Граница держится не соглашением, а read-only guard: под ним writeText,
writeTextAtomic и мутирующие раннеры lib/process кидают ошибку.
Внутри install() preflight выполняется раньше первой записи состояния.
Остальное в этом же инварианте:
- clean-host контракт расширен с двух маркеров до четырнадцати, пути
установки и данных берутся из конфигурации, а не захардкожены;
- отсутствие HY2XS_CONFIG_SCHEMA_VERSION трактуется как legacy, а не
как текущая схема: до v1 этого поля не существовало. Тест,
закреплявший прежнее поведение, инвертирован;
- install-state несёт идентификацию поколения (product, release_line,
config_schema_version); reconfigure и repair проверяют её до всего
остального, потому что installed: true мог остаться и от 0.x;
- repair требует явного --allow-partial-state;
- классификация отказа опирается на ownership-флаги, а не на текст
ошибки: раньше сообщение со словом nftables приводило к откату
чужого firewall. stop/disable выполняется только для юнитов,
развёрнутых текущей операцией, а fatal_pre_apply не делает
системного отката и не собирает diagnostics-бандл.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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 { runHidden, runRawVisible, runVisible } from "../src/lib/process";
|
||||
|
||||
type Ownership = Parameters<typeof classifyFailure>[0];
|
||||
|
||||
function ownership(overrides: Partial<Ownership> = {}): Ownership {
|
||||
return {
|
||||
stateWritten: false,
|
||||
depsInstalled: false,
|
||||
filesystemPrepared: false,
|
||||
unitsDeployed: false,
|
||||
firewallTouched: false,
|
||||
postInstallWritten: 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(runVisible`true`).rejects.toThrow(/read-only guard violation/);
|
||||
await expect(runHidden`true`).rejects.toThrow(/read-only guard violation/);
|
||||
await expect(runRawVisible("true")).rejects.toThrow(/read-only guard violation/);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
test("установленные пакеты уже делают отказ post-apply", () => {
|
||||
expect(classifyFailure(ownership({ depsInstalled: true }), "preflight_ok")).toBe("fatal_post_apply");
|
||||
});
|
||||
|
||||
test("развёрнутые юниты без firewall — post-apply", () => {
|
||||
expect(classifyFailure(ownership({ filesystemPrepared: true, unitsDeployed: true }), "units_deployed")).toBe(
|
||||
"fatal_post_apply"
|
||||
);
|
||||
});
|
||||
|
||||
test("тронутый firewall классифицируется как firewall failure", () => {
|
||||
expect(
|
||||
classifyFailure(ownership({ unitsDeployed: true, firewallTouched: true }), "firewall_applied")
|
||||
).toBe("firewall_connectivity_failure");
|
||||
});
|
||||
|
||||
test("после записи post-install env отказ — postinstall validation", () => {
|
||||
expect(
|
||||
classifyFailure(
|
||||
ownership({ unitsDeployed: true, firewallTouched: true, postInstallWritten: true }),
|
||||
"postinstall_env_written"
|
||||
)
|
||||
).toBe("postinstall_validation_failure");
|
||||
});
|
||||
|
||||
test("после старта сервисов различаются smoke и service failure", () => {
|
||||
const started = ownership({
|
||||
unitsDeployed: true,
|
||||
firewallTouched: true,
|
||||
postInstallWritten: 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({ depsInstalled: true }), "deps_ok")).toBe("fatal_post_apply");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user