diff --git a/.changeset/dashboard-provisioning.md b/.changeset/dashboard-provisioning.md new file mode 100644 index 00000000..f5220106 --- /dev/null +++ b/.changeset/dashboard-provisioning.md @@ -0,0 +1,5 @@ +--- +"helm-charts": minor +--- + +feat: add dashboard provisioning via a k8s-sidecar that discovers ConfigMaps labeled `hyperdx.io/dashboard: true`. Discovery is scoped to the release namespace by default (a namespaced Role, no cluster-wide access); set `hyperdx.dashboards.namespaces` to also watch specific namespaces, or `hyperdx.dashboards.namespaces: [ALL]` for cluster-wide discovery. Requires hyperdxio/hyperdx#1962 (file-based dashboard provisioner). diff --git a/charts/clickstack/templates/NOTES.txt b/charts/clickstack/templates/NOTES.txt index 40796eb2..685da7bf 100644 --- a/charts/clickstack/templates/NOTES.txt +++ b/charts/clickstack/templates/NOTES.txt @@ -34,5 +34,13 @@ Application Access: directly to the internet without proper authentication and encryption. {{- end }} +{{- if and .Values.hyperdx.dashboards.enabled (not .Values.hyperdx.dashboards.rbac.create) }} + +WARNING: hyperdx.dashboards.rbac.create is false. The dashboard watcher needs +read access (list/get/watch) to ConfigMaps in its discovery scope. Grant it +out-of-band (e.g. a Role/RoleBinding bound to the HyperDX ServiceAccount), or the +watcher will hit 403 errors and CrashLoop. +{{- end }} + To verify the deployment status, run: - kubectl get pods -l "app.kubernetes.io/name={{ include "clickstack.name" . }}" + kubectl get pods -l "app.kubernetes.io/name={{ include "clickstack.name" . }}" diff --git a/charts/clickstack/templates/_helpers.tpl b/charts/clickstack/templates/_helpers.tpl index 913dfcf0..e1f45a3c 100644 --- a/charts/clickstack/templates/_helpers.tpl +++ b/charts/clickstack/templates/_helpers.tpl @@ -34,6 +34,98 @@ suffix is kept for backward compatibility. {{- end -}} {{- end -}} +{{/* +HyperDX ServiceAccount name. Shared by the Deployment, ServiceAccount, and +dashboard-provisioner RBAC subject so the three can never drift out of sync. +*/}} +{{- define "clickstack.hyperdx.serviceAccountName" -}} +{{- .Values.hyperdx.serviceAccount.name | default (include "clickstack.hyperdx.fullname" .) -}} +{{- end -}} + +{{/* +Dashboard discovery label. Shared by the inline dashboard ConfigMap (producer) +and the watcher sidecar (consumer) so the selector can't drift between them. +*/}} +{{- define "clickstack.hyperdx.dashboardLabelKey" -}}hyperdx.io/dashboard{{- end -}} +{{- define "clickstack.hyperdx.dashboardLabelValue" -}}true{{- end -}} + +{{/* +RBAC rules the dashboard watcher needs: read-only access to ConfigMaps. Shared by +the namespaced Role and the cluster-scoped ClusterRole branches. +*/}} +{{- define "clickstack.hyperdx.dashboardRbacRules" -}} +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["list", "get", "watch"] +{{- end -}} + +{{/* +Reject contradictory discovery scope. The "ALL" sentinel already grants cluster-wide +discovery, so combining it with named namespaces would silently escalate to cluster +scope — fail fast instead of granting more than the operator likely intended. +*/}} +{{- define "clickstack.hyperdx.validateDashboards" -}} +{{- if .Values.hyperdx.dashboards.enabled -}} +{{- $namespaces := .Values.hyperdx.dashboards.namespaces -}} +{{- if and (eq (include "clickstack.hyperdx.dashboardsClusterWide" .) "true") (gt (len $namespaces) 1) -}} +{{- fail "hyperdx.dashboards.namespaces: \"ALL\" cannot be combined with specific namespaces (it already grants cluster-wide discovery)" -}} +{{- end -}} +{{- range $namespaces -}} +{{- if not (trim .) -}} +{{- fail "hyperdx.dashboards.namespaces: entries must be non-empty namespace names" -}} +{{- end -}} +{{- if and (ne . "ALL") (eq (upper (trim .)) "ALL") -}} +{{- fail "hyperdx.dashboards.namespaces: use exactly \"ALL\" (uppercase, no surrounding spaces) for cluster-wide discovery" -}} +{{- end -}} +{{- if and (ne . "ALL") (not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" (trim .))) -}} +{{- fail "hyperdx.dashboards.namespaces: entries must be valid DNS-1123 labels (lowercase alphanumeric and '-')" -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Namespaces the watcher discovers in for the non-ALL scope: the release namespace +plus any configured extras, deduped and comma-joined. Single source for both the +watcher NAMESPACE env and the per-namespace RoleBindings so watch scope and granted +scope can't drift. +*/}} +{{- define "clickstack.hyperdx.effectiveNamespaces" -}} +{{- $trimmed := list -}} +{{- range (concat (list .Release.Namespace) .Values.hyperdx.dashboards.namespaces) -}} +{{- $trimmed = append $trimmed (trim .) -}} +{{- end -}} +{{- $trimmed | uniq | join "," -}} +{{- end -}} + +{{/* +Whether dashboard discovery is cluster-wide (the ALL sentinel). Single source for the +Role-vs-ClusterRole choice in the RBAC template and the watcher NAMESPACE env in the +Deployment, so the granted scope and the watched scope can't diverge. Renders "true" or "". +*/}} +{{- define "clickstack.hyperdx.dashboardsClusterWide" -}} +{{- if has "ALL" .Values.hyperdx.dashboards.namespaces }}true{{- end -}} +{{- end -}} + +{{/* +Whether discovery spans more than one namespace (the effective, deduped set is larger +than just the release namespace). Selects the scoped ClusterRole + per-namespace +RoleBinding path over a plain namespaced Role. Renders "true" or "". +*/}} +{{- define "clickstack.hyperdx.dashboardsCrossNamespace" -}} +{{- if gt (len (include "clickstack.hyperdx.effectiveNamespaces" . | splitList ",")) 1 }}true{{- end -}} +{{- end -}} + +{{/* +Whether this chart creates the HyperDX ServiceAccount: when explicitly requested, or when +dashboards need one and no external name is supplied. Shared by the ServiceAccount template +and the Deployment's serviceAccountName guard so the "the named SA must exist" invariant +can't drift between them. Renders "true" or "". +*/}} +{{- define "clickstack.hyperdx.createServiceAccount" -}} +{{- if or .Values.hyperdx.serviceAccount.create (and .Values.hyperdx.dashboards.enabled (not .Values.hyperdx.serviceAccount.name)) }}true{{- end -}} +{{- end -}} + {{/* Create chart name and version as used by the chart label. */}} diff --git a/charts/clickstack/templates/hyperdx/dashboard-configmap.yaml b/charts/clickstack/templates/hyperdx/dashboard-configmap.yaml new file mode 100644 index 00000000..e5d9cf8b --- /dev/null +++ b/charts/clickstack/templates/hyperdx/dashboard-configmap.yaml @@ -0,0 +1,18 @@ +{{- if and .Values.hyperdx.dashboards.enabled .Values.hyperdx.dashboards.configMaps }} +{{- /* + Inline dashboard ConfigMap; labeled for discovery by the dashboard provisioner + alongside any external dashboard ConfigMaps from application charts. +*/ -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "clickstack.fullname" . }}-dashboards + labels: + {{- include "clickstack.labels" . | nindent 4 }} + {{ include "clickstack.hyperdx.dashboardLabelKey" . }}: {{ include "clickstack.hyperdx.dashboardLabelValue" . | quote }} +data: + {{- range $key, $value := .Values.hyperdx.dashboards.configMaps }} + {{ $key | quote }}: | + {{- $value | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/clickstack/templates/hyperdx/dashboard-provisioner-rbac.yaml b/charts/clickstack/templates/hyperdx/dashboard-provisioner-rbac.yaml new file mode 100644 index 00000000..7a4a784f --- /dev/null +++ b/charts/clickstack/templates/hyperdx/dashboard-provisioner-rbac.yaml @@ -0,0 +1,86 @@ +{{- if and .Values.hyperdx.dashboards.enabled .Values.hyperdx.dashboards.rbac.create }} +{{- $fullname := include "clickstack.fullname" . -}} +{{- $saName := include "clickstack.hyperdx.serviceAccountName" . -}} +{{- /* Cluster-scoped objects are namespace-qualified so two same-named releases don't collide. RBAC names allow 253 chars (not 63), so the qualifier always survives. */}} +{{- $clusterName := printf "%s-%s-dashboard-provisioner" $fullname .Release.Namespace | trunc 253 | trimSuffix "-" -}} +{{- if eq (include "clickstack.hyperdx.dashboardsClusterWide" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ $clusterName }} + labels: + {{- include "clickstack.labels" . | nindent 4 }} +rules: + {{- include "clickstack.hyperdx.dashboardRbacRules" . | nindent 2 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ $clusterName }} + labels: + {{- include "clickstack.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ $clusterName }} +subjects: + - kind: ServiceAccount + name: {{ $saName }} + namespace: {{ .Release.Namespace }} +{{- else if eq (include "clickstack.hyperdx.dashboardsCrossNamespace" .) "true" }} +{{- /* Scoped cross-namespace discovery (effective set spans >1 namespace): one ClusterRole as a rule template, bound per namespace via RoleBindings so read access never becomes cluster-wide. */}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ $clusterName }} + labels: + {{- include "clickstack.labels" . | nindent 4 }} +rules: + {{- include "clickstack.hyperdx.dashboardRbacRules" . | nindent 2 }} +{{- range $ns := (include "clickstack.hyperdx.effectiveNamespaces" . | splitList ",") }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ $clusterName }} + namespace: {{ $ns }} + labels: + {{- include "clickstack.labels" $ | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ $clusterName }} +subjects: + - kind: ServiceAccount + name: {{ $saName }} + namespace: {{ $.Release.Namespace }} +{{- end }} +{{- else }} +{{- /* Default: discovery scoped to the release namespace only — a namespaced Role, no cluster-scoped objects. */}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ $fullname }}-dashboard-provisioner + namespace: {{ .Release.Namespace }} + labels: + {{- include "clickstack.labels" . | nindent 4 }} +rules: + {{- include "clickstack.hyperdx.dashboardRbacRules" . | nindent 2 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ $fullname }}-dashboard-provisioner + namespace: {{ .Release.Namespace }} + labels: + {{- include "clickstack.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ $fullname }}-dashboard-provisioner +subjects: + - kind: ServiceAccount + name: {{ $saName }} + namespace: {{ .Release.Namespace }} +{{- end }} +{{- end }} diff --git a/charts/clickstack/templates/hyperdx/deployment.yaml b/charts/clickstack/templates/hyperdx/deployment.yaml index 0719d075..87a81469 100644 --- a/charts/clickstack/templates/hyperdx/deployment.yaml +++ b/charts/clickstack/templates/hyperdx/deployment.yaml @@ -1,3 +1,4 @@ +{{- include "clickstack.hyperdx.validateDashboards" . -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -45,8 +46,8 @@ spec: {{- if .Values.hyperdx.deployment.priorityClassName }} priorityClassName: {{ .Values.hyperdx.deployment.priorityClassName | quote }} {{- end }} - {{- if or .Values.hyperdx.serviceAccount.create .Values.hyperdx.serviceAccount.name }} - serviceAccountName: {{ .Values.hyperdx.serviceAccount.name | default (include "clickstack.hyperdx.fullname" .) }} + {{- if or (eq (include "clickstack.hyperdx.createServiceAccount" .) "true") .Values.hyperdx.serviceAccount.name }} + serviceAccountName: {{ include "clickstack.hyperdx.serviceAccountName" . }} {{- end }} {{- if .Values.global.imagePullSecrets }} imagePullSecrets: @@ -64,9 +65,15 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} {{- end }} - {{- with .Values.hyperdx.deployment.volumes }} + {{- if or .Values.hyperdx.deployment.volumes .Values.hyperdx.dashboards.enabled }} volumes: + {{- with .Values.hyperdx.deployment.volumes }} {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.hyperdx.dashboards.enabled }} + - name: dashboards + emptyDir: {} + {{- end }} {{- end }} containers: - name: app @@ -103,9 +110,18 @@ spec: timeoutSeconds: {{ .Values.hyperdx.deployment.readinessProbe.timeoutSeconds }} failureThreshold: {{ .Values.hyperdx.deployment.readinessProbe.failureThreshold }} {{- end }} - {{- with .Values.hyperdx.deployment.volumeMounts }} + {{- if or .Values.hyperdx.deployment.volumeMounts .Values.hyperdx.dashboards.enabled }} volumeMounts: + {{- with .Values.hyperdx.deployment.volumeMounts }} {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.hyperdx.dashboards.enabled }} + # read-only: the HyperDX provisioner only reads .json files here, never writes + # (hyperdxio/hyperdx#1962); the watcher sidecar owns writes to the shared volume. + - name: dashboards + mountPath: /dashboards + readOnly: true + {{- end }} {{- end }} envFrom: - configMapRef: @@ -138,6 +154,47 @@ spec: value: {{ tpl .Values.hyperdx.deployment.defaultSources . | quote }} {{- end }} {{- end }} + {{- if .Values.hyperdx.dashboards.enabled }} + - name: DASHBOARD_PROVISIONER_DIR + value: "/dashboards" + # Provision discovered dashboards for all HyperDX teams: the watcher has no + # team context, so per-team provisioning isn't expressible from a ConfigMap. + - name: DASHBOARD_PROVISIONER_ALL_TEAMS + value: "true" + {{- end }} {{- with .Values.hyperdx.deployment.env }} {{- toYaml . | nindent 12 }} {{- end }} + {{- if .Values.hyperdx.dashboards.enabled }} + - name: dashboard-watcher + image: {{ .Values.hyperdx.dashboards.sidecarImage | quote }} + {{- with .Values.hyperdx.dashboards.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: LABEL + value: {{ include "clickstack.hyperdx.dashboardLabelKey" . | quote }} + - name: LABEL_VALUE + value: {{ include "clickstack.hyperdx.dashboardLabelValue" . | quote }} + - name: FOLDER + value: "/dashboards" + - name: RESOURCE + value: "configmap" + # Continuously watch for ConfigMap changes (also the image default) so + # dashboards added after startup are picked up, not just at boot. + - name: METHOD + value: "WATCH" + {{- if eq (include "clickstack.hyperdx.dashboardsClusterWide" .) "true" }} + - name: NAMESPACE + value: "ALL" + {{- else }} + - name: NAMESPACE + value: {{ include "clickstack.hyperdx.effectiveNamespaces" . | quote }} + {{- end }} + - name: UNIQUE_FILENAMES + value: "true" + volumeMounts: + - name: dashboards + mountPath: /dashboards + {{- end }} diff --git a/charts/clickstack/templates/hyperdx/serviceaccount.yaml b/charts/clickstack/templates/hyperdx/serviceaccount.yaml index 78a60014..f42b6bc9 100644 --- a/charts/clickstack/templates/hyperdx/serviceaccount.yaml +++ b/charts/clickstack/templates/hyperdx/serviceaccount.yaml @@ -1,8 +1,8 @@ -{{- if .Values.hyperdx.serviceAccount.create }} +{{- if eq (include "clickstack.hyperdx.createServiceAccount" .) "true" }} apiVersion: v1 kind: ServiceAccount metadata: - name: {{ .Values.hyperdx.serviceAccount.name | default (include "clickstack.hyperdx.fullname" .) }} + name: {{ include "clickstack.hyperdx.serviceAccountName" . }} labels: {{- include "clickstack.labels" . | nindent 4 }} {{- with .Values.hyperdx.serviceAccount.annotations }} diff --git a/charts/clickstack/tests/dashboard-notes_test.yaml b/charts/clickstack/tests/dashboard-notes_test.yaml new file mode 100644 index 00000000..33dae61e --- /dev/null +++ b/charts/clickstack/tests/dashboard-notes_test.yaml @@ -0,0 +1,28 @@ +suite: Test Dashboard Provisioner NOTES +templates: + - NOTES.txt +tests: + - it: should warn when rbac.create is false with dashboards enabled + set: + hyperdx: + dashboards: + enabled: true + rbac: + create: false + asserts: + - matchRegexRaw: + pattern: "hyperdx.dashboards.rbac.create is false" + + - it: should not warn when rbac.create is true and dashboards enabled + set: + hyperdx: + dashboards: + enabled: true + asserts: + - notMatchRegexRaw: + pattern: "hyperdx.dashboards.rbac.create is false" + + - it: should not warn when dashboards are disabled + asserts: + - notMatchRegexRaw: + pattern: "hyperdx.dashboards.rbac.create is false" diff --git a/charts/clickstack/tests/dashboard-provisioner_test.yaml b/charts/clickstack/tests/dashboard-provisioner_test.yaml new file mode 100644 index 00000000..4d9c2241 --- /dev/null +++ b/charts/clickstack/tests/dashboard-provisioner_test.yaml @@ -0,0 +1,920 @@ +suite: Test Dashboard Provisioner +templates: + - hyperdx/deployment.yaml + - hyperdx/dashboard-configmap.yaml + - hyperdx/dashboard-provisioner-rbac.yaml + - hyperdx/serviceaccount.yaml +tests: + - it: should not render RBAC or ConfigMap when dashboards are disabled + set: + hyperdx: + dashboards: + enabled: false + asserts: + - hasDocuments: + count: 0 + template: hyperdx/dashboard-configmap.yaml + - hasDocuments: + count: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should not add sidecar when dashboards are disabled + set: + hyperdx: + dashboards: + enabled: false + asserts: + - lengthEqual: + path: spec.template.spec.containers + count: 1 + template: hyperdx/deployment.yaml + + - it: should not render dashboard volumes, mounts, or env when disabled + set: + hyperdx: + dashboards: + enabled: false + asserts: + - isNull: + path: spec.template.spec.volumes + template: hyperdx/deployment.yaml + - isNull: + path: spec.template.spec.containers[0].volumeMounts + template: hyperdx/deployment.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: DASHBOARD_PROVISIONER_DIR + value: "/dashboards" + template: hyperdx/deployment.yaml + + - it: should keep user volumes and mounts without a dashboards entry when disabled + set: + hyperdx: + dashboards: + enabled: false + deployment: + volumes: + - name: extra + emptyDir: {} + volumeMounts: + - name: extra + mountPath: /extra + asserts: + - lengthEqual: + path: spec.template.spec.volumes + count: 1 + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.volumes + content: + name: extra + emptyDir: {} + template: hyperdx/deployment.yaml + - notContains: + path: spec.template.spec.volumes + content: + name: dashboards + emptyDir: {} + template: hyperdx/deployment.yaml + - lengthEqual: + path: spec.template.spec.containers[0].volumeMounts + count: 1 + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: extra + mountPath: /extra + template: hyperdx/deployment.yaml + - notContains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: dashboards + mountPath: /dashboards + readOnly: true + template: hyperdx/deployment.yaml + + - it: should add dashboard-watcher sidecar when enabled + set: + hyperdx: + dashboards: + enabled: true + asserts: + - lengthEqual: + path: spec.template.spec.containers + count: 2 + template: hyperdx/deployment.yaml + - equal: + path: spec.template.spec.containers[1].name + value: dashboard-watcher + template: hyperdx/deployment.yaml + + - it: should set DASHBOARD_PROVISIONER_DIR on the app container + set: + hyperdx: + dashboards: + enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DASHBOARD_PROVISIONER_DIR + value: "/dashboards" + template: hyperdx/deployment.yaml + + - it: should use the k8s-sidecar image for watcher + set: + hyperdx: + dashboards: + enabled: true + sidecarImage: "kiwigrid/k8s-sidecar:2.5.0" + asserts: + - equal: + path: spec.template.spec.containers[1].image + value: "kiwigrid/k8s-sidecar:2.5.0" + template: hyperdx/deployment.yaml + + - it: should set the discovery label on the watcher + set: + hyperdx: + dashboards: + enabled: true + asserts: + - contains: + path: spec.template.spec.containers[1].env + content: + name: LABEL + value: "hyperdx.io/dashboard" + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.containers[1].env + content: + name: LABEL_VALUE + value: "true" + template: hyperdx/deployment.yaml + + - it: should configure the watcher's discovery behavior env + set: + hyperdx: + dashboards: + enabled: true + asserts: + - contains: + path: spec.template.spec.containers[1].env + content: + name: FOLDER + value: "/dashboards" + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.containers[1].env + content: + name: RESOURCE + value: "configmap" + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.containers[1].env + content: + name: METHOD + value: "WATCH" + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.containers[1].env + content: + name: UNIQUE_FILENAMES + value: "true" + template: hyperdx/deployment.yaml + + - it: should scope the watcher to the release namespace by default + release: + namespace: observability + set: + hyperdx: + dashboards: + enabled: true + asserts: + - contains: + path: spec.template.spec.containers[1].env + content: + name: NAMESPACE + value: observability + template: hyperdx/deployment.yaml + + - it: should watch listed namespaces plus the release namespace + release: + namespace: observability + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - team-a + - team-b + asserts: + - contains: + path: spec.template.spec.containers[1].env + content: + name: NAMESPACE + value: observability,team-a,team-b + template: hyperdx/deployment.yaml + + - it: should watch all namespaces when ALL is listed + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - ALL + asserts: + - contains: + path: spec.template.spec.containers[1].env + content: + name: NAMESPACE + value: "ALL" + template: hyperdx/deployment.yaml + + - it: should share dashboards volume between app and watcher + set: + hyperdx: + dashboards: + enabled: true + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: dashboards + emptyDir: {} + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: dashboards + mountPath: /dashboards + readOnly: true + template: hyperdx/deployment.yaml + # Exact match: the watcher's mount must be writable (no readOnly), or it can't + # write discovered dashboards into the shared volume. + - equal: + path: spec.template.spec.containers[1].volumeMounts[0] + value: + name: dashboards + mountPath: /dashboards + template: hyperdx/deployment.yaml + + - it: should merge user volumes and volumeMounts with dashboards entries without duplicate keys + set: + hyperdx: + dashboards: + enabled: true + deployment: + volumes: + - name: extra + emptyDir: {} + volumeMounts: + - name: extra + mountPath: /extra + asserts: + - lengthEqual: + path: spec.template.spec.containers[0].volumeMounts + count: 2 + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: extra + mountPath: /extra + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: dashboards + mountPath: /dashboards + readOnly: true + template: hyperdx/deployment.yaml + - lengthEqual: + path: spec.template.spec.volumes + count: 2 + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.volumes + content: + name: extra + emptyDir: {} + template: hyperdx/deployment.yaml + - contains: + path: spec.template.spec.volumes + content: + name: dashboards + emptyDir: {} + template: hyperdx/deployment.yaml + + - it: should set DASHBOARD_PROVISIONER_ALL_TEAMS on the app container + set: + hyperdx: + dashboards: + enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DASHBOARD_PROVISIONER_ALL_TEAMS + value: "true" + template: hyperdx/deployment.yaml + + - it: should apply configured resources to the dashboard-watcher sidecar + set: + hyperdx: + dashboards: + enabled: true + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + asserts: + - equal: + path: spec.template.spec.containers[1].resources.limits.memory + value: 256Mi + template: hyperdx/deployment.yaml + - equal: + path: spec.template.spec.containers[1].resources.requests.cpu + value: 100m + template: hyperdx/deployment.yaml + + - it: should apply the default watcher resources when not overridden + set: + hyperdx: + dashboards: + enabled: true + asserts: + - equal: + path: spec.template.spec.containers[1].resources.limits.memory + value: 64Mi + template: hyperdx/deployment.yaml + - equal: + path: spec.template.spec.containers[1].resources.limits.cpu + value: 50m + template: hyperdx/deployment.yaml + - equal: + path: spec.template.spec.containers[1].resources.requests.memory + value: 32Mi + template: hyperdx/deployment.yaml + - equal: + path: spec.template.spec.containers[1].resources.requests.cpu + value: 10m + template: hyperdx/deployment.yaml + + - it: should auto-create SA when dashboards enabled and no user SA configured + set: + hyperdx: + dashboards: + enabled: true + serviceAccount: + create: false + name: "" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: RELEASE-NAME-clickstack-app + template: hyperdx/deployment.yaml + - hasDocuments: + count: 1 + template: hyperdx/serviceaccount.yaml + + - it: should use existing user SA and not create a new one + set: + hyperdx: + dashboards: + enabled: true + serviceAccount: + create: false + name: "my-custom-sa" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: my-custom-sa + template: hyperdx/deployment.yaml + - hasDocuments: + count: 0 + template: hyperdx/serviceaccount.yaml + + - it: should create a namespaced Role and RoleBinding by default + release: + namespace: observability + set: + hyperdx: + dashboards: + enabled: true + asserts: + - hasDocuments: + count: 2 + template: hyperdx/dashboard-provisioner-rbac.yaml + - isKind: + of: Role + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: metadata.namespace + value: observability + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + - isKind: + of: RoleBinding + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: metadata.namespace + value: observability + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: subjects[0].namespace + value: observability + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: roleRef.kind + value: Role + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: roleRef.name + value: RELEASE-NAME-clickstack-dashboard-provisioner + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should grant configmap list, get, and watch on the default Role + set: + hyperdx: + dashboards: + enabled: true + asserts: + - equal: + path: rules[0].verbs + value: ["list", "get", "watch"] + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should bind the default Role to the user SA when configured + set: + hyperdx: + dashboards: + enabled: true + serviceAccount: + name: "my-custom-sa" + asserts: + - equal: + path: subjects[0].name + value: my-custom-sa + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should bind the default Role to the generated SA when no user SA configured + set: + hyperdx: + dashboards: + enabled: true + asserts: + - equal: + path: subjects[0].name + value: RELEASE-NAME-clickstack-app + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should create a ClusterRole plus a RoleBinding per namespace when namespaces are listed + release: + namespace: observability + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - team-a + - team-b + asserts: + - hasDocuments: + count: 4 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: rules[0].verbs + value: ["list", "get", "watch"] + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + # Pin the cross-namespace ClusterRole to ConfigMaps only, same as the [ALL] branch, + # so a future added rule can't silently widen the shared SA's access. + - equal: + path: rules[0].resources + value: ["configmaps"] + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + - lengthEqual: + path: rules + count: 1 + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + - isKind: + of: ClusterRole + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + - isKind: + of: RoleBinding + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: metadata.namespace + value: observability + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: metadata.namespace + value: team-a + documentIndex: 2 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: metadata.namespace + value: team-b + documentIndex: 3 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: subjects[0].name + value: RELEASE-NAME-clickstack-app + documentIndex: 2 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: roleRef.kind + value: ClusterRole + documentIndex: 2 + template: hyperdx/dashboard-provisioner-rbac.yaml + # Cluster-scoped ClusterRole and the per-namespace RoleBindings must both be + # namespace-qualified so two same-named releases can't collide. + - equal: + path: metadata.name + value: RELEASE-NAME-clickstack-observability-dashboard-provisioner + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: metadata.name + value: RELEASE-NAME-clickstack-observability-dashboard-provisioner + documentIndex: 2 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: roleRef.name + value: RELEASE-NAME-clickstack-observability-dashboard-provisioner + documentIndex: 2 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: subjects[0].namespace + value: observability + documentIndex: 2 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should deduplicate the release namespace when it is also listed + release: + namespace: observability + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - observability + - team-a + asserts: + - hasDocuments: + count: 3 + template: hyperdx/dashboard-provisioner-rbac.yaml + - contains: + path: spec.template.spec.containers[1].env + content: + name: NAMESPACE + value: observability,team-a + template: hyperdx/deployment.yaml + + - it: should dedupe whitespace-padded near-duplicate namespaces on the trimmed value + release: + namespace: observability + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - team-a + - " team-a " + asserts: + # ClusterRole + RoleBinding(observability) + RoleBinding(team-a) — the padded + # duplicate must collapse, not emit a second colliding RoleBinding. + - hasDocuments: + count: 3 + template: hyperdx/dashboard-provisioner-rbac.yaml + - contains: + path: spec.template.spec.containers[1].env + content: + name: NAMESPACE + value: observability,team-a + template: hyperdx/deployment.yaml + + - it: should use a namespaced Role when the only effective namespace is the release namespace + release: + namespace: observability + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - observability + asserts: + - hasDocuments: + count: 2 + template: hyperdx/dashboard-provisioner-rbac.yaml + - isKind: + of: Role + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + - isKind: + of: RoleBinding + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - contains: + path: spec.template.spec.containers[1].env + content: + name: NAMESPACE + value: observability + template: hyperdx/deployment.yaml + + - it: should create a ClusterRole and ClusterRoleBinding when ALL is listed + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - ALL + asserts: + - hasDocuments: + count: 2 + template: hyperdx/dashboard-provisioner-rbac.yaml + - isKind: + of: ClusterRole + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + - isKind: + of: ClusterRoleBinding + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: rules[0].verbs + value: ["list", "get", "watch"] + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + # Pin the cluster-wide grant to ConfigMaps only — a future rule for secrets + # (or another resource) would silently widen the shared SA's access otherwise. + - equal: + path: rules[0].resources + value: ["configmaps"] + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + - lengthEqual: + path: rules + count: 1 + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should bind the ClusterRoleBinding to the SA when ALL is listed + release: + namespace: observability + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - ALL + asserts: + - equal: + path: subjects[0].name + value: RELEASE-NAME-clickstack-app + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: subjects[0].namespace + value: observability + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: roleRef.kind + value: ClusterRole + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: roleRef.name + value: RELEASE-NAME-clickstack-observability-dashboard-provisioner + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should bind the per-namespace RoleBindings to the user SA when configured + release: + namespace: observability + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - team-a + - team-b + serviceAccount: + name: my-custom-sa + asserts: + - equal: + path: subjects[0].name + value: my-custom-sa + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + - equal: + path: subjects[0].name + value: my-custom-sa + documentIndex: 3 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should bind the ClusterRoleBinding to the user SA when ALL is listed + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - ALL + serviceAccount: + name: my-custom-sa + asserts: + - equal: + path: subjects[0].name + value: my-custom-sa + documentIndex: 1 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should fail when ALL is combined with specific namespaces + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - ALL + - team-a + asserts: + - failedTemplate: + errorMessage: 'hyperdx.dashboards.namespaces: "ALL" cannot be combined with specific namespaces (it already grants cluster-wide discovery)' + template: hyperdx/deployment.yaml + + - it: should fail when a namespace entry is empty or blank + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - "" + - team-a + asserts: + - failedTemplate: + errorMessage: "hyperdx.dashboards.namespaces: entries must be non-empty namespace names" + template: hyperdx/deployment.yaml + + - it: should fail when a namespace entry is whitespace-only + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - " " + - team-a + asserts: + - failedTemplate: + errorMessage: "hyperdx.dashboards.namespaces: entries must be non-empty namespace names" + template: hyperdx/deployment.yaml + + - it: should fail on a case or whitespace variant of the ALL sentinel + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - all + asserts: + - failedTemplate: + errorMessage: 'hyperdx.dashboards.namespaces: use exactly "ALL" (uppercase, no surrounding spaces) for cluster-wide discovery' + template: hyperdx/deployment.yaml + + - it: should fail when a namespace entry is not a valid DNS-1123 label + set: + hyperdx: + dashboards: + enabled: true + namespaces: + - Team_A + asserts: + - failedTemplate: + errorMessage: "hyperdx.dashboards.namespaces: entries must be valid DNS-1123 labels (lowercase alphanumeric and '-')" + template: hyperdx/deployment.yaml + + - it: should not validate namespaces when dashboards are disabled + set: + hyperdx: + dashboards: + enabled: false + namespaces: + - ALL + - team-a + asserts: + - lengthEqual: + path: spec.template.spec.containers + count: 1 + template: hyperdx/deployment.yaml + - hasDocuments: + count: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should keep the namespace qualifier in cluster-scoped names when fullname is long + release: + namespace: observability + set: + fullnameOverride: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + hyperdx: + dashboards: + enabled: true + namespaces: + - ALL + asserts: + - matchRegex: + path: metadata.name + pattern: -observability-dashboard-provisioner$ + documentIndex: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should not render any RBAC when rbac.create is false + set: + hyperdx: + dashboards: + enabled: true + rbac: + create: false + asserts: + - hasDocuments: + count: 0 + template: hyperdx/dashboard-provisioner-rbac.yaml + + - it: should still render the watcher sidecar when rbac.create is false + set: + hyperdx: + dashboards: + enabled: true + rbac: + create: false + asserts: + - lengthEqual: + path: spec.template.spec.containers + count: 2 + template: hyperdx/deployment.yaml + + - it: should not render inline ConfigMap when configMaps is empty + set: + hyperdx: + dashboards: + enabled: true + configMaps: {} + asserts: + - hasDocuments: + count: 0 + template: hyperdx/dashboard-configmap.yaml + + - it: should render inline ConfigMap with discovery label + set: + hyperdx: + dashboards: + enabled: true + configMaps: + test.json: | + { "name": "Test", "tiles": [] } + asserts: + - equal: + path: metadata.labels["hyperdx.io/dashboard"] + value: "true" + template: hyperdx/dashboard-configmap.yaml + - matchRegex: + path: data["test.json"] + pattern: '^\{ "name": "Test", "tiles": \[\] \}' + template: hyperdx/dashboard-configmap.yaml + + - it: should include multiple dashboard files in inline ConfigMap + set: + hyperdx: + dashboards: + enabled: true + configMaps: + k8s-overview.json: | + { "name": "Kubernetes Overview", "tiles": [] } + app-metrics.json: | + { "name": "App Metrics", "tiles": [] } + asserts: + - isNotNull: + path: data["k8s-overview.json"] + template: hyperdx/dashboard-configmap.yaml + - isNotNull: + path: data["app-metrics.json"] + template: hyperdx/dashboard-configmap.yaml diff --git a/charts/clickstack/values.yaml b/charts/clickstack/values.yaml index edd375c2..58592399 100644 --- a/charts/clickstack/values.yaml +++ b/charts/clickstack/values.yaml @@ -15,6 +15,56 @@ hyperdx: app: 3000 opamp: 4320 + # ── Dashboard provisioning ──────────────────────────────── + # Discovers and upserts dashboard JSON into MongoDB (matched by name, never deletes) + # + # Two ways to provide dashboards: + # 1. Inline: set configMaps below with dashboard JSON + # 2. External: ConfigMaps labeled "hyperdx.io/dashboard: true" are discovered by a + # k8s-sidecar watcher (ideal for application charts managing their own dashboards) + dashboards: + enabled: false + + # ── Discovery scope ────────────────────────────────────── + # Namespaces the watcher discovers labeled ConfigMaps in: + # [] only the release namespace (a namespaced Role; no cluster-wide access) + # [ns-a, ns-b] those namespaces plus the release namespace. A cluster-scoped ClusterRole + # rule-template is created, but access stays namespace-scoped — it is bound + # only via a RoleBinding in each namespace, never a ClusterRoleBinding. + # [ALL] every namespace cluster-wide (creates a ClusterRole + ClusterRoleBinding) + # "ALL" is case-sensitive and is exclusive — it cannot be combined with named namespaces. + # Each listed namespace must already exist at install time: a RoleBinding into a missing + # namespace is rejected by the API server and fails the whole (atomic) helm install/upgrade. + # TRUST: within any watched namespace the watcher can read ALL ConfigMaps (the label only + # filters which are provisioned, not which are readable), and it shares the HyperDX pod's + # ServiceAccount, so that read grant (cluster-wide under [ALL]) also accrues to the app + # container. Discovery is also a write path — anyone able to create a labeled ConfigMap in a + # watched namespace can upsert dashboards into the shared HyperDX. Prefer [ALL] only on + # single-tenant clusters; a wider scope is a wider blast radius. + namespaces: [] + rbac: + # Create the Role/ClusterRole and binding(s) the watcher needs. Set to false to + # grant access out-of-band (e.g. a RoleBinding applied by each namespace owner). + create: true + + # Image for the k8s-sidecar that watches for labeled ConfigMaps + sidecarImage: "kiwigrid/k8s-sidecar:2.5.0" + # Inline dashboard definitions - key is filename, value is exported dashboard JSON + configMaps: {} + # Example: + # configMaps: + # k8s-overview.json: | + # { "name": "Kubernetes Overview", "tiles": [...] } + # Sidecar is a lightweight ConfigMap watcher; bump (esp. memory) for very large + # dashboard sets or wide `namespaces: [ALL]` discovery to avoid OOMKill. + resources: + limits: + cpu: 50m + memory: 64Mi + requests: + cpu: 10m + memory: 32Mi + # ── K8s ConfigMap (clickstack-config) ──────────────────── # Shared non-sensitive environment variables. Used by HyperDX and OTEL collector via envFrom. # All values support Helm template expressions (rendered via tpl).