From e13f42667e1bd1d31107af469f4897ee67fe029f Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Thu, 30 Jul 2026 17:36:25 +0100 Subject: [PATCH] feat(kubernetes): add OpenShift-safe Helm profile --- .github/workflows/helm-ci.yml | 30 +++++++ deploy/helm/pulse/Chart.yaml | 2 +- deploy/helm/pulse/README.md | 7 +- deploy/helm/pulse/templates/_helpers.tpl | 10 ++- deploy/helm/pulse/templates/agent-rbac.yaml | 74 ++++++++++++++++ deploy/helm/pulse/templates/agent-secret.yaml | 3 +- .../pulse/templates/agent-serviceaccount.yaml | 3 +- deploy/helm/pulse/templates/agent.yaml | 38 ++++++-- deploy/helm/pulse/templates/deployment.yaml | 12 ++- deploy/helm/pulse/values.schema.json | 35 ++++++++ deploy/helm/pulse/values.yaml | 17 ++++ docs/KUBERNETES.md | 58 ++++++++++++- .../subsystems/deployment-installability.md | 22 +++++ .../installtests/build_release_assets_test.go | 86 +++++++++++++++++++ 14 files changed, 380 insertions(+), 17 deletions(-) create mode 100644 deploy/helm/pulse/templates/agent-rbac.yaml diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml index a98e46a6ec..aa3e1dece5 100644 --- a/.github/workflows/helm-ci.yml +++ b/.github/workflows/helm-ci.yml @@ -49,3 +49,33 @@ jobs: --set server.secretEnv.data.API_TOKENS=dummy-token \ --set persistence.enabled=false \ > /tmp/pulse-agent-rendered.yaml + + - name: Render and verify the OpenShift profile + run: | + helm template pulse deploy/helm/pulse \ + --namespace pulse \ + --set openShift.enabled=true \ + --set openShift.kubernetesAgent.enabled=true \ + --set agent.secretEnv.create=true \ + --set agent.secretEnv.data.PULSE_TOKEN=dummy-token \ + --set persistence.enabled=false \ + --show-only templates/deployment.yaml \ + --show-only templates/agent.yaml \ + --show-only templates/agent-serviceaccount.yaml \ + --show-only templates/agent-rbac.yaml \ + > /tmp/pulse-openshift-rendered.yaml + + grep -q -- "--enable-kubernetes" /tmp/pulse-openshift-rendered.yaml + grep -q "PULSE_AGENT_ID" /tmp/pulse-openshift-rendered.yaml + grep -q "kind: ClusterRole" /tmp/pulse-openshift-rendered.yaml + grep -q "kind: ClusterRoleBinding" /tmp/pulse-openshift-rendered.yaml + grep -q "runAsNonRoot: true" /tmp/pulse-openshift-rendered.yaml + + if grep -q "/var/run/docker.sock" /tmp/pulse-openshift-rendered.yaml; then + echo "OpenShift profile must not mount the Docker socket" >&2 + exit 1 + fi + if grep -Eq "runAs(User|Group):|fsGroup:" /tmp/pulse-openshift-rendered.yaml; then + echo "OpenShift profile must let the SCC assign UID/GID/fsGroup" >&2 + exit 1 + fi diff --git a/deploy/helm/pulse/Chart.yaml b/deploy/helm/pulse/Chart.yaml index 0a8f1d893f..d0c3775f0f 100644 --- a/deploy/helm/pulse/Chart.yaml +++ b/deploy/helm/pulse/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: pulse -description: Helm chart for deploying the Pulse hub and optional Docker monitoring agent. +description: Helm chart for deploying the Pulse hub and optional Docker, Kubernetes, or OpenShift monitoring agent. type: application version: 6.2.0-rc.4 appVersion: "6.2.0-rc.4" diff --git a/deploy/helm/pulse/README.md b/deploy/helm/pulse/README.md index 56f58f6e9a..9cc0e4a029 100644 --- a/deploy/helm/pulse/README.md +++ b/deploy/helm/pulse/README.md @@ -2,7 +2,7 @@ ![Version: 6.2.0-rc.4](https://img.shields.io/badge/Version-6.2.0--rc.4-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 6.2.0-rc.4](https://img.shields.io/badge/AppVersion-6.2.0--rc.4-informational?style=flat-square) -Helm chart for deploying the Pulse hub and optional Docker monitoring agent. +Helm chart for deploying the Pulse hub and optional Docker, Kubernetes, or OpenShift monitoring agent. **Homepage:** @@ -50,6 +50,8 @@ Helm chart for deploying the Pulse hub and optional Docker monitoring agent. | agent.podAnnotations | object | `{}` | | | agent.podLabels | object | `{}` | | | agent.podSecurityContext | object | `{}` | | +| agent.rbac.create | bool | `false` | Create the read-only Kubernetes collector ClusterRole and binding. | +| agent.rbac.name | string | `""` | Override the Kubernetes collector ClusterRole name. | | agent.readinessProbe.enabled | bool | `true` | | | agent.readinessProbe.failureThreshold | int | `3` | | | agent.readinessProbe.initialDelaySeconds | int | `5` | | @@ -93,6 +95,9 @@ Helm chart for deploying the Pulse hub and optional Docker monitoring agent. | monitoring.serviceMonitor.relabelings | list | `[]` | | | monitoring.serviceMonitor.scrapeTimeout | string | `"10s"` | | | nameOverride | string | `""` | | +| openShift.enabled | bool | `false` | Use SCC-compatible server and agent security defaults. | +| openShift.kubernetesAgent.clusterID | string | `""` | Stable logical agent ID; defaults to the release fullname plus `-openshift`. | +| openShift.kubernetesAgent.enabled | bool | `false` | Deploy the socket-free OpenShift Kubernetes collector. | | persistence.accessModes[0] | string | `"ReadWriteOnce"` | | | persistence.annotations | object | `{}` | | | persistence.enabled | bool | `true` | | diff --git a/deploy/helm/pulse/templates/_helpers.tpl b/deploy/helm/pulse/templates/_helpers.tpl index 24e4b31dc9..a5bb590927 100644 --- a/deploy/helm/pulse/templates/_helpers.tpl +++ b/deploy/helm/pulse/templates/_helpers.tpl @@ -87,7 +87,8 @@ Return the agent secret name. Return the agent service account name. */}} {{- define "pulse.agentServiceAccountName" -}} -{{- if .Values.agent.serviceAccount.create -}} +{{- $openShiftAgent := and .Values.openShift.enabled .Values.openShift.kubernetesAgent.enabled -}} +{{- if or .Values.agent.serviceAccount.create $openShiftAgent -}} {{- default (printf "%s-agent" (include "pulse.fullname" .)) .Values.agent.serviceAccount.name -}} {{- else if .Values.agent.serviceAccount.name -}} {{- .Values.agent.serviceAccount.name -}} @@ -95,3 +96,10 @@ Return the agent service account name. {{- include "pulse.serviceAccountName" . -}} {{- end -}} {{- end -}} + +{{/* +Return the read-only agent ClusterRole/ClusterRoleBinding name. +*/}} +{{- define "pulse.agentRBACName" -}} +{{- default (printf "%s-agent-read" (include "pulse.fullname" .)) .Values.agent.rbac.name -}} +{{- end -}} diff --git a/deploy/helm/pulse/templates/agent-rbac.yaml b/deploy/helm/pulse/templates/agent-rbac.yaml new file mode 100644 index 0000000000..6c172f6536 --- /dev/null +++ b/deploy/helm/pulse/templates/agent-rbac.yaml @@ -0,0 +1,74 @@ +{{- $openShiftAgent := and .Values.openShift.enabled .Values.openShift.kubernetesAgent.enabled }} +{{- if and (or .Values.agent.enabled $openShiftAgent) (or .Values.agent.rbac.create $openShiftAgent) }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "pulse.agentRBACName" . }} + labels: + {{- include "pulse.labels" . | nindent 4 }} + app.kubernetes.io/component: agent +rules: + - apiGroups: [""] + resources: + - configmaps + - events + - limitranges + - namespaces + - nodes + - persistentvolumeclaims + - persistentvolumes + - pods + - resourcequotas + - serviceaccounts + - services + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["daemonsets", "deployments", "replicasets", "statefulsets"] + verbs: ["get", "list", "watch"] + - apiGroups: ["batch"] + resources: ["cronjobs", "jobs"] + verbs: ["get", "list", "watch"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses", "networkpolicies"] + verbs: ["get", "list", "watch"] + - apiGroups: ["discovery.k8s.io"] + resources: ["endpointslices"] + verbs: ["get", "list", "watch"] + - apiGroups: ["storage.k8s.io"] + resources: ["storageclasses"] + verbs: ["get", "list", "watch"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterrolebindings", "clusterroles", "rolebindings", "roles"] + verbs: ["get", "list", "watch"] + - apiGroups: ["policy"] + resources: ["poddisruptionbudgets"] + verbs: ["get", "list", "watch"] + - apiGroups: ["autoscaling"] + resources: ["horizontalpodautoscalers"] + verbs: ["get", "list", "watch"] + - apiGroups: ["metrics.k8s.io"] + resources: ["nodes", "pods"] + verbs: ["get", "list"] + - apiGroups: ["snapshot.storage.k8s.io"] + resources: ["volumesnapshots"] + verbs: ["get", "list", "watch"] + - apiGroups: ["velero.io"] + resources: ["backups"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "pulse.agentRBACName" . }} + labels: + {{- include "pulse.labels" . | nindent 4 }} + app.kubernetes.io/component: agent +subjects: + - kind: ServiceAccount + name: {{ include "pulse.agentServiceAccountName" . }} + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "pulse.agentRBACName" . }} +{{- end }} diff --git a/deploy/helm/pulse/templates/agent-secret.yaml b/deploy/helm/pulse/templates/agent-secret.yaml index fdadfc3638..9e4221cf40 100644 --- a/deploy/helm/pulse/templates/agent-secret.yaml +++ b/deploy/helm/pulse/templates/agent-secret.yaml @@ -1,5 +1,6 @@ {{- $secret := .Values.agent.secretEnv }} -{{- if and .Values.agent.enabled $secret.create (gt (len $secret.data) 0) }} +{{- $openShiftAgent := and .Values.openShift.enabled .Values.openShift.kubernetesAgent.enabled }} +{{- if and (or .Values.agent.enabled $openShiftAgent) $secret.create (gt (len $secret.data) 0) }} apiVersion: v1 kind: Secret metadata: diff --git a/deploy/helm/pulse/templates/agent-serviceaccount.yaml b/deploy/helm/pulse/templates/agent-serviceaccount.yaml index 253378a9e8..c56a55ae1e 100644 --- a/deploy/helm/pulse/templates/agent-serviceaccount.yaml +++ b/deploy/helm/pulse/templates/agent-serviceaccount.yaml @@ -1,4 +1,5 @@ -{{- if and .Values.agent.enabled .Values.agent.serviceAccount.create }} +{{- $openShiftAgent := and .Values.openShift.enabled .Values.openShift.kubernetesAgent.enabled }} +{{- if and (or .Values.agent.enabled $openShiftAgent) (or .Values.agent.serviceAccount.create $openShiftAgent) }} apiVersion: v1 kind: ServiceAccount metadata: diff --git a/deploy/helm/pulse/templates/agent.yaml b/deploy/helm/pulse/templates/agent.yaml index c8bcffb0fd..a164de9b53 100644 --- a/deploy/helm/pulse/templates/agent.yaml +++ b/deploy/helm/pulse/templates/agent.yaml @@ -1,6 +1,8 @@ -{{- if .Values.agent.enabled }} -{{- $kind := default "DaemonSet" .Values.agent.kind | lower }} +{{- $openShiftAgent := and .Values.openShift.enabled .Values.openShift.kubernetesAgent.enabled }} +{{- if or .Values.agent.enabled $openShiftAgent }} +{{- $kind := ternary "deployment" (default "DaemonSet" .Values.agent.kind | lower) $openShiftAgent }} {{- $isDaemon := eq $kind "daemonset" }} +{{- $dockerSocketEnabled := and .Values.agent.dockerSocket.enabled (not $openShiftAgent) }} {{- $workloadName := printf "%s-agent" (include "pulse.fullname" .) }} apiVersion: apps/v1 kind: {{ if $isDaemon }}DaemonSet{{ else }}Deployment{{ end }} @@ -11,7 +13,7 @@ metadata: app.kubernetes.io/component: agent spec: {{- if not $isDaemon }} - replicas: {{ .Values.agent.replicaCount }} + replicas: {{ ternary 1 .Values.agent.replicaCount $openShiftAgent }} {{- end }} selector: matchLabels: @@ -48,11 +50,25 @@ spec: command: {{- toYaml .Values.agent.command | nindent 12 }} {{- end }} - {{- if .Values.agent.args }} + {{- if $openShiftAgent }} + args: + - --enable-kubernetes + - --enable-host=false + {{- else if .Values.agent.args }} args: {{- toYaml .Values.agent.args | nindent 12 }} {{- end }} - {{- $csec := .Values.agent.securityContext }} + {{- $openShiftSecurityContext := dict + "allowPrivilegeEscalation" false + "capabilities" (dict "drop" (list "ALL")) + "privileged" false + "readOnlyRootFilesystem" true + "runAsNonRoot" true }} + {{- $openShiftSecurityContext = mergeOverwrite $openShiftSecurityContext .Values.agent.securityContext }} + {{- $csec := ternary + (omit $openShiftSecurityContext "runAsUser" "runAsGroup") + .Values.agent.securityContext + $openShiftAgent }} {{- if $csec }} securityContext: {{- toYaml $csec | nindent 12 }} @@ -64,6 +80,10 @@ spec: {{- range .Values.agent.extraEnv }} {{- $envList = append $envList . }} {{- end }} + {{- if $openShiftAgent }} + {{- $clusterID := default (printf "%s-openshift" (include "pulse.fullname" .)) .Values.openShift.kubernetesAgent.clusterID }} + {{- $envList = append $envList (dict "name" "PULSE_AGENT_ID" "value" $clusterID) }} + {{- end }} {{- $secret := .Values.agent.secretEnv }} {{- $secretKeys := list }} {{- if $secret.keys }} @@ -86,9 +106,9 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} {{- end }} - {{- if or .Values.agent.dockerSocket.enabled .Values.agent.extraVolumeMounts }} + {{- if or $dockerSocketEnabled .Values.agent.extraVolumeMounts }} volumeMounts: - {{- if .Values.agent.dockerSocket.enabled }} + {{- if $dockerSocketEnabled }} - name: docker-socket mountPath: {{ .Values.agent.dockerSocket.path }} {{- end }} @@ -120,9 +140,9 @@ spec: timeoutSeconds: {{ .Values.agent.readinessProbe.timeoutSeconds }} failureThreshold: {{ .Values.agent.readinessProbe.failureThreshold }} {{- end }} - {{- if or .Values.agent.dockerSocket.enabled .Values.agent.extraVolumes }} + {{- if or $dockerSocketEnabled .Values.agent.extraVolumes }} volumes: - {{- if .Values.agent.dockerSocket.enabled }} + {{- if $dockerSocketEnabled }} - name: docker-socket hostPath: path: {{ .Values.agent.dockerSocket.path }} diff --git a/deploy/helm/pulse/templates/deployment.yaml b/deploy/helm/pulse/templates/deployment.yaml index d1efe9afc5..06a18e1756 100644 --- a/deploy/helm/pulse/templates/deployment.yaml +++ b/deploy/helm/pulse/templates/deployment.yaml @@ -25,11 +25,12 @@ spec: {{- end }} spec: serviceAccountName: {{ include "pulse.serviceAccountName" . }} - {{- $podSecurityContext := + {{- $defaultPodSecurityContext := ternary (merge (dict) .Values.podSecurityContext .Values.server.podSecurityContext) .Values.server.podSecurityContext .Values.podSecurityContext.enabled }} + {{- $podSecurityContext := ternary .Values.server.podSecurityContext $defaultPodSecurityContext .Values.openShift.enabled }} {{- if $podSecurityContext }} securityContext: {{- omit $podSecurityContext "enabled" | toYaml | nindent 8 }} @@ -45,11 +46,18 @@ spec: ports: - name: http containerPort: 7655 - {{- $containerSecurityContext := + {{- $defaultContainerSecurityContext := ternary (merge (dict) .Values.containerSecurityContext .Values.server.securityContext) .Values.server.securityContext .Values.containerSecurityContext.enabled }} + {{- $openShiftContainerSecurityContext := dict + "allowPrivilegeEscalation" false + "capabilities" (dict "drop" (list "ALL")) + "runAsNonRoot" true }} + {{- $openShiftContainerSecurityContext = mergeOverwrite $openShiftContainerSecurityContext .Values.server.securityContext }} + {{- $openShiftContainerSecurityContext = omit $openShiftContainerSecurityContext "runAsUser" "runAsGroup" }} + {{- $containerSecurityContext := ternary $openShiftContainerSecurityContext $defaultContainerSecurityContext .Values.openShift.enabled }} {{- if $containerSecurityContext }} securityContext: {{- omit $containerSecurityContext "enabled" | toYaml | nindent 12 }} diff --git a/deploy/helm/pulse/values.schema.json b/deploy/helm/pulse/values.schema.json index f84b2eeb55..33b6e1cd94 100644 --- a/deploy/helm/pulse/values.schema.json +++ b/deploy/helm/pulse/values.schema.json @@ -38,6 +38,28 @@ }, "description": "Image pull secrets for private registries" }, + "openShift": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use SCC-compatible server and agent security defaults" + }, + "kubernetesAgent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Deploy the socket-free OpenShift Kubernetes collector" + }, + "clusterID": { + "type": "string", + "description": "Stable logical agent ID for the OpenShift cluster" + } + } + } + } + }, "service": { "type": "object", "properties": { @@ -160,6 +182,19 @@ "enum": ["DaemonSet", "Deployment"], "description": "Agent deployment type" }, + "rbac": { + "type": "object", + "properties": { + "create": { + "type": "boolean", + "description": "Create the read-only Kubernetes collector ClusterRole and binding" + }, + "name": { + "type": "string", + "description": "Override the Kubernetes collector ClusterRole name" + } + } + }, "healthPort": { "type": "integer", "minimum": 1, diff --git a/deploy/helm/pulse/values.yaml b/deploy/helm/pulse/values.yaml index 97692ff648..17d6a6507f 100644 --- a/deploy/helm/pulse/values.yaml +++ b/deploy/helm/pulse/values.yaml @@ -19,6 +19,18 @@ imagePullSecrets: [] nameOverride: "" fullnameOverride: "" +# OpenShift compatibility. When enabled, the server lets the namespace SCC +# assign UID/GID/fsGroup values instead of pinning the chart defaults. The +# optional Kubernetes agent is a single-replica, socket-free cluster reader. +openShift: + # -- Use SCC-compatible server and agent security defaults. + enabled: false + kubernetesAgent: + # -- Deploy the socket-free OpenShift Kubernetes collector. + enabled: false + # -- Stable logical agent ID; defaults to the release fullname plus `-openshift`. + clusterID: "" + serviceAccount: create: true annotations: {} @@ -112,6 +124,11 @@ agent: create: false name: "" annotations: {} + rbac: + # -- Create the read-only Kubernetes collector ClusterRole and binding. + create: false + # -- Override the Kubernetes collector ClusterRole name. + name: "" image: # Defaults to the main Pulse image; the runtime stage now ships an arch- # resolved /usr/local/bin/pulse-agent symlink so the same image can serve diff --git a/docs/KUBERNETES.md b/docs/KUBERNETES.md index 11cd7797bc..9455942397 100644 --- a/docs/KUBERNETES.md +++ b/docs/KUBERNETES.md @@ -61,6 +61,61 @@ By default, this workload runs in container-monitoring mode (`--enable-docker -- For Kubernetes monitoring, use a custom DaemonSet as shown below. +### OpenShift profile (Helm) + +The chart has an SCC-compatible OpenShift profile for the Pulse server and an +optional cluster-level Kubernetes collector: + +```bash +export PULSE_TOKEN='replace-with-a-kubernetes-report-token' + +kubectl create namespace pulse --dry-run=client -o yaml | kubectl apply -f - +kubectl -n pulse create secret generic pulse-server-env \ + --from-literal=API_TOKENS="${PULSE_TOKEN}" \ + --dry-run=client -o yaml | kubectl apply -f - +kubectl -n pulse create secret generic pulse-agent-env \ + --from-literal=PULSE_TOKEN="${PULSE_TOKEN}" \ + --dry-run=client -o yaml | kubectl apply -f - + +helm upgrade --install pulse pulse/pulse \ + --namespace pulse \ + --set openShift.enabled=true \ + --set openShift.kubernetesAgent.enabled=true \ + --set openShift.kubernetesAgent.clusterID=my-openshift-cluster \ + --set server.secretEnv.name=pulse-server-env \ + --set 'server.secretEnv.keys[0]=API_TOKENS' \ + --set agent.secretEnv.name=pulse-agent-env \ + --set 'agent.secretEnv.keys[0]=PULSE_TOKEN' +``` + +Using pre-created Secrets keeps the token out of Helm release values. For an +agent reporting to an existing external Pulse server, omit the server Secret +and override `agent.env[0].value` with that server's reachable `PULSE_URL`. + +The profile deliberately: + +- lets the OpenShift SCC assign the server and agent UID, GID, and filesystem + group instead of pinning `1000` or `0`; +- runs one non-privileged agent replica with `--enable-kubernetes` and + `--enable-host=false`; +- does not mount `/var/run/docker.sock` (OpenShift uses CRI-O); +- creates a dedicated service account and read-only ClusterRole/Binding for + the Kubernetes objects Pulse collects; and +- uses a stable cluster agent ID, configurable through + `openShift.kubernetesAgent.clusterID`. + +The default role intentionally does not grant Kubernetes `secrets` or +`nodes/proxy`. +Secret metadata inventory and direct kubelet-summary fallback therefore remain +unavailable under this least-privilege profile; OpenShift's metrics API supplies +the normal node and pod usage path. + +Standard Kubernetes resources—including nodes, pods, Deployments, StatefulSets, +DaemonSets, Jobs, Services, Ingresses, storage, policy, RBAC summaries, events, +and metrics—are collected. OpenShift-native Routes and DeploymentConfigs are +not yet modeled; workloads managed only by those APIs may appear as pods +without their OpenShift controller. + ### Unified Agent on Kubernetes (DaemonSet) To monitor Kubernetes resources, run the unified agent as a DaemonSet and enable the Kubernetes module. @@ -124,7 +179,8 @@ spec: - operator: Exists ``` -> **Note for ARM64 clusters**: Replace `pulse-agent-linux-amd64` with `pulse-agent-linux-arm64`. +> **Note for ARM64 clusters**: The `/usr/local/bin/pulse-agent` symlink in the +> main image resolves to the correct bundled binary for both amd64 and arm64. Use a token scoped for the agent: - `kubernetes:report` for Kubernetes reporting diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 0c5ecb4aca..4f683d9fc2 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2817,3 +2817,25 @@ remains the final schema, permission, token-file, URL, and TLS-policy validator. Updates recover the observer-file argument from the existing service command so an in-place binary refresh does not silently collapse a multi-destination installation back to primary-only reporting. + +### OpenShift Helm installation is SCC-owned and socket-free + +`openShift.enabled` is the chart-owned compatibility switch for installing the +Pulse server under OpenShift Security Context Constraints. It suppresses the +chart's fixed UID, GID, and fsGroup defaults while retaining non-root, +no-privilege-escalation container posture, so the namespace SCC assigns valid +runtime identities. + +When `openShift.kubernetesAgent.enabled` is also true, the chart deploys one +cluster-level Kubernetes collector as a `Deployment`, creates its service +account plus a read-only ClusterRole/Binding, assigns a stable cluster agent +ID, enables Kubernetes collection, disables host collection, and omits the +Docker socket. Normal chart behavior is unchanged when the OpenShift switch is +false. The default role does not grant Secrets or `nodes/proxy`; the metrics +API is the supported OpenShift node/pod usage path, and unsupported +OpenShift-native Routes and DeploymentConfigs remain outside this slice. + +`.github/workflows/helm-ci.yml` must render the profile and fail if the output +contains a Docker socket mount or fixed `runAsUser`, `runAsGroup`, or `fsGroup` +values. `scripts/installtests/build_release_assets_test.go` pins the packaged +values, templates, RBAC, render assertions, and operator documentation. diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index cfe26acd3e..9a13bece06 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -107,6 +107,92 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) { } } +func TestHelmChartShipsOpenShiftProfile(t *testing.T) { + read := func(parts ...string) string { + t.Helper() + content, err := os.ReadFile(repoFile(parts...)) + if err != nil { + t.Fatalf("read %s: %v", filepath.Join(parts...), err) + } + return string(content) + } + + values := read("deploy", "helm", "pulse", "values.yaml") + agent := read("deploy", "helm", "pulse", "templates", "agent.yaml") + deployment := read("deploy", "helm", "pulse", "templates", "deployment.yaml") + rbac := read("deploy", "helm", "pulse", "templates", "agent-rbac.yaml") + helmCI := read(".github", "workflows", "helm-ci.yml") + docs := read("docs", "KUBERNETES.md") + + for _, required := range []string{ + "openShift:", + "kubernetesAgent:", + "clusterID:", + "rbac:", + } { + if !strings.Contains(values, required) { + t.Fatalf("values.yaml missing OpenShift chart value %q", required) + } + } + for _, required := range []string{ + `$openShiftAgent := and .Values.openShift.enabled .Values.openShift.kubernetesAgent.enabled`, + `- --enable-kubernetes`, + `- --enable-host=false`, + `"name" "PULSE_AGENT_ID"`, + `$dockerSocketEnabled := and .Values.agent.dockerSocket.enabled (not $openShiftAgent)`, + `"runAsNonRoot" true`, + `omit $openShiftSecurityContext "runAsUser" "runAsGroup"`, + } { + if !strings.Contains(agent, required) { + t.Fatalf("agent template missing OpenShift contract %q", required) + } + } + for _, required := range []string{ + `.Values.openShift.enabled`, + `omit $openShiftContainerSecurityContext "runAsUser" "runAsGroup"`, + `"allowPrivilegeEscalation" false`, + } { + if !strings.Contains(deployment, required) { + t.Fatalf("server deployment missing OpenShift SCC contract %q", required) + } + } + for _, required := range []string{ + "kind: ClusterRole", + "kind: ClusterRoleBinding", + `apiGroups: ["metrics.k8s.io"]`, + `resources: ["nodes", "pods"]`, + `apiGroups: ["discovery.k8s.io"]`, + `resources: ["endpointslices"]`, + `apiGroups: ["rbac.authorization.k8s.io"]`, + } { + if !strings.Contains(rbac, required) { + t.Fatalf("agent RBAC template missing read-only collector rule %q", required) + } + } + if strings.Contains(rbac, "- secrets") || strings.Contains(rbac, "- nodes/proxy") { + t.Fatal("OpenShift default role must not grant Secrets or direct kubelet proxy access") + } + for _, required := range []string{ + "Render and verify the OpenShift profile", + "--show-only templates/agent-rbac.yaml", + `grep -Eq "runAs(User|Group):|fsGroup:"`, + `grep -q "/var/run/docker.sock"`, + } { + if !strings.Contains(helmCI, required) { + t.Fatalf("Helm CI missing OpenShift render assertion %q", required) + } + } + if !strings.Contains(docs, "--set openShift.enabled=true") || + !strings.Contains(docs, "--set openShift.kubernetesAgent.enabled=true") || + !strings.Contains(docs, "create secret generic pulse-server-env") || + !strings.Contains(docs, "create secret generic pulse-agent-env") { + t.Fatal("Kubernetes guide must document the shipped OpenShift profile") + } + if strings.Contains(docs, "--set-string agent.secretEnv.data.PULSE_TOKEN") { + t.Fatal("OpenShift guide must not persist the agent token in Helm release values") + } +} + func shieldsBadgeMessage(value string) string { return strings.ReplaceAll(value, "-", "--") }