refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. actions module for scripts/src/platform-infra-observability.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-observability.ts:515-623 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 { ApplyOptions, CommonOptions, ObservabilityConfig, ObservabilityTarget, StatusEndpoint } from "./types";
|
||||
import { readObservabilityConfig } from "./config";
|
||||
import { applyScript, statusScript } from "./search-script";
|
||||
import { compactStatus, configSummary, manifestObjectSummary, policyChecks, statusSummary, targetSummary } from "./summary";
|
||||
import { renderManifest } from "./trace-script";
|
||||
import { apiPathField, configLabel, kubernetesNameField, stringField } from "./types";
|
||||
|
||||
export function parseStatusEndpoint(record: Record<string, unknown>, index: number): StatusEndpoint {
|
||||
const path = `probes.statusEndpoints[${index}]`;
|
||||
return {
|
||||
name: stringField(record, "name", path),
|
||||
service: kubernetesNameField(record, "service", path),
|
||||
portName: stringField(record, "portName", path),
|
||||
path: apiPathField(record, "path", path),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertKnownEnabledTarget(targets: ObservabilityTarget[], targetId: string, path: string): void {
|
||||
const target = targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase());
|
||||
if (target === undefined) throw new Error(`${configLabel}.${path} references unknown target ${targetId}; known targets: ${targets.map((item) => item.id).join(", ")}`);
|
||||
if (!target.enabled) throw new Error(`${configLabel}.${path} references disabled target ${target.id}`);
|
||||
}
|
||||
|
||||
export function resolveTarget(observability: ObservabilityConfig, targetId: string | null): ObservabilityTarget {
|
||||
const resolved = targetId ?? observability.defaults.targetId;
|
||||
const target = observability.targets.find((item) => item.id.toLowerCase() === resolved.toLowerCase());
|
||||
if (target === undefined) throw new Error(`unknown observability target ${resolved}; known targets: ${observability.targets.map((item) => item.id).join(", ")}`);
|
||||
if (!target.enabled) throw new Error(`observability target ${target.id} is disabled in ${configLabel}`);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function plan(options: CommonOptions): Record<string, unknown> {
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const yaml = renderManifest(observability, target);
|
||||
const policy = policyChecks(yaml, target);
|
||||
return {
|
||||
ok: policy.every((check) => check.ok),
|
||||
action: "platform-infra-observability-plan",
|
||||
mutation: false,
|
||||
config: configSummary(observability, target),
|
||||
renderPlan: {
|
||||
target: targetSummary(target),
|
||||
objects: manifestObjectSummary(yaml),
|
||||
otlp: {
|
||||
collectorGrpcEndpoint: `${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.grpcPort}`,
|
||||
collectorHttpEndpoint: `http://${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.httpPort}`,
|
||||
backendGrpcEndpoint: `${observability.traceBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.traceBackend.otlp.grpcPort}`,
|
||||
},
|
||||
instrumentation: observability.instrumentation.serviceConnections,
|
||||
resourceAttributes: observability.resourceAttributes,
|
||||
},
|
||||
policy,
|
||||
next: {
|
||||
dryRun: `bun scripts/cli.ts platform-infra observability apply --target ${target.id} --dry-run`,
|
||||
apply: `bun scripts/cli.ts platform-infra observability apply --target ${target.id} --confirm`,
|
||||
status: `bun scripts/cli.ts platform-infra observability status --target ${target.id}`,
|
||||
validate: `bun scripts/cli.ts platform-infra observability validate --target ${target.id}`,
|
||||
trace: `bun scripts/cli.ts platform-infra observability trace --target ${target.id} --trace-id <traceId>`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const yaml = renderManifest(observability, target);
|
||||
const policy = policyChecks(yaml, target);
|
||||
if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-observability-apply", mode: "policy-blocked", policy };
|
||||
const result = await capture(config, target.route, ["sh"], applyScript({
|
||||
yaml,
|
||||
target,
|
||||
dryRun: options.dryRun,
|
||||
wait: options.wait,
|
||||
collectorDeploymentName: observability.collector.deploymentName,
|
||||
backendDeploymentName: observability.traceBackend.deploymentName,
|
||||
}));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
return {
|
||||
ok: result.exitCode === 0 && parsed?.ok === true,
|
||||
action: "platform-infra-observability-apply",
|
||||
mode: options.dryRun ? "dry-run" : "confirmed",
|
||||
mutation: !options.dryRun,
|
||||
target: targetSummary(target),
|
||||
policy,
|
||||
remote: parsed ?? compactCapture(result, { full: true }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const result = await capture(config, target.route, ["sh"], statusScript(observability, target, options.full));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const summary = parsed === null ? null : statusSummary(parsed);
|
||||
return {
|
||||
ok: result.exitCode === 0 && summary?.ready === true,
|
||||
action: "platform-infra-observability-status",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
summary,
|
||||
remote: options.raw ? parsed : compactStatus(parsed, options.full) ?? compactCapture(result, { full: true }),
|
||||
next: {
|
||||
plan: `bun scripts/cli.ts platform-infra observability plan --target ${target.id}`,
|
||||
apply: `bun scripts/cli.ts platform-infra observability apply --target ${target.id} --confirm`,
|
||||
validate: `bun scripts/cli.ts platform-infra observability validate --target ${target.id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function validate(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const result = await capture(config, target.route, ["sh"], statusScript(observability, target, options.full, true));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const summary = parsed === null ? null : statusSummary(parsed);
|
||||
const ready = summary?.ready === true;
|
||||
const validationTrace = parsed !== null && typeof parsed.validationTrace === "object" && parsed.validationTrace !== null && !Array.isArray(parsed.validationTrace)
|
||||
? parsed.validationTrace as Record<string, unknown>
|
||||
: null;
|
||||
const validationTraceId = typeof validationTrace?.traceId === "string" ? validationTrace.traceId : null;
|
||||
const validationTraceOk = validationTrace?.ok === true;
|
||||
return {
|
||||
ok: result.exitCode === 0 && ready && validationTraceOk,
|
||||
action: "platform-infra-observability-validate",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
summary,
|
||||
validation: {
|
||||
readiness: ready ? "passed" : "failed",
|
||||
testTrace: validationTrace ?? "not-generated-runtime-not-ready",
|
||||
traceQuery: validationTraceId !== null
|
||||
? `bun scripts/cli.ts platform-infra observability trace --target ${target.id} --trace-id ${validationTraceId}`
|
||||
: "blocked-until-validation-trace-generated",
|
||||
metricsBoundary: "Prometheus/RUM remains outside this trace readiness check.",
|
||||
},
|
||||
remote: options.raw ? parsed : compactStatus(parsed, options.full) ?? compactCapture(result, { full: true }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. apply-status-scripts module for scripts/src/platform-infra-observability.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-observability.ts:1300-1574 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 { isoFromUnixNano, shortenEnd, textValue } from "./manifest";
|
||||
import { trace } from "./render";
|
||||
import { asArray, limitArray } from "./trace-script";
|
||||
|
||||
export function compactSearchTrace(value: unknown): Record<string, unknown> {
|
||||
const trace = asPlainRecord(value) ?? {};
|
||||
const meta = compactMetaRecord(trace.meta);
|
||||
const startTimeUnixNano = trace.startTimeUnixNano ?? meta?.startTimeUnixNano ?? null;
|
||||
return {
|
||||
traceId: trace.traceId ?? null,
|
||||
startAt: isoFromUnixNano(startTimeUnixNano),
|
||||
durationMs: trace.durationMs ?? meta?.durationMs ?? null,
|
||||
traceCommand: trace.traceCommand ?? null,
|
||||
grepCommand: trace.grepCommand ?? null,
|
||||
meta,
|
||||
queryOk: trace.queryOk ?? null,
|
||||
parseOk: trace.parseOk ?? null,
|
||||
rawMatched: trace.rawMatched ?? null,
|
||||
matchingActive: trace.matchingActive ?? null,
|
||||
bodyBytes: trace.bodyBytes ?? null,
|
||||
spanCount: trace.spanCount ?? null,
|
||||
serviceCount: trace.serviceCount ?? null,
|
||||
services: trace.services ?? null,
|
||||
businessTraceIds: trace.businessTraceIds ?? null,
|
||||
errorSpanCount: trace.errorSpanCount ?? null,
|
||||
matchedSpanCount: trace.matchedSpanCount ?? null,
|
||||
spanNameCounts: compactNameCounts(trace.spanNameCounts, 6),
|
||||
errorSpanNames: compactSpanNames(trace.errorSpans, 5),
|
||||
matchedSpanNames: compactSpanNames(trace.matchedSpans, 8),
|
||||
stderrTail: trace.stderrTail ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function compactDiagnoseCodeAgentResult(value: unknown): Record<string, unknown> {
|
||||
const source = asPlainRecord(value) ?? {};
|
||||
const mapping = asPlainRecord(source.mapping);
|
||||
const evidence = asPlainRecord(source.evidence);
|
||||
return {
|
||||
ok: source.ok === true,
|
||||
mapping: mapping === null ? null : {
|
||||
mode: mapping.mode ?? null,
|
||||
businessTraceId: mapping.businessTraceId ?? null,
|
||||
otelTraceId: mapping.otelTraceId ?? null,
|
||||
searchOk: mapping.searchOk ?? null,
|
||||
searchParseOk: mapping.searchParseOk ?? null,
|
||||
candidateTraceCount: mapping.candidateTraceCount ?? null,
|
||||
scannedCandidateCount: mapping.scannedCandidateCount ?? null,
|
||||
candidateSelectionMode: mapping.candidateSelectionMode ?? null,
|
||||
selectedScore: mapping.selectedScore ?? null,
|
||||
selectedQuality: mapping.selectedQuality ?? null,
|
||||
selectedLowConfidence: mapping.selectedLowConfidence ?? null,
|
||||
selectedRejectedReason: mapping.selectedRejectedReason ?? null,
|
||||
selectedReasons: limitArray(mapping.selectedReasons, 4),
|
||||
selectedMeta: compactMetaRecord(mapping.selectedMeta),
|
||||
bestCandidate: asArray(mapping.candidatePreview).slice(0, 1).map(compactDiagnoseCandidate)[0] ?? null,
|
||||
searchStderrTail: mapping.searchStderrTail ?? null,
|
||||
},
|
||||
tracePath: source.tracePath ?? null,
|
||||
bodyBytes: source.bodyBytes ?? null,
|
||||
traceParseOk: source.traceParseOk ?? null,
|
||||
spanCount: source.spanCount ?? null,
|
||||
services: source.services ?? null,
|
||||
servicePath: source.servicePath ?? null,
|
||||
businessTraceIds: source.businessTraceIds ?? null,
|
||||
identity: compactDiagnoseIdentity(source.identity),
|
||||
agentrun: compactDiagnoseAgentRun(source.agentrun),
|
||||
hwlabReadModel: source.hwlabReadModel ?? null,
|
||||
http: compactDiagnoseHttp(source.http),
|
||||
projectionLag: source.projectionLag ?? null,
|
||||
summary: source.summary ?? null,
|
||||
rootCauseCandidates: asArray(source.rootCauseCandidates).slice(0, 3).map(compactRootCauseCandidate),
|
||||
spanNameCounts: compactNameCounts(source.spanNameCounts, 6),
|
||||
evidence: evidence === null ? null : {
|
||||
httpProblemSpanCount: evidence.httpProblemSpanCount ?? null,
|
||||
terminalSpanCount: evidence.terminalSpanCount ?? null,
|
||||
turnStatusReadSpanCount: evidence.turnStatusReadSpanCount ?? null,
|
||||
projectionSpanCount: evidence.projectionSpanCount ?? null,
|
||||
errorSpanCount: evidence.errorSpanCount ?? null,
|
||||
errorSpanSampleNames: evidence.errorSpanSampleNames ?? null,
|
||||
idleWarningSpanCount: evidence.idleWarningSpanCount ?? null,
|
||||
idleWarningSpanTail: compactSpanList(evidence.idleWarningSpanTail, 1),
|
||||
},
|
||||
next: compactDiagnoseNext(source.next),
|
||||
stderrTail: source.stderrTail ?? null,
|
||||
disclosure: {
|
||||
defaultView: "compact diagnosis; --full is bounded JSON, not a span dump",
|
||||
expand: "use trace --grep <span> --full for span evidence; use diagnose-code-agent --raw only for parser/envelope debugging",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function compactDiagnoseIdentity(value: unknown): Record<string, unknown> | null {
|
||||
return compactRecord(value, [
|
||||
"runId",
|
||||
"commandId",
|
||||
"sessionId",
|
||||
"turnId",
|
||||
"runnerJobId",
|
||||
"runnerId",
|
||||
"backendProfile",
|
||||
"sourceCommit",
|
||||
"jobName",
|
||||
"namespace",
|
||||
]);
|
||||
}
|
||||
|
||||
export function compactDiagnoseHttp(value: unknown): Record<string, unknown> | null {
|
||||
const http = asPlainRecord(value);
|
||||
if (http === null) return null;
|
||||
return {
|
||||
problemCounts: http.problemCounts ?? null,
|
||||
problemSpanCount: http.problemSpanCount ?? null,
|
||||
statusCounts: asArray(http.statusCounts).slice(0, 4),
|
||||
};
|
||||
}
|
||||
|
||||
export function compactDiagnoseAgentRun(value: unknown): Record<string, unknown> | null {
|
||||
const agentrun = asPlainRecord(value);
|
||||
if (agentrun === null) return null;
|
||||
return {
|
||||
terminalStatus: agentrun.terminalStatus ?? null,
|
||||
terminalSource: agentrun.terminalSource ?? null,
|
||||
latestSeq: agentrun.latestSeq ?? null,
|
||||
terminalEventType: agentrun.terminalEventType ?? null,
|
||||
runnerProviderClassification: agentrun.runnerProviderClassification ?? null,
|
||||
authority: compactAgentRunAuthority(agentrun.authority),
|
||||
};
|
||||
}
|
||||
|
||||
export function compactAgentRunAuthority(value: unknown): Record<string, unknown> | null {
|
||||
const authority = asPlainRecord(value);
|
||||
if (authority === null) return null;
|
||||
const result: Record<string, unknown> = {
|
||||
queried: authority.queried ?? null,
|
||||
namespace: authority.namespace ?? null,
|
||||
ok: authority.ok ?? null,
|
||||
commandOk: authority.commandOk ?? null,
|
||||
resultOk: authority.resultOk ?? null,
|
||||
eventsOk: authority.eventsOk ?? null,
|
||||
commandState: authority.commandState ?? null,
|
||||
commandStatus: authority.commandStatus ?? null,
|
||||
resultTerminalStatus: authority.resultTerminalStatus ?? null,
|
||||
resultStatus: authority.resultStatus ?? null,
|
||||
terminalStatus: authority.terminalStatus ?? null,
|
||||
terminalSource: authority.terminalSource ?? null,
|
||||
terminalEventSeq: authority.terminalEventSeq ?? null,
|
||||
terminalEventType: authority.terminalEventType ?? null,
|
||||
latestSeq: authority.latestSeq ?? null,
|
||||
eventCount: authority.eventCount ?? null,
|
||||
fallback: authority.fallback ?? null,
|
||||
error: authority.error ?? null,
|
||||
warnings: limitArray(authority.warnings, 3),
|
||||
};
|
||||
const ok = authority.ok === true && authority.commandOk === true && authority.resultOk === true && authority.eventsOk === true;
|
||||
if (!ok) {
|
||||
result.attempts = compactAgentRunAuthorityAttempts(authority.attempts);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function compactAgentRunAuthorityAttempts(value: unknown): unknown[] {
|
||||
return asArray(value).slice(0, 3).map((item) => {
|
||||
const attempt = asPlainRecord(item) ?? {};
|
||||
return {
|
||||
name: attempt.name ?? attempt.section ?? attempt.kind ?? null,
|
||||
ok: attempt.ok ?? null,
|
||||
status: attempt.status ?? null,
|
||||
exitCode: attempt.exitCode ?? null,
|
||||
url: attempt.url ?? null,
|
||||
error: shortenEnd(textValue(attempt.error), 240),
|
||||
stderrTail: shortenEnd(textValue(attempt.stderrTail ?? attempt.stderr), 500),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function compactDiagnoseCandidate(value: unknown): Record<string, unknown> {
|
||||
const candidate = asPlainRecord(value) ?? {};
|
||||
return {
|
||||
traceId: candidate.traceId ?? null,
|
||||
score: candidate.score ?? null,
|
||||
reasons: limitArray(candidate.reasons, 3),
|
||||
parseOk: candidate.parseOk ?? null,
|
||||
queryOk: candidate.queryOk ?? null,
|
||||
spanCount: candidate.spanCount ?? null,
|
||||
services: candidate.services ?? null,
|
||||
servicePath: candidate.servicePath ?? null,
|
||||
identity: compactDiagnoseIdentity(candidate.identity),
|
||||
terminalStatus: candidate.terminalStatus ?? null,
|
||||
errorSpanCount: candidate.errorSpanCount ?? null,
|
||||
candidateQuality: candidate.candidateQuality ?? null,
|
||||
lowConfidence: candidate.lowConfidence ?? null,
|
||||
spanNamePreview: limitArray(candidate.spanNamePreview, 4),
|
||||
};
|
||||
}
|
||||
|
||||
export function compactDiagnoseNext(value: unknown): Record<string, unknown> | null {
|
||||
const next = asPlainRecord(value);
|
||||
if (next === null) return null;
|
||||
return {
|
||||
diagnoseFull: next.diagnoseFull ?? null,
|
||||
traceSummary: next.traceSummary ?? null,
|
||||
traceReads: next.traceReads ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function compactRootCauseCandidate(value: unknown): Record<string, unknown> {
|
||||
const candidate = asPlainRecord(value) ?? {};
|
||||
return {
|
||||
code: candidate.code ?? null,
|
||||
label: candidate.label ?? null,
|
||||
confidence: candidate.confidence ?? null,
|
||||
summary: candidate.summary ?? null,
|
||||
evidenceCount: Array.isArray(candidate.evidence) ? candidate.evidence.length : candidate.evidence === undefined || candidate.evidence === null ? 0 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function compactSpanList(value: unknown, limit: number): unknown[] {
|
||||
return asArray(value).slice(0, limit).map((item) => {
|
||||
const span = asPlainRecord(item) ?? {};
|
||||
const attrs = asPlainRecord(span.attributes) ?? {};
|
||||
return {
|
||||
name: span.name ?? null,
|
||||
service: span.service ?? null,
|
||||
attributes: compactRecord(attrs, ["failureKind", "terminalStatus", "status", "eventType", "idleMs", "waitingFor", "lastEventLabel", "http.route", "http.status_code", "http.response.status_code", "workbench.session_id", "workbench.trace_id", "workbench.turn_id", "workbench.read_model.route", "workbench.read_model.count", "workbench.read_model.family", "workbench.read_model.status", "workbench.read_model.reason", "hwlab.http.stage", "hwlab.http.phase", "hwlab.http.phase.outcome", "hwlab.live_builds.service_id", "hwlab.live_builds.service_kind", "hwlab.live_builds.external", "hwlab.live_builds.deploy_manifest_status", "hwlab.live_builds.artifact_catalog_status"]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function compactSpanNames(value: unknown, limit: number): string[] {
|
||||
return asArray(value).slice(0, limit).map((item) => {
|
||||
const span = asPlainRecord(item);
|
||||
return String(span?.name ?? "<unknown>");
|
||||
});
|
||||
}
|
||||
|
||||
export function compactNameCounts(value: unknown, limit: number): unknown[] {
|
||||
return asArray(value).slice(0, limit).map((item) => {
|
||||
const record = asPlainRecord(item) ?? {};
|
||||
return { name: record.name ?? null, count: record.count ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
export function compactMetaRecord(value: unknown): Record<string, unknown> | null {
|
||||
return compactRecord(value, ["traceID", "traceId", "rootServiceName", "rootTraceName", "startTimeUnixNano", "durationMs"]);
|
||||
}
|
||||
|
||||
export function compactRecord(value: unknown, keys: string[]): Record<string, unknown> | null {
|
||||
const source = asPlainRecord(value);
|
||||
if (source === null) return null;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) result[key] = source[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function asPlainRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. config module for scripts/src/platform-infra-observability.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-observability.ts:370-514 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 { ImageSpec, ObservabilityConfig, ObservabilityTarget, OtlpPorts, ServiceConnection } from "./types";
|
||||
import { assertKnownEnabledTarget, parseStatusEndpoint } from "./actions";
|
||||
import { apiPathField, arrayOfRecords, asRecord, booleanField, configFile, configLabel, enumField, integerField, kubernetesNameField, numberArrayField, objectField, portField, stringArrayField, stringField } from "./types";
|
||||
|
||||
export function readObservabilityConfig(): ObservabilityConfig {
|
||||
const parsed = Bun.YAML.parse(readFileSync(configFile, "utf8")) as unknown;
|
||||
const root = asRecord(parsed, configLabel);
|
||||
const version = integerField(root, "version", "");
|
||||
const kind = stringField(root, "kind", "");
|
||||
if (kind !== "platform-infra-observability") throw new Error(`${configLabel}.kind must be platform-infra-observability`);
|
||||
const metadata = objectField(root, "metadata", "");
|
||||
const defaults = objectField(root, "defaults", "");
|
||||
const images = objectField(root, "images", "");
|
||||
const collector = objectField(root, "collector", "");
|
||||
const collectorOtlp = objectField(collector, "otlp", "collector");
|
||||
const traceBackend = objectField(root, "traceBackend", "");
|
||||
const traceBackendOtlp = objectField(traceBackend, "otlp", "traceBackend");
|
||||
const traceBackendStorage = objectField(traceBackend, "storage", "traceBackend");
|
||||
const sampling = objectField(root, "sampling", "");
|
||||
const instrumentation = objectField(root, "instrumentation", "");
|
||||
const resourceAttributes = objectField(root, "resourceAttributes", "");
|
||||
const probes = objectField(root, "probes", "");
|
||||
const config: ObservabilityConfig = {
|
||||
version,
|
||||
kind,
|
||||
metadata: {
|
||||
id: stringField(metadata, "id", "metadata"),
|
||||
owner: stringField(metadata, "owner", "metadata"),
|
||||
spec: stringField(metadata, "spec", "metadata"),
|
||||
relatedIssues: numberArrayField(metadata, "relatedIssues", "metadata"),
|
||||
},
|
||||
defaults: { targetId: stringField(defaults, "targetId", "defaults") },
|
||||
images: {
|
||||
collector: imageSpec(objectField(images, "collector", "images"), "images.collector"),
|
||||
tempo: imageSpec(objectField(images, "tempo", "images"), "images.tempo"),
|
||||
},
|
||||
targets: arrayOfRecords(root.targets, "targets").map(parseTarget),
|
||||
collector: {
|
||||
deploymentName: kubernetesNameField(collector, "deploymentName", "collector"),
|
||||
serviceName: kubernetesNameField(collector, "serviceName", "collector"),
|
||||
configMapName: kubernetesNameField(collector, "configMapName", "collector"),
|
||||
replicas: integerField(collector, "replicas", "collector"),
|
||||
healthPort: portField(collector, "healthPort", "collector"),
|
||||
otlp: parseOtlpPorts(collectorOtlp, "collector.otlp"),
|
||||
},
|
||||
traceBackend: {
|
||||
type: enumField(traceBackend, "type", "traceBackend", ["tempo"] as const),
|
||||
deploymentName: kubernetesNameField(traceBackend, "deploymentName", "traceBackend"),
|
||||
serviceName: kubernetesNameField(traceBackend, "serviceName", "traceBackend"),
|
||||
configMapName: kubernetesNameField(traceBackend, "configMapName", "traceBackend"),
|
||||
replicas: integerField(traceBackend, "replicas", "traceBackend"),
|
||||
httpPort: portField(traceBackend, "httpPort", "traceBackend"),
|
||||
otlp: parseOtlpPorts(traceBackendOtlp, "traceBackend.otlp"),
|
||||
storage: {
|
||||
mode: enumField(traceBackendStorage, "mode", "traceBackend.storage", ["emptyDir"] as const),
|
||||
retention: stringField(traceBackendStorage, "retention", "traceBackend.storage"),
|
||||
},
|
||||
},
|
||||
sampling: {
|
||||
mode: enumField(sampling, "mode", "sampling", ["parentbased_traceidratio"] as const),
|
||||
ratio: numberField(sampling, "ratio", "sampling"),
|
||||
},
|
||||
instrumentation: {
|
||||
contextPropagation: stringArrayField(instrumentation, "contextPropagation", "instrumentation"),
|
||||
serviceConnections: arrayOfRecords(instrumentation.serviceConnections, "instrumentation.serviceConnections").map(parseServiceConnection),
|
||||
},
|
||||
resourceAttributes: {
|
||||
required: stringArrayField(resourceAttributes, "required", "resourceAttributes"),
|
||||
businessCorrelationAttributes: stringArrayField(resourceAttributes, "businessCorrelationAttributes", "resourceAttributes"),
|
||||
},
|
||||
probes: {
|
||||
readinessPath: apiPathField(probes, "readinessPath", "probes"),
|
||||
traceQueryPathTemplate: stringField(probes, "traceQueryPathTemplate", "probes"),
|
||||
statusEndpoints: arrayOfRecords(probes.statusEndpoints, "probes.statusEndpoints").map(parseStatusEndpoint),
|
||||
},
|
||||
};
|
||||
if (config.targets.length === 0) throw new Error(`${configLabel}.targets must not be empty`);
|
||||
assertKnownEnabledTarget(config.targets, config.defaults.targetId, "defaults.targetId");
|
||||
if (config.collector.replicas < 0 || config.traceBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`);
|
||||
if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`);
|
||||
if (!config.probes.traceQueryPathTemplate.includes("{{traceId}}")) throw new Error(`${configLabel}.probes.traceQueryPathTemplate must include {{traceId}}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
export function imageSpec(record: Record<string, unknown>, path: string): ImageSpec {
|
||||
const image = {
|
||||
repository: stringField(record, "repository", path),
|
||||
tag: stringField(record, "tag", path),
|
||||
pullPolicy: enumField(record, "pullPolicy", path, ["Always", "IfNotPresent", "Never"] as const),
|
||||
};
|
||||
if (!/^[A-Za-z0-9._/:@-]+$/u.test(`${image.repository}:${image.tag}`)) throw new Error(`${configLabel}.${path} must render a valid image reference`);
|
||||
return image;
|
||||
}
|
||||
|
||||
export function parseTarget(record: Record<string, unknown>, index: number): ObservabilityTarget {
|
||||
const path = `targets[${index}]`;
|
||||
return {
|
||||
id: stringField(record, "id", path),
|
||||
route: stringField(record, "route", path),
|
||||
namespace: kubernetesNameField(record, "namespace", path),
|
||||
role: enumField(record, "role", path, ["active", "standby"] as const),
|
||||
enabled: booleanField(record, "enabled", path),
|
||||
createNamespace: booleanField(record, "createNamespace", path),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOtlpPorts(record: Record<string, unknown>, path: string): OtlpPorts {
|
||||
return {
|
||||
grpcPort: portField(record, "grpcPort", path),
|
||||
httpPort: portField(record, "httpPort", path),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseServiceConnection(record: Record<string, unknown>, index: number): ServiceConnection {
|
||||
const path = `instrumentation.serviceConnections[${index}]`;
|
||||
return {
|
||||
serviceName: stringField(record, "serviceName", path),
|
||||
owningRepo: stringField(record, "owningRepo", path),
|
||||
targetNode: stringField(record, "targetNode", path),
|
||||
lane: stringField(record, "lane", path),
|
||||
namespace: kubernetesNameField(record, "namespace", path),
|
||||
requiredSpans: stringArrayField(record, "requiredSpans", path),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. Domain barrel for scripts/src/platform-infra-observability.ts.
|
||||
export * from "./types";
|
||||
export * from "./options";
|
||||
export * from "./config";
|
||||
export * from "./actions";
|
||||
export * from "./render";
|
||||
export * from "./manifest";
|
||||
export * from "./apply-status-scripts";
|
||||
export * from "./trace-script";
|
||||
export * from "./search-script";
|
||||
export * from "./diagnose-code-agent-script";
|
||||
export * from "./summary";
|
||||
@@ -0,0 +1,308 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. manifest module for scripts/src/platform-infra-observability.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-observability.ts:983-1299 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 { DiagnoseCodeAgentOptions, ObservabilityTarget, SearchOptions, TraceOptions } from "./types";
|
||||
import { asPlainRecord, compactSearchTrace } from "./apply-status-scripts";
|
||||
import { trace } from "./render";
|
||||
import { asArray } from "./trace-script";
|
||||
|
||||
export function traceSpanDurationMs(span: Record<string, unknown>, attrs: Record<string, unknown> | null): number {
|
||||
const topLevel = numberFromUnknown(span.durationMs);
|
||||
if (Number.isFinite(topLevel)) return topLevel;
|
||||
if (attrs === null) return Number.NaN;
|
||||
for (const key of ["workbench.ui.value_ms", "workbench.ui.resource_duration_ms", "durationMs"]) {
|
||||
const value = numberFromUnknown(attrs[key]);
|
||||
if (Number.isFinite(value)) return value;
|
||||
}
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
export function traceSpanImportanceScore(span: Record<string, unknown>): number {
|
||||
const attrs = asPlainRecord(span.attributes);
|
||||
const statusCode = textValue(span.statusCode);
|
||||
const name = textValue(span.name).toLowerCase();
|
||||
const hasFailure = spanColumnAttr(attrs, ["failureKind", "errorSummary", "error.message", "exception.message"]) !== "-";
|
||||
const terminalStatus = spanColumnAttr(attrs, ["terminalStatus"]);
|
||||
const problemScore = statusCode === "STATUS_CODE_ERROR" || statusCode === "2" || name.includes("error") || hasFailure || terminalStatus === "failed" || terminalStatus === "blocked" ? 1_000_000_000 : 0;
|
||||
const durationMs = traceSpanDurationMs(span, attrs);
|
||||
const durationScore = Number.isFinite(durationMs) ? durationMs : 0;
|
||||
return problemScore + durationScore;
|
||||
}
|
||||
|
||||
export function searchTraceStatus(trace: Record<string, unknown>): string {
|
||||
if (trace.queryOk === false) return "query-bad";
|
||||
if (trace.parseOk === false) return "parse-bad";
|
||||
const errorCount = numberFromUnknown(trace.errorSpanCount);
|
||||
if (errorCount > 0) return "error";
|
||||
const matchedCount = numberFromUnknown(trace.matchedSpanCount);
|
||||
if (matchedCount > 0 || trace.rawMatched === true) return "matched";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export function uniqueSpanRecords(values: unknown[]): Record<string, unknown>[] {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const span = asPlainRecord(value) ?? {};
|
||||
const key = `${textValue(span.name)}\n${textValue(span.service)}\n${spanDetail(span, 200)}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rows.push(span);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function spanColumnAttr(attrs: Record<string, unknown> | null, keys: string[]): string {
|
||||
if (attrs === null) return "-";
|
||||
for (const key of keys) {
|
||||
const value = attrs[key];
|
||||
const text = textValue(value);
|
||||
if (text !== "-") return text;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
export function spanDetail(span: Record<string, unknown>, maxLength: number): string {
|
||||
const attrs = asPlainRecord(span.attributes);
|
||||
const parts = [
|
||||
valuePart("durationMs", span.durationMs),
|
||||
attrPart(attrs, "failureKind"),
|
||||
attrPart(attrs, "errorSummary"),
|
||||
attrPart(attrs, "error.message"),
|
||||
attrPart(attrs, "exception.message"),
|
||||
attrPart(attrs, "terminalStatus"),
|
||||
attrPart(attrs, "status"),
|
||||
attrPart(attrs, "toolName"),
|
||||
attrPart(attrs, "exitCode"),
|
||||
attrPart(attrs, "outputSummary"),
|
||||
attrPart(attrs, "outputBytes"),
|
||||
attrPart(attrs, "durationMs"),
|
||||
attrPart(attrs, "cwd"),
|
||||
attrPart(attrs, "command"),
|
||||
attrPart(attrs, "waitingFor"),
|
||||
attrPart(attrs, "lastEventLabel"),
|
||||
attrPart(attrs, "idleMs"),
|
||||
attrPart(attrs, "idleSeconds"),
|
||||
attrPart(attrs, "commandFingerprint"),
|
||||
attrPart(attrs, "http.route"),
|
||||
attrPart(attrs, "http.response.status_code"),
|
||||
attrPart(attrs, "http.response.status_class"),
|
||||
attrPart(attrs, "workbench.ui.event_type"),
|
||||
attrPart(attrs, "workbench.ui.scope"),
|
||||
attrPart(attrs, "workbench.ui.state"),
|
||||
attrPart(attrs, "workbench.ui.reason"),
|
||||
attrPart(attrs, "workbench.ui.outcome"),
|
||||
attrPart(attrs, "workbench.ui.value_ms"),
|
||||
attrPart(attrs, "workbench.ui.activity_idle_ms"),
|
||||
attrPart(attrs, "workbench.ui.activity_waiting_for"),
|
||||
attrPart(attrs, "workbench.ui.activity_last_event_label"),
|
||||
attrPart(attrs, "workbench.ui.resource_duration_ms"),
|
||||
attrPart(attrs, "workbench.ui.resource_fetch_to_request_ms"),
|
||||
attrPart(attrs, "workbench.ui.resource_request_wait_ms"),
|
||||
attrPart(attrs, "workbench.ui.resource_response_transfer_ms"),
|
||||
attrPart(attrs, "workbench.ui.resource_next_hop_protocol"),
|
||||
attrPart(attrs, "workbench.ui.resource_server_timing"),
|
||||
attrPart(attrs, "hwlab.http.stage"),
|
||||
attrPart(attrs, "hwlab.http.phase"),
|
||||
attrPart(attrs, "hwlab.http.phase.outcome"),
|
||||
attrPart(attrs, "hwlab.live_builds.service_id"),
|
||||
attrPart(attrs, "hwlab.live_builds.service_kind"),
|
||||
attrPart(attrs, "hwlab.live_builds.external"),
|
||||
attrPart(attrs, "hwlab.live_builds.deploy_manifest_status"),
|
||||
attrPart(attrs, "hwlab.live_builds.artifact_catalog_status"),
|
||||
attrPart(attrs, "db.system"),
|
||||
attrPart(attrs, "db.operation.name"),
|
||||
attrPart(attrs, "db.sql.table"),
|
||||
attrPart(attrs, "db.query.arg_count"),
|
||||
attrPart(attrs, "db.index.expected"),
|
||||
attrPart(attrs, "db.pool.max_open"),
|
||||
attrPart(attrs, "db.pool.open_connections"),
|
||||
attrPart(attrs, "db.pool.in_use"),
|
||||
attrPart(attrs, "db.pool.idle"),
|
||||
attrPart(attrs, "db.pool.wait_count"),
|
||||
attrPart(attrs, "db.pool.wait_duration_ms"),
|
||||
attrPart(attrs, "error.code"),
|
||||
attrPart(attrs, "error.category"),
|
||||
attrPart(attrs, "error.layer"),
|
||||
].filter((item) => item !== null) as string[];
|
||||
if (parts.length === 0) return "-";
|
||||
return shortenEnd(parts.join(" "), maxLength);
|
||||
}
|
||||
|
||||
export function valuePart(key: string, value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
return `${key}=${textValue(value)}`;
|
||||
}
|
||||
|
||||
export function attrPart(attrs: Record<string, unknown> | null, key: string): string | null {
|
||||
if (attrs === null) return null;
|
||||
const value = attrs[key];
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
return `${key}=${textValue(value)}`;
|
||||
}
|
||||
|
||||
export function httpTableRows(http: Record<string, unknown> | null): string[][] {
|
||||
if (http === null) return [];
|
||||
const problemRows = asArray(http.problemCounts).map((item) => {
|
||||
const row = asPlainRecord(item) ?? {};
|
||||
return [
|
||||
textValue(row.method),
|
||||
shortenEnd(textValue(row.route ?? row.path), 44),
|
||||
textValue(row.status ?? row.statusCode),
|
||||
textValue(row.count),
|
||||
];
|
||||
});
|
||||
const statusRows = asArray(http.statusCounts).map((item) => {
|
||||
const row = asPlainRecord(item) ?? {};
|
||||
return [
|
||||
textValue(row.method),
|
||||
shortenEnd(textValue(row.route ?? row.path), 44),
|
||||
textValue(row.status ?? row.statusCode),
|
||||
textValue(row.count),
|
||||
];
|
||||
});
|
||||
return [...problemRows, ...statusRows].slice(0, 8);
|
||||
}
|
||||
|
||||
export function buildTraceCommand(target: ObservabilityTarget, options: TraceOptions, full: boolean): string {
|
||||
const parts = ["bun", "scripts/cli.ts", "platform-infra", "observability", "trace", "--target", target.id];
|
||||
if (options.traceId !== null) parts.push("--trace-id", options.traceId);
|
||||
if (options.grep !== null) parts.push("--grep", options.grep);
|
||||
parts.push("--limit", String(options.limit));
|
||||
if (full) parts.push("--full");
|
||||
return parts.map(cliArg).join(" ");
|
||||
}
|
||||
|
||||
export function buildSearchCommand(target: ObservabilityTarget, options: SearchOptions, full: boolean): string {
|
||||
const parts = ["bun", "scripts/cli.ts", "platform-infra", "observability", "search", "--target", target.id];
|
||||
if (options.grep !== null) parts.push("--grep", options.grep);
|
||||
if (options.query !== null) parts.push("--query", options.query);
|
||||
if (options.path !== null) parts.push("--path", options.path);
|
||||
if (options.status !== null) parts.push("--status", String(options.status));
|
||||
if (options.endAt !== null) parts.push("--end-at", options.endAt);
|
||||
parts.push("--lookback-minutes", String(options.lookbackMinutes), "--candidate-limit", String(options.candidateLimit), "--limit", String(options.limit));
|
||||
if (full) parts.push("--full");
|
||||
return parts.map(cliArg).join(" ");
|
||||
}
|
||||
|
||||
export function buildDiagnoseCommand(target: ObservabilityTarget, options: DiagnoseCodeAgentOptions, full: boolean): string {
|
||||
const parts = ["bun", "scripts/cli.ts", "platform-infra", "observability", "diagnose-code-agent", "--target", target.id];
|
||||
if (options.businessTraceId !== null) parts.push("--business-trace-id", options.businessTraceId);
|
||||
if (options.traceId !== null) parts.push("--trace-id", options.traceId);
|
||||
parts.push("--lookback-minutes", String(options.lookbackMinutes), "--candidate-limit", String(options.candidateLimit), "--limit", String(options.limit));
|
||||
if (full) parts.push("--full");
|
||||
return parts.map(cliArg).join(" ");
|
||||
}
|
||||
|
||||
export function cliArg(value: string): string {
|
||||
return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : shQuote(value);
|
||||
}
|
||||
|
||||
export function formatTable(headers: string[], rows: string[][]): string {
|
||||
const normalizedRows = rows.map((row) => headers.map((_, index) => row[index] ?? ""));
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...normalizedRows.map((row) => row[index].length)));
|
||||
return [
|
||||
headers.map((header, index) => header.padEnd(widths[index])).join(" ").trimEnd(),
|
||||
...normalizedRows.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd()),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function textValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function numberValue(value: unknown): string {
|
||||
const number = numberFromUnknown(value);
|
||||
return Number.isFinite(number) ? String(number) : "-";
|
||||
}
|
||||
|
||||
export function numberFromUnknown(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
export function isoFromUnixNano(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
try {
|
||||
const text = String(value);
|
||||
if (!/^\d+$/u.test(text)) return "-";
|
||||
const millis = Number(BigInt(text) / 1000000n);
|
||||
if (!Number.isFinite(millis) || millis <= 0) return "-";
|
||||
return new Date(millis).toISOString().replace(/\.000Z$/u, "Z");
|
||||
} catch {
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
export function joinValues(value: unknown, maxLength: number): string {
|
||||
const joined = asArray(value).map(textValue).filter((item) => item !== "-").join(",");
|
||||
return shortenEnd(joined.length > 0 ? joined : textValue(value), maxLength);
|
||||
}
|
||||
|
||||
export function shortenEnd(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
if (maxLength <= 1) return value.slice(0, maxLength);
|
||||
return `${value.slice(0, maxLength - 1)}~`;
|
||||
}
|
||||
|
||||
export function shortenMiddle(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
if (maxLength <= 3) return value.slice(0, maxLength);
|
||||
const left = Math.ceil((maxLength - 1) / 2);
|
||||
const right = Math.floor((maxLength - 1) / 2);
|
||||
return `${value.slice(0, left)}~${value.slice(value.length - right)}`;
|
||||
}
|
||||
|
||||
export function compactSearchResult(value: unknown): Record<string, unknown> {
|
||||
const source = asPlainRecord(value) ?? {};
|
||||
const traces = asArray(source.traces).map(compactSearchTrace);
|
||||
return {
|
||||
ok: source.ok === true,
|
||||
searchPath: source.searchPath ?? null,
|
||||
grep: source.grep ?? null,
|
||||
tempoQuery: source.tempoQuery ?? null,
|
||||
pathFilter: source.pathFilter ?? null,
|
||||
statusFilter: source.statusFilter ?? null,
|
||||
limit: source.limit ?? null,
|
||||
candidateLimit: source.candidateLimit ?? null,
|
||||
searchParseOk: source.searchParseOk ?? null,
|
||||
candidateTraceCount: source.candidateTraceCount ?? null,
|
||||
scannedTraceCount: source.scannedTraceCount ?? null,
|
||||
matchedTraceCount: source.matchedTraceCount ?? null,
|
||||
scanStopped: source.scanStopped ?? null,
|
||||
matchingActive: source.matchingActive ?? null,
|
||||
traces,
|
||||
truncated: source.truncated ?? null,
|
||||
next: source.next ?? null,
|
||||
searchStderrTail: source.searchStderrTail ?? null,
|
||||
disclosure: {
|
||||
defaultView: "compact trace rows only; span arrays are omitted to keep stdout below config/unidesk-cli.yaml output.maxStdoutBytes",
|
||||
expand: "rerun with --full or query a specific trace id with platform-infra observability trace",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/platform-infra-observability.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-observability.ts:150-369 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 { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, SearchOptions, TraceOptions } from "./types";
|
||||
import { apply, plan, status, validate } from "./actions";
|
||||
import { diagnoseCodeAgent, search, trace } from "./render";
|
||||
|
||||
export function observabilityHelp(): Record<string, unknown> {
|
||||
return {
|
||||
command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent",
|
||||
output: "text/table by default for search and diagnose-code-agent; use --full or --raw for structured JSON",
|
||||
configTruth: "config/platform-infra/observability.yaml",
|
||||
spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra observability plan --target D601",
|
||||
"bun scripts/cli.ts platform-infra observability apply --target D601 --dry-run",
|
||||
"bun scripts/cli.ts platform-infra observability apply --target D601 --confirm",
|
||||
"bun scripts/cli.ts platform-infra observability status --target D601 [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra observability validate --target D601 [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra observability trace --target D601 --trace-id <traceId> [--grep provider-stream-disconnected] [--limit 40] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra observability search --target D601 --grep 'no rollout found' [--lookback-minutes 360] [--candidate-limit 80] [--limit 20] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra observability search --target D601 --path /v1/workbench/sessions --status 502 [--lookback-minutes 120] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra observability diagnose-code-agent --target D601 --business-trace-id <trc_...> [--full|--raw]",
|
||||
],
|
||||
boundary: "Prometheus remains the metrics source; this command owns only platform-infra OTel Collector, trace backend readiness, and trace lookup.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPlatformObservabilityCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const [action = "plan"] = args;
|
||||
if (action === "plan") return plan(parseCommonOptions(args.slice(1)));
|
||||
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(1)));
|
||||
if (action === "status") return await status(config, parseCommonOptions(args.slice(1)));
|
||||
if (action === "validate") return await validate(config, parseCommonOptions(args.slice(1)));
|
||||
if (action === "trace") return await trace(config, parseTraceOptions(args.slice(1)));
|
||||
if (action === "search") return await search(config, parseSearchOptions(args.slice(1)));
|
||||
if (action === "diagnose-code-agent") return await diagnoseCodeAgent(config, parseDiagnoseCodeAgentOptions(args.slice(1)));
|
||||
return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() };
|
||||
}
|
||||
|
||||
export function parseCommonOptions(args: string[]): CommonOptions {
|
||||
let targetId: string | null = null;
|
||||
let full = false;
|
||||
let raw = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--target") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error("--target must be a simple target id");
|
||||
targetId = value;
|
||||
index += 1;
|
||||
} else if (arg === "--full") {
|
||||
full = true;
|
||||
} else if (arg === "--raw") {
|
||||
raw = true;
|
||||
full = true;
|
||||
} else {
|
||||
throw new Error(`unsupported observability option: ${arg}`);
|
||||
}
|
||||
}
|
||||
return { targetId, full, raw };
|
||||
}
|
||||
|
||||
export function parseApplyOptions(args: string[]): ApplyOptions {
|
||||
const commonArgs: string[] = [];
|
||||
let confirm = false;
|
||||
let dryRun = false;
|
||||
let wait = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--confirm") confirm = true;
|
||||
else if (arg === "--dry-run") dryRun = true;
|
||||
else if (arg === "--wait") wait = true;
|
||||
else {
|
||||
commonArgs.push(arg);
|
||||
if (arg === "--target") {
|
||||
commonArgs.push(args[index + 1] ?? "");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (confirm && dryRun) throw new Error("observability apply accepts only one of --confirm or --dry-run");
|
||||
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
|
||||
}
|
||||
|
||||
export function parseTraceOptions(args: string[]): TraceOptions {
|
||||
const commonArgs: string[] = [];
|
||||
let traceId: string | null = null;
|
||||
let grep: string | null = null;
|
||||
let limit = 40;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--trace-id") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--trace-id requires a value");
|
||||
if (!/^[0-9a-fA-F]{32}$/u.test(value)) throw new Error("--trace-id must be a 32-character OpenTelemetry trace id encoded as hex");
|
||||
traceId = value;
|
||||
index += 1;
|
||||
} else if (arg === "--grep") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--grep requires a value");
|
||||
grep = value;
|
||||
index += 1;
|
||||
} else if (arg === "--limit") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--limit requires a value");
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 500) throw new Error("--limit must be an integer from 1 to 500");
|
||||
limit = parsed;
|
||||
index += 1;
|
||||
} else {
|
||||
commonArgs.push(arg);
|
||||
if (arg === "--target") {
|
||||
commonArgs.push(args[index + 1] ?? "");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...parseCommonOptions(commonArgs), traceId, grep, limit };
|
||||
}
|
||||
|
||||
export function parseSearchOptions(args: string[]): SearchOptions {
|
||||
const commonArgs: string[] = [];
|
||||
let grep: string | null = null;
|
||||
let query: string | null = null;
|
||||
let path: string | null = null;
|
||||
let status: number | null = null;
|
||||
let limit = 20;
|
||||
let candidateLimit = 80;
|
||||
let lookbackMinutes = 360;
|
||||
let endAt: string | null = null;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--grep") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--grep requires a value");
|
||||
grep = value;
|
||||
index += 1;
|
||||
} else if (arg === "--query" || arg === "--q") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||||
query = value;
|
||||
index += 1;
|
||||
} else if (arg === "--path") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--path requires a value");
|
||||
if (!value.startsWith("/") || value.includes("\n") || value.length > 300) throw new Error("--path must be an absolute HTTP path up to 300 characters");
|
||||
path = value;
|
||||
index += 1;
|
||||
} else if (arg === "--status") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--status requires a value");
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 100 || parsed > 599) throw new Error("--status must be an integer HTTP status from 100 to 599");
|
||||
status = parsed;
|
||||
index += 1;
|
||||
} else if (arg === "--limit") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--limit requires a value");
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) throw new Error("--limit must be an integer from 1 to 100");
|
||||
limit = parsed;
|
||||
index += 1;
|
||||
} else if (arg === "--candidate-limit") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--candidate-limit requires a value");
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 500) throw new Error("--candidate-limit must be an integer from 1 to 500");
|
||||
candidateLimit = parsed;
|
||||
index += 1;
|
||||
} else if (arg === "--lookback-minutes" || arg === "--since-minutes") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10080) throw new Error(`${arg} must be an integer from 1 to 10080`);
|
||||
lookbackMinutes = parsed;
|
||||
index += 1;
|
||||
} else if (arg === "--end-at") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--end-at requires an ISO timestamp value");
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isFinite(parsed)) throw new Error("--end-at must be an ISO timestamp parseable by Date.parse");
|
||||
endAt = new Date(parsed).toISOString();
|
||||
index += 1;
|
||||
} else {
|
||||
commonArgs.push(arg);
|
||||
if (arg === "--target") {
|
||||
commonArgs.push(args[index + 1] ?? "");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grep === null && query === null && path === null && status === null) throw new Error("observability search requires --grep <text>, --query <tempo-query>, --path <route>, or --status <code>");
|
||||
return { ...parseCommonOptions(commonArgs), grep, query, path, status, limit, candidateLimit, lookbackMinutes, endAt };
|
||||
}
|
||||
|
||||
export function parseDiagnoseCodeAgentOptions(args: string[]): DiagnoseCodeAgentOptions {
|
||||
const commonArgs: string[] = [];
|
||||
let businessTraceId: string | null = null;
|
||||
let traceId: string | null = null;
|
||||
let limit = 40;
|
||||
let candidateLimit = 300;
|
||||
let lookbackMinutes = 10080;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--business-trace-id" || arg === "--trace") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||||
if (!/^trc_[A-Za-z0-9_-]+$/u.test(value)) throw new Error(`${arg} must look like trc_<id>`);
|
||||
businessTraceId = value;
|
||||
index += 1;
|
||||
} else if (arg === "--trace-id" || arg === "--otel-trace-id") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||||
if (!/^[0-9a-fA-F]{32}$/u.test(value)) throw new Error(`${arg} must be a 32-character OpenTelemetry trace id encoded as hex`);
|
||||
traceId = value.toLowerCase();
|
||||
index += 1;
|
||||
} else if (arg === "--limit") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--limit requires a value");
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 200) throw new Error("--limit must be an integer from 1 to 200");
|
||||
limit = parsed;
|
||||
index += 1;
|
||||
} else if (arg === "--candidate-limit") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--candidate-limit requires a value");
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 500) throw new Error("--candidate-limit must be an integer from 1 to 500");
|
||||
candidateLimit = parsed;
|
||||
index += 1;
|
||||
} else if (arg === "--lookback-minutes" || arg === "--since-minutes") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10080) throw new Error(`${arg} must be an integer from 1 to 10080`);
|
||||
lookbackMinutes = parsed;
|
||||
index += 1;
|
||||
} else {
|
||||
commonArgs.push(arg);
|
||||
if (arg === "--target") {
|
||||
commonArgs.push(args[index + 1] ?? "");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (businessTraceId === null && traceId === null) throw new Error("observability diagnose-code-agent requires --business-trace-id <trc_...> or --trace-id <otelTraceId>");
|
||||
return { ...parseCommonOptions(commonArgs), businessTraceId, traceId, limit, candidateLimit, lookbackMinutes };
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. render module for scripts/src/platform-infra-observability.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-observability.ts:624-982 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 { DiagnoseCodeAgentOptions, ObservabilityTarget, SearchOptions, TraceOptions } from "./types";
|
||||
import { resolveTarget } from "./actions";
|
||||
import { asPlainRecord, compactDiagnoseCodeAgentResult } from "./apply-status-scripts";
|
||||
import { readObservabilityConfig } from "./config";
|
||||
import { diagnoseCodeAgentScript, searchScript, traceScript } from "./diagnose-code-agent-script";
|
||||
import { buildDiagnoseCommand, buildSearchCommand, buildTraceCommand, compactSearchResult, formatTable, httpTableRows, isoFromUnixNano, joinValues, numberFromUnknown, numberValue, searchTraceStatus, shortenEnd, shortenMiddle, spanColumnAttr, spanDetail, textValue, traceSpanDurationMs, traceSpanImportanceScore, uniqueSpanRecords } from "./manifest";
|
||||
import { targetSummary } from "./summary";
|
||||
import { asArray } from "./trace-script";
|
||||
|
||||
export async function trace(config: UniDeskConfig, options: TraceOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
if (options.traceId === null) throw new Error("observability trace requires --trace-id <traceId>");
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const tracePath = observability.probes.traceQueryPathTemplate.replaceAll("{{traceId}}", encodeURIComponent(options.traceId));
|
||||
const result = await capture(config, target.route, ["sh"], traceScript(observability, target, tracePath, options));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const ok = result.exitCode === 0 && parsed?.ok === true;
|
||||
if (!options.full && !options.raw) {
|
||||
return renderTraceTable({
|
||||
ok,
|
||||
target,
|
||||
options,
|
||||
tracePath,
|
||||
query: {
|
||||
backend: observability.traceBackend.type,
|
||||
service: observability.traceBackend.serviceName,
|
||||
path: tracePath,
|
||||
},
|
||||
result: parsed === null ? { ok: false, remote: compactCapture(result, { full: false }) } : asPlainRecord(redactSensitiveUnknown(parsed)) ?? {},
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-observability-trace",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
traceId: options.traceId,
|
||||
query: {
|
||||
backend: observability.traceBackend.type,
|
||||
service: observability.traceBackend.serviceName,
|
||||
path: tracePath,
|
||||
},
|
||||
result: redactSensitiveUnknown(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
export async function search(config: UniDeskConfig, options: SearchOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const businessTraceId = options.query === null ? businessTraceIdFromSearchText(options.grep) : null;
|
||||
const effectiveLookbackMinutes = businessTraceId === null ? options.lookbackMinutes : Math.max(options.lookbackMinutes, 10080);
|
||||
const effectiveTempoQuery = options.query ?? (businessTraceId === null ? inferSearchTempoQuery(options) : `{ .traceId = "${businessTraceId}" }`);
|
||||
const effectiveOptions: SearchOptions = {
|
||||
...options,
|
||||
query: effectiveTempoQuery,
|
||||
lookbackMinutes: effectiveLookbackMinutes,
|
||||
};
|
||||
const endMillis = options.endAt === null ? Date.now() : Date.parse(options.endAt);
|
||||
if (!Number.isFinite(endMillis)) throw new Error("--end-at must be an ISO timestamp parseable by Date.parse");
|
||||
const endSeconds = Math.floor(endMillis / 1000);
|
||||
const startSeconds = endSeconds - (effectiveLookbackMinutes * 60);
|
||||
const params = new URLSearchParams({
|
||||
start: String(startSeconds),
|
||||
end: String(endSeconds),
|
||||
limit: String(options.candidateLimit),
|
||||
});
|
||||
if (effectiveTempoQuery !== null) params.set("q", effectiveTempoQuery);
|
||||
const searchPath = `/api/search?${params.toString()}`;
|
||||
const result = await capture(config, target.route, ["sh"], searchScript(observability, target, searchPath, effectiveOptions));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const ok = result.exitCode === 0 && parsed?.ok === true;
|
||||
const query = {
|
||||
backend: observability.traceBackend.type,
|
||||
service: observability.traceBackend.serviceName,
|
||||
path: searchPath,
|
||||
grep: options.grep,
|
||||
tempoQuery: effectiveTempoQuery,
|
||||
explicitTempoQuery: options.query,
|
||||
pathFilter: options.path,
|
||||
statusFilter: options.status,
|
||||
lookbackMinutes: effectiveLookbackMinutes,
|
||||
requestedLookbackMinutes: options.lookbackMinutes,
|
||||
endAt: new Date(endSeconds * 1000).toISOString(),
|
||||
businessTraceId,
|
||||
mode: businessTraceId === null ? "candidate-grep" : "business-trace-exact",
|
||||
candidateLimit: options.candidateLimit,
|
||||
limit: options.limit,
|
||||
};
|
||||
if (!options.full && !options.raw) {
|
||||
return renderSearchTable({
|
||||
ok,
|
||||
target,
|
||||
options,
|
||||
query,
|
||||
result: parsed === null ? { ok: false, remote: compactCapture(result, { full: false }) } : compactSearchResult(parsed),
|
||||
});
|
||||
}
|
||||
const exposedResult = parsed === null
|
||||
? compactCapture(result, { full: true })
|
||||
: redactSensitiveUnknown(parsed);
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-observability-search",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
query,
|
||||
result: exposedResult,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferSearchTempoQuery(options: SearchOptions): string | null {
|
||||
const filters: string[] = [];
|
||||
if (options.path !== null) filters.push(`.http.route = ${JSON.stringify(options.path)}`);
|
||||
if (options.status !== null) filters.push(`.http.response.status_code = ${options.status}`);
|
||||
return filters.length > 0 ? `{ ${filters.join(" && ")} }` : null;
|
||||
}
|
||||
|
||||
export function businessTraceIdFromSearchText(value: string | null): string | null {
|
||||
const match = value?.match(/\btrc_[A-Za-z0-9_-]+\b/u);
|
||||
return match?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function diagnoseCodeAgent(config: UniDeskConfig, options: DiagnoseCodeAgentOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const endSeconds = Math.floor(Date.now() / 1000);
|
||||
const startSeconds = endSeconds - (options.lookbackMinutes * 60);
|
||||
let searchPath: string | null = null;
|
||||
if (options.traceId === null && options.businessTraceId !== null) {
|
||||
const params = new URLSearchParams({
|
||||
start: String(startSeconds),
|
||||
end: String(endSeconds),
|
||||
limit: String(options.candidateLimit),
|
||||
q: `{ .traceId = "${options.businessTraceId}" }`,
|
||||
});
|
||||
searchPath = `/api/search?${params.toString()}`;
|
||||
}
|
||||
const result = await capture(config, target.route, ["sh"], diagnoseCodeAgentScript(observability, target, searchPath, options));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const ok = result.exitCode === 0 && parsed?.ok === true;
|
||||
const query = {
|
||||
backend: observability.traceBackend.type,
|
||||
service: observability.traceBackend.serviceName,
|
||||
businessTraceId: options.businessTraceId,
|
||||
traceId: options.traceId,
|
||||
path: searchPath,
|
||||
lookbackMinutes: options.lookbackMinutes,
|
||||
candidateLimit: options.candidateLimit,
|
||||
limit: options.limit,
|
||||
};
|
||||
if (!options.full && !options.raw) {
|
||||
return renderDiagnoseCodeAgentTable({
|
||||
ok,
|
||||
target,
|
||||
options,
|
||||
query,
|
||||
result: parsed === null ? { ok: false, remote: compactCapture(result, { full: false }) } : compactDiagnoseCodeAgentResult(parsed),
|
||||
});
|
||||
}
|
||||
const exposedResult = parsed === null
|
||||
? compactCapture(result, { full: true })
|
||||
: options.raw
|
||||
? redactSensitiveUnknown(parsed)
|
||||
: {
|
||||
...compactDiagnoseCodeAgentResult(parsed),
|
||||
outputMode: "bounded-compact-json",
|
||||
rawDisclosure: "Use --raw only for Tempo/API envelope or CLI parser debugging.",
|
||||
};
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-observability-diagnose-code-agent",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
query,
|
||||
result: exposedResult,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTraceTable(input: {
|
||||
ok: boolean;
|
||||
target: ObservabilityTarget;
|
||||
options: TraceOptions;
|
||||
tracePath: string;
|
||||
query: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
}): RenderedCliResult {
|
||||
const spanSource = input.result.grep === null || input.result.grep === undefined
|
||||
? [...asArray(input.result.errorSpans), ...asArray(input.result.spans)]
|
||||
: asArray(input.result.spans);
|
||||
const spanRows = uniqueSpanRecords(spanSource)
|
||||
.sort((left, right) => traceSpanImportanceScore(right) - traceSpanImportanceScore(left))
|
||||
.slice(0, 10)
|
||||
.map((span) => {
|
||||
const attrs = asPlainRecord(span.attributes);
|
||||
const durationMs = traceSpanDurationMs(span, attrs);
|
||||
return [
|
||||
shortenEnd(textValue(span.name), 40),
|
||||
shortenEnd(textValue(span.service), 22),
|
||||
shortenEnd(spanColumnAttr(attrs, ["http.route", "workbench.ui.route"]), 34),
|
||||
spanColumnAttr(attrs, ["http.response.status_code", "http.status_code", "http.response.status_class"]),
|
||||
numberValue(durationMs),
|
||||
spanColumnAttr(attrs, ["workbench.ui.resource_duration_ms"]),
|
||||
spanColumnAttr(attrs, ["workbench.ui.resource_fetch_to_request_ms"]),
|
||||
spanColumnAttr(attrs, ["workbench.ui.resource_request_wait_ms"]),
|
||||
spanColumnAttr(attrs, ["workbench.ui.resource_response_transfer_ms"]),
|
||||
shortenEnd(spanColumnAttr(attrs, ["workbench.ui.resource_next_hop_protocol"]), 14),
|
||||
spanDetail(span, 220),
|
||||
];
|
||||
});
|
||||
const countRows = asArray(input.result.spanNameCounts).slice(0, 8).map((item) => {
|
||||
const row = asPlainRecord(item) ?? {};
|
||||
return [shortenEnd(textValue(row.name), 64), textValue(row.count)];
|
||||
});
|
||||
const identity = asPlainRecord(input.result.identity) ?? {};
|
||||
const identityRows = ["runId", "commandId", "runnerJobId", "runnerId", "backendProfile", "sourceCommit", "jobName", "podName"]
|
||||
.map((key) => [key, joinValues(identity[key], 88)])
|
||||
.filter((row) => row[1] !== "-");
|
||||
const next = asPlainRecord(input.result.next);
|
||||
const lines = [
|
||||
`platform-infra observability trace (${input.ok ? "ok" : "not-ok"})`,
|
||||
"",
|
||||
formatTable(["TRACE", "SPANS", "ERR", "MATCH", "SERVICES", "BUSINESS_TRACE"], [[
|
||||
shortenMiddle(input.options.traceId ?? "-", 20),
|
||||
textValue(input.result.spanCount),
|
||||
textValue(input.result.errorSpanCount),
|
||||
textValue(input.result.matchedSpanCount),
|
||||
joinValues(input.result.services, 44),
|
||||
joinValues(input.result.businessTraceIds, 34),
|
||||
]]),
|
||||
"",
|
||||
"Identity:",
|
||||
formatTable(["FIELD", "VALUE"], identityRows.length > 0 ? identityRows : [["-", "-"]]),
|
||||
"",
|
||||
"Key spans:",
|
||||
formatTable(["NAME", "SERVICE", "ROUTE", "STATUS", "DUR_MS", "RES_MS", "FETCH_REQ", "REQ_WAIT", "RESP_XFER", "PROTO", "DETAIL"], spanRows.length > 0 ? spanRows : [["-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-"]]),
|
||||
"",
|
||||
"Span name counts:",
|
||||
formatTable(["NAME", "COUNT"], countRows.length > 0 ? countRows : [["-", "-"]]),
|
||||
"",
|
||||
"Summary:",
|
||||
` target=${input.target.id} backend=${textValue(input.query.backend)} service=${textValue(input.query.service)} path=${input.tracePath}`,
|
||||
` grep=${textValue(input.result.grep)} limit=${textValue(input.result.limit)} traceFound=${textValue(input.result.traceFound)} parseOk=${textValue(input.result.parseOk)}`,
|
||||
"",
|
||||
"Next:",
|
||||
];
|
||||
const nextCommands = [
|
||||
textValue(next?.fullSummary),
|
||||
textValue(next?.grepProviderStream),
|
||||
textValue(next?.raw),
|
||||
].filter((item) => item !== "-");
|
||||
if (nextCommands.length > 0) {
|
||||
for (const command of nextCommands.slice(0, 3)) lines.push(` ${command}`);
|
||||
} else {
|
||||
lines.push(` ${buildTraceCommand(input.target, input.options, true)}`);
|
||||
}
|
||||
lines.push("", "Disclosure:");
|
||||
lines.push(" default view is a bounded table; use --full for structured span summary JSON or --raw for backend response.");
|
||||
return {
|
||||
ok: input.ok,
|
||||
command: "platform-infra observability trace",
|
||||
contentType: "text/plain",
|
||||
renderedText: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSearchTable(input: {
|
||||
ok: boolean;
|
||||
target: ObservabilityTarget;
|
||||
options: SearchOptions;
|
||||
query: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
}): RenderedCliResult {
|
||||
const traces = asArray(input.result.traces).map((item) => asPlainRecord(item) ?? {});
|
||||
const requestedLimit = numberFromUnknown(input.options.limit);
|
||||
const rowLimit = Math.max(1, Math.min(Number.isFinite(requestedLimit) ? Math.round(requestedLimit) : 12, 40));
|
||||
const rows = traces.slice(0, rowLimit).map((trace) => {
|
||||
const meta = asPlainRecord(trace.meta) ?? {};
|
||||
return [
|
||||
shortenMiddle(textValue(trace.traceId), 32),
|
||||
shortenEnd(textValue(trace.startAt ?? isoFromUnixNano(meta.startTimeUnixNano)), 20),
|
||||
numberValue(trace.durationMs ?? meta.durationMs),
|
||||
searchTraceStatus(trace),
|
||||
numberValue(trace.spanCount),
|
||||
numberValue(trace.errorSpanCount),
|
||||
numberValue(trace.matchedSpanCount),
|
||||
joinValues(trace.services, 38),
|
||||
];
|
||||
});
|
||||
const lines = [
|
||||
`platform-infra observability search (${input.ok ? "ok" : "not-ok"})`,
|
||||
"",
|
||||
formatTable(["TRACE", "START", "DUR_MS", "STATUS", "SPANS", "ERR", "MATCH", "SERVICES"], rows.length > 0 ? rows : [["-", "-", "-", "no-match", "-", "-", "-", "-"]]),
|
||||
"",
|
||||
"Summary:",
|
||||
` target=${input.target.id} backend=${textValue(input.query.backend)} service=${textValue(input.query.service)}`,
|
||||
` grep=${textValue(input.query.grep)} path=${textValue(input.query.pathFilter)} status=${textValue(input.query.statusFilter)} tempoQuery=${shortenMiddle(textValue(input.query.tempoQuery), 80)}`,
|
||||
` mode=${textValue(input.query.mode)} businessTraceId=${textValue(input.query.businessTraceId)} lookbackMinutes=${textValue(input.query.lookbackMinutes)} requestedLookbackMinutes=${textValue(input.query.requestedLookbackMinutes)} endAt=${textValue(input.query.endAt)}`,
|
||||
` candidateLimit=${textValue(input.query.candidateLimit)} limit=${textValue(input.query.limit)}`,
|
||||
` candidates=${textValue(input.result.candidateTraceCount)} scanned=${textValue(input.result.scannedTraceCount)} matched=${textValue(input.result.matchedTraceCount)} stopped=${textValue(input.result.scanStopped)}`,
|
||||
];
|
||||
const firstTraceId = traces.length > 0 ? textValue(traces[0].traceId) : "";
|
||||
lines.push("", "Next:");
|
||||
if (firstTraceId.length > 0 && firstTraceId !== "-") {
|
||||
lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${input.target.id} --trace-id ${firstTraceId}`);
|
||||
}
|
||||
lines.push(` ${buildSearchCommand(input.target, input.options, true)}`);
|
||||
lines.push("", "Disclosure:");
|
||||
lines.push(" default view is a bounded table; use --full for structured diagnosis JSON or trace --trace-id for one trace.");
|
||||
return {
|
||||
ok: input.ok,
|
||||
command: "platform-infra observability search",
|
||||
contentType: "text/plain",
|
||||
renderedText: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderDiagnoseCodeAgentTable(input: {
|
||||
ok: boolean;
|
||||
target: ObservabilityTarget;
|
||||
options: DiagnoseCodeAgentOptions;
|
||||
query: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
}): RenderedCliResult {
|
||||
const mapping = asPlainRecord(input.result.mapping);
|
||||
const identity = asPlainRecord(input.result.identity);
|
||||
const agentrun = asPlainRecord(input.result.agentrun);
|
||||
const projectionLag = asPlainRecord(input.result.projectionLag);
|
||||
const summary = asPlainRecord(input.result.summary);
|
||||
const evidence = asPlainRecord(input.result.evidence);
|
||||
const rootCauses = asArray(input.result.rootCauseCandidates).map((item) => asPlainRecord(item) ?? {});
|
||||
const http = asPlainRecord(input.result.http);
|
||||
const services = joinValues(input.result.services, 54);
|
||||
const traceId = textValue(mapping?.otelTraceId ?? input.query.traceId);
|
||||
const rootCause = textValue(summary?.rootCause ?? rootCauses[0]?.code);
|
||||
const rows = [[
|
||||
shortenMiddle(traceId, 20),
|
||||
shortenEnd(rootCause, 34),
|
||||
textValue(agentrun?.terminalStatus),
|
||||
textValue(agentrun?.runnerProviderClassification),
|
||||
textValue(projectionLag?.status),
|
||||
textValue(evidence?.errorSpanCount),
|
||||
services,
|
||||
]];
|
||||
const rootRows = rootCauses.slice(0, 5).map((candidate) => [
|
||||
shortenEnd(textValue(candidate.code), 40),
|
||||
textValue(candidate.confidence),
|
||||
shortenEnd(textValue(candidate.summary ?? candidate.label), 80),
|
||||
]);
|
||||
const httpRows = httpTableRows(http);
|
||||
const lines = [
|
||||
`platform-infra observability diagnose-code-agent (${input.ok ? "ok" : "not-ok"})`,
|
||||
"",
|
||||
formatTable(["TRACE", "ROOT_CAUSE", "TERMINAL", "RUNNER", "PROJECTION", "ERR", "SERVICES"], rows),
|
||||
"",
|
||||
"Identity:",
|
||||
` businessTraceId=${textValue(mapping?.businessTraceId ?? input.query.businessTraceId)} otelTraceId=${traceId}`,
|
||||
` runId=${textValue(identity?.runId)} commandId=${textValue(identity?.commandId)} runnerJobId=${textValue(identity?.runnerJobId)} runnerId=${textValue(identity?.runnerId)}`,
|
||||
` backendProfile=${textValue(identity?.backendProfile)} sourceCommit=${shortenMiddle(textValue(identity?.sourceCommit), 20)}`,
|
||||
"",
|
||||
"Root causes:",
|
||||
formatTable(["CODE", "CONF", "SUMMARY"], rootRows.length > 0 ? rootRows : [["-", "-", "-"]]),
|
||||
"",
|
||||
"HTTP:",
|
||||
formatTable(["METHOD", "ROUTE", "STATUS", "COUNT"], httpRows.length > 0 ? httpRows : [["-", "-", "-", "-"]]),
|
||||
"",
|
||||
"Summary:",
|
||||
` target=${input.target.id} spanCount=${textValue(input.result.spanCount)} servicePath=${joinValues(input.result.servicePath, 60)}`,
|
||||
` agentrun=${textValue(agentrun?.terminalStatus)} source=${textValue(agentrun?.terminalSource)} authority=${shortenEnd(JSON.stringify((asPlainRecord(agentrun?.authority) ?? {})), 120)}`,
|
||||
` readModel=${shortenEnd(JSON.stringify(input.result.hwlabReadModel ?? null), 140)}`,
|
||||
];
|
||||
const next = asPlainRecord(input.result.next);
|
||||
lines.push("", "Next:");
|
||||
const nextCommands = [
|
||||
textValue(next?.diagnoseFull),
|
||||
textValue(next?.traceSummary),
|
||||
textValue(next?.traceReads),
|
||||
].filter((item) => item !== "-");
|
||||
if (nextCommands.length > 0) {
|
||||
for (const command of nextCommands.slice(0, 3)) lines.push(` ${command}`);
|
||||
} else {
|
||||
lines.push(` ${buildDiagnoseCommand(input.target, input.options, true)}`);
|
||||
if (traceId.length > 0 && traceId !== "-") lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${input.target.id} --trace-id ${traceId}`);
|
||||
}
|
||||
lines.push("", "Disclosure:");
|
||||
lines.push(" default view is a bounded table; use --full for structured diagnosis JSON or trace --grep for span-level drill-down.");
|
||||
return {
|
||||
ok: input.ok,
|
||||
command: "platform-infra observability diagnose-code-agent",
|
||||
contentType: "text/plain",
|
||||
renderedText: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// 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
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. summary module for scripts/src/platform-infra-observability.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-observability.ts:3031-4400 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 { ImageSpec, ObservabilityConfig, ObservabilityTarget } from "./types";
|
||||
import { status } from "./actions";
|
||||
import { asRecord, configLabel } from "./types";
|
||||
|
||||
export function configSummary(observability: ObservabilityConfig, target: ObservabilityTarget): Record<string, unknown> {
|
||||
return {
|
||||
configPath: configLabel,
|
||||
spec: observability.metadata.spec,
|
||||
target: targetSummary(target),
|
||||
images: {
|
||||
collector: imageReference(observability.images.collector),
|
||||
traceBackend: imageReference(observability.images.tempo),
|
||||
},
|
||||
collector: observability.collector,
|
||||
traceBackend: observability.traceBackend,
|
||||
sampling: observability.sampling,
|
||||
};
|
||||
}
|
||||
|
||||
export function targetSummary(target: ObservabilityTarget): Record<string, unknown> {
|
||||
return {
|
||||
id: target.id,
|
||||
route: target.route,
|
||||
namespace: target.namespace,
|
||||
role: target.role,
|
||||
createNamespace: target.createNamespace,
|
||||
};
|
||||
}
|
||||
|
||||
export function policyChecks(yaml: string, target: ObservabilityTarget): Array<Record<string, unknown>> {
|
||||
return [
|
||||
{ name: "yaml-source-of-truth", ok: true, detail: "All concrete images, routes, namespace, ports, retention and sampling values are read from config/platform-infra/observability.yaml." },
|
||||
{ name: "clusterip-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Collector and trace backend stay ClusterIP-only." },
|
||||
{ name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: "No public ingress is rendered for the first tracing backend." },
|
||||
{ name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." },
|
||||
{ name: "allow-all-network-policy", ok: yaml.includes("kind: NetworkPolicy") && yaml.includes("name: allow-all") && yaml.includes(`namespace: ${target.namespace}`), detail: `NetworkPolicy/allow-all is rendered in ${target.namespace}.` },
|
||||
];
|
||||
}
|
||||
|
||||
export function statusSummary(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const sections = asRecord(payload.sections, "status.sections");
|
||||
const deployments = objectList(sectionJson(sections, "deployments"));
|
||||
const services = objectList(sectionJson(sections, "services"));
|
||||
const pods = objectList(sectionJson(sections, "pods"));
|
||||
const events = objectList(sectionJson(sections, "events"));
|
||||
const podEvents = latestPodEvents(events);
|
||||
const probes = Array.isArray(payload.probes) ? payload.probes as Array<Record<string, unknown>> : [];
|
||||
const readyDeployments = deployments.map((item) => deploymentSummary(item));
|
||||
return {
|
||||
ready: payload.ok === true,
|
||||
namespace: payload.namespace,
|
||||
deployments: readyDeployments,
|
||||
services: services.map((item) => metadataName(item)),
|
||||
pods: pods.map((item) => podSummary(item, podEvents)),
|
||||
probes: probes.map((item) => ({
|
||||
name: item.name,
|
||||
ok: item.ok === true,
|
||||
service: item.service,
|
||||
path: item.path,
|
||||
stderrTail: item.ok === true ? "" : item.stderrTail,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function compactStatus(payload: Record<string, unknown> | null, full: boolean): Record<string, unknown> | null {
|
||||
if (payload === null) return null;
|
||||
if (full) return redactSensitiveUnknown(payload) as Record<string, unknown>;
|
||||
return statusSummary(payload);
|
||||
}
|
||||
|
||||
export function sectionJson(sections: Record<string, unknown>, name: string): unknown {
|
||||
const section = asRecord(sections[name], `sections.${name}`);
|
||||
return section.json;
|
||||
}
|
||||
|
||||
export function objectList(value: unknown): Record<string, unknown>[] {
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
const items = (value as Record<string, unknown>).items;
|
||||
if (!Array.isArray(items)) return [];
|
||||
return items.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item));
|
||||
}
|
||||
|
||||
export function deploymentSummary(item: Record<string, unknown>): Record<string, unknown> {
|
||||
const spec = item.spec as Record<string, unknown> | undefined;
|
||||
const status = item.status as Record<string, unknown> | undefined;
|
||||
const replicas = typeof spec?.replicas === "number" ? spec.replicas : null;
|
||||
const available = typeof status?.availableReplicas === "number" ? status.availableReplicas : 0;
|
||||
return {
|
||||
name: metadataName(item),
|
||||
replicas,
|
||||
availableReplicas: available,
|
||||
ready: replicas !== null && available >= replicas,
|
||||
};
|
||||
}
|
||||
|
||||
export function podSummary(item: Record<string, unknown>, podEvents: Map<string, Record<string, unknown>>): Record<string, unknown> {
|
||||
const status = item.status as Record<string, unknown> | undefined;
|
||||
const waiting = firstWaitingContainer(status);
|
||||
const scheduled = conditionSummary(status, "PodScheduled");
|
||||
const ready = conditionSummary(status, "Ready");
|
||||
const name = metadataName(item);
|
||||
const showEvent = status?.phase !== "Running" || waiting !== null || scheduled !== null || ready !== null;
|
||||
const latestEvent = showEvent && name !== null ? podEvents.get(name) ?? null : null;
|
||||
return {
|
||||
name,
|
||||
phase: status?.phase ?? null,
|
||||
reason: waiting?.reason ?? scheduled?.reason ?? ready?.reason ?? null,
|
||||
message: waiting?.message ?? scheduled?.message ?? ready?.message ?? null,
|
||||
latestEvent: latestEvent === null ? null : {
|
||||
reason: stringValue(latestEvent.reason),
|
||||
message: stringValue(latestEvent.message),
|
||||
type: stringValue(latestEvent.type),
|
||||
count: typeof latestEvent.count === "number" ? latestEvent.count : null,
|
||||
timestamp: eventTimestamp(latestEvent),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function latestPodEvents(events: Record<string, unknown>[]): Map<string, Record<string, unknown>> {
|
||||
const byPod = new Map<string, Record<string, unknown>>();
|
||||
for (const item of events) {
|
||||
const involved = item.involvedObject;
|
||||
if (typeof involved !== "object" || involved === null || Array.isArray(involved)) continue;
|
||||
const podName = (involved as Record<string, unknown>).name;
|
||||
const kind = (involved as Record<string, unknown>).kind;
|
||||
if (kind !== "Pod" || typeof podName !== "string") continue;
|
||||
const current = byPod.get(podName);
|
||||
if (current === undefined || eventTimestamp(item) > eventTimestamp(current)) byPod.set(podName, item);
|
||||
}
|
||||
return byPod;
|
||||
}
|
||||
|
||||
export function eventTimestamp(item: Record<string, unknown>): string | null {
|
||||
const candidate = stringValue(item.eventTime)
|
||||
?? stringValue(item.lastTimestamp)
|
||||
?? stringValue(item.deprecatedLastTimestamp)
|
||||
?? (typeof item.metadata === "object" && item.metadata !== null && !Array.isArray(item.metadata)
|
||||
? stringValue((item.metadata as Record<string, unknown>).creationTimestamp)
|
||||
: null);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function firstWaitingContainer(status: Record<string, unknown> | undefined): { reason: string | null; message: string | null } | null {
|
||||
const statuses = Array.isArray(status?.containerStatuses) ? status.containerStatuses : [];
|
||||
for (const item of statuses) {
|
||||
if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
|
||||
const state = (item as Record<string, unknown>).state;
|
||||
if (typeof state !== "object" || state === null || Array.isArray(state)) continue;
|
||||
const waiting = (state as Record<string, unknown>).waiting;
|
||||
if (typeof waiting !== "object" || waiting === null || Array.isArray(waiting)) continue;
|
||||
const record = waiting as Record<string, unknown>;
|
||||
return {
|
||||
reason: typeof record.reason === "string" ? record.reason : null,
|
||||
message: typeof record.message === "string" ? record.message : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function conditionSummary(status: Record<string, unknown> | undefined, type: string): { reason: string | null; message: string | null } | null {
|
||||
const conditions = Array.isArray(status?.conditions) ? status.conditions : [];
|
||||
for (const item of conditions) {
|
||||
if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
if (record.type !== type || record.status === "True") continue;
|
||||
return {
|
||||
reason: typeof record.reason === "string" ? record.reason : null,
|
||||
message: typeof record.message === "string" ? record.message : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function metadataName(item: Record<string, unknown>): string | null {
|
||||
const metadata = item.metadata as Record<string, unknown> | undefined;
|
||||
return typeof metadata?.name === "string" ? metadata.name : null;
|
||||
}
|
||||
|
||||
export function manifestObjectSummary(yaml: string): Array<Record<string, string>> {
|
||||
const docs = yaml.split(/^---$/mu);
|
||||
return docs.map((doc) => {
|
||||
const kind = doc.match(/^\s*kind:\s*(.+)$/mu)?.[1]?.trim() ?? "unknown";
|
||||
const name = doc.match(/^\s*name:\s*(.+)$/mu)?.[1]?.trim() ?? "unknown";
|
||||
return { kind, name };
|
||||
});
|
||||
}
|
||||
|
||||
export function imageReference(image: ImageSpec): string {
|
||||
return `${image.repository}:${image.tag}`;
|
||||
}
|
||||
|
||||
export function indent(value: string, spaces: number): string {
|
||||
const prefix = " ".repeat(spaces);
|
||||
return value.split("\n").map((line) => `${prefix}${line}`).join("\n");
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. trace-script module for scripts/src/platform-infra-observability.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-observability.ts:1575-1813 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 { tempoService } from "./search-script";
|
||||
import { imageReference, indent } from "./summary";
|
||||
|
||||
export function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function limitArray(value: unknown, limit: number): unknown[] {
|
||||
return asArray(value).slice(0, limit);
|
||||
}
|
||||
|
||||
export function renderManifest(observability: ObservabilityConfig, target: ObservabilityTarget): string {
|
||||
const collectorImage = imageReference(observability.images.collector);
|
||||
const tempoImage = imageReference(observability.images.tempo);
|
||||
return [
|
||||
target.createNamespace ? namespaceManifest(target) : "",
|
||||
allowAllNetworkPolicy(target),
|
||||
collectorConfigMap(observability, target),
|
||||
collectorDeployment(observability, target, collectorImage),
|
||||
collectorService(observability, target),
|
||||
tempoConfigMap(observability, target),
|
||||
tempoDeployment(observability, target, tempoImage),
|
||||
tempoService(observability, target),
|
||||
].filter((item) => item.trim().length > 0).join("\n---\n");
|
||||
}
|
||||
|
||||
export function namespaceManifest(target: ObservabilityTarget): string {
|
||||
return `apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
unidesk.ai/runtime-node: ${target.id}
|
||||
`;
|
||||
}
|
||||
|
||||
export function allowAllNetworkPolicy(target: ObservabilityTarget): string {
|
||||
return `apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-all
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- {}
|
||||
egress:
|
||||
- {}
|
||||
`;
|
||||
}
|
||||
|
||||
export function collectorConfigMap(observability: ObservabilityConfig, target: ObservabilityTarget): string {
|
||||
const config = `receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:${observability.collector.otlp.grpcPort}
|
||||
http:
|
||||
endpoint: 0.0.0.0:${observability.collector.otlp.httpPort}
|
||||
processors:
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp/tempo:
|
||||
endpoint: ${observability.traceBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.traceBackend.otlp.grpcPort}
|
||||
tls:
|
||||
insecure: true
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: 0.0.0.0:${observability.collector.healthPort}
|
||||
service:
|
||||
extensions: [health_check]
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [otlp/tempo]
|
||||
`;
|
||||
return `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ${observability.collector.configMapName}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
||||
app.kubernetes.io/component: tracing
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
annotations:
|
||||
unidesk.ai/spec: "${observability.metadata.spec}"
|
||||
data:
|
||||
collector.yaml: |
|
||||
${indent(config, 4)}
|
||||
`;
|
||||
}
|
||||
|
||||
export function collectorDeployment(observability: ObservabilityConfig, target: ObservabilityTarget, image: string): string {
|
||||
return `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${observability.collector.deploymentName}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
||||
app.kubernetes.io/component: tracing
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
spec:
|
||||
replicas: ${observability.collector.replicas}
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 0
|
||||
maxUnavailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
||||
app.kubernetes.io/component: tracing
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
||||
app.kubernetes.io/component: tracing
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
annotations:
|
||||
unidesk.ai/spec: "${observability.metadata.spec}"
|
||||
spec:
|
||||
containers:
|
||||
- name: collector
|
||||
image: ${image}
|
||||
imagePullPolicy: ${observability.images.collector.pullPolicy}
|
||||
args:
|
||||
- --config=/etc/otelcol/collector.yaml
|
||||
ports:
|
||||
- name: otlp-grpc
|
||||
containerPort: ${observability.collector.otlp.grpcPort}
|
||||
- name: otlp-http
|
||||
containerPort: ${observability.collector.otlp.httpPort}
|
||||
- name: health
|
||||
containerPort: ${observability.collector.healthPort}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: health
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/otelcol/collector.yaml
|
||||
subPath: collector.yaml
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: ${observability.collector.configMapName}
|
||||
`;
|
||||
}
|
||||
|
||||
export function collectorService(observability: ObservabilityConfig, target: ObservabilityTarget): string {
|
||||
return `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${observability.collector.serviceName}
|
||||
namespace: ${target.namespace}
|
||||
labels:
|
||||
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
||||
app.kubernetes.io/component: tracing
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
||||
app.kubernetes.io/component: tracing
|
||||
ports:
|
||||
- name: otlp-grpc
|
||||
port: ${observability.collector.otlp.grpcPort}
|
||||
targetPort: otlp-grpc
|
||||
- name: otlp-http
|
||||
port: ${observability.collector.otlp.httpPort}
|
||||
targetPort: otlp-http
|
||||
- name: health
|
||||
port: ${observability.collector.healthPort}
|
||||
targetPort: health
|
||||
`;
|
||||
}
|
||||
|
||||
export function tempoConfigMap(observability: ObservabilityConfig, _target: ObservabilityTarget): string {
|
||||
const config = `server:
|
||||
http_listen_port: ${observability.traceBackend.httpPort}
|
||||
distributor:
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:${observability.traceBackend.otlp.grpcPort}
|
||||
http:
|
||||
endpoint: 0.0.0.0:${observability.traceBackend.otlp.httpPort}
|
||||
ingester:
|
||||
trace_idle_period: 10s
|
||||
max_block_duration: 5m
|
||||
compactor:
|
||||
compaction:
|
||||
block_retention: ${observability.traceBackend.storage.retention}
|
||||
storage:
|
||||
trace:
|
||||
backend: local
|
||||
wal:
|
||||
path: /var/tempo/wal
|
||||
local:
|
||||
path: /var/tempo/traces
|
||||
`;
|
||||
return `apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ${observability.traceBackend.configMapName}
|
||||
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
|
||||
annotations:
|
||||
unidesk.ai/spec: "${observability.metadata.spec}"
|
||||
data:
|
||||
tempo.yaml: |
|
||||
${indent(config, 4)}
|
||||
`;
|
||||
}
|
||||
|
||||
export function tempoDeployment(observability: ObservabilityConfig, target: ObservabilityTarget, image: string): string {
|
||||
return `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${observability.traceBackend.deploymentName}
|
||||
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:
|
||||
replicas: ${observability.traceBackend.replicas}
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 0
|
||||
maxUnavailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
|
||||
app.kubernetes.io/component: trace-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
|
||||
app.kubernetes.io/component: trace-backend
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
annotations:
|
||||
unidesk.ai/spec: "${observability.metadata.spec}"
|
||||
spec:
|
||||
containers:
|
||||
- name: tempo
|
||||
image: ${image}
|
||||
imagePullPolicy: ${observability.images.tempo.pullPolicy}
|
||||
args:
|
||||
- -config.file=/etc/tempo/tempo.yaml
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: ${observability.traceBackend.httpPort}
|
||||
- name: otlp-grpc
|
||||
containerPort: ${observability.traceBackend.otlp.grpcPort}
|
||||
- name: otlp-http
|
||||
containerPort: ${observability.traceBackend.otlp.httpPort}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: ${observability.probes.readinessPath}
|
||||
port: http
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/tempo/tempo.yaml
|
||||
subPath: tempo.yaml
|
||||
readOnly: true
|
||||
- name: data
|
||||
mountPath: /var/tempo
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: ${observability.traceBackend.configMapName}
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. types module for scripts/src/platform-infra-observability.ts.
|
||||
|
||||
// Moved mechanically from scripts/src/platform-infra-observability.ts:1-149 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 { status } from "./actions";
|
||||
|
||||
export const configFile = rootPath("config", "platform-infra", "observability.yaml");
|
||||
|
||||
export const configLabel = "config/platform-infra/observability.yaml";
|
||||
|
||||
export const fieldManager = "unidesk-platform-observability";
|
||||
|
||||
export const {
|
||||
asRecord,
|
||||
objectField,
|
||||
arrayOfRecords,
|
||||
stringField,
|
||||
integerField,
|
||||
booleanField,
|
||||
stringArrayField,
|
||||
numberArrayField,
|
||||
enumField,
|
||||
kubernetesNameField,
|
||||
portField,
|
||||
apiPathField,
|
||||
} = createYamlFieldReader(configLabel);
|
||||
|
||||
export interface ObservabilityConfig {
|
||||
version: number;
|
||||
kind: "platform-infra-observability";
|
||||
metadata: { id: string; owner: string; spec: string; relatedIssues: number[] };
|
||||
defaults: { targetId: string };
|
||||
images: {
|
||||
collector: ImageSpec;
|
||||
tempo: ImageSpec;
|
||||
};
|
||||
targets: ObservabilityTarget[];
|
||||
collector: {
|
||||
deploymentName: string;
|
||||
serviceName: string;
|
||||
configMapName: string;
|
||||
replicas: number;
|
||||
healthPort: number;
|
||||
otlp: OtlpPorts;
|
||||
};
|
||||
traceBackend: {
|
||||
type: "tempo";
|
||||
deploymentName: string;
|
||||
serviceName: string;
|
||||
configMapName: string;
|
||||
replicas: number;
|
||||
httpPort: number;
|
||||
otlp: OtlpPorts;
|
||||
storage: { mode: "emptyDir"; retention: string };
|
||||
};
|
||||
sampling: { mode: "parentbased_traceidratio"; ratio: number };
|
||||
instrumentation: {
|
||||
contextPropagation: string[];
|
||||
serviceConnections: ServiceConnection[];
|
||||
};
|
||||
resourceAttributes: {
|
||||
required: string[];
|
||||
businessCorrelationAttributes: string[];
|
||||
};
|
||||
probes: {
|
||||
readinessPath: string;
|
||||
traceQueryPathTemplate: string;
|
||||
statusEndpoints: StatusEndpoint[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageSpec {
|
||||
repository: string;
|
||||
tag: string;
|
||||
pullPolicy: "Always" | "IfNotPresent" | "Never";
|
||||
}
|
||||
|
||||
export interface ObservabilityTarget {
|
||||
id: string;
|
||||
route: string;
|
||||
namespace: string;
|
||||
role: "active" | "standby";
|
||||
enabled: boolean;
|
||||
createNamespace: boolean;
|
||||
}
|
||||
|
||||
export interface OtlpPorts {
|
||||
grpcPort: number;
|
||||
httpPort: number;
|
||||
}
|
||||
|
||||
export interface ServiceConnection {
|
||||
serviceName: string;
|
||||
owningRepo: string;
|
||||
targetNode: string;
|
||||
lane: string;
|
||||
namespace: string;
|
||||
requiredSpans: string[];
|
||||
}
|
||||
|
||||
export interface StatusEndpoint {
|
||||
name: string;
|
||||
service: string;
|
||||
portName: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface CommonOptions {
|
||||
targetId: string | null;
|
||||
full: boolean;
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
export interface ApplyOptions extends CommonOptions {
|
||||
confirm: boolean;
|
||||
dryRun: boolean;
|
||||
wait: boolean;
|
||||
}
|
||||
|
||||
export interface TraceOptions extends CommonOptions {
|
||||
traceId: string | null;
|
||||
grep: string | null;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface SearchOptions extends CommonOptions {
|
||||
grep: string | null;
|
||||
query: string | null;
|
||||
path: string | null;
|
||||
status: number | null;
|
||||
limit: number;
|
||||
candidateLimit: number;
|
||||
lookbackMinutes: number;
|
||||
endAt: string | null;
|
||||
}
|
||||
|
||||
export interface DiagnoseCodeAgentOptions extends CommonOptions {
|
||||
businessTraceId: string | null;
|
||||
traceId: string | null;
|
||||
limit: number;
|
||||
candidateLimit: number;
|
||||
lookbackMinutes: number;
|
||||
}
|
||||
Reference in New Issue
Block a user