diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index b5eb3328..b5e080b0 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -161,6 +161,7 @@ testRuntime: serviceType: NodePort hostIP: 152.53.229.148 port: 32080 + publicBaseUrl: https://oa-dev.hwpod.com observability: configRef: config/platform-infra/observability.yaml otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317 @@ -318,6 +319,7 @@ releaseRuntime: serviceType: NodePort hostIP: 152.53.229.148 port: 32096 + publicBaseUrl: https://oa.hwpod.com observability: configRef: config/platform-infra/observability.yaml otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317 @@ -340,6 +342,54 @@ releaseRuntime: livenessPath: /healthz readinessPath: /healthz +publicEdge: + target: + node: NC01 + route: NC01 + publicAddress: 152.53.229.148 + caddy: + image: caddy:2.10.2-alpine + workDir: /opt/unidesk/pikaoa-edge + composePath: /opt/unidesk/pikaoa-edge/docker-compose.yaml + caddyfilePath: /opt/unidesk/pikaoa-edge/Caddyfile + dataDir: /var/lib/unidesk/pikaoa-edge/caddy-data + configDir: /var/lib/unidesk/pikaoa-edge/caddy-config + containerName: pikaoa-edge + httpPort: 80 + httpsPort: 443 + protocols: + - h1 + - h2 + responseHeaderTimeoutSeconds: 60 + sites: + production: + hostname: oa.hwpod.com + upstream: 127.0.0.1:32096 + healthPath: /healthz + development: + hostname: oa-dev.hwpod.com + upstream: 127.0.0.1:32080 + healthPath: /healthz + tcpPortRelease: + settingsPath: /root/vpn-server/config/settings.yaml + workDir: /root/vpn-server + service: xray + fromPort: 443 + toPort: 8447 + applyCommand: npm run apply + legacyExposure: + pk01: + route: PK01 + caddyConfigPath: /etc/caddy/Caddyfile + caddyServiceName: caddy + managedBlock: pikaoa + hostname: oa.pikapython.com + frpc: + route: NC01:k3s + namespace: pikaoa + deploymentName: pikaoa-frpc + retirementBlocking: false + delivery: targets: NC01: @@ -448,31 +498,5 @@ delivery: username: employee passwordSourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt publicExposure: - enabled: true - publicBaseUrl: https://oa.pikapython.com - dns: - hostname: oa.pikapython.com - expectedA: 82.156.23.220 - resolvers: - - 1.1.1.1 - - 8.8.8.8 - - 223.5.5.5 - - 114.114.114.114 - frpc: - deploymentName: pikaoa-frpc - configMapName: pikaoa-frpc-config - configKey: frpc.toml - image: 127.0.0.1:5000/hwlab/frpc:v0.68.1 - serverAddr: 82.156.23.220 - serverPort: 22000 - proxyName: pikaoa-nc01-web - remotePort: 22096 - localIP: pikaoa-web.pikaoa.svc.cluster.local - localPort: 8080 - tokenEnvName: FRP_TOKEN - tokenTargetKey: FRP_TOKEN - pk01: - route: PK01 - caddyConfigPath: /etc/caddy/Caddyfile - caddyServiceName: caddy - responseHeaderTimeoutSeconds: 60 + configRef: config/pikaoa.yaml#publicEdge + site: production diff --git a/docs/MDTODO/mdtodo-tooling-reliability.md b/docs/MDTODO/mdtodo-tooling-reliability.md index f94e9f4b..0c9d8a61 100644 --- a/docs/MDTODO/mdtodo-tooling-reliability.md +++ b/docs/MDTODO/mdtodo-tooling-reliability.md @@ -22,3 +22,11 @@ ## R2 [completed] 解决 UniDesk #1913(https://github.com/pikasTech/unidesk/issues/1913):复用 agent_skills #7 与 PR #8 的 MDTODO 安全 stdin 实现,让主代理、Artificer resource bundle 和受控 host skill 投影默认使用安全标题输入,并有界披露 source/target freshness、warning 与精确修复入口;不得复制第二套 MDTODO 工具、增加版本门禁或覆盖宿主并行脏改,由 Artificer 在独立 worktree 和分支实现、测试并提交 PR,不自行合并,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R2_Task_Report.md)。 + +## R3 [in_progress] + +解决 [UniDesk #2170](https://github.com/pikasTech/unidesk/issues/2170):为 unidesk-ymalops 增加配置引用问题的 skill + owning YAML 快速收敛与停止条件,证明同类问题减少不必要工具调用,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R3_Task_Report.md)。 + +## R4 [in_progress] + +解决 [agent_skills #15](https://github.com/pikasTech/agent_skills/issues/15):为 Playwright wrapper 增加不泄露凭据的 fill-file 表单填充,并证明 Secret 不进入 argv、输出或 JSONL 日志,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R4_Task_Report.md)。 diff --git a/docs/reference/pikaoa.md b/docs/reference/pikaoa.md index 844c027d..2313ba0e 100644 --- a/docs/reference/pikaoa.md +++ b/docs/reference/pikaoa.md @@ -17,7 +17,8 @@ - runtime namespace:`pikaoa`; - CI namespace:`pikaoa-ci`; - Argo Application:`pikaoa-nc01`; - - 公网入口:`https://oa.pikapython.com`。 + - 公网入口:`https://oa.hwpod.com`。 +- 测试公网入口固定使用 `https://oa-dev.hwpod.com`。 - 生产交付与公网配置分别以以下 selector 为真相: - `config/pikaoa.yaml#releaseRuntime.targets.NC01`; - `config/pikaoa.yaml#delivery.targets.NC01`。 @@ -139,6 +140,23 @@ - Argo Application `pikaoa-nc01` 自动同步 `pikaoa` namespace; - 禁止人工补建 PipelineRun 代替 source merge。 - 生产使用 PK01 host PostgreSQL 独立 `pikaoa` database/role,不使用 namespace-local PostgreSQL。 +- 公网边缘由 `config/pikaoa.yaml#publicEdge` 唯一声明: + - `oa.hwpod.com` 反向代理生产 NodePort; + - `oa-dev.hwpod.com` 反向代理测试 NodePort; + - NC01 Caddy 使用标准 TCP 80/443; + - Hysteria 继续使用 UDP 443,不受 TCP listener 变更影响; + - Xray Reality 使用 YAML 声明的独立 TCP 端口,不再占用 TCP 443; + - 旧 `oa.pikapython.com` PK01 Caddy 托管块必须退役。 +- 公网边缘受控入口是: + + ```bash + bun scripts/cli.ts pikaoa public-edge plan + bun scripts/cli.ts pikaoa public-edge apply --confirm + bun scripts/cli.ts pikaoa public-edge status + ``` + +- `public-edge apply` 只按 managed marker 删除 PikaOA 旧边缘块;不得覆盖 PK01 其他 Caddy 站点。 +- 旧 FRPC 残留只产生 `blocking=false` warning,不阻塞新域名上线。 - 生产初始化模式是 `fresh-database-only`,不实现旧数据迁移、兼容或回滚。 - 版本、审计和配置一致性异常只产生 `blocking=false` warning,不作为 MVP 业务门禁。 - PR、构建或单测不能替代固定 NodePort 原入口验收。 @@ -160,7 +178,8 @@ - 再验证公网和共享可观测性: ```bash - curl -fsSI https://oa.pikapython.com/ + curl -fsSI https://oa.hwpod.com/ + curl -fsSI https://oa-dev.hwpod.com/ bun scripts/cli.ts platform-infra observability metrics-query \ --target NC01 \ --query 'pikaoa_database_ready' \ diff --git a/scripts/src/pikaoa-public-edge.ts b/scripts/src/pikaoa-public-edge.ts new file mode 100644 index 00000000..550395f8 --- /dev/null +++ b/scripts/src/pikaoa-public-edge.ts @@ -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 { + 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 { + 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 ?? ""); + } + 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 { + 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> { + 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> { + 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw inputError(`${path} 必须是对象`, "invalid-config", path); + return value as Record; +} + +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" }); +} diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts index b99f5b8c..ea4e67a0 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -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 { } export async function runPikaoaCommand(config: UniDeskConfig, args: string[], remoteCapture: RemoteCapture = capture): Promise { - 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);