Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 13 additions & 2 deletions cmd/nodevitals/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions deploy/chart/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 -}}
1 change: 1 addition & 0 deletions deploy/chart/templates/configmap-single.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{{- include "nodevitals.validateKsmMode" . }}
{{- if .Values.singlePod }}
{{- $tiers := splitList " " (include "nodevitals.enabledTiers" .) }}
apiVersion: v1
Expand Down
1 change: 1 addition & 0 deletions deploy/chart/templates/configmap.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{{- include "nodevitals.validateKsmMode" . }}
{{- if and (not .Values.singlePod) .Values.tiers.core.enabled }}
apiVersion: v1
kind: ConfigMap
Expand Down
2 changes: 1 addition & 1 deletion deploy/chart/templates/daemonset-single.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion deploy/chart/templates/daemonset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions deploy/chart/templates/rbac.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
42 changes: 38 additions & 4 deletions deploy/chart/tests/compatibility-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
24 changes: 20 additions & 4 deletions deploy/chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading