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:
2026-08-27 12:14:47 +05:00
parent ddf0ddf71e
commit 2b4a2cb2d5
18 changed files with 1188 additions and 179 deletions
+57 -2
View File
@@ -1,4 +1,5 @@
import { install } from "./commands/install";
import { preflightInstall } from "./commands/preflight-install";
import { reconfigure, repair } from "./commands/reconfigure";
import { doctor } from "./commands/doctor";
import { status } from "./commands/status";
@@ -8,9 +9,10 @@ import type { InstallOptions, ReconfigureOptions } from "./types/context";
function usage(): never {
console.error("Usage:");
console.error(" hy2xs-orchestrator preflight-install --package-dir <path> [--config <path>]");
console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke] [--non-interactive]");
console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator repair --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator repair --package-dir <path> [--config <path>] [--allow-partial-state] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator doctor --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator status --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator diagnostics collect --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
@@ -148,6 +150,7 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions {
nonInteractive: false,
dryRun: false,
apply: false,
allowPartialState: false,
skipFirewall: false,
skipServiceStart: false,
skipSmoke: false
@@ -164,6 +167,9 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions {
options.sourceConfigPath = takeValue(args, i, arg);
i += 1;
break;
case "--allow-partial-state":
options.allowPartialState = true;
break;
case "--dry-run":
options.dryRun = true;
break;
@@ -212,14 +218,63 @@ function parseCommonOptions(args: string[]): InstallOptions {
return parseInstallOptions(args);
}
/**
* PHASE 0 не принимает ничего, что могло бы повлиять на мутацию: только
* расположение пакета и источник конфигурации.
*/
function parsePreflightInstallOptions(args: string[]): InstallOptions {
const options: InstallOptions = {
packageDir: "",
sourceConfigPath: "",
runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: true,
skipFirewall: false,
skipServiceStart: false,
skipSmoke: false
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
switch (arg) {
case "--package-dir":
options.packageDir = takeValue(args, i, arg);
i += 1;
break;
case "--config":
options.sourceConfigPath = takeValue(args, i, arg);
i += 1;
break;
default:
console.error(`Unknown argument: ${arg}`);
usage();
}
}
if (!options.packageDir) {
console.error("Missing --package-dir");
usage();
}
return options;
}
async function main(): Promise<void> {
const [command, ...args] = Bun.argv.slice(2);
if (command === "preflight-install") {
await preflightInstall(parsePreflightInstallOptions(args));
return;
}
if (command === "install") {
await install(parseInstallOptions(args));
return;
}
if (command === "reconfigure") {
await reconfigure(parseReconfigureOptions(args));
const options = parseReconfigureOptions(args);
if (options.allowPartialState) {
console.error("--allow-partial-state is only valid for `repair`");
usage();
}
await reconfigure(options);
return;
}
if (command === "repair") {