diff --git a/Makefile b/Makefile index 6e1994b..631fede 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,7 @@ chart-lint: chart-test: bash deploy/chart/tests/secret-isolation.sh bash deploy/chart/tests/tier-runtime.sh + bash deploy/chart/tests/compatibility-check.sh # Vuln-scan IMGREF, failing on HIGH/CRITICAL. Override IMGREF for the gpu image. scan: diff --git a/cmd/nodevitals/main.go b/cmd/nodevitals/main.go index 8310d44..fd41f41 100644 --- a/cmd/nodevitals/main.go +++ b/cmd/nodevitals/main.go @@ -158,13 +158,24 @@ func main() { slog.Info("node_exporter collectors registered", "count", neCount) } + // kube_* 표면. 설정이 잘못됐거나(cluster 모드·오타) 토큰이 없으면 여기서 + // 멈춘다 — 그 상태로 계속 돌면 증상이 "메트릭이 안 나온다" 하나뿐이라 + // 원인까지 도달하는 데 시간이 걸린다. if cfg.KSMCompat.Enabled { - ksm := ksmcompat.New(ksmcompat.Config{Node: cfg.Node, Mode: cfg.KSMCompat.Mode}) + ksm, err := ksmcompat.New(ksmcompat.Config{ + Node: cfg.Node, + Mode: cfg.KSMCompat.Mode, + Log: slog.Default(), + }) + if err != nil { + slog.Error("ksm compat surface", "err", err) + os.Exit(1) + } 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) + slog.Info("ksm compat surface enabled", "mode", "node", "scope", "this node and its pods") } // Long-term downsampled history — local to this node, survives past the diff --git a/deploy/chart/templates/_helpers.tpl b/deploy/chart/templates/_helpers.tpl index cad636d..0c47397 100644 --- a/deploy/chart/templates/_helpers.tpl +++ b/deploy/chart/templates/_helpers.tpl @@ -214,3 +214,40 @@ Skipped when alreadyRoot: root already owns the file it's about to write. mountPath: {{ $ctx.Values.history.mountPath | quote }} {{- end }} {{- end -}} + +{{/* +ServiceAccount wiring for a pod spec. + +Only ksmCompat needs Kubernetes API credentials — every other tier reads +/proc, /sys, /dev and NVML. So the token is mounted only when it is switched +on, and the pod otherwise keeps automountServiceAccountToken: false. + +Rendered only into the tiers that actually serve the kube_* surface (core and +singlePod); smart and gpu have no ksmCompat section in their config and would +be holding a credential they never present. +*/}} +{{- define "nodevitals.serviceAccount" -}} +{{- if .Values.ksmCompat.enabled }} +serviceAccountName: {{ include "nodevitals.name" . }} +automountServiceAccountToken: true +{{- else }} +automountServiceAccountToken: false +{{- end }} +{{- end -}} + +{{/* +Guard on ksmCompat.mode. + +"cluster" is refused at render time rather than at startup: nodevitals runs as +a DaemonSet, so a cluster-wide collection happens once per node and every +series is duplicated by the node count. The agent refuses it too, but finding +out through a CrashLoop after the rollout is a worse way to learn it. +*/}} +{{- define "nodevitals.validateKsmMode" -}} +{{- if .Values.ksmCompat.enabled }} +{{- $m := .Values.ksmCompat.mode | default "node" }} +{{- if ne $m "node" }} +{{- fail (printf "ksmCompat.mode %q is not supported — nodevitals is a DaemonSet, so a cluster-wide collection would run once per node and duplicate every series by the node count. Use \"node\" and keep kube-state-metrics for cluster-scoped objects." $m) }} +{{- end }} +{{- end }} +{{- end -}} diff --git a/deploy/chart/templates/configmap-single.yaml b/deploy/chart/templates/configmap-single.yaml index 534b845..aa7b6bd 100644 --- a/deploy/chart/templates/configmap-single.yaml +++ b/deploy/chart/templates/configmap-single.yaml @@ -1,3 +1,4 @@ +{{- include "nodevitals.validateKsmMode" . }} {{- if .Values.singlePod }} {{- $tiers := splitList " " (include "nodevitals.enabledTiers" .) }} apiVersion: v1 diff --git a/deploy/chart/templates/configmap.yaml b/deploy/chart/templates/configmap.yaml index 55b173d..7a6f4a7 100644 --- a/deploy/chart/templates/configmap.yaml +++ b/deploy/chart/templates/configmap.yaml @@ -1,3 +1,4 @@ +{{- include "nodevitals.validateKsmMode" . }} {{- if and (not .Values.singlePod) .Values.tiers.core.enabled }} apiVersion: v1 kind: ConfigMap diff --git a/deploy/chart/templates/daemonset-single.yaml b/deploy/chart/templates/daemonset-single.yaml index 290851d..b9b9445 100644 --- a/deploy/chart/templates/daemonset-single.yaml +++ b/deploy/chart/templates/daemonset-single.yaml @@ -24,7 +24,7 @@ spec: annotations: {{- include "nodevitals.configChecksums" (dict "ctx" . "tier" "single") | nindent 8 }} spec: - automountServiceAccountToken: false + {{- include "nodevitals.serviceAccount" . | nindent 6 }} {{- include "nodevitals.hostNetwork" . | nindent 6 }} {{- if .Values.tiers.gpu.enabled }} {{- with .Values.tiers.gpu.runtimeClassName }} diff --git a/deploy/chart/templates/daemonset.yaml b/deploy/chart/templates/daemonset.yaml index 792b18f..bf2f502 100644 --- a/deploy/chart/templates/daemonset.yaml +++ b/deploy/chart/templates/daemonset.yaml @@ -22,7 +22,7 @@ spec: annotations: {{- include "nodevitals.configChecksums" (dict "ctx" . "tier" "core") | nindent 8 }} spec: - automountServiceAccountToken: false + {{- include "nodevitals.serviceAccount" . | nindent 6 }} {{- include "nodevitals.hostNetwork" . | nindent 6 }} securityContext: runAsNonRoot: true diff --git a/deploy/chart/templates/rbac.yaml b/deploy/chart/templates/rbac.yaml new file mode 100644 index 0000000..a59bd93 --- /dev/null +++ b/deploy/chart/templates/rbac.yaml @@ -0,0 +1,49 @@ +{{- if .Values.ksmCompat.enabled }} +# ksmCompat 이 켜졌을 때만 존재한다. 그 외 모든 tier 는 /proc·/sys·/dev 와 NVML 만 +# 읽으므로 Kubernetes API 자격이 아예 필요 없고, 쓰지 않는 권한을 상시로 들고 있을 +# 이유도 없다. +# +# 권한은 이 에이전트가 실제로 답하는 범위에서 역산했다 — 자기 노드 하나와 그 +# 노드에 스케줄된 파드. kube-state-metrics 의 ClusterRole(20+ 리소스)을 복사하지 +# 않는다: 읽지 않는 리소스에 대한 list/watch 는 이 파드가 탈취됐을 때 그대로 +# 클러스터 열람 권한이 된다. +# +# pods 가 list 인 이유: 서버 쪽 fieldSelector(spec.nodeName)로 자기 노드로 좁히지만, +# RBAC 은 필드 단위로 좁힐 수 없어 리소스 단위 권한이 된다. nodes 는 이름으로 +# 하나만 읽으므로 get 이면 충분하다. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "nodevitals.name" . }} + labels: + app.kubernetes.io/name: {{ include "nodevitals.name" . }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "nodevitals.name" . }}-ksmcompat + labels: + app.kubernetes.io/name: {{ include "nodevitals.name" . }} +rules: + - apiGroups: [""] + resources: ["nodes"] + verbs: ["get"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "nodevitals.name" . }}-ksmcompat + labels: + app.kubernetes.io/name: {{ include "nodevitals.name" . }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "nodevitals.name" . }}-ksmcompat +subjects: + - kind: ServiceAccount + name: {{ include "nodevitals.name" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/deploy/chart/tests/compatibility-check.sh b/deploy/chart/tests/compatibility-check.sh index 7d55e82..392718f 100755 --- a/deploy/chart/tests/compatibility-check.sh +++ b/deploy/chart/tests/compatibility-check.sh @@ -32,9 +32,43 @@ echo "$node_rendered" | grep -q 'nativeCollectors: true' || { echo "FAIL: native 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" +node_ksm="$(helm template nodevitals "$CHART_DIR" --set ksmCompat.enabled=true)" +echo "$node_ksm" | grep -q 'ksmCompat:' || { echo "FAIL: ksmCompat section missing in configmap"; exit 1; } +echo "$node_ksm" | grep -q 'mode: "node"' || { echo "FAIL: ksmCompat mode node not rendered"; exit 1; } +# cluster 는 배포 전에 막는다 — DaemonSet 이라 노드마다 전역 수집이 돌아 모든 +# 시리즈가 노드 수만큼 중복된다. 에이전트도 거부하지만, 롤아웃 후 CrashLoop 로 +# 알게 되는 것보다 helm 단계에서 멈추는 편이 낫다. +if helm template nodevitals "$CHART_DIR" --set ksmCompat.enabled=true --set ksmCompat.mode=cluster >/dev/null 2>&1; then + echo "FAIL: ksmCompat.mode=cluster rendered successfully; it would duplicate every series by the node count" + exit 1 +fi +echo "PASS: ksmCompat renders node mode and refuses cluster mode" + +echo "=== 7. Checking ksmCompat RBAC is gated and minimal ===" +# 꺼져 있으면 자격 자체가 없어야 한다 — 다른 tier 는 /proc·/sys·/dev 와 NVML 만 +# 읽으므로 API 토큰을 들고 있을 이유가 없다. +if echo "$rendered" | grep -qE '^kind: (ServiceAccount|ClusterRole|ClusterRoleBinding)'; then + echo "FAIL: RBAC objects render with ksmCompat off; the agent would hold cluster credentials it never uses" + exit 1 +fi +echo "$rendered" | grep -q 'automountServiceAccountToken: false' \ + || { echo "FAIL: pods mount a service-account token with ksmCompat off"; exit 1; } +echo "PASS: no cluster credentials unless ksmCompat is on" + +ksm_rendered="$(helm template nodevitals "$CHART_DIR" --set ksmCompat.enabled=true)" +for k in ServiceAccount ClusterRole ClusterRoleBinding; do + echo "$ksm_rendered" | grep -q "^kind: $k" || { echo "FAIL: $k missing with ksmCompat on"; exit 1; } +done +# 권한은 이 에이전트가 실제로 읽는 두 리소스로 한정한다. kube-state-metrics 의 +# 20+ 리소스 목록을 베끼면 쓰지 않는 열람 권한이 그대로 공격 표면이 된다. +rules="$(echo "$ksm_rendered" | awk '/^kind: ClusterRole$/,/^---$/' | grep -A2 'resources:')" +for forbidden in secrets configmaps deployments statefulsets; do + echo "$rules" | grep -q "\"$forbidden\"" && { echo "FAIL: ClusterRole grants $forbidden, which ksmcompat never reads"; exit 1; } +done +echo "$ksm_rendered" | grep -q 'automountServiceAccountToken: true' \ + || { echo "FAIL: ksmCompat on but no pod mounts a token — the agent cannot authenticate"; exit 1; } +echo "$ksm_rendered" | grep -q 'serviceAccountName: nodevitals' \ + || { echo "FAIL: ksmCompat on but pods still use the default ServiceAccount"; exit 1; } +echo "PASS: ksmCompat RBAC gated, scoped to nodes+pods, and bound to the pods" echo "SUCCESS: All service compatibility assertions PASSED!" diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 1539e3e..1fbe41e 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -106,10 +106,26 @@ 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" (전역 수집) +# kube-state-metrics 호환 kube_* 표면 — **노드 스코프 한정**. +# +# 이 에이전트가 사실로서 말할 수 있는 범위, 즉 자기 노드 하나와 그 노드에 +# 스케줄된 파드만 낸다: kube_node_info / kube_node_status_condition / +# kube_node_status_capacity / kube_node_status_allocatable / kube_pod_info / +# kube_pod_status_phase / kube_pod_container_status_ready / _restarts_total. +# 값은 전부 API 서버에서 읽는다. +# +# **kube-state-metrics 를 은퇴시키지는 못한다.** kube_deployment_* · +# kube_daemonset_* 같은 클러스터 스코프 지표는 여기서 내지 않는다 — DaemonSet 은 +# 노드마다 한 벌 도는데, 전역 객체를 각자 보고하면 같은 시리즈가 노드 수만큼 +# 생긴다. dcgmCompat·smartctlCompat 이 대체에 성공한 이유는 원본이 애초에 +# DaemonSet 이었기 때문이고, KSM 은 단일 Deployment 라 전제가 다르다. +# 그 지표들이 필요하면 kube-state-metrics 를 그대로 두면 된다(같이 떠 있어도 +# 메트릭 이름이 겹치지 않는다). +# +# 켜면 ServiceAccount + ClusterRole(nodes:get, pods:list) + Binding 이 함께 +# 렌더되고 파드가 토큰을 마운트한다. 끄면 그 어느 것도 생기지 않는다. +# +# mode 는 "node" 만 유효하다. "cluster" 는 helm 렌더 단계에서 거부된다. ksmCompat: enabled: false mode: node diff --git a/internal/ksmcompat/ksmcompat.go b/internal/ksmcompat/ksmcompat.go index 3213dc5..31a6f3d 100644 --- a/internal/ksmcompat/ksmcompat.go +++ b/internal/ksmcompat/ksmcompat.go @@ -1,42 +1,44 @@ -// Package ksmcompat implements a kube-state-metrics (KSM) compatibility surface, -// emitting standard kube_* metrics for pods, nodes, workloads, and storage. +// Package ksmcompat serves a kube-state-metrics-compatible kube_* surface for +// the objects this node can speak for: the node itself and the pods scheduled +// onto it. +// +// Scope is deliberate. kube-state-metrics runs as a single Deployment and +// reports the whole cluster; nodevitals runs as a DaemonSet, one copy per node. +// A per-node copy reporting cluster-wide objects would emit the same series +// once per node, so this package only reports node-scoped facts and refuses any +// other mode outright rather than quietly collecting something else. +// +// Every value here comes from the API server. Nothing is defaulted, filled in, +// or approximated: a plausible number invented on this side becomes an +// assertion about the cluster that no consumer can tell apart from a real +// measurement, and alert rules act on it. package ksmcompat import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "log/slog" "net/http" + "net/url" "os" - "strings" "sync" "time" "github.com/prometheus/client_golang/prometheus" ) +const ( + defaultAPIURL = "https://kubernetes.default.svc" + tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" // #nosec G101 -- well-known in-cluster path, not a credential + caPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + defaultCacheTTL = 30 * time.Second + requestTimeout = 5 * time.Second +) + 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.", @@ -61,116 +63,357 @@ var ( []string{"node", "resource", "unit"}, nil, ) - deploymentReplicasDesc = prometheus.NewDesc( - "kube_deployment_status_replicas", - "The number of replicas per deployment.", - []string{"deployment", "namespace"}, + 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, ) - daemonsetReadyDesc = prometheus.NewDesc( - "kube_daemonset_status_number_ready", - "The number of ready nodes running at least one daemon pod.", - []string{"daemonset", "namespace"}, + podContainerReadyDesc = prometheus.NewDesc( + "kube_pod_container_status_ready", + "Describes whether the container is ready.", + []string{"container", "pod", "namespace"}, nil, ) - pvcInfoDesc = prometheus.NewDesc( - "kube_persistentvolumeclaim_info", - "Information about a persistent volume claim.", - []string{"persistentvolumeclaim", "namespace", "storageclass", "volume_name"}, + podContainerRestartsDesc = prometheus.NewDesc( + "kube_pod_container_status_restarts_total", + "The number of container restarts.", + []string{"container", "pod", "namespace"}, nil, ) + // Carrier for scrape failures. Handing this to the channel makes + // client_golang surface the error through the scrape's error path instead + // of the endpoint quietly serving fewer series than it did a minute ago. + scrapeErrorDesc = prometheus.NewDesc( + "kube_state_metrics_scrape_error", + "Placeholder that carries a ksmcompat scrape failure to the registry.", + nil, nil, + ) ) +// podPhases and nodeConditionStates are emitted in full for every object, one +// series per possible value with only the current one set to 1. That is what +// kube-state-metrics does, and queries depend on it: a rule matching +// phase="Failed" needs the series to exist and read 0, not to be absent. +var podPhases = []string{"Pending", "Running", "Succeeded", "Failed", "Unknown"} +var nodeConditionStates = []string{"true", "false", "unknown"} + +// Config configures the ksmcompat Exporter. +type Config struct { + Node string + Mode string // "node" only — see the package comment + APIURL string // defaults to the in-cluster API service + Token string // defaults to the mounted service-account token + CAPath string // defaults to the mounted service-account CA + Log *slog.Logger + // CacheTTL bounds how often the API is queried. Scrapes are far more + // frequent than these objects change, and every node runs a copy. + CacheTTL time.Duration +} + // 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 + node string + apiURL string + token string + client *http.Client + log *slog.Logger + ttl time.Duration + mu sync.Mutex + cached *snapshot 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 +type snapshot struct { + node *nodeObj + pods []podObj } -// New returns a new ksmcompat Exporter. -func New(cfg Config) *Exporter { - mode := cfg.Mode - if mode == "" { - mode = "node" +// New returns a new ksmcompat Exporter, or an error if it could not be +// configured to actually reach the API. Refusing at startup is deliberate: +// every failure mode here shows up at scrape time as "the metrics are just not +// there", which is a long way from the cause. +func New(cfg Config) (*Exporter, error) { + switch cfg.Mode { + case "", "node": + // ok + case "cluster": + return nil, fmt.Errorf(`ksmCompat mode "cluster" is not supported: nodevitals runs as a DaemonSet, ` + + `so a cluster-wide collection would be performed once per node and every series would be ` + + `duplicated by the node count. Use mode "node", and keep kube-state-metrics for cluster-scoped objects`) + default: + return nil, fmt.Errorf("ksmCompat mode %q is not known (want \"node\")", cfg.Mode) } - apiURL := cfg.APIURL - if apiURL == "" { - apiURL = "https://kubernetes.default.svc" + + if cfg.Node == "" { + return nil, fmt.Errorf("ksmCompat needs the node name: every series it emits is scoped to one node") } + token := cfg.Token if token == "" { - if data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token"); err == nil { - token = strings.TrimSpace(string(data)) + data, err := os.ReadFile(tokenPath) + if err != nil { + return nil, fmt.Errorf("read service-account token at %s: %w "+ + "(the pod needs a ServiceAccount with automountServiceAccountToken enabled)", tokenPath, err) } + token = string(data) + } + if token == "" { + return nil, fmt.Errorf("service-account token is empty") + } + + apiURL := cfg.APIURL + if apiURL == "" { + apiURL = defaultAPIURL + } + + transport, err := transportFor(apiURL, cfg.CAPath) + if err != nil { + return nil, err + } + + log := cfg.Log + if log == nil { + log = slog.Default() + } + ttl := cfg.CacheTTL + if ttl <= 0 { + ttl = defaultCacheTTL } + return &Exporter{ node: cfg.Node, - mode: mode, apiURL: apiURL, token: token, - client: &http.Client{Timeout: 5 * time.Second}, + client: &http.Client{Timeout: requestTimeout, Transport: transport}, + log: log, + ttl: ttl, + }, nil +} + +// transportFor pins the API server's CA when talking HTTPS. Falling back to +// InsecureSkipVerify would make an intercepted connection indistinguishable +// from the real API server, and this client sends a bearer token. +func transportFor(apiURL, caPathOverride string) (http.RoundTripper, error) { + u, err := url.Parse(apiURL) + if err != nil { + return nil, fmt.Errorf("parse apiURL %q: %w", apiURL, err) + } + if u.Scheme != "https" { + return http.DefaultTransport, nil + } + + path := caPathOverride + if path == "" { + path = caPath + } + pem, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read API server CA at %s: %w", path, err) } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("no certificate found in %s", path) + } + return &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}, nil } -// Describe satisfies prometheus.Collector. +// Describe satisfies prometheus.Collector. Nothing is described: the series +// depend on what the API returns, and an unchecked collector is what lets a +// failed scrape emit nothing at all rather than a fabricated skeleton. 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) + snap, err := e.load() + if err != nil { + e.log.Warn("ksmcompat scrape failed — emitting no kube_* series", "node", e.node, "err", err) + ch <- prometheus.NewInvalidMetric(scrapeErrorDesc, err) + return + } + e.collectNode(ch, snap.node) + e.collectPods(ch, snap.pods) } -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) load() (*snapshot, error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.cached != nil && time.Since(e.lastFetch) < e.ttl { + return e.cached, nil + } + + var node nodeObj + if err := e.get("/api/v1/nodes/"+url.PathEscape(e.node), nil, &node); err != nil { + return nil, fmt.Errorf("get node %s: %w", e.node, err) + } + var pods podList + q := url.Values{"fieldSelector": {"spec.nodeName=" + e.node}} + if err := e.get("/api/v1/pods", q, &pods); err != nil { + return nil, fmt.Errorf("list pods on %s: %w", e.node, err) + } + + e.cached = &snapshot{node: &node, pods: pods.Items} + e.lastFetch = time.Now() + return e.cached, nil } -func (e *Exporter) collectPodInfo(ch chan<- prometheus.Metric) { - node := e.node - if node == "" { - node = "current-node" +func (e *Exporter) get(path string, query url.Values, into any) error { + u := e.apiURL + path + if len(query) > 0 { + u += "?" + query.Encode() } + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+e.token) + req.Header.Set("Accept", "application/json") + + resp, err := e.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // 403 은 RBAC 이 모자란다는 뜻이고, 그 상태에서 메트릭을 지어내면 + // 권한 문제가 "건강한 클러스터" 처럼 보이게 된다. + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("HTTP %d from %s: %s", resp.StatusCode, path, body) + } + return json.NewDecoder(resp.Body).Decode(into) +} + +func (e *Exporter) collectNode(ch chan<- prometheus.Metric, n *nodeObj) { + ni := n.Status.NodeInfo + ch <- prometheus.MustNewConstMetric(nodeInfoDesc, prometheus.GaugeValue, 1, + e.node, ni.KernelVersion, ni.OSImage, ni.ContainerRuntimeVersion, ni.KubeProxyVersion) - podName := "nodevitals-" + node - ns := "platform-system" + for _, c := range n.Status.Conditions { + current := conditionState(c.Status) + for _, state := range nodeConditionStates { + v := 0.0 + if state == current { + v = 1 + } + ch <- prometheus.MustNewConstMetric(nodeStatusConditionDesc, prometheus.GaugeValue, v, + e.node, c.Type, state) + } + } - 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) + e.collectQuantities(ch, nodeStatusCapacityDesc, n.Status.Capacity) + e.collectQuantities(ch, nodeStatusAllocatableDesc, n.Status.Allocatable) } -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") +// collectQuantities skips resources whose value cannot be parsed rather than +// emitting a zero for them — "this node has 0 memory" is a worse answer than +// "this node did not report memory". +func (e *Exporter) collectQuantities(ch chan<- prometheus.Metric, desc *prometheus.Desc, res map[string]string) { + for name, raw := range res { + v, err := parseQuantity(raw) + if err != nil { + e.log.Warn("unreadable resource quantity — skipping", "node", e.node, "resource", name, "value", raw, "err", err) + continue + } + ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, v, e.node, name, resourceUnit(name)) } } + +func (e *Exporter) collectPods(ch chan<- prometheus.Metric, pods []podObj) { + for _, p := range pods { + kind, owner := "", "" + if len(p.Metadata.OwnerReferences) > 0 { + kind = p.Metadata.OwnerReferences[0].Kind + owner = p.Metadata.OwnerReferences[0].Name + } + ch <- prometheus.MustNewConstMetric(podInfoDesc, prometheus.GaugeValue, 1, + p.Metadata.Name, p.Metadata.Namespace, p.Status.HostIP, p.Status.PodIP, e.node, kind, owner) + + for _, phase := range podPhases { + v := 0.0 + if phase == p.Status.Phase { + v = 1 + } + ch <- prometheus.MustNewConstMetric(podStatusPhaseDesc, prometheus.GaugeValue, v, + p.Metadata.Name, p.Metadata.Namespace, phase) + } + + for _, cs := range p.Status.ContainerStatuses { + ready := 0.0 + if cs.Ready { + ready = 1 + } + ch <- prometheus.MustNewConstMetric(podContainerReadyDesc, prometheus.GaugeValue, ready, + cs.Name, p.Metadata.Name, p.Metadata.Namespace) + ch <- prometheus.MustNewConstMetric(podContainerRestartsDesc, prometheus.CounterValue, cs.RestartCount, + cs.Name, p.Metadata.Name, p.Metadata.Namespace) + } + } +} + +// conditionState maps the API's "True"/"False"/anything-else to the lowercase +// label value kube-state-metrics uses. +func conditionState(s string) string { + switch s { + case "True": + return "true" + case "False": + return "false" + default: + return "unknown" + } +} + +// Only the fields this package reports are declared; the API returns far more +// and encoding/json ignores the rest. +type nodeObj struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Status struct { + NodeInfo struct { + KernelVersion string `json:"kernelVersion"` + OSImage string `json:"osImage"` + ContainerRuntimeVersion string `json:"containerRuntimeVersion"` + KubeProxyVersion string `json:"kubeProxyVersion"` + } `json:"nodeInfo"` + Conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + } `json:"conditions"` + Capacity map[string]string `json:"capacity"` + Allocatable map[string]string `json:"allocatable"` + } `json:"status"` +} + +type podObj struct { + Metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + OwnerReferences []struct { + Kind string `json:"kind"` + Name string `json:"name"` + } `json:"ownerReferences"` + } `json:"metadata"` + Status struct { + Phase string `json:"phase"` + HostIP string `json:"hostIP"` + PodIP string `json:"podIP"` + ContainerStatuses []struct { + Name string `json:"name"` + Ready bool `json:"ready"` + RestartCount float64 `json:"restartCount"` + } `json:"containerStatuses"` + } `json:"status"` +} + +type podList struct { + Items []podObj `json:"items"` +} diff --git a/internal/ksmcompat/ksmcompat_test.go b/internal/ksmcompat/ksmcompat_test.go index e5b8c89..2b65be4 100644 --- a/internal/ksmcompat/ksmcompat_test.go +++ b/internal/ksmcompat/ksmcompat_test.go @@ -1,38 +1,278 @@ package ksmcompat import ( + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" "testing" "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/testutil" + dto "github.com/prometheus/client_model/go" ) -func TestKSMCompatExporter_NodeMode(t *testing.T) { - exp := New(Config{Node: "node-test-1", Mode: "node"}) +const nodeJSON = `{ + "metadata": {"name": "e999"}, + "status": { + "nodeInfo": { + "kernelVersion": "6.17.0-test", + "osImage": "Ubuntu Test 24.04", + "containerRuntimeVersion": "containerd://9.9.9", + "kubeProxyVersion": "v1.36.2+test" + }, + "conditions": [ + {"type": "Ready", "status": "True"}, + {"type": "MemoryPressure", "status": "False"} + ], + "capacity": {"cpu": "7", "memory": "12345Ki", "pods": "110"}, + "allocatable": {"cpu": "6", "memory": "12000Ki", "pods": "110"} + } +}` + +const podsJSON = `{"items": [ + { + "metadata": {"name": "p1", "namespace": "ns1", + "ownerReferences": [{"kind": "DaemonSet", "name": "ds1"}]}, + "status": {"phase": "Running", "hostIP": "10.31.10.99", "podIP": "10.31.10.99", + "containerStatuses": [{"name": "c1", "ready": true, "restartCount": 3}]} + } +]}` + +// fakeAPI stands in for the Kubernetes API server. It records what was asked +// for, so a test can assert on the request as well as the response. +type fakeAPI struct { + *httptest.Server + podQuery string + nodePath string +} + +func newFakeAPI(t *testing.T) *fakeAPI { + t.Helper() + f := &fakeAPI{} + f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/v1/nodes/"): + f.nodePath = r.URL.Path + fmt.Fprint(w, nodeJSON) + case r.URL.Path == "/api/v1/pods": + f.podQuery = r.URL.RawQuery + fmt.Fprint(w, podsJSON) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(f.Close) + return f +} + +func gather(t *testing.T, e *Exporter) map[string][]*dto.Metric { + t.Helper() reg := prometheus.NewRegistry() - reg.MustRegister(exp) + if err := reg.Register(e); err != nil { + t.Fatalf("register: %v", err) + } + families, err := reg.Gather() + if err != nil { + t.Logf("gather reported: %v", err) + } + out := map[string][]*dto.Metric{} + for _, f := range families { + out[f.GetName()] = f.GetMetric() + } + return out +} - count, err := testutil.GatherAndCount(reg, "kube_node_info", "kube_pod_info", "kube_node_status_condition") +func newTestExporter(t *testing.T, apiURL string) *Exporter { + t.Helper() + e, err := New(Config{ + Node: "e999", + Mode: "node", + APIURL: apiURL, + Token: "test-token", + Log: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) if err != nil { - t.Fatalf("failed to gather ksm metrics: %v", err) + t.Fatalf("New: %v", err) } + return e +} - if count < 3 { - t.Fatalf("expected at least 3 ksm metrics, got %d", count) +func labelOf(m *dto.Metric, name string) string { + for _, l := range m.GetLabel() { + if l.GetName() == name { + return l.GetValue() + } } + return "" } -func TestKSMCompatExporter_ClusterMode(t *testing.T) { - exp := New(Config{Node: "node-test-1", Mode: "cluster"}) - reg := prometheus.NewRegistry() - reg.MustRegister(exp) +// 노드 값은 API 가 말한 것이어야 한다. 상수로 채우면 모든 노드가 같은 용량을 +// 신고하고, 용량 기반 알림과 대시보드가 조용히 틀린 기준으로 돈다. +func TestNodeMetricsComeFromTheAPI(t *testing.T) { + api := newFakeAPI(t) + got := gather(t, newTestExporter(t, api.URL)) + + caps := got["kube_node_status_capacity"] + if len(caps) == 0 { + t.Fatal("kube_node_status_capacity not emitted") + } + var sawCPU bool + for _, m := range caps { + if labelOf(m, "resource") == "cpu" { + sawCPU = true + if v := m.GetGauge().GetValue(); v != 7 { + t.Errorf("cpu capacity = %v, want 7 (the value the API reported)", v) + } + if u := labelOf(m, "unit"); u != "core" { + t.Errorf("cpu unit = %q, want \"core\"", u) + } + } + if labelOf(m, "resource") == "memory" { + if v := m.GetGauge().GetValue(); v != 12345*1024 { + t.Errorf("memory capacity = %v, want %v", v, 12345*1024) + } + } + } + if !sawCPU { + t.Error("no cpu capacity series") + } + + info := got["kube_node_info"] + if len(info) != 1 { + t.Fatalf("kube_node_info: got %d series, want 1", len(info)) + } + if k := labelOf(info[0], "kernel_version"); k != "6.17.0-test" { + t.Errorf("kernel_version = %q, want the value from the API", k) + } + if r := labelOf(info[0], "container_runtime_version"); r != "containerd://9.9.9" { + t.Errorf("container_runtime_version = %q, want the value from the API", r) + } +} + +func TestNodeConditionsComeFromTheAPI(t *testing.T) { + api := newFakeAPI(t) + got := gather(t, newTestExporter(t, api.URL)) + + conds := got["kube_node_status_condition"] + if len(conds) == 0 { + t.Fatal("kube_node_status_condition not emitted") + } + // KSM 은 condition 마다 true/false/unknown 세 시리즈를 내고 해당하는 하나만 1 이다. + found := map[string]float64{} + for _, m := range conds { + if labelOf(m, "condition") == "Ready" { + found[labelOf(m, "status")] = m.GetGauge().GetValue() + } + } + if found["true"] != 1 || found["false"] != 0 || found["unknown"] != 0 { + t.Errorf("Ready condition series = %v, want true=1 false=0 unknown=0", found) + } +} + +func TestPodMetricsComeFromTheAPI(t *testing.T) { + api := newFakeAPI(t) + got := gather(t, newTestExporter(t, api.URL)) - count, err := testutil.GatherAndCount(reg, "kube_deployment_status_replicas", "kube_daemonset_status_number_ready") + info := got["kube_pod_info"] + if len(info) != 1 { + t.Fatalf("kube_pod_info: got %d series, want 1", len(info)) + } + if p := labelOf(info[0], "pod"); p != "p1" { + t.Errorf("pod = %q, want \"p1\"", p) + } + if ns := labelOf(info[0], "namespace"); ns != "ns1" { + t.Errorf("namespace = %q, want \"ns1\"", ns) + } + if k := labelOf(info[0], "created_by_kind"); k != "DaemonSet" { + t.Errorf("created_by_kind = %q, want \"DaemonSet\"", k) + } + + restarts := got["kube_pod_container_status_restarts_total"] + if len(restarts) != 1 { + t.Fatalf("restarts: got %d series, want 1", len(restarts)) + } + if v := restarts[0].GetCounter().GetValue(); v != 3 { + t.Errorf("restarts = %v, want 3", v) + } +} + +// DaemonSet 은 노드마다 한 벌 돈다. 각 파드가 클러스터 전체 파드를 내면 같은 +// 시리즈가 노드 수만큼 생긴다 — 서버 쪽에서 자기 노드로 좁혀야 한다. +func TestOnlyThisNodesPodsAreRequested(t *testing.T) { + api := newFakeAPI(t) + gather(t, newTestExporter(t, api.URL)) + + q, err := url.ParseQuery(api.podQuery) + if err != nil { + t.Fatalf("parse query %q: %v", api.podQuery, err) + } + if got := q.Get("fieldSelector"); got != "spec.nodeName=e999" { + t.Errorf("pod list fieldSelector = %q, want \"spec.nodeName=e999\"; "+ + "without it every node's agent reports every pod and the series multiply by the node count", got) + } +} + +// 도달 불가한 API 에 그럴듯한 기본값을 채우면, 관측 스택은 그것을 사실로 받아 +// 저장한다. 아무것도 내지 않는 편이 낫다. +func TestNoFabricatedMetricsWhenTheAPIIsUnreachable(t *testing.T) { + e, err := New(Config{ + Node: "e999", Mode: "node", + APIURL: "http://127.0.0.1:1", // 아무도 듣지 않는 포트 + Token: "test-token", + Log: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) if err != nil { - t.Fatalf("failed to gather cluster ksm metrics: %v", err) + t.Fatalf("New: %v", err) + } + got := gather(t, e) + for name, series := range got { + for _, m := range series { + t.Errorf("%s emitted %d series with the API unreachable; a value invented here "+ + "becomes an assertion about the cluster that nothing can distinguish from a real one", + name, len(m.GetLabel())) + break + } + } +} + +func TestAPIErrorStatusDoesNotBecomeMetrics(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"forbidden"}`, http.StatusForbidden) + })) + t.Cleanup(srv.Close) + + got := gather(t, newTestExporter(t, srv.URL)) + if len(got) != 0 { + t.Errorf("got %d metric families on HTTP 403; RBAC being wrong must look like missing data, not like a healthy cluster", len(got)) + } +} + +// cluster 모드는 DaemonSet 에서 성립하지 않는다 — 노드마다 한 벌씩 전역 수집을 +// 하면 모든 시리즈가 노드 수만큼 중복된다. 조용히 node 로 강등하지 않고 거부해서, +// 설정한 사람이 그 사실을 알게 한다. +func TestClusterModeIsRefusedAtStartup(t *testing.T) { + _, err := New(Config{Node: "e999", Mode: "cluster", APIURL: "http://example.invalid", Token: "t"}) + if err == nil { + t.Fatal("cluster mode was accepted; every node would then report the same cluster-wide series") } + if !strings.Contains(err.Error(), "cluster") { + t.Errorf("error %q does not name the offending mode", err) + } +} + +func TestUnknownModeIsRefused(t *testing.T) { + if _, err := New(Config{Node: "e999", Mode: "nodes", APIURL: "http://example.invalid", Token: "t"}); err == nil { + t.Error("typo'd mode was accepted; it would silently collect something other than what was asked for") + } +} - if count < 2 { - t.Fatalf("expected at least 2 cluster metrics, got %d", count) +// 토큰이 없으면 인증이 성립하지 않는다. 기동 시점에 말해 주지 않으면 증상은 +// "메트릭이 안 나온다" 뿐이라 원인까지 도달하는 데 시간이 걸린다. +func TestMissingTokenIsRefusedAtStartup(t *testing.T) { + if _, err := New(Config{Node: "e999", Mode: "node", APIURL: "http://example.invalid"}); err == nil { + t.Error("empty token was accepted; the failure would only surface as absent metrics") } } diff --git a/internal/ksmcompat/quantity.go b/internal/ksmcompat/quantity.go new file mode 100644 index 0000000..3d017ad --- /dev/null +++ b/internal/ksmcompat/quantity.go @@ -0,0 +1,87 @@ +package ksmcompat + +import ( + "fmt" + "strconv" +) + +// suffixes maps a Kubernetes Quantity suffix to its multiplier. The binary set +// (Ki, Mi, …) and the decimal set (k, M, …) differ by more than 2% at Gi and +// keep diverging, so treating one as the other silently rescales every capacity +// metric derived from it. +// +// "m" is the odd one out: it is a *milli* factor, used for fractional CPU +// ("500m" = half a core), not a multiple. +var suffixes = map[string]float64{ + "": 1, + "m": 0.001, + "k": 1e3, + "M": 1e6, + "G": 1e9, + "T": 1e12, + "P": 1e15, + "E": 1e18, + "Ki": 1 << 10, + "Mi": 1 << 20, + "Gi": 1 << 30, + "Ti": 1 << 40, + "Pi": 1 << 50, + "Ei": 1 << 60, +} + +// parseQuantity converts a Kubernetes Quantity string to a float64. +// +// It returns an error rather than a zero value for anything it cannot read: a +// quantity that silently becomes 0 turns into a metric asserting the node has +// no capacity, which is a worse outcome than the series being absent. +func parseQuantity(s string) (float64, error) { + if s == "" { + return 0, fmt.Errorf("empty quantity") + } + + // 접미사는 뒤에서부터 긴 것(2자)을 먼저 본다 — "Mi" 를 "M" 으로 읽으면 + // 1.048576 배가 아니라 1.0 배가 되어 값이 조용히 어긋난다. + num, suffix := s, "" + for _, n := range []int{2, 1} { + if len(s) <= n { + continue + } + cand := s[len(s)-n:] + if _, ok := suffixes[cand]; ok { + // 지수 표기("1e3")의 뒷자리를 접미사로 오인하지 않도록, 남은 앞부분이 + // 숫자로 끝나는지 본다. + head := s[:len(s)-n] + if head != "" && isDigitOrDot(head[len(head)-1]) { + num, suffix = head, cand + break + } + } + } + + v, err := strconv.ParseFloat(num, 64) + if err != nil { + return 0, fmt.Errorf("parse quantity %q: %w", s, err) + } + return v * suffixes[suffix], nil +} + +func isDigitOrDot(b byte) bool { + return (b >= '0' && b <= '9') || b == '.' +} + +// resourceUnit reports the unit label kube-state-metrics attaches to a given +// resource in kube_node_status_capacity / _allocatable. Matching it matters: +// the label is part of the series identity, so a different value silently +// creates a parallel series instead of the one existing queries select. +func resourceUnit(resource string) string { + switch resource { + case "cpu": + return "core" + case "memory", "ephemeral-storage": + return "byte" + case "pods": + return "integer" + default: + return "integer" + } +} diff --git a/internal/ksmcompat/quantity_test.go b/internal/ksmcompat/quantity_test.go new file mode 100644 index 0000000..3b6cff6 --- /dev/null +++ b/internal/ksmcompat/quantity_test.go @@ -0,0 +1,46 @@ +package ksmcompat + +import "testing" + +// kube_node_status_capacity 는 API 가 돌려주는 Quantity 문자열을 숫자로 옮긴 +// 값이다. 접미사를 잘못 읽으면 값이 1024 배씩 어긋나는데, 메트릭 자체는 정상으로 +// 보이므로 용량 기반 알림이 조용히 틀린 기준으로 돈다. +func TestParseQuantity(t *testing.T) { + cases := []struct { + in string + want float64 + }{ + {"7", 7}, + {"0", 0}, + {"500m", 0.5}, // cpu 밀리코어 + {"1500m", 1.5}, // 1 코어를 넘는 밀리코어 + {"12345Ki", 12641280}, + {"64Mi", 67108864}, + {"2Gi", 2147483648}, + {"1Ti", 1099511627776}, + {"1k", 1000}, // 10진 접두사 — Ki 와 다르다 + {"1M", 1000000}, + {"1G", 1000000000}, + {"123456789", 123456789}, + {"1e3", 1000}, // 지수 표기도 유효한 Quantity 다 + } + for _, c := range cases { + got, err := parseQuantity(c.in) + if err != nil { + t.Errorf("parseQuantity(%q): unexpected error %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("parseQuantity(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// 읽을 수 없는 값에 0 을 돌려주면 "용량 0" 이라는 거짓 사실이 메트릭이 된다. +func TestParseQuantityRejectsGarbageRatherThanReturningZero(t *testing.T) { + for _, in := range []string{"", "abc", "12Xi", "1.2.3", "Ki"} { + if got, err := parseQuantity(in); err == nil { + t.Errorf("parseQuantity(%q) = %v with no error; an unreadable quantity must not become a number", in, got) + } + } +}