diff --git a/.github/workflows/helm-verify.yaml b/.github/workflows/helm-verify.yaml index 9c9a16d92d..fd47524717 100644 --- a/.github/workflows/helm-verify.yaml +++ b/.github/workflows/helm-verify.yaml @@ -36,4 +36,4 @@ jobs: - name: Install helm-unittest run: .github/scripts/helm-verify/install-helm-unittest.sh - name: Run the chart unit tests - run: helm unittest charts/substrate + run: helm unittest charts/substrate charts/substrate-podcert charts/substrate-atelet diff --git a/charts/substrate-atelet/README.md b/charts/substrate-atelet/README.md index 4f756e6b0b..284ce19720 100644 --- a/charts/substrate-atelet/README.md +++ b/charts/substrate-atelet/README.md @@ -41,8 +41,11 @@ Use the credentials from the substrate release's `rustfs.accessKey` and This chart reads the same value paths as the substrate chart for `atelet`, `objectStorePlugin.node`, `objectStorePlugin.extraEnv`, `preview`, `otel`, -`image`, `imagePullSecrets` and `global`, so the substrate release's values -file can configure both releases. +`image`, `imagePullSecrets`, `podLabels` and `global`, so the substrate release's +values file can configure both releases. + +`podLabels` defaults to `{}` and adds labels to the atelet pod template. +It cannot set `app`, which is reserved for the DaemonSet selector. Object storage is set under `objectStore` instead of `rustfs` and `atelet.storageBackend`: diff --git a/charts/substrate-atelet/templates/_helpers.tpl b/charts/substrate-atelet/templates/_helpers.tpl index f9905c8fda..0f9449abd6 100644 --- a/charts/substrate-atelet/templates/_helpers.tpl +++ b/charts/substrate-atelet/templates/_helpers.tpl @@ -30,6 +30,16 @@ substrate release references. {{- end -}} {{- end -}} +{{/* The app label is reserved for the atelet DaemonSet selector. */}} +{{- define "substrate.podLabels" -}} +{{- with .Values.podLabels -}} +{{- if hasKey . "app" -}} +{{- fail "podLabels must not set app: it is the selector label of the atelet DaemonSet" -}} +{{- end -}} +{{- toYaml . -}} +{{- end -}} +{{- end -}} + {{/* OTLP endpoint a signal exports to. Copied from the substrate chart. */}} {{- define "substrate.otel.signalEndpoint" -}} {{- $signal := index . 0 -}} diff --git a/charts/substrate-atelet/templates/atelet.yaml b/charts/substrate-atelet/templates/atelet.yaml index c8374bbf94..66c8e3a4ce 100644 --- a/charts/substrate-atelet/templates/atelet.yaml +++ b/charts/substrate-atelet/templates/atelet.yaml @@ -133,6 +133,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-atelet/tests/pod_labels_test.yaml b/charts/substrate-atelet/tests/pod_labels_test.yaml new file mode 100644 index 0000000000..28d2b3d21f --- /dev/null +++ b/charts/substrate-atelet/tests/pod_labels_test.yaml @@ -0,0 +1,59 @@ +# Copyright 2026 The Agent Substrate Authors +# +# 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: +- atelet.yaml +tests: +- it: keeps only the app label on the atelet pod by default + documentSelector: + path: kind + value: DaemonSet + asserts: + - equal: + path: spec.template.metadata.labels + value: + app: atelet + +- it: adds podLabels without changing the DaemonSet selector or labels + set: + podLabels: + example.com/tenant: team-a + example.com/tier: node + documentSelector: + path: kind + value: DaemonSet + asserts: + - equal: + path: spec.template.metadata.labels + value: + app: atelet + example.com/tenant: team-a + example.com/tier: node + - equal: + path: spec.selector.matchLabels + value: + app: atelet + - equal: + path: metadata.labels + value: + app: atelet + +- 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 atelet DaemonSet" diff --git a/charts/substrate-atelet/values.yaml b/charts/substrate-atelet/values.yaml index b68515745e..14f352da81 100644 --- a/charts/substrate-atelet/values.yaml +++ b/charts/substrate-atelet/values.yaml @@ -107,6 +107,9 @@ global: # Pull secrets for every pod this chart renders. Merged with global.imagePullSecrets. imagePullSecrets: [] +# Labels added to the atelet pod alongside its reserved app selector label. +podLabels: {} + image: # Registry host for the component images, and nothing else. A path inside # `registry` fails the render. 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 7ee405fbf9..d53e6b1c60 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 0ffe080e8d..0b1ade6dd4 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/tests/pod_labels_test.yaml b/charts/substrate-podcert/tests/pod_labels_test.yaml new file mode 100644 index 0000000000..c3312374b5 --- /dev/null +++ b/charts/substrate-podcert/tests/pod_labels_test.yaml @@ -0,0 +1,53 @@ +# Copyright 2026 The Agent Substrate Authors +# +# 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-podcert/values.yaml b/charts/substrate-podcert/values.yaml index d4203c8a04..8f05cae247 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 35d7bc96c8..ed039a3447 100644 --- a/charts/substrate/README.md +++ b/charts/substrate/README.md @@ -104,6 +104,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 35d435d350..d33d416a98 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 88119f98c5..dc1ea0263d 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 }} checksum/api-envvars: {{ include (print $.Template.BasePath "/ate-api-server-envvars.yaml") . | sha256sum }} diff --git a/charts/substrate/templates/ate-controller.yaml b/charts/substrate/templates/ate-controller.yaml index 3631740ca2..833aeb6556 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 e8c3ecad3b..6c55ffa5ed 100644 --- a/charts/substrate/templates/atelet.yaml +++ b/charts/substrate/templates/atelet.yaml @@ -134,6 +134,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 304b211184..0eb07a9265 100644 --- a/charts/substrate/templates/atenet-egress.yaml +++ b/charts/substrate/templates/atenet-egress.yaml @@ -116,6 +116,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 a6bb3da310..d132fd8cf5 100644 --- a/charts/substrate/templates/atenet-router.yaml +++ b/charts/substrate/templates/atenet-router.yaml @@ -181,6 +181,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 b2cd5290bb..5f236411ce 100644 --- a/charts/substrate/templates/k8s-credential-provider.yaml +++ b/charts/substrate/templates/k8s-credential-provider.yaml @@ -66,6 +66,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 95a5b10053..48e0ce7822 100644 --- a/charts/substrate/templates/rustfs.yaml +++ b/charts/substrate/templates/rustfs.yaml @@ -67,6 +67,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/tests/pod_labels_guard_test.yaml b/charts/substrate/tests/pod_labels_guard_test.yaml new file mode 100644 index 0000000000..2f8032f005 --- /dev/null +++ b/charts/substrate/tests/pod_labels_guard_test.yaml @@ -0,0 +1,25 @@ +# Copyright 2026 The Agent Substrate Authors +# +# 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..96f84af4aa --- /dev/null +++ b/charts/substrate/tests/pod_labels_test.yaml @@ -0,0 +1,76 @@ +# Copyright 2026 The Agent Substrate Authors +# +# 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-api-server-envvars.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"] diff --git a/charts/substrate/values.yaml b/charts/substrate/values.yaml index 5d0f7a4dce..3fa7d1e65c 100644 --- a/charts/substrate/values.yaml +++ b/charts/substrate/values.yaml @@ -243,6 +243,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: []