fix(cicd): 披露 public-edge 失败站点

This commit is contained in:
pikastech
2026-07-19 04:36:14 +02:00
parent 34ce2fd228
commit 995ce30773
4 changed files with 55 additions and 17 deletions
+4
View File
@@ -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 边界
+4
View File
@@ -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。
- 受控入口:
@@ -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. 过程控制
+41 -17
View File
@@ -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
`;