Files
pikasTech-unidesk/scripts/src/pikaoa-public-edge.ts
T
2026-07-15 10:57:25 +02:00

374 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
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"),
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 ]; then
cd ${shQuote(spec.tcpPortRelease.workDir)}
${spec.tcpPortRelease.applyCommand} >"$tmp/vpn.out" 2>"$tmp/vpn.err"
vpn_rc=$?
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 {
return 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 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" });
}