From 1ec7ada28d61ef1d82008e82bfef382a03839093 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Thu, 8 Oct 2026 23:38:00 +0200 Subject: [PATCH 1/2] feat(helm): add podLabels for the chart's pods A multi-tenant telemetry collector commonly takes a pod's tenant from a pod label, the one signal a headerless OTLP export carries. The chart sets only its own `app` label on every pod and offers no way to add one. `podLabels` is added to every long-running pod template, next to `app`, which it cannot replace: `app` is the selector of every workload, so the render fails when podLabels sets it. The rustfs-bucket-init Job is left out, because a Job's pod template is immutable and a label change would fail the upgrade. The substrate-podcert chart takes its own `podLabels` for the podcertificate-controller pod, under the same rule. The default render is unchanged, so manifests/ate-install/ stays as is. Signed-off-by: QuentinBisson --- charts/substrate-podcert/README.md | 3 +++ charts/substrate-podcert/templates/_helpers.tpl | 14 ++++++++++++++ .../templates/pod-certificate-controller.yaml | 1 + charts/substrate-podcert/values.yaml | 4 ++++ charts/substrate/README.md | 1 + charts/substrate/templates/_helpers.tpl | 14 ++++++++++++++ charts/substrate/templates/ate-api-server.yaml | 1 + charts/substrate/templates/ate-controller.yaml | 1 + charts/substrate/templates/atelet.yaml | 1 + charts/substrate/templates/atenet-egress.yaml | 1 + charts/substrate/templates/atenet-router.yaml | 1 + .../templates/k8s-credential-provider.yaml | 1 + charts/substrate/templates/rustfs.yaml | 1 + charts/substrate/values.yaml | 7 +++++++ 14 files changed, 51 insertions(+) diff --git a/charts/substrate-podcert/README.md b/charts/substrate-podcert/README.md index 368142e2f5..0089f39dfd 100644 --- a/charts/substrate-podcert/README.md +++ b/charts/substrate-podcert/README.md @@ -37,6 +37,9 @@ Configure the controller image through `image.registry`, `image.repository`, and `image.tag`. `global.imageRegistry` overrides the registry, and `imagePullSecrets` and `global.imagePullSecrets` are merged for the controller pod. +`podLabels` adds labels to the controller pod, next to its `app` label, which +they cannot replace. + For an existing installation, the controller resources must be transferred out of the application release before the new release can own them. Installing this chart over resources still owned by the application release fails Helm ownership diff --git a/charts/substrate-podcert/templates/_helpers.tpl b/charts/substrate-podcert/templates/_helpers.tpl index 5ba9d45f72..eb1ad1d9a4 100644 --- a/charts/substrate-podcert/templates/_helpers.tpl +++ b/charts/substrate-podcert/templates/_helpers.tpl @@ -46,3 +46,17 @@ imagePullSecrets: {{- toYaml $merged | nindent 0 }} {{- end -}} {{- end -}} + +{{/* +The podLabels entries for the controller's pod template, as YAML map entries. +Renders nothing when podLabels is empty. `app` is the Deployment's selector +label, so a podLabels entry cannot replace it. +*/}} +{{- define "podcert.podLabels" -}} +{{- with .Values.podLabels -}} +{{- if hasKey . "app" -}} +{{- fail "podLabels must not set app: it is the selector label of the controller Deployment" -}} +{{- end -}} +{{- toYaml . -}} +{{- end -}} +{{- end -}} diff --git a/charts/substrate-podcert/templates/pod-certificate-controller.yaml b/charts/substrate-podcert/templates/pod-certificate-controller.yaml index 80284bb991..1944ef62dc 100644 --- a/charts/substrate-podcert/templates/pod-certificate-controller.yaml +++ b/charts/substrate-podcert/templates/pod-certificate-controller.yaml @@ -149,6 +149,7 @@ spec: metadata: labels: app: podcertificate-controller + {{- with include "podcert.podLabels" . }}{{- . | nindent 8 }}{{- end }} spec: {{- with include "podcert.imagePullSecrets" . }}{{- . | nindent 6 }}{{- end }} containers: diff --git a/charts/substrate-podcert/values.yaml b/charts/substrate-podcert/values.yaml index 30674c848f..e6658b9a56 100644 --- a/charts/substrate-podcert/values.yaml +++ b/charts/substrate-podcert/values.yaml @@ -38,6 +38,10 @@ global: imageRegistry: "" imagePullSecrets: [] +# Labels added to the controller pod, next to its own `app` label, which they +# cannot replace. +podLabels: {} + imagePullSecrets: [] image: registry: ghcr.io diff --git a/charts/substrate/README.md b/charts/substrate/README.md index f80beb3809..1f71363a15 100644 --- a/charts/substrate/README.md +++ b/charts/substrate/README.md @@ -73,6 +73,7 @@ See `values.yaml` for the full set; the important keys: | `ateApi.logLevel` | `info` | API server log level in its mounted config; one of `debug`, `info`, `warn`, or `error` | | `ateApi.authorization.bootstrapOwners` | `[]` | Global owners; empty defaults to the release's shared `ate-client` ServiceAccount | | `ateApi.extraArgs` | `[]` | Additional command-line arguments appended to the ateapi defaults | +| `podLabels` | `{}` | Labels added to every long-running pod, next to the chart's own `app` label | | `otel.endpoint` | `""` | Set to an OTLP endpoint to export traces, metrics, the actor lifecycle events and the router access log | | `otel.traces.enabled` | `true` | Set to `false` to export no traces from the router; the Go components do not honor this yet | | `otel.traces.endpoint` | `""` | OTLP endpoint for traces, overriding `otel.endpoint` | diff --git a/charts/substrate/templates/_helpers.tpl b/charts/substrate/templates/_helpers.tpl index d8a497a4ef..7becd6d158 100644 --- a/charts/substrate/templates/_helpers.tpl +++ b/charts/substrate/templates/_helpers.tpl @@ -231,6 +231,20 @@ imagePullSecrets: {{- end -}} {{- end -}} +{{/* +The podLabels entries for a pod template, as YAML map entries. Renders nothing +when podLabels is empty. The chart's own `app` label is the selector of every +workload, so a podLabels entry cannot replace it. +*/}} +{{- define "substrate.podLabels" -}} +{{- with .Values.podLabels -}} +{{- if hasKey . "app" -}} +{{- fail "podLabels must not set app: it is the selector label of every workload in the chart" -}} +{{- end -}} +{{- toYaml . -}} +{{- end -}} +{{- end -}} + {{/* imagePullPolicy: global.imagePullPolicy when set, IfNotPresent otherwise. One definition so the fallback cannot drift between pods. diff --git a/charts/substrate/templates/ate-api-server.yaml b/charts/substrate/templates/ate-api-server.yaml index 8089720d3e..d1f256982a 100644 --- a/charts/substrate/templates/ate-api-server.yaml +++ b/charts/substrate/templates/ate-api-server.yaml @@ -77,6 +77,7 @@ spec: metadata: labels: app: ate-api-server + {{- with include "substrate.podLabels" $ }}{{- . | nindent 8 }}{{- end }} annotations: checksum/api-config: {{ .Values.ateApi.logLevel | sha256sum }} prometheus.io/scrape: "true" diff --git a/charts/substrate/templates/ate-controller.yaml b/charts/substrate/templates/ate-controller.yaml index 7ac38e2409..d2309cc654 100644 --- a/charts/substrate/templates/ate-controller.yaml +++ b/charts/substrate/templates/ate-controller.yaml @@ -79,6 +79,7 @@ spec: metadata: labels: app: ate-controller + {{- with include "substrate.podLabels" $ }}{{- . | nindent 8 }}{{- end }} spec: serviceAccountName: {{ include "substrate.fullname" (list "ate-controller" .) }} {{- with include "substrate.imagePullSecrets" . }}{{- . | nindent 6 }}{{- end }} diff --git a/charts/substrate/templates/atelet.yaml b/charts/substrate/templates/atelet.yaml index 5abad9d1c5..8a96d9526a 100644 --- a/charts/substrate/templates/atelet.yaml +++ b/charts/substrate/templates/atelet.yaml @@ -126,6 +126,7 @@ spec: metadata: labels: app: atelet + {{- with include "substrate.podLabels" $ }}{{- . | nindent 8 }}{{- end }} annotations: prometheus.io/scrape: "true" prometheus.io/port: "9090" diff --git a/charts/substrate/templates/atenet-egress.yaml b/charts/substrate/templates/atenet-egress.yaml index 76cb93ad50..4557255264 100644 --- a/charts/substrate/templates/atenet-egress.yaml +++ b/charts/substrate/templates/atenet-egress.yaml @@ -132,6 +132,7 @@ spec: metadata: labels: app: atenet-egress + {{- with include "substrate.podLabels" $ }}{{- . | nindent 8 }}{{- end }} spec: serviceAccountName: {{ include "substrate.fullname" (list "atenet-egress" .) }} securityContext: diff --git a/charts/substrate/templates/atenet-router.yaml b/charts/substrate/templates/atenet-router.yaml index 761386f4b6..50ca5295e4 100644 --- a/charts/substrate/templates/atenet-router.yaml +++ b/charts/substrate/templates/atenet-router.yaml @@ -177,6 +177,7 @@ spec: metadata: labels: app: atenet-router + {{- with include "substrate.podLabels" $ }}{{- . | nindent 8 }}{{- end }} annotations: prometheus.io/scrape: "true" prometheus.io/port: "15020" diff --git a/charts/substrate/templates/k8s-credential-provider.yaml b/charts/substrate/templates/k8s-credential-provider.yaml index 718e60879c..8b5e93ec71 100644 --- a/charts/substrate/templates/k8s-credential-provider.yaml +++ b/charts/substrate/templates/k8s-credential-provider.yaml @@ -65,6 +65,7 @@ spec: checksum/namespace-policy: {{ toJson .Values.credentialProvider.namespacePolicies | sha256sum }} labels: app: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} + {{- with include "substrate.podLabels" $ }}{{- . | nindent 8 }}{{- end }} spec: serviceAccountName: {{ include "substrate.fullname" (list "k8s-credential-provider" .) }} {{- with include "substrate.imagePullSecrets" . }}{{- . | nindent 6 }}{{- end }} diff --git a/charts/substrate/templates/rustfs.yaml b/charts/substrate/templates/rustfs.yaml index ab04cb1a20..aed53e3e56 100644 --- a/charts/substrate/templates/rustfs.yaml +++ b/charts/substrate/templates/rustfs.yaml @@ -64,6 +64,7 @@ spec: metadata: labels: app: rustfs + {{- with include "substrate.podLabels" $ }}{{- . | nindent 8 }}{{- end }} spec: serviceAccountName: {{ include "substrate.fullname" (list "rustfs" .) }} securityContext: diff --git a/charts/substrate/values.yaml b/charts/substrate/values.yaml index 6cd8426931..c32bd2e362 100644 --- a/charts/substrate/values.yaml +++ b/charts/substrate/values.yaml @@ -219,6 +219,13 @@ global: # -- Fallback imagePullPolicy where a container does not set one. imagePullPolicy: "" +# Labels added to every long-running pod the chart renders, next to the chart's +# own `app` label, which they cannot replace. For example the label a +# multi-tenant telemetry collector routes a pod's OTLP export by. The +# rustfs-bucket-init Job does not get them: a Job's pod template is immutable, +# so a change would fail the upgrade. +podLabels: {} + # Pull secrets for every pod this chart renders. Merged with global.imagePullSecrets. imagePullSecrets: [] From 49218b8718f82ea542649bf36a38b2acd0b81e61 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Wed, 4 Nov 2026 19:46:54 +0100 Subject: [PATCH 2/2] test(helm): cover podLabels with helm unit tests --- .../tests/pod_labels_test.yaml | 53 +++++++++++++ .../tests/pod_labels_guard_test.yaml | 25 +++++++ charts/substrate/tests/pod_labels_test.yaml | 75 +++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 charts/substrate-podcert/tests/pod_labels_test.yaml create mode 100644 charts/substrate/tests/pod_labels_guard_test.yaml create mode 100644 charts/substrate/tests/pod_labels_test.yaml diff --git a/charts/substrate-podcert/tests/pod_labels_test.yaml b/charts/substrate-podcert/tests/pod_labels_test.yaml new file mode 100644 index 0000000000..3f85cab2ab --- /dev/null +++ b/charts/substrate-podcert/tests/pod_labels_test.yaml @@ -0,0 +1,53 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +suite: Pod labels +templates: +- pod-certificate-controller.yaml +tests: +- it: keeps only the app label on the controller pod by default + documentSelector: + path: kind + value: Deployment + asserts: + - equal: + path: spec.template.metadata.labels + value: + app: podcertificate-controller + +- it: adds podLabels to the controller pod next to the app label + set: + podLabels: + giantswarm.io/tenant: team-a + documentSelector: + path: kind + value: Deployment + asserts: + - equal: + path: spec.template.metadata.labels + value: + app: podcertificate-controller + giantswarm.io/tenant: team-a + - equal: + path: spec.selector.matchLabels + value: + app: podcertificate-controller + +- it: refuses a podLabels entry that replaces the app selector label + set: + podLabels: + app: other + asserts: + - failedTemplate: + errorMessage: "podLabels must not set app: it is the selector label of the controller Deployment" diff --git a/charts/substrate/tests/pod_labels_guard_test.yaml b/charts/substrate/tests/pod_labels_guard_test.yaml new file mode 100644 index 0000000000..f93bee7caa --- /dev/null +++ b/charts/substrate/tests/pod_labels_guard_test.yaml @@ -0,0 +1,25 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +suite: Pod labels selector guard +templates: +- ate-controller.yaml +tests: +- it: refuses a podLabels entry that replaces the app selector label + set: + podLabels: + app: other + asserts: + - failedTemplate: + errorMessage: "podLabels must not set app: it is the selector label of every workload in the chart" diff --git a/charts/substrate/tests/pod_labels_test.yaml b/charts/substrate/tests/pod_labels_test.yaml new file mode 100644 index 0000000000..f48d22edb0 --- /dev/null +++ b/charts/substrate/tests/pod_labels_test.yaml @@ -0,0 +1,75 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +suite: Pod labels +templates: +- ate-api-server.yaml +- ate-controller.yaml +- atelet.yaml +- atenet-egress.yaml +- atenet-router.yaml +- k8s-credential-provider.yaml +- rustfs.yaml +tests: +- it: adds podLabels to every Deployment pod next to the app label + set: + podLabels: + giantswarm.io/tenant: team-a + example.com/tier: control + documentSelector: + path: kind + value: Deployment + skipEmptyTemplates: true + matchMany: true + asserts: + - isSubset: + path: spec.template.metadata.labels + content: + giantswarm.io/tenant: team-a + example.com/tier: control + - exists: + path: spec.template.metadata.labels.app + - notExists: + path: spec.selector.matchLabels["giantswarm.io/tenant"] + +- it: adds podLabels to the atelet DaemonSet pod + template: atelet.yaml + set: + podLabels: + giantswarm.io/tenant: team-a + documentSelector: + path: kind + value: DaemonSet + asserts: + - equal: + path: spec.template.metadata.labels + value: + app: atelet + giantswarm.io/tenant: team-a + - equal: + path: spec.selector.matchLabels + value: + app: atelet + +- it: leaves the rustfs-bucket-init Job pod without podLabels + template: rustfs.yaml + set: + podLabels: + giantswarm.io/tenant: team-a + documentSelector: + path: kind + value: Job + asserts: + - notExists: + path: spec.template.metadata.labels["giantswarm.io/tenant"]