refactor: split control-plane cli modules

This commit is contained in:
Codex
2026-06-25 16:16:00 +00:00
parent cca855daa3
commit e0af0f85ac
147 changed files with 63967 additions and 59999 deletions
@@ -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;
}