Довёл fix20: firewall-mode, staged state, diagnostics, readiness и build-gate

This commit is contained in:
2026-05-07 23:38:55 +05:00
parent 0a8f4e0c3d
commit 4b382d6ef9
25 changed files with 840 additions and 133 deletions
+121 -5
View File
@@ -1,5 +1,5 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { exists, readText, writeText } from "../lib/fs";
import { fileExists, readText, writeText } from "../lib/fs";
import { info, setOperationContext, step, stepDone } from "../lib/log";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight";
@@ -10,13 +10,73 @@ import { writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke";
import { runVisible } from "../lib/process";
import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta";
import { diagnosticsCollect } from "./diagnostics";
const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
type InstallState = {
installed?: boolean;
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 =
| "reconfiguring"
| "repairing"
| "config_generated"
| "units_deployed"
| "firewall_applied"
| "runtime_env_written"
| "smoke_ok"
| "firewall_connectivity_failure"
| "smoke_failed"
| "installed";
function operationKey(context: ReconfigureContext): string {
return context.installDate;
}
function ownedPaths(context: ReconfigureContext): string[] {
return [
context.options.runtimeConfigPath,
"/etc/hysteria/config.yaml",
"/etc/hysteria/post-install.env",
context.config.bootstrapAdminSecretPath,
"/etc/systemd/system/hy2xs-admin.service",
"/etc/systemd/system/hysteria-server.service",
"/etc/nftables.conf",
"/etc/nftables.d/hy2xs.nft",
context.config.installDir
];
}
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> {
await writeInstallState({
installed: phase === "installed",
phase,
version: context.packageVersion,
build_id: context.packageBuildId,
op_id: operationKey(context),
started_at: context.installDate,
updated_at: new Date().toISOString(),
owned_paths: ownedPaths(context),
last_error: lastError,
repair_hint: phase === "installed" ? "" : "run: hy2xs-orchestrator repair --package-dir <path> --config /etc/hy2xs/hy2xs.env"
});
}
async function backupCurrentState(): Promise<void> {
await runVisible`mkdir -p /etc/hy2xs/backups`;
await runVisible`cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak 2>/dev/null || true`;
@@ -49,7 +109,7 @@ async function rollbackCurrentState(): Promise<void> {
}
async function ensureInstallStateExists(): Promise<void> {
if (!(await exists(INSTALL_STATE_PATH))) {
if (!(await fileExists(INSTALL_STATE_PATH))) {
throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`);
}
@@ -65,8 +125,34 @@ async function ensureInstallStateExists(): Promise<void> {
}
}
async function readInstallState(): Promise<InstallState | null> {
if (!(await fileExists(INSTALL_STATE_PATH))) {
return null;
}
try {
return JSON.parse(await readText(INSTALL_STATE_PATH)) as InstallState;
} catch {
return null;
}
}
async function ensureInstallStateForOperation(options: ReconfigureOptions): Promise<void> {
const state = await readInstallState();
if (!state) {
throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`);
}
if (state.installed) {
return;
}
if (options.allowPartialState) {
info(`repair mode: proceeding with partial install state (phase=${state.phase ?? "unknown"})`);
return;
}
throw new Error(`install state marker does not indicate successful installation: ${INSTALL_STATE_PATH}`);
}
async function warnBootstrapDrift(nextConfigRaw: string): Promise<void> {
if (!(await exists("/etc/hy2xs/hy2xs.env"))) {
if (!(await fileExists("/etc/hy2xs/hy2xs.env"))) {
return;
}
@@ -97,7 +183,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
await preflight(context);
stepDone("preflight");
step("install state marker");
await ensureInstallStateExists();
await ensureInstallStateForOperation(options);
stepDone("install state marker");
await warnBootstrapDrift(configRaw);
@@ -116,31 +202,61 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
stepDone("backup");
try {
await markPhase(context, options.allowPartialState ? "repairing" : "reconfiguring");
step("config generation");
await generateConfig(context);
stepDone("config generation");
await markPhase(context, "config_generated");
step("systemd units");
await deploySystemd(context);
stepDone("systemd units");
await markPhase(context, "units_deployed");
step("firewall");
await applyFirewall(context);
stepDone("firewall");
await markPhase(context, "firewall_applied");
step("write env artifacts");
await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600);
await runVisible`chown root:root ${options.runtimeConfigPath}`;
await runVisible`chmod 0600 ${options.runtimeConfigPath}`;
await writePostInstallEnv(context);
stepDone("write env artifacts");
await markPhase(context, "runtime_env_written");
step("smoke checks");
await smoke(context);
stepDone("smoke checks");
await markPhase(context, "smoke_ok");
step("finalize firewall rollback guard");
await cancelFirewallRollback(context);
stepDone("finalize firewall rollback guard");
await markPhase(context, "installed");
const finalState = await readInstallState();
if (!finalState?.installed || finalState.phase !== "installed") {
throw new Error("deterministic state violation: reconfigure/repair finished without installed phase");
}
} catch (error) {
info("reconfigure failed, rollback in progress");
const message = error instanceof Error ? error.message : String(error);
const phase: ReconfigurePhase = /firewall|nft|ssh port check failed/i.test(message)
? "firewall_connectivity_failure"
: "smoke_failed";
await markPhase(context, phase, message);
await diagnosticsCollect(options);
await rollbackFirewallNow(context);
await rollbackCurrentState();
throw error;
}
}
export async function repair(options: ReconfigureOptions): Promise<void> {
const effective: ReconfigureOptions = {
...options,
dryRun: false,
apply: true,
allowPartialState: true,
skipSmoke: options.skipSmoke ?? false,
skipServiceStart: options.skipServiceStart ?? false
};
await reconfigure(effective);
}