376 lines
16 KiB
TypeScript
376 lines
16 KiB
TypeScript
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("'", "'\"'\"'")}'`;
|
|
}
|