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 { install } from "./commands/install";
import { preflightInstall } from "./commands/preflight-install";
import { reconfigure, repair } from "./commands/reconfigure"; import { reconfigure, repair } from "./commands/reconfigure";
import { doctor } from "./commands/doctor"; import { doctor } from "./commands/doctor";
import { status } from "./commands/status"; import { status } from "./commands/status";
@@ -8,9 +9,10 @@ import type { InstallOptions, ReconfigureOptions } from "./types/context";
function usage(): never { function usage(): never {
console.error("Usage:"); 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 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 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 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 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]"); 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, nonInteractive: false,
dryRun: false, dryRun: false,
apply: false, apply: false,
allowPartialState: false,
skipFirewall: false, skipFirewall: false,
skipServiceStart: false, skipServiceStart: false,
skipSmoke: false skipSmoke: false
@@ -164,6 +167,9 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions {
options.sourceConfigPath = takeValue(args, i, arg); options.sourceConfigPath = takeValue(args, i, arg);
i += 1; i += 1;
break; break;
case "--allow-partial-state":
options.allowPartialState = true;
break;
case "--dry-run": case "--dry-run":
options.dryRun = true; options.dryRun = true;
break; break;
@@ -212,14 +218,63 @@ function parseCommonOptions(args: string[]): InstallOptions {
return parseInstallOptions(args); 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> { async function main(): Promise<void> {
const [command, ...args] = Bun.argv.slice(2); const [command, ...args] = Bun.argv.slice(2);
if (command === "preflight-install") {
await preflightInstall(parsePreflightInstallOptions(args));
return;
}
if (command === "install") { if (command === "install") {
await install(parseInstallOptions(args)); await install(parseInstallOptions(args));
return; return;
} }
if (command === "reconfigure") { 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; return;
} }
if (command === "repair") { if (command === "repair") {
+151 -101
View File
@@ -1,8 +1,13 @@
import type { InstallContext, InstallOptions } from "../types/context"; import type { InstallContext, InstallOptions } from "../types/context";
import { fileExists, readText, writeText, writeTextAtomic } from "../lib/fs"; import { fileExists, readText, writeText, writeTextAtomic } from "../lib/fs";
import { runVisible } from "../lib/process"; import { runVisible } from "../lib/process";
import { setOperationContext, step, stepDone } from "../lib/log"; import { info, setOperationContext, step, stepDone } from "../lib/log";
import { readPackageValue } from "../lib/packageMeta"; import { readPackageValue } from "../lib/packageMeta";
import {
INSTALL_STATE_PATH,
REPAIR_HINT,
buildInstallStateRecord
} from "../lib/installState";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight"; import { preflight } from "../steps/preflight";
import { installDeps } from "../steps/deps"; import { installDeps } from "../steps/deps";
@@ -16,8 +21,6 @@ import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke"; import { smoke } from "../steps/smoke";
import { diagnosticsCollect } from "./diagnostics"; import { diagnosticsCollect } from "./diagnostics";
const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
type InstallPhase = type InstallPhase =
| "installing" | "installing"
| "preflight_ok" | "preflight_ok"
@@ -37,26 +40,43 @@ type InstallPhase =
| "failed" | "failed"
| "installed"; | "installed";
type InstallState = { /**
installed: boolean; * Что именно текущая операция успела изменить на сервере.
phase: InstallPhase; *
version: string; * Rollback обязан опираться на это, а не на текст ошибки: остановить чужой
build_id: string; * работающий сервис только потому, что в сообщении встретилось слово
op_id: string; * "firewall", — недопустимо.
started_at: string; */
updated_at: string; type OperationOwnership = {
owned_paths: string[]; stateWritten: boolean;
last_error: string; depsInstalled: boolean;
repair_hint?: string; filesystemPrepared: boolean;
unitsDeployed: boolean;
firewallTouched: boolean;
postInstallWritten: boolean;
servicesStarted: boolean;
}; };
type FailureKind = type FailureKind =
| "fatal_pre_apply" | "fatal_pre_apply"
| "fatal_post_apply"
| "firewall_connectivity_failure" | "firewall_connectivity_failure"
| "service_start_failure" | "service_start_failure"
| "smoke_readiness_timeout" | "smoke_readiness_timeout"
| "postinstall_validation_failure"; | "postinstall_validation_failure";
function newOwnership(): OperationOwnership {
return {
stateWritten: false,
depsInstalled: false,
filesystemPrepared: false,
unitsDeployed: false,
firewallTouched: false,
postInstallWritten: false,
servicesStarted: false
};
}
function installOwnedPaths(context: InstallContext): string[] { function installOwnedPaths(context: InstallContext): string[] {
return [ return [
context.options.runtimeConfigPath, context.options.runtimeConfigPath,
@@ -71,74 +91,87 @@ function installOwnedPaths(context: InstallContext): string[] {
]; ];
} }
async function writeInstallState(state: InstallState): Promise<void> { async function writeInstallState(
context: InstallContext,
phase: InstallPhase,
lastError: string
): Promise<void> {
const record = buildInstallStateRecord({
productVersion: context.packageVersion,
buildId: context.packageBuildId,
opId: context.installDate,
startedAt: context.installDate,
phase,
installed: phase === "installed",
ownedPaths: installOwnedPaths(context),
lastError,
repairHint: phase === "installed" ? undefined : REPAIR_HINT
});
await runVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`; await runVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`;
await writeText(INSTALL_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, 0o644); await writeText(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, 0o644);
await runVisible`chown root:root ${INSTALL_STATE_PATH}`; await runVisible`chown root:root ${INSTALL_STATE_PATH}`;
} }
async function markInstallSuccessful(context: InstallContext): Promise<void> { async function advanceInstallState(
await writeInstallState({ context: InstallContext,
phase: "installed", ownership: OperationOwnership,
installed: true, phase: InstallPhase,
version: context.packageVersion, lastError = ""
build_id: context.packageBuildId, ): Promise<void> {
op_id: context.installDate, await writeInstallState(context, phase, lastError);
started_at: context.installDate, ownership.stateWritten = true;
updated_at: new Date().toISOString(),
owned_paths: installOwnedPaths(context),
last_error: ""
});
} }
async function advanceInstallState(context: InstallContext, phase: InstallPhase, lastError = ""): Promise<void> { /**
await writeInstallState({ * Классификация опирается на то, что операция реально успела применить.
phase, * `fatal_pre_apply` по определению означает «на сервере ничего не изменено».
installed: phase === "installed", */
version: context.packageVersion, export function classifyFailure(ownership: OperationOwnership, phase: InstallPhase): FailureKind {
build_id: context.packageBuildId, if (ownership.servicesStarted) {
op_id: context.installDate, return phase === "smoke_running" || phase === "smoke_failed"
started_at: context.installDate, ? "smoke_readiness_timeout"
updated_at: new Date().toISOString(), : "service_start_failure";
owned_paths: installOwnedPaths(context), }
last_error: lastError, if (ownership.postInstallWritten) {
repair_hint: phase === "installed" ? undefined : "run: hy2xs-orchestrator repair --package-dir <path> --config /etc/hy2xs/hy2xs.env" return "postinstall_validation_failure";
}); }
} if (ownership.firewallTouched) {
function classifyFailure(phase: InstallPhase, message: string): FailureKind {
const m = message.toLowerCase();
if (phase === "firewall_applied" || m.includes("nft") || m.includes("firewall") || m.includes("ssh port check failed")) {
return "firewall_connectivity_failure"; return "firewall_connectivity_failure";
} }
if (phase === "services_started" || m.includes("is not active")) { if (ownership.unitsDeployed || ownership.filesystemPrepared || ownership.depsInstalled) {
return "service_start_failure"; return "fatal_post_apply";
}
if (phase === "smoke_failed" || m.includes("not ready") || m.includes("timeout") || m.includes("listener")) {
return "smoke_readiness_timeout";
}
if (phase === "postinstall_env_written" || phase === "bootstrap_secret_written" || m.includes("permission") || m.includes("unexpected")) {
return "postinstall_validation_failure";
} }
return "fatal_pre_apply"; return "fatal_pre_apply";
} }
/**
* Инвариант: сервисы останавливаются и выключаются ТОЛЬКО если их развернула
* текущая операция. Иначе неудачный запуск установщика на чужом сервере
* положил бы работающий сервис.
*/
async function rollbackFailedInstall( async function rollbackFailedInstall(
context: InstallContext, context: InstallContext,
state: { firewallTouched: boolean }, ownership: OperationOwnership,
failureKind: FailureKind, failureKind: FailureKind
): Promise<void> { ): Promise<void> {
if (failureKind === "firewall_connectivity_failure" || failureKind === "postinstall_validation_failure") { if (failureKind === "fatal_pre_apply") {
if (state.firewallTouched) { info("pre-apply failure: nothing was applied, system rollback is not required");
await rollbackFirewallNow(context); return;
} }
if (ownership.firewallTouched) {
await rollbackFirewallNow(context);
}
if (!ownership.unitsDeployed) {
info("rollback: systemd units were not deployed by this operation, leaving services untouched");
return;
} }
if (failureKind === "fatal_pre_apply" || failureKind === "firewall_connectivity_failure" || failureKind === "postinstall_validation_failure") {
await runVisible`systemctl stop hysteria-server hy2xs-admin || true`; await runVisible`systemctl stop hysteria-server hy2xs-admin || true`;
await runVisible`systemctl disable hysteria-server hy2xs-admin || true`; await runVisible`systemctl disable hysteria-server hy2xs-admin || true`;
await runVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`; await runVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
}
} }
export async function install(options: InstallOptions): Promise<void> { export async function install(options: InstallOptions): Promise<void> {
@@ -169,32 +202,33 @@ export async function install(options: InstallOptions): Promise<void> {
throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256"); throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256");
} }
const state = { const ownership = newOwnership();
firewallTouched: false, let phase: InstallPhase = "installing";
lastPhase: "installing" as InstallPhase
};
try { try {
await advanceInstallState(context, "installing"); // PHASE 1 начинается только после успешного preflight: до него install
state.lastPhase = "installing"; // не пишет ни одного persistent path, включая install-state.
step("preflight"); step("preflight");
await preflight(context, { requireCapabilities: false }); await preflight(context, { requireCapabilities: false, cleanHostPhase: "install" });
stepDone("preflight"); stepDone("preflight");
await advanceInstallState(context, "preflight_ok");
state.lastPhase = "preflight_ok"; await advanceInstallState(context, ownership, "preflight_ok");
phase = "preflight_ok";
step("system dependencies"); step("system dependencies");
await installDeps(context); await installDeps(context);
ownership.depsInstalled = true;
stepDone("system dependencies"); stepDone("system dependencies");
step("preflight capabilities"); step("preflight capabilities");
await preflight(context, { requireCapabilities: true }); await preflight(context, { requireCapabilities: true, cleanHostPhase: "install" });
stepDone("preflight capabilities"); stepDone("preflight capabilities");
await advanceInstallState(context, "deps_ok"); await advanceInstallState(context, ownership, "deps_ok");
state.lastPhase = "deps_ok"; phase = "deps_ok";
step("filesystem"); step("filesystem");
await prepareFilesystem(context); await prepareFilesystem(context);
ownership.filesystemPrepared = true;
stepDone("filesystem"); stepDone("filesystem");
await advanceInstallState(context, "filesystem_ready"); await advanceInstallState(context, ownership, "filesystem_ready");
state.lastPhase = "filesystem_ready"; phase = "filesystem_ready";
step("write runtime env"); step("write runtime env");
await runVisible`mkdir -p /etc/hy2xs`; await runVisible`mkdir -p /etc/hy2xs`;
await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), { await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
@@ -203,63 +237,79 @@ export async function install(options: InstallOptions): Promise<void> {
group: "root" group: "root"
}); });
stepDone("write runtime env"); stepDone("write runtime env");
await advanceInstallState(context, "runtime_env_written"); await advanceInstallState(context, ownership, "runtime_env_written");
state.lastPhase = "runtime_env_written"; phase = "runtime_env_written";
step("bundled UI"); step("bundled UI");
await deployUi(context); await deployUi(context);
stepDone("bundled UI"); stepDone("bundled UI");
await advanceInstallState(context, "ui_deployed"); await advanceInstallState(context, ownership, "ui_deployed");
state.lastPhase = "ui_deployed"; phase = "ui_deployed";
step("Hysteria2 upstream install"); step("Hysteria2 upstream install");
await installHysteria(context); await installHysteria(context);
stepDone("Hysteria2 upstream install"); stepDone("Hysteria2 upstream install");
await advanceInstallState(context, "hysteria_installed"); await advanceInstallState(context, ownership, "hysteria_installed");
state.lastPhase = "hysteria_installed"; phase = "hysteria_installed";
step("config generation"); step("config generation");
await generateConfig(context); await generateConfig(context);
stepDone("config generation"); stepDone("config generation");
await advanceInstallState(context, "config_generated"); await advanceInstallState(context, ownership, "config_generated");
state.lastPhase = "config_generated"; phase = "config_generated";
step("systemd units"); step("systemd units");
await deploySystemd(context); await deploySystemd(context);
ownership.unitsDeployed = true;
stepDone("systemd units"); stepDone("systemd units");
await advanceInstallState(context, "units_deployed"); await advanceInstallState(context, ownership, "units_deployed");
state.lastPhase = "units_deployed"; phase = "units_deployed";
step("firewall"); step("firewall");
state.firewallTouched = true; ownership.firewallTouched = true;
await applyFirewall(context); await applyFirewall(context);
stepDone("firewall"); stepDone("firewall");
await advanceInstallState(context, "firewall_applied"); await advanceInstallState(context, ownership, "firewall_applied");
state.lastPhase = "firewall_applied"; phase = "firewall_applied";
step("post-install env"); step("post-install env");
await writePostInstallEnv(context); await writePostInstallEnv(context);
ownership.postInstallWritten = true;
stepDone("post-install env"); stepDone("post-install env");
await advanceInstallState(context, "postinstall_env_written"); await advanceInstallState(context, ownership, "postinstall_env_written");
state.lastPhase = "postinstall_env_written"; phase = "postinstall_env_written";
step("bootstrap admin secret"); step("bootstrap admin secret");
await ensureBootstrapAdminSecret(context); await ensureBootstrapAdminSecret(context);
stepDone("bootstrap admin secret"); stepDone("bootstrap admin secret");
await advanceInstallState(context, "bootstrap_secret_written"); await advanceInstallState(context, ownership, "bootstrap_secret_written");
state.lastPhase = "bootstrap_secret_written"; phase = "bootstrap_secret_written";
step("smoke checks"); step("smoke checks");
await advanceInstallState(context, "services_started"); ownership.servicesStarted = true;
state.lastPhase = "services_started"; await advanceInstallState(context, ownership, "services_started");
await advanceInstallState(context, "smoke_running"); phase = "services_started";
state.lastPhase = "smoke_running"; await advanceInstallState(context, ownership, "smoke_running");
phase = "smoke_running";
await smoke(context); await smoke(context);
stepDone("smoke checks"); stepDone("smoke checks");
step("finalize firewall rollback guard"); step("finalize firewall rollback guard");
await cancelFirewallRollback(context); await cancelFirewallRollback(context);
stepDone("finalize firewall rollback guard"); stepDone("finalize firewall rollback guard");
step("mark install successful"); step("mark install successful");
await markInstallSuccessful(context); await advanceInstallState(context, ownership, "installed");
stepDone("mark install successful"); stepDone("mark install successful");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const failureKind = classifyFailure(state.lastPhase, message); const failureKind = classifyFailure(ownership, phase);
await advanceInstallState(context, failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed", `${failureKind}: ${message}`);
if (failureKind === "fatal_pre_apply") {
// Хост не изменён: не пишем install-state и не собираем bundle в
// /var/log/hy2xs — сбор диагностики сам создал бы каталоги.
info(`install refused before applying anything: ${failureKind}`);
throw error;
}
await advanceInstallState(
context,
ownership,
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
`${failureKind}: ${message}`
);
await diagnosticsCollect(options); await diagnosticsCollect(options);
await rollbackFailedInstall(context, state, failureKind); await rollbackFailedInstall(context, ownership, failureKind);
throw error; throw error;
} }
} }
@@ -0,0 +1,43 @@
import type { InstallOptions, RuntimeContext } from "../types/context";
import { fileExists, readText } from "../lib/fs";
import { enableReadOnlyGuard } from "../lib/guard";
import { info, setOperationContext, step, stepDone } from "../lib/log";
import { readPackageValue } from "../lib/packageMeta";
import { parseRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight";
/**
* PHASE 0 установки: полностью read-only проверка целевого сервера.
*
* Запускается install.sh из РАСПАКОВАННОГО пакета до того, как на сервере
* будет изменён хотя бы один persistent path — включая /usr/local/lib/hy2xs.
* Единственный результат — код возврата.
*/
export async function preflightInstall(options: InstallOptions): Promise<void> {
setOperationContext(`preflight-install-${Date.now().toString(36)}`);
enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
const hasSourceConfig = options.sourceConfigPath ? await fileExists(options.sourceConfigPath) : false;
if (options.sourceConfigPath && !hasSourceConfig) {
throw new Error(`config source not found: ${options.sourceConfigPath}`);
}
const sourceConfigPath = hasSourceConfig ? options.sourceConfigPath : `${options.packageDir}/config/hy2xs.env`;
const config = parseRuntimeEnv(await readText(sourceConfigPath));
const context: RuntimeContext = {
mode: "install",
options,
config,
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
hysteriaVersion: "unknown",
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
};
step("bootstrap preflight (read-only)");
await preflight(context, { requireCapabilities: false, cleanHostPhase: "bootstrap" });
stepDone("bootstrap preflight (read-only)");
info("clean-host contract satisfied: no persistent path was modified");
}
+59 -59
View File
@@ -1,6 +1,13 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { fileExists, readText, writeText } from "../lib/fs"; import { fileExists, readText, writeTextAtomic } from "../lib/fs";
import { info, setOperationContext, step, stepDone } from "../lib/log"; import { info, setOperationContext, step, stepDone } from "../lib/log";
import {
INSTALL_STATE_PATH,
REPAIR_HINT,
assertCurrentGeneration,
buildInstallStateRecord,
type InstallStateRecord
} from "../lib/installState";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight"; import { preflight } from "../steps/preflight";
import { generateConfig } from "../steps/config"; import { generateConfig } from "../steps/config";
@@ -12,21 +19,6 @@ import { runVisible } from "../lib/process";
import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta";
import { diagnosticsCollect } from "./diagnostics"; import { diagnosticsCollect } from "./diagnostics";
const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
type InstallState = {
installed: boolean;
phase: string;
version?: string;
build_id?: string;
op_id?: string;
started_at?: string;
updated_at?: string;
owned_paths?: string[];
last_error?: string;
repair_hint?: string;
};
type ReconfigurePhase = type ReconfigurePhase =
| "reconfiguring" | "reconfiguring"
| "repairing" | "repairing"
@@ -57,23 +49,23 @@ function ownedPaths(context: ReconfigureContext): string[] {
]; ];
} }
async function writeInstallState(state: InstallState): Promise<void> {
await writeText(INSTALL_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, 0o644);
await runVisible`chown root:root ${INSTALL_STATE_PATH}`;
}
async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, lastError = ""): Promise<void> { async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, lastError = ""): Promise<void> {
await writeInstallState({ const record = buildInstallStateRecord({
installed: phase === "installed", productVersion: context.packageVersion,
buildId: context.packageBuildId,
opId: operationKey(context),
startedAt: context.installDate,
phase, phase,
version: context.packageVersion, installed: phase === "installed",
build_id: context.packageBuildId, ownedPaths: ownedPaths(context),
op_id: operationKey(context), lastError,
started_at: context.installDate, repairHint: phase === "installed" ? undefined : REPAIR_HINT
updated_at: new Date().toISOString(), });
owned_paths: ownedPaths(context),
last_error: lastError, await writeTextAtomic(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, {
repair_hint: phase === "installed" ? "" : "run: hy2xs-orchestrator repair --package-dir <path> --config /etc/hy2xs/hy2xs.env" mode: 0o644,
owner: "root",
group: "root"
}); });
} }
@@ -108,39 +100,34 @@ async function rollbackCurrentState(): Promise<void> {
await runVisible`systemctl restart hysteria-server hy2xs-admin || true`; await runVisible`systemctl restart hysteria-server hy2xs-admin || true`;
} }
async function ensureInstallStateExists(): Promise<void> { async function readInstallState(): Promise<InstallStateRecord | null> {
if (!(await fileExists(INSTALL_STATE_PATH))) {
throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`);
}
const raw = await readText(INSTALL_STATE_PATH);
let parsed: InstallState;
try {
parsed = JSON.parse(raw) as InstallState;
} catch {
throw new Error(`invalid install state marker format: ${INSTALL_STATE_PATH}`);
}
if (!parsed.installed) {
throw new Error(`install state marker does not indicate successful installation: ${INSTALL_STATE_PATH}`);
}
}
async function readInstallState(): Promise<InstallState | null> {
if (!(await fileExists(INSTALL_STATE_PATH))) { if (!(await fileExists(INSTALL_STATE_PATH))) {
return null; return null;
} }
try { try {
return JSON.parse(await readText(INSTALL_STATE_PATH)) as InstallState; return JSON.parse(await readText(INSTALL_STATE_PATH)) as InstallStateRecord;
} catch { } catch {
return null; return null;
} }
} }
/**
* reconfigure/repair работают только поверх установки ТЕКУЩЕГО поколения.
*
* Наличие `installed: true` ничего не доказывает: такой же маркер мог остаться
* от 0.x. Поэтому сначала проверяется поколение, и только потом — полнота.
*/
async function ensureInstallStateForOperation(options: ReconfigureOptions): Promise<void> { async function ensureInstallStateForOperation(options: ReconfigureOptions): Promise<void> {
const state = await readInstallState(); const state = await readInstallState();
if (!state) { if (!state) {
throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`); throw new Error(
`install state marker is missing: ${INSTALL_STATE_PATH}. ` +
"HY2XS v1 требует чистой установки; см. docs/14-legacy-cleanup.md"
);
} }
assertCurrentGeneration(state);
if (state.installed) { if (state.installed) {
return; return;
} }
@@ -148,7 +135,10 @@ async function ensureInstallStateForOperation(options: ReconfigureOptions): Prom
info(`repair mode: proceeding with partial install state (phase=${state.phase ?? "unknown"})`); info(`repair mode: proceeding with partial install state (phase=${state.phase ?? "unknown"})`);
return; return;
} }
throw new Error(`install state marker does not indicate successful installation: ${INSTALL_STATE_PATH}`); throw new Error(
`install state marker does not indicate successful installation (phase=${state.phase ?? "unknown"}): ` +
`${INSTALL_STATE_PATH}. Для незавершённой установки используйте: repair --allow-partial-state`
);
} }
async function warnBootstrapDrift(nextConfigRaw: string): Promise<void> { async function warnBootstrapDrift(nextConfigRaw: string): Promise<void> {
@@ -180,12 +170,14 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown") hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
}; };
step("preflight"); // Поколение проверяется до всего остального: если маркер чужой, дальнейшие
await preflight(context); // проверки конфигурации не имеют смысла.
stepDone("preflight");
step("install state marker"); step("install state marker");
await ensureInstallStateForOperation(options); await ensureInstallStateForOperation(options);
stepDone("install state marker"); stepDone("install state marker");
step("preflight");
await preflight(context);
stepDone("preflight");
await warnBootstrapDrift(configRaw); await warnBootstrapDrift(configRaw);
if (options.dryRun) { if (options.dryRun) {
@@ -217,9 +209,11 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
stepDone("firewall"); stepDone("firewall");
await markPhase(context, "firewall_applied"); await markPhase(context, "firewall_applied");
step("write env artifacts"); step("write env artifacts");
await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600); await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), {
await runVisible`chown root:root ${options.runtimeConfigPath}`; mode: 0o600,
await runVisible`chmod 0600 ${options.runtimeConfigPath}`; owner: "root",
group: "root"
});
await ensureBootstrapAdminSecret(context); await ensureBootstrapAdminSecret(context);
await writePostInstallEnv(context); await writePostInstallEnv(context);
stepDone("write env artifacts"); stepDone("write env artifacts");
@@ -251,12 +245,18 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
} }
} }
/**
* repair — это тот же apply-проход, но с явным разрешением работать поверх
* незавершённой установки. Разрешение приходит флагом `--allow-partial-state`,
* а не подразумевается: молчаливое согласие на произвольный partial marker и
* было тем, что позволяло чинить «установку» чужого поколения.
*/
export async function repair(options: ReconfigureOptions): Promise<void> { export async function repair(options: ReconfigureOptions): Promise<void> {
const effective: ReconfigureOptions = { const effective: ReconfigureOptions = {
...options, ...options,
dryRun: false, dryRun: false,
apply: true, apply: true,
allowPartialState: true, allowPartialState: options.allowPartialState ?? false,
skipSmoke: options.skipSmoke ?? false, skipSmoke: options.skipSmoke ?? false,
skipServiceStart: options.skipServiceStart ?? false skipServiceStart: options.skipServiceStart ?? false
}; };
+8 -2
View File
@@ -4,8 +4,7 @@ import { info, setOperationContext } from "../lib/log";
import { run } from "../lib/process"; import { run } from "../lib/process";
import { getPlatformProfile } from "../platform/profile"; import { getPlatformProfile } from "../platform/profile";
import { detectFirewallEntrypointKind } from "../steps/firewall"; import { detectFirewallEntrypointKind } from "../steps/firewall";
import { INSTALL_STATE_PATH, detectGenerationProblems } from "../lib/installState";
const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
async function unitState(unit: string): Promise<string> { async function unitState(unit: string): Promise<string> {
try { try {
@@ -61,6 +60,9 @@ export async function status(_options: CommonOptions): Promise<void> {
? "installed" ? "installed"
: (installPhase === "unknown" ? "failed" : installPhase); : (installPhase === "unknown" ? "failed" : installPhase);
const rollbackGuardActive = rollbackGuardUnits.length > 0; const rollbackGuardActive = rollbackGuardUnits.length > 0;
// Отдельное поле: маркер может присутствовать и быть «installed», но
// принадлежать другому поколению продукта.
const generationProblems = installState ? detectGenerationProblems(installState) : [];
const runtimeState = (hysteriaService === "active" && adminService === "active") const runtimeState = (hysteriaService === "active" && adminService === "active")
? (installStateEffective === "installed" ? "running" : "partial") ? (installStateEffective === "installed" ? "running" : "partial")
: "stopped"; : "stopped";
@@ -82,6 +84,10 @@ export async function status(_options: CommonOptions): Promise<void> {
tls: await tlsState(), tls: await tlsState(),
install_state_present: await fileExists(INSTALL_STATE_PATH), install_state_present: await fileExists(INSTALL_STATE_PATH),
install_state: installState, install_state: installState,
install_state_generation: installState
? (generationProblems.length === 0 ? "current" : "foreign")
: "absent",
install_state_generation_problems: generationProblems,
rollback_guard_active: rollbackGuardActive, rollback_guard_active: rollbackGuardActive,
rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [], rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [],
runtime_state: runtimeState, runtime_state: runtimeState,
+14 -1
View File
@@ -150,10 +150,23 @@ function normalizeFixedHysteriaAuthMode(value: string | undefined): "http" {
return "http"; return "http";
} }
/**
* Версия схемы конфигурации — обязательное поле.
*
* Отсутствие маркера НЕ означает «текущая схема»: до HY2XS v1 этого поля не
* существовало вовсе, поэтому именно пустое значение — самый вероятный признак
* конфигурации 0.x. Любой fallback здесь молча превращал бы legacy-конфиг в
* якобы валидный.
*/
function normalizeConfigSchemaVersion(value: string | undefined): number { function normalizeConfigSchemaVersion(value: string | undefined): number {
const raw = (value ?? "").trim(); const raw = (value ?? "").trim();
if (!raw) { if (!raw) {
return HY2XS_CONFIG_SCHEMA_VERSION; throw new Error(
"HY2XS_CONFIG_SCHEMA_VERSION отсутствует в конфигурации.\n" +
"Похоже на конфигурацию предыдущего поколения (0.x) или на неизвестный формат.\n" +
`HY2XS v1 понимает только схему ${HY2XS_CONFIG_SCHEMA_VERSION} и не выполняет миграцию на месте.\n` +
"Очистите старую установку и установите HY2XS v1 с нуля: см. docs/14-legacy-cleanup.md"
);
} }
const parsed = Number(raw); const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) { if (!Number.isInteger(parsed) || parsed < 1) {
+17
View File
@@ -8,6 +8,23 @@ import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
export const HY2XS_CONFIG_SCHEMA_VERSION = 2; export const HY2XS_CONFIG_SCHEMA_VERSION = 2;
/**
* Линия релиза продукта. Меняется только при смене поколения, внутри которого
* установка остаётся совместимой сама с собой. Используется install-state,
* чтобы reconfigure/repair не работали поверх чужого поколения.
*
* Значение синхронизировано с HY2XS_RELEASE_LINE в корневом versions.env
* (проверяется шагом verify_versions_contract на сборке).
*/
export const HY2XS_RELEASE_LINE = 1;
/**
* Целевая платформа production-профиля. Значения синхронизированы с
* HY2XS_TARGET_OS_VERSION / HY2XS_TARGET_ARCH в корневом versions.env.
*/
export const HY2XS_TARGET_DEBIAN_VERSION = 13;
export const HY2XS_TARGET_ARCH = "amd64";
export const HYSTERIA_OBFS_TYPES: readonly HysteriaObfsType[] = ["gecko", "salamander"]; export const HYSTERIA_OBFS_TYPES: readonly HysteriaObfsType[] = ["gecko", "salamander"];
/** Тип обфускации для новой установки. Salamander остаётся compatibility fallback. */ /** Тип обфускации для новой установки. Salamander остаётся compatibility fallback. */
+3
View File
@@ -1,4 +1,5 @@
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import { assertMutationAllowed } from "./guard";
async function statSafe(path: string): Promise<import("node:fs").Stats | null> { async function statSafe(path: string): Promise<import("node:fs").Stats | null> {
try { try {
@@ -30,6 +31,7 @@ export async function readText(path: string): Promise<string> {
} }
export async function writeText(path: string, data: string, mode?: number): Promise<void> { export async function writeText(path: string, data: string, mode?: number): Promise<void> {
assertMutationAllowed(`writeText(${path})`);
await Bun.write(path, data); await Bun.write(path, data);
if (mode !== undefined) { if (mode !== undefined) {
const result = Bun.spawnSync(["chmod", mode.toString(8), path], { const result = Bun.spawnSync(["chmod", mode.toString(8), path], {
@@ -51,6 +53,7 @@ export async function writeTextAtomic(
group: string; group: string;
} }
): Promise<void> { ): Promise<void> {
assertMutationAllowed(`writeTextAtomic(${path})`);
const dir = path.replace(/\/[^/]+$/, "") || "."; const dir = path.replace(/\/[^/]+$/, "") || ".";
const base = path.split("/").pop() || "tmp"; const base = path.split("/").pop() || "tmp";
const tmp = `${dir}/.${base}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`; const tmp = `${dir}/.${base}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+44
View File
@@ -0,0 +1,44 @@
/**
* Read-only guard для bootstrap-фазы установки.
*
* Контракт установки HY2XS состоит из двух фаз:
*
* PHASE 0 — read only: проверка чистоты хоста. Ни одной записи в persistent
* path, даже в /usr/local/lib/hy2xs.
* PHASE 1 — mutation: только после успешной PHASE 0.
*
* Граница между фазами держится не соглашением, а этим guard'ом: под ним
* любая запись через lib/fs и любой мутирующий раннер lib/process кидают
* ошибку. Без него «PHASE 0 ничего не пишет» снова станет неправдой при
* первой же правке.
*/
let active = false;
let reason = "";
export function enableReadOnlyGuard(guardReason: string): void {
active = true;
reason = guardReason;
}
export function disableReadOnlyGuard(): void {
active = false;
reason = "";
}
export function isReadOnlyGuardActive(): boolean {
return active;
}
export function readOnlyGuardReason(): string {
return reason;
}
export function assertMutationAllowed(operation: string): void {
if (!active) {
return;
}
throw new Error(
`read-only guard violation: ${operation} is not allowed during ${reason || "the read-only phase"}`
);
}
+151
View File
@@ -0,0 +1,151 @@
import { HY2XS_CONFIG_SCHEMA_VERSION, HY2XS_RELEASE_LINE } from "../config/profile";
/**
* Маркер установки HY2XS.
*
* Файл отвечает на один вопрос: «эта машина — установка ТЕКУЩЕГО поколения
* HY2XS, и в каком она состоянии». Поэтому кроме фазы он обязан нести
* идентификацию поколения: product/release_line/config_schema_version.
* Без них reconfigure/repair не отличают v1 от произвольного старого маркера.
*/
export const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
export const HY2XS_PRODUCT_ID = "hy2xs";
export type InstallStateRecord = {
product: string;
release_line: number;
config_schema_version: number;
product_version: string;
installed: boolean;
phase: string;
build_id: string;
op_id: string;
started_at: string;
updated_at: string;
owned_paths: string[];
last_error: string;
repair_hint?: string;
};
export type InstallStateGenerationProblem =
| "missing_product"
| "foreign_product"
| "missing_release_line"
| "foreign_release_line"
| "missing_config_schema"
| "foreign_config_schema";
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
/**
* Возвращает список расхождений поколения. Пустой список означает, что маркер
* принадлежит текущему поколению HY2XS.
*/
export function detectGenerationProblems(state: unknown): InstallStateGenerationProblem[] {
const record = asRecord(state);
if (!record) {
return ["missing_product", "missing_release_line", "missing_config_schema"];
}
const problems: InstallStateGenerationProblem[] = [];
if (typeof record.product !== "string" || record.product.trim() === "") {
problems.push("missing_product");
} else if (record.product !== HY2XS_PRODUCT_ID) {
problems.push("foreign_product");
}
if (typeof record.release_line !== "number" || !Number.isInteger(record.release_line)) {
problems.push("missing_release_line");
} else if (record.release_line !== HY2XS_RELEASE_LINE) {
problems.push("foreign_release_line");
}
if (typeof record.config_schema_version !== "number" || !Number.isInteger(record.config_schema_version)) {
problems.push("missing_config_schema");
} else if (record.config_schema_version !== HY2XS_CONFIG_SCHEMA_VERSION) {
problems.push("foreign_config_schema");
}
return problems;
}
export function renderGenerationFailure(
problems: readonly InstallStateGenerationProblem[],
state: unknown
): string {
const record = asRecord(state) ?? {};
const detail = problems
.map((problem) => {
switch (problem) {
case "missing_product":
return " - в маркере нет поля product";
case "foreign_product":
return ` - product=${JSON.stringify(record.product)}, ожидается ${JSON.stringify(HY2XS_PRODUCT_ID)}`;
case "missing_release_line":
return " - в маркере нет поля release_line";
case "foreign_release_line":
return ` - release_line=${JSON.stringify(record.release_line)}, ожидается ${HY2XS_RELEASE_LINE}`;
case "missing_config_schema":
return " - в маркере нет поля config_schema_version";
case "foreign_config_schema":
return ` - config_schema_version=${JSON.stringify(record.config_schema_version)}, ожидается ${HY2XS_CONFIG_SCHEMA_VERSION}`;
}
})
.join("\n");
return [
`Маркер установки ${INSTALL_STATE_PATH} не относится к текущему поколению HY2XS.`,
"HY2XS v1 не мигрирует состояние предыдущих версий.",
"",
"Расхождения:",
detail,
"",
"Требуется чистая переустановка: см. docs/14-legacy-cleanup.md"
].join("\n");
}
export function assertCurrentGeneration(state: unknown): void {
const problems = detectGenerationProblems(state);
if (problems.length > 0) {
throw new Error(renderGenerationFailure(problems, state));
}
}
export function buildInstallStateRecord(input: {
productVersion: string;
buildId: string;
opId: string;
startedAt: string;
phase: string;
installed: boolean;
ownedPaths: string[];
lastError?: string;
repairHint?: string;
}): InstallStateRecord {
return {
product: HY2XS_PRODUCT_ID,
release_line: HY2XS_RELEASE_LINE,
config_schema_version: HY2XS_CONFIG_SCHEMA_VERSION,
product_version: input.productVersion,
installed: input.installed,
phase: input.phase,
build_id: input.buildId,
op_id: input.opId,
started_at: input.startedAt,
updated_at: new Date().toISOString(),
owned_paths: input.ownedPaths,
last_error: input.lastError ?? "",
...(input.repairHint ? { repair_hint: input.repairHint } : {})
};
}
export const REPAIR_HINT =
"run: hy2xs-orchestrator repair --package-dir <path> --config /etc/hy2xs/hy2xs.env --allow-partial-state";
+4
View File
@@ -1,3 +1,4 @@
import { assertMutationAllowed } from "./guard";
import { info } from "./log"; import { info } from "./log";
function shellQuote(value: unknown): string { function shellQuote(value: unknown): string {
@@ -38,6 +39,7 @@ export async function run(command: TemplateStringsArray, ...args: unknown[]): Pr
export async function runVisible(command: TemplateStringsArray, ...args: unknown[]): Promise<void> { export async function runVisible(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
const rendered = renderCommand(command, args); const rendered = renderCommand(command, args);
assertMutationAllowed(`runVisible(${rendered})`);
info(`running: ${rendered}`); info(`running: ${rendered}`);
const process = Bun.spawn(["sh", "-c", rendered], { const process = Bun.spawn(["sh", "-c", rendered], {
stdout: "inherit", stdout: "inherit",
@@ -50,6 +52,7 @@ export async function runVisible(command: TemplateStringsArray, ...args: unknown
} }
export async function runRawVisible(command: string): Promise<void> { export async function runRawVisible(command: string): Promise<void> {
assertMutationAllowed("runRawVisible(...)");
info(`running script:\n${command}`); info(`running script:\n${command}`);
const process = Bun.spawn(["sh", "-eu", "-c", command], { const process = Bun.spawn(["sh", "-eu", "-c", command], {
stdout: "inherit", stdout: "inherit",
@@ -63,6 +66,7 @@ export async function runRawVisible(command: string): Promise<void> {
export async function runHidden(command: TemplateStringsArray, ...args: unknown[]): Promise<void> { export async function runHidden(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
const rendered = renderCommand(command, args); const rendered = renderCommand(command, args);
assertMutationAllowed(`runHidden(${rendered})`);
const process = Bun.spawn(["sh", "-c", rendered], { const process = Bun.spawn(["sh", "-c", rendered], {
stdout: "inherit", stdout: "inherit",
stderr: "inherit" stderr: "inherit"
+201
View File
@@ -0,0 +1,201 @@
import type { RuntimeConfig } from "../types/context";
import { dirExists, fileExists } from "../lib/fs";
import { run } from "../lib/process";
/**
* Контракт чистого хоста для HY2XS v1.
*
* HY2XS v1 не мигрирует состояние 0.x и не устанавливается поверх другого
* поколения. Поэтому список маркеров ниже — это не эвристика «на всякий
* случай», а формальная граница продукта: пока хотя бы один маркер найден,
* установка не имеет права изменить на сервере ничего.
*
* Список намеренно шире, чем «post-install.env + /opt/hy2xs-admin»: установка
* поверх сервера, где остались только systemd-юниты или база админки, тоже
* должна быть остановлена.
*/
export type LegacyMarkerKind = "file" | "dir" | "unit";
export type LegacyMarker = {
kind: LegacyMarkerKind;
target: string;
/** Человекочитаемое объяснение на русском: попадает в текст отказа. */
description: string;
/**
* Путь, который создаёт сам install.sh в PHASE 1 до запуска оркестратора.
* В PHASE 0 он остаётся признаком чужой установки, в PHASE 1 — уже наш.
*/
createdByInstaller?: true;
};
/**
* PHASE 0 проверяет полный список. PHASE 1 (уже внутри `install`) исключает
* пути, которые install.sh законно создал между фазами.
*/
export type CleanHostPhase = "bootstrap" | "install";
export type HostProbe = {
fileExists(path: string): Promise<boolean>;
dirExists(path: string): Promise<boolean>;
unitExists(unit: string): Promise<boolean>;
};
/**
* Собирает список маркеров с учётом путей из конфигурации: installDir/dataDir
* переопределяемы, и захардкоженный список пропустил бы нестандартную установку.
*/
export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase = "bootstrap"): LegacyMarker[] {
const markers: LegacyMarker[] = [
{
kind: "file",
target: "/etc/hysteria/post-install.env",
description: "post-install.env предыдущей установки HY2XS"
},
{
kind: "file",
target: "/etc/hy2xs/hy2xs.env",
description: "runtime-конфигурация предыдущей установки HY2XS"
},
{
kind: "file",
target: "/var/lib/hy2xs/install-state.json",
description: "маркер состояния установки HY2XS"
},
{
kind: "file",
target: "/etc/hy2xs/bootstrap-admin.secret",
description: "bootstrap-секрет администратора предыдущей установки"
},
{
kind: "dir",
target: "/usr/local/lib/hy2xs/package",
description: "runtime-пакет предыдущей установки HY2XS",
createdByInstaller: true
},
{
kind: "file",
target: "/etc/hysteria/config.yaml",
description: "сгенерированный серверный конфиг Hysteria"
},
{
kind: "file",
target: "/usr/local/bin/hysteria",
description: "уже установленный бинарник Hysteria"
},
{
kind: "file",
target: "/etc/nftables.d/hy2xs.nft",
description: "nftables-фрагмент HY2XS"
},
{
kind: "unit",
target: "hy2xs-admin.service",
description: "systemd-юнит админки HY2XS"
},
{
kind: "unit",
target: "hysteria-server.service",
description: "systemd-юнит сервера Hysteria"
},
// Наследие H UI / HY2XS 0.x.
{
kind: "unit",
target: "h-ui.service",
description: "systemd-юнит панели предыдущего поколения (0.x)"
},
{
kind: "dir",
target: "/usr/local/h-ui",
description: "каталог панели предыдущего поколения (0.x)"
}
];
markers.push({
kind: "dir",
target: config.installDir,
description: "каталог приложения админки HY2XS"
});
markers.push({
kind: "dir",
target: config.dataDir,
description: "каталог данных админки HY2XS (включая базу)"
});
const seen = new Set<string>();
return markers.filter((marker) => {
if (phase === "install" && marker.createdByInstaller) {
return false;
}
const key = `${marker.kind}:${marker.target}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
export const defaultHostProbe: HostProbe = {
fileExists,
dirExists,
async unitExists(unit: string): Promise<boolean> {
if (await fileExists(`/etc/systemd/system/${unit}`)) {
return true;
}
try {
const listed = await run`systemctl list-unit-files --no-legend ${unit} 2>/dev/null || true`;
return listed.trim().length > 0;
} catch {
return false;
}
}
};
export async function detectLegacyMarkers(
markers: readonly LegacyMarker[],
probe: HostProbe
): Promise<LegacyMarker[]> {
const found: LegacyMarker[] = [];
for (const marker of markers) {
const present = marker.kind === "file"
? await probe.fileExists(marker.target)
: marker.kind === "dir"
? await probe.dirExists(marker.target)
: await probe.unitExists(marker.target);
if (present) {
found.push(marker);
}
}
return found;
}
export function renderLegacyFailure(found: readonly LegacyMarker[]): string {
const list = found.map((marker) => ` - ${marker.target} (${marker.description})`).join("\n");
return [
"На сервере обнаружена предыдущая или посторонняя установка.",
"HY2XS v1 не поддерживает установку поверх и не мигрирует состояние 0.x.",
"Ни один файл на сервере не изменён.",
"",
"Найденные маркеры:",
list,
"",
"Очистите сервер и установите HY2XS заново: см. docs/14-legacy-cleanup.md",
"или запустите tools/legacy/purge-v0.sh из репозитория."
].join("\n");
}
/**
* Read-only проверка. Вызывается в PHASE 0 (bootstrap preflight) и повторно
* внутри install перед первой записью состояния.
*/
export async function assertCleanHost(
config: RuntimeConfig,
phase: CleanHostPhase = "bootstrap",
probe: HostProbe = defaultHostProbe
): Promise<void> {
const found = await detectLegacyMarkers(legacyMarkersFor(config, phase), probe);
if (found.length > 0) {
throw new Error(renderLegacyFailure(found));
}
}
+14 -9
View File
@@ -1,12 +1,19 @@
import type { RuntimeContext } from "../types/context"; import type { RuntimeContext } from "../types/context";
import { resolve4, resolve6 } from "node:dns/promises"; import { resolve4, resolve6 } from "node:dns/promises";
import { dirExists, fileExists } from "../lib/fs"; import { fileExists } from "../lib/fs";
import { fail, info } from "../lib/log"; import { fail, info } from "../lib/log";
import { run } from "../lib/process"; import { run } from "../lib/process";
import { assertPlatform } from "../platform/assert"; import { assertPlatform } from "../platform/assert";
import { HY2XS_TARGET_ARCH, HY2XS_TARGET_DEBIAN_VERSION } from "../config/profile";
import { assertCleanHost, type CleanHostPhase } from "./cleanHost";
type PreflightOptions = { type PreflightOptions = {
requireCapabilities?: boolean; requireCapabilities?: boolean;
/**
* bootstrap — PHASE 0, до любой мутации (полный список маркеров).
* install — PHASE 1, install.sh уже разложил runtime-пакет.
*/
cleanHostPhase?: CleanHostPhase;
}; };
function isNoDnsRecords(error: unknown): boolean { function isNoDnsRecords(error: unknown): boolean {
@@ -59,8 +66,8 @@ export async function preflight(context: RuntimeContext, options?: PreflightOpti
await assertPlatform({ await assertPlatform({
distro: "debian", distro: "debian",
supportedVersions: [13], supportedVersions: [HY2XS_TARGET_DEBIAN_VERSION],
architectures: ["amd64"], architectures: [HY2XS_TARGET_ARCH],
requireSystemdRun: requireCapabilities, requireSystemdRun: requireCapabilities,
requireNftables: requireCapabilities && needsFirewallCapabilities, requireNftables: requireCapabilities && needsFirewallCapabilities,
requireOpenSsl3: requireCapabilities requireOpenSsl3: requireCapabilities
@@ -95,12 +102,10 @@ export async function preflight(context: RuntimeContext, options?: PreflightOpti
} }
} }
if (!isReconfigure && (await fileExists("/etc/hysteria/post-install.env"))) { // Полный clean-host контракт: HY2XS v1 не устанавливается поверх другого
fail("existing HY2XS post-install.env found; update/repair is out of scope"); // поколения. Проверка read-only и выполняется до любой мутации.
} if (!isReconfigure) {
await assertCleanHost(context.config, options?.cleanHostPhase ?? "install");
if (!isReconfigure && (await dirExists(context.config.installDir))) {
fail("existing /opt/hy2xs-admin found; conflicting old state");
} }
const ports = new Set([context.config.hysteriaPort, context.config.uiPort]); const ports = new Set([context.config.hysteriaPort, context.config.uiPort]);
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, test } from "bun:test";
import {
assertCleanHost,
detectLegacyMarkers,
legacyMarkersFor,
renderLegacyFailure,
type HostProbe
} from "../src/steps/cleanHost";
import { baselineConfig } from "./fixtures";
/**
* Проба, которая считает «существующими» ровно переданный набор целей.
* Это позволяет проверить контракт чистого хоста без файловой системы.
*/
function probeWith(present: readonly string[]): HostProbe {
const set = new Set(present);
return {
async fileExists(path) {
return set.has(path);
},
async dirExists(path) {
return set.has(path);
},
async unitExists(unit) {
return set.has(unit);
}
};
}
const config = baselineConfig();
describe("clean-host контракт", () => {
test("чистый хост проходит", async () => {
await expect(assertCleanHost(config, "bootstrap", probeWith([]))).resolves.toBeUndefined();
});
test("каждый маркер по отдельности останавливает установку", async () => {
const markers = legacyMarkersFor(config, "bootstrap");
expect(markers.length).toBeGreaterThan(10);
for (const marker of markers) {
const probe = probeWith([marker.target]);
await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow(
/предыдущая или посторонняя установка/
);
}
});
test("список покрывает состояние, юниты, бинарник и наследие 0.x", () => {
const targets = legacyMarkersFor(config, "bootstrap").map((marker) => marker.target);
for (const expected of [
"/etc/hysteria/post-install.env",
"/etc/hy2xs/hy2xs.env",
"/var/lib/hy2xs/install-state.json",
"/etc/hy2xs/bootstrap-admin.secret",
"/usr/local/lib/hy2xs/package",
"/etc/hysteria/config.yaml",
"/usr/local/bin/hysteria",
"/etc/nftables.d/hy2xs.nft",
"hy2xs-admin.service",
"hysteria-server.service",
"h-ui.service",
"/usr/local/h-ui",
config.installDir,
config.dataDir
]) {
expect(targets).toContain(expected);
}
});
test("пути из конфигурации попадают в список, а не только дефолтные", () => {
const custom = baselineConfig({
HY2XS_INSTALL_DIR: "/srv/hy2xs-app",
HY2XS_DATA_DIR: "/srv/hy2xs-data"
});
const targets = legacyMarkersFor(custom, "bootstrap").map((marker) => marker.target);
expect(targets).toContain("/srv/hy2xs-app");
expect(targets).toContain("/srv/hy2xs-data");
});
// install.sh раскладывает runtime-пакет между фазами, поэтому в PHASE 1
// этот путь уже наш и маркером быть не может.
test("runtime-пакет — маркер в PHASE 0, но не в PHASE 1", async () => {
const probe = probeWith(["/usr/local/lib/hy2xs/package"]);
await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow(/usr\/local\/lib\/hy2xs\/package/);
await expect(assertCleanHost(config, "install", probe)).resolves.toBeUndefined();
});
test("остальные маркеры продолжают работать и в PHASE 1", async () => {
const probe = probeWith(["hysteria-server.service"]);
await expect(assertCleanHost(config, "install", probe)).rejects.toThrow(/hysteria-server\.service/);
});
test("сообщение перечисляет все найденные маркеры и говорит, что хост не изменён", async () => {
const found = await detectLegacyMarkers(
legacyMarkersFor(config, "bootstrap"),
probeWith(["/etc/hy2xs/hy2xs.env", "hy2xs-admin.service"])
);
expect(found).toHaveLength(2);
const message = renderLegacyFailure(found);
expect(message).toContain("/etc/hy2xs/hy2xs.env");
expect(message).toContain("hy2xs-admin.service");
expect(message).toContain("Ни один файл на сервере не изменён");
expect(message).toContain("docs/14-legacy-cleanup.md");
});
});
+22 -3
View File
@@ -93,9 +93,28 @@ describe("gecko packet sizes", () => {
}); });
describe("config schema version", () => { describe("config schema version", () => {
test("отсутствие значения даёт текущую схему", () => { // Отсутствующий маркер — самый вероятный признак конфигурации 0.x: до v1
const config = parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null })); // этого поля не существовало. Любой fallback здесь молча принял бы legacy.
expect(config.configSchemaVersion).toBe(HY2XS_CONFIG_SCHEMA_VERSION); test("отсутствие значения трактуется как legacy и отклоняется", () => {
expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }))).toThrow(
/HY2XS_CONFIG_SCHEMA_VERSION отсутствует/
);
});
test("отказ по отсутствующей схеме указывает на чистую установку", () => {
expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }))).toThrow(
/legacy-cleanup/
);
});
test("пустое значение отклоняется так же, как отсутствующее", () => {
expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: "" }))).toThrow(
/HY2XS_CONFIG_SCHEMA_VERSION отсутствует/
);
});
test("текущая схема принимается", () => {
expect(baselineConfig().configSchemaVersion).toBe(HY2XS_CONFIG_SCHEMA_VERSION);
}); });
test("схема v0/v1 отклоняется с указанием на чистую установку", () => { test("схема v0/v1 отклоняется с указанием на чистую установку", () => {
+112
View File
@@ -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");
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from "bun:test";
import {
HY2XS_PRODUCT_ID,
assertCurrentGeneration,
buildInstallStateRecord,
detectGenerationProblems,
renderGenerationFailure
} from "../src/lib/installState";
import { HY2XS_CONFIG_SCHEMA_VERSION, HY2XS_RELEASE_LINE } from "../src/config/profile";
function currentState(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
product: HY2XS_PRODUCT_ID,
release_line: HY2XS_RELEASE_LINE,
config_schema_version: HY2XS_CONFIG_SCHEMA_VERSION,
product_version: "1.0.0",
installed: true,
phase: "installed",
...overrides
};
}
describe("идентификация поколения установки", () => {
test("маркер текущего поколения принимается", () => {
expect(detectGenerationProblems(currentState())).toEqual([]);
expect(() => assertCurrentGeneration(currentState())).not.toThrow();
});
// Именно этот случай и есть «старый state marker от 0.x»: installed: true
// сам по себе ничего не доказывает.
test("маркер без полей поколения отклоняется, несмотря на installed: true", () => {
const legacy = { installed: true, phase: "installed", version: "0.0.22" };
expect(detectGenerationProblems(legacy)).toEqual([
"missing_product",
"missing_release_line",
"missing_config_schema"
]);
expect(() => assertCurrentGeneration(legacy)).toThrow(/не относится к текущему поколению/);
});
test("чужой продукт отклоняется", () => {
expect(detectGenerationProblems(currentState({ product: "h-ui" }))).toContain("foreign_product");
});
test("другая линия релиза отклоняется", () => {
expect(detectGenerationProblems(currentState({ release_line: 0 }))).toContain("foreign_release_line");
expect(detectGenerationProblems(currentState({ release_line: 2 }))).toContain("foreign_release_line");
});
test("другая схема конфигурации отклоняется", () => {
expect(detectGenerationProblems(currentState({ config_schema_version: 1 }))).toContain(
"foreign_config_schema"
);
});
test("нечисловые поля поколения считаются отсутствующими", () => {
const problems = detectGenerationProblems(
currentState({ release_line: "1", config_schema_version: "2" })
);
expect(problems).toContain("missing_release_line");
expect(problems).toContain("missing_config_schema");
});
test("не-объект отклоняется целиком", () => {
expect(detectGenerationProblems(null)).toHaveLength(3);
expect(detectGenerationProblems("installed")).toHaveLength(3);
expect(detectGenerationProblems([1, 2])).toHaveLength(3);
});
test("сообщение объясняет расхождение и направляет на чистую переустановку", () => {
const state = currentState({ release_line: 0 });
const message = renderGenerationFailure(detectGenerationProblems(state), state);
expect(message).toContain("release_line");
expect(message).toContain("docs/14-legacy-cleanup.md");
});
});
describe("запись маркера установки", () => {
test("маркер всегда несёт идентификацию поколения", () => {
const record = buildInstallStateRecord({
productVersion: "1.0.0",
buildId: "test-build",
opId: "op",
startedAt: "2026-08-27T00:00:00.000Z",
phase: "installed",
installed: true,
ownedPaths: ["/etc/hysteria/config.yaml"]
});
expect(record.product).toBe(HY2XS_PRODUCT_ID);
expect(record.release_line).toBe(HY2XS_RELEASE_LINE);
expect(record.config_schema_version).toBe(HY2XS_CONFIG_SCHEMA_VERSION);
expect(detectGenerationProblems(record)).toEqual([]);
});
test("успешная установка не несёт repair_hint", () => {
const record = buildInstallStateRecord({
productVersion: "1.0.0",
buildId: "b",
opId: "op",
startedAt: "2026-08-27T00:00:00.000Z",
phase: "installed",
installed: true,
ownedPaths: []
});
expect(record.repair_hint).toBeUndefined();
});
test("незавершённая установка подсказывает repair с явным флагом", () => {
const record = buildInstallStateRecord({
productVersion: "1.0.0",
buildId: "b",
opId: "op",
startedAt: "2026-08-27T00:00:00.000Z",
phase: "failed",
installed: false,
ownedPaths: [],
lastError: "boom",
repairHint: "run: hy2xs-orchestrator repair --allow-partial-state"
});
expect(record.installed).toBe(false);
expect(record.repair_hint).toContain("--allow-partial-state");
});
});
+54
View File
@@ -1,6 +1,20 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -eu set -eu
# Установка HY2XS состоит из двух строго разделённых фаз.
#
# PHASE 0 — READ ONLY
# проверка прав, целостности пакета и чистоты хоста.
# Ни один persistent path не изменяется, включая /usr/local/lib/hy2xs.
#
# PHASE 1 — MUTATION
# раскладка оркестратора и runtime-пакета, затем сама установка.
#
# Инвариант: до успешного завершения PHASE 0 установщик не имеет права
# изменить на сервере ничего. HY2XS v1 не устанавливается поверх 0.x и не
# мигрирует состояние, поэтому ошибочный запуск поверх работающего сервера
# обязан быть безвредным.
PACKAGE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" PACKAGE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
ORCHESTRATOR="$PACKAGE_DIR/orchestrator/hy2xs-orchestrator" ORCHESTRATOR="$PACKAGE_DIR/orchestrator/hy2xs-orchestrator"
ORCHESTRATOR_INSTALL_PATH="/usr/local/lib/hy2xs/hy2xs-orchestrator" ORCHESTRATOR_INSTALL_PATH="/usr/local/lib/hy2xs/hy2xs-orchestrator"
@@ -16,6 +30,10 @@ fail() {
exit 1 exit 1
} }
# ---------------------------------------------------------------- PHASE 0 ---
log "PHASE 0: read-only checks (no persistent path is modified)"
if [ "$(id -u)" != "0" ]; then if [ "$(id -u)" != "0" ]; then
fail "HY2XS install must run as root." fail "HY2XS install must run as root."
fi fi
@@ -34,6 +52,42 @@ log "verifying package checksums"
sha256sum -c metadata/checksums.txt sha256sum -c metadata/checksums.txt
) )
# PHASE 0 принимает только источник конфигурации: флаги, влияющие на мутацию
# (--skip-*), к read-only проверке отношения не имеют.
PREFLIGHT_CONFIG=""
scan_config_argument() {
while [ "$#" -gt 0 ]; do
case "$1" in
--config)
[ "$#" -ge 2 ] || fail "Missing value for --config"
PREFLIGHT_CONFIG="$2"
shift 2
;;
*)
shift
;;
esac
done
}
scan_config_argument "$@"
# Bootstrap preflight запускается из РАСПАКОВАННОГО пакета: установленного
# оркестратора на этом этапе ещё не существует и появиться не должен.
log "running clean-host preflight from the unpacked package"
if [ -n "$PREFLIGHT_CONFIG" ]; then
"$ORCHESTRATOR" preflight-install --package-dir "$PACKAGE_DIR" --config "$PREFLIGHT_CONFIG" \
|| fail "clean-host preflight failed; the server was left untouched"
else
"$ORCHESTRATOR" preflight-install --package-dir "$PACKAGE_DIR" \
|| fail "clean-host preflight failed; the server was left untouched"
fi
log "PHASE 0 passed"
# ---------------------------------------------------------------- PHASE 1 ---
log "PHASE 1: applying changes"
install -d -m 0755 /usr/local/lib/hy2xs install -d -m 0755 /usr/local/lib/hy2xs
install -m 0755 "$ORCHESTRATOR" "$ORCHESTRATOR_INSTALL_PATH" install -m 0755 "$ORCHESTRATOR" "$ORCHESTRATOR_INSTALL_PATH"
ln -sf "$ORCHESTRATOR_INSTALL_PATH" "$ORCHESTRATOR_SYMLINK" ln -sf "$ORCHESTRATOR_INSTALL_PATH" "$ORCHESTRATOR_SYMLINK"