Merge remote-tracking branch 'origin/master' into feat/2010-hwlab-release-production-lane

This commit is contained in:
Codex
2026-07-15 11:12:57 +02:00
5 changed files with 469 additions and 32 deletions
+383
View File
@@ -0,0 +1,383 @@
import { Buffer } from "node:buffer";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import type { UniDeskConfig } from "./config";
import { repoRoot } from "./config";
import { renderMachine } from "./cicd-render";
import type { RenderedCliResult } from "./output";
import { CliInputError } from "./output";
import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";
type EdgeAction = "plan" | "apply" | "status";
interface EdgeOptions {
action: EdgeAction;
configPath: string;
confirm: boolean;
}
interface EdgeSite {
id: string;
hostname: string;
upstream: string;
healthPath: string;
}
interface EdgeSpec {
route: string;
publicAddress: string;
caddy: {
image: string;
workDir: string;
composePath: string;
caddyfilePath: string;
dataDir: string;
configDir: string;
containerName: string;
httpPort: number;
httpsPort: number;
protocols: string[];
responseHeaderTimeoutSeconds: number;
};
sites: EdgeSite[];
tcpPortRelease: {
settingsPath: string;
workDir: string;
service: string;
fromPort: number;
toPort: number;
applyCommand: string;
};
legacyExposure: {
pk01: { route: string; caddyConfigPath: string; caddyServiceName: string; managedBlock: string; hostname: string };
frpc: { route: string; namespace: string; deploymentName: string; retirementBlocking: boolean };
};
}
export function pikaoaPublicEdgeHelp(): Record<string, unknown> {
return {
ok: true,
command: "pikaoa public-edge",
usage: [
"bun scripts/cli.ts pikaoa public-edge plan [--config path]",
"bun scripts/cli.ts pikaoa public-edge apply [--config path] --confirm",
"bun scripts/cli.ts pikaoa public-edge status [--config path]",
],
source: "config/pikaoa.yaml#publicEdge",
guarantees: [
"TCP 80/443、站点、upstream、Caddy 和 TCP 端口释放全部来自 owning YAML。",
"apply 保留 UDP 443;只迁移 YAML 声明的 TCP 服务端口。",
"旧 PK01 Caddy 托管块按 marker 精确删除;FRPC 退役仅为 non-blocking warning。",
],
};
}
export async function runPikaoaPublicEdgeCommand(config: UniDeskConfig, args: string[]): Promise<RenderedCliResult> {
if (args.length === 0 || args.some((arg) => arg === "--help" || arg === "-h")) {
return renderMachine("pikaoa public-edge", pikaoaPublicEdgeHelp(), "json");
}
const options = parseOptions(args);
const spec = readSpec(options.configPath);
if (options.action === "plan") return renderMachine("pikaoa public-edge plan", plan(spec, options.configPath), "json");
if (options.action === "status") return renderMachine("pikaoa public-edge status", await status(config, spec), "json");
if (!options.confirm) throw inputError("apply 必须显式提供 --confirm", "confirm-required", "--confirm");
return renderMachine("pikaoa public-edge apply", await apply(config, spec), "json");
}
function parseOptions(args: string[]): EdgeOptions {
const action = args[0];
if (action !== "plan" && action !== "apply" && action !== "status") {
throw inputError("public-edge action 必须是 plan、apply 或 status", "invalid-action", action ?? "<missing>");
}
let configPath = resolve(repoRoot, "config/pikaoa.yaml");
let confirm = false;
for (let index = 1; index < args.length; index += 1) {
const arg = args[index]!;
if (arg === "--config") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw inputError("--config 需要参数", "missing-option-value", "--config");
configPath = resolve(repoRoot, value);
index += 1;
} else if (arg === "--confirm") {
confirm = true;
} else {
throw inputError(`不支持的 public-edge 参数:${arg}`, "unsupported-option", arg);
}
}
if ((action === "plan" || action === "status") && confirm) throw inputError(`${action} 不接受 --confirm`, "confirm-not-allowed", "--confirm");
return { action, configPath, confirm };
}
function readSpec(path: string): EdgeSpec {
if (!existsSync(path)) throw inputError(`找不到 owning YAML${path}`, "config-not-found", "--config");
const root = readYamlRecord(path, "pikaoa-platform-delivery");
const edge = record(root.publicEdge, "publicEdge");
const target = record(edge.target, "publicEdge.target");
const caddy = record(edge.caddy, "publicEdge.caddy");
const sites = record(edge.sites, "publicEdge.sites");
const tcp = record(edge.tcpPortRelease, "publicEdge.tcpPortRelease");
const legacy = record(edge.legacyExposure, "publicEdge.legacyExposure");
const pk01 = record(legacy.pk01, "publicEdge.legacyExposure.pk01");
const frpc = record(legacy.frpc, "publicEdge.legacyExposure.frpc");
return {
route: string(target.route, "publicEdge.target.route"),
publicAddress: string(target.publicAddress, "publicEdge.target.publicAddress"),
caddy: {
image: string(caddy.image, "publicEdge.caddy.image"),
workDir: string(caddy.workDir, "publicEdge.caddy.workDir"),
composePath: string(caddy.composePath, "publicEdge.caddy.composePath"),
caddyfilePath: string(caddy.caddyfilePath, "publicEdge.caddy.caddyfilePath"),
dataDir: string(caddy.dataDir, "publicEdge.caddy.dataDir"),
configDir: string(caddy.configDir, "publicEdge.caddy.configDir"),
containerName: string(caddy.containerName, "publicEdge.caddy.containerName"),
httpPort: integer(caddy.httpPort, "publicEdge.caddy.httpPort"),
httpsPort: integer(caddy.httpsPort, "publicEdge.caddy.httpsPort"),
protocols: stringList(caddy.protocols, "publicEdge.caddy.protocols"),
responseHeaderTimeoutSeconds: integer(caddy.responseHeaderTimeoutSeconds, "publicEdge.caddy.responseHeaderTimeoutSeconds"),
},
sites: Object.entries(sites).map(([id, value]) => {
const site = record(value, `publicEdge.sites.${id}`);
return { id, hostname: hostname(site.hostname, `publicEdge.sites.${id}.hostname`), upstream: string(site.upstream, `publicEdge.sites.${id}.upstream`), healthPath: httpPath(site.healthPath, `publicEdge.sites.${id}.healthPath`) };
}),
tcpPortRelease: {
settingsPath: string(tcp.settingsPath, "publicEdge.tcpPortRelease.settingsPath"),
workDir: string(tcp.workDir, "publicEdge.tcpPortRelease.workDir"),
service: string(tcp.service, "publicEdge.tcpPortRelease.service"),
fromPort: integer(tcp.fromPort, "publicEdge.tcpPortRelease.fromPort"),
toPort: integer(tcp.toPort, "publicEdge.tcpPortRelease.toPort"),
applyCommand: string(tcp.applyCommand, "publicEdge.tcpPortRelease.applyCommand"),
},
legacyExposure: {
pk01: {
route: string(pk01.route, "publicEdge.legacyExposure.pk01.route"),
caddyConfigPath: string(pk01.caddyConfigPath, "publicEdge.legacyExposure.pk01.caddyConfigPath"),
caddyServiceName: string(pk01.caddyServiceName, "publicEdge.legacyExposure.pk01.caddyServiceName"),
managedBlock: string(pk01.managedBlock, "publicEdge.legacyExposure.pk01.managedBlock"),
hostname: hostname(pk01.hostname, "publicEdge.legacyExposure.pk01.hostname"),
},
frpc: {
route: string(frpc.route, "publicEdge.legacyExposure.frpc.route"),
namespace: string(frpc.namespace, "publicEdge.legacyExposure.frpc.namespace"),
deploymentName: string(frpc.deploymentName, "publicEdge.legacyExposure.frpc.deploymentName"),
retirementBlocking: boolean(frpc.retirementBlocking, "publicEdge.legacyExposure.frpc.retirementBlocking"),
},
},
};
}
function plan(spec: EdgeSpec, configPath: string): Record<string, unknown> {
return {
ok: true,
mutation: false,
configRef: `${configPath}#publicEdge`,
target: { route: spec.route, publicAddress: spec.publicAddress },
listeners: { tcp: [spec.caddy.httpPort, spec.caddy.httpsPort], udpPreserved: [spec.caddy.httpsPort] },
sites: spec.sites,
tcpPortRelease: { service: spec.tcpPortRelease.service, fromPort: spec.tcpPortRelease.fromPort, toPort: spec.tcpPortRelease.toPort },
legacyExposure: { hostname: spec.legacyExposure.pk01.hostname, managedBlock: spec.legacyExposure.pk01.managedBlock, frpcRetirementBlocking: false },
renderedFingerprint: sha256Fingerprint(`${renderCaddyfile(spec)}\n${renderCompose(spec)}`),
valuesPrinted: false,
};
}
async function apply(config: UniDeskConfig, spec: EdgeSpec): Promise<Record<string, unknown>> {
const edgeResult = await capture(config, spec.route, ["sh"], edgeApplyScript(spec), { runtimeTimeoutMs: 240_000 });
const edge = parseJsonOutput(edgeResult.stdout) ?? { ok: false, capture: compactCapture(edgeResult, { full: true }) };
if (edgeResult.exitCode !== 0 || edge.ok === false) return { ok: false, mutation: true, edge, valuesPrinted: false };
const legacyResult = await capture(config, spec.legacyExposure.pk01.route, ["sh"], legacyRemovalScript(spec), { runtimeTimeoutMs: 60_000 });
const legacy = parseJsonOutput(legacyResult.stdout) ?? { ok: false, capture: compactCapture(legacyResult, { full: true }) };
const frpcResult = await capture(config, spec.legacyExposure.frpc.route, ["kubectl", "get", "deployment", "-n", spec.legacyExposure.frpc.namespace, spec.legacyExposure.frpc.deploymentName, "-o", "name"], "");
const warnings = frpcResult.exitCode === 0 ? [{ code: "legacy-frpc-still-present", blocking: false, object: frpcResult.stdout.trim() }] : [];
return { ok: legacyResult.exitCode === 0 && legacy.ok !== false, mutation: true, edge, legacy, warnings, valuesPrinted: false };
}
async function status(config: UniDeskConfig, spec: EdgeSpec): Promise<Record<string, unknown>> {
const edgeResult = await capture(config, spec.route, ["sh"], edgeStatusScript(spec));
const legacyResult = await capture(config, spec.legacyExposure.pk01.route, ["sh"], legacyStatusScript(spec));
const edge = parseJsonOutput(edgeResult.stdout) ?? { ok: false, capture: compactCapture(edgeResult, { full: true }) };
const legacy = parseJsonOutput(legacyResult.stdout) ?? { ok: false, capture: compactCapture(legacyResult, { full: true }) };
return { ok: edgeResult.exitCode === 0 && legacyResult.exitCode === 0 && edge.ok !== false && legacy.ok !== false, mutation: false, edge, legacy, valuesPrinted: false };
}
function edgeApplyScript(spec: EdgeSpec): string {
const caddyfile = Buffer.from(renderCaddyfile(spec), "utf8").toString("base64");
const compose = Buffer.from(renderCompose(spec), "utf8").toString("base64");
return `set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
settings=${shQuote(spec.tcpPortRelease.settingsPath)}
python3 - "$settings" ${shQuote(spec.tcpPortRelease.service)} ${spec.tcpPortRelease.fromPort} ${spec.tcpPortRelease.toPort} >"$tmp/port.json" <<'PY'
import json, sys
path, service, old, new = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
text = open(path, encoding="utf-8").read()
lines = text.splitlines(keepends=True)
inside = False
changed = False
seen = False
for i, line in enumerate(lines):
if line.rstrip() == f"{service}:":
inside = True
continue
if inside and line and not line[0].isspace():
inside = False
if inside and line.strip() == f"port: {new}":
seen = True
if inside and line.strip() == f"port: {old}":
prefix = line[:len(line) - len(line.lstrip())]
newline = "\\n" if line.endswith("\\n") else ""
lines[i] = f"{prefix}port: {new}{newline}"
changed = True
seen = True
if not seen:
raise SystemExit(f"{service} port is neither {old} nor {new}")
if changed:
open(path, "w", encoding="utf-8").write("".join(lines))
print(json.dumps({"ok": True, "service": service, "fromPort": old, "toPort": new, "changed": changed}))
PY
port_rc=$?
if [ "$port_rc" -eq 0 ] && grep -q '"changed": true' "$tmp/port.json"; then
cd ${shQuote(spec.tcpPortRelease.workDir)}
${spec.tcpPortRelease.applyCommand} >"$tmp/vpn.out" 2>"$tmp/vpn.err"
vpn_rc=$?
elif [ "$port_rc" -eq 0 ]; then
: >"$tmp/vpn.out"; : >"$tmp/vpn.err"; vpn_rc=0
else
: >"$tmp/vpn.out"; : >"$tmp/vpn.err"; vpn_rc=1
fi
mkdir -p ${shQuote(spec.caddy.workDir)} ${shQuote(spec.caddy.dataDir)} ${shQuote(spec.caddy.configDir)}
printf '%s' '${caddyfile}' | base64 -d >${shQuote(spec.caddy.caddyfilePath)}
printf '%s' '${compose}' | base64 -d >${shQuote(spec.caddy.composePath)}
if [ "$vpn_rc" -eq 0 ]; then
docker compose -f ${shQuote(spec.caddy.composePath)} up -d >"$tmp/caddy.out" 2>"$tmp/caddy.err"
caddy_rc=$?
else
: >"$tmp/caddy.out"; : >"$tmp/caddy.err"; caddy_rc=1
fi
sleep 2
probe_rc=0
for item in ${spec.sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}`)).join(" ")}; do
host="${'${item%%|*}'}"; path="/${'${item#*|}'}"; path="/${'${path#/}'}"
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 30 --resolve "$host:${spec.caddy.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null 2>"$tmp/probe-$host.err" || probe_rc=1
done
python3 - "$port_rc" "$vpn_rc" "$caddy_rc" "$probe_rc" "$tmp/port.json" <<'PY'
import json, sys
rcs = [int(x) for x in sys.argv[1:5]]
try:
port = json.load(open(sys.argv[5], encoding="utf-8"))
except Exception:
port = {"ok": False}
print(json.dumps({"ok": all(x == 0 for x in rcs), "steps": {"tcpPortRelease": {"exitCode": rcs[0], **port}, "vpnApply": {"exitCode": rcs[1], "outputRedacted": True}, "caddyApply": {"exitCode": rcs[2]}, "siteProbes": {"exitCode": rcs[3]}}, "valuesPrinted": False}))
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
PY
`;
}
function edgeStatusScript(spec: EdgeSpec): string {
return `set -u
container_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(spec.caddy.containerName)} 2>/dev/null | grep -qx true || container_rc=1
ports_rc=0; ss -ltn | grep -Eq '[:.]${spec.caddy.httpPort}[[:space:]]' || ports_rc=1; ss -ltn | grep -Eq '[:.]${spec.caddy.httpsPort}[[:space:]]' || ports_rc=1
sites_rc=0
for item in ${spec.sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}`)).join(" ")}; do
host="${'${item%%|*}'}"; path="/${'${item#*|}'}"; path="/${'${path#/}'}"
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve "$host:${spec.caddy.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || sites_rc=1
done
xray_rc=0; ss -ltn | grep -Eq '[:.]${spec.tcpPortRelease.toPort}[[:space:]]' || xray_rc=1
python3 - "$container_rc" "$ports_rc" "$sites_rc" "$xray_rc" <<'PY'
import json, sys
rcs = [int(x) for x in sys.argv[1:]]
print(json.dumps({"ok": all(x == 0 for x in rcs), "container": {"exitCode": rcs[0]}, "tcpListeners": {"exitCode": rcs[1]}, "siteProbes": {"exitCode": rcs[2]}, "releasedService": {"exitCode": rcs[3]}, "valuesPrinted": False}))
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
PY
`;
}
function legacyRemovalScript(spec: EdgeSpec): string {
const legacy = spec.legacyExposure.pk01;
return `set -u
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
python3 - ${shQuote(legacy.caddyConfigPath)} ${shQuote(legacy.managedBlock)} "$tmp/Caddyfile" <<'PY'
import re, sys
path, marker, out = sys.argv[1:]
text = open(path, encoding="utf-8").read()
pattern = re.compile(rf"(?ms)^# BEGIN unidesk managed {re.escape(marker)}\\n.*?^# END unidesk managed {re.escape(marker)}\\n?")
next_text, count = pattern.subn("", text)
open(out, "w", encoding="utf-8").write(next_text)
print(count)
PY
remove_rc=$?
if [ "$remove_rc" -eq 0 ]; then caddy fmt --overwrite "$tmp/Caddyfile" >/dev/null 2>"$tmp/fmt.err"; fmt_rc=$?; else fmt_rc=1; fi
if [ "$fmt_rc" -eq 0 ]; then caddy validate --config "$tmp/Caddyfile" >/dev/null 2>"$tmp/validate.err"; validate_rc=$?; else validate_rc=1; fi
if [ "$validate_rc" -eq 0 ]; then install -m 0644 "$tmp/Caddyfile" ${shQuote(legacy.caddyConfigPath)}; install_rc=$?; else install_rc=1; fi
if [ "$install_rc" -eq 0 ]; then systemctl reload ${shQuote(legacy.caddyServiceName)}; reload_rc=$?; else reload_rc=1; fi
python3 - "$remove_rc" "$fmt_rc" "$validate_rc" "$install_rc" "$reload_rc" <<'PY'
import json, sys
rcs = [int(x) for x in sys.argv[1:]]
print(json.dumps({"ok": all(x == 0 for x in rcs), "managedBlockRemoved": all(x == 0 for x in rcs), "steps": rcs, "valuesPrinted": False}))
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
PY
`;
}
function legacyStatusScript(spec: EdgeSpec): string {
const legacy = spec.legacyExposure.pk01;
return `set -u
if grep -Fq ${shQuote(`# BEGIN unidesk managed ${legacy.managedBlock}`)} ${shQuote(legacy.caddyConfigPath)}; then present=true; rc=1; else present=false; rc=0; fi
python3 - "$present" "$rc" <<'PY'
import json, sys
present = sys.argv[1] == "true"; rc = int(sys.argv[2])
print(json.dumps({"ok": rc == 0, "legacyManagedBlockPresent": present, "valuesPrinted": False}))
raise SystemExit(rc)
PY
`;
}
function renderCaddyfile(spec: EdgeSpec): string {
const global = `{\n servers {\n protocols ${spec.caddy.protocols.join(" ")}\n }\n}\n`;
return global + spec.sites.map((site) => `${site.hostname} {\n reverse_proxy ${site.upstream} {\n transport http {\n response_header_timeout ${spec.caddy.responseHeaderTimeoutSeconds}s\n }\n }\n}`).join("\n\n") + "\n";
}
function renderCompose(spec: EdgeSpec): string {
return `services:\n caddy:\n image: ${spec.caddy.image}\n container_name: ${spec.caddy.containerName}\n network_mode: host\n restart: unless-stopped\n volumes:\n - ${spec.caddy.caddyfilePath}:/etc/caddy/Caddyfile:ro\n - ${spec.caddy.dataDir}:/data\n - ${spec.caddy.configDir}:/config\n`;
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw inputError(`${path} 必须是对象`, "invalid-config", path);
return value as Record<string, unknown>;
}
function string(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) throw inputError(`${path} 必须是非空字符串`, "invalid-config", path);
return value.trim();
}
function integer(value: unknown, path: string): number {
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 65_535) throw inputError(`${path} 必须是 1..65535 整数`, "invalid-config", path);
return value as number;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw inputError(`${path} 必须是布尔值`, "invalid-config", path);
return value;
}
function stringList(value: unknown, path: string): string[] {
if (!Array.isArray(value) || value.length === 0) throw inputError(`${path} 必须是非空字符串数组`, "invalid-config", path);
return value.map((item, index) => string(item, `${path}[${index}]`));
}
function hostname(value: unknown, path: string): string {
const result = string(value, path);
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(result)) throw inputError(`${path} hostname`, "invalid-config", path);
return result;
}
function httpPath(value: unknown, path: string): string {
const result = string(value, path);
if (!result.startsWith("/")) throw inputError(`${path} 必须以 / 开头`, "invalid-config", path);
return result;
}
function inputError(message: string, code: string, argument?: string): CliInputError {
return new CliInputError(message, { code, argument, usage: "bun scripts/cli.ts pikaoa public-edge plan|apply|status" });
}
+5 -2
View File
@@ -10,6 +10,7 @@ import type { RenderedCliResult } from "./output";
import { CliInputError } from "./output";
import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";
import { resolveConfigRef } from "./ops/config-refs";
import { pikaoaPublicEdgeHelp, runPikaoaPublicEdgeCommand } from "./pikaoa-public-edge";
import {
pikaoaManifestForStep,
placeholderPikaoaSecrets,
@@ -236,8 +237,10 @@ export function pikaoaTestTargetHelp(): Record<string, unknown> {
}
export async function runPikaoaCommand(config: UniDeskConfig, args: string[], remoteCapture: RemoteCapture = capture): Promise<RenderedCliResult> {
if (args.length === 0 || args.some(isHelpToken)) return renderMachine("pikaoa test-target", pikaoaTestTargetHelp(), "json");
if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target", "unsupported-pikaoa-command", "pikaoa", ["test-target"]);
if (args.length === 0) return renderMachine("pikaoa", { ok: true, commands: [pikaoaTestTargetHelp(), pikaoaPublicEdgeHelp()] }, "json");
if (args[0] === "public-edge") return await runPikaoaPublicEdgeCommand(config, args.slice(1));
if (args.some(isHelpToken)) return renderMachine("pikaoa test-target", pikaoaTestTargetHelp(), "json");
if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target 或 public-edge", "unsupported-pikaoa-command", "pikaoa", ["test-target", "public-edge"]);
const options = parseOptions(args.slice(1));
const selection = readSelection(options);
if (options.action === "plan") return renderResult(planPayload(options, selection), options);