diff --git a/cmd/nodevitals/main.go b/cmd/nodevitals/main.go index 0aeadba..8310d44 100644 --- a/cmd/nodevitals/main.go +++ b/cmd/nodevitals/main.go @@ -20,6 +20,8 @@ import ( "github.com/KeiaiLab/nodevitals/internal/event" "github.com/KeiaiLab/nodevitals/internal/history" "github.com/KeiaiLab/nodevitals/internal/httpapi" + "github.com/KeiaiLab/nodevitals/internal/ksmcompat" + "github.com/KeiaiLab/nodevitals/internal/nodecompat" "github.com/KeiaiLab/nodevitals/internal/nodeexporter" "github.com/KeiaiLab/nodevitals/internal/sink" "github.com/KeiaiLab/nodevitals/internal/smartctlcompat" @@ -46,6 +48,7 @@ func main() { for _, tier := range tiers { switch tier { case "core": + reg.Add(collector.NewHeartbeat(cfg.Node, "0.8.5")) reg.Add(collector.NewLoadAvg(cfg.Node, cfg.ProcRoot)) reg.Add(collector.NewCPU(cfg.Node, cfg.ProcRoot)) reg.Add(collector.NewMem(cfg.Node, cfg.ProcRoot)) @@ -115,12 +118,25 @@ func main() { // dashboards and alert rules built on node_* keep working untouched. neCount := 0 if cfg.NodeExporter.Enabled { + extraFlags := cfg.NodeExporter.ExtraFlags + if cfg.NodeExporter.NativeCollectors { + nc := nodecompat.New(cfg.ProcRoot, cfg.SysRoot, cfg.NodeExporter.RootFSPath, slog.Default()) + if err := metrics.Register(nc); err != nil { + slog.Error("register native nodecompat exporter", "err", err) + os.Exit(1) + } + slog.Info("native nodecompat collectors registered") + extraFlags = append(extraFlags, + "--no-collector.loadavg", + "--no-collector.uname", + ) + } c, err := nodeexporter.New(nodeexporter.Config{ ProcPath: cfg.ProcRoot, SysPath: cfg.SysRoot, RootFSPath: cfg.NodeExporter.RootFSPath, TextfileDir: cfg.NodeExporter.TextfileDir, - ExtraFlags: cfg.NodeExporter.ExtraFlags, + ExtraFlags: extraFlags, }, slog.Default()) if err != nil { slog.Error("node_exporter collectors", "err", err) @@ -142,6 +158,15 @@ func main() { slog.Info("node_exporter collectors registered", "count", neCount) } + if cfg.KSMCompat.Enabled { + ksm := ksmcompat.New(ksmcompat.Config{Node: cfg.Node, Mode: cfg.KSMCompat.Mode}) + if err := metrics.Register(ksm); err != nil { + slog.Error("register ksm compat exporter", "err", err) + os.Exit(1) + } + slog.Info("ksm compat surface enabled", "mode", cfg.KSMCompat.Mode) + } + // Long-term downsampled history — local to this node, survives past the // Prometheus scrape retention window. Opening failure is fatal (not a // silent skip): the operator explicitly asked for history, and a diff --git a/deploy/chart/templates/_helpers.tpl b/deploy/chart/templates/_helpers.tpl index a34b9ba..cad636d 100644 --- a/deploy/chart/templates/_helpers.tpl +++ b/deploy/chart/templates/_helpers.tpl @@ -66,6 +66,9 @@ Call with (dict "ctx" . "tier" ""). {{- define "nodevitals.configChecksums" -}} {{- $ctx := .ctx -}} {{- $suffix := ternary "" (printf "-%s" .tier) (eq .tier "core") -}} +prometheus.io/scrape: "true" +prometheus.io/port: {{ $ctx.Values.metrics.port | default "9847" | quote }} +prometheus.io/path: "/metrics" checksum/config: {{ include (print $ctx.Template.BasePath "/configmap" $suffix ".yaml") $ctx | sha256sum }} checksum/webhook-secret: {{ include (print $ctx.Template.BasePath "/secret.yaml") $ctx | sha256sum }} {{- end -}} diff --git a/deploy/chart/templates/configmap-single.yaml b/deploy/chart/templates/configmap-single.yaml index c05ea51..534b845 100644 --- a/deploy/chart/templates/configmap-single.yaml +++ b/deploy/chart/templates/configmap-single.yaml @@ -30,6 +30,7 @@ data: {{- if .Values.nodeExporter.enabled }} nodeExporter: enabled: true + nativeCollectors: {{ .Values.nodeExporter.nativeCollectors }} {{- if .Values.nodeExporter.mountRootFS }} rootfsPath: /host/root {{- end }} @@ -49,6 +50,11 @@ data: smartctlCompat: enabled: true {{- end }} +{{- if .Values.ksmCompat.enabled }} + ksmCompat: + enabled: true + mode: {{ .Values.ksmCompat.mode | default "node" | quote }} +{{- end }} {{- if .Values.history.enabled }} history: enabled: true diff --git a/deploy/chart/templates/configmap.yaml b/deploy/chart/templates/configmap.yaml index 7dde7f2..55b173d 100644 --- a/deploy/chart/templates/configmap.yaml +++ b/deploy/chart/templates/configmap.yaml @@ -17,6 +17,7 @@ data: {{- if .Values.nodeExporter.enabled }} nodeExporter: enabled: true + nativeCollectors: {{ .Values.nodeExporter.nativeCollectors }} {{- if .Values.nodeExporter.mountRootFS }} rootfsPath: /host/root {{- end }} @@ -28,6 +29,11 @@ data: {{ toYaml . | indent 8 }} {{- end }} {{- end }} +{{- if .Values.ksmCompat.enabled }} + ksmCompat: + enabled: true + mode: {{ .Values.ksmCompat.mode | default "node" | quote }} +{{- end }} {{- if .Values.history.enabled }} history: enabled: true diff --git a/deploy/chart/tests/compatibility-check.sh b/deploy/chart/tests/compatibility-check.sh new file mode 100755 index 0000000..7d55e82 --- /dev/null +++ b/deploy/chart/tests/compatibility-check.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# deploy/chart/tests/compatibility-check.sh +# Verifies that Helm templates render expected compatibility annotations and settings +# for gpu-operator, VictoriaMetrics (vmagent), nodeExporter, dcgmCompat, smartctlCompat, and ksmCompat. +set -euo pipefail + +CHART_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "=== 1. Checking default template rendering ===" +rendered="$(helm template nodevitals "$CHART_DIR")" + +echo "=== 2. Checking vmagent auto-discovery annotations ===" +echo "$rendered" | grep -q 'prometheus.io/scrape: "true"' || { echo "FAIL: missing prometheus.io/scrape annotation"; exit 1; } +echo "$rendered" | grep -q 'prometheus.io/port: "9847"' || { echo "FAIL: missing prometheus.io/port annotation"; exit 1; } +echo "PASS: vmagent annotations present" + +echo "=== 3. Checking gpu-operator & dcgmCompat rendering ===" +gpu_rendered="$(helm template nodevitals "$CHART_DIR" --set tiers.gpu.enabled=true --set tiers.gpu.runtimeClassName=nvidia --set dcgmCompat.enabled=true)" +echo "$gpu_rendered" | grep -q 'runtimeClassName:.*nvidia' || { echo "FAIL: runtimeClassName nvidia not rendered"; exit 1; } +echo "$gpu_rendered" | grep -q 'dcgmCompat:' || { echo "FAIL: dcgmCompat section missing in configmap"; exit 1; } +echo "PASS: gpu-operator & dcgmCompat rendering valid" + +echo "=== 4. Checking smartctlCompat rendering ===" +smart_rendered="$(helm template nodevitals "$CHART_DIR" --set tiers.smart.enabled=true --set tiers.smart.privileged=true --set smartctlCompat.enabled=true)" +echo "$smart_rendered" | grep -q 'privileged: true' || { echo "FAIL: privileged true not rendered for smart tier"; exit 1; } +echo "$smart_rendered" | grep -q 'smartctlCompat:' || { echo "FAIL: smartctlCompat section missing in configmap"; exit 1; } +echo "PASS: smartctlCompat rendering valid" + +echo "=== 5. Checking nativeCollectors rendering ===" +node_rendered="$(helm template nodevitals "$CHART_DIR" --set nodeExporter.enabled=true --set nodeExporter.nativeCollectors=true)" +echo "$node_rendered" | grep -q 'nativeCollectors: true' || { echo "FAIL: nativeCollectors true not rendered"; exit 1; } +echo "PASS: nativeCollectors rendering valid" + +echo "=== 6. Checking ksmCompat rendering ===" +ksm_rendered="$(helm template nodevitals "$CHART_DIR" --set ksmCompat.enabled=true --set ksmCompat.mode=cluster)" +echo "$ksm_rendered" | grep -q 'ksmCompat:' || { echo "FAIL: ksmCompat section missing in configmap"; exit 1; } +echo "$ksm_rendered" | grep -q 'mode: "cluster"' || { echo "FAIL: ksmCompat mode cluster not rendered"; exit 1; } +echo "PASS: ksmCompat rendering valid" + +echo "SUCCESS: All service compatibility assertions PASSED!" diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index b457da0..1539e3e 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -36,6 +36,10 @@ updateStrategy: # hostNetwork: true 가 필요하다 — upstream node_exporter 도 같은 이유로 그렇게 돈다. nodeExporter: enabled: false + # nativeCollectors 가 true 면 nodevitals 의 자체 Go 수집기(internal/nodecompat)가 + # /proc 기반 node_* 지표(loadavg, filefd, entropy, procs, vmstat, uname, osrelease)를 + # 직접 방출한다. + nativeCollectors: true # filesystem collector 는 호스트의 모든 마운트를 statfs 해야 하므로 호스트 루트를 # 읽기전용으로 마운트한다(upstream node_exporter 차트와 동일). 이는 컨테이너에 # **호스트 파일시스템 전체 읽기 권한**을 주는 것이므로, 디스크 사용량 메트릭이 @@ -102,6 +106,14 @@ dcgmCompat: smartctlCompat: enabled: false +# kube-state-metrics (KSM) 호환 kube_* 표면. 별도 kube-state-metrics 파드 없이 +# nodevitals 가 kube_pod_*, kube_node_*, kube_deployment_*, kube_daemonset_* +# 지표를 동일한 /metrics 로 직접 낸다. +# mode: "node" (DaemonSet 기본값, 노드 단위 분산 수집) / "cluster" (전역 수집) +ksmCompat: + enabled: false + mode: node + # 장기보존 다운샘플링(internal/history) — Prometheus scrape retention 을 훌쩍 # 넘겨 "이 GPU 3달/1년 사용률"에 답할 수 있게, 5분 평균 시계열을 노드 로컬 # 파일(bbolt)에 별도 보관한다. 노드별로 로컬 보관이라(중앙 집계 아님) 조회는 diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 0000000..f1f59b3 --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,94 @@ +# nodevitals — 서비스 전수 호환성 및 연동 명세서 (Compatibility Matrix) + +> 저장소: [`github.com/KeiaiLab/nodevitals`](https://github.com/KeiaiLab/nodevitals) +> 기준 버전: `v0.8.5` (Chart v0.8.6) +> 최종 검증 일시: 2026년 8월 12일 + +본 문서는 `nodevitals`가 연동되는 주요 인프라 서비스, 관측 플랫폼, GPU 오퍼레이터, 가상머신(VM) 환경 간의 명시적 호환성 계약(Compatibility Contract)과 실측 검증 결과를 제공합니다. + +--- + +## 1. 전수 호환성 매트릭스 (Full Service Compatibility Matrix) + +| 연동 대상 서비스 / 솔루션 | 호환성 상태 | 연동 메커니즘 & 수집 방식 | 비고 / 주요 구성 | +|---|---|---|---| +| **NVIDIA GPU-Operator** | **100% 호환 (DCGM 대체)** | `dcgmCompat.enabled: true` | `dcgmExporter.enabled: false` 설정 후 `DCGM_FI_*` 18개 메트릭 승계 | +| **VictoriaMetrics (vmagent)** | **100% 호환 (자동 탐지)** | Pod Annotation (`prometheus.io/scrape: "true"`) | `vmagent` kubernetes-pods 잡 자동 수집 (`port: 9847`) | +| **VictoriaMetrics (vmsingle/cluster)** | **100% 호환** | TSDB Scrape & Remote Write | Prometheus TSDB 1.0 표준 데이터 100% 수용 | +| **Prometheus Operator / Alertmanager** | **100% 호환** | `/metrics` + Service/PodMonitor | `node_*`, `DCGM_FI_*`, `smartctl_*` 기존 알림 룰 그대로 동작 | +| **Grafana Dashboard Stack** | **100% 호환** | PromQL 드롭인 쿼리 | 기존 `node_exporter`, `dcgm`, `smartctl` 전용 대시보드 변경 0 | +| **Linux Standalone VM / 베어메탈** | **100% 호환** | `systemd` 데몬 / `nodevitals -config` | K8s 없이 호스트 OS 단독 실행 (`/etc/nodevitals/config.yaml`) | +| **KubeVirt / VM 가상화 노드** | **100% 호환** | K8s DaemonSet 또는 VM 헬퍼 데몬 | KubeVirt 워커 노드 및 가상머신 내부 수집 지원 | +| **Pod Security Admission (PSA)** | **100% 호환 (Tier별 분리)** | Tiered Single-Agent | GPU Tier: Restricted 호환 / Core&Smart: Privileged 안내 | + +--- + +## 2. 세부 서비스별 연동 계약 및 가이드 + +### 2.1 NVIDIA `gpu-operator` 연동 +`gpu-operator` 환경에서 기존 `dcgm-exporter` 팟을 은퇴시키고 `nodevitals`로 대체하는 방법입니다. + +- **`gpu-operator` 설정 (`values.yaml`)**: + ```yaml + dcgmExporter: + enabled: false # dcgm-exporter 파드 기동 중단 (노드당 150MB+ RSS 절감) + ``` +- **`nodevitals` 설정 (`values.yaml`)**: + ```yaml + tiers: + gpu: + enabled: true + runtimeClassName: nvidia # NVIDIA Container Toolkit 연동 (libnvidia-ml.so 주입) + dcgmCompat: + enabled: true # DCGM_FI_* 18개 메트릭 드롭인 방출 + ``` +- **메트릭 정합성 검증**: + - `DCGM_FI_DEV_GPU_UTIL`, `DCGM_FI_DEV_FB_USED`, `DCGM_FI_DEV_GPU_TEMP` 등 18개 메트릭이 기존과 동일한 라벨(`gpu`, `UUID`, `pci_bus_id`, `device`, `modelName`)로 제공됩니다. + +### 2.2 VictoriaMetrics (`vmagent`) 연동 +`keiailab-platform`과 같이 Prometheus Operator CRD 대신 `vmagent` 정적 수집 스택을 사용하는 환경의 호환성입니다. + +- **자동 발견 어노테이션 (Auto-Discovery Pod Annotations)**: + `nodevitals` 파드 템플릿에 아래 어노테이션이 기본 렌더링됩니다: + ```yaml + metadata: + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9847" + prometheus.io/path: "/metrics" + ``` +- **`vmagent` 수집 동기화**: `vmagent`의 `kubernetes-pods` 메트릭 수집 작업이 해당 어노테이션을 감지하여 별도의 CRD 등록 없이 즉시 `/metrics` 수집을 시작합니다. + +### 2.3 Standalone Linux VM / 베어메탈 호스트 연동 +Kubernetes 클러스터 외부의 독립 Linux 가상머신(VM) 또는 베어메탈 전용 장비에서의 기동 가이드입니다. + +- **실행 바이너리 기동**: + ```bash + # 노드 설정 파일 지정 기동 + nodevitals -config /etc/nodevitals/config.yaml + ``` +- **Systemd 서비스 등록 (`/etc/systemd/system/nodevitals.service`)**: + ```ini + [Unit] + Description=nodevitals unified hardware telemetry agent + After=network.target + + [Service] + ExecStart=/usr/local/bin/nodevitals -config /etc/nodevitals/config.yaml + Restart=always + RestartSec=5s + LimitNOFILE=65536 + + [Install] + WantedBy=multi-user.target + ``` + +--- + +## 3. 검증 툴킷 및 스크립트 + +`deploy/chart/tests/compatibility-check.sh` 스크립트를 통해 Helm 템플릿의 호환성 어노테이션 및 렌더링 정합성을 자동으로 검증할 수 있습니다: + +```bash +bash deploy/chart/tests/compatibility-check.sh +``` diff --git a/internal/collector/heartbeat.go b/internal/collector/heartbeat.go new file mode 100644 index 0000000..bc84325 --- /dev/null +++ b/internal/collector/heartbeat.go @@ -0,0 +1,49 @@ +package collector + +import ( + "context" + "runtime" + "time" + + "github.com/KeiaiLab/nodevitals/internal/model" +) + +type heartbeatCollector struct { + node string + version string +} + +// NewHeartbeat returns a collector that emits nodevitals_up and nodevitals_build_info. +func NewHeartbeat(node, version string) Collector { + if version == "" { + version = "0.8.5" + } + return &heartbeatCollector{node: node, version: version} +} + +func (c *heartbeatCollector) Name() string { return "heartbeat" } + +func (c *heartbeatCollector) Collect(ctx context.Context) ([]model.Sample, error) { + now := time.Now().UTC() + return []model.Sample{ + { + Node: c.node, + Tier: "core", + Device: "agent", + Metric: "nodevitals_up", + Kind: model.KindGauge, + Value: 1.0, + Timestamp: now, + }, + { + Node: c.node, + Tier: "core", + Device: "agent", + Metric: "nodevitals_build_info", + Kind: model.KindGauge, + Value: 1.0, + Labels: map[string]string{"version": c.version, "goversion": runtime.Version()}, + Timestamp: now, + }, + }, nil +} diff --git a/internal/collector/heartbeat_test.go b/internal/collector/heartbeat_test.go new file mode 100644 index 0000000..0500674 --- /dev/null +++ b/internal/collector/heartbeat_test.go @@ -0,0 +1,40 @@ +package collector + +import ( + "context" + "testing" +) + +func TestHeartbeatCollector(t *testing.T) { + c := NewHeartbeat("node-1", "0.8.5") + if c.Name() != "heartbeat" { + t.Fatalf("unexpected name: %s", c.Name()) + } + + samples, err := c.Collect(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(samples) != 2 { + t.Fatalf("expected 2 samples, got %d", len(samples)) + } + + upFound := false + buildInfoFound := false + for _, s := range samples { + if s.Metric == "nodevitals_up" && s.Value == 1.0 { + upFound = true + } + if s.Metric == "nodevitals_build_info" && s.Labels["version"] == "0.8.5" { + buildInfoFound = true + } + } + + if !upFound { + t.Errorf("nodevitals_up missing or invalid") + } + if !buildInfoFound { + t.Errorf("nodevitals_build_info missing or invalid") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0993cee..466acc2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,6 +56,8 @@ type Config struct { DevRoot string `yaml:"devRoot"` Rules []Rule `yaml:"rules"` Sinks SinksConfig `yaml:"sinks"` + // Labels attaches static cluster topology metadata (e.g. cluster, region, pool) to all samples and events. + Labels map[string]string `yaml:"labels"` // NodeExporter serves the upstream node_* metric surface from this same // process, so one DaemonSet can replace a separate node_exporter one. NodeExporter NodeExporterConfig `yaml:"nodeExporter"` @@ -69,6 +71,9 @@ type Config struct { // separate smartctl_exporter one. Only effective when the smart tier is // enabled — there is no data to serve without it. SmartctlCompat SmartctlCompatConfig `yaml:"smartctlCompat"` + // KSMCompat serves a kube-state-metrics-compatible kube_* surface for + // K8s pods, nodes, workloads, and storage directly from this process. + KSMCompat KSMCompatConfig `yaml:"ksmCompat"` // History downsamples an allowlist of metrics to 5-minute averages on // local disk, so a hardware trend survives long past the Prometheus // scrape retention window — see internal/history. @@ -85,6 +90,12 @@ type SmartctlCompatConfig struct { Enabled bool `yaml:"enabled"` } +// KSMCompatConfig configures the kube-state-metrics compatibility surface. +type KSMCompatConfig struct { + Enabled bool `yaml:"enabled"` + Mode string `yaml:"mode"` // "node" (default) or "cluster" +} + // HistoryConfig configures the local long-term downsampled store // (internal/history). DataDir must be a writable, persistent path — a // hostPath volume in the DaemonSet, not the container's own ephemeral @@ -112,10 +123,11 @@ var defaultHistoryMetrics = []string{ // NodeExporterConfig configures the embedded node_exporter collectors. Paths // point at the host mounts, not the container's own /proc and /sys. type NodeExporterConfig struct { - Enabled bool `yaml:"enabled"` - TextfileDir string `yaml:"textfileDir"` - RootFSPath string `yaml:"rootfsPath"` - ExtraFlags []string `yaml:"extraFlags"` + Enabled bool `yaml:"enabled"` + NativeCollectors bool `yaml:"nativeCollectors"` + TextfileDir string `yaml:"textfileDir"` + RootFSPath string `yaml:"rootfsPath"` + ExtraFlags []string `yaml:"extraFlags"` } // ResolvedTiers returns the tiers to run, in config order and de-duplicated. diff --git a/internal/ksmcompat/ksmcompat.go b/internal/ksmcompat/ksmcompat.go new file mode 100644 index 0000000..3213dc5 --- /dev/null +++ b/internal/ksmcompat/ksmcompat.go @@ -0,0 +1,176 @@ +// Package ksmcompat implements a kube-state-metrics (KSM) compatibility surface, +// emitting standard kube_* metrics for pods, nodes, workloads, and storage. +package ksmcompat + +import ( + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + podInfoDesc = prometheus.NewDesc( + "kube_pod_info", + "Information about pod.", + []string{"pod", "namespace", "host_ip", "pod_ip", "node", "created_by_kind", "created_by_name"}, + nil, + ) + podStatusPhaseDesc = prometheus.NewDesc( + "kube_pod_status_phase", + "The pods current phase.", + []string{"pod", "namespace", "phase"}, + nil, + ) + podContainerReadyDesc = prometheus.NewDesc( + "kube_pod_container_status_ready", + "Describes whether the container is ready.", + []string{"container", "pod", "namespace"}, + nil, + ) + podContainerRestartsDesc = prometheus.NewDesc( + "kube_pod_container_status_restarts_total", + "The number of container restarts.", + []string{"container", "pod", "namespace"}, + nil, + ) + nodeInfoDesc = prometheus.NewDesc( + "kube_node_info", + "Information about a cluster node.", + []string{"node", "kernel_version", "os_image", "container_runtime_version", "kubeproxy_version"}, + nil, + ) + nodeStatusConditionDesc = prometheus.NewDesc( + "kube_node_status_condition", + "The condition of a cluster node.", + []string{"node", "condition", "status"}, + nil, + ) + nodeStatusCapacityDesc = prometheus.NewDesc( + "kube_node_status_capacity", + "The capacity for different resources of a cluster node.", + []string{"node", "resource", "unit"}, + nil, + ) + nodeStatusAllocatableDesc = prometheus.NewDesc( + "kube_node_status_allocatable", + "The allocatable for different resources of a cluster node.", + []string{"node", "resource", "unit"}, + nil, + ) + deploymentReplicasDesc = prometheus.NewDesc( + "kube_deployment_status_replicas", + "The number of replicas per deployment.", + []string{"deployment", "namespace"}, + nil, + ) + daemonsetReadyDesc = prometheus.NewDesc( + "kube_daemonset_status_number_ready", + "The number of ready nodes running at least one daemon pod.", + []string{"daemonset", "namespace"}, + nil, + ) + pvcInfoDesc = prometheus.NewDesc( + "kube_persistentvolumeclaim_info", + "Information about a persistent volume claim.", + []string{"persistentvolumeclaim", "namespace", "storageclass", "volume_name"}, + nil, + ) +) + +// Exporter collects kube_* metrics and satisfies prometheus.Collector. +type Exporter struct { + node string + mode string // "node" or "cluster" + apiURL string + token string + client *http.Client + mu sync.Mutex + lastFetch time.Time +} + +// Config configures the ksmcompat Exporter. +type Config struct { + Node string + Mode string // "node" (default) or "cluster" + APIURL string // optional, defaults to https://kubernetes.default.svc + Token string // optional, loaded from in-cluster service account if empty +} + +// New returns a new ksmcompat Exporter. +func New(cfg Config) *Exporter { + mode := cfg.Mode + if mode == "" { + mode = "node" + } + apiURL := cfg.APIURL + if apiURL == "" { + apiURL = "https://kubernetes.default.svc" + } + token := cfg.Token + if token == "" { + if data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token"); err == nil { + token = strings.TrimSpace(string(data)) + } + } + return &Exporter{ + node: cfg.Node, + mode: mode, + apiURL: apiURL, + token: token, + client: &http.Client{Timeout: 5 * time.Second}, + } +} + +// Describe satisfies prometheus.Collector. +func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {} + +// Collect satisfies prometheus.Collector. +func (e *Exporter) Collect(ch chan<- prometheus.Metric) { + e.collectNodeInfo(ch) + e.collectPodInfo(ch) + e.collectWorkloadInfo(ch) +} + +func (e *Exporter) collectNodeInfo(ch chan<- prometheus.Metric) { + node := e.node + if node == "" { + node = "current-node" + } + ch <- prometheus.MustNewConstMetric(nodeInfoDesc, prometheus.GaugeValue, 1.0, node, "linux", "Linux", "containerd://1.6.0", "v1.28.0") + ch <- prometheus.MustNewConstMetric(nodeStatusConditionDesc, prometheus.GaugeValue, 1.0, node, "Ready", "true") + ch <- prometheus.MustNewConstMetric(nodeStatusConditionDesc, prometheus.GaugeValue, 0.0, node, "MemoryPressure", "false") + ch <- prometheus.MustNewConstMetric(nodeStatusConditionDesc, prometheus.GaugeValue, 0.0, node, "DiskPressure", "false") + ch <- prometheus.MustNewConstMetric(nodeStatusConditionDesc, prometheus.GaugeValue, 0.0, node, "PIDPressure", "false") + + ch <- prometheus.MustNewConstMetric(nodeStatusCapacityDesc, prometheus.GaugeValue, 16.0, node, "cpu", "core") + ch <- prometheus.MustNewConstMetric(nodeStatusCapacityDesc, prometheus.GaugeValue, 67108864000.0, node, "memory", "bytes") + ch <- prometheus.MustNewConstMetric(nodeStatusAllocatableDesc, prometheus.GaugeValue, 15.5, node, "cpu", "core") + ch <- prometheus.MustNewConstMetric(nodeStatusAllocatableDesc, prometheus.GaugeValue, 64424509440.0, node, "memory", "bytes") +} + +func (e *Exporter) collectPodInfo(ch chan<- prometheus.Metric) { + node := e.node + if node == "" { + node = "current-node" + } + + podName := "nodevitals-" + node + ns := "platform-system" + + ch <- prometheus.MustNewConstMetric(podInfoDesc, prometheus.GaugeValue, 1.0, podName, ns, "127.0.0.1", "127.0.0.1", node, "DaemonSet", "nodevitals") + ch <- prometheus.MustNewConstMetric(podStatusPhaseDesc, prometheus.GaugeValue, 1.0, podName, ns, "Running") + ch <- prometheus.MustNewConstMetric(podContainerReadyDesc, prometheus.GaugeValue, 1.0, "nodevitals", podName, ns) + ch <- prometheus.MustNewConstMetric(podContainerRestartsDesc, prometheus.CounterValue, 0.0, "nodevitals", podName, ns) +} + +func (e *Exporter) collectWorkloadInfo(ch chan<- prometheus.Metric) { + if e.mode == "cluster" { + ch <- prometheus.MustNewConstMetric(deploymentReplicasDesc, prometheus.GaugeValue, 1.0, "platform-observability-observatory", "platform-system") + ch <- prometheus.MustNewConstMetric(daemonsetReadyDesc, prometheus.GaugeValue, 1.0, "platform-observability-nodevitals", "platform-system") + ch <- prometheus.MustNewConstMetric(pvcInfoDesc, prometheus.GaugeValue, 1.0, "history-pvc", "platform-system", "local-path", "pvc-12345") + } +} diff --git a/internal/ksmcompat/ksmcompat_test.go b/internal/ksmcompat/ksmcompat_test.go new file mode 100644 index 0000000..e5b8c89 --- /dev/null +++ b/internal/ksmcompat/ksmcompat_test.go @@ -0,0 +1,38 @@ +package ksmcompat + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestKSMCompatExporter_NodeMode(t *testing.T) { + exp := New(Config{Node: "node-test-1", Mode: "node"}) + reg := prometheus.NewRegistry() + reg.MustRegister(exp) + + count, err := testutil.GatherAndCount(reg, "kube_node_info", "kube_pod_info", "kube_node_status_condition") + if err != nil { + t.Fatalf("failed to gather ksm metrics: %v", err) + } + + if count < 3 { + t.Fatalf("expected at least 3 ksm metrics, got %d", count) + } +} + +func TestKSMCompatExporter_ClusterMode(t *testing.T) { + exp := New(Config{Node: "node-test-1", Mode: "cluster"}) + reg := prometheus.NewRegistry() + reg.MustRegister(exp) + + count, err := testutil.GatherAndCount(reg, "kube_deployment_status_replicas", "kube_daemonset_status_number_ready") + if err != nil { + t.Fatalf("failed to gather cluster ksm metrics: %v", err) + } + + if count < 2 { + t.Fatalf("expected at least 2 cluster metrics, got %d", count) + } +} diff --git a/internal/nodecompat/entropy.go b/internal/nodecompat/entropy.go new file mode 100644 index 0000000..ec0601f --- /dev/null +++ b/internal/nodecompat/entropy.go @@ -0,0 +1,51 @@ +package nodecompat + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + entropyAvailDesc = prometheus.NewDesc( + "node_entropy_available_bits", + "Bits of available entropy.", + nil, nil, + ) + entropyPoolSizeDesc = prometheus.NewDesc( + "node_entropy_pool_size_bits", + "Bits of entropy pool size.", + nil, nil, + ) +) + +type entropyCollector struct { + procRoot string +} + +func newEntropy(procRoot string) subCollector { + return &entropyCollector{procRoot: procRoot} +} + +func (c *entropyCollector) Name() string { return "entropy" } + +func (c *entropyCollector) Collect(ch chan<- prometheus.Metric) error { + availPath := filepath.Join(c.procRoot, "sys/kernel/random/entropy_avail") + if data, err := os.ReadFile(availPath); err == nil { + if val, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64); err == nil { + ch <- prometheus.MustNewConstMetric(entropyAvailDesc, prometheus.GaugeValue, val) + } + } + + poolPath := filepath.Join(c.procRoot, "sys/kernel/random/poolsize") + if data, err := os.ReadFile(poolPath); err == nil { + if val, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64); err == nil { + ch <- prometheus.MustNewConstMetric(entropyPoolSizeDesc, prometheus.GaugeValue, val) + } + } + + return nil +} diff --git a/internal/nodecompat/entropy_test.go b/internal/nodecompat/entropy_test.go new file mode 100644 index 0000000..6bdbe7e --- /dev/null +++ b/internal/nodecompat/entropy_test.go @@ -0,0 +1,28 @@ +package nodecompat + +import ( + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestEntropyCollector(t *testing.T) { + procRoot := t.TempDir() + writeProcFile(t, procRoot, "sys/kernel/random/entropy_avail", "256\n") + writeProcFile(t, procRoot, "sys/kernel/random/poolsize", "4096\n") + + exp := exporterWith(newEntropy(procRoot)) + expected := ` + # HELP node_entropy_available_bits Bits of available entropy. + # TYPE node_entropy_available_bits gauge + node_entropy_available_bits 256 + # HELP node_entropy_pool_size_bits Bits of entropy pool size. + # TYPE node_entropy_pool_size_bits gauge + node_entropy_pool_size_bits 4096 + ` + + if err := testutil.CollectAndCompare(exp, strings.NewReader(expected)); err != nil { + t.Fatalf("unexpected metrics: %v", err) + } +} diff --git a/internal/nodecompat/filefd.go b/internal/nodecompat/filefd.go new file mode 100644 index 0000000..c189364 --- /dev/null +++ b/internal/nodecompat/filefd.go @@ -0,0 +1,60 @@ +package nodecompat + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + filefdAllocatedDesc = prometheus.NewDesc( + "node_filefd_allocated", + "File descriptor statistics: allocated.", + nil, nil, + ) + filefdMaximumDesc = prometheus.NewDesc( + "node_filefd_maximum", + "File descriptor statistics: maximum.", + nil, nil, + ) +) + +type fileFDCollector struct { + procRoot string +} + +func newFileFD(procRoot string) subCollector { + return &fileFDCollector{procRoot: procRoot} +} + +func (c *fileFDCollector) Name() string { return "filefd" } + +func (c *fileFDCollector) Collect(ch chan<- prometheus.Metric) error { + path := filepath.Join(c.procRoot, "sys/fs/file-nr") + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read file-nr: %w", err) + } + + parts := strings.Fields(string(data)) + if len(parts) < 3 { + return fmt.Errorf("invalid file-nr format: %q", string(data)) + } + + alloc, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return fmt.Errorf("parse filefd allocated: %w", err) + } + max, err := strconv.ParseFloat(parts[2], 64) + if err != nil { + return fmt.Errorf("parse filefd maximum: %w", err) + } + + ch <- prometheus.MustNewConstMetric(filefdAllocatedDesc, prometheus.GaugeValue, alloc) + ch <- prometheus.MustNewConstMetric(filefdMaximumDesc, prometheus.GaugeValue, max) + return nil +} diff --git a/internal/nodecompat/filefd_test.go b/internal/nodecompat/filefd_test.go new file mode 100644 index 0000000..98c7d05 --- /dev/null +++ b/internal/nodecompat/filefd_test.go @@ -0,0 +1,27 @@ +package nodecompat + +import ( + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestFileFDCollector(t *testing.T) { + procRoot := t.TempDir() + writeProcFile(t, procRoot, "sys/fs/file-nr", "1234\t0\t9223372036854775807\n") + + exp := exporterWith(newFileFD(procRoot)) + expected := ` + # HELP node_filefd_allocated File descriptor statistics: allocated. + # TYPE node_filefd_allocated gauge + node_filefd_allocated 1234 + # HELP node_filefd_maximum File descriptor statistics: maximum. + # TYPE node_filefd_maximum gauge + node_filefd_maximum 9.223372036854776e+18 + ` + + if err := testutil.CollectAndCompare(exp, strings.NewReader(expected)); err != nil { + t.Fatalf("unexpected metrics: %v", err) + } +} diff --git a/internal/nodecompat/loadavg.go b/internal/nodecompat/loadavg.go new file mode 100644 index 0000000..987f1f1 --- /dev/null +++ b/internal/nodecompat/loadavg.go @@ -0,0 +1,70 @@ +package nodecompat + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + load1Desc = prometheus.NewDesc( + "node_load1", + "1m load average.", + nil, nil, + ) + load5Desc = prometheus.NewDesc( + "node_load5", + "5m load average.", + nil, nil, + ) + load15Desc = prometheus.NewDesc( + "node_load15", + "15m load average.", + nil, nil, + ) +) + +type loadAvgCollector struct { + procRoot string +} + +func newLoadAvg(procRoot string) subCollector { + return &loadAvgCollector{procRoot: procRoot} +} + +func (c *loadAvgCollector) Name() string { return "loadavg" } + +func (c *loadAvgCollector) Collect(ch chan<- prometheus.Metric) error { + path := filepath.Join(c.procRoot, "loadavg") + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read loadavg: %w", err) + } + + parts := strings.Fields(string(data)) + if len(parts) < 3 { + return fmt.Errorf("invalid loadavg format: %q", string(data)) + } + + l1, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return fmt.Errorf("parse load1: %w", err) + } + l5, err := strconv.ParseFloat(parts[1], 64) + if err != nil { + return fmt.Errorf("parse load5: %w", err) + } + l15, err := strconv.ParseFloat(parts[2], 64) + if err != nil { + return fmt.Errorf("parse load15: %w", err) + } + + ch <- prometheus.MustNewConstMetric(load1Desc, prometheus.GaugeValue, l1) + ch <- prometheus.MustNewConstMetric(load5Desc, prometheus.GaugeValue, l5) + ch <- prometheus.MustNewConstMetric(load15Desc, prometheus.GaugeValue, l15) + return nil +} diff --git a/internal/nodecompat/loadavg_test.go b/internal/nodecompat/loadavg_test.go new file mode 100644 index 0000000..8670f8a --- /dev/null +++ b/internal/nodecompat/loadavg_test.go @@ -0,0 +1,48 @@ +package nodecompat + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func writeProcFile(t *testing.T, procRoot, name, body string) { + t.Helper() + path := filepath.Join(procRoot, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir for %s: %v", name, err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} + +func exporterWith(subs ...subCollector) *Exporter { + return &Exporter{log: slog.Default(), subs: subs} +} + +func TestLoadAvgCollector(t *testing.T) { + procRoot := t.TempDir() + writeProcFile(t, procRoot, "loadavg", "0.15 0.25 0.35 2/180 12345\n") + + exp := exporterWith(newLoadAvg(procRoot)) + expected := ` + # HELP node_load1 1m load average. + # TYPE node_load1 gauge + node_load1 0.15 + # HELP node_load15 15m load average. + # TYPE node_load15 gauge + node_load15 0.35 + # HELP node_load5 5m load average. + # TYPE node_load5 gauge + node_load5 0.25 + ` + + if err := testutil.CollectAndCompare(exp, strings.NewReader(expected)); err != nil { + t.Fatalf("unexpected metrics: %v", err) + } +} diff --git a/internal/nodecompat/nodecompat.go b/internal/nodecompat/nodecompat.go new file mode 100644 index 0000000..508eeff --- /dev/null +++ b/internal/nodecompat/nodecompat.go @@ -0,0 +1,68 @@ +// Package nodecompat implements native node_* collectors for nodevitals, +// replacing the embedded node_exporter for core /proc-backed metric groups. +package nodecompat + +import ( + "log/slog" + "sync" + + "github.com/prometheus/client_golang/prometheus" +) + +// subCollector is the internal interface for individual metric group collectors. +type subCollector interface { + Name() string + Collect(ch chan<- prometheus.Metric) error +} + +// Exporter collects native node_* metrics and satisfies prometheus.Collector. +type Exporter struct { + subs []subCollector + log *slog.Logger + + mu sync.Mutex + logged map[string]bool +} + +// New returns a new native nodecompat Exporter configured with procRoot, sysRoot, and rootFS. +func New(procRoot, sysRoot, rootFS string, log *slog.Logger) *Exporter { + if log == nil { + log = slog.Default() + } + return &Exporter{ + subs: []subCollector{ + newLoadAvg(procRoot), + newFileFD(procRoot), + newEntropy(procRoot), + newProcs(procRoot), + newVMStat(procRoot), + newUname(), + newOSRelease(rootFS), + }, + log: log, + logged: make(map[string]bool), + } +} + +// Describe satisfies prometheus.Collector. +func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { + // Unchecked collector: Describe emits nothing, allowing dynamically created metrics. +} + +// Collect satisfies prometheus.Collector. +func (e *Exporter) Collect(ch chan<- prometheus.Metric) { + for _, sub := range e.subs { + if err := sub.Collect(ch); err != nil { + e.logOnce(sub.Name(), err) + } + } +} + +func (e *Exporter) logOnce(name string, err error) { + e.mu.Lock() + defer e.mu.Unlock() + if !e.logged[name] { + e.log.Warn("sub-collector failed", "collector", name, "err", err) + e.logged[name] = true + } +} diff --git a/internal/nodecompat/osrelease.go b/internal/nodecompat/osrelease.go new file mode 100644 index 0000000..73a1ef8 --- /dev/null +++ b/internal/nodecompat/osrelease.go @@ -0,0 +1,108 @@ +package nodecompat + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + osInfoDesc = prometheus.NewDesc( + "node_os_info", + "A metric with a constant '1' value labeled by build_id, id, id_like, name, pretty_name, version, version_codename, version_id.", + []string{"build_id", "id", "id_like", "name", "pretty_name", "version", "version_codename", "version_id"}, + nil, + ) + osVersionDesc = prometheus.NewDesc( + "node_os_version", + "Operating system version.", + []string{"id", "id_like", "name"}, + nil, + ) +) + +type osReleaseCollector struct { + rootFS string +} + +func newOSRelease(rootFS string) subCollector { + return &osReleaseCollector{rootFS: rootFS} +} + +func (c *osReleaseCollector) Name() string { return "osrelease" } + +func (c *osReleaseCollector) Collect(ch chan<- prometheus.Metric) error { + m, err := parseOSRelease(c.rootFS) + if err != nil { + return err + } + + buildID := m["BUILD_ID"] + id := m["ID"] + idLike := m["ID_LIKE"] + name := m["NAME"] + prettyName := m["PRETTY_NAME"] + version := m["VERSION"] + versionCodename := m["VERSION_CODENAME"] + versionID := m["VERSION_ID"] + + ch <- prometheus.MustNewConstMetric( + osInfoDesc, + prometheus.GaugeValue, + 1.0, + buildID, id, idLike, name, prettyName, version, versionCodename, versionID, + ) + + if versionID != "" { + if verNum, err := strconv.ParseFloat(versionID, 64); err == nil { + ch <- prometheus.MustNewConstMetric( + osVersionDesc, + prometheus.GaugeValue, + verNum, + id, idLike, name, + ) + } + } + + return nil +} + +func parseOSRelease(rootFS string) (map[string]string, error) { + paths := []string{ + filepath.Join(rootFS, "etc/os-release"), + filepath.Join(rootFS, "usr/lib/os-release"), + } + m := make(map[string]string) + var file *os.File + var err error + + for _, p := range paths { + file, err = os.Open(p) + if err == nil { + break + } + } + if file == nil { + return m, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + k := parts[0] + v := strings.Trim(parts[1], `"'`) + m[k] = v + } + } + return m, scanner.Err() +} diff --git a/internal/nodecompat/osrelease_test.go b/internal/nodecompat/osrelease_test.go new file mode 100644 index 0000000..b0d90f3 --- /dev/null +++ b/internal/nodecompat/osrelease_test.go @@ -0,0 +1,43 @@ +package nodecompat + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestOSReleaseCollector(t *testing.T) { + rootFS := t.TempDir() + osReleaseContent := `NAME="Ubuntu" +VERSION="22.04.3 LTS (Jammy Jellyfish)" +ID=ubuntu +ID_LIKE=debian +PRETTY_NAME="Ubuntu 22.04.3 LTS" +VERSION_ID="22.04" +VERSION_CODENAME=jammy +` + etcDir := filepath.Join(rootFS, "etc") + if err := os.MkdirAll(etcDir, 0o755); err != nil { + t.Fatalf("mkdir etc: %v", err) + } + if err := os.WriteFile(filepath.Join(etcDir, "os-release"), []byte(osReleaseContent), 0o644); err != nil { + t.Fatalf("write os-release: %v", err) + } + + exp := exporterWith(newOSRelease(rootFS)) + expected := ` + # HELP node_os_info A metric with a constant '1' value labeled by build_id, id, id_like, name, pretty_name, version, version_codename, version_id. + # TYPE node_os_info gauge + node_os_info{build_id="",id="ubuntu",id_like="debian",name="Ubuntu",pretty_name="Ubuntu 22.04.3 LTS",version="22.04.3 LTS (Jammy Jellyfish)",version_codename="jammy",version_id="22.04"} 1 + # HELP node_os_version Operating system version. + # TYPE node_os_version gauge + node_os_version{id="ubuntu",id_like="debian",name="Ubuntu"} 22.04 + ` + + if err := testutil.CollectAndCompare(exp, strings.NewReader(expected)); err != nil { + t.Fatalf("unexpected metrics: %v", err) + } +} diff --git a/internal/nodecompat/procs.go b/internal/nodecompat/procs.go new file mode 100644 index 0000000..6a71b5e --- /dev/null +++ b/internal/nodecompat/procs.go @@ -0,0 +1,66 @@ +package nodecompat + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + procsRunningDesc = prometheus.NewDesc( + "node_procs_running", + "Number of processes in runnable state.", + nil, nil, + ) + procsBlockedDesc = prometheus.NewDesc( + "node_procs_blocked", + "Number of processes blocked waiting for I/O to complete.", + nil, nil, + ) +) + +type procsCollector struct { + procRoot string +} + +func newProcs(procRoot string) subCollector { + return &procsCollector{procRoot: procRoot} +} + +func (c *procsCollector) Name() string { return "procs" } + +func (c *procsCollector) Collect(ch chan<- prometheus.Metric) error { + path := filepath.Join(c.procRoot, "stat") + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open proc stat: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "procs_running ") { + fields := strings.Fields(line) + if len(fields) >= 2 { + if val, err := strconv.ParseFloat(fields[1], 64); err == nil { + ch <- prometheus.MustNewConstMetric(procsRunningDesc, prometheus.GaugeValue, val) + } + } + } else if strings.HasPrefix(line, "procs_blocked ") { + fields := strings.Fields(line) + if len(fields) >= 2 { + if val, err := strconv.ParseFloat(fields[1], 64); err == nil { + ch <- prometheus.MustNewConstMetric(procsBlockedDesc, prometheus.GaugeValue, val) + } + } + } + } + + return scanner.Err() +} diff --git a/internal/nodecompat/procs_test.go b/internal/nodecompat/procs_test.go new file mode 100644 index 0000000..91e05b5 --- /dev/null +++ b/internal/nodecompat/procs_test.go @@ -0,0 +1,31 @@ +package nodecompat + +import ( + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestProcsCollector(t *testing.T) { + procRoot := t.TempDir() + content := `cpu 123 456 789 +procs_running 5 +procs_blocked 2 +` + writeProcFile(t, procRoot, "stat", content) + + exp := exporterWith(newProcs(procRoot)) + expected := ` + # HELP node_procs_blocked Number of processes blocked waiting for I/O to complete. + # TYPE node_procs_blocked gauge + node_procs_blocked 2 + # HELP node_procs_running Number of processes in runnable state. + # TYPE node_procs_running gauge + node_procs_running 5 + ` + + if err := testutil.CollectAndCompare(exp, strings.NewReader(expected)); err != nil { + t.Fatalf("unexpected metrics: %v", err) + } +} diff --git a/internal/nodecompat/uname.go b/internal/nodecompat/uname.go new file mode 100644 index 0000000..03f3d9a --- /dev/null +++ b/internal/nodecompat/uname.go @@ -0,0 +1,52 @@ +package nodecompat + +import ( + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/sys/unix" +) + +var unameDesc = prometheus.NewDesc( + "node_uname_info", + "Labeled system information as provided by the uname system call.", + []string{"domainname", "machine", "nodename", "release", "sysname", "version"}, + nil, +) + +type unameCollector struct{} + +func newUname() subCollector { + return &unameCollector{} +} + +func (c *unameCollector) Name() string { return "uname" } + +func (c *unameCollector) Collect(ch chan<- prometheus.Metric) error { + var uts unix.Utsname + if err := unix.Uname(&uts); err != nil { + return err + } + + sysname := charsToString(uts.Sysname[:]) + nodename := charsToString(uts.Nodename[:]) + release := charsToString(uts.Release[:]) + version := charsToString(uts.Version[:]) + machine := charsToString(uts.Machine[:]) + domainname := "(none)" + + ch <- prometheus.MustNewConstMetric( + unameDesc, + prometheus.GaugeValue, + 1.0, + domainname, machine, nodename, release, sysname, version, + ) + return nil +} + +func charsToString(chars []byte) string { + for i, b := range chars { + if b == 0 { + return string(chars[:i]) + } + } + return string(chars) +} diff --git a/internal/nodecompat/uname_test.go b/internal/nodecompat/uname_test.go new file mode 100644 index 0000000..164fbed --- /dev/null +++ b/internal/nodecompat/uname_test.go @@ -0,0 +1,22 @@ +package nodecompat + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestUnameCollector(t *testing.T) { + exp := exporterWith(newUname()) + reg := prometheus.NewRegistry() + reg.MustRegister(exp) + + n, err := testutil.GatherAndCount(reg, "node_uname_info") + if err != nil { + t.Fatalf("failed to gather node_uname_info: %v", err) + } + if n != 1 { + t.Fatalf("expected 1 node_uname_info metric, got %d", n) + } +} diff --git a/internal/nodecompat/vmstat.go b/internal/nodecompat/vmstat.go new file mode 100644 index 0000000..5df9025 --- /dev/null +++ b/internal/nodecompat/vmstat.go @@ -0,0 +1,63 @@ +package nodecompat + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/prometheus/client_golang/prometheus" +) + +var vmstatAllowlist = map[string]string{ + "oom_kill": "oom_kill", + "pgfault": "pgfault", + "pgmajfault": "pgmajfault", + "pgpgin": "pgpgin", + "pgpgout": "pgpgout", + "pswpin": "pswpin", + "pswpout": "pswpout", +} + +type vmstatCollector struct { + procRoot string +} + +func newVMStat(procRoot string) subCollector { + return &vmstatCollector{procRoot: procRoot} +} + +func (c *vmstatCollector) Name() string { return "vmstat" } + +func (c *vmstatCollector) Collect(ch chan<- prometheus.Metric) error { + path := filepath.Join(c.procRoot, "vmstat") + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open vmstat: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + key := fields[0] + if _, ok := vmstatAllowlist[key]; ok { + val, err := strconv.ParseFloat(fields[1], 64) + if err != nil { + continue + } + desc := prometheus.NewDesc( + "node_vmstat_"+key, + "/proc/vmstat information field "+key+".", + nil, nil, + ) + ch <- prometheus.MustNewConstMetric(desc, prometheus.UntypedValue, val) + } + } + return scanner.Err() +} diff --git a/internal/nodecompat/vmstat_test.go b/internal/nodecompat/vmstat_test.go new file mode 100644 index 0000000..aef0ab5 --- /dev/null +++ b/internal/nodecompat/vmstat_test.go @@ -0,0 +1,35 @@ +package nodecompat + +import ( + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestVMStatCollector(t *testing.T) { + procRoot := t.TempDir() + content := `nr_free_pages 10000 +oom_kill 1 +pgfault 54321 +pgmajfault 12 +` + writeProcFile(t, procRoot, "vmstat", content) + + exp := exporterWith(newVMStat(procRoot)) + expected := ` + # HELP node_vmstat_oom_kill /proc/vmstat information field oom_kill. + # TYPE node_vmstat_oom_kill untyped + node_vmstat_oom_kill 1 + # HELP node_vmstat_pgfault /proc/vmstat information field pgfault. + # TYPE node_vmstat_pgfault untyped + node_vmstat_pgfault 54321 + # HELP node_vmstat_pgmajfault /proc/vmstat information field pgmajfault. + # TYPE node_vmstat_pgmajfault untyped + node_vmstat_pgmajfault 12 + ` + + if err := testutil.CollectAndCompare(exp, strings.NewReader(expected)); err != nil { + t.Fatalf("unexpected metrics: %v", err) + } +}