From 0ef9129befb77f67283efaa8d7d8d579da0a375d Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 26 Jun 2026 10:32:43 +0000 Subject: [PATCH] feat: add yaml egress proxy benchmark --- config/hwlab-node-control-plane.yaml | 12 +- config/hwlab-node-lanes.yaml | 20 + .../platform-infra/egress-proxy-sources.yaml | 16 + config/platform-infra/sub2api.yaml | 15 +- scripts/src/egress-proxy-benchmark.ts | 375 ++++++++++++++++++ scripts/src/egress-proxy-sources.ts | 147 +++++++ scripts/src/help.ts | 9 +- scripts/src/hwlab-node-control-plane.ts | 288 +++++++++++++- scripts/src/platform-infra-egress-proxy.ts | 202 ++++++++++ scripts/src/platform-infra/actions.ts | 2 + scripts/src/platform-infra/config.ts | 40 +- scripts/src/platform-infra/entry.ts | 2 + scripts/src/platform-infra/manifest.ts | 2 + scripts/src/platform-infra/options.ts | 6 +- 14 files changed, 1090 insertions(+), 46 deletions(-) create mode 100644 config/platform-infra/egress-proxy-sources.yaml create mode 100644 scripts/src/egress-proxy-benchmark.ts create mode 100644 scripts/src/egress-proxy-sources.ts create mode 100644 scripts/src/platform-infra-egress-proxy.ts diff --git a/config/hwlab-node-control-plane.yaml b/config/hwlab-node-control-plane.yaml index e14fb397..cdf7c826 100644 --- a/config/hwlab-node-control-plane.yaml +++ b/config/hwlab-node-control-plane.yaml @@ -60,9 +60,7 @@ nodes: namespace: platform-infra serviceName: sub2api-egress-proxy port: 10808 - sourceRef: platform-infra/master-vpn-subscription.env - sourceKey: MASTER_VPN_SUBSCRIPTION_URL - sourceType: subscription-url + sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks noProxy: - localhost - 127.0.0.1 @@ -82,8 +80,6 @@ nodes: - 192.168.0.0/16 - 82.156.23.220 - 74.48.78.17 - - registry.npmmirror.com - - cdn.npmmirror.com - hyueapi.com - .hyueapi.com @@ -116,8 +112,8 @@ targets: readUrl: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/HWLAB.git egressProxy: - mode: direct - required: false + mode: node-global + required: true githubTransport: mode: ssh tekton: @@ -146,7 +142,7 @@ targets: - ENV PATH=/usr/local/go/bin:$PATH - RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx - ENV HWLAB_CI_NODE_DEPS=/opt/hwlab-ci-node-deps/node_modules - - RUN set -eu; export HTTP_PROXY="${HTTP_PROXY:-${http_proxy:-}}"; export HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-$HTTP_PROXY}}"; export ALL_PROXY="${ALL_PROXY:-${all_proxy:-}}"; export NO_PROXY="${NO_PROXY:-${no_proxy:-}}"; export http_proxy="$HTTP_PROXY"; export https_proxy="$HTTPS_PROXY"; export all_proxy="$ALL_PROXY"; export no_proxy="$NO_PROXY"; export npm_config_registry="https://registry.npmmirror.com/"; export BUN_CONFIG_REGISTRY="https://registry.npmmirror.com/"; export NO_PROXY="${NO_PROXY:+$NO_PROXY,}registry.npmmirror.com,cdn.npmmirror.com"; export no_proxy="$NO_PROXY"; export npm_config_noproxy="$NO_PROXY"; export npm_config_proxy=""; export npm_config_https_proxy=""; export HTTP_PROXY=""; export HTTPS_PROXY=""; export ALL_PROXY=""; export http_proxy=""; export https_proxy=""; export all_proxy=""; export npm_config_fetch_retries=2; export npm_config_fetch_retry_mintimeout=2000; export npm_config_fetch_retry_maxtimeout=16000; export npm_config_fetch_timeout=120000; mkdir -p /opt/hwlab-ci-node-deps; cd /opt/hwlab-ci-node-deps; printf '{"private":true,"dependencies":{}}\n' > package.json; ok=0; delay=2; for attempt in 1 2 3 4 5; do echo "{\"event\":\"tools-yaml-node-npm-install\",\"attempt\":\"$attempt/5\",\"registry\":\"$npm_config_registry\",\"proxy\":\"direct\"}" >&2; if timeout 180s npm install --package-lock=false --no-save --ignore-scripts --no-audit --no-fund --omit=dev yaml@2.8.3; then ok=1; break; fi; if [ "$attempt" = 5 ]; then break; fi; echo "{\"event\":\"tools-yaml-node-npm-install\",\"status\":\"retrying\",\"attempt\":\"$attempt/5\",\"sleepSeconds\":$delay}" >&2; sleep "$delay"; delay=$((delay * 2)); done; test "$ok" = 1; node --input-type=module -e 'import("/opt/hwlab-ci-node-deps/node_modules/yaml/browser/dist/index.js").then((yaml)=>console.log("yaml-ok", typeof yaml.parse))' + - RUN set -eu; export HTTP_PROXY="${HTTP_PROXY:-${http_proxy:-}}"; export HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-$HTTP_PROXY}}"; export ALL_PROXY="${ALL_PROXY:-${all_proxy:-}}"; export NO_PROXY="${NO_PROXY:-${no_proxy:-}}"; export http_proxy="$HTTP_PROXY"; export https_proxy="$HTTPS_PROXY"; export all_proxy="$ALL_PROXY"; export no_proxy="$NO_PROXY"; export npm_config_registry="https://registry.npmmirror.com/"; export BUN_CONFIG_REGISTRY="https://registry.npmmirror.com/"; export npm_config_noproxy="$NO_PROXY"; if [ -n "$HTTP_PROXY" ]; then export npm_config_proxy="$HTTP_PROXY"; fi; if [ -n "$HTTPS_PROXY" ]; then export npm_config_https_proxy="$HTTPS_PROXY"; fi; export npm_config_fetch_retries=2; export npm_config_fetch_retry_mintimeout=2000; export npm_config_fetch_retry_maxtimeout=16000; export npm_config_fetch_timeout=120000; proxy_label="${HTTP_PROXY:+HTTP_PROXY}"; proxy_label="${proxy_label:-none}"; mkdir -p /opt/hwlab-ci-node-deps; cd /opt/hwlab-ci-node-deps; printf '{"private":true,"dependencies":{}}\n' > package.json; ok=0; delay=2; for attempt in 1 2 3 4 5; do echo "{\"event\":\"tools-yaml-node-npm-install\",\"attempt\":\"$attempt/5\",\"registry\":\"$npm_config_registry\",\"proxy\":\"$proxy_label\"}" >&2; if timeout 180s npm install --package-lock=false --no-save --ignore-scripts --no-audit --no-fund --omit=dev yaml@2.8.3; then ok=1; break; fi; if [ "$attempt" = 5 ]; then break; fi; echo "{\"event\":\"tools-yaml-node-npm-install\",\"status\":\"retrying\",\"attempt\":\"$attempt/5\",\"sleepSeconds\":$delay}" >&2; sleep "$delay"; delay=$((delay * 2)); done; test "$ok" = 1; node --input-type=module -e 'import("/opt/hwlab-ci-node-deps/node_modules/yaml/browser/dist/index.js").then((yaml)=>console.log("yaml-ok", typeof yaml.parse))' - RUN node --version && npm --version && bun --version && git --version && python3 --version && docker --version && ssh -V && go version buildArgs: {} buildNetwork: host diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 3c711b7f..0bd21366 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -624,3 +624,23 @@ downloadProfiles: retries: 3 connectTimeoutSeconds: 10 maxTimeSeconds: 120 + d601-node-no-mirror-benchmark: + git: + proxyMode: inherit + retries: 3 + timeoutSeconds: 120 + npm: + registry: https://registry.npmjs.org/ + retries: 3 + fetchTimeoutSeconds: 120 + pip: + indexUrl: https://pypi.org/simple + retries: 3 + timeoutSeconds: 120 + docker: + registryMirrors: [] + pullRetries: 3 + curl: + retries: 3 + connectTimeoutSeconds: 10 + maxTimeSeconds: 120 diff --git a/config/platform-infra/egress-proxy-sources.yaml b/config/platform-infra/egress-proxy-sources.yaml new file mode 100644 index 00000000..3c973df7 --- /dev/null +++ b/config/platform-infra/egress-proxy-sources.yaml @@ -0,0 +1,16 @@ +version: 1 +kind: platform-infra-egress-proxy-sources +metadata: + owner: unidesk + relatedIssues: + - 1004 +sources: + master-shadowsocks: + sourceType: master-shadowsocks + sourceRef: platform-infra/sub2api-master-egress-proxy.env + sourceKey: SUB2API_MASTER_SHADOWSOCKS_PASSWORD + masterShadowsocks: + serverHost: 74.48.78.17 + serverPort: 18792 + method: chacha20-ietf-poly1305 + healthProbeUrl: https://www.gstatic.com/generate_204 diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index 1f462c68..565be4f6 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -150,16 +150,9 @@ targets: image: 127.0.0.1:5000/platform-infra/sing-box:latest imagePullPolicy: IfNotPresent listenPort: 10808 - sourceRef: platform-infra/sub2api-master-egress-proxy.env - sourceKey: SUB2API_MASTER_SHADOWSOCKS_PASSWORD - sourceType: master-shadowsocks - masterShadowsocks: - serverHost: 74.48.78.17 - serverPort: 18792 - method: chacha20-ietf-poly1305 + sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks applyToSub2Api: true applyToSentinel: true - healthProbeUrl: https://www.gstatic.com/generate_204 noProxy: - localhost - 127.0.0.1 @@ -252,13 +245,9 @@ targets: image: ghcr.io/sagernet/sing-box:latest imagePullPolicy: IfNotPresent listenPort: 10808 - sourceRef: platform-infra/master-vpn-subscription.env - sourceKey: MASTER_VPN_SUBSCRIPTION_URL - sourceType: subscription-url - preferredOutbound: hysteria2 + sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks applyToSub2Api: true applyToSentinel: true - healthProbeUrl: https://www.gstatic.com/generate_204 noProxy: - localhost - 127.0.0.1 diff --git a/scripts/src/egress-proxy-benchmark.ts b/scripts/src/egress-proxy-benchmark.ts new file mode 100644 index 00000000..457cecd6 --- /dev/null +++ b/scripts/src/egress-proxy-benchmark.ts @@ -0,0 +1,375 @@ +import type { CommandResult } from "./command"; + +export interface EgressBenchmarkSpec { + scope: "platform-infra" | "hwlab-control-plane"; + targetId: string; + route: string; + namespace: string; + serviceName: string; + port: number; + noProxy: readonly string[]; + sourceType: string; + sourceRef: string; + sourceConfigRef: string | null; + sourceFingerprint: string | null; + profile: "no-mirror"; + samples: number; + sampleTimeoutSeconds: number; +} + +export function egressBenchmarkStateDir(spec: EgressBenchmarkSpec): string { + return `/tmp/unidesk-egress-benchmark/${spec.scope}/${safeSegment(spec.targetId)}/${spec.profile}`; +} + +export function egressBenchmarkStartScript(spec: EgressBenchmarkSpec): string { + const stateDir = egressBenchmarkStateDir(spec); + const noProxy = unique(["localhost", "127.0.0.1", "::1", ".svc", ".svc.cluster.local", ".cluster.local", ...spec.noProxy]).join(","); + return ` +set -eu +state_dir=${shQuote(stateDir)} +mkdir -p "$state_dir" +if [ -s "$state_dir/pid" ] && kill -0 "$(cat "$state_dir/pid")" >/dev/null 2>&1; then + printf '{"started":false,"reason":"job-already-running","pid":%s,"stateDir":"%s"}\\n' "$(cat "$state_dir/pid")" "$state_dir" + exit 0 +fi +cat >"$state_dir/job.sh" <<'JOB' +#!/bin/sh +set -eu +state_dir=${shQuote(stateDir)} +status="$state_dir/status.json" +log="$state_dir/job.log" +samples=${shQuote(String(spec.samples))} +sample_timeout=${shQuote(String(spec.sampleTimeoutSeconds))} +target_id=${shQuote(spec.targetId)} + target_safe=${shQuote(safeSegment(spec.targetId).toLowerCase())} +scope=${shQuote(spec.scope)} +profile=${shQuote(spec.profile)} +namespace=${shQuote(spec.namespace)} +service_name=${shQuote(spec.serviceName)} + service_port=${shQuote(String(spec.port))} + source_type=${shQuote(spec.sourceType)} + source_ref=${shQuote(spec.sourceRef)} + source_config_ref=${shQuote(spec.sourceConfigRef ?? "")} + source_fingerprint=${shQuote(spec.sourceFingerprint ?? "")} + docker_image='quay.io/prometheus/busybox:latest' + no_proxy=${shQuote(noProxy)} +write_state() { + state="$1"; shift + message="$1"; shift || true + python3 - "$status" "$state" "$message" "$target_id" "$scope" "$profile" <<'PY' +import json, pathlib, sys, time +path=pathlib.Path(sys.argv[1]) +payload={"ok": sys.argv[2] == "succeeded", "state":sys.argv[2], "message":sys.argv[3], "target":sys.argv[4], "scope":sys.argv[5], "profile":sys.argv[6], "updatedAt":time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} +if path.exists(): + try: + old=json.loads(path.read_text()) + if isinstance(old, dict): + old.update(payload) + payload=old + except Exception: + pass +path.write_text(json.dumps(payload, ensure_ascii=False) + "\\n") +PY +} +classify_family() { + rc="$1" + text="$2" + if [ "$rc" = "0" ]; then printf success; return; fi + printf '%s' "$text" | grep -Eiq 'toomanyrequests|too many requests|rate limit' && { printf rate-limit; return; } + printf '%s' "$text" | grep -Eiq 'timed out|timeout|i/o timeout|TLS handshake timeout' && { printf timeout; return; } + printf '%s' "$text" | grep -Eiq 'Could not resolve|no such host|ENOTFOUND|getaddrinfo|Temporary failure in name resolution' && { printf dns; return; } + printf '%s' "$text" | grep -Eiq '407|proxy|CONNECT|connection reset|ECONNRESET|ECONNREFUSED' && { printf proxy-or-connect; return; } + printf exit-"$rc" +} +sample_json() { + test_name="$1" + sample="$2" + cache_state="$3" + started_ms="$4" + rc="$5" + family="$6" + elapsed_ms="$7" + python3 - "$state_dir/results.ndjson" "$test_name" "$sample" "$cache_state" "$started_ms" "$rc" "$family" "$elapsed_ms" "$source_type" "$source_ref" "$source_config_ref" "$source_fingerprint" <<'PY' +import json, pathlib, sys +path=pathlib.Path(sys.argv[1]) +payload={ + "test": sys.argv[2], + "sample": int(sys.argv[3]), + "proxy": "on", + "cacheState": sys.argv[4], + "startedMs": int(sys.argv[5]), + "exitCode": int(sys.argv[6]), + "failureFamily": sys.argv[7], + "elapsedMs": int(sys.argv[8]), + "sourceType": sys.argv[9], + "sourceRef": sys.argv[10], + "sourceConfigRef": sys.argv[11] or None, + "sourceFingerprint": sys.argv[12] or None, + "valuesPrinted": False, +} +with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, ensure_ascii=False) + "\\n") +PY +} +run_sample() { + test_name="$1" + sample="$2" + cache_state="$3" + shift 3 + out="$state_dir/\${test_name}-\${sample}.out" + err="$state_dir/\${test_name}-\${sample}.err" + started_ms=$(date +%s%3N 2>/dev/null || python3 - <<'PY' +import time +print(int(time.time()*1000)) +PY +) + set +e + env HTTP_PROXY="$proxy_url" HTTPS_PROXY="$proxy_url" ALL_PROXY="$proxy_url" http_proxy="$proxy_url" https_proxy="$proxy_url" all_proxy="$proxy_url" NO_PROXY="$no_proxy" no_proxy="$no_proxy" npm_config_proxy="$proxy_url" npm_config_https_proxy="$proxy_url" npm_config_noproxy="$no_proxy" "$@" >"$out" 2>"$err" + rc=$? + set -e + finished_ms=$(date +%s%3N 2>/dev/null || python3 - <<'PY' +import time +print(int(time.time()*1000)) +PY +) + elapsed_ms=$((finished_ms - started_ms)) + family=$(classify_family "$rc" "$(cat "$err" "$out" 2>/dev/null | tail -40)") + sample_json "$test_name" "$sample" "$cache_state" "$started_ms" "$rc" "$family" "$elapsed_ms" +} +summarize() { + python3 - "$state_dir/results.ndjson" "$status" "$target_id" "$scope" "$profile" "$proxy_url" "$source_type" "$source_ref" "$source_config_ref" "$source_fingerprint" <<'PY' +import json, pathlib, statistics, sys, time +results_path=pathlib.Path(sys.argv[1]) +status_path=pathlib.Path(sys.argv[2]) +items=[] +if results_path.exists(): + items=[json.loads(line) for line in results_path.read_text().splitlines() if line.strip()] +by_test={} +for item in items: + by_test.setdefault(item["test"], []).append(item) +rows=[] +for name in sorted(by_test): + values=by_test[name] + elapsed=sorted(int(item["elapsedMs"]) for item in values) + failures={} + for item in values: + if item["failureFamily"] != "success": + failures[item["failureFamily"]] = failures.get(item["failureFamily"], 0) + 1 + def percentile(p): + if not elapsed: + return None + idx=min(len(elapsed)-1, max(0, round((len(elapsed)-1)*p))) + return elapsed[idx] + rows.append({ + "test": name, + "samples": len(values), + "success": sum(1 for item in values if item["exitCode"] == 0), + "successRate": (sum(1 for item in values if item["exitCode"] == 0) / len(values)) if values else 0, + "p50Ms": percentile(0.50), + "p95Ms": percentile(0.95), + "maxMs": max(elapsed) if elapsed else None, + "failureFamilies": failures, + }) +ok=bool(rows) and all(row["success"] == row["samples"] for row in rows) +payload={ + "ok": ok, + "state": "succeeded" if ok else "failed", + "message": "benchmark-complete" if ok else "benchmark-failed", + "target": sys.argv[3], + "scope": sys.argv[4], + "profile": sys.argv[5], + "proxy": {"mode": "on", "urlRedacted": sys.argv[6].replace("://", "://").split("@")[-1], "valuesPrinted": False}, + "source": {"sourceType": sys.argv[7], "sourceRef": sys.argv[8], "sourceConfigRef": sys.argv[9] or None, "sourceFingerprint": sys.argv[10] or None, "valuesPrinted": False}, + "rows": rows, + "resultCount": len(items), + "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), +} +status_path.write_text(json.dumps(payload, ensure_ascii=False) + "\\n") +PY +} +run_job() { + write_state running resolving-proxy + rm -f "$state_dir/results.ndjson" + proxy_ip="$(kubectl -n "$namespace" get svc "$service_name" -o 'jsonpath={.spec.clusterIP}' 2>/dev/null || true)" + if [ -z "$proxy_ip" ]; then echo "egress proxy service missing: $namespace/$service_name" >&2; write_state failed proxy-service-missing; exit 41; fi + proxy_url="http://$proxy_ip:$service_port" + echo "benchmark target=$target_id scope=$scope profile=$profile proxy=$proxy_url sourceType=$source_type sourceRef=$source_ref sourceFingerprint=$source_fingerprint valuesPrinted=false" + for sample in $(seq 1 "$samples"); do + write_state running docker-manifest + run_sample docker-manifest "$sample" none timeout "$sample_timeout" docker manifest inspect "$docker_image" + write_state running docker-pull + docker image rm -f "$docker_image" >/dev/null 2>&1 || true + cache_state=absent + docker image inspect "$docker_image" >/dev/null 2>&1 && cache_state=present || true + run_sample docker-pull "$sample" "$cache_state" timeout "$sample_timeout" docker pull "$docker_image" + write_state running docker-build + build_dir="$state_dir/build-$sample" + rm -rf "$build_dir" + mkdir -p "$build_dir" + printf 'FROM quay.io/prometheus/busybox:latest\\nRUN printf no-mirror-benchmark >/benchmark.txt\\n' >"$build_dir/Dockerfile" + run_sample docker-build "$sample" no-cache timeout "$sample_timeout" docker build --pull --no-cache --network host --build-arg HTTP_PROXY="$proxy_url" --build-arg HTTPS_PROXY="$proxy_url" --build-arg ALL_PROXY="$proxy_url" --build-arg NO_PROXY="$no_proxy" -t "unidesk-egress-benchmark-$target_safe:$sample" "$build_dir" + write_state running npm-install + npm_dir="$state_dir/npm-$sample" + rm -rf "$npm_dir" + mkdir -p "$npm_dir/cache" "$npm_dir/pkg" + printf '{"private":true,"dependencies":{}}\\n' >"$npm_dir/pkg/package.json" + run_sample npm-install "$sample" temp-cache timeout "$sample_timeout" npm --prefix "$npm_dir/pkg" install --package-lock=false --no-save --ignore-scripts --no-audit --no-fund --omit=dev --registry=https://registry.npmjs.org/ --cache "$npm_dir/cache" --fetch-timeout 120000 --fetch-retries 2 yaml@2.8.3 + done + summarize +} +run_job >>"$log" 2>&1 || { + rc=$? + summarize || true + exit "$rc" +} +JOB +chmod +x "$state_dir/job.sh" +: >"$state_dir/job.log" +nohup "$state_dir/job.sh" >/dev/null 2>&1 & +pid=$! +printf '%s' "$pid" >"$state_dir/pid" +printf '{"started":true,"pid":%s,"stateDir":"%s","statusCommand":"%s","logsCommand":"%s"}\\n' "$pid" "$state_dir" ${shQuote(statusCommand(spec))} ${shQuote(logsCommand(spec))} +`; +} + +export function egressBenchmarkStatusScript(spec: EgressBenchmarkSpec, tailLines: number): string { + const stateDir = egressBenchmarkStateDir(spec); + return ` +set +e +state_dir=${shQuote(stateDir)} +status_file="$state_dir/status.json" +log_file="$state_dir/job.log" +pid_file="$state_dir/pid" +running=false +pid=null +if [ -s "$pid_file" ]; then + pid_raw="$(cat "$pid_file" 2>/dev/null || true)" + if [ -n "$pid_raw" ] && kill -0 "$pid_raw" >/dev/null 2>&1; then running=true; pid="$pid_raw"; else pid="$pid_raw"; fi +fi +python3 - "$state_dir" "$status_file" "$log_file" "$running" "$pid" ${shQuote(String(tailLines))} <<'PY' +import json, pathlib, sys +state_dir=pathlib.Path(sys.argv[1]) +status_path=pathlib.Path(sys.argv[2]) +log_path=pathlib.Path(sys.argv[3]) +running=sys.argv[4] == "true" +pid=None if sys.argv[5] in ("", "null") else sys.argv[5] +tail_lines=int(sys.argv[6]) +status=None +if status_path.exists(): + try: + status=json.loads(status_path.read_text()) + except Exception as error: + status={"parseError": str(error), "raw": status_path.read_text(errors="replace")[-1000:]} +if isinstance(status, dict) and not running and status.get("state") == "running": + status["state"]="stopped" + status["ok"]=False + status["message"]="job-not-running" +log_tail="" +if log_path.exists(): + lines=log_path.read_text(errors="replace").splitlines() + log_tail="\\n".join(lines[-tail_lines:]) +results_path=state_dir/"results.ndjson" +items=[] +if results_path.exists(): + for line in results_path.read_text(errors="replace").splitlines(): + if not line.strip(): + continue + try: + items.append(json.loads(line)) + except Exception: + pass +if items: + by_test={} + for item in items: + by_test.setdefault(item.get("test", "unknown"), []).append(item) + rows=[] + for name in sorted(by_test): + values=by_test[name] + elapsed=sorted(int(item.get("elapsedMs", 0)) for item in values) + failures={} + for item in values: + family=str(item.get("failureFamily", "unknown")) + if family.startswith("exit-"): + err_path=state_dir/(str(item.get("test", "unknown")) + "-" + str(item.get("sample", "")) + ".err") + out_path=state_dir/(str(item.get("test", "unknown")) + "-" + str(item.get("sample", "")) + ".out") + text="" + for path in (err_path, out_path): + if path.exists(): + text += path.read_text(errors="replace")[-2000:] + lowered=text.lower() + if "toomanyrequests" in lowered or "too many requests" in lowered or "rate limit" in lowered: + family="rate-limit" + if family != "success": + failures[family] = failures.get(family, 0) + 1 + def percentile(p): + if not elapsed: + return None + idx=min(len(elapsed)-1, max(0, round((len(elapsed)-1)*p))) + return elapsed[idx] + rows.append({"test": name, "samples": len(values), "success": sum(1 for item in values if item.get("exitCode") == 0), "successRate": (sum(1 for item in values if item.get("exitCode") == 0) / len(values)) if values else 0, "p50Ms": percentile(0.50), "p95Ms": percentile(0.95), "maxMs": max(elapsed) if elapsed else None, "failureFamilies": failures}) + if status is None: + status={"state": "running" if running else "unknown", "ok": False} + status["rows"]=rows + status["resultCount"]=len(items) +print(json.dumps({"stateDir": str(state_dir), "pid": pid, "running": running, "status": status, "logBytes": log_path.stat().st_size if log_path.exists() else 0, "logTail": log_tail}, ensure_ascii=False)) +PY +`; +} + +export function egressBenchmarkDryRun(spec: EgressBenchmarkSpec): Record { + return { + profile: spec.profile, + target: spec.targetId, + route: spec.route, + namespace: spec.namespace, + serviceName: spec.serviceName, + servicePort: spec.port, + samples: spec.samples, + sampleTimeoutSeconds: spec.sampleTimeoutSeconds, + tests: ["docker-manifest", "docker-pull", "docker-build", "npm-install"], + dockerImage: "quay.io/prometheus/busybox:latest", + forbiddenMirrors: ["DaoCloud mirror", "registry.npmmirror.com", "cnpm", "pnpm mirror", "preheated cache as success"], + source: { + sourceType: spec.sourceType, + sourceRef: spec.sourceRef, + sourceConfigRef: spec.sourceConfigRef, + sourceFingerprint: spec.sourceFingerprint, + valuesPrinted: false, + }, + stateDir: egressBenchmarkStateDir(spec), + }; +} + +export function egressBenchmarkCompactResult(result: CommandResult): Record { + return { + exitCode: result.exitCode, + stdoutBytes: Buffer.byteLength(result.stdout, "utf8"), + stderrBytes: Buffer.byteLength(result.stderr, "utf8"), + stdoutTail: result.stdout.slice(-2000), + stderrTail: result.stderr.slice(-2000), + }; +} + +export function statusCommand(spec: EgressBenchmarkSpec): string { + if (spec.scope === "platform-infra") return `bun scripts/cli.ts platform-infra egress-proxy benchmark-status --target ${spec.targetId} --profile ${spec.profile}`; + const [node, lane] = spec.targetId.split("/", 2); + return `bun scripts/cli.ts hwlab nodes control-plane infra egress-benchmark status --node ${node} --lane ${lane} --profile ${spec.profile}`; +} + +export function logsCommand(spec: EgressBenchmarkSpec): string { + if (spec.scope === "platform-infra") return `bun scripts/cli.ts platform-infra egress-proxy benchmark-logs --target ${spec.targetId} --profile ${spec.profile}`; + const [node, lane] = spec.targetId.split("/", 2); + return `bun scripts/cli.ts hwlab nodes control-plane infra egress-benchmark logs --node ${node} --lane ${lane} --profile ${spec.profile}`; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.filter((value) => value.length > 0))]; +} + +function safeSegment(value: string): string { + return value.replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "") || "target"; +} + +function shQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} diff --git a/scripts/src/egress-proxy-sources.ts b/scripts/src/egress-proxy-sources.ts new file mode 100644 index 00000000..60d1301b --- /dev/null +++ b/scripts/src/egress-proxy-sources.ts @@ -0,0 +1,147 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { rootPath } from "./config"; + +export type EgressProxySourceType = "subscription-url" | "master-shadowsocks"; +export type EgressProxyPreferredOutbound = "vless-reality" | "hysteria2"; + +export interface MasterShadowsocksSourceSpec { + serverHost: string; + serverPort: number; + method: string; +} + +export interface SharedEgressProxySourceSpec { + id: string; + configRef: string; + sourceType: EgressProxySourceType; + sourceRef: string; + sourceKey: string; + preferredOutbound: EgressProxyPreferredOutbound | null; + masterShadowsocks: MasterShadowsocksSourceSpec | null; + healthProbeUrl: string; + fingerprint: string; + valuesPrinted: false; +} + +export function resolveEgressProxySourceRef(ref: string, label: string): SharedEgressProxySourceSpec { + const { file, path } = parseConfigRef(ref, label); + if (!path.startsWith("sources.")) throw new Error(`${label} must reference sources.`); + const sourceId = path.slice("sources.".length); + if (!/^[A-Za-z0-9._-]+$/u.test(sourceId)) throw new Error(`${label} has an unsupported source id`); + const parsed = Bun.YAML.parse(readFileSync(rootPath(file), "utf8")) as unknown; + const root = record(parsed, file); + if (root.kind !== "platform-infra-egress-proxy-sources") throw new Error(`${file}.kind must be platform-infra-egress-proxy-sources`); + const sources = record(root.sources, `${file}.sources`); + const raw = record(sources[sourceId], `${file}#sources.${sourceId}`); + return parseSharedEgressProxySource(sourceId, ref, raw, `${file}#sources.${sourceId}`); +} + +function parseSharedEgressProxySource(id: string, configRef: string, raw: Record, label: string): SharedEgressProxySourceSpec { + const sourceType = enumField(raw, "sourceType", label, ["subscription-url", "master-shadowsocks"] as const); + const preferredOutbound = raw.preferredOutbound === undefined + ? null + : enumField(raw, "preferredOutbound", label, ["vless-reality", "hysteria2"] as const); + const masterShadowsocks = raw.masterShadowsocks === undefined + ? null + : masterShadowsocksSpec(record(raw.masterShadowsocks, `${label}.masterShadowsocks`), `${label}.masterShadowsocks`); + const spec: SharedEgressProxySourceSpec = { + id, + configRef, + sourceType, + sourceRef: sourceRefField(raw, "sourceRef", label), + sourceKey: envKeyField(raw, "sourceKey", label), + preferredOutbound, + masterShadowsocks, + healthProbeUrl: urlField(raw, "healthProbeUrl", label), + fingerprint: "", + valuesPrinted: false, + }; + validateSharedEgressProxySource(spec, label); + return { + ...spec, + fingerprint: `sha256:${createHash("sha256").update(JSON.stringify({ + sourceType: spec.sourceType, + sourceRef: spec.sourceRef, + sourceKey: spec.sourceKey, + preferredOutbound: spec.preferredOutbound, + masterShadowsocks: spec.masterShadowsocks, + healthProbeUrl: spec.healthProbeUrl, + })).digest("hex").slice(0, 16)}`, + }; +} + +function validateSharedEgressProxySource(spec: SharedEgressProxySourceSpec, label: string): void { + if (spec.sourceType === "subscription-url" && spec.preferredOutbound === null) { + throw new Error(`${label}.preferredOutbound is required for sourceType=subscription-url`); + } + if (spec.sourceType === "master-shadowsocks") { + if (spec.masterShadowsocks === null) throw new Error(`${label}.masterShadowsocks is required for sourceType=master-shadowsocks`); + if (spec.preferredOutbound !== null) throw new Error(`${label}.preferredOutbound must be omitted for sourceType=master-shadowsocks`); + } +} + +function masterShadowsocksSpec(raw: Record, label: string): MasterShadowsocksSourceSpec { + const serverHost = stringField(raw, "serverHost", label); + if (!/^[A-Za-z0-9._:-]+$/u.test(serverHost)) throw new Error(`${label}.serverHost has an unsupported format`); + const serverPort = integerField(raw, "serverPort", label); + if (serverPort < 1 || serverPort > 65535) throw new Error(`${label}.serverPort must be a TCP port`); + const method = stringField(raw, "method", label); + if (!/^[A-Za-z0-9-]+$/u.test(method)) throw new Error(`${label}.method has an unsupported format`); + return { serverHost, serverPort, method }; +} + +function parseConfigRef(ref: string, label: string): { file: string; path: string } { + const [file, path, extra] = ref.split("#"); + if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) { + throw new Error(`${label} must use path/to/file.yaml#sources. syntax`); + } + if (file.startsWith("/") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) { + throw new Error(`${label} must reference a repo-relative config/*.yaml file without ..`); + } + if (!/^[A-Za-z0-9_.-]+$/u.test(path)) throw new Error(`${label} has an unsupported YAML path`); + return { file, path }; +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`); + return value as Record; +} + +function stringField(obj: Record, key: string, label: string): string { + const value = obj[key]; + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${label}.${key} must be a non-empty string`); + return value.trim(); +} + +function integerField(obj: Record, key: string, label: string): number { + const value = obj[key]; + if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${label}.${key} must be an integer`); + return value; +} + +function enumField(obj: Record, key: string, label: string, values: T): T[number] { + const value = stringField(obj, key, label); + if (!(values as readonly string[]).includes(value)) throw new Error(`${label}.${key} must be one of ${values.join(", ")}`); + return value as T[number]; +} + +function sourceRefField(obj: Record, key: string, label: string): string { + const value = stringField(obj, key, label); + if (value.startsWith("/") || value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${label}.${key} has an unsupported sourceRef format`); + return value; +} + +function envKeyField(obj: Record, key: string, label: string): string { + const value = stringField(obj, key, label); + if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${label}.${key} must be an env key`); + return value; +} + +function urlField(obj: Record, key: string, label: string): string { + const value = stringField(obj, key, label); + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${label}.${key} must use http:// or https://`); + if (url.username || url.password || url.search || url.hash) throw new Error(`${label}.${key} must not include credentials, query, or hash`); + return value; +} diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 8614eee8..a1d54456 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -644,7 +644,7 @@ function agentRunHelpSummary(): unknown { function platformInfraHelpSummary(): unknown { return { - command: "platform-infra sub2api|langbot|n8n|wechat-archive ...", + command: "platform-infra sub2api|egress-proxy|langbot|n8n|wechat-archive ...", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan", @@ -654,6 +654,8 @@ function platformInfraHelpSummary(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-report", + "bun scripts/cli.ts platform-infra egress-proxy benchmark --target D601 --profile no-mirror --confirm", + "bun scripts/cli.ts platform-infra egress-proxy benchmark-status --target D601 --profile no-mirror", "bun scripts/cli.ts platform-infra langbot plan", "bun scripts/cli.ts platform-infra langbot apply --confirm", "bun scripts/cli.ts platform-infra langbot status", @@ -669,7 +671,7 @@ function platformInfraHelpSummary(): unknown { "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full", "bun scripts/cli.ts platform-infra wechat-archive collector-status --full", ], - description: "Operate G14 platform-infra services such as Sub2API, LangBot, n8n, WeChat archive workflows, and the YAML-controlled Codex pool.", + description: "Operate G14 platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, WeChat archive workflows, and the YAML-controlled Codex pool.", }; } @@ -682,6 +684,7 @@ function hwlabNodeHelpSummary(): unknown { "bun scripts/cli.ts hwlab nodes control-plane infra status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane infra tools-image status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes control-plane infra argo status --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes control-plane infra egress-benchmark --node D601 --lane v03 --profile no-mirror --confirm", "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03", "bun scripts/cli.ts hwlab nodes git-mirror status --node G14 --lane v03", "bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node D601 --lane v03 --dry-run", @@ -690,7 +693,7 @@ function hwlabNodeHelpSummary(): unknown { "bun scripts/cli.ts hwlab nodes test-accounts status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes test-accounts sync --node D601 --lane v03 --confirm", ], - description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD, git-mirror, public Dockerfile tools image, and declarative Argo CD prerequisites for D601 v03 while keeping cross-node work semi-automatic; hwpod-preinstall renders D601/v03 71-FREQ HWPOD/MDTODO/gateway configRefs without runtime mutation; observability reads runtime metrics and authenticated Web Performance summaries; test-accounts prepares UniDesk YAML-declared admin/test account API keys with redacted sourceRef/fingerprint output. Web probe commands moved to top-level `bun scripts/cli.ts web-probe`.", + description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD, git-mirror, public Dockerfile tools image, no-mirror egress benchmarks, and declarative Argo CD prerequisites for D601 v03 while keeping cross-node work semi-automatic; hwpod-preinstall renders D601/v03 71-FREQ HWPOD/MDTODO/gateway configRefs without runtime mutation; observability reads runtime metrics and authenticated Web Performance summaries; test-accounts prepares UniDesk YAML-declared admin/test account API keys with redacted sourceRef/fingerprint output. Web probe commands moved to top-level `bun scripts/cli.ts web-probe`.", }; } diff --git a/scripts/src/hwlab-node-control-plane.ts b/scripts/src/hwlab-node-control-plane.ts index cee7dc8d..8e225bbc 100644 --- a/scripts/src/hwlab-node-control-plane.ts +++ b/scripts/src/hwlab-node-control-plane.ts @@ -2,6 +2,9 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { rootPath } from "./config"; import { runCommand, type CommandResult } from "./command"; +import { egressBenchmarkCompactResult, egressBenchmarkDryRun, egressBenchmarkStartScript, egressBenchmarkStatusScript, type EgressBenchmarkSpec } from "./egress-proxy-benchmark"; +import { resolveEgressProxySourceRef } from "./egress-proxy-sources"; +import type { RenderedCliResult } from "./output"; import { fingerprintSecretValues, readEnvSourceFile, requiredEnvValue } from "./secrets"; export const HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH = "config/hwlab-node-control-plane.yaml"; @@ -9,6 +12,7 @@ export const HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH = "config/hwlab-node-control-p type InfraAction = "plan" | "status" | "apply"; type ToolsImageAction = "status" | "build" | "logs"; type ArgoAction = "status" | "apply" | "logs"; +type EgressBenchmarkAction = "benchmark" | "status" | "logs"; interface InfraOptions { action: InfraAction; @@ -39,15 +43,31 @@ interface ArgoOptions { tailLines: number; } +interface EgressBenchmarkOptions { + action: EgressBenchmarkAction; + node: string; + lane: string; + profile: "no-mirror"; + dryRun: boolean; + confirm: boolean; + samples: number; + sampleTimeoutSeconds: number; + timeoutSeconds: number; + tailLines: number; +} + interface ControlPlaneEgressProxySpec { mode: "k8s-service-cluster-ip"; clientName: string; namespace: string; serviceName: string; port: number; + sourceConfigRef: string | null; + sourceFingerprint: string | null; sourceRef: string; sourceKey: string; - sourceType: "subscription-url"; + sourceType: "subscription-url" | "master-shadowsocks"; + preferredOutbound: "vless-reality" | "hysteria2" | null; noProxy: readonly string[]; } @@ -180,7 +200,7 @@ interface ControlPlaneConfig { targets: readonly ControlPlaneTargetSpec[]; } -export function runHwlabNodeControlPlaneInfra(args: string[]): Record { +export function runHwlabNodeControlPlaneInfra(args: string[]): Record | RenderedCliResult { if (args[0] === "tools-image") { const options = parseToolsImageOptions(args.slice(1)); const { config, node, target } = controlPlaneContext(options.node, options.lane); @@ -191,6 +211,11 @@ export function runHwlabNodeControlPlaneInfra(args: string[]): Record { "bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node D601 --lane v03 --dry-run", "bun scripts/cli.ts hwlab nodes control-plane infra argo apply --node D601 --lane v03 --confirm", "bun scripts/cli.ts hwlab nodes control-plane infra argo logs --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes control-plane infra egress-benchmark --node D601 --lane v03 --profile no-mirror --confirm", + "bun scripts/cli.ts hwlab nodes control-plane infra egress-benchmark status --node D601 --lane v03 --profile no-mirror", ], g14Consistency: "D601 target fields mirror the existing G14 runtime lane control-plane vocabulary: source branch, gitops branch/path, Pipeline, PipelineRun prefix, ServiceAccount, Argo Application, and git-mirror read/write/sync/flush status concepts.", }; @@ -402,6 +429,116 @@ function runArgoCommand(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, return argoApply(node, target, options); } +function runEgressBenchmarkCommand(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: EgressBenchmarkOptions): Record | RenderedCliResult { + const spec = controlPlaneEgressBenchmarkSpec(node, target, options); + if (options.action === "status" || options.action === "logs") { + const result = runTransK3s(node.kubeRoute, egressBenchmarkStatusScript(spec, options.tailLines), options.timeoutSeconds); + const parsed = parseRemoteJson(result.stdout); + const status = record(record(parsed).status); + const state = renderCell(status.state, "unknown"); + return renderControlPlaneBenchmarkResult({ + ok: result.exitCode === 0 && (options.action === "logs" || state !== "failed"), + command: `hwlab nodes control-plane infra egress-benchmark ${options.action}`, + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mutation: false, + job: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) }, + result: egressBenchmarkCompactResult(result), + }); + } + if (options.dryRun) { + return renderControlPlaneBenchmarkResult({ + ok: true, + command: "hwlab nodes control-plane infra egress-benchmark", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "dry-run", + mutation: false, + plan: egressBenchmarkDryRun(spec), + next: { confirm: `bun scripts/cli.ts hwlab nodes control-plane infra egress-benchmark --node ${node.id} --lane ${target.lane} --profile ${options.profile} --confirm` }, + }); + } + const result = runTransK3s(node.kubeRoute, egressBenchmarkStartScript(spec), options.timeoutSeconds); + const parsed = parseRemoteJson(result.stdout); + return renderControlPlaneBenchmarkResult({ + ok: result.exitCode === 0, + command: "hwlab nodes control-plane infra egress-benchmark", + configPath: HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, + node: node.id, + lane: target.lane, + mode: "async-job", + mutation: result.exitCode === 0, + start: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) }, + result: egressBenchmarkCompactResult(result), + }); +} + +function controlPlaneEgressBenchmarkSpec(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: EgressBenchmarkOptions): EgressBenchmarkSpec { + const proxy = node.egressProxy; + if (proxy === null) throw new Error(`nodes.${node.id}.egressProxy is required for egress-benchmark`); + return { + scope: "hwlab-control-plane", + targetId: `${node.id}/${target.lane}`, + route: node.kubeRoute, + namespace: proxy.namespace, + serviceName: proxy.serviceName, + port: proxy.port, + noProxy: proxy.noProxy, + sourceType: proxy.sourceType, + sourceRef: proxy.sourceRef, + sourceConfigRef: proxy.sourceConfigRef, + sourceFingerprint: proxy.sourceFingerprint, + profile: options.profile, + samples: options.samples, + sampleTimeoutSeconds: options.sampleTimeoutSeconds, + }; +} + +function renderControlPlaneBenchmarkResult(result: Record): RenderedCliResult { + const start = record(result.start); + const job = record(result.job); + const status = record(job.status); + const plan = record(result.plan); + const next = record(result.next); + const rows = Array.isArray(status.rows) ? status.rows.map(record) : []; + const statusText = renderCell(status.state, result.ok === false ? "failed" : "ok"); + const logTail = typeof job.logTail === "string" ? job.logTail.trimEnd() : ""; + const node = renderCell(result.node); + const lane = renderCell(result.lane); + const target = `${node}/${lane}`; + const profile = renderCell(result.profile ?? plan.profile ?? status.profile, "no-mirror"); + const statusCommand = renderCell(start.statusCommand, `bun scripts/cli.ts hwlab nodes control-plane infra egress-benchmark status --node ${node} --lane ${lane} --profile ${profile}`); + const logsCommand = renderCell(start.logsCommand, `bun scripts/cli.ts hwlab nodes control-plane infra egress-benchmark logs --node ${node} --lane ${lane} --profile ${profile}`); + const lines = [ + "HWLAB CONTROL-PLANE EGRESS BENCHMARK", + "", + ...renderTable(["TARGET", "PROFILE", "MODE", "STATUS"], [[target, profile, renderCell(result.mode ?? optionsModeFromCommand(result.command)), statusText]]), + "", + rows.length === 0 ? "RESULTS\n-" : [ + "RESULTS", + ...renderTable(["TEST", "SUCCESS", "SAMPLES", "P50", "P95", "FAILURES"], rows.map((row) => [ + renderCell(row.test), + renderCell(row.success), + renderCell(row.samples), + renderCell(row.p50Ms), + renderCell(row.p95Ms), + JSON.stringify(row.failureFamilies ?? {}), + ])), + ].join("\n"), + ...(logTail.length === 0 ? [] : ["", "LOG TAIL", logTail]), + "", + "NEXT", + ` ${renderCell(next.confirm, "")}`, + ` ${statusCommand}`, + ` ${logsCommand}`, + "", + "Disclosure: default output is bounded; Secret/proxy source values are not printed.", + ].filter((line) => line !== " "); + return { ok: result.ok !== false, command: renderCell(result.command, "hwlab nodes control-plane infra egress-benchmark"), renderedText: lines.join("\n"), contentType: "text/plain" }; +} + function toolsImageCommandStatus(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ToolsImageOptions): Record { const registry = toolsImageStatus(node, target, options.timeoutSeconds); const jobResult = runTransK3s(node.kubeRoute, remoteJobStatusScript(target, "tools-image", options.tailLines), options.timeoutSeconds); @@ -437,7 +574,7 @@ function toolsImageBuild(node: ControlPlaneNodeSpec, target: ControlPlaneTargetS buildNetwork: target.tekton.toolsImage.buildNetwork, publicBaseImages: target.tekton.toolsImage.publicBaseImages, nodeLocalRegistryOutputOnly: true, - egressProxy: node.egressProxy, + egressProxy: controlPlaneEgressProxySummary(node.egressProxy), stateDir: remoteJobStateDir(target, "tools-image"), }; if (dryRun) { @@ -536,7 +673,7 @@ function argoApply(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, o desired: manifestObjectSummary(desired), desiredSha256: sha256Short(desiredYaml), stateDir: remoteJobStateDir(target, "argo"), - egressProxy: node.egressProxy, + egressProxy: controlPlaneEgressProxySummary(node.egressProxy), }; if (dryRun) { return { @@ -628,6 +765,34 @@ function parseArgoOptions(args: string[]): ArgoOptions { }; } +function parseEgressBenchmarkOptions(args: string[]): EgressBenchmarkOptions { + const first = args[0]; + const action: EgressBenchmarkAction = first === "status" || first === "logs" + ? first + : "benchmark"; + const effectiveArgs = action === "benchmark" ? args : args.slice(1); + if (first === "--help" || first === "-h" || first === "help") { + throw new Error("infra egress-benchmark usage: egress-benchmark [status|logs] --node NODE --lane vNN --profile no-mirror [--dry-run|--confirm]"); + } + const profileRaw = optionValue(effectiveArgs, "--profile") ?? "no-mirror"; + if (profileRaw !== "no-mirror") throw new Error("egress-benchmark --profile currently supports no-mirror"); + const confirm = effectiveArgs.includes("--confirm"); + const explicitDryRun = effectiveArgs.includes("--dry-run"); + if (confirm && explicitDryRun) throw new Error("egress-benchmark accepts only one of --confirm or --dry-run"); + return { + action, + node: requiredOption(effectiveArgs, "--node"), + lane: requiredOption(effectiveArgs, "--lane"), + profile: profileRaw, + confirm, + dryRun: action === "benchmark" ? explicitDryRun || !confirm : false, + samples: positiveIntegerOption(effectiveArgs, "--samples", 5, 20), + sampleTimeoutSeconds: positiveIntegerOption(effectiveArgs, "--sample-timeout-seconds", 240, 900), + timeoutSeconds: positiveIntegerOption(effectiveArgs, "--timeout-seconds", 60, 60), + tailLines: positiveIntegerOption(effectiveArgs, "--tail-lines", 80, 1000), + }; +} + function readControlPlaneConfig(): ControlPlaneConfig { const parsed = asRecord(Bun.YAML.parse(readFileSync(rootPath(HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH), "utf8")) as unknown, HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH); const version = numberField(parsed, "version", HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH); @@ -846,21 +1011,45 @@ function execStartPreField(raw: unknown, path: string): readonly (readonly strin function egressProxySpec(raw: Record, path: string): ControlPlaneEgressProxySpec { const mode = stringField(raw, "mode", path); if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be k8s-service-cluster-ip`); - const sourceType = stringField(raw, "sourceType", path); - if (sourceType !== "subscription-url") throw new Error(`${path}.sourceType must be subscription-url`); + const sourceConfigRef = optionalStringField(raw, "sourceConfigRef", path) ?? null; + const source = sourceConfigRef === null ? null : resolveEgressProxySourceRef(sourceConfigRef, `${HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH}.${path}.sourceConfigRef`); + const sourceType = source?.sourceType ?? stringField(raw, "sourceType", path); + if (sourceType !== "subscription-url" && sourceType !== "master-shadowsocks") throw new Error(`${path}.sourceType must be subscription-url or master-shadowsocks`); + if (raw.sourceType !== undefined && raw.sourceType !== sourceType) throw new Error(`${path}.sourceType must match sourceConfigRef value ${sourceType}`); + const preferredOutbound = source?.preferredOutbound ?? (raw.preferredOutbound === undefined ? null : preferredOutboundField(raw, "preferredOutbound", path)); + if (source !== null && source.preferredOutbound !== null && raw.preferredOutbound !== undefined && raw.preferredOutbound !== source.preferredOutbound) { + throw new Error(`${path}.preferredOutbound must match sourceConfigRef value ${source.preferredOutbound}`); + } + if (source !== null) { + if (raw.sourceRef !== undefined && raw.sourceRef !== source.sourceRef) throw new Error(`${path}.sourceRef must match sourceConfigRef value ${source.sourceRef}`); + if (raw.sourceKey !== undefined && raw.sourceKey !== source.sourceKey) throw new Error(`${path}.sourceKey must match sourceConfigRef value ${source.sourceKey}`); + } + const sourceRef = source?.sourceRef ?? stringField(raw, "sourceRef", path); + const sourceKey = source?.sourceKey ?? stringField(raw, "sourceKey", path); + validateSourceRef(sourceRef, `${path}.sourceRef`); + validateEnvKey(sourceKey, `${path}.sourceKey`); return { mode, clientName: stringField(raw, "clientName", path), namespace: stringField(raw, "namespace", path), serviceName: stringField(raw, "serviceName", path), port: positiveConfigIntegerField(raw, "port", path), - sourceRef: stringField(raw, "sourceRef", path), - sourceKey: stringField(raw, "sourceKey", path), + sourceConfigRef, + sourceFingerprint: source?.fingerprint ?? null, + sourceRef, + sourceKey, sourceType, + preferredOutbound, noProxy: stringArrayField(raw, "noProxy", path), }; } +function preferredOutboundField(raw: Record, key: string, path: string): "vless-reality" | "hysteria2" { + const value = stringField(raw, key, path); + if (value !== "vless-reality" && value !== "hysteria2") throw new Error(`${path}.${key} must be vless-reality or hysteria2`); + return value; +} + function gitMirrorEgressProxySpec(raw: Record, path: string): ControlPlaneGitMirrorEgressProxySpec { const mode = stringField(raw, "mode", path); if (mode !== "node-global" && mode !== "direct") throw new Error(`${path}.mode must be node-global or direct`); @@ -1297,11 +1486,14 @@ function gitMirrorProxyPrelude(node: ControlPlaneNodeSpec, target: ControlPlaneT const proxyPort = useDirect || node.egressProxy === null ? 0 : node.egressProxy.port; const proxyUrl = useDirect ? "" : `http://${proxyHost}:${proxyPort}`; const noProxy = useDirect || node.egressProxy === null ? "" : node.egressProxy.noProxy.join(","); + const proxySource = useDirect || node.egressProxy === null + ? "sourceType=direct sourceRef=- sourceFingerprint=-" + : `sourceType=${node.egressProxy.sourceType} sourceRef=${node.egressProxy.sourceRef} sourceFingerprint=${node.egressProxy.sourceFingerprint ?? "-"}`; const proxySummary = useDirect ? `git-mirror-egress-proxy mode=direct required=false transport=${transport.mode} source=yaml` : transport.mode === "https" - ? `git-mirror-egress-proxy client=${node.egressProxy?.clientName} mode=node-global required=${proxyRequired ? "true" : "false"} host=${proxyHost} port=${proxyPort} transport=https proxy=HTTP_PROXY authSecret=${transport.tokenSecretName} authKey=${transport.tokenSecretKey} authSourceRef=${transport.tokenSourceRef} source=yaml` - : `git-mirror-egress-proxy client=${node.egressProxy?.clientName} mode=node-global required=${proxyRequired ? "true" : "false"} host=${proxyHost} port=${proxyPort} transport=ssh ssh=GIT_SSH-wrapper source=yaml`; + ? `git-mirror-egress-proxy client=${node.egressProxy?.clientName} mode=node-global required=${proxyRequired ? "true" : "false"} host=${proxyHost} port=${proxyPort} transport=https proxy=HTTP_PROXY authSecret=${transport.tokenSecretName} authKey=${transport.tokenSecretKey} authSourceRef=${transport.tokenSourceRef} ${proxySource} source=yaml` + : `git-mirror-egress-proxy client=${node.egressProxy?.clientName} mode=node-global required=${proxyRequired ? "true" : "false"} host=${proxyHost} port=${proxyPort} transport=ssh ssh=GIT_SSH-wrapper ${proxySource} source=yaml`; const proxyCommand = useDirect ? "" : `ProxyCommand=node /tmp/hwlab-github-proxy-connect.cjs ${proxyHost} ${proxyPort} %h %p`; const common = [ `printf '%s\\n' ${shQuote(proxySummary)} >&2`, @@ -1574,7 +1766,7 @@ function planSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec) runtimeNamespace: target.runtimeNamespace, k3sNodeConfig: k3sNodeConfigPlan(node), registry: node.registry.endpoint, - egressProxy: node.egressProxy, + egressProxy: controlPlaneEgressProxySummary(node.egressProxy), sourceBranch: target.source.branch, gitopsBranch: target.gitops.branch, gitopsPath: target.gitops.path, @@ -1615,6 +1807,7 @@ function expectedSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetS deploymentReplicas: target.gitMirror.deploymentReplicas, syncConfigMap: target.gitMirror.syncConfigMapName, egressProxy: target.gitMirror.egressProxy, + effectiveEgressProxy: gitMirrorEffectiveEgressProxySummary(node, target), githubTransport: gitMirrorGithubTransportSummary(target.gitMirror.githubTransport), statusSummaryKeys: ["localSource", "githubSource", "localGitops", "githubGitops", "pendingFlush", "flushNeeded", "githubInSync"], }, @@ -1670,6 +1863,45 @@ function k3sDropInContent(spec: ControlPlaneK3sNodeSpec): string { ].join("\n"); } +function controlPlaneEgressProxySummary(proxy: ControlPlaneEgressProxySpec | null): Record | null { + if (proxy === null) return null; + return { + mode: proxy.mode, + clientName: proxy.clientName, + namespace: proxy.namespace, + serviceName: proxy.serviceName, + port: proxy.port, + sourceConfigRef: proxy.sourceConfigRef, + sourceType: proxy.sourceType, + sourceRef: proxy.sourceRef, + sourceKey: proxy.sourceKey, + sourceFingerprint: proxy.sourceFingerprint, + preferredOutbound: proxy.preferredOutbound, + valuesPrinted: false, + }; +} + +function gitMirrorEffectiveEgressProxySummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): Record { + const config = target.gitMirror.egressProxy; + if (config === null || config.mode === "direct") { + return { + mode: "direct", + required: false, + transport: target.gitMirror.githubTransport.mode, + valuesPrinted: false, + }; + } + const proxy = node.egressProxy; + return { + mode: config.mode, + required: config.required, + transport: target.gitMirror.githubTransport.mode, + ready: proxy !== null, + nodeProxy: controlPlaneEgressProxySummary(proxy), + valuesPrinted: false, + }; +} + function gitMirrorGithubTransportSummary(transport: ControlPlaneGitMirrorGithubTransportSpec): Record { if (transport.mode === "ssh") return { mode: "ssh", valuesPrinted: false }; return { @@ -1694,6 +1926,7 @@ function statusScript(nodeSpec: ControlPlaneNodeSpec, target: ControlPlaneTarget const argoStatefulSets = shellJsonArray(target.argo.install.expectedStatefulSets); const k3s = nodeSpec.k3s; const k3sDropIn = k3s === null ? "" : k3sDropInContent(k3s); + const gitMirrorEgressProxyJson = JSON.stringify(gitMirrorEffectiveEgressProxySummary(nodeSpec, target)); return ` set +e node=${shQuote(target.node)} @@ -1712,6 +1945,7 @@ github_token_secret=${shQuote(target.gitMirror.githubTransport.mode === "https" github_token_key=${shQuote(target.gitMirror.githubTransport.mode === "https" ? target.gitMirror.githubTransport.tokenSecretKey : "")} github_token_source_ref=${shQuote(target.gitMirror.githubTransport.mode === "https" ? target.gitMirror.githubTransport.tokenSourceRef : "")} github_token_source_key=${shQuote(target.gitMirror.githubTransport.mode === "https" ? target.gitMirror.githubTransport.tokenSourceKey : "")} +gitmirror_egress_proxy_json=${shQuote(gitMirrorEgressProxyJson)} pipeline=${shQuote(target.tekton.pipelineName)} service_account=${shQuote(target.tekton.serviceAccountName)} argo_ns=${shQuote(target.argo.namespace)} @@ -1845,7 +2079,7 @@ print(json.dumps({"crds": crds, "deployments": deploy, "statefulSets": sts, "crd PY argo_fragment=$(cat /tmp/hwlab-node-status-fragments.json 2>/dev/null || printf '{}') cat </dev/null 2>&1 && printf true || printf false),"controllerReady":$(deploy_ready tekton-pipelines tekton-pipelines-controller),"webhookReady":$(deploy_ready tekton-pipelines tekton-pipelines-webhook)},"ciNamespace":{"name":"$ci_ns","exists":$(exists_ns "$ci_ns"),"serviceAccountExists":$(exists_res "$ci_ns" serviceaccount "$service_account"),"pipelineExists":$(exists_res "$ci_ns" pipeline "$pipeline")},"gitMirror":{"namespace":"$gitmirror_ns","namespaceExists":$(exists_ns "$gitmirror_ns"),"readDeploymentReady":$(deploy_ready "$gitmirror_ns" "$read_deploy"),"writeDeploymentReady":$(deploy_ready "$gitmirror_ns" "$write_deploy"),"readServiceExists":$(exists_res "$gitmirror_ns" service "$read_svc"),"writeServiceExists":$(exists_res "$gitmirror_ns" service "$write_svc"),"readEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$read_svc"),"writeEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$write_svc"),"cachePvcExists":$(exists_res "$gitmirror_ns" pvc "$cache_pvc"),"cacheHostPath":"$cache_host_path","cacheHostPathReady":$cache_host_path_ready,"githubTransport":$github_transport_json,"summary":{"localSource":null,"githubSource":null,"localGitops":null,"githubGitops":null,"pendingFlush":null,"flushNeeded":null,"githubInSync":null}},"argo":{"namespace":"$argo_ns","namespaceExists":$(exists_ns "$argo_ns"),"installed":$(kubectl get crd applications.argoproj.io appprojects.argoproj.io >/dev/null 2>&1 && printf true || printf false),"projectExists":$(kubectl -n "$argo_ns" get appproject "$argo_project" >/dev/null 2>&1 && printf true || printf false),"applicationExists":$(kubectl -n "$argo_ns" get application "$argo_app" >/dev/null 2>&1 && printf true || printf false),"install":$argo_fragment},"registry":{"endpoint":"$registry","ready":$registry_ready,"toolsImage":"$tools_image","toolsImageReady":$tools_image_ready},"runtimeNamespace":{"name":"$runtime_ns","exists":$(exists_ns "$runtime_ns")}}} +{"observedAt":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","node":"$node","lane":"$lane","components":{"k3sNodeConfig":$k3s_fragment,"tekton":{"installed":$(kubectl get crd pipelines.tekton.dev pipelineruns.tekton.dev >/dev/null 2>&1 && printf true || printf false),"controllerReady":$(deploy_ready tekton-pipelines tekton-pipelines-controller),"webhookReady":$(deploy_ready tekton-pipelines tekton-pipelines-webhook)},"ciNamespace":{"name":"$ci_ns","exists":$(exists_ns "$ci_ns"),"serviceAccountExists":$(exists_res "$ci_ns" serviceaccount "$service_account"),"pipelineExists":$(exists_res "$ci_ns" pipeline "$pipeline")},"gitMirror":{"namespace":"$gitmirror_ns","namespaceExists":$(exists_ns "$gitmirror_ns"),"readDeploymentReady":$(deploy_ready "$gitmirror_ns" "$read_deploy"),"writeDeploymentReady":$(deploy_ready "$gitmirror_ns" "$write_deploy"),"readServiceExists":$(exists_res "$gitmirror_ns" service "$read_svc"),"writeServiceExists":$(exists_res "$gitmirror_ns" service "$write_svc"),"readEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$read_svc"),"writeEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$write_svc"),"cachePvcExists":$(exists_res "$gitmirror_ns" pvc "$cache_pvc"),"cacheHostPath":"$cache_host_path","cacheHostPathReady":$cache_host_path_ready,"egressProxy":$gitmirror_egress_proxy_json,"githubTransport":$github_transport_json,"summary":{"localSource":null,"githubSource":null,"localGitops":null,"githubGitops":null,"pendingFlush":null,"flushNeeded":null,"githubInSync":null}},"argo":{"namespace":"$argo_ns","namespaceExists":$(exists_ns "$argo_ns"),"installed":$(kubectl get crd applications.argoproj.io appprojects.argoproj.io >/dev/null 2>&1 && printf true || printf false),"projectExists":$(kubectl -n "$argo_ns" get appproject "$argo_project" >/dev/null 2>&1 && printf true || printf false),"applicationExists":$(kubectl -n "$argo_ns" get application "$argo_app" >/dev/null 2>&1 && printf true || printf false),"install":$argo_fragment},"registry":{"endpoint":"$registry","ready":$registry_ready,"toolsImage":"$tools_image","toolsImageReady":$tools_image_ready},"runtimeNamespace":{"name":"$runtime_ns","exists":$(exists_ns "$runtime_ns")}}} JSON `; } @@ -2509,6 +2743,23 @@ function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } +function renderTable(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0))); + const render = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd(); + return [render(headers), ...rows.map(render)]; +} + +function renderCell(value: unknown, fallback = "-"): string { + if (value === undefined || value === null || value === "") return fallback; + return String(value); +} + +function optionsModeFromCommand(command: unknown): string { + const value = String(command ?? ""); + if (value.endsWith(" status") || value.endsWith(" logs")) return "status"; + return "benchmark"; +} + function stringField(obj: Record, key: string, path: string): string { const value = obj[key]; if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); @@ -2600,10 +2851,17 @@ function requiredOption(args: string[], name: string): string { return value; } -function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number { +function optionValue(args: string[], name: string): string | null { const index = args.indexOf(name); - if (index === -1) return defaultValue; - const raw = args[index + 1]; + if (index === -1) return null; + const value = args[index + 1]; + if (value === undefined || value.startsWith("--") || value.length === 0) throw new Error(`${name} requires a value`); + return value; +} + +function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number { + const raw = optionValue(args, name); + if (raw === null) return defaultValue; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return Math.min(value, maxValue); diff --git a/scripts/src/platform-infra-egress-proxy.ts b/scripts/src/platform-infra-egress-proxy.ts new file mode 100644 index 00000000..05717167 --- /dev/null +++ b/scripts/src/platform-infra-egress-proxy.ts @@ -0,0 +1,202 @@ +import type { UniDeskConfig } from "./config"; +import { rootPath } from "./config"; +import { runCommand } from "./command"; +import type { RenderedCliResult } from "./output"; +import { egressBenchmarkCompactResult, egressBenchmarkDryRun, egressBenchmarkStartScript, egressBenchmarkStatusScript, type EgressBenchmarkSpec } from "./egress-proxy-benchmark"; +import { readSub2ApiConfig } from "./platform-infra/config"; +import { resolveTarget } from "./platform-infra/manifest"; + +type BenchmarkAction = "benchmark" | "benchmark-status" | "benchmark-logs"; + +interface BenchmarkOptions { + action: BenchmarkAction; + targetId: string; + profile: "no-mirror"; + confirm: boolean; + dryRun: boolean; + samples: number; + sampleTimeoutSeconds: number; + timeoutSeconds: number; + tailLines: number; +} + +export async function runPlatformInfraEgressProxyCommand(_config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + const options = parseBenchmarkOptions(args); + const spec = platformBenchmarkSpec(options); + if (options.action === "benchmark-status" || options.action === "benchmark-logs") return benchmarkStatus(spec, options); + if (options.dryRun) { + return renderBenchmark({ + ok: true, + command: "platform-infra egress-proxy benchmark", + mode: "dry-run", + mutation: false, + plan: egressBenchmarkDryRun(spec), + next: { confirm: `bun scripts/cli.ts platform-infra egress-proxy benchmark --target ${options.targetId} --profile ${options.profile} --confirm` }, + }); + } + const result = runTrans(spec.route, egressBenchmarkStartScript(spec), options.timeoutSeconds); + const parsed = parseJson(result.stdout); + return renderBenchmark({ + ok: result.exitCode === 0, + command: "platform-infra egress-proxy benchmark", + mode: "async-job", + mutation: result.exitCode === 0, + target: options.targetId, + profile: options.profile, + start: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) }, + result: egressBenchmarkCompactResult(result), + }); +} + +function benchmarkStatus(spec: EgressBenchmarkSpec, options: BenchmarkOptions): RenderedCliResult { + const result = runTrans(spec.route, egressBenchmarkStatusScript(spec, options.tailLines), options.timeoutSeconds); + const parsed = parseJson(result.stdout); + const status = record(record(parsed).status); + const state = text(status.state, "unknown"); + return renderBenchmark({ + ok: result.exitCode === 0 && (options.action === "benchmark-logs" || state !== "failed"), + command: `platform-infra egress-proxy ${options.action}`, + mode: "status", + mutation: false, + target: options.targetId, + profile: options.profile, + job: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 2000) }, + result: egressBenchmarkCompactResult(result), + }); +} + +function platformBenchmarkSpec(options: BenchmarkOptions): EgressBenchmarkSpec { + const sub2api = readSub2ApiConfig(); + const target = resolveTarget(sub2api, options.targetId); + const proxy = target.egressProxy; + if (proxy === null || !proxy.enabled) throw new Error(`target ${target.id} has no enabled egressProxy`); + return { + scope: "platform-infra", + targetId: target.id, + route: target.route, + namespace: target.namespace, + serviceName: proxy.serviceName, + port: proxy.listenPort, + noProxy: proxy.noProxy, + sourceType: proxy.sourceType, + sourceRef: proxy.sourceRef, + sourceConfigRef: proxy.sourceConfigRef, + sourceFingerprint: proxy.sourceFingerprint, + profile: options.profile, + samples: options.samples, + sampleTimeoutSeconds: options.sampleTimeoutSeconds, + }; +} + +function parseBenchmarkOptions(args: string[]): BenchmarkOptions { + const actionRaw = args[0] ?? "benchmark"; + if (actionRaw !== "benchmark" && actionRaw !== "benchmark-status" && actionRaw !== "benchmark-logs") { + throw new Error("platform-infra egress-proxy usage: benchmark|benchmark-status|benchmark-logs --target D601|D518 --profile no-mirror [--dry-run|--confirm]"); + } + const action = actionRaw; + const rest = args.slice(1); + const targetId = option(rest, "--target") ?? "D601"; + const profileRaw = option(rest, "--profile") ?? "no-mirror"; + if (profileRaw !== "no-mirror") throw new Error("--profile currently supports no-mirror"); + const confirm = rest.includes("--confirm"); + const dryRun = rest.includes("--dry-run") || !confirm; + if (confirm && rest.includes("--dry-run")) throw new Error("benchmark accepts only one of --confirm or --dry-run"); + return { + action, + targetId, + profile: profileRaw, + confirm, + dryRun: action === "benchmark" ? dryRun : false, + samples: positiveIntOption(rest, "--samples", 5, 20), + sampleTimeoutSeconds: positiveIntOption(rest, "--sample-timeout-seconds", 240, 900), + timeoutSeconds: positiveIntOption(rest, "--timeout-seconds", 30, 60), + tailLines: positiveIntOption(rest, "--tail", 40, 200), + }; +} + +function runTrans(route: string, script: string, timeoutSeconds: number) { + return runCommand(["/root/.local/bin/trans", route, "sh", "--", script], rootPath(), { timeoutMs: timeoutSeconds * 1000 }); +} + +function option(args: string[], name: string): string | null { + const index = args.indexOf(name); + if (index === -1) return null; + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +function positiveIntOption(args: string[], name: string, defaultValue: number, maxValue: number): number { + const raw = option(args, name); + if (raw === null) return defaultValue; + const value = Number.parseInt(raw, 10); + if (!Number.isInteger(value) || value < 1 || value > maxValue) throw new Error(`${name} must be an integer from 1 to ${maxValue}`); + return value; +} + +function parseJson(text: string): unknown { + const trimmed = text.trim(); + if (trimmed.length === 0) return null; + try { return JSON.parse(trimmed); } catch { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { return JSON.parse(trimmed.slice(start, end + 1)); } catch {} + } + } + return null; +} + +function renderBenchmark(result: Record): RenderedCliResult { + const start = record(result.start); + const job = record(result.job); + const status = record(job.status); + const plan = record(result.plan); + const next = record(result.next); + const rows = Array.isArray(status.rows) ? status.rows.map(record) : []; + const target = text(result.target ?? plan.target); + const profile = text(result.profile ?? plan.profile, "no-mirror"); + const statusText = text(status.state, result.ok === false ? "failed" : "ok"); + const logTail = typeof job.logTail === "string" ? job.logTail.trimEnd() : ""; + const lines = [ + `platform-infra egress-proxy ${result.mode === "dry-run" ? "benchmark dry-run" : "benchmark"}`, + "", + ...table(["TARGET", "PROFILE", "MODE", "STATUS"], [[target, profile, text(result.mode), statusText]]), + "", + rows.length === 0 ? "RESULTS\n-" : [ + "RESULTS", + ...table(["TEST", "SUCCESS", "SAMPLES", "P50", "P95", "FAILURES"], rows.map((row) => [ + text(row.test), + text(row.success), + text(row.samples), + text(row.p50Ms), + text(row.p95Ms), + JSON.stringify(row.failureFamilies ?? {}), + ])), + ].join("\n"), + ...(logTail.length === 0 ? [] : ["", "LOG TAIL", logTail]), + "", + "NEXT", + ` ${text(next.confirm, "")}`, + ` ${text(start.statusCommand, `bun scripts/cli.ts platform-infra egress-proxy benchmark-status --target ${target} --profile ${profile}`)}`, + ` ${text(start.logsCommand, `bun scripts/cli.ts platform-infra egress-proxy benchmark-logs --target ${target} --profile ${profile}`)}`, + "", + "Disclosure: default output is a bounded benchmark table; Secret/proxy source values are redacted.", + ].filter((line) => line !== " "); + return { ok: result.ok !== false, command: text(result.command, "platform-infra egress-proxy benchmark"), renderedText: lines.join("\n"), contentType: "text/plain" }; +} + +function table(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0))); + const render = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd(); + return [render(headers), ...rows.map(render)]; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function text(value: unknown, fallback = "-"): string { + if (value === undefined || value === null || value === "") return fallback; + return String(value); +} diff --git a/scripts/src/platform-infra/actions.ts b/scripts/src/platform-infra/actions.ts index 320b0817..712f9ab3 100644 --- a/scripts/src/platform-infra/actions.ts +++ b/scripts/src/platform-infra/actions.ts @@ -66,6 +66,8 @@ export function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record; - const sourceType = enumField(record, "sourceType", `${path}.egressProxy`, ["subscription-url", "master-shadowsocks"] as const); + const sourceConfigRef = optionalConfigStringField(record, "sourceConfigRef", `${path}.egressProxy`); + const source = sourceConfigRef === null ? null : resolveEgressProxySourceRef(sourceConfigRef, `${configPath}.${path}.egressProxy.sourceConfigRef`); + const sourceType = source?.sourceType ?? enumField(record, "sourceType", `${path}.egressProxy`, ["subscription-url", "master-shadowsocks"] as const); + assertOptionalMatch(record, "sourceType", sourceType, `${path}.egressProxy`); const preferredOutbound = sourceType === "subscription-url" || record.preferredOutbound !== undefined - ? enumField(record, "preferredOutbound", `${path}.egressProxy`, ["vless-reality", "hysteria2"] as const) + ? source?.preferredOutbound ?? enumField(record, "preferredOutbound", `${path}.egressProxy`, ["vless-reality", "hysteria2"] as const) : null; + if (source !== null && source.preferredOutbound !== null && record.preferredOutbound !== undefined) assertOptionalMatch(record, "preferredOutbound", source.preferredOutbound, `${path}.egressProxy`); const masterShadowsocks = sourceType === "master-shadowsocks" - ? parseMasterShadowsocksConfig(record.masterShadowsocks, `${path}.egressProxy.masterShadowsocks`) + ? source?.masterShadowsocks ?? parseMasterShadowsocksConfig(record.masterShadowsocks, `${path}.egressProxy.masterShadowsocks`) : null; const imagePullPolicy = enumField(record, "imagePullPolicy", `${path}.egressProxy`, ["Always", "IfNotPresent", "Never"] as const); const noProxy = stringArrayField(record, "noProxy", `${path}.egressProxy`); @@ -300,16 +305,23 @@ export function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgr image: stringField(record, "image", `${path}.egressProxy`), imagePullPolicy, listenPort: integerField(record, "listenPort", `${path}.egressProxy`), - sourceRef: stringField(record, "sourceRef", `${path}.egressProxy`), - sourceKey: stringField(record, "sourceKey", `${path}.egressProxy`), + sourceConfigRef, + sourceFingerprint: source?.fingerprint ?? null, + sourceRef: source?.sourceRef ?? stringField(record, "sourceRef", `${path}.egressProxy`), + sourceKey: source?.sourceKey ?? stringField(record, "sourceKey", `${path}.egressProxy`), sourceType, preferredOutbound, masterShadowsocks, noProxy, applyToSub2Api: booleanField(record, "applyToSub2Api", `${path}.egressProxy`), applyToSentinel: booleanField(record, "applyToSentinel", `${path}.egressProxy`), - healthProbeUrl: stringField(record, "healthProbeUrl", `${path}.egressProxy`), + healthProbeUrl: source?.healthProbeUrl ?? stringField(record, "healthProbeUrl", `${path}.egressProxy`), }; + if (source !== null) { + assertOptionalMatch(record, "sourceRef", source.sourceRef, `${path}.egressProxy`); + assertOptionalMatch(record, "sourceKey", source.sourceKey, `${path}.egressProxy`); + assertOptionalMatch(record, "healthProbeUrl", source.healthProbeUrl, `${path}.egressProxy`); + } validateEgressProxyConfig(proxy, path); return proxy; } @@ -406,6 +418,19 @@ export function stringArrayField(obj: Record, key: string, path return value.map((item) => item.trim()); } +function optionalConfigStringField(obj: Record, key: string, path: string): string | null { + const value = obj[key]; + if (value === undefined || value === null) return null; + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${fieldLabel(path, key)} must be a non-empty string when set`); + return value.trim(); +} + +function assertOptionalMatch(obj: Record, key: string, expected: string | null, path: string): void { + if (obj[key] === undefined || obj[key] === null) return; + const actual = stringField(obj, key, path); + if (actual !== expected) throw new Error(`${fieldLabel(path, key)} must match sourceConfigRef value ${expected}`); +} + export function enumField(obj: Record, key: string, path: string, values: T): T[number] { const value = stringField(obj, key, path); if (!(values as readonly string[]).includes(value)) throw new Error(`${fieldLabel(path, key)} must be one of ${values.join(", ")}`); @@ -483,6 +508,9 @@ export function validateEgressProxyConfig(config: Sub2ApiEgressProxyConfig, path if (config.secretKey !== "config.json") throw new Error(`${configPath}.${path}.egressProxy.secretKey must be config.json`); if (!isImageReference(config.image)) throw new Error(`${configPath}.${path}.egressProxy.image has an unsupported format`); validatePort(config.listenPort, `${path}.egressProxy.listenPort`); + if (config.sourceConfigRef !== null && (!config.sourceConfigRef.startsWith("config/") || !config.sourceConfigRef.includes("#sources.") || config.sourceConfigRef.includes(".."))) { + throw new Error(`${configPath}.${path}.egressProxy.sourceConfigRef must reference config/*.yaml#sources.`); + } if (!/^[A-Za-z0-9_./-]+$/u.test(config.sourceRef)) throw new Error(`${configPath}.${path}.egressProxy.sourceRef has an unsupported format`); if (!/^[A-Z0-9_]+$/u.test(config.sourceKey)) throw new Error(`${configPath}.${path}.egressProxy.sourceKey must be an env key`); if (config.sourceType === "subscription-url" && config.preferredOutbound === null) { diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index bf9f13a0..011cbc12 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -169,6 +169,8 @@ export interface Sub2ApiEgressProxyConfig { image: string; imagePullPolicy: "Always" | "IfNotPresent" | "Never"; listenPort: number; + sourceConfigRef: string | null; + sourceFingerprint: string | null; sourceRef: string; sourceKey: string; sourceType: "subscription-url" | "master-shadowsocks"; diff --git a/scripts/src/platform-infra/manifest.ts b/scripts/src/platform-infra/manifest.ts index 3e4faeb4..dd5ff72c 100644 --- a/scripts/src/platform-infra/manifest.ts +++ b/scripts/src/platform-infra/manifest.ts @@ -623,6 +623,8 @@ spec: annotations: unidesk.ai/proxy-source-ref: "${proxy.sourceRef}" unidesk.ai/proxy-source-type: "${proxy.sourceType}" + unidesk.ai/proxy-source-config-ref: "${proxy.sourceConfigRef ?? ""}" + unidesk.ai/proxy-source-fingerprint: "${proxy.sourceFingerprint ?? ""}" unidesk.ai/proxy-selected-outbound: "${proxy.sourceType === "subscription-url" ? proxy.preferredOutbound : "shadowsocks"}" spec: containers: diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index 7dbb2330..dc280014 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -36,6 +36,10 @@ export function sub2ApiHelpTargetSummary(): Record { export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [target, action] = args; + if (target === "egress-proxy") { + const { runPlatformInfraEgressProxyCommand } = await import("../platform-infra-egress-proxy"); + return await runPlatformInfraEgressProxyCommand(config, args.slice(1)); + } if (target === "langbot") { const { runLangBotCommand } = await import("../platform-infra-langbot"); return await runLangBotCommand(config, args.slice(1)); @@ -109,7 +113,7 @@ function renderSub2ApiPlan(result: Record): RenderedCliResult { ["DATABASE", stringValue(config.databaseMode), "redis", stringValue(config.redisMode)], ["REPLICAS", `${stringValue(config.appReplicas)}/${stringValue(config.redisReplicas)}`, "service", stringValue(target.serviceDns)], ["PUBLIC", boolText(exposure.enabled), "url", stringValue(exposure.publicBaseUrl, "-")], - ["EGRESS", boolText(egress.enabled), "sourceRef", stringValue(egress.sourceRef, "-")], + ["EGRESS", boolText(egress.enabled), "source", `${stringValue(egress.sourceType, "-")} ${stringValue(egress.sourceFingerprint, "-")}`], ["POLICY", failedPolicy.length === 0 ? "ok" : `failed=${failedPolicy.length}`, "valuesPrinted", "false"], ]; const lines = [