diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index b5e080b0..8100eb9b 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -159,9 +159,10 @@ testRuntime: exposure: serviceName: pikaoa-web serviceType: NodePort - hostIP: 152.53.229.148 + hostIP: 127.0.0.1 port: 32080 publicBaseUrl: https://oa-dev.hwpod.com + expectedA: 152.53.229.148 observability: configRef: config/platform-infra/observability.yaml otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317 @@ -317,9 +318,10 @@ releaseRuntime: exposure: serviceName: pikaoa-web serviceType: NodePort - hostIP: 152.53.229.148 + hostIP: 127.0.0.1 port: 32096 publicBaseUrl: https://oa.hwpod.com + expectedA: 152.53.229.148 observability: configRef: config/platform-infra/observability.yaml otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317 @@ -342,54 +344,6 @@ 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: @@ -497,6 +451,3 @@ delivery: employee: username: employee passwordSourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt - publicExposure: - configRef: config/pikaoa.yaml#publicEdge - site: production diff --git a/config/platform-infra/nc01-public-edge.yaml b/config/platform-infra/nc01-public-edge.yaml new file mode 100644 index 00000000..fb28ab27 --- /dev/null +++ b/config/platform-infra/nc01-public-edge.yaml @@ -0,0 +1,50 @@ +version: 1 +kind: platform-infra-public-edge + +metadata: + id: nc01-public-edge + owner: platform-infra + relatedIssues: + - 2181 + +defaults: + targetId: NC01 + +targets: + NC01: + route: NC01 + publicAddress: 152.53.229.148 + listener: + httpPort: 80 + httpsPort: 443 + protocols: + - h1 + - h2 + runtime: + image: caddy:2.10.2-alpine + containerName: pikaoa-edge + 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 + responseHeaderTimeoutSeconds: 60 + sites: + - id: pikaoa-development + configRef: config/pikaoa.yaml + path: testRuntime.targets.NC01 + - id: pikaoa-production + configRef: config/pikaoa.yaml + path: releaseRuntime.targets.NC01 + - id: hwlab-development + configRef: config/hwlab-node-control-plane.yaml + path: targets[id=nc01-v03].publicExposure + - id: hwlab-production + configRef: config/hwlab-node-control-plane.yaml + path: targets[id=nc01-production].publicExposure + - id: unidesk + configRef: config/frontend.yaml + path: publicExposure + - id: webterm + configRef: config/platform-infra/webterm.yaml + path: targets[id=local-7683].publicExposure diff --git a/docs/reference/pikaoa.md b/docs/reference/pikaoa.md index 2313ba0e..a429bf37 100644 --- a/docs/reference/pikaoa.md +++ b/docs/reference/pikaoa.md @@ -140,23 +140,25 @@ - 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 托管块必须退役。 -- 公网边缘受控入口是: +- PikaOA 公网 exposure 由 `config/pikaoa.yaml` 的产品 target 唯一声明: + - `testRuntime.targets.NC01.exposure` 拥有 `oa-dev.hwpod.com` 与 NodePort `32080`; + - `releaseRuntime.targets.NC01.exposure` 拥有 `oa.hwpod.com` 与 NodePort `32096`; + - 产品 YAML 不再拥有 Caddy listener、workDir、证书目录或 Compose; + - 平台聚合只通过 `configRef/path` 读取 exposure,不复制 hostname 或 upstream。 +- NC01 共享公网边缘由 + `config/platform-infra/nc01-public-edge.yaml` 唯一声明,受控入口是: ```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 + bun scripts/cli.ts platform-infra public-edge plan --target NC01 + bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run + bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm + bun scripts/cli.ts platform-infra public-edge status --target NC01 ``` -- `public-edge apply` 只按 managed marker 删除 PikaOA 旧边缘块;不得覆盖 PK01 其他 Caddy 站点。 -- 旧 FRPC 残留只产生 `blocking=false` warning,不阻塞新域名上线。 +- 共享边缘复用现有 `pikaoa-edge` 容器、workDir、Caddy data/config 和证书状态; + 禁止启动第二个 TCP `80/443` listener。 +- PikaOA 两站是 R9.1 已解析消费者;其他产品引用未迁移时, + `plan/status` 精确披露,`apply` 拒绝 mutation。 - 生产初始化模式是 `fresh-database-only`,不实现旧数据迁移、兼容或回滚。 - 版本、审计和配置一致性异常只产生 `blocking=false` warning,不作为 MVP 业务门禁。 - PR、构建或单测不能替代固定 NodePort 原入口验收。 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 71fb5999..8c895536 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -10,6 +10,32 @@ - UniDesk 拥有的 image version、namespace、endpoint、FRP port 与 profile 列表不得隐藏在 helper 常量里;外部工具要求的 TOML、JSON 和 env 仅是边缘渲染格式。 - 新节点出网 bootstrap 遵循 `docs/reference/yaml-first-ops.md` 的零依赖 host proxy 边界,不得依赖尚未安装的 Docker、k3s、containerd 或包管理器网络能力。 +## NC01 共享公网边缘 + +- `config/platform-infra/nc01-public-edge.yaml` 是 NC01 TCP `80/443` listener、 + Caddy runtime、workDir、Compose、TLS data/config 和站点聚合顺序的唯一真相。 +- 每个站点只声明 `configRef` 与 `path`: + - hostname、upstream、DNS expectation 和 probe path 从产品 owning YAML 解析; + - 共享 YAML、renderer 和产品 YAML 不得重复声明同一 hostname 或 upstream; + - 路径缺失、对象形状不兼容、hostname 重复或 `expectedA` 不匹配时, + `plan/status` 必须精确披露,`apply` 不得补隐藏默认值。 +- 共享边缘复用 `pikaoa-edge` 的 Caddy workDir、data、config、容器名和证书状态; + 禁止创建第二 Caddy、FRP 或 fallback listener。 +- `platform-infra public-edge apply --confirm` 每次重新读取 YAML 与运行面: + - 先生成临时候选 Caddyfile; + - 先执行 Caddy adapt 与 validate; + - 校验成功后才安装并 reload; + - reload 失败时恢复上一份有效 Caddyfile 与 Compose; + - 不得基于旧 `status` 结果直接 mutation。 +- 受控入口: + + ```bash + bun scripts/cli.ts platform-infra public-edge plan --target NC01 + bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run + bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm + bun scripts/cli.ts platform-infra public-edge status --target NC01 + ``` + ## Gitea 与 Pipelines-as-Code 边界 - Gitea mirror 与 Pipelines-as-Code 是 UniDesk 运维的 CI source/trigger 服务。`config/platform-infra/gitea.yaml` 和 `config/platform-infra/pipelines-as-code.yaml` 分别拥有 mirror/webhook 与 PaC Repository/consumer 配置;repo URL、snapshot、webhook、public exposure、FRP/Caddy port、token sourceRef 和 PaC params 不得隐藏在代码特例中。 diff --git a/scripts/src/pikaoa-public-edge.ts b/scripts/src/pikaoa-public-edge.ts deleted file mode 100644 index 550395f8..00000000 --- a/scripts/src/pikaoa-public-edge.ts +++ /dev/null @@ -1,383 +0,0 @@ -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 ea4e67a0..9f166bba 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -10,7 +10,6 @@ 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, @@ -237,10 +236,9 @@ export function pikaoaTestTargetHelp(): Record { } export async function runPikaoaCommand(config: UniDeskConfig, args: string[], remoteCapture: RemoteCapture = capture): Promise { - 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.length === 0) return renderMachine("pikaoa", { ok: true, commands: [pikaoaTestTargetHelp()] }, "json"); 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"]); + if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target", "unsupported-pikaoa-command", "pikaoa", ["test-target"]); const options = parseOptions(args.slice(1)); const selection = readSelection(options); if (options.action === "plan") return renderResult(planPayload(options, selection), options); diff --git a/scripts/src/platform-infra-public-edge.ts b/scripts/src/platform-infra-public-edge.ts new file mode 100644 index 00000000..26043b54 --- /dev/null +++ b/scripts/src/platform-infra-public-edge.ts @@ -0,0 +1,492 @@ +import { Buffer } from "node:buffer"; +import { existsSync } from "node:fs"; + +import type { UniDeskConfig } from "./config"; +import { renderMachine } from "./cicd-render"; +import type { RenderedCliResult } from "./output"; +import { CliInputError } from "./output"; +import { + capture, + compactCapture, + parseJsonOutput, + readYamlRecord, + resolveRepoPath, + sha256Fingerprint, + shQuote, +} from "./platform-infra-ops-library"; + +type PublicEdgeAction = "plan" | "apply" | "status"; + +interface PublicEdgeOptions { + action: PublicEdgeAction; + configPath: string; + targetId: string | null; + dryRun: boolean; + confirm: boolean; +} + +export interface SiteReference { + id: string; + configRef: string; + path: string; +} + +export interface ResolvedSite extends SiteReference { + hostname: string; + upstream: string; + healthPath: string; + expectedA: string; +} + +export interface PublicEdgeTarget { + id: string; + route: string; + publicAddress: string; + listener: { + httpPort: number; + httpsPort: number; + protocols: string[]; + }; + runtime: { + image: string; + containerName: string; + workDir: string; + composePath: string; + caddyfilePath: string; + dataDir: string; + configDir: string; + responseHeaderTimeoutSeconds: number; + }; + siteReferences: SiteReference[]; +} + +interface ResolutionResult { + sites: ResolvedSite[]; + unresolved: Array; +} + +export function publicEdgeHelp(): Record { + return { + ok: true, + command: "platform-infra public-edge", + usage: [ + "bun scripts/cli.ts platform-infra public-edge plan --target NC01", + "bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run", + "bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm", + "bun scripts/cli.ts platform-infra public-edge status --target NC01", + ], + source: "config/platform-infra/nc01-public-edge.yaml", + guarantees: [ + "平台 YAML 只拥有 listener、Caddy runtime 和 site 聚合顺序。", + "hostname 与 upstream 只从产品 owning YAML 的 configRef/path 解析。", + "apply 每次重新读取 YAML 与运行面,缺失引用时拒绝 mutation。", + "候选 Caddyfile 先 adapt/validate,reload 失败时恢复上一份配置。", + ], + }; +} + +export async function runPlatformInfraPublicEdgeCommand(config: UniDeskConfig, args: string[]): Promise { + if (args.length === 0 || args.some((arg) => arg === "--help" || arg === "-h")) { + return renderMachine("platform-infra public-edge", publicEdgeHelp(), "json"); + } + const options = parseOptions(args); + const root = readConfig(options.configPath); + const target = readTarget(root, options.targetId); + const resolution = resolveSites(target); + if (options.action === "plan") { + const result = planResult(options.configPath, target, resolution, "plan"); + return renderMachine("platform-infra public-edge plan", result, "json", result.ok === true); + } + if (options.action === "apply" && options.dryRun) { + const result = planResult(options.configPath, target, resolution, "dry-run"); + return renderMachine("platform-infra public-edge apply", result, "json", result.ok === true); + } + if (options.action === "status") { + const result = await status(config, options.configPath, target, resolution); + return renderMachine("platform-infra public-edge status", result, "json", result.ok === true); + } + if (resolution.unresolved.length > 0) { + const result = { + ...planResult(options.configPath, target, resolution, "blocked"), + mutation: false, + error: "存在缺失或不兼容的产品 owning YAML 引用,apply 未执行。", + }; + return renderMachine("platform-infra public-edge apply", result, "json", false); + } + const result = await apply(config, options.configPath, target, resolution.sites); + return renderMachine("platform-infra public-edge apply", result, "json", result.ok === true); +} + +function parseOptions(args: string[]): PublicEdgeOptions { + const action = args[0]; + if (action !== "plan" && action !== "apply" && action !== "status") { + throw inputError("public-edge action 必须是 plan、apply 或 status", "invalid-action", action ?? ""); + } + let configPath = resolveRepoPath("config/platform-infra/nc01-public-edge.yaml"); + let targetId: string | null = null; + let dryRun = false; + let confirm = false; + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]!; + if (arg === "--config" || arg === "--target") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw inputError(`${arg} 需要参数`, "missing-option-value", arg); + if (arg === "--config") configPath = resolveRepoPath(value); + else targetId = value; + index += 1; + } else if (arg === "--dry-run") { + dryRun = true; + } else if (arg === "--confirm") { + confirm = true; + } else { + throw inputError(`不支持的 public-edge 参数:${arg}`, "unsupported-option", arg); + } + } + if (action !== "apply" && (dryRun || confirm)) throw inputError(`${action} 不接受 mutation 参数`, "mutation-option-not-allowed"); + if (action === "apply" && dryRun === confirm) throw inputError("apply 必须且只能选择 --dry-run 或 --confirm", "apply-mode-required"); + return { action, configPath, targetId, dryRun, confirm }; +} + +function readConfig(configPath: string): Record { + if (!existsSync(configPath)) throw inputError(`找不到 owning YAML:${configPath}`, "config-not-found", "--config"); + return readYamlRecord(configPath, "platform-infra-public-edge"); +} + +function readTarget(root: Record, requestedTargetId: string | null): PublicEdgeTarget { + const defaults = record(root.defaults, "defaults"); + const targets = record(root.targets, "targets"); + const targetId = requestedTargetId ?? string(defaults.targetId, "defaults.targetId"); + const target = record(targets[targetId], `targets.${targetId}`); + const listener = record(target.listener, `targets.${targetId}.listener`); + const runtime = record(target.runtime, `targets.${targetId}.runtime`); + const sites = array(target.sites, `targets.${targetId}.sites`); + return { + id: targetId, + route: string(target.route, `targets.${targetId}.route`), + publicAddress: ipv4(target.publicAddress, `targets.${targetId}.publicAddress`), + listener: { + httpPort: port(listener.httpPort, `targets.${targetId}.listener.httpPort`), + httpsPort: port(listener.httpsPort, `targets.${targetId}.listener.httpsPort`), + protocols: stringArray(listener.protocols, `targets.${targetId}.listener.protocols`), + }, + runtime: { + image: string(runtime.image, `targets.${targetId}.runtime.image`), + containerName: string(runtime.containerName, `targets.${targetId}.runtime.containerName`), + workDir: absolutePath(runtime.workDir, `targets.${targetId}.runtime.workDir`), + composePath: absolutePath(runtime.composePath, `targets.${targetId}.runtime.composePath`), + caddyfilePath: absolutePath(runtime.caddyfilePath, `targets.${targetId}.runtime.caddyfilePath`), + dataDir: absolutePath(runtime.dataDir, `targets.${targetId}.runtime.dataDir`), + configDir: absolutePath(runtime.configDir, `targets.${targetId}.runtime.configDir`), + responseHeaderTimeoutSeconds: positiveInteger(runtime.responseHeaderTimeoutSeconds, `targets.${targetId}.runtime.responseHeaderTimeoutSeconds`), + }, + siteReferences: sites.map((value, index) => { + const site = record(value, `targets.${targetId}.sites[${index}]`); + return { + id: string(site.id, `targets.${targetId}.sites[${index}].id`), + configRef: repoYamlPath(site.configRef, `targets.${targetId}.sites[${index}].configRef`), + path: string(site.path, `targets.${targetId}.sites[${index}].path`), + }; + }), + }; +} + +function resolveSites(target: PublicEdgeTarget): ResolutionResult { + const sites: ResolvedSite[] = []; + const unresolved: ResolutionResult["unresolved"] = []; + const hostnames = new Map(); + for (const reference of target.siteReferences) { + try { + const ownerPath = resolveRepoPath(reference.configRef); + if (!existsSync(ownerPath)) throw new Error(`configRef 文件不存在:${reference.configRef}`); + const owner = resolveObjectPath(readYamlRecord(ownerPath), reference.path); + const exposure = exposureObject(owner); + const publicBaseUrl = string(exposure.publicBaseUrl, `${reference.configRef}#${reference.path}.publicBaseUrl`); + const url = new URL(publicBaseUrl); + if (url.protocol !== "https:") throw new Error(`publicBaseUrl 必须使用 https:${publicBaseUrl}`); + const dns = optionalRecord(exposure.dns); + const hostname = hostnameValue(dns?.hostname ?? url.hostname, `${reference.configRef}#${reference.path}.hostname`); + if (url.hostname !== hostname) throw new Error(`publicBaseUrl hostname 与 dns.hostname 不一致:${url.hostname} != ${hostname}`); + const expectedA = ipv4(dns?.expectedA ?? exposure.expectedA, `${reference.configRef}#${reference.path}.expectedA`); + if (expectedA !== target.publicAddress) throw new Error(`expectedA 尚未迁移到 ${target.publicAddress},当前为 ${expectedA}`); + const upstream = resolveUpstream(exposure, `${reference.configRef}#${reference.path}`); + const healthPath = resolveHealthPath(owner, exposure, `${reference.configRef}#${reference.path}`); + const previous = hostnames.get(hostname); + if (previous !== undefined) throw new Error(`hostname 与 site ${previous} 重复:${hostname}`); + hostnames.set(hostname, reference.id); + sites.push({ ...reference, hostname, upstream, healthPath, expectedA }); + } catch (error) { + unresolved.push({ ...reference, code: "config-ref-unresolved", detail: error instanceof Error ? error.message : String(error) }); + } + } + return { sites, unresolved }; +} + +function resolveObjectPath(root: unknown, path: string): Record { + let value: unknown = root; + for (const segment of path.split(".")) { + const match = /^([A-Za-z0-9_-]+)(?:\[id=([A-Za-z0-9_-]+)\])?$/u.exec(segment); + if (match === null) throw new Error(`path segment 不合法:${segment}`); + const key = match[1]!; + value = record(value, path)[key]; + if (match[2] !== undefined) { + const values = array(value, `${path}.${key}`); + value = values.find((item) => optionalRecord(item)?.id === match[2]); + if (value === undefined) throw new Error(`path 未找到 id=${match[2]}:${path}`); + } + if (value === undefined) throw new Error(`path 不存在:${path}`); + } + return record(value, path); +} + +function exposureObject(owner: Record): Record { + if (optionalRecord(owner.publicExposure) !== null) return record(owner.publicExposure, "publicExposure"); + if (optionalRecord(owner.exposure) !== null) return record(owner.exposure, "exposure"); + return owner; +} + +function resolveUpstream(exposure: Record, label: string): string { + if (typeof exposure.upstream === "string") return upstreamValue(exposure.upstream, `${label}.upstream`); + const upstream = optionalRecord(exposure.upstream); + if (upstream !== null) return upstreamValue(`${string(upstream.host, `${label}.upstream.host`)}:${port(upstream.port, `${label}.upstream.port`)}`, `${label}.upstream`); + return upstreamValue(`${string(exposure.hostIP, `${label}.hostIP`)}:${port(exposure.port, `${label}.port`)}`, `${label}.upstream`); +} + +function resolveHealthPath(owner: Record, exposure: Record, label: string): string { + const exposureProbes = optionalRecord(exposure.probes); + if (exposureProbes !== null && exposureProbes.healthPath !== undefined) return httpPath(exposureProbes.healthPath, `${label}.probes.healthPath`); + const probes = optionalRecord(owner.probes); + if (probes !== null) { + if (probes.healthPath !== undefined) return httpPath(probes.healthPath, `${label}.probes.healthPath`); + const web = optionalRecord(probes.web); + if (web !== null && web.livenessPath !== undefined) return httpPath(web.livenessPath, `${label}.probes.web.livenessPath`); + } + throw new Error(`${label} 缺少 probes.healthPath 或 probes.web.livenessPath`); +} + +function planResult(configPath: string, target: PublicEdgeTarget, resolution: ResolutionResult, mode: string): Record & { ok: boolean } { + const complete = resolution.unresolved.length === 0; + const rendered = complete ? renderPublicEdgeArtifacts(target, resolution.sites) : null; + return { + ok: complete, + action: "platform-infra-public-edge-plan", + mode, + mutation: false, + configRef: `${configPath}#targets.${target.id}`, + target: targetSummary(target), + sites: resolution.sites, + unresolved: resolution.unresolved, + rendered: rendered === null ? null : { + caddyfileFingerprint: sha256Fingerprint(rendered.caddyfile), + composeFingerprint: sha256Fingerprint(rendered.compose), + siteCount: resolution.sites.length, + }, + valuesPrinted: false, + }; +} + +async function apply(config: UniDeskConfig, configPath: string, target: PublicEdgeTarget, sites: ResolvedSite[]): Promise> { + const artifacts = renderPublicEdgeArtifacts(target, sites); + const result = await capture(config, target.route, ["sh"], applyScript(target, sites, artifacts), { runtimeTimeoutMs: 120_000 }); + const runtime = parseJsonOutput(result.stdout) ?? { ok: false, capture: compactCapture(result, { full: true }) }; + return { + ok: result.exitCode === 0 && runtime.ok !== false, + action: "platform-infra-public-edge-apply", + mutation: result.exitCode === 0 && runtime.ok !== false, + configRef: `${configPath}#targets.${target.id}`, + target: targetSummary(target), + sites, + runtime, + valuesPrinted: false, + }; +} + +async function status(config: UniDeskConfig, configPath: string, target: PublicEdgeTarget, resolution: ResolutionResult): Promise> { + const artifacts = resolution.unresolved.length === 0 ? renderPublicEdgeArtifacts(target, resolution.sites) : null; + const result = await capture(config, target.route, ["sh"], statusScript(target, resolution.sites, artifacts?.caddyfile ?? null)); + const runtime = parseJsonOutput(result.stdout) ?? { ok: false, capture: compactCapture(result, { full: true }) }; + const ok = resolution.unresolved.length === 0 && result.exitCode === 0 && runtime.ok !== false; + return { + ok, + action: "platform-infra-public-edge-status", + mutation: false, + configRef: `${configPath}#targets.${target.id}`, + target: targetSummary(target), + sites: resolution.sites, + unresolved: resolution.unresolved, + runtime, + valuesPrinted: false, + }; +} + +export function renderPublicEdgeArtifacts(target: PublicEdgeTarget, sites: ResolvedSite[]): { caddyfile: string; compose: string } { + const protocols = target.listener.protocols.join(" "); + const global = `\{\n\tservers \{\n\t\tprotocols ${protocols}\n\t\}\n\}\n`; + const blocks = sites.map((site) => `${site.hostname} \{\n\treverse_proxy ${site.upstream} \{\n\t\ttransport http \{\n\t\t\tresponse_header_timeout ${target.runtime.responseHeaderTimeoutSeconds}s\n\t\t\}\n\t\}\n\}\n`).join("\n"); + const compose = `services:\n caddy:\n image: ${target.runtime.image}\n container_name: ${target.runtime.containerName}\n network_mode: host\n restart: unless-stopped\n volumes:\n - ${target.runtime.caddyfilePath}:/etc/caddy/Caddyfile:ro\n - ${target.runtime.dataDir}:/data\n - ${target.runtime.configDir}:/config\n`; + return { caddyfile: `${global}\n${blocks}`, compose }; +} + +function applyScript(target: PublicEdgeTarget, sites: ResolvedSite[], artifacts: { caddyfile: string; compose: string }): string { + const caddyfile = Buffer.from(artifacts.caddyfile, "utf8").toString("base64"); + const compose = Buffer.from(artifacts.compose, "utf8").toString("base64"); + const dnsItems = sites.map((site) => shQuote(`${site.hostname}|${site.expectedA}`)).join(" "); + const probeItems = sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}`)).join(" "); + return `set -u +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +printf '%s' ${shQuote(caddyfile)} | base64 -d >"$tmp/Caddyfile" +printf '%s' ${shQuote(compose)} | base64 -d >"$tmp/docker-compose.yaml" +container_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true || container_rc=1 +listeners_rc=0; ss -ltn | grep -Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc=1; ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc=1 +dns_rc=0 +for item in ${dnsItems}; do host="${"${item%%|*}"}"; expected="${"${item#*|}"}"; getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected" || dns_rc=1; done +adapt_rc=1; validate_rc=1 +if [ "$container_rc" -eq 0 ] && [ "$listeners_rc" -eq 0 ] && [ "$dns_rc" -eq 0 ]; then + docker run --rm -v "$tmp/Caddyfile:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy adapt --validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/adapt.err"; adapt_rc=$? + if [ "$adapt_rc" -eq 0 ]; then docker run --rm -v "$tmp/Caddyfile:/etc/caddy/Caddyfile:ro" ${shQuote(target.runtime.image)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>"$tmp/validate.err"; validate_rc=$?; fi +fi +install_rc=1; reload_rc=1; rollback_rc=0; probe_rc=1 +if [ "$validate_rc" -eq 0 ]; then + mkdir -p ${shQuote(target.runtime.workDir)} ${shQuote(target.runtime.dataDir)} ${shQuote(target.runtime.configDir)} + cp ${shQuote(target.runtime.caddyfilePath)} "$tmp/Caddyfile.previous" && cp ${shQuote(target.runtime.composePath)} "$tmp/docker-compose.previous.yaml" + install -m 0644 "$tmp/docker-compose.yaml" ${shQuote(`${target.runtime.composePath}.next`)} && mv ${shQuote(`${target.runtime.composePath}.next`)} ${shQuote(target.runtime.composePath)} + compose_rc=$? + if [ "$compose_rc" -eq 0 ]; then cat "$tmp/Caddyfile" >${shQuote(target.runtime.caddyfilePath)}; install_rc=$?; fi + if [ "$install_rc" -eq 0 ]; then docker exec ${shQuote(target.runtime.containerName)} caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>"$tmp/reload.err"; reload_rc=$?; fi + if [ "$reload_rc" -ne 0 ]; then + cat "$tmp/Caddyfile.previous" >${shQuote(target.runtime.caddyfilePath)} || rollback_rc=1 + install -m 0644 "$tmp/docker-compose.previous.yaml" ${shQuote(target.runtime.composePath)} || rollback_rc=1 + docker exec ${shQuote(target.runtime.containerName)} caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>>"$tmp/reload.err" || rollback_rc=1 + else + probe_rc=0 + for item in ${probeItems}; do host="${"${item%%|*}"}"; path="/${"${item#*|}"}"; path="/${"${path#/}"}"; curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || probe_rc=1; done + fi +fi +python3 - "$container_rc" "$listeners_rc" "$dns_rc" "$adapt_rc" "$validate_rc" "$install_rc" "$reload_rc" "$rollback_rc" "$probe_rc" <<'PY' +import json, sys +names = ["container", "listeners", "dns", "adapt", "validate", "install", "reload", "rollback", "probes"] +values = [int(value) for value in sys.argv[1:]] +steps = {name: {"exitCode": value} for name, value in zip(names, values)} +ok = all(value == 0 for value in values) +print(json.dumps({"ok": ok, "steps": steps, "valuesPrinted": False})) +raise SystemExit(0 if ok else 1) +PY +`; +} + +function statusScript(target: PublicEdgeTarget, sites: ResolvedSite[], desiredCaddyfile: string | null): string { + const probeItems = sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}|${site.expectedA}`)).join(" "); + const desiredFingerprint = desiredCaddyfile === null ? "" : sha256Fingerprint(desiredCaddyfile); + return `set -u +container_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true || container_rc=1 +listeners_rc=0; ss -ltn | grep -Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc=1; ss -ltn | grep -Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc=1 +validate_rc=0; docker exec ${shQuote(target.runtime.containerName)} caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>/dev/null || validate_rc=1 +sites_rc=0; dns_rc=0 +for item in ${probeItems}; do + host="${"${item%%|*}"}"; rest="${"${item#*|}"}"; path="/${"${rest%%|*}"}"; path="/${"${path#/}"}"; expected="${"${rest#*|}"}" + getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected" || dns_rc=1 + curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || sites_rc=1 +done +current_sha=""; if [ -f ${shQuote(target.runtime.caddyfilePath)} ]; then current_sha="sha256:$(sha256sum ${shQuote(target.runtime.caddyfilePath)} | awk '{print $1}')"; fi +python3 - "$container_rc" "$listeners_rc" "$validate_rc" "$dns_rc" "$sites_rc" "$current_sha" ${shQuote(desiredFingerprint)} <<'PY' +import json, sys +container, listeners, validate, dns, sites = [int(value) for value in sys.argv[1:6]] +current, desired = sys.argv[6:8] +ok = all(value == 0 for value in [container, listeners, validate, dns, sites]) +print(json.dumps({"ok": ok, "container": {"exitCode": container}, "listeners": {"exitCode": listeners}, "validate": {"exitCode": validate}, "dns": {"exitCode": dns}, "siteProbes": {"exitCode": sites}, "config": {"currentFingerprint": current or None, "desiredFingerprint": desired or None, "matchesDesired": bool(desired) and current == desired}, "valuesPrinted": False})) +raise SystemExit(0 if ok else 1) +PY +`; +} + +function targetSummary(target: PublicEdgeTarget): Record { + return { + id: target.id, + route: target.route, + publicAddress: target.publicAddress, + listeners: [target.listener.httpPort, target.listener.httpsPort], + containerName: target.runtime.containerName, + workDir: target.runtime.workDir, + caddyfilePath: target.runtime.caddyfilePath, + composePath: target.runtime.composePath, + dataDir: target.runtime.dataDir, + configDir: target.runtime.configDir, + }; +} + +function record(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} 必须是对象`); + return value as Record; +} + +function optionalRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function array(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${path} 必须是数组`); + return value; +} + +function string(value: unknown, path: string): string { + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} 必须是非空字符串`); + return value.trim(); +} + +function stringArray(value: unknown, path: string): string[] { + if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} 必须是非空字符串数组`); + return value.map((item, index) => string(item, `${path}[${index}]`)); +} + +function positiveInteger(value: unknown, path: string): number { + if (!Number.isInteger(value) || (value as number) < 1) throw new Error(`${path} 必须是正整数`); + return value as number; +} + +function port(value: unknown, path: string): number { + const result = positiveInteger(value, path); + if (result > 65_535) throw new Error(`${path} 必须小于等于 65535`); + return result; +} + +function ipv4(value: unknown, path: string): string { + const result = string(value, path); + const parts = result.split("."); + if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/u.test(part) || Number(part) > 255)) throw new Error(`${path} 必须是 IPv4 地址`); + return result; +} + +function hostnameValue(value: unknown, path: string): string { + const result = string(value, path).toLowerCase(); + if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(result)) throw new Error(`${path} 不是合法 hostname`); + return result; +} + +function upstreamValue(value: unknown, path: string): string { + const result = string(value, path); + if (!/^(?:[A-Za-z0-9.-]+|\[[0-9a-fA-F:]+\]):[1-9][0-9]{0,4}$/u.test(result)) throw new Error(`${path} 必须是 host:port`); + const parsedPort = Number(result.slice(result.lastIndexOf(":") + 1)); + if (parsedPort > 65_535) throw new Error(`${path} port 必须小于等于 65535`); + return result; +} + +function httpPath(value: unknown, path: string): string { + const result = string(value, path); + if (!result.startsWith("/")) throw new Error(`${path} 必须以 / 开头`); + return result; +} + +function absolutePath(value: unknown, path: string): string { + const result = string(value, path); + if (!result.startsWith("/")) throw new Error(`${path} 必须是绝对路径`); + return result; +} + +function repoYamlPath(value: unknown, path: string): string { + const result = string(value, path); + if (result.startsWith("/") || result.includes("..") || !result.endsWith(".yaml")) throw new Error(`${path} 必须是 repo-relative YAML 路径`); + return result; +} + +function inputError(message: string, code: string, argument?: string): CliInputError { + return new CliInputError(message, { code, argument, usage: "bun scripts/cli.ts platform-infra public-edge plan|apply|status --target NC01" }); +} diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index 23426058..8497c9cc 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -400,6 +400,7 @@ export function platformInfraHelp(): unknown { "langbot", "n8n", "selfmedia-voice", + "public-edge", "webterm", "wechat-archive", "observability", diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index fbe7cc5e..7fe0492b 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -38,6 +38,10 @@ export function sub2ApiHelpTargetSummary(): Record { export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [target, action] = args; + if (target === "public-edge") { + const { runPlatformInfraPublicEdgeCommand } = await import("../platform-infra-public-edge"); + return await runPlatformInfraPublicEdgeCommand(config, args.slice(1)); + } if (target === "sub2rank") { const { runSub2RankCommand } = await import("../platform-infra-sub2rank"); return await runSub2RankCommand(config, args.slice(1));