Миграция оркестратора на Debian 13: platform layer, preflight, status/diagnostics и structured logging

This commit is contained in:
2026-05-03 02:16:52 +05:00
parent 18ff86058a
commit 52f1e5d909
12 changed files with 304 additions and 13 deletions
+20
View File
@@ -1,6 +1,8 @@
import { install } from "./commands/install"; import { install } from "./commands/install";
import { reconfigure } from "./commands/reconfigure"; import { reconfigure } from "./commands/reconfigure";
import { doctor } from "./commands/doctor"; import { doctor } from "./commands/doctor";
import { status } from "./commands/status";
import { diagnosticsCollect } from "./commands/diagnostics";
import type { InstallOptions, ReconfigureOptions } from "./types/context"; import type { InstallOptions, ReconfigureOptions } from "./types/context";
function usage(): never { function usage(): never {
@@ -8,6 +10,8 @@ function usage(): never {
console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start] [--non-interactive]"); console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start] [--non-interactive]");
console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-start]"); console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-start]");
console.error(" hy2xs-orchestrator doctor --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start]"); console.error(" hy2xs-orchestrator doctor --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start]");
console.error(" hy2xs-orchestrator status --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start]");
console.error(" hy2xs-orchestrator diagnostics collect --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start]");
process.exit(2); process.exit(2);
} }
@@ -118,6 +122,10 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions {
return options; return options;
} }
function parseCommonOptions(args: string[]): InstallOptions {
return parseInstallOptions(args);
}
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 === "install") { if (command === "install") {
@@ -133,6 +141,18 @@ async function main(): Promise<void> {
await doctor(options); await doctor(options);
return; return;
} }
if (command === "status") {
await status(parseCommonOptions(args));
return;
}
if (command === "diagnostics") {
const [subcommand, ...rest] = args;
if (subcommand !== "collect") {
usage();
}
await diagnosticsCollect(parseCommonOptions(rest));
return;
}
usage(); usage();
} }
+27
View File
@@ -0,0 +1,27 @@
import type { CommonOptions } from "../types/context";
import { info, setOperationContext } from "../lib/log";
import { run } from "../lib/process";
function shellEscapeSingleQuotes(value: string): string {
return value.replaceAll("'", "'\\''");
}
export async function diagnosticsCollect(_options: CommonOptions): Promise<void> {
setOperationContext(`diag-${Date.now().toString(36)}`);
const outDir = `/tmp/hy2xs-diagnostics-${Date.now()}`;
await run`mkdir -p ${outDir}`;
await run`sh -c ${`systemctl status hysteria-server > '${shellEscapeSingleQuotes(`${outDir}/systemd-hysteria.txt`)}' 2>&1 || true`}`;
await run`sh -c ${`systemctl status hy2xs-admin > '${shellEscapeSingleQuotes(`${outDir}/systemd-admin.txt`)}' 2>&1 || true`}`;
await run`sh -c ${`journalctl -u hysteria-server -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-hysteria.log`)}' 2>&1 || true`}`;
await run`sh -c ${`journalctl -u hy2xs-admin -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-admin.log`)}' 2>&1 || true`}`;
await run`sh -c ${`nft list ruleset > '${shellEscapeSingleQuotes(`${outDir}/nftables.ruleset`)}' 2>&1 || true`}`;
await run`sh -c ${`uname -a > '${shellEscapeSingleQuotes(`${outDir}/uname.txt`)}' 2>&1 || true`}`;
await run`sh -c ${`cat /etc/os-release > '${shellEscapeSingleQuotes(`${outDir}/os-release.txt`)}' 2>&1 || true`}`;
await run`sh -c ${`cp -a /etc/hysteria/post-install.env '${shellEscapeSingleQuotes(`${outDir}/post-install.env`)}' 2>/dev/null || true`}`;
await run`sh -c ${`cp -a /var/lib/hy2xs/install-state.json '${shellEscapeSingleQuotes(`${outDir}/install-state.json`)}' 2>/dev/null || true`}`;
info(`diagnostics bundle collected: ${outDir}`);
}
+4 -1
View File
@@ -1,12 +1,13 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { readText } from "../lib/fs"; import { readText } from "../lib/fs";
import { step } from "../lib/log"; import { setOperationContext, step, stepDone } from "../lib/log";
import { parseRuntimeEnv } from "../config/env"; import { parseRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight"; import { preflight } from "../steps/preflight";
import { smoke } from "../steps/smoke"; import { smoke } from "../steps/smoke";
import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta";
export async function doctor(options: ReconfigureOptions): Promise<void> { export async function doctor(options: ReconfigureOptions): Promise<void> {
setOperationContext(`doctor-${Date.now().toString(36)}`);
const configRaw = await readText(options.sourceConfigPath); const configRaw = await readText(options.sourceConfigPath);
const config = parseRuntimeEnv(configRaw); const config = parseRuntimeEnv(configRaw);
@@ -22,7 +23,9 @@ export async function doctor(options: ReconfigureOptions): Promise<void> {
step("doctor preflight"); step("doctor preflight");
await preflight(context); await preflight(context);
stepDone("doctor preflight");
step("doctor smoke"); step("doctor smoke");
await smoke(context); await smoke(context);
stepDone("doctor smoke");
} }
+16 -1
View File
@@ -1,7 +1,7 @@
import type { InstallContext, InstallOptions } from "../types/context"; import type { InstallContext, InstallOptions } from "../types/context";
import { exists, readText, writeText, writeTextAtomic } from "../lib/fs"; import { exists, readText, writeText, writeTextAtomic } from "../lib/fs";
import { runVisible } from "../lib/process"; import { runVisible } from "../lib/process";
import { step } from "../lib/log"; import { setOperationContext, step, stepDone } from "../lib/log";
import { readPackageValue } from "../lib/packageMeta"; import { readPackageValue } from "../lib/packageMeta";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight"; import { preflight } from "../steps/preflight";
@@ -43,6 +43,7 @@ async function rollbackFailedInstall(context: InstallContext, state: { firewallT
} }
export async function install(options: InstallOptions): Promise<void> { export async function install(options: InstallOptions): Promise<void> {
setOperationContext(`install-${Date.now().toString(36)}`);
const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false; const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false;
if (options.sourceConfigPath && !hasSourceConfig) { if (options.sourceConfigPath && !hasSourceConfig) {
throw new Error(`config source not found: ${options.sourceConfigPath}`); throw new Error(`config source not found: ${options.sourceConfigPath}`);
@@ -75,10 +76,13 @@ export async function install(options: InstallOptions): Promise<void> {
try { try {
step("preflight"); step("preflight");
await preflight(context); await preflight(context);
stepDone("preflight");
step("system dependencies"); step("system dependencies");
await installDeps(context); await installDeps(context);
stepDone("system dependencies");
step("filesystem"); step("filesystem");
await prepareFilesystem(context); await prepareFilesystem(context);
stepDone("filesystem");
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), {
@@ -86,27 +90,38 @@ export async function install(options: InstallOptions): Promise<void> {
owner: "root", owner: "root",
group: "root" group: "root"
}); });
stepDone("write runtime env");
step("bundled UI"); step("bundled UI");
await deployUi(context); await deployUi(context);
stepDone("bundled UI");
step("Hysteria2 upstream install"); step("Hysteria2 upstream install");
await installHysteria(context); await installHysteria(context);
stepDone("Hysteria2 upstream install");
step("config generation"); step("config generation");
await generateConfig(context); await generateConfig(context);
stepDone("config generation");
step("systemd units"); step("systemd units");
await deploySystemd(context); await deploySystemd(context);
stepDone("systemd units");
step("firewall"); step("firewall");
state.firewallTouched = true; state.firewallTouched = true;
await applyFirewall(context); await applyFirewall(context);
stepDone("firewall");
step("post-install env"); step("post-install env");
await writePostInstallEnv(context); await writePostInstallEnv(context);
stepDone("post-install env");
step("bootstrap admin secret"); step("bootstrap admin secret");
await writeBootstrapAdminSecret(context); await writeBootstrapAdminSecret(context);
stepDone("bootstrap admin secret");
step("smoke checks"); step("smoke checks");
await smoke(context); await smoke(context);
stepDone("smoke checks");
step("finalize firewall rollback guard"); step("finalize firewall rollback guard");
await cancelFirewallRollback(context); await cancelFirewallRollback(context);
stepDone("finalize firewall rollback guard");
step("mark install successful"); step("mark install successful");
await markInstallSuccessful(context); await markInstallSuccessful(context);
stepDone("mark install successful");
} catch (error) { } catch (error) {
await rollbackFailedInstall(context, state); await rollbackFailedInstall(context, state);
throw error; throw error;
+11 -1
View File
@@ -1,6 +1,6 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { exists, readText, writeText } from "../lib/fs"; import { exists, readText, writeText } from "../lib/fs";
import { info, step } from "../lib/log"; import { info, setOperationContext, step, stepDone } from "../lib/log";
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";
@@ -79,6 +79,7 @@ async function warnBootstrapDrift(nextConfigRaw: string): Promise<void> {
} }
export async function reconfigure(options: ReconfigureOptions): Promise<void> { export async function reconfigure(options: ReconfigureOptions): Promise<void> {
setOperationContext(`reconfigure-${Date.now().toString(36)}`);
const configRaw = await readText(options.sourceConfigPath); const configRaw = await readText(options.sourceConfigPath);
const config = parseRuntimeEnv(configRaw); const config = parseRuntimeEnv(configRaw);
@@ -94,8 +95,10 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
step("preflight"); step("preflight");
await preflight(context); await preflight(context);
stepDone("preflight");
step("install state marker"); step("install state marker");
await ensureInstallStateExists(); await ensureInstallStateExists();
stepDone("install state marker");
await warnBootstrapDrift(configRaw); await warnBootstrapDrift(configRaw);
if (options.dryRun) { if (options.dryRun) {
@@ -110,23 +113,30 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
step("backup"); step("backup");
await backupCurrentState(); await backupCurrentState();
stepDone("backup");
try { try {
step("config generation"); step("config generation");
await generateConfig(context); await generateConfig(context);
stepDone("config generation");
step("systemd units"); step("systemd units");
await deploySystemd(context); await deploySystemd(context);
stepDone("systemd units");
step("firewall"); step("firewall");
await applyFirewall(context); await applyFirewall(context);
stepDone("firewall");
step("write env artifacts"); step("write env artifacts");
await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600); await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600);
await runVisible`chown root:root ${options.runtimeConfigPath}`; await runVisible`chown root:root ${options.runtimeConfigPath}`;
await runVisible`chmod 0600 ${options.runtimeConfigPath}`; await runVisible`chmod 0600 ${options.runtimeConfigPath}`;
await writePostInstallEnv(context); await writePostInstallEnv(context);
stepDone("write env artifacts");
step("smoke checks"); step("smoke checks");
await smoke(context); await smoke(context);
stepDone("smoke checks");
step("finalize firewall rollback guard"); step("finalize firewall rollback guard");
await cancelFirewallRollback(context); await cancelFirewallRollback(context);
stepDone("finalize firewall rollback guard");
} catch (error) { } catch (error) {
info("reconfigure failed, rollback in progress"); info("reconfigure failed, rollback in progress");
await rollbackFirewallNow(context); await rollbackFirewallNow(context);
+55
View File
@@ -0,0 +1,55 @@
import type { CommonOptions } from "../types/context";
import { exists, readText } from "../lib/fs";
import { info, setOperationContext } from "../lib/log";
import { run } from "../lib/process";
import { getPlatformProfile } from "../platform/profile";
async function unitState(unit: string): Promise<string> {
try {
const out = await run`systemctl is-active ${unit}`;
return out.trim() || "unknown";
} catch {
return "inactive";
}
}
async function firewallState(): Promise<string> {
try {
await run`nft -c -f /etc/nftables.conf`;
return "valid";
} catch {
return "invalid";
}
}
async function tlsState(): Promise<string> {
if (!(await exists("/etc/hysteria/config.yaml"))) {
return "missing-config";
}
const cfg = await readText("/etc/hysteria/config.yaml");
if (/acme:/m.test(cfg)) {
return "acme";
}
if (/^tls:/m.test(cfg)) {
return "file/self-signed";
}
return "unknown";
}
export async function status(_options: CommonOptions): Promise<void> {
setOperationContext(`status-${Date.now().toString(36)}`);
const platform = await getPlatformProfile();
const result = {
ts: new Date().toISOString(),
platform,
services: {
hysteria: await unitState("hysteria-server"),
admin: await unitState("hy2xs-admin")
},
firewall: await firewallState(),
tls: await tlsState(),
install_state_present: await exists("/var/lib/hy2xs/install-state.json")
};
info(`status report: ${JSON.stringify(result)}`);
}
+36 -2
View File
@@ -1,11 +1,45 @@
let operationId = "";
function nowIso(): string {
return new Date().toISOString();
}
function ensureOperationId(): string {
if (!operationId) {
operationId = `op-${Date.now().toString(36)}`;
}
return operationId;
}
export function setOperationContext(id: string): void {
operationId = id;
}
function emit(level: "STEP" | "INFO" | "ERROR", message: string, stepName?: string, status?: "start" | "ok" | "fail"): void {
const payload = {
ts: nowIso(),
op_id: ensureOperationId(),
level,
step: stepName ?? null,
status: status ?? null,
message
};
console.log(`[hy2xs] ${JSON.stringify(payload)}`);
}
export function step(name: string): void { export function step(name: string): void {
console.log(`\n[hy2xs] ==> ${name}`); emit("STEP", `==> ${name}`, name, "start");
}
export function stepDone(name: string): void {
emit("STEP", `<== ${name}`, name, "ok");
} }
export function info(message: string): void { export function info(message: string): void {
console.log(`[hy2xs] ${message}`); emit("INFO", message);
} }
export function fail(message: string): never { export function fail(message: string): never {
emit("ERROR", message, undefined, "fail");
throw new Error(message); throw new Error(message);
} }
+43
View File
@@ -0,0 +1,43 @@
import { fail } from "../lib/log";
import { getPlatformProfile } from "./profile";
type AssertPlatformOptions = {
distro: "debian";
supportedVersions: number[];
architectures: Array<"amd64">;
};
export async function assertPlatform(options: AssertPlatformOptions): Promise<void> {
const profile = await getPlatformProfile();
if (profile.distro !== options.distro) {
fail(`HY2XS baseline supports only ${options.distro}; detected: ${profile.distro || "unknown"}`);
}
if (!options.supportedVersions.includes(profile.majorVersion)) {
fail(
`HY2XS baseline supports only ${options.distro} ${options.supportedVersions.join(", ")}; detected major version: ${profile.majorVersion || "unknown"}`
);
}
if (profile.architecture !== "amd64" || !options.architectures.includes("amd64")) {
fail(`HY2XS baseline supports only ${options.architectures.join(",")}; detected: ${profile.architecture}`);
}
if (!profile.capabilities.systemd) {
fail("required capability missing: systemd");
}
if (!profile.capabilities.systemdRun) {
fail("required capability missing: systemd-run");
}
if (!profile.capabilities.nftables) {
fail("required capability missing: nft");
}
if (!profile.capabilities.nftAtomicReplace) {
fail("required capability missing: nft atomic replace");
}
if (!profile.capabilities.openssl3) {
fail("required capability missing: OpenSSL 3.x runtime");
}
}
+82
View File
@@ -0,0 +1,82 @@
import { exists, readText } from "../lib/fs";
import { run } from "../lib/process";
export type PlatformProfile = {
distro: string;
majorVersion: number;
architecture: "amd64" | "unsupported";
capabilities: {
nftables: boolean;
systemd: boolean;
openssl3: boolean;
systemdRun: boolean;
nftAtomicReplace: boolean;
};
};
function parseOsRelease(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const index = line.indexOf("=");
if (index <= 0) {
continue;
}
const key = line.slice(0, index);
let value = line.slice(index + 1);
value = value.replace(/^"(.*)"$/, "$1");
result[key] = value;
}
return result;
}
async function commandExists(command: string): Promise<boolean> {
try {
await run`command -v ${command} >/dev/null 2>&1`;
return true;
} catch {
return false;
}
}
async function detectOpenSsl3(): Promise<boolean> {
try {
const output = await run`openssl version`;
return /^OpenSSL\s+3\./.test(output);
} catch {
return false;
}
}
export async function getPlatformProfile(): Promise<PlatformProfile> {
const osReleaseRaw = await readText("/etc/os-release");
const parsed = parseOsRelease(osReleaseRaw);
const distro = (parsed.ID || "").toLowerCase();
const majorVersion = Number.parseInt((parsed.VERSION_ID || "").replace(/"/g, ""), 10);
const archRaw = await run`uname -m`;
const architecture: PlatformProfile["architecture"] = archRaw.trim() === "x86_64" ? "amd64" : "unsupported";
const nftables = await commandExists("nft");
const systemd = await commandExists("systemctl") && (await exists("/run/systemd/system"));
const systemdRun = await commandExists("systemd-run");
const openssl3 = await detectOpenSsl3();
const nftAtomicReplace = nftables;
return {
distro,
majorVersion,
architecture,
capabilities: {
nftables,
systemd,
openssl3,
systemdRun,
nftAtomicReplace
}
};
}
+7 -5
View File
@@ -1,7 +1,8 @@
import type { RuntimeContext } from "../types/context"; import type { RuntimeContext } from "../types/context";
import { exists, readText } from "../lib/fs"; import { exists } 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";
async function isTcpPortListening(port: number): Promise<boolean> { async function isTcpPortListening(port: number): Promise<boolean> {
try { try {
@@ -43,10 +44,11 @@ export async function preflight(context: RuntimeContext): Promise<void> {
fail("sudo is required for installer smoke checks. Install it with: apt-get update && apt-get install -y sudo"); fail("sudo is required for installer smoke checks. Install it with: apt-get update && apt-get install -y sudo");
} }
const osRelease = await readText("/etc/os-release"); await assertPlatform({
if (!/^ID=debian$/m.test(osRelease) || !/^VERSION_ID="?12"?$/m.test(osRelease)) { distro: "debian",
fail("HY2XS baseline supports only clean Debian 12"); supportedVersions: [13],
} architectures: ["amd64"]
});
if (!(await exists(`${context.options.packageDir}/systemd/hy2xs-admin.service`))) { if (!(await exists(`${context.options.packageDir}/systemd/hy2xs-admin.service`))) {
fail("missing hy2xs-admin systemd unit in package"); fail("missing hy2xs-admin systemd unit in package");
+2 -2
View File
@@ -1,8 +1,8 @@
# HY2XS install package # HY2XS install package
Этот пакет создаётся production builder'ом на Debian 12 amd64. Этот пакет создаётся production builder'ом на Debian 13 amd64.
Пакет предназначен для чистого Debian 12 target и содержит: Пакет предназначен для чистого Debian 13 target и содержит:
- compiled install-only orchestrator; - compiled install-only orchestrator;
- bundled HY2XS admin; - bundled HY2XS admin;
+1 -1
View File
@@ -1,7 +1,7 @@
# HY2XS post-install reference file. # HY2XS post-install reference file.
# Файл создаётся оркестратором после первичной установки и не является runtime-конфигом. # Файл создаётся оркестратором после первичной установки и не является runtime-конфигом.
DEPLOY_TARGET_OS=debian12 DEPLOY_TARGET_OS=debian13
DEPLOY_TIMESTAMP={{LAST_APPLY_DATE}} DEPLOY_TIMESTAMP={{LAST_APPLY_DATE}}
PACKAGE_NAME=hy2xs-install-package PACKAGE_NAME=hy2xs-install-package
PACKAGE_BUILD_ID={{PACKAGE_BUILD_ID}} PACKAGE_BUILD_ID={{PACKAGE_BUILD_ID}}