Files
pikasTech-unidesk/scripts/src/platform-infra-observability/search-script.ts
T
2026-06-25 16:16:25 +00:00

328 lines
13 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. search-script module for scripts/src/platform-infra-observability.ts.
// Moved mechanically from scripts/src/platform-infra-observability.ts:1814-2136 for #903.
// SPEC: PJ2026-01060501 OTel追踪 draft-2026-06-19-p0.
// Responsibility: YAML-first platform-infra OpenTelemetry tracing control commands.
import { Buffer } from "node:buffer";
import { readFileSync } from "node:fs";
import type { UniDeskConfig } from "../config";
import { rootPath } from "../config";
import type { RenderedCliResult } from "../output";
import {
compactCapture,
createYamlFieldReader,
numberField,
parseJsonOutput,
redactSensitiveUnknown,
shQuote,
capture,
} from "../platform-infra-ops-library";
import type { ObservabilityConfig, ObservabilityTarget } from "./types";
import { fieldManager } from "./types";
export function tempoService(observability: ObservabilityConfig, target: ObservabilityTarget): string {
return `apiVersion: v1
kind: Service
metadata:
name: ${observability.traceBackend.serviceName}
namespace: ${target.namespace}
labels:
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
app.kubernetes.io/component: trace-backend
app.kubernetes.io/part-of: platform-infra
app.kubernetes.io/managed-by: unidesk
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
app.kubernetes.io/component: trace-backend
ports:
- name: http
port: ${observability.traceBackend.httpPort}
targetPort: http
- name: otlp-grpc
port: ${observability.traceBackend.otlp.grpcPort}
targetPort: otlp-grpc
- name: otlp-http
port: ${observability.traceBackend.otlp.httpPort}
targetPort: otlp-http
`;
}
export function applyScript(params: {
yaml: string;
target: ObservabilityTarget;
dryRun: boolean;
wait: boolean;
collectorDeploymentName: string;
backendDeploymentName: string;
}): string {
const encoded = Buffer.from(params.yaml, "utf8").toString("base64");
const dryRunArg = params.dryRun ? "--dry-run=server" : "";
const waitDisposition = params.wait ? "skipped-short-connection-use-status" : "skipped";
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
manifest="$tmp/platform-infra-observability.yaml"
printf '%s' '${encoded}' | base64 -d > "$manifest"
kubectl apply --server-side --field-manager=${fieldManager} ${dryRunArg} -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
apply_rc=$?
collector_rollout_rc=0
backend_rollout_rc=0
wait_disposition=${waitDisposition}
python3 - "$apply_rc" "$collector_rollout_rc" "$backend_rollout_rc" "$wait_disposition" "$tmp/apply.out" "$tmp/apply.err" "$tmp/collector-rollout.out" "$tmp/collector-rollout.err" "$tmp/backend-rollout.out" "$tmp/backend-rollout.err" <<'PY'
import json, os, sys
def text(path, limit=6000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
apply_rc = int(sys.argv[1])
collector_rc = int(sys.argv[2])
backend_rc = int(sys.argv[3])
payload = {
"ok": apply_rc == 0 and collector_rc == 0 and backend_rc == 0,
"target": "${params.target.id}",
"namespace": "${params.target.namespace}",
"dryRun": ${params.dryRun ? "True" : "False"},
"apply": {"exitCode": apply_rc, "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])},
"rollout": {
"disposition": sys.argv[4],
"collector": {"exitCode": collector_rc, "stdout": text(sys.argv[7]), "stderr": text(sys.argv[8])},
"backend": {"exitCode": backend_rc, "stdout": text(sys.argv[9]), "stderr": text(sys.argv[10])},
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function statusScript(observability: ObservabilityConfig, target: ObservabilityTarget, full: boolean, generateValidationTrace = false): string {
const endpointsJson = JSON.stringify(observability.probes.statusEndpoints);
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
capture_json() {
name="$1"
shift
"$@" >"$tmp/$name.json" 2>"$tmp/$name.err"
echo $? >"$tmp/$name.rc"
}
capture_raw() {
name="$1"
shift
"$@" >"$tmp/$name.out" 2>"$tmp/$name.err"
echo $? >"$tmp/$name.rc"
}
capture_json namespace kubectl get namespace ${shQuote(target.namespace)} -o json
capture_json deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.collector.deploymentName)} ${shQuote(observability.traceBackend.deploymentName)} -o json
capture_json services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.collector.serviceName)} ${shQuote(observability.traceBackend.serviceName)} -o json
capture_json pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name in (${observability.collector.deploymentName},${observability.traceBackend.deploymentName})`)} -o json
capture_json events kubectl -n ${shQuote(target.namespace)} get events -o json
python3 - "$tmp" '${endpointsJson.replaceAll("'", "'\"'\"'")}' <<'PY'
import json, random, socket, subprocess, sys, time, urllib.error, urllib.request
tmp = sys.argv[1]
endpoints = json.loads(sys.argv[2])
namespace = "${target.namespace}"
def read(path, binary=False, limit=8000):
try:
mode = "rb" if binary else "r"
with open(path, mode, encoding=None if binary else "utf-8", errors=None if binary else "replace") as fh:
data = fh.read()
if binary:
data = data.decode("utf-8", errors="replace")
return data[-limit:]
except FileNotFoundError:
return ""
def rc(name):
try:
return int(read(f"{tmp}/{name}.rc").strip() or "1")
except ValueError:
return 1
def parsed_json(name):
raw = read(f"{tmp}/{name}.json", limit=1000000)
try:
return json.loads(raw) if raw else None
except Exception:
return None
def compact_section(name):
return {
"exitCode": rc(name),
"stderrTail": read(f"{tmp}/{name}.err", limit=2000),
"json": parsed_json(name),
}
probe_results = []
for ep in endpoints:
path = ep.get("path") or "/"
if not path.startswith("/"):
path = "/" + path
proxy_path = f"/api/v1/namespaces/{namespace}/services/http:{ep['service']}:{ep['portName']}/proxy{path}"
proc = subprocess.run(["kubectl", "get", "--raw", proxy_path], text=True, capture_output=True, timeout=20)
probe_results.append({
"name": ep["name"],
"service": ep["service"],
"portName": ep["portName"],
"path": path,
"exitCode": proc.returncode,
"stdoutTail": proc.stdout[-1000:],
"stderrTail": proc.stderr[-1000:],
"ok": proc.returncode == 0,
})
def free_port():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def parse_body(raw):
try:
return json.loads(raw) if raw else None
except Exception:
return raw[-2000:]
def http_request(method, url, body=None, timeout=10):
data = None
headers = {}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read(200000).decode("utf-8", errors="replace")
status = int(getattr(resp, "status", 0) or 0)
return {"ok": 200 <= status < 300, "status": status, "body": parse_body(raw)}
except urllib.error.HTTPError as exc:
raw = exc.read(200000).decode("utf-8", errors="replace")
return {"ok": False, "status": exc.code, "body": parse_body(raw)}
except Exception as exc:
return {"ok": False, "error": type(exc).__name__ + ": " + str(exc)}
def wait_proxy(base_url):
last = None
for _ in range(30):
last = http_request("GET", base_url + "/version", timeout=1)
if last.get("ok") is True:
return last
time.sleep(0.2)
return last or {"ok": False, "error": "kubectl proxy did not answer"}
def compact_http(result):
if not isinstance(result, dict):
return {"ok": False, "error": "missing-result"}
compact = {"ok": result.get("ok") is True}
if "status" in result:
compact["status"] = result.get("status")
if "error" in result:
compact["error"] = result.get("error")
body = result.get("body")
if isinstance(body, dict):
compact["bodyKeys"] = sorted(list(body.keys()))[:12]
elif isinstance(body, str) and body:
compact["bodyTail"] = body[-1000:]
return compact
def validation_trace_payload(trace_id, span_id):
now = time.time_ns()
return {
"resourceSpans": [{
"resource": {"attributes": [
{"key": "service.name", "value": {"stringValue": "unidesk-observability-validate"}},
{"key": "deployment.environment", "value": {"stringValue": "${target.id}"}},
{"key": "unidesk.node", "value": {"stringValue": "${target.id}"}},
{"key": "k8s.namespace.name", "value": {"stringValue": namespace}},
]},
"scopeSpans": [{
"scope": {"name": "unidesk-cli-platform-infra-observability"},
"spans": [{
"traceId": trace_id,
"spanId": span_id,
"name": "unidesk.observability.validate",
"kind": 1,
"startTimeUnixNano": str(now),
"endTimeUnixNano": str(now + 1000000),
"attributes": [
{"key": "unidesk.issue", "value": {"stringValue": "489"}},
{"key": "unidesk.spec", "value": {"stringValue": "${observability.metadata.spec}"}},
{"key": "unidesk.validation", "value": {"stringValue": "platform-infra-observability"}},
],
"status": {"code": 1},
}],
}],
}],
}
def generate_test_trace():
trace_id = ("%032x" % random.getrandbits(128))
span_id = ("%016x" % random.getrandbits(64))
port = free_port()
base_url = "http://127.0.0.1:%s" % port
stdout_path = f"{tmp}/kubectl-proxy.out"
stderr_path = f"{tmp}/kubectl-proxy.err"
with open(stdout_path, "w", encoding="utf-8") as stdout, open(stderr_path, "w", encoding="utf-8") as stderr:
proc = subprocess.Popen(
["kubectl", "proxy", "--address=127.0.0.1", "--port", str(port), "--accept-hosts=^127\\\\.0\\\\.0\\\\.1$"],
stdout=stdout,
stderr=stderr,
text=True,
)
try:
proxy_ready = wait_proxy(base_url)
if proxy_ready.get("ok") is not True:
return {
"ok": False,
"stage": "proxy-start",
"traceId": trace_id,
"proxy": compact_http(proxy_ready),
"proxyStderrTail": read(stderr_path, limit=2000),
}
collector_url = base_url + "/api/v1/namespaces/%s/services/http:${observability.collector.serviceName}:otlp-http/proxy/v1/traces" % namespace
tempo_url = base_url + "/api/v1/namespaces/%s/services/http:${observability.traceBackend.serviceName}:http/proxy/api/traces/%s" % (namespace, trace_id)
ingest = http_request("POST", collector_url, validation_trace_payload(trace_id, span_id), timeout=15)
query = None
if ingest.get("ok") is True:
for _ in range(20):
query = http_request("GET", tempo_url, timeout=10)
if query.get("ok") is True:
break
time.sleep(1)
return {
"ok": ingest.get("ok") is True and isinstance(query, dict) and query.get("ok") is True,
"stage": "query" if ingest.get("ok") is True else "ingest",
"traceId": trace_id,
"spanId": span_id,
"collectorEndpoint": "${observability.collector.serviceName}:otlp-http",
"backendEndpoint": "${observability.traceBackend.serviceName}:http",
"ingest": compact_http(ingest),
"query": compact_http(query),
"valuesPrinted": False,
}
finally:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=3)
runtime_ready = rc("namespace") == 0 and rc("deployments") == 0 and rc("services") == 0 and all(item["ok"] for item in probe_results)
validation_trace = generate_test_trace() if (${generateValidationTrace ? "True" : "False"} and runtime_ready) else None
payload = {
"ok": runtime_ready and (validation_trace is None or validation_trace.get("ok") is True),
"target": "${target.id}",
"namespace": namespace,
"sections": {
"namespace": compact_section("namespace"),
"deployments": compact_section("deployments"),
"services": compact_section("services"),
"pods": compact_section("pods"),
"events": compact_section("events"),
},
"probes": probe_results,
"validationTrace": validation_trace,
}
print(json.dumps(payload, ensure_ascii=False, indent=2 if ${full ? "True" : "False"} else None))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}