54 lines
9.4 KiB
TypeScript
54 lines
9.4 KiB
TypeScript
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";
|
|
import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target";
|
|
|
|
export function prometheusManifests(observability: ObservabilityConfig, target: ObservabilityTarget): string[] {
|
|
const prometheus = observability.metricsBackend;
|
|
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_${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`,
|
|
`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(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`,
|
|
];
|
|
}
|
|
|
|
export async function metricsQuery(config: UniDeskConfig, options: MetricsQueryOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
if (options.query === null || options.query.trim() === "") throw new Error("observability metrics-query requires --query <promql>");
|
|
const observability = readObservabilityConfig();
|
|
const target = resolveTarget(observability, options.targetId);
|
|
const prometheus = observability.metricsBackend;
|
|
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);
|
|
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") };
|
|
}
|
|
|
|
export function prometheusMetaLabel(value: string): string {
|
|
return value.replace(/[^A-Za-z0-9_]/gu, "_");
|
|
}
|
|
|
|
export function re2Literal(value: string): string {
|
|
return `^${value.replace(/[\\.^$|?*+()[\]{}]/gu, "\\$&")}$`;
|
|
}
|