From f516691a6dde4ab6fef81fa8f815fe5d18446416 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 20:58:00 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=20Prometheus=20=E5=8F=AF=E8=A7=82=E6=B5=8B=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/observability.yaml | 40 +++++++++++++++ docs/reference/observability.md | 9 ++++ .../src/platform-infra-observability.test.ts | 9 ++-- .../platform-infra-observability/actions.ts | 3 ++ .../platform-infra-observability/config.ts | 45 ++++++++++++++++- .../platform-infra-observability/options.ts | 37 ++++++++++++-- .../plan-render.ts | 4 ++ .../prometheus.ts | 50 +++++++++++++++++++ .../search-script.ts | 17 ++++++- .../platform-infra-observability/summary.ts | 21 +++++++- .../trace-script.ts | 2 + .../src/platform-infra-observability/types.ts | 30 +++++++++++ 12 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 scripts/src/platform-infra-observability/prometheus.ts diff --git a/config/platform-infra/observability.yaml b/config/platform-infra/observability.yaml index 3bb2e19a..045b2760 100644 --- a/config/platform-infra/observability.yaml +++ b/config/platform-infra/observability.yaml @@ -21,6 +21,10 @@ images: repository: docker.m.daocloud.io/grafana/tempo tag: 2.8.1 pullPolicy: IfNotPresent + prometheus: + repository: docker.m.daocloud.io/prom/prometheus + tag: v3.5.0 + pullPolicy: IfNotPresent targets: - id: NC01 @@ -66,6 +70,38 @@ traceBackend: mode: emptyDir retention: 24h +metricsBackend: + type: prometheus + enabled: true + deploymentName: prometheus + serviceName: prometheus + configMapName: prometheus-config + serviceAccountName: prometheus + clusterRoleName: platform-infra-prometheus + clusterRoleBindingName: platform-infra-prometheus + persistentVolumeClaimName: prometheus-data + replicas: 1 + httpPort: 9090 + storage: + mode: persistentVolumeClaim + size: 20Gi + retention: 15d + discovery: + namespaces: [] + labelSelector: unidesk.ai/metrics=true + annotationKeys: + scrape: prometheus.io/scrape + path: prometheus.io/path + port: prometheus.io/port + defaultPath: /metrics + scrapeInterval: 30s + scrapeTimeout: 10s + query: + path: /api/v1/query + timeoutSeconds: 15 + maxSeries: 200 + maxResponseBytes: 262144 + sampling: mode: parentbased_traceidratio ratio: 1 @@ -185,3 +221,7 @@ probes: service: otel-collector portName: health path: / + - name: prometheus-ready + service: prometheus + portName: http + path: /-/ready diff --git a/docs/reference/observability.md b/docs/reference/observability.md index a109cdf6..4ae7d50c 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -2,6 +2,15 @@ UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker 日志和数据库状态都必须能通过短命令查询。 +## 共享 Prometheus 指标面 + +- `config/platform-infra/observability.yaml` 同时拥有 Collector、Tempo 和 Prometheus 的 image、retention、storage、port、服务发现与查询预算;TypeScript 只校验和渲染。 +- Prometheus 使用 Kubernetes 原生 pod 服务发现。服务通过稳定 label `unidesk.ai/metrics=true` 与 `prometheus.io/scrape=true`、`prometheus.io/path`、`prometheus.io/port` annotation 声明 `/metrics` 抓取,不允许业务专属分支。 +- `bun scripts/cli.ts platform-infra observability plan --target ` 展示 Prometheus enabled/disabled、Service、发现选择器和 manifest 摘要。 +- `bun scripts/cli.ts platform-infra observability status --target ` 与 `validate` 将 Prometheus readiness 单列为 warning;不得改变业务 readiness 或交付成功结论。 +- `bun scripts/cli.ts platform-infra observability metrics-query --target --query ` 只执行 YAML 预算约束的瞬时 PromQL 查询;默认有界,`--full` 展开结构,`--raw` 仅用于解析诊断。 +- Collector + Tempo 继续是 trace authority;Prometheus 只承担 metrics,不成为第二业务状态库或交付门禁。 + ## Capability Boundary 版本、commit 和微服务契约漂移只属于可观测性信号。OTel、日志、diagnose 和 status 必须把它们投影为非阻塞 warning,并继续以真实业务结果为权威;不得把漂移告警升级为业务失败、admission blocker、readiness failure 或 CI/CD gate。完整边界见 [DevOps Hygiene](devops-hygiene.md#prohibited-deployment-truth)。 diff --git a/scripts/src/platform-infra-observability.test.ts b/scripts/src/platform-infra-observability.test.ts index 064b904c..e86c0421 100644 --- a/scripts/src/platform-infra-observability.test.ts +++ b/scripts/src/platform-infra-observability.test.ts @@ -13,6 +13,8 @@ describe("platform-infra observability progressive disclosure", () => { expect(Buffer.byteLength(compact.renderedText, "utf8")).toBeLessThan(10_240); expect(compact.renderedText).toContain("serviceConnections=8"); expect(compact.renderedText).toContain("configRefs=6/6 missing=0"); + expect(compact.renderedText).toContain("prometheus=true service=prometheus"); + expect(compact.renderedText).toContain("scrapeSelector=unidesk.ai/metrics=true"); expect(compact.renderedText).toContain("--full"); expect(compact.renderedText).toContain("--raw"); @@ -25,6 +27,7 @@ describe("platform-infra observability progressive disclosure", () => { renderPlan: { target: { id: "NC01", route: "NC01:k3s", namespace: "platform-infra" } }, }); expect((expanded as Record).renderPlan.instrumentation).toHaveLength(8); + expect((expanded as Record).config.metricsBackend).toMatchObject({ type: "prometheus", enabled: true }); } }); @@ -38,8 +41,8 @@ describe("platform-infra observability progressive disclosure", () => { expect(result.stdout).not.toContain("/tmp/unidesk-cli-output"); }); - test("search, trace and diagnose-code-agent expose only scoped help", () => { - for (const operation of ["search", "trace", "diagnose-code-agent"]) { + test("query commands expose only scoped help", () => { + for (const operation of ["search", "trace", "diagnose-code-agent", "metrics-query"]) { const result = cli(["platform-infra", "observability", operation, "--help"]); expect(result.status).toBe(0); const payload = JSON.parse(result.stdout) as Record; @@ -57,7 +60,7 @@ describe("platform-infra observability progressive disclosure", () => { expect(result.status).toBe(0); const payload = JSON.parse(result.stdout) as Record; expect(payload.data.operations.map((item: Record) => item.name)).toEqual([ - "plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", + "plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query", ]); expect(payload.data.scopedHelp).toContain(" --help"); expect(payload.data.usage).toBeUndefined(); diff --git a/scripts/src/platform-infra-observability/actions.ts b/scripts/src/platform-infra-observability/actions.ts index fbbc182e..24369aa0 100644 --- a/scripts/src/platform-infra-observability/actions.ts +++ b/scripts/src/platform-infra-observability/actions.ts @@ -69,6 +69,8 @@ export function plan(options: CommonOptions): Record { 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}`, + prometheusHttpEndpoint: observability.metricsBackend.enabled ? `http://${observability.metricsBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.metricsBackend.httpPort}` : null, + scrapeDiscovery: observability.metricsBackend.discovery, }, instrumentation: observability.instrumentation.serviceConnections, resourceAttributes: observability.resourceAttributes, @@ -80,6 +82,7 @@ export function plan(options: CommonOptions): Record { 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 `, + metricsQuery: `bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query `, }, }; } diff --git a/scripts/src/platform-infra-observability/config.ts b/scripts/src/platform-infra-observability/config.ts index 119f4b19..a941c7be 100644 --- a/scripts/src/platform-infra-observability/config.ts +++ b/scripts/src/platform-infra-observability/config.ts @@ -39,6 +39,11 @@ export function readObservabilityConfig(): ObservabilityConfig { const traceBackend = objectField(root, "traceBackend", ""); const traceBackendOtlp = objectField(traceBackend, "otlp", "traceBackend"); const traceBackendStorage = objectField(traceBackend, "storage", "traceBackend"); + const metricsBackend = objectField(root, "metricsBackend", ""); + const metricsStorage = objectField(metricsBackend, "storage", "metricsBackend"); + const metricsDiscovery = objectField(metricsBackend, "discovery", "metricsBackend"); + const metricsAnnotations = objectField(metricsDiscovery, "annotationKeys", "metricsBackend.discovery"); + const metricsQuery = objectField(metricsBackend, "query", "metricsBackend"); const sampling = objectField(root, "sampling", ""); const instrumentation = objectField(root, "instrumentation", ""); const resourceAttributes = objectField(root, "resourceAttributes", ""); @@ -56,6 +61,7 @@ export function readObservabilityConfig(): ObservabilityConfig { images: { collector: imageSpec(objectField(images, "collector", "images"), "images.collector"), tempo: imageSpec(objectField(images, "tempo", "images"), "images.tempo"), + prometheus: imageSpec(objectField(images, "prometheus", "images"), "images.prometheus"), }, targets: arrayOfRecords(root.targets, "targets").map(parseTarget), collector: { @@ -79,6 +85,42 @@ export function readObservabilityConfig(): ObservabilityConfig { retention: stringField(traceBackendStorage, "retention", "traceBackend.storage"), }, }, + metricsBackend: { + type: enumField(metricsBackend, "type", "metricsBackend", ["prometheus"] as const), + enabled: booleanField(metricsBackend, "enabled", "metricsBackend"), + deploymentName: kubernetesNameField(metricsBackend, "deploymentName", "metricsBackend"), + serviceName: kubernetesNameField(metricsBackend, "serviceName", "metricsBackend"), + configMapName: kubernetesNameField(metricsBackend, "configMapName", "metricsBackend"), + serviceAccountName: kubernetesNameField(metricsBackend, "serviceAccountName", "metricsBackend"), + clusterRoleName: kubernetesNameField(metricsBackend, "clusterRoleName", "metricsBackend"), + clusterRoleBindingName: kubernetesNameField(metricsBackend, "clusterRoleBindingName", "metricsBackend"), + persistentVolumeClaimName: kubernetesNameField(metricsBackend, "persistentVolumeClaimName", "metricsBackend"), + replicas: integerField(metricsBackend, "replicas", "metricsBackend"), + httpPort: portField(metricsBackend, "httpPort", "metricsBackend"), + storage: { + mode: enumField(metricsStorage, "mode", "metricsBackend.storage", ["persistentVolumeClaim"] as const), + size: stringField(metricsStorage, "size", "metricsBackend.storage"), + retention: stringField(metricsStorage, "retention", "metricsBackend.storage"), + }, + discovery: { + namespaces: stringArrayField(metricsDiscovery, "namespaces", "metricsBackend.discovery"), + labelSelector: stringField(metricsDiscovery, "labelSelector", "metricsBackend.discovery"), + annotationKeys: { + scrape: stringField(metricsAnnotations, "scrape", "metricsBackend.discovery.annotationKeys"), + path: stringField(metricsAnnotations, "path", "metricsBackend.discovery.annotationKeys"), + port: stringField(metricsAnnotations, "port", "metricsBackend.discovery.annotationKeys"), + }, + defaultPath: apiPathField(metricsDiscovery, "defaultPath", "metricsBackend.discovery"), + scrapeInterval: stringField(metricsDiscovery, "scrapeInterval", "metricsBackend.discovery"), + scrapeTimeout: stringField(metricsDiscovery, "scrapeTimeout", "metricsBackend.discovery"), + }, + query: { + path: apiPathField(metricsQuery, "path", "metricsBackend.query"), + timeoutSeconds: integerField(metricsQuery, "timeoutSeconds", "metricsBackend.query"), + maxSeries: integerField(metricsQuery, "maxSeries", "metricsBackend.query"), + maxResponseBytes: integerField(metricsQuery, "maxResponseBytes", "metricsBackend.query"), + }, + }, sampling: { mode: enumField(sampling, "mode", "sampling", ["parentbased_traceidratio"] as const), ratio: numberField(sampling, "ratio", "sampling"), @@ -99,7 +141,8 @@ export function readObservabilityConfig(): ObservabilityConfig { }; 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.collector.replicas < 0 || config.traceBackend.replicas < 0 || config.metricsBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`); + if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`); 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; diff --git a/scripts/src/platform-infra-observability/options.ts b/scripts/src/platform-infra-observability/options.ts index 46800fc9..b9bea663 100644 --- a/scripts/src/platform-infra-observability/options.ts +++ b/scripts/src/platform-infra-observability/options.ts @@ -19,18 +19,19 @@ import { capture, } from "../platform-infra-ops-library"; -import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, SearchOptions, TraceOptions } from "./types"; +import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, MetricsQueryOptions, SearchOptions, TraceOptions } from "./types"; import { apply, plan, status, validate } from "./actions"; import { diagnoseCodeAgent, search, trace } from "./render"; import { renderObservabilityPlan, renderObservabilityPlanFailure } from "./plan-render"; +import { metricsQuery } from "./prometheus"; -const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent"] as const; +const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query"] as const; export function observabilityHelp(scope: string | null = null): Record { const scoped = scope === null ? null : observabilityOperationHelp(scope); if (scoped !== null) return scoped; return { - command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent", + command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent|metrics-query", output: "默认输出为有界摘要;--full/--raw 显式披露完整结构", configTruth: "config/platform-infra/observability.yaml", spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0", @@ -39,7 +40,7 @@ export function observabilityHelp(scope: string | null = null): Record --help", - boundary: "Prometheus 仍是指标来源;本命令只负责 platform-infra OTel Collector、Tempo readiness 与 trace 查询。", + boundary: "Collector + Tempo 是 trace authority;Prometheus 仅承担 metrics,故障和漂移保持非阻塞 warning。", }; } @@ -62,6 +63,7 @@ export async function runPlatformObservabilityCommand(config: UniDeskConfig, arg 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))); + if (action === "metrics-query") return await metricsQuery(config, parseMetricsQueryOptions(args.slice(1))); return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() }; } @@ -105,6 +107,12 @@ function observabilityOperationHelp(scope: string): Record | nu ], options: ["--trace-id <32-hex>", "--target ", "--grep ", "--limit <1..500>", "--full", "--raw"], }; + if (scope === "metrics-query") return { + ...common, + command: "platform-infra observability metrics-query", + usage: ["bun scripts/cli.ts platform-infra observability metrics-query --target --query [--full|--raw]"], + options: ["--target ", "--query ", "--full", "--raw"], + }; if (scope === "search") return { ...common, command: "platform-infra observability search", @@ -126,6 +134,27 @@ function observabilityOperationHelp(scope: string): Record | nu return null; } +export function parseMetricsQueryOptions(args: string[]): MetricsQueryOptions { + const commonArgs: string[] = []; + let query: string | null = null; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--query") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--query requires a value"); + query = value; + index += 1; + } else { + commonArgs.push(arg); + if (arg === "--target") { + commonArgs.push(args[index + 1] ?? ""); + index += 1; + } + } + } + return { ...parseCommonOptions(commonArgs), query }; +} + export function parseCommonOptions(args: string[]): CommonOptions { let targetId: string | null = null; let full = false; diff --git a/scripts/src/platform-infra-observability/plan-render.ts b/scripts/src/platform-infra-observability/plan-render.ts index cac6c86f..aa02c981 100644 --- a/scripts/src/platform-infra-observability/plan-render.ts +++ b/scripts/src/platform-infra-observability/plan-render.ts @@ -8,6 +8,7 @@ export function renderObservabilityPlan(result: Record): Render const images = record(config.images); const collector = record(config.collector); const traceBackend = record(config.traceBackend); + const metricsBackend = record(config.metricsBackend); const renderPlan = record(result.renderPlan); const otlp = record(renderPlan.otlp); const connections = records(config.serviceConnections); @@ -39,6 +40,8 @@ export function renderObservabilityPlan(result: Record): Render `target=${textValue(target.id)} route=${textValue(target.route)} namespace=${textValue(target.namespace)} role=${textValue(target.role)}`, `collector=${textValue(collector.serviceName)} replicas=${textValue(collector.replicas)} image=${textValue(images.collector)}`, `tempo=${textValue(traceBackend.serviceName)} replicas=${textValue(traceBackend.replicas)} retention=${textValue(record(traceBackend.storage).retention)} image=${textValue(images.traceBackend)}`, + `prometheus=${textValue(metricsBackend.enabled)} service=${textValue(metricsBackend.serviceName)} replicas=${textValue(metricsBackend.replicas)} retention=${textValue(record(metricsBackend.storage).retention)} image=${textValue(images.metricsBackend)}`, + `scrapeSelector=${textValue(record(metricsBackend.discovery).labelSelector)} namespaces=${values(record(metricsBackend.discovery).namespaces).length === 0 ? "all" : values(record(metricsBackend.discovery).namespaces).join(",")} queryBudget=${textValue(record(metricsBackend.query).timeoutSeconds)}s/${textValue(record(metricsBackend.query).maxSeries)}series/${textValue(record(metricsBackend.query).maxResponseBytes)}bytes`, `serviceConnections=${connections.length} services=${serviceNames.size} nodes=${nodes.size} configRefs=${presentConfigRefs.length}/${configRefs.length} missing=${missingConfigRefs.length} objects=${objects.length}`, "", "Service connections:", @@ -77,6 +80,7 @@ export function renderObservabilityPlan(result: Record): Render ` collector-grpc: ${textValue(otlp.collectorGrpcEndpoint)}`, ` collector-http: ${textValue(otlp.collectorHttpEndpoint)}`, ` tempo-grpc: ${textValue(otlp.backendGrpcEndpoint)}`, + ` prometheus-http: ${textValue(otlp.prometheusHttpEndpoint)}`, "", "Next:", ` bun scripts/cli.ts platform-infra observability plan --target ${textValue(target.id)} --full`, diff --git a/scripts/src/platform-infra-observability/prometheus.ts b/scripts/src/platform-infra-observability/prometheus.ts new file mode 100644 index 00000000..dfd3536b --- /dev/null +++ b/scripts/src/platform-infra-observability/prometheus.ts @@ -0,0 +1,50 @@ +import type { UniDeskConfig } from "../config"; +import type { RenderedCliResult } from "../output"; +import { capture, compactCapture, parseJsonOutput, redactSensitiveUnknown, shQuote } from "../platform-infra-ops-library"; +import { resolveTarget } from "./actions"; +import { readObservabilityConfig } from "./config"; +import { formatTable, shortenEnd, textValue } from "./manifest"; +import { imageReference, indent, targetSummary } from "./summary"; +import type { MetricsQueryOptions, ObservabilityConfig, ObservabilityTarget } from "./types"; + +export function prometheusManifests(observability: ObservabilityConfig, target: ObservabilityTarget): string[] { + const prometheus = observability.metricsBackend; + if (!prometheus.enabled) return []; + const namespaceNames = prometheus.discovery.namespaces.length > 0 + ? `\n namespaces:\n names:\n${prometheus.discovery.namespaces.map((name) => ` - ${name}`).join("\n")}` + : ""; + const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${labelMeta(prometheus.discovery.labelSelector.split("=")[0] ?? "")} ]\n action: keep\n regex: \"${prometheus.discovery.labelSelector.split("=")[1] ?? ".+"}\"\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - target_label: __metrics_path__\n replacement: ${prometheus.discovery.defaultPath}\n`; + const renderedConfig = config.replace( + " - target_label: __metrics_path__\n replacement:", + ` - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement:`, + ); + const labels = ` app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n app.kubernetes.io/managed-by: unidesk`; + return [ + `apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n labels:\n${labels}\n`, + `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: ${prometheus.clusterRoleName}\nrules:\n - apiGroups: [\"\"]\n resources: [\"nodes\", \"nodes/proxy\", \"services\", \"endpoints\", \"pods\"]\n verbs: [\"get\", \"list\", \"watch\"]\n`, + `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n name: ${prometheus.clusterRoleBindingName}\nroleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: ClusterRole\n name: ${prometheus.clusterRoleName}\nsubjects:\n - kind: ServiceAccount\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n`, + `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ${prometheus.configMapName}\n namespace: ${target.namespace}\n labels:\n${labels}\ndata:\n prometheus.yml: |\n${indent(renderedConfig, 4)}\n`, + `apiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n name: ${prometheus.persistentVolumeClaimName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n accessModes: [ReadWriteOnce]\n resources:\n requests:\n storage: ${prometheus.storage.size}\n`, + `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: ${prometheus.deploymentName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n replicas: ${prometheus.replicas}\n selector:\n matchLabels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n template:\n metadata:\n labels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n spec:\n serviceAccountName: ${prometheus.serviceAccountName}\n containers:\n - name: prometheus\n image: ${imageReference(observability.images.prometheus)}\n imagePullPolicy: ${observability.images.prometheus.pullPolicy}\n args:\n - --config.file=/etc/prometheus/prometheus.yml\n - --storage.tsdb.path=/prometheus\n - --storage.tsdb.retention.time=${prometheus.storage.retention}\n ports:\n - name: http\n containerPort: ${prometheus.httpPort}\n readinessProbe:\n httpGet:\n path: /-/ready\n port: http\n volumeMounts:\n - name: config\n mountPath: /etc/prometheus/prometheus.yml\n subPath: prometheus.yml\n readOnly: true\n - name: data\n mountPath: /prometheus\n volumes:\n - name: config\n configMap:\n name: ${prometheus.configMapName}\n - name: data\n persistentVolumeClaim:\n claimName: ${prometheus.persistentVolumeClaimName}\n`, + `apiVersion: v1\nkind: Service\nmetadata:\n name: ${prometheus.serviceName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n type: ClusterIP\n selector:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n ports:\n - name: http\n port: ${prometheus.httpPort}\n targetPort: http\n`, + ]; +} + +export async function metricsQuery(config: UniDeskConfig, options: MetricsQueryOptions): Promise | RenderedCliResult> { + if (options.query === null || options.query.trim() === "") throw new Error("observability metrics-query requires --query "); + const observability = readObservabilityConfig(); + const target = resolveTarget(observability, options.targetId); + const prometheus = observability.metricsBackend; + if (!prometheus.enabled) return { ok: false, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), metrics: { enabled: false }, warnings: ["Prometheus is disabled in config/platform-infra/observability.yaml"] }; + const path = `${prometheus.query.path}?query=${encodeURIComponent(options.query)}`; + const script = `set -u\npython3 - <<'PY'\nimport json, subprocess\npath = ${JSON.stringify(`/api/v1/namespaces/${target.namespace}/services/http:${prometheus.serviceName}:http/proxy${path}`)}\nproc = subprocess.run([\"kubectl\", \"get\", \"--raw\", path], text=True, capture_output=True, timeout=${prometheus.query.timeoutSeconds})\nbody = proc.stdout[:${prometheus.query.maxResponseBytes}]\ntry:\n parsed = json.loads(body) if body else None\nexcept Exception:\n parsed = None\nresult = parsed.get(\"data\", {}).get(\"result\", []) if isinstance(parsed, dict) else []\nprint(json.dumps({\"ok\": proc.returncode == 0 and isinstance(parsed, dict) and parsed.get(\"status\") == \"success\", \"exitCode\": proc.returncode, \"truncated\": len(proc.stdout) > ${prometheus.query.maxResponseBytes} or len(result) > ${prometheus.query.maxSeries}, \"resultType\": parsed.get(\"data\", {}).get(\"resultType\") if isinstance(parsed, dict) else None, \"result\": result[:${prometheus.query.maxSeries}], \"stderrTail\": proc.stderr[-2000:]}, ensure_ascii=False))\nPY`; + const result = await capture(config, target.route, ["sh"], script); + const parsed = parseJsonOutput(result.stdout); + const ok = result.exitCode === 0 && parsed?.ok === true; + if (options.full || options.raw) return { ok, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), query: options.query, budgets: prometheus.query, result: parsed === null ? compactCapture(result, { full: true }) : redactSensitiveUnknown(parsed) }; + const rows = Array.isArray(parsed?.result) ? parsed.result.slice(0, 20).map((item: unknown) => [shortenEnd(JSON.stringify(item), 160)]) : []; + return { ok, command: "platform-infra observability metrics-query", contentType: "text/plain", renderedText: [`platform-infra observability metrics-query (${ok ? "ok" : "not-ok"})`, "", `target=${target.id} prometheus=${prometheus.serviceName}.${target.namespace}.svc.cluster.local:${prometheus.httpPort}`, `query=${options.query}`, `budget=timeout:${prometheus.query.timeoutSeconds}s series:${prometheus.query.maxSeries} bytes:${prometheus.query.maxResponseBytes} truncated=${textValue(parsed?.truncated)}`, "", formatTable(["RESULT"], rows.length > 0 ? rows : [["-"]]), "", `Next: bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query ${shQuote(options.query)} --full`].join("\n") }; +} + +function annotationMeta(value: string): string { return value.replaceAll(".", "_").replaceAll("/", "_"); } +function labelMeta(value: string): string { return value.replaceAll(".", "_").replaceAll("/", "_").replaceAll("-", "_"); } diff --git a/scripts/src/platform-infra-observability/search-script.ts b/scripts/src/platform-infra-observability/search-script.ts index 1a1b0538..1bd8df23 100644 --- a/scripts/src/platform-infra-observability/search-script.ts +++ b/scripts/src/platform-infra-observability/search-script.ts @@ -102,7 +102,13 @@ PY } export function statusScript(observability: ObservabilityConfig, target: ObservabilityTarget, full: boolean, generateValidationTrace = false): string { - const endpointsJson = JSON.stringify(observability.probes.statusEndpoints); + const enabledEndpoints = observability.metricsBackend.enabled + ? observability.probes.statusEndpoints + : observability.probes.statusEndpoints.filter((endpoint) => endpoint.service !== observability.metricsBackend.serviceName); + const endpointsJson = JSON.stringify(enabledEndpoints); + const metricsCaptures = observability.metricsBackend.enabled + ? `capture_json metrics_deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.metricsBackend.deploymentName)} -o json\ncapture_json metrics_services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.metricsBackend.serviceName)} -o json\ncapture_json metrics_pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name=${observability.metricsBackend.deploymentName}`)} -o json` + : ""; return ` set -u tmp="$(mktemp -d)" @@ -123,6 +129,7 @@ capture_json namespace kubectl get namespace ${shQuote(target.namespace)} -o jso 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 +${metricsCaptures} 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 @@ -304,7 +311,9 @@ def generate_test_trace(): 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) +blocking_probes = [item for item in probe_results if item.get("service") != "${observability.metricsBackend.serviceName}"] +metrics_probes = [item for item in probe_results if item.get("service") == "${observability.metricsBackend.serviceName}"] +runtime_ready = rc("namespace") == 0 and rc("deployments") == 0 and rc("services") == 0 and all(item["ok"] for item in blocking_probes) 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), @@ -316,8 +325,12 @@ payload = { "services": compact_section("services"), "pods": compact_section("pods"), "events": compact_section("events"), + "metricsDeployments": compact_section("metrics_deployments") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, + "metricsServices": compact_section("metrics_services") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, + "metricsPods": compact_section("metrics_pods") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, }, "probes": probe_results, + "warnings": ([{"code": "prometheus-not-ready", "detail": item} for item in metrics_probes if item.get("ok") is not True] if ${observability.metricsBackend.enabled ? "True" : "False"} else [{"code": "prometheus-disabled"}]), "validationTrace": validation_trace, } print(json.dumps(payload, ensure_ascii=False, indent=2 if ${full ? "True" : "False"} else None)) diff --git a/scripts/src/platform-infra-observability/summary.ts b/scripts/src/platform-infra-observability/summary.ts index 3f530e1d..0c035f87 100644 --- a/scripts/src/platform-infra-observability/summary.ts +++ b/scripts/src/platform-infra-observability/summary.ts @@ -32,9 +32,11 @@ export function configSummary(observability: ObservabilityConfig, target: Observ images: { collector: imageReference(observability.images.collector), traceBackend: imageReference(observability.images.tempo), + metricsBackend: imageReference(observability.images.prometheus), }, collector: observability.collector, traceBackend: observability.traceBackend, + metricsBackend: observability.metricsBackend, sampling: observability.sampling, serviceConnections: observability.instrumentation.serviceConnections.map((item) => ({ serviceName: item.serviceName, @@ -62,8 +64,8 @@ export function targetSummary(target: ObservabilityTarget): Record> { 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: "yaml-source-of-truth", ok: true, detail: "All concrete images, routes, namespace, ports, retention, storage, scrape discovery and query budgets are read from config/platform-infra/observability.yaml." }, + { name: "clusterip-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Collector, Tempo and Prometheus 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}.` }, @@ -76,6 +78,9 @@ export function statusSummary(payload: Record): Record> : []; const readyDeployments = deployments.map((item) => deploymentSummary(item)); @@ -92,6 +97,12 @@ export function statusSummary(payload: Record): Record deploymentSummary(item)), + services: metricsServices.map((item) => metadataName(item)), + pods: metricsPods.map((item) => podSummary(item, podEvents)), + }, }; } @@ -106,6 +117,12 @@ export function sectionJson(sections: Record, name: string): un return section.json; } +function optionalSectionJson(sections: Record, name: string): unknown { + const value = sections[name]; + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return (value as Record).json; +} + export function objectList(value: unknown): Record[] { if (typeof value !== "object" || value === null) return []; const items = (value as Record).items; diff --git a/scripts/src/platform-infra-observability/trace-script.ts b/scripts/src/platform-infra-observability/trace-script.ts index a9c123c3..acbda357 100644 --- a/scripts/src/platform-infra-observability/trace-script.ts +++ b/scripts/src/platform-infra-observability/trace-script.ts @@ -22,6 +22,7 @@ import { import type { ObservabilityConfig, ObservabilityTarget } from "./types"; import { tempoService } from "./search-script"; import { imageReference, indent } from "./summary"; +import { prometheusManifests } from "./prometheus"; export function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; @@ -43,6 +44,7 @@ export function renderManifest(observability: ObservabilityConfig, target: Obser tempoConfigMap(observability, target), tempoDeployment(observability, target, tempoImage), tempoService(observability, target), + ...prometheusManifests(observability, target), ].filter((item) => item.trim().length > 0).join("\n---\n"); } diff --git a/scripts/src/platform-infra-observability/types.ts b/scripts/src/platform-infra-observability/types.ts index 3fc94b97..2ce889df 100644 --- a/scripts/src/platform-infra-observability/types.ts +++ b/scripts/src/platform-infra-observability/types.ts @@ -51,6 +51,7 @@ export interface ObservabilityConfig { images: { collector: ImageSpec; tempo: ImageSpec; + prometheus: ImageSpec; }; targets: ObservabilityTarget[]; collector: { @@ -71,6 +72,7 @@ export interface ObservabilityConfig { otlp: OtlpPorts; storage: { mode: "emptyDir"; retention: string }; }; + metricsBackend: PrometheusConfig; sampling: { mode: "parentbased_traceidratio"; ratio: number }; instrumentation: { contextPropagation: string[]; @@ -87,6 +89,30 @@ export interface ObservabilityConfig { }; } +export interface PrometheusConfig { + type: "prometheus"; + enabled: boolean; + deploymentName: string; + serviceName: string; + configMapName: string; + serviceAccountName: string; + clusterRoleName: string; + clusterRoleBindingName: string; + persistentVolumeClaimName: string; + replicas: number; + httpPort: number; + storage: { mode: "persistentVolumeClaim"; size: string; retention: string }; + discovery: { + namespaces: string[]; + labelSelector: string; + annotationKeys: { scrape: string; path: string; port: string }; + defaultPath: string; + scrapeInterval: string; + scrapeTimeout: string; + }; + query: { path: string; timeoutSeconds: number; maxSeries: number; maxResponseBytes: number }; +} + export interface ImageSpec { repository: string; tag: string; @@ -165,3 +191,7 @@ export interface DiagnoseCodeAgentOptions extends CommonOptions { candidateLimit: number; lookbackMinutes: number; } + +export interface MetricsQueryOptions extends CommonOptions { + query: string | null; +} From ef3b5fde28308fec25df614798de4b5692c5cbfd Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:27:44 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E9=99=90=E5=AE=9A=20Prometheus=20?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E4=B8=8E=E5=8F=91=E7=8E=B0=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/observability.yaml | 6 ++- docs/reference/observability.md | 5 ++- .../src/platform-infra-observability.test.ts | 44 ++++++++++++++++++- .../platform-infra-observability/actions.ts | 8 +++- .../platform-infra-observability/config.ts | 12 ++++- .../metrics-target.ts | 12 +++++ .../plan-render.ts | 5 ++- .../prometheus.ts | 14 +++--- .../search-script.ts | 15 ++++--- .../src/platform-infra-observability/types.ts | 3 +- 10 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 scripts/src/platform-infra-observability/metrics-target.ts diff --git a/config/platform-infra/observability.yaml b/config/platform-infra/observability.yaml index 045b2760..1c4e3dd8 100644 --- a/config/platform-infra/observability.yaml +++ b/config/platform-infra/observability.yaml @@ -73,6 +73,8 @@ traceBackend: metricsBackend: type: prometheus enabled: true + targetIds: + - NC01 deploymentName: prometheus serviceName: prometheus configMapName: prometheus-config @@ -88,7 +90,9 @@ metricsBackend: retention: 15d discovery: namespaces: [] - labelSelector: unidesk.ai/metrics=true + labelSelector: + key: unidesk.ai/metrics + value: "true" annotationKeys: scrape: prometheus.io/scrape path: prometheus.io/path diff --git a/docs/reference/observability.md b/docs/reference/observability.md index 4ae7d50c..760b58cc 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -4,8 +4,9 @@ UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker ## 共享 Prometheus 指标面 -- `config/platform-infra/observability.yaml` 同时拥有 Collector、Tempo 和 Prometheus 的 image、retention、storage、port、服务发现与查询预算;TypeScript 只校验和渲染。 -- Prometheus 使用 Kubernetes 原生 pod 服务发现。服务通过稳定 label `unidesk.ai/metrics=true` 与 `prometheus.io/scrape=true`、`prometheus.io/path`、`prometheus.io/port` annotation 声明 `/metrics` 抓取,不允许业务专属分支。 +- `config/platform-infra/observability.yaml` 同时拥有 Collector、Tempo 和 Prometheus 的 image、retention、storage、port、目标 `targetIds`、服务发现与查询预算;TypeScript 只校验和渲染。 +- Prometheus 仅作用于 `metricsBackend.targetIds` 选中的 observability target;其他 target 的 plan 显示 `disabled-for-target`,manifest、status probe/capture 和 `metrics-query` 均不访问 Prometheus。 +- Prometheus 使用 Kubernetes 原生 pod 服务发现。YAML 通过结构化 `labelSelector.key/value` 声明稳定 label,并用 `prometheus.io/scrape=true`、`prometheus.io/path`、`prometheus.io/port` annotation 声明抓取;显式 path 优先,仅在 annotation 缺失时使用 `defaultPath`,不允许业务专属分支。 - `bun scripts/cli.ts platform-infra observability plan --target ` 展示 Prometheus enabled/disabled、Service、发现选择器和 manifest 摘要。 - `bun scripts/cli.ts platform-infra observability status --target ` 与 `validate` 将 Prometheus readiness 单列为 warning;不得改变业务 readiness 或交付成功结论。 - `bun scripts/cli.ts platform-infra observability metrics-query --target --query ` 只执行 YAML 预算约束的瞬时 PromQL 查询;默认有界,`--full` 展开结构,`--raw` 仅用于解析诊断。 diff --git a/scripts/src/platform-infra-observability.test.ts b/scripts/src/platform-infra-observability.test.ts index e86c0421..6022844d 100644 --- a/scripts/src/platform-infra-observability.test.ts +++ b/scripts/src/platform-infra-observability.test.ts @@ -3,7 +3,11 @@ import { spawnSync } from "node:child_process"; import { rootPath } from "./config"; import { isRenderedCliResult } from "./output"; import { runPlatformObservabilityCommand } from "./platform-infra-observability"; +import { resolveTarget } from "./platform-infra-observability/actions"; +import { readObservabilityConfig } from "./platform-infra-observability/config"; import { renderObservabilityPlanFailure } from "./platform-infra-observability/plan-render"; +import { statusScript } from "./platform-infra-observability/search-script"; +import { renderManifest } from "./platform-infra-observability/trace-script"; describe("platform-infra observability progressive disclosure", () => { test("default plan is a bounded table while full and raw preserve the complete plan", async () => { @@ -13,7 +17,7 @@ describe("platform-infra observability progressive disclosure", () => { expect(Buffer.byteLength(compact.renderedText, "utf8")).toBeLessThan(10_240); expect(compact.renderedText).toContain("serviceConnections=8"); expect(compact.renderedText).toContain("configRefs=6/6 missing=0"); - expect(compact.renderedText).toContain("prometheus=true service=prometheus"); + expect(compact.renderedText).toContain("prometheus=enabled service=prometheus targets=NC01"); expect(compact.renderedText).toContain("scrapeSelector=unidesk.ai/metrics=true"); expect(compact.renderedText).toContain("--full"); expect(compact.renderedText).toContain("--raw"); @@ -41,6 +45,44 @@ describe("platform-infra observability progressive disclosure", () => { expect(result.stdout).not.toContain("/tmp/unidesk-cli-output"); }); + test("Prometheus manifests and runtime access only apply to YAML-selected targets", async () => { + const observability = readObservabilityConfig(); + const nc01 = resolveTarget(observability, "NC01"); + const d518 = resolveTarget(observability, "D518"); + const jd01 = resolveTarget(observability, "JD01"); + const nc01Manifest = renderManifest(observability, nc01); + for (const target of [d518, jd01]) { + const manifest = renderManifest(observability, target); + expect(manifest).not.toContain("kind: ClusterRole\nmetadata:\n name: platform-infra-prometheus"); + expect(manifest).not.toContain("kind: PersistentVolumeClaim\nmetadata:\n name: prometheus-data"); + expect(manifest).not.toContain("app.kubernetes.io/component: metrics-backend"); + const plan = await runPlatformObservabilityCommand({} as never, ["plan", "--target", target.id]); + expect(isRenderedCliResult(plan)).toBe(true); + if (!isRenderedCliResult(plan)) throw new Error("expected rendered plan"); + expect(plan.renderedText).toContain("prometheus=disabled-for-target"); + expect(plan.renderedText).toContain("objects=8"); + const script = statusScript(observability, target, false); + expect(script).not.toContain("capture_json metrics_deployments"); + expect(script).not.toContain("prometheus-ready"); + const query = await runPlatformObservabilityCommand({} as never, ["metrics-query", "--target", target.id, "--query", "up", "--full"]); + expect(query).toMatchObject({ metrics: { enabled: false, disposition: "disabled-for-target", targetIds: ["NC01"] } }); + } + expect(nc01Manifest).toContain("kind: ClusterRole\nmetadata:\n name: platform-infra-prometheus"); + expect(nc01Manifest).toContain("kind: PersistentVolumeClaim\nmetadata:\n name: prometheus-data"); + }); + + test("Prometheus relabel rules prefer an explicit path and only default when absent", () => { + const observability = readObservabilityConfig(); + const manifest = renderManifest(observability, resolveTarget(observability, "NC01")); + const explicitPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)"; + const defaultPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: /metrics"; + expect(manifest).toContain("source_labels: [__meta_kubernetes_pod_label_unidesk_ai_metrics]"); + expect(manifest).toContain('regex: "true"'); + expect(manifest).toContain(explicitPath); + expect(manifest).toContain(defaultPath); + expect(manifest.indexOf(explicitPath)).toBeLessThan(manifest.indexOf(defaultPath)); + }); + test("query commands expose only scoped help", () => { for (const operation of ["search", "trace", "diagnose-code-agent", "metrics-query"]) { const result = cli(["platform-infra", "observability", operation, "--help"]); diff --git a/scripts/src/platform-infra-observability/actions.ts b/scripts/src/platform-infra-observability/actions.ts index 24369aa0..ba8cbd14 100644 --- a/scripts/src/platform-infra-observability/actions.ts +++ b/scripts/src/platform-infra-observability/actions.ts @@ -27,6 +27,7 @@ import { compactStatus, configSummary, manifestObjectSummary, policyChecks, stat import { renderManifest } from "./trace-script"; import { formatTable, shortenEnd, textValue } from "./manifest"; import { apiPathField, configLabel, kubernetesNameField, stringField } from "./types"; +import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target"; export function parseStatusEndpoint(record: Record, index: number): StatusEndpoint { const path = `probes.statusEndpoints[${index}]`; @@ -57,6 +58,7 @@ export function plan(options: CommonOptions): Record { const target = resolveTarget(observability, options.targetId); const yaml = renderManifest(observability, target); const policy = policyChecks(yaml, target); + const metricsDisposition = metricsTargetDisposition(observability, target); return { ok: policy.every((check) => check.ok), action: "platform-infra-observability-plan", @@ -65,11 +67,15 @@ export function plan(options: CommonOptions): Record { renderPlan: { target: targetSummary(target), objects: manifestObjectSummary(yaml), + metrics: { + disposition: metricsDisposition, + targetIds: observability.metricsBackend.targetIds, + }, 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}`, - prometheusHttpEndpoint: observability.metricsBackend.enabled ? `http://${observability.metricsBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.metricsBackend.httpPort}` : null, + prometheusHttpEndpoint: metricsEnabledForTarget(observability, target) ? `http://${observability.metricsBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.metricsBackend.httpPort}` : null, scrapeDiscovery: observability.metricsBackend.discovery, }, instrumentation: observability.instrumentation.serviceConnections, diff --git a/scripts/src/platform-infra-observability/config.ts b/scripts/src/platform-infra-observability/config.ts index a941c7be..33b1fb10 100644 --- a/scripts/src/platform-infra-observability/config.ts +++ b/scripts/src/platform-infra-observability/config.ts @@ -42,6 +42,7 @@ export function readObservabilityConfig(): ObservabilityConfig { const metricsBackend = objectField(root, "metricsBackend", ""); const metricsStorage = objectField(metricsBackend, "storage", "metricsBackend"); const metricsDiscovery = objectField(metricsBackend, "discovery", "metricsBackend"); + const metricsLabelSelector = objectField(metricsDiscovery, "labelSelector", "metricsBackend.discovery"); const metricsAnnotations = objectField(metricsDiscovery, "annotationKeys", "metricsBackend.discovery"); const metricsQuery = objectField(metricsBackend, "query", "metricsBackend"); const sampling = objectField(root, "sampling", ""); @@ -88,6 +89,7 @@ export function readObservabilityConfig(): ObservabilityConfig { metricsBackend: { type: enumField(metricsBackend, "type", "metricsBackend", ["prometheus"] as const), enabled: booleanField(metricsBackend, "enabled", "metricsBackend"), + targetIds: stringArrayField(metricsBackend, "targetIds", "metricsBackend"), deploymentName: kubernetesNameField(metricsBackend, "deploymentName", "metricsBackend"), serviceName: kubernetesNameField(metricsBackend, "serviceName", "metricsBackend"), configMapName: kubernetesNameField(metricsBackend, "configMapName", "metricsBackend"), @@ -104,7 +106,10 @@ export function readObservabilityConfig(): ObservabilityConfig { }, discovery: { namespaces: stringArrayField(metricsDiscovery, "namespaces", "metricsBackend.discovery"), - labelSelector: stringField(metricsDiscovery, "labelSelector", "metricsBackend.discovery"), + labelSelector: { + key: stringField(metricsLabelSelector, "key", "metricsBackend.discovery.labelSelector"), + value: stringField(metricsLabelSelector, "value", "metricsBackend.discovery.labelSelector"), + }, annotationKeys: { scrape: stringField(metricsAnnotations, "scrape", "metricsBackend.discovery.annotationKeys"), path: stringField(metricsAnnotations, "path", "metricsBackend.discovery.annotationKeys"), @@ -141,6 +146,11 @@ export function readObservabilityConfig(): ObservabilityConfig { }; if (config.targets.length === 0) throw new Error(`${configLabel}.targets must not be empty`); assertKnownEnabledTarget(config.targets, config.defaults.targetId, "defaults.targetId"); + for (const targetId of config.metricsBackend.targetIds) assertKnownEnabledTarget(config.targets, targetId, "metricsBackend.targetIds"); + if (config.metricsBackend.enabled && config.metricsBackend.targetIds.length === 0) throw new Error(`${configLabel}.metricsBackend.targetIds must not be empty when metricsBackend.enabled is true`); + if (new Set(config.metricsBackend.targetIds.map((targetId) => targetId.toLowerCase())).size !== config.metricsBackend.targetIds.length) throw new Error(`${configLabel}.metricsBackend.targetIds must not contain duplicates`); + if (!/^[A-Za-z0-9]([A-Za-z0-9._/-]*[A-Za-z0-9])?$/u.test(config.metricsBackend.discovery.labelSelector.key)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.key must be a Kubernetes label key`); + if (!/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)?$/u.test(config.metricsBackend.discovery.labelSelector.value) || config.metricsBackend.discovery.labelSelector.value.length > 63) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.value must be a Kubernetes label value`); if (config.collector.replicas < 0 || config.traceBackend.replicas < 0 || config.metricsBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`); if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`); if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`); diff --git a/scripts/src/platform-infra-observability/metrics-target.ts b/scripts/src/platform-infra-observability/metrics-target.ts new file mode 100644 index 00000000..1fa27a3b --- /dev/null +++ b/scripts/src/platform-infra-observability/metrics-target.ts @@ -0,0 +1,12 @@ +import type { ObservabilityConfig, ObservabilityTarget } from "./types"; + +export type MetricsTargetDisposition = "enabled" | "disabled" | "disabled-for-target"; + +export function metricsTargetDisposition(observability: ObservabilityConfig, target: ObservabilityTarget): MetricsTargetDisposition { + if (!observability.metricsBackend.enabled) return "disabled"; + return observability.metricsBackend.targetIds.some((targetId) => targetId.toLowerCase() === target.id.toLowerCase()) ? "enabled" : "disabled-for-target"; +} + +export function metricsEnabledForTarget(observability: ObservabilityConfig, target: ObservabilityTarget): boolean { + return metricsTargetDisposition(observability, target) === "enabled"; +} diff --git a/scripts/src/platform-infra-observability/plan-render.ts b/scripts/src/platform-infra-observability/plan-render.ts index aa02c981..c41f365e 100644 --- a/scripts/src/platform-infra-observability/plan-render.ts +++ b/scripts/src/platform-infra-observability/plan-render.ts @@ -10,6 +10,7 @@ export function renderObservabilityPlan(result: Record): Render const traceBackend = record(config.traceBackend); const metricsBackend = record(config.metricsBackend); const renderPlan = record(result.renderPlan); + const metricsPlan = record(renderPlan.metrics); const otlp = record(renderPlan.otlp); const connections = records(config.serviceConnections); const objects = records(renderPlan.objects); @@ -40,8 +41,8 @@ export function renderObservabilityPlan(result: Record): Render `target=${textValue(target.id)} route=${textValue(target.route)} namespace=${textValue(target.namespace)} role=${textValue(target.role)}`, `collector=${textValue(collector.serviceName)} replicas=${textValue(collector.replicas)} image=${textValue(images.collector)}`, `tempo=${textValue(traceBackend.serviceName)} replicas=${textValue(traceBackend.replicas)} retention=${textValue(record(traceBackend.storage).retention)} image=${textValue(images.traceBackend)}`, - `prometheus=${textValue(metricsBackend.enabled)} service=${textValue(metricsBackend.serviceName)} replicas=${textValue(metricsBackend.replicas)} retention=${textValue(record(metricsBackend.storage).retention)} image=${textValue(images.metricsBackend)}`, - `scrapeSelector=${textValue(record(metricsBackend.discovery).labelSelector)} namespaces=${values(record(metricsBackend.discovery).namespaces).length === 0 ? "all" : values(record(metricsBackend.discovery).namespaces).join(",")} queryBudget=${textValue(record(metricsBackend.query).timeoutSeconds)}s/${textValue(record(metricsBackend.query).maxSeries)}series/${textValue(record(metricsBackend.query).maxResponseBytes)}bytes`, + `prometheus=${textValue(metricsPlan.disposition)} service=${textValue(metricsBackend.serviceName)} targets=${values(metricsPlan.targetIds).join(",")} replicas=${textValue(metricsBackend.replicas)} retention=${textValue(record(metricsBackend.storage).retention)} image=${textValue(images.metricsBackend)}`, + `scrapeSelector=${textValue(record(record(metricsBackend.discovery).labelSelector).key)}=${textValue(record(record(metricsBackend.discovery).labelSelector).value)} namespaces=${values(record(metricsBackend.discovery).namespaces).length === 0 ? "all" : values(record(metricsBackend.discovery).namespaces).join(",")} queryBudget=${textValue(record(metricsBackend.query).timeoutSeconds)}s/${textValue(record(metricsBackend.query).maxSeries)}series/${textValue(record(metricsBackend.query).maxResponseBytes)}bytes`, `serviceConnections=${connections.length} services=${serviceNames.size} nodes=${nodes.size} configRefs=${presentConfigRefs.length}/${configRefs.length} missing=${missingConfigRefs.length} objects=${objects.length}`, "", "Service connections:", diff --git a/scripts/src/platform-infra-observability/prometheus.ts b/scripts/src/platform-infra-observability/prometheus.ts index dfd3536b..bb28d59c 100644 --- a/scripts/src/platform-infra-observability/prometheus.ts +++ b/scripts/src/platform-infra-observability/prometheus.ts @@ -6,24 +6,21 @@ import { readObservabilityConfig } from "./config"; import { formatTable, shortenEnd, textValue } from "./manifest"; import { imageReference, indent, targetSummary } from "./summary"; import type { MetricsQueryOptions, ObservabilityConfig, ObservabilityTarget } from "./types"; +import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target"; export function prometheusManifests(observability: ObservabilityConfig, target: ObservabilityTarget): string[] { const prometheus = observability.metricsBackend; - if (!prometheus.enabled) return []; + if (!metricsEnabledForTarget(observability, target)) return []; const namespaceNames = prometheus.discovery.namespaces.length > 0 ? `\n namespaces:\n names:\n${prometheus.discovery.namespaces.map((name) => ` - ${name}`).join("\n")}` : ""; - const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${labelMeta(prometheus.discovery.labelSelector.split("=")[0] ?? "")} ]\n action: keep\n regex: \"${prometheus.discovery.labelSelector.split("=")[1] ?? ".+"}\"\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - target_label: __metrics_path__\n replacement: ${prometheus.discovery.defaultPath}\n`; - const renderedConfig = config.replace( - " - target_label: __metrics_path__\n replacement:", - ` - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement:`, - ); + const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${labelMeta(prometheus.discovery.labelSelector.key)}]\n action: keep\n regex: \"${prometheus.discovery.labelSelector.value}\"\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: ${prometheus.discovery.defaultPath}\n`; const labels = ` app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n app.kubernetes.io/managed-by: unidesk`; return [ `apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n labels:\n${labels}\n`, `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: ${prometheus.clusterRoleName}\nrules:\n - apiGroups: [\"\"]\n resources: [\"nodes\", \"nodes/proxy\", \"services\", \"endpoints\", \"pods\"]\n verbs: [\"get\", \"list\", \"watch\"]\n`, `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n name: ${prometheus.clusterRoleBindingName}\nroleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: ClusterRole\n name: ${prometheus.clusterRoleName}\nsubjects:\n - kind: ServiceAccount\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n`, - `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ${prometheus.configMapName}\n namespace: ${target.namespace}\n labels:\n${labels}\ndata:\n prometheus.yml: |\n${indent(renderedConfig, 4)}\n`, + `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ${prometheus.configMapName}\n namespace: ${target.namespace}\n labels:\n${labels}\ndata:\n prometheus.yml: |\n${indent(config, 4)}\n`, `apiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n name: ${prometheus.persistentVolumeClaimName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n accessModes: [ReadWriteOnce]\n resources:\n requests:\n storage: ${prometheus.storage.size}\n`, `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: ${prometheus.deploymentName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n replicas: ${prometheus.replicas}\n selector:\n matchLabels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n template:\n metadata:\n labels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n spec:\n serviceAccountName: ${prometheus.serviceAccountName}\n containers:\n - name: prometheus\n image: ${imageReference(observability.images.prometheus)}\n imagePullPolicy: ${observability.images.prometheus.pullPolicy}\n args:\n - --config.file=/etc/prometheus/prometheus.yml\n - --storage.tsdb.path=/prometheus\n - --storage.tsdb.retention.time=${prometheus.storage.retention}\n ports:\n - name: http\n containerPort: ${prometheus.httpPort}\n readinessProbe:\n httpGet:\n path: /-/ready\n port: http\n volumeMounts:\n - name: config\n mountPath: /etc/prometheus/prometheus.yml\n subPath: prometheus.yml\n readOnly: true\n - name: data\n mountPath: /prometheus\n volumes:\n - name: config\n configMap:\n name: ${prometheus.configMapName}\n - name: data\n persistentVolumeClaim:\n claimName: ${prometheus.persistentVolumeClaimName}\n`, `apiVersion: v1\nkind: Service\nmetadata:\n name: ${prometheus.serviceName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n type: ClusterIP\n selector:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n ports:\n - name: http\n port: ${prometheus.httpPort}\n targetPort: http\n`, @@ -35,7 +32,8 @@ export async function metricsQuery(config: UniDeskConfig, options: MetricsQueryO const observability = readObservabilityConfig(); const target = resolveTarget(observability, options.targetId); const prometheus = observability.metricsBackend; - if (!prometheus.enabled) return { ok: false, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), metrics: { enabled: false }, warnings: ["Prometheus is disabled in config/platform-infra/observability.yaml"] }; + const disposition = metricsTargetDisposition(observability, target); + if (disposition !== "enabled") return { ok: false, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), metrics: { enabled: false, disposition, targetIds: prometheus.targetIds }, warnings: [`Prometheus is ${disposition} in config/platform-infra/observability.yaml`] }; const path = `${prometheus.query.path}?query=${encodeURIComponent(options.query)}`; const script = `set -u\npython3 - <<'PY'\nimport json, subprocess\npath = ${JSON.stringify(`/api/v1/namespaces/${target.namespace}/services/http:${prometheus.serviceName}:http/proxy${path}`)}\nproc = subprocess.run([\"kubectl\", \"get\", \"--raw\", path], text=True, capture_output=True, timeout=${prometheus.query.timeoutSeconds})\nbody = proc.stdout[:${prometheus.query.maxResponseBytes}]\ntry:\n parsed = json.loads(body) if body else None\nexcept Exception:\n parsed = None\nresult = parsed.get(\"data\", {}).get(\"result\", []) if isinstance(parsed, dict) else []\nprint(json.dumps({\"ok\": proc.returncode == 0 and isinstance(parsed, dict) and parsed.get(\"status\") == \"success\", \"exitCode\": proc.returncode, \"truncated\": len(proc.stdout) > ${prometheus.query.maxResponseBytes} or len(result) > ${prometheus.query.maxSeries}, \"resultType\": parsed.get(\"data\", {}).get(\"resultType\") if isinstance(parsed, dict) else None, \"result\": result[:${prometheus.query.maxSeries}], \"stderrTail\": proc.stderr[-2000:]}, ensure_ascii=False))\nPY`; const result = await capture(config, target.route, ["sh"], script); diff --git a/scripts/src/platform-infra-observability/search-script.ts b/scripts/src/platform-infra-observability/search-script.ts index 1bd8df23..83295560 100644 --- a/scripts/src/platform-infra-observability/search-script.ts +++ b/scripts/src/platform-infra-observability/search-script.ts @@ -21,6 +21,7 @@ import { import type { ObservabilityConfig, ObservabilityTarget } from "./types"; import { fieldManager } from "./types"; +import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target"; export function tempoService(observability: ObservabilityConfig, target: ObservabilityTarget): string { return `apiVersion: v1 @@ -102,11 +103,13 @@ PY } export function statusScript(observability: ObservabilityConfig, target: ObservabilityTarget, full: boolean, generateValidationTrace = false): string { - const enabledEndpoints = observability.metricsBackend.enabled + const metricsEnabled = metricsEnabledForTarget(observability, target); + const metricsDisposition = metricsTargetDisposition(observability, target); + const enabledEndpoints = metricsEnabled ? observability.probes.statusEndpoints : observability.probes.statusEndpoints.filter((endpoint) => endpoint.service !== observability.metricsBackend.serviceName); const endpointsJson = JSON.stringify(enabledEndpoints); - const metricsCaptures = observability.metricsBackend.enabled + const metricsCaptures = metricsEnabled ? `capture_json metrics_deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.metricsBackend.deploymentName)} -o json\ncapture_json metrics_services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.metricsBackend.serviceName)} -o json\ncapture_json metrics_pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name=${observability.metricsBackend.deploymentName}`)} -o json` : ""; return ` @@ -325,12 +328,12 @@ payload = { "services": compact_section("services"), "pods": compact_section("pods"), "events": compact_section("events"), - "metricsDeployments": compact_section("metrics_deployments") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, - "metricsServices": compact_section("metrics_services") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, - "metricsPods": compact_section("metrics_pods") if ${observability.metricsBackend.enabled ? "True" : "False"} else None, + "metricsDeployments": compact_section("metrics_deployments") if ${metricsEnabled ? "True" : "False"} else None, + "metricsServices": compact_section("metrics_services") if ${metricsEnabled ? "True" : "False"} else None, + "metricsPods": compact_section("metrics_pods") if ${metricsEnabled ? "True" : "False"} else None, }, "probes": probe_results, - "warnings": ([{"code": "prometheus-not-ready", "detail": item} for item in metrics_probes if item.get("ok") is not True] if ${observability.metricsBackend.enabled ? "True" : "False"} else [{"code": "prometheus-disabled"}]), + "warnings": ([{"code": "prometheus-not-ready", "detail": item} for item in metrics_probes if item.get("ok") is not True] if ${metricsEnabled ? "True" : "False"} else [{"code": "prometheus-${metricsDisposition}"}]), "validationTrace": validation_trace, } print(json.dumps(payload, ensure_ascii=False, indent=2 if ${full ? "True" : "False"} else None)) diff --git a/scripts/src/platform-infra-observability/types.ts b/scripts/src/platform-infra-observability/types.ts index 2ce889df..629ff0d7 100644 --- a/scripts/src/platform-infra-observability/types.ts +++ b/scripts/src/platform-infra-observability/types.ts @@ -92,6 +92,7 @@ export interface ObservabilityConfig { export interface PrometheusConfig { type: "prometheus"; enabled: boolean; + targetIds: string[]; deploymentName: string; serviceName: string; configMapName: string; @@ -104,7 +105,7 @@ export interface PrometheusConfig { storage: { mode: "persistentVolumeClaim"; size: string; retention: string }; discovery: { namespaces: string[]; - labelSelector: string; + labelSelector: { key: string; value: string }; annotationKeys: { scrape: string; path: string; port: string }; defaultPath: string; scrapeInterval: string; From c3762b310cf34266c166113527832396fd9edb22 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 21:35:36 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=B8=A5=E6=A0=BC=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=20Prometheus=20=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/platform-infra-observability.test.ts | 25 +++++++++++++++++-- .../platform-infra-observability/config.ts | 23 +++++++++++++++-- .../prometheus.ts | 11 +++++--- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/scripts/src/platform-infra-observability.test.ts b/scripts/src/platform-infra-observability.test.ts index 6022844d..8c4eec8a 100644 --- a/scripts/src/platform-infra-observability.test.ts +++ b/scripts/src/platform-infra-observability.test.ts @@ -4,8 +4,9 @@ import { rootPath } from "./config"; import { isRenderedCliResult } from "./output"; import { runPlatformObservabilityCommand } from "./platform-infra-observability"; import { resolveTarget } from "./platform-infra-observability/actions"; -import { readObservabilityConfig } from "./platform-infra-observability/config"; +import { isKubernetesLabelValue, isKubernetesQualifiedName, readObservabilityConfig } from "./platform-infra-observability/config"; import { renderObservabilityPlanFailure } from "./platform-infra-observability/plan-render"; +import { prometheusMetaLabel, re2Literal } from "./platform-infra-observability/prometheus"; import { statusScript } from "./platform-infra-observability/search-script"; import { renderManifest } from "./platform-infra-observability/trace-script"; @@ -77,12 +78,32 @@ describe("platform-infra observability progressive disclosure", () => { const explicitPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)"; const defaultPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: /metrics"; expect(manifest).toContain("source_labels: [__meta_kubernetes_pod_label_unidesk_ai_metrics]"); - expect(manifest).toContain('regex: "true"'); + expect(manifest).toContain("regex: '^true$'"); expect(manifest).toContain(explicitPath); expect(manifest).toContain(defaultPath); expect(manifest.indexOf(explicitPath)).toBeLessThan(manifest.indexOf(defaultPath)); }); + test("Prometheus selector rendering uses qualified names, stable meta labels and literal RE2", () => { + const observability = readObservabilityConfig(); + observability.metricsBackend.discovery.labelSelector = { + key: "metrics.example.com/release-track", + value: "v1.2-beta", + }; + observability.metricsBackend.discovery.annotationKeys.scrape = "prometheus.io/scrape-enabled"; + const manifest = renderManifest(observability, resolveTarget(observability, "NC01")); + expect(manifest).toContain("__meta_kubernetes_pod_label_metrics_example_com_release_track"); + expect(manifest).toContain("__meta_kubernetes_pod_annotation_prometheus_io_scrape_enabled"); + expect(manifest).toContain("regex: '^v1\\.2-beta$'"); + expect(prometheusMetaLabel("a.b/c-d_e")).toBe("a_b_c_d_e"); + expect(re2Literal("v1.2-beta")).toBe("^v1\\.2-beta$"); + expect(isKubernetesQualifiedName("metrics.example.com/release-track")).toBe(true); + expect(isKubernetesQualifiedName("metrics.example.com/release/track")).toBe(false); + expect(isKubernetesQualifiedName("Metrics.example.com/release-track")).toBe(false); + expect(isKubernetesQualifiedName(`${"a".repeat(64)}.example/release-track`)).toBe(false); + expect(isKubernetesLabelValue("v1.2-beta")).toBe(true); + }); + test("query commands expose only scoped help", () => { for (const operation of ["search", "trace", "diagnose-code-agent", "metrics-query"]) { const result = cli(["platform-infra", "observability", operation, "--help"]); diff --git a/scripts/src/platform-infra-observability/config.ts b/scripts/src/platform-infra-observability/config.ts index 33b1fb10..31c86b15 100644 --- a/scripts/src/platform-infra-observability/config.ts +++ b/scripts/src/platform-infra-observability/config.ts @@ -149,8 +149,8 @@ export function readObservabilityConfig(): ObservabilityConfig { for (const targetId of config.metricsBackend.targetIds) assertKnownEnabledTarget(config.targets, targetId, "metricsBackend.targetIds"); if (config.metricsBackend.enabled && config.metricsBackend.targetIds.length === 0) throw new Error(`${configLabel}.metricsBackend.targetIds must not be empty when metricsBackend.enabled is true`); if (new Set(config.metricsBackend.targetIds.map((targetId) => targetId.toLowerCase())).size !== config.metricsBackend.targetIds.length) throw new Error(`${configLabel}.metricsBackend.targetIds must not contain duplicates`); - if (!/^[A-Za-z0-9]([A-Za-z0-9._/-]*[A-Za-z0-9])?$/u.test(config.metricsBackend.discovery.labelSelector.key)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.key must be a Kubernetes label key`); - if (!/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)?$/u.test(config.metricsBackend.discovery.labelSelector.value) || config.metricsBackend.discovery.labelSelector.value.length > 63) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.value must be a Kubernetes label value`); + if (!isKubernetesQualifiedName(config.metricsBackend.discovery.labelSelector.key)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.key must be a Kubernetes qualified name`); + if (!isKubernetesLabelValue(config.metricsBackend.discovery.labelSelector.value)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.value must be a Kubernetes label value`); if (config.collector.replicas < 0 || config.traceBackend.replicas < 0 || config.metricsBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`); if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`); if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`); @@ -158,6 +158,25 @@ export function readObservabilityConfig(): ObservabilityConfig { return config; } +export function isKubernetesQualifiedName(value: string): boolean { + const slash = value.indexOf("/"); + if (slash !== value.lastIndexOf("/")) return false; + const prefix = slash < 0 ? null : value.slice(0, slash); + const name = slash < 0 ? value : value.slice(slash + 1); + if (!isKubernetesNamePart(name)) return false; + if (prefix === null) return true; + if (prefix.length === 0 || prefix.length > 253) return false; + return prefix.split(".").every((segment) => segment.length <= 63 && /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(segment)); +} + +export function isKubernetesLabelValue(value: string): boolean { + return value.length <= 63 && (value === "" || isKubernetesNamePart(value)); +} + +function isKubernetesNamePart(value: string): boolean { + return value.length > 0 && value.length <= 63 && /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/u.test(value); +} + export function imageSpec(record: Record, path: string): ImageSpec { const image = { repository: stringField(record, "repository", path), diff --git a/scripts/src/platform-infra-observability/prometheus.ts b/scripts/src/platform-infra-observability/prometheus.ts index bb28d59c..0b1b893a 100644 --- a/scripts/src/platform-infra-observability/prometheus.ts +++ b/scripts/src/platform-infra-observability/prometheus.ts @@ -14,7 +14,7 @@ export function prometheusManifests(observability: ObservabilityConfig, target: const namespaceNames = prometheus.discovery.namespaces.length > 0 ? `\n namespaces:\n names:\n${prometheus.discovery.namespaces.map((name) => ` - ${name}`).join("\n")}` : ""; - const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${labelMeta(prometheus.discovery.labelSelector.key)}]\n action: keep\n regex: \"${prometheus.discovery.labelSelector.value}\"\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - source_labels: [__meta_kubernetes_pod_annotation_${annotationMeta(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: ${prometheus.discovery.defaultPath}\n`; + const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${prometheusMetaLabel(prometheus.discovery.labelSelector.key)}]\n action: keep\n regex: '${re2Literal(prometheus.discovery.labelSelector.value)}'\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: ${prometheus.discovery.defaultPath}\n`; const labels = ` app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n app.kubernetes.io/managed-by: unidesk`; return [ `apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n labels:\n${labels}\n`, @@ -44,5 +44,10 @@ export async function metricsQuery(config: UniDeskConfig, options: MetricsQueryO return { ok, command: "platform-infra observability metrics-query", contentType: "text/plain", renderedText: [`platform-infra observability metrics-query (${ok ? "ok" : "not-ok"})`, "", `target=${target.id} prometheus=${prometheus.serviceName}.${target.namespace}.svc.cluster.local:${prometheus.httpPort}`, `query=${options.query}`, `budget=timeout:${prometheus.query.timeoutSeconds}s series:${prometheus.query.maxSeries} bytes:${prometheus.query.maxResponseBytes} truncated=${textValue(parsed?.truncated)}`, "", formatTable(["RESULT"], rows.length > 0 ? rows : [["-"]]), "", `Next: bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query ${shQuote(options.query)} --full`].join("\n") }; } -function annotationMeta(value: string): string { return value.replaceAll(".", "_").replaceAll("/", "_"); } -function labelMeta(value: string): string { return value.replaceAll(".", "_").replaceAll("/", "_").replaceAll("-", "_"); } +export function prometheusMetaLabel(value: string): string { + return value.replace(/[^A-Za-z0-9_]/gu, "_"); +} + +export function re2Literal(value: string): string { + return `^${value.replace(/[\\.^$|?*+()[\]{}]/gu, "\\$&")}$`; +}