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
5 changes: 5 additions & 0 deletions .changeset/dashboard-provisioning.md
Original file line number Diff line number Diff line change
@@ -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).
11 changes: 11 additions & 0 deletions charts/clickstack/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ Application Access:
directly to the internet without proper authentication and encryption.
{{- end }}

{{- if and (default (dict) .Values.hyperdx.dashboards).enabled (ne (include "clickstack.hyperdx.dashboardsRbacCreate" .) "true") }}

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)
before installing: without it the watcher fails its startup sync and
crash-loops, leaving the whole pod NotReady. If access is revoked after
startup instead, the watcher stays Running but logs 403 errors and stops
picking up dashboard changes.
{{- end }}

To verify the deployment status, run:
kubectl get pods -l "app.kubernetes.io/name={{ include "clickstack.name" . }}"

Expand Down
126 changes: 126 additions & 0 deletions charts/clickstack/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,132 @@ 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 -}}

{{/*
Fail fast on invalid dashboards input instead of surfacing Go template panics or
API-server rejections: unusable ServiceAccount config, a non-list or ill-typed
namespaces value, the contradictory ALL-plus-named-namespaces scope, malformed
configMaps entries, and volume/mount names that collide with the provisioner's.
*/}}
{{- define "clickstack.hyperdx.validateDashboards" -}}
{{- $dashboards := default (dict) .Values.hyperdx.dashboards -}}
{{- if $dashboards.enabled -}}
{{- if and (not .Values.hyperdx.serviceAccount.create) (not .Values.hyperdx.serviceAccount.name) -}}
{{- fail "hyperdx.dashboards: the dashboard watcher needs a ServiceAccount to bind RBAC to; set hyperdx.serviceAccount.create=true or provide hyperdx.serviceAccount.name" -}}
{{- end -}}
{{- $namespaces := default (list) $dashboards.namespaces -}}
{{- if not (kindIs "slice" $namespaces) -}}
{{- fail "hyperdx.dashboards.namespaces: must be a list of namespace names" -}}
{{- end -}}
{{- range $namespaces -}}
{{- if not (kindIs "string" .) -}}
{{- fail "hyperdx.dashboards.namespaces: entries must be quoted strings" -}}
{{- end -}}
{{- end -}}
{{- 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 -}}
{{- range $key, $value := (default (dict) $dashboards.configMaps) -}}
{{- if not (kindIs "string" $value) -}}
{{- fail (printf "hyperdx.dashboards.configMaps[%s]: value must be a JSON string — use a YAML block scalar (|)" $key) -}}
{{- end -}}
{{- if not (regexMatch "^[A-Za-z0-9][A-Za-z0-9._-]*\\.json$" $key) -}}
{{- fail (printf "hyperdx.dashboards.configMaps: key %q must be a valid ConfigMap key ending in .json (the provisioner only reads *.json files)" $key) -}}
{{- end -}}
{{- end -}}
{{- range (default (list) .Values.hyperdx.deployment.volumes) -}}
{{- if eq (get . "name") "dashboards" -}}
{{- fail "hyperdx.deployment.volumes: the name \"dashboards\" is reserved by the dashboard provisioner" -}}
{{- end -}}
{{- end -}}
{{- range (default (list) .Values.hyperdx.deployment.volumeMounts) -}}
{{- if or (eq (get . "name") "dashboards") (eq (get . "mountPath") "/dashboards") -}}
{{- fail "hyperdx.deployment.volumeMounts: the name \"dashboards\" and mountPath \"/dashboards\" are reserved by the dashboard provisioner" -}}
{{- 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" -}}
{{- $raw := default (list) (default (dict) .Values.hyperdx.dashboards).namespaces -}}
{{- $namespaces := ternary $raw (list) (kindIs "slice" $raw) -}}
{{- $trimmed := list -}}
{{- range (concat (list .Release.Namespace) $namespaces) -}}
{{- $trimmed = append $trimmed (trim (toString .)) -}}
{{- 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" -}}
{{- $namespaces := default (list) (default (dict) .Values.hyperdx.dashboards).namespaces -}}
{{- if and (kindIs "slice" $namespaces) (has "ALL" $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 the chart creates the watcher's RBAC. Missing rbac subtree (e.g. an upgrade
with --reuse-values that only sets dashboards.enabled) keeps the chart default of
true rather than silently deploying a watcher with no read access. Renders "true" or "".
*/}}
{{- define "clickstack.hyperdx.dashboardsRbacCreate" -}}
{{- $rbac := default (dict) (default (dict) .Values.hyperdx.dashboards).rbac -}}
{{- if or (not (hasKey $rbac "create")) $rbac.create }}true{{- end -}}
{{- end -}}

{{/*
Create chart name and version as used by the chart label.
*/}}
Expand Down
16 changes: 16 additions & 0 deletions charts/clickstack/templates/hyperdx/dashboard-configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{{- $dashboards := default (dict) .Values.hyperdx.dashboards -}}
{{- if and $dashboards.enabled $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:
{{- $dashboards.configMaps | toYaml | nindent 2 }}
{{- end }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{{- if and (default (dict) .Values.hyperdx.dashboards).enabled (eq (include "clickstack.hyperdx.dashboardsRbacCreate" .) "true") }}
{{- $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 }}
69 changes: 66 additions & 3 deletions charts/clickstack/templates/hyperdx/deployment.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{{- $podAnnotations := mergeOverwrite (dict) (default (dict) .Values.hyperdx.deployment.annotations) (default (dict) .Values.hyperdx.deployment.podAnnotations) -}}
{{- $dashboards := default (dict) .Values.hyperdx.dashboards -}}
{{- include "clickstack.hyperdx.validateDashboards" . -}}
apiVersion: apps/v1
kind: Deployment
metadata:
Expand Down Expand Up @@ -51,7 +53,7 @@ spec:
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" .) }}
serviceAccountName: {{ include "clickstack.hyperdx.serviceAccountName" . }}
{{- end }}
{{- with .Values.hyperdx.deployment.podSecurityContext }}
securityContext:
Expand All @@ -77,9 +79,15 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- with .Values.hyperdx.deployment.volumes }}
{{- if or .Values.hyperdx.deployment.volumes $dashboards.enabled }}
volumes:
{{- with .Values.hyperdx.deployment.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if $dashboards.enabled }}
- name: dashboards
emptyDir: {}
{{- end }}
{{- end }}
containers:
- name: app
Expand Down Expand Up @@ -120,9 +128,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 $dashboards.enabled }}
volumeMounts:
{{- with .Values.hyperdx.deployment.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if $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:
Expand Down Expand Up @@ -155,6 +172,52 @@ spec:
value: {{ tpl .Values.hyperdx.deployment.defaultSources . | quote }}
{{- end }}
{{- end }}
{{- if $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 $dashboards.enabled }}
- name: dashboard-watcher

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add a liveness probe to the watcher?

@ZeynelKoca ZeynelKoca Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to dig quite deep to remember why I didn't add any health probes in the first place but there was (and is) a reason for it: the k8s-sidecar image already self-terminates on every failure a probe could catch. A failed startup sync exits the process, and its internal supervisor exits the container if a watcher dies. Together with the restartPolicy: Always, we already provide the restart behavior a probe would add.

A health probe would mainly add a false-positive risk instead: /healthz returns 503 until the initial sync completes, and with cluster-wide discovery on a large cluster that sync can be slow. A liveness probe could kill a healthy watcher mid-sync, and each restart briefly flips the whole pod NotReady, dropping the HyperDX app from Service endpoints over an auxiliary container.

I could add a liveness probe with passthrough values if you'd like though (although I'd advise against it to avoid people shooting themselves in the foot). I'd at least argue to make it empty by default but this would at least allow operators the flexibility to opt-in.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me. Thanks for the explanation.

image: {{ $dashboards.sidecarImage | quote }}
imagePullPolicy: {{ $dashboards.sidecarPullPolicy | default "IfNotPresent" }}
{{- with .Values.hyperdx.deployment.containerSecurityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $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 }}
2 changes: 1 addition & 1 deletion charts/clickstack/templates/hyperdx/serviceaccount.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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 }}
Expand Down
Loading
Loading