diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 9783a18a..8ef10a85 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -163,6 +163,10 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - installed/desired source commit; - authority、managed fingerprint、Compose fingerprint 与 site count; - legacy container 是否已退役; + - DNS 与 HTTP probe 的失败列表必须直接包含 site id、hostname、 + health path 或 expected A 与稳定错误码; + - public-edge 失败定位只执行一次 `status`, + 禁止再串联 plan、临时脚本或额外 `trans` probe; - 不披露 Secret、token 或 archive payload。 ## P0 边界 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index b2a1ef72..0862a817 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -76,6 +76,10 @@ - authority、managed Caddy fingerprint、Compose fingerprint 与 site count; - legacy container 是否已退役; - DNS、listener、Caddy validate 与逐站点 probe 摘要; + - DNS 或 HTTP probe 失败时直接列出 site id、hostname、 + health path 或 expected A 与稳定错误码; + - `status` 是失败站点定位的唯一首选入口, + 禁止再组合 plan、临时脚本和额外 `trans` probe; - 输出保持 `valuesPrinted=false`,不披露 Secret 或 archive payload。 - 受控入口: diff --git a/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md b/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md index 424e141f..a20f7f1b 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md +++ b/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md @@ -136,6 +136,12 @@ Web CaseRun 与 web-probe 验收默认从正式 public Web URL 进入,并通 公开入口应在 YAML、FRP、Caddy、DNS/TLS、public health 或 runtime 服务不一致时输出可定位的漂移摘要,使平台管理员能判断问题位于入口投递、目标服务、域名证书还是业务接口。 +逐站点 DNS 或 public health 失败摘要应满足: + +- 直接给出 YAML site 身份、hostname、health path 或 expected A 与稳定错误码。 +- 单次受控状态查询即可定位失败对象。 +- 不要求调用者组合其他配置读取或临时远端探针。 + 入口漂移处理只说明外部访问链路是否到达正确 runtime,不把 health 200、Caddy reachable 或 FRP connected 升级为 Web、登录、Agent、HWPOD 或 CaseRun 业务通过。 ## 7. 过程控制 diff --git a/scripts/src/platform-infra-public-edge.ts b/scripts/src/platform-infra-public-edge.ts index 5d977f94..5b44645d 100644 --- a/scripts/src/platform-infra-public-edge.ts +++ b/scripts/src/platform-infra-public-edge.ts @@ -563,7 +563,7 @@ async function status( config, target.route, ["sh"], - statusScript( + renderPublicEdgeStatusScript( target, resolution.sites, artifacts?.caddyfile ?? null, @@ -884,7 +884,7 @@ PY `; } -function statusScript( +export function renderPublicEdgeStatusScript( target: PublicEdgeTarget, sites: ResolvedSite[], desiredCaddyfile: string | null, @@ -892,28 +892,48 @@ function statusScript( desiredSourceCommit: string | null, authorityId: string, ): string { - const probeItems = sites.map((site) => shQuote(`${site.hostname}|${site.healthPath}|${site.expectedA}`)).join(" "); + const probeCommands = sites.map((site, index) => { + const failureFile = `${String(index).padStart(6, "0")}.json`; + const dnsFailure = Buffer.from(JSON.stringify({ + code: "public-edge-dns-mismatch", + siteId: site.id, + hostname: site.hostname, + expectedA: site.expectedA, + }), "utf8").toString("base64"); + const siteFailure = Buffer.from(JSON.stringify({ + code: "public-edge-site-probe-failed", + siteId: site.id, + hostname: site.hostname, + path: site.healthPath, + }), "utf8").toString("base64"); + return `( + if ! getent ahostsv4 ${shQuote(site.hostname)} | awk '{print $1}' | grep -Fxq ${shQuote(site.expectedA)}; then + printf '%s' ${shQuote(dnsFailure)} | base64 -d >"$dns_failures_dir/${failureFile}" + fi + if ! curl --noproxy '*' -fsS --connect-timeout 3 --max-time 10 --resolve ${shQuote(`${site.hostname}:${target.listener.httpsPort}:127.0.0.1`)} ${shQuote(`https://${site.hostname}${site.healthPath}`)} >/dev/null; then + printf '%s' ${shQuote(siteFailure)} | base64 -d >"$site_failures_dir/${failureFile}" + fi +) &`; + }).join("\n"); const desiredFingerprint = desiredCaddyfile === null ? "" : sha256Fingerprint(desiredCaddyfile); const desiredComposeFingerprint = desiredCompose === null ? "" : sha256Fingerprint(desiredCompose); return `set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +dns_failures_dir="$tmp/dns-failures" +site_failures_dir="$tmp/site-failures" +mkdir -p "$dns_failures_dir" "$site_failures_dir" container_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.containerName)} 2>/dev/null | grep -qx true || container_rc=1 legacy_rc=0; docker inspect -f '{{.State.Running}}' ${shQuote(target.runtime.legacy.containerName)} 2>/dev/null | grep -qx true || legacy_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 udp_rc=0 ${target.isolation.preservedUdpPorts.map((value) => `ss -lun | grep -Eq '[:.]${value}[[:space:]]' || udp_rc=1`).join("\n")} 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; probe_index=0 -for item in ${probeItems}; do - probe_index=$((probe_index + 1)) - ( - host="${"${item%%|*}"}"; rest="${"${item#*|}"}"; path="${"${rest%%|*}"}"; path="/${"${path#/}"}"; expected="${"${rest#*|}"}" - getent ahostsv4 "$host" | awk '{print $1}' | grep -Fxq "$expected" || : >"/tmp/public-edge-dns-$$-$probe_index.failed" - curl --noproxy '*' -fsS --connect-timeout 3 --max-time 10 --resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" >/dev/null || : >"/tmp/public-edge-site-$$-$probe_index.failed" - ) & -done +sites_rc=0; dns_rc=0 +${probeCommands} wait || true -for failure in /tmp/public-edge-dns-$$-*.failed; do [ -f "$failure" ] || continue; dns_rc=1; rm -f "$failure"; done -for failure in /tmp/public-edge-site-$$-*.failed; do [ -f "$failure" ] || continue; sites_rc=1; rm -f "$failure"; done +for failure in "$dns_failures_dir"/*.json; do [ -f "$failure" ] || continue; dns_rc=1; done +for failure in "$site_failures_dir"/*.json; do [ -f "$failure" ] || continue; sites_rc=1; done current_sha="" if [ -f ${shQuote(target.runtime.caddyfilePath)} ]; then current_sha="$(python3 - ${shQuote(target.runtime.caddyfilePath)} <<'PY' @@ -927,10 +947,14 @@ PY fi current_compose_sha="" if [ -f ${shQuote(target.runtime.composePath)} ]; then current_compose_sha="sha256:$(sha256sum ${shQuote(target.runtime.composePath)} | awk '{print $1}')"; fi -python3 - "$container_rc" "$legacy_rc" "$listeners_rc" "$udp_rc" "$validate_rc" "$dns_rc" "$sites_rc" "$current_sha" ${shQuote(desiredFingerprint)} "$current_compose_sha" ${shQuote(desiredComposeFingerprint)} ${shQuote(desiredSourceCommit ?? "")} ${shQuote(authorityId)} ${shQuote(target.runtime.statePath)} <<'PY' +python3 - "$container_rc" "$legacy_rc" "$listeners_rc" "$udp_rc" "$validate_rc" "$dns_rc" "$sites_rc" "$current_sha" ${shQuote(desiredFingerprint)} "$current_compose_sha" ${shQuote(desiredComposeFingerprint)} ${shQuote(desiredSourceCommit ?? "")} ${shQuote(authorityId)} ${shQuote(target.runtime.statePath)} "$dns_failures_dir" "$site_failures_dir" <<'PY' import json, pathlib, sys container, legacy, listeners, udp, validate, dns, sites = [int(value) for value in sys.argv[1:8]] -current, desired, current_compose, desired_compose, desired_commit, desired_authority, state_path = sys.argv[8:15] +current, desired, current_compose, desired_compose, desired_commit, desired_authority, state_path, dns_failures_path, site_failures_path = sys.argv[8:17] +def read_failures(directory): + return [json.loads(path.read_text(encoding="utf-8")) for path in sorted(pathlib.Path(directory).glob("*.json"))] +dns_failures = read_failures(dns_failures_path) +site_failures = read_failures(site_failures_path) state = None try: state = json.loads(pathlib.Path(state_path).read_text(encoding="utf-8")) @@ -949,7 +973,7 @@ state_matches_runtime = ( ) config_matches_desired = bool(desired) and current == desired and bool(desired_compose) and current_compose == desired_compose ok = all(value == 0 for value in [container, listeners, udp, validate, dns, sites]) and legacy != 0 and state_matches_runtime and config_matches_desired -print(json.dumps({"ok": ok, "container": {"exitCode": container}, "legacyContainer": {"exitCode": legacy, "retired": legacy != 0}, "tcpListeners": {"exitCode": listeners}, "preservedUdpListeners": {"exitCode": udp}, "validate": {"exitCode": validate}, "dns": {"exitCode": dns}, "siteProbes": {"exitCode": sites}, "provenance": {"installed": state, "desiredSourceCommit": desired_commit or None, "matchesRuntime": state_matches_runtime}, "config": {"currentManagedFingerprint": current or None, "desiredManagedFingerprint": desired or None, "currentComposeFingerprint": current_compose or None, "desiredComposeFingerprint": desired_compose or None, "matchesDesired": config_matches_desired}, "valuesPrinted": False})) +print(json.dumps({"ok": ok, "container": {"exitCode": container}, "legacyContainer": {"exitCode": legacy, "retired": legacy != 0}, "tcpListeners": {"exitCode": listeners}, "preservedUdpListeners": {"exitCode": udp}, "validate": {"exitCode": validate}, "dns": {"exitCode": dns, "failures": dns_failures}, "siteProbes": {"exitCode": sites, "failures": site_failures}, "provenance": {"installed": state, "desiredSourceCommit": desired_commit or None, "matchesRuntime": state_matches_runtime}, "config": {"currentManagedFingerprint": current or None, "desiredManagedFingerprint": desired or None, "currentComposeFingerprint": current_compose or None, "desiredComposeFingerprint": desired_compose or None, "matchesDesired": config_matches_desired}, "valuesPrinted": False})) raise SystemExit(0 if ok else 1) PY `;