// 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 `; }