328 lines
13 KiB
TypeScript
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
|
|
`;
|
|
}
|