feat: add yaml egress proxy benchmark
This commit is contained in:
@@ -60,9 +60,7 @@ nodes:
|
|||||||
namespace: platform-infra
|
namespace: platform-infra
|
||||||
serviceName: sub2api-egress-proxy
|
serviceName: sub2api-egress-proxy
|
||||||
port: 10808
|
port: 10808
|
||||||
sourceRef: platform-infra/master-vpn-subscription.env
|
sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks
|
||||||
sourceKey: MASTER_VPN_SUBSCRIPTION_URL
|
|
||||||
sourceType: subscription-url
|
|
||||||
noProxy:
|
noProxy:
|
||||||
- localhost
|
- localhost
|
||||||
- 127.0.0.1
|
- 127.0.0.1
|
||||||
@@ -82,8 +80,6 @@ nodes:
|
|||||||
- 192.168.0.0/16
|
- 192.168.0.0/16
|
||||||
- 82.156.23.220
|
- 82.156.23.220
|
||||||
- 74.48.78.17
|
- 74.48.78.17
|
||||||
- registry.npmmirror.com
|
|
||||||
- cdn.npmmirror.com
|
|
||||||
- hyueapi.com
|
- hyueapi.com
|
||||||
- .hyueapi.com
|
- .hyueapi.com
|
||||||
|
|
||||||
@@ -116,8 +112,8 @@ targets:
|
|||||||
readUrl: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git
|
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
|
writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/HWLAB.git
|
||||||
egressProxy:
|
egressProxy:
|
||||||
mode: direct
|
mode: node-global
|
||||||
required: false
|
required: true
|
||||||
githubTransport:
|
githubTransport:
|
||||||
mode: ssh
|
mode: ssh
|
||||||
tekton:
|
tekton:
|
||||||
@@ -146,7 +142,7 @@ targets:
|
|||||||
- ENV PATH=/usr/local/go/bin:$PATH
|
- ENV PATH=/usr/local/go/bin:$PATH
|
||||||
- RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx
|
- RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx
|
||||||
- ENV HWLAB_CI_NODE_DEPS=/opt/hwlab-ci-node-deps/node_modules
|
- 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
|
- RUN node --version && npm --version && bun --version && git --version && python3 --version && docker --version && ssh -V && go version
|
||||||
buildArgs: {}
|
buildArgs: {}
|
||||||
buildNetwork: host
|
buildNetwork: host
|
||||||
|
|||||||
@@ -624,3 +624,23 @@ downloadProfiles:
|
|||||||
retries: 3
|
retries: 3
|
||||||
connectTimeoutSeconds: 10
|
connectTimeoutSeconds: 10
|
||||||
maxTimeSeconds: 120
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -150,16 +150,9 @@ targets:
|
|||||||
image: 127.0.0.1:5000/platform-infra/sing-box:latest
|
image: 127.0.0.1:5000/platform-infra/sing-box:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
listenPort: 10808
|
listenPort: 10808
|
||||||
sourceRef: platform-infra/sub2api-master-egress-proxy.env
|
sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks
|
||||||
sourceKey: SUB2API_MASTER_SHADOWSOCKS_PASSWORD
|
|
||||||
sourceType: master-shadowsocks
|
|
||||||
masterShadowsocks:
|
|
||||||
serverHost: 74.48.78.17
|
|
||||||
serverPort: 18792
|
|
||||||
method: chacha20-ietf-poly1305
|
|
||||||
applyToSub2Api: true
|
applyToSub2Api: true
|
||||||
applyToSentinel: true
|
applyToSentinel: true
|
||||||
healthProbeUrl: https://www.gstatic.com/generate_204
|
|
||||||
noProxy:
|
noProxy:
|
||||||
- localhost
|
- localhost
|
||||||
- 127.0.0.1
|
- 127.0.0.1
|
||||||
@@ -252,13 +245,9 @@ targets:
|
|||||||
image: ghcr.io/sagernet/sing-box:latest
|
image: ghcr.io/sagernet/sing-box:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
listenPort: 10808
|
listenPort: 10808
|
||||||
sourceRef: platform-infra/master-vpn-subscription.env
|
sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks
|
||||||
sourceKey: MASTER_VPN_SUBSCRIPTION_URL
|
|
||||||
sourceType: subscription-url
|
|
||||||
preferredOutbound: hysteria2
|
|
||||||
applyToSub2Api: true
|
applyToSub2Api: true
|
||||||
applyToSentinel: true
|
applyToSentinel: true
|
||||||
healthProbeUrl: https://www.gstatic.com/generate_204
|
|
||||||
noProxy:
|
noProxy:
|
||||||
- localhost
|
- localhost
|
||||||
- 127.0.0.1
|
- 127.0.0.1
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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("'", "'\"'\"'")}'`;
|
||||||
|
}
|
||||||
@@ -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.<id>`);
|
||||||
|
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<string, unknown>, 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<string, unknown>, 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.<id> 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<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`);
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(obj: Record<string, unknown>, 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<string, unknown>, 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<const T extends readonly string[]>(obj: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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;
|
||||||
|
}
|
||||||
+6
-3
@@ -644,7 +644,7 @@ function agentRunHelpSummary(): unknown {
|
|||||||
|
|
||||||
function platformInfraHelpSummary(): unknown {
|
function platformInfraHelpSummary(): unknown {
|
||||||
return {
|
return {
|
||||||
command: "platform-infra sub2api|langbot|n8n|wechat-archive ...",
|
command: "platform-infra sub2api|egress-proxy|langbot|n8n|wechat-archive ...",
|
||||||
output: "json",
|
output: "json",
|
||||||
usage: [
|
usage: [
|
||||||
"bun scripts/cli.ts platform-infra sub2api plan",
|
"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-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-probe --account unidesk-codex-hy --confirm",
|
||||||
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-report",
|
"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 plan",
|
||||||
"bun scripts/cli.ts platform-infra langbot apply --confirm",
|
"bun scripts/cli.ts platform-infra langbot apply --confirm",
|
||||||
"bun scripts/cli.ts platform-infra langbot status",
|
"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 wcf-host-status --full",
|
||||||
"bun scripts/cli.ts platform-infra wechat-archive collector-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 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 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 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 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 git-mirror status --node G14 --lane v03",
|
||||||
"bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node D601 --lane v03 --dry-run",
|
"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 status --node D601 --lane v03",
|
||||||
"bun scripts/cli.ts hwlab nodes test-accounts sync --node D601 --lane v03 --confirm",
|
"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`.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { createHash } from "node:crypto";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { rootPath } from "./config";
|
import { rootPath } from "./config";
|
||||||
import { runCommand, type CommandResult } from "./command";
|
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";
|
import { fingerprintSecretValues, readEnvSourceFile, requiredEnvValue } from "./secrets";
|
||||||
|
|
||||||
export const HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH = "config/hwlab-node-control-plane.yaml";
|
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 InfraAction = "plan" | "status" | "apply";
|
||||||
type ToolsImageAction = "status" | "build" | "logs";
|
type ToolsImageAction = "status" | "build" | "logs";
|
||||||
type ArgoAction = "status" | "apply" | "logs";
|
type ArgoAction = "status" | "apply" | "logs";
|
||||||
|
type EgressBenchmarkAction = "benchmark" | "status" | "logs";
|
||||||
|
|
||||||
interface InfraOptions {
|
interface InfraOptions {
|
||||||
action: InfraAction;
|
action: InfraAction;
|
||||||
@@ -39,15 +43,31 @@ interface ArgoOptions {
|
|||||||
tailLines: number;
|
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 {
|
interface ControlPlaneEgressProxySpec {
|
||||||
mode: "k8s-service-cluster-ip";
|
mode: "k8s-service-cluster-ip";
|
||||||
clientName: string;
|
clientName: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
serviceName: string;
|
serviceName: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
sourceConfigRef: string | null;
|
||||||
|
sourceFingerprint: string | null;
|
||||||
sourceRef: string;
|
sourceRef: string;
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
sourceType: "subscription-url";
|
sourceType: "subscription-url" | "master-shadowsocks";
|
||||||
|
preferredOutbound: "vless-reality" | "hysteria2" | null;
|
||||||
noProxy: readonly string[];
|
noProxy: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +200,7 @@ interface ControlPlaneConfig {
|
|||||||
targets: readonly ControlPlaneTargetSpec[];
|
targets: readonly ControlPlaneTargetSpec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runHwlabNodeControlPlaneInfra(args: string[]): Record<string, unknown> {
|
export function runHwlabNodeControlPlaneInfra(args: string[]): Record<string, unknown> | RenderedCliResult {
|
||||||
if (args[0] === "tools-image") {
|
if (args[0] === "tools-image") {
|
||||||
const options = parseToolsImageOptions(args.slice(1));
|
const options = parseToolsImageOptions(args.slice(1));
|
||||||
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
||||||
@@ -191,6 +211,11 @@ export function runHwlabNodeControlPlaneInfra(args: string[]): Record<string, un
|
|||||||
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
||||||
return runArgoCommand(config, node, target, options);
|
return runArgoCommand(config, node, target, options);
|
||||||
}
|
}
|
||||||
|
if (args[0] === "egress-benchmark") {
|
||||||
|
const options = parseEgressBenchmarkOptions(args.slice(1));
|
||||||
|
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
||||||
|
return runEgressBenchmarkCommand(config, node, target, options);
|
||||||
|
}
|
||||||
const options = parseInfraOptions(args);
|
const options = parseInfraOptions(args);
|
||||||
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
const { config, node, target } = controlPlaneContext(options.node, options.lane);
|
||||||
|
|
||||||
@@ -228,6 +253,8 @@ export function hwlabNodeControlPlaneInfraHelp(): Record<string, unknown> {
|
|||||||
"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 --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 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 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.",
|
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);
|
return argoApply(node, target, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runEgressBenchmarkCommand(_config: ControlPlaneConfig, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: EgressBenchmarkOptions): Record<string, unknown> | 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<string, unknown>): 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<string, unknown> {
|
function toolsImageCommandStatus(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, options: ToolsImageOptions): Record<string, unknown> {
|
||||||
const registry = toolsImageStatus(node, target, options.timeoutSeconds);
|
const registry = toolsImageStatus(node, target, options.timeoutSeconds);
|
||||||
const jobResult = runTransK3s(node.kubeRoute, remoteJobStatusScript(target, "tools-image", options.tailLines), 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,
|
buildNetwork: target.tekton.toolsImage.buildNetwork,
|
||||||
publicBaseImages: target.tekton.toolsImage.publicBaseImages,
|
publicBaseImages: target.tekton.toolsImage.publicBaseImages,
|
||||||
nodeLocalRegistryOutputOnly: true,
|
nodeLocalRegistryOutputOnly: true,
|
||||||
egressProxy: node.egressProxy,
|
egressProxy: controlPlaneEgressProxySummary(node.egressProxy),
|
||||||
stateDir: remoteJobStateDir(target, "tools-image"),
|
stateDir: remoteJobStateDir(target, "tools-image"),
|
||||||
};
|
};
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
@@ -536,7 +673,7 @@ function argoApply(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, o
|
|||||||
desired: manifestObjectSummary(desired),
|
desired: manifestObjectSummary(desired),
|
||||||
desiredSha256: sha256Short(desiredYaml),
|
desiredSha256: sha256Short(desiredYaml),
|
||||||
stateDir: remoteJobStateDir(target, "argo"),
|
stateDir: remoteJobStateDir(target, "argo"),
|
||||||
egressProxy: node.egressProxy,
|
egressProxy: controlPlaneEgressProxySummary(node.egressProxy),
|
||||||
};
|
};
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return {
|
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 {
|
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 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);
|
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<string, unknown>, path: string): ControlPlaneEgressProxySpec {
|
function egressProxySpec(raw: Record<string, unknown>, path: string): ControlPlaneEgressProxySpec {
|
||||||
const mode = stringField(raw, "mode", path);
|
const mode = stringField(raw, "mode", path);
|
||||||
if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be k8s-service-cluster-ip`);
|
if (mode !== "k8s-service-cluster-ip") throw new Error(`${path}.mode must be k8s-service-cluster-ip`);
|
||||||
const sourceType = stringField(raw, "sourceType", path);
|
const sourceConfigRef = optionalStringField(raw, "sourceConfigRef", path) ?? null;
|
||||||
if (sourceType !== "subscription-url") throw new Error(`${path}.sourceType must be subscription-url`);
|
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 {
|
return {
|
||||||
mode,
|
mode,
|
||||||
clientName: stringField(raw, "clientName", path),
|
clientName: stringField(raw, "clientName", path),
|
||||||
namespace: stringField(raw, "namespace", path),
|
namespace: stringField(raw, "namespace", path),
|
||||||
serviceName: stringField(raw, "serviceName", path),
|
serviceName: stringField(raw, "serviceName", path),
|
||||||
port: positiveConfigIntegerField(raw, "port", path),
|
port: positiveConfigIntegerField(raw, "port", path),
|
||||||
sourceRef: stringField(raw, "sourceRef", path),
|
sourceConfigRef,
|
||||||
sourceKey: stringField(raw, "sourceKey", path),
|
sourceFingerprint: source?.fingerprint ?? null,
|
||||||
|
sourceRef,
|
||||||
|
sourceKey,
|
||||||
sourceType,
|
sourceType,
|
||||||
|
preferredOutbound,
|
||||||
noProxy: stringArrayField(raw, "noProxy", path),
|
noProxy: stringArrayField(raw, "noProxy", path),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preferredOutboundField(raw: Record<string, unknown>, 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<string, unknown>, path: string): ControlPlaneGitMirrorEgressProxySpec {
|
function gitMirrorEgressProxySpec(raw: Record<string, unknown>, path: string): ControlPlaneGitMirrorEgressProxySpec {
|
||||||
const mode = stringField(raw, "mode", path);
|
const mode = stringField(raw, "mode", path);
|
||||||
if (mode !== "node-global" && mode !== "direct") throw new Error(`${path}.mode must be node-global or direct`);
|
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 proxyPort = useDirect || node.egressProxy === null ? 0 : node.egressProxy.port;
|
||||||
const proxyUrl = useDirect ? "" : `http://${proxyHost}:${proxyPort}`;
|
const proxyUrl = useDirect ? "" : `http://${proxyHost}:${proxyPort}`;
|
||||||
const noProxy = useDirect || node.egressProxy === null ? "" : node.egressProxy.noProxy.join(",");
|
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
|
const proxySummary = useDirect
|
||||||
? `git-mirror-egress-proxy mode=direct required=false transport=${transport.mode} source=yaml`
|
? `git-mirror-egress-proxy mode=direct required=false transport=${transport.mode} source=yaml`
|
||||||
: transport.mode === "https"
|
: 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=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 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 proxyCommand = useDirect ? "" : `ProxyCommand=node /tmp/hwlab-github-proxy-connect.cjs ${proxyHost} ${proxyPort} %h %p`;
|
||||||
const common = [
|
const common = [
|
||||||
`printf '%s\\n' ${shQuote(proxySummary)} >&2`,
|
`printf '%s\\n' ${shQuote(proxySummary)} >&2`,
|
||||||
@@ -1574,7 +1766,7 @@ function planSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec)
|
|||||||
runtimeNamespace: target.runtimeNamespace,
|
runtimeNamespace: target.runtimeNamespace,
|
||||||
k3sNodeConfig: k3sNodeConfigPlan(node),
|
k3sNodeConfig: k3sNodeConfigPlan(node),
|
||||||
registry: node.registry.endpoint,
|
registry: node.registry.endpoint,
|
||||||
egressProxy: node.egressProxy,
|
egressProxy: controlPlaneEgressProxySummary(node.egressProxy),
|
||||||
sourceBranch: target.source.branch,
|
sourceBranch: target.source.branch,
|
||||||
gitopsBranch: target.gitops.branch,
|
gitopsBranch: target.gitops.branch,
|
||||||
gitopsPath: target.gitops.path,
|
gitopsPath: target.gitops.path,
|
||||||
@@ -1615,6 +1807,7 @@ function expectedSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetS
|
|||||||
deploymentReplicas: target.gitMirror.deploymentReplicas,
|
deploymentReplicas: target.gitMirror.deploymentReplicas,
|
||||||
syncConfigMap: target.gitMirror.syncConfigMapName,
|
syncConfigMap: target.gitMirror.syncConfigMapName,
|
||||||
egressProxy: target.gitMirror.egressProxy,
|
egressProxy: target.gitMirror.egressProxy,
|
||||||
|
effectiveEgressProxy: gitMirrorEffectiveEgressProxySummary(node, target),
|
||||||
githubTransport: gitMirrorGithubTransportSummary(target.gitMirror.githubTransport),
|
githubTransport: gitMirrorGithubTransportSummary(target.gitMirror.githubTransport),
|
||||||
statusSummaryKeys: ["localSource", "githubSource", "localGitops", "githubGitops", "pendingFlush", "flushNeeded", "githubInSync"],
|
statusSummaryKeys: ["localSource", "githubSource", "localGitops", "githubGitops", "pendingFlush", "flushNeeded", "githubInSync"],
|
||||||
},
|
},
|
||||||
@@ -1670,6 +1863,45 @@ function k3sDropInContent(spec: ControlPlaneK3sNodeSpec): string {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function controlPlaneEgressProxySummary(proxy: ControlPlaneEgressProxySpec | null): Record<string, unknown> | 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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
function gitMirrorGithubTransportSummary(transport: ControlPlaneGitMirrorGithubTransportSpec): Record<string, unknown> {
|
||||||
if (transport.mode === "ssh") return { mode: "ssh", valuesPrinted: false };
|
if (transport.mode === "ssh") return { mode: "ssh", valuesPrinted: false };
|
||||||
return {
|
return {
|
||||||
@@ -1694,6 +1926,7 @@ function statusScript(nodeSpec: ControlPlaneNodeSpec, target: ControlPlaneTarget
|
|||||||
const argoStatefulSets = shellJsonArray(target.argo.install.expectedStatefulSets);
|
const argoStatefulSets = shellJsonArray(target.argo.install.expectedStatefulSets);
|
||||||
const k3s = nodeSpec.k3s;
|
const k3s = nodeSpec.k3s;
|
||||||
const k3sDropIn = k3s === null ? "" : k3sDropInContent(k3s);
|
const k3sDropIn = k3s === null ? "" : k3sDropInContent(k3s);
|
||||||
|
const gitMirrorEgressProxyJson = JSON.stringify(gitMirrorEffectiveEgressProxySummary(nodeSpec, target));
|
||||||
return `
|
return `
|
||||||
set +e
|
set +e
|
||||||
node=${shQuote(target.node)}
|
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_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_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 : "")}
|
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)}
|
pipeline=${shQuote(target.tekton.pipelineName)}
|
||||||
service_account=${shQuote(target.tekton.serviceAccountName)}
|
service_account=${shQuote(target.tekton.serviceAccountName)}
|
||||||
argo_ns=${shQuote(target.argo.namespace)}
|
argo_ns=${shQuote(target.argo.namespace)}
|
||||||
@@ -1845,7 +2079,7 @@ print(json.dumps({"crds": crds, "deployments": deploy, "statefulSets": sts, "crd
|
|||||||
PY
|
PY
|
||||||
argo_fragment=$(cat /tmp/hwlab-node-status-fragments.json 2>/dev/null || printf '{}')
|
argo_fragment=$(cat /tmp/hwlab-node-status-fragments.json 2>/dev/null || printf '{}')
|
||||||
cat <<JSON
|
cat <<JSON
|
||||||
{"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,"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
|
JSON
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -2509,6 +2743,23 @@ function record(value: unknown): Record<string, unknown> {
|
|||||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string, unknown>, key: string, path: string): string {
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
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;
|
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);
|
const index = args.indexOf(name);
|
||||||
if (index === -1) return defaultValue;
|
if (index === -1) return null;
|
||||||
const raw = args[index + 1];
|
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);
|
const value = Number(raw);
|
||||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
||||||
return Math.min(value, maxValue);
|
return Math.min(value, maxValue);
|
||||||
|
|||||||
@@ -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<Record<string, unknown> | 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<string, unknown>): 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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, fallback = "-"): string {
|
||||||
|
if (value === undefined || value === null || value === "") return fallback;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ export function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record<stri
|
|||||||
image: proxy.image,
|
image: proxy.image,
|
||||||
imagePullPolicy: proxy.imagePullPolicy,
|
imagePullPolicy: proxy.imagePullPolicy,
|
||||||
listenPort: proxy.listenPort,
|
listenPort: proxy.listenPort,
|
||||||
|
sourceConfigRef: proxy.sourceConfigRef,
|
||||||
|
sourceFingerprint: proxy.sourceFingerprint,
|
||||||
sourceRef: proxy.sourceRef,
|
sourceRef: proxy.sourceRef,
|
||||||
sourceType: proxy.sourceType,
|
sourceType: proxy.sourceType,
|
||||||
preferredOutbound: proxy.preferredOutbound,
|
preferredOutbound: proxy.preferredOutbound,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
|
|||||||
import { dirname, isAbsolute, join } from "node:path";
|
import { dirname, isAbsolute, join } from "node:path";
|
||||||
import type { UniDeskConfig } from "../config";
|
import type { UniDeskConfig } from "../config";
|
||||||
import { rootPath } from "../config";
|
import { rootPath } from "../config";
|
||||||
|
import { resolveEgressProxySourceRef } from "../egress-proxy-sources";
|
||||||
import { startJob } from "../jobs";
|
import { startJob } from "../jobs";
|
||||||
import type { RenderedCliResult } from "../output";
|
import type { RenderedCliResult } from "../output";
|
||||||
import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "../pk01-caddy";
|
import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "../pk01-caddy";
|
||||||
@@ -282,12 +283,16 @@ export function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgr
|
|||||||
if (value === undefined || value === null) return null;
|
if (value === undefined || value === null) return null;
|
||||||
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.egressProxy must be an object`);
|
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.egressProxy must be an object`);
|
||||||
const record = value as Record<string, unknown>;
|
const record = value as Record<string, unknown>;
|
||||||
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
|
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;
|
: null;
|
||||||
|
if (source !== null && source.preferredOutbound !== null && record.preferredOutbound !== undefined) assertOptionalMatch(record, "preferredOutbound", source.preferredOutbound, `${path}.egressProxy`);
|
||||||
const masterShadowsocks = sourceType === "master-shadowsocks"
|
const masterShadowsocks = sourceType === "master-shadowsocks"
|
||||||
? parseMasterShadowsocksConfig(record.masterShadowsocks, `${path}.egressProxy.masterShadowsocks`)
|
? source?.masterShadowsocks ?? parseMasterShadowsocksConfig(record.masterShadowsocks, `${path}.egressProxy.masterShadowsocks`)
|
||||||
: null;
|
: null;
|
||||||
const imagePullPolicy = enumField(record, "imagePullPolicy", `${path}.egressProxy`, ["Always", "IfNotPresent", "Never"] as const);
|
const imagePullPolicy = enumField(record, "imagePullPolicy", `${path}.egressProxy`, ["Always", "IfNotPresent", "Never"] as const);
|
||||||
const noProxy = stringArrayField(record, "noProxy", `${path}.egressProxy`);
|
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`),
|
image: stringField(record, "image", `${path}.egressProxy`),
|
||||||
imagePullPolicy,
|
imagePullPolicy,
|
||||||
listenPort: integerField(record, "listenPort", `${path}.egressProxy`),
|
listenPort: integerField(record, "listenPort", `${path}.egressProxy`),
|
||||||
sourceRef: stringField(record, "sourceRef", `${path}.egressProxy`),
|
sourceConfigRef,
|
||||||
sourceKey: stringField(record, "sourceKey", `${path}.egressProxy`),
|
sourceFingerprint: source?.fingerprint ?? null,
|
||||||
|
sourceRef: source?.sourceRef ?? stringField(record, "sourceRef", `${path}.egressProxy`),
|
||||||
|
sourceKey: source?.sourceKey ?? stringField(record, "sourceKey", `${path}.egressProxy`),
|
||||||
sourceType,
|
sourceType,
|
||||||
preferredOutbound,
|
preferredOutbound,
|
||||||
masterShadowsocks,
|
masterShadowsocks,
|
||||||
noProxy,
|
noProxy,
|
||||||
applyToSub2Api: booleanField(record, "applyToSub2Api", `${path}.egressProxy`),
|
applyToSub2Api: booleanField(record, "applyToSub2Api", `${path}.egressProxy`),
|
||||||
applyToSentinel: booleanField(record, "applyToSentinel", `${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);
|
validateEgressProxyConfig(proxy, path);
|
||||||
return proxy;
|
return proxy;
|
||||||
}
|
}
|
||||||
@@ -406,6 +418,19 @@ export function stringArrayField(obj: Record<string, unknown>, key: string, path
|
|||||||
return value.map((item) => item.trim());
|
return value.map((item) => item.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalConfigStringField(obj: Record<string, unknown>, 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<string, unknown>, 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<const T extends readonly string[]>(obj: Record<string, unknown>, key: string, path: string, values: T): T[number] {
|
export function enumField<const T extends readonly string[]>(obj: Record<string, unknown>, key: string, path: string, values: T): T[number] {
|
||||||
const value = stringField(obj, key, path);
|
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(", ")}`);
|
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 (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`);
|
if (!isImageReference(config.image)) throw new Error(`${configPath}.${path}.egressProxy.image has an unsupported format`);
|
||||||
validatePort(config.listenPort, `${path}.egressProxy.listenPort`);
|
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.<id>`);
|
||||||
|
}
|
||||||
if (!/^[A-Za-z0-9_./-]+$/u.test(config.sourceRef)) throw new Error(`${configPath}.${path}.egressProxy.sourceRef has an unsupported format`);
|
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 (!/^[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) {
|
if (config.sourceType === "subscription-url" && config.preferredOutbound === null) {
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ export interface Sub2ApiEgressProxyConfig {
|
|||||||
image: string;
|
image: string;
|
||||||
imagePullPolicy: "Always" | "IfNotPresent" | "Never";
|
imagePullPolicy: "Always" | "IfNotPresent" | "Never";
|
||||||
listenPort: number;
|
listenPort: number;
|
||||||
|
sourceConfigRef: string | null;
|
||||||
|
sourceFingerprint: string | null;
|
||||||
sourceRef: string;
|
sourceRef: string;
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
sourceType: "subscription-url" | "master-shadowsocks";
|
sourceType: "subscription-url" | "master-shadowsocks";
|
||||||
|
|||||||
@@ -623,6 +623,8 @@ spec:
|
|||||||
annotations:
|
annotations:
|
||||||
unidesk.ai/proxy-source-ref: "${proxy.sourceRef}"
|
unidesk.ai/proxy-source-ref: "${proxy.sourceRef}"
|
||||||
unidesk.ai/proxy-source-type: "${proxy.sourceType}"
|
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"}"
|
unidesk.ai/proxy-selected-outbound: "${proxy.sourceType === "subscription-url" ? proxy.preferredOutbound : "shadowsocks"}"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function sub2ApiHelpTargetSummary(): Record<string, unknown> {
|
|||||||
|
|
||||||
export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||||
const [target, action] = args;
|
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") {
|
if (target === "langbot") {
|
||||||
const { runLangBotCommand } = await import("../platform-infra-langbot");
|
const { runLangBotCommand } = await import("../platform-infra-langbot");
|
||||||
return await runLangBotCommand(config, args.slice(1));
|
return await runLangBotCommand(config, args.slice(1));
|
||||||
@@ -109,7 +113,7 @@ function renderSub2ApiPlan(result: Record<string, unknown>): RenderedCliResult {
|
|||||||
["DATABASE", stringValue(config.databaseMode), "redis", stringValue(config.redisMode)],
|
["DATABASE", stringValue(config.databaseMode), "redis", stringValue(config.redisMode)],
|
||||||
["REPLICAS", `${stringValue(config.appReplicas)}/${stringValue(config.redisReplicas)}`, "service", stringValue(target.serviceDns)],
|
["REPLICAS", `${stringValue(config.appReplicas)}/${stringValue(config.redisReplicas)}`, "service", stringValue(target.serviceDns)],
|
||||||
["PUBLIC", boolText(exposure.enabled), "url", stringValue(exposure.publicBaseUrl, "-")],
|
["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"],
|
["POLICY", failedPolicy.length === 0 ? "ok" : `failed=${failedPolicy.length}`, "valuesPrinted", "false"],
|
||||||
];
|
];
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|||||||
Reference in New Issue
Block a user