diff --git a/.ainav/config/index.md b/.ainav/config/index.md
index 1bf4aab4c..b55fc9518 100644
--- a/.ainav/config/index.md
+++ b/.ainav/config/index.md
@@ -38,6 +38,8 @@ Key config categories:
Capacity-planner operator constraints — shared `capacityPlannerConstraints.*` (`maxCoresPerContainer`, `driveSharing.computeTo{Tlc,Qlc}DriveCoreRatio`, `fullDrives.computeToDriveCoreRatio`) and clusterCapacity-only `clusterCapacity.*` (`tlcCapacityPerCoreGiB`, `qlcCapacityPerCoreGiB`) — are documented in `doc/operator/deployment/cluster-capacity.md` (Helm constraints table).
+`manager.extraVolumes`/`extraVolumeMounts` add volumes/mounts to the operator's own pod (reserved: `tmpdir` volume, `/tmp` mount, guarded via Helm `fail`).
+
## API Types (CRDs)
**Path**: `pkg/weka-k8s-api/api/v1alpha1/`
@@ -54,6 +56,10 @@ Capacity-planner operator constraints — shared `capacityPlannerConstraints.*`
| `metrics.go` | Metrics types |
| `condition/conditions.go` | Status conditions |
+WekaCluster (`spec.podConfig`), WekaClient (`spec`), and WekaContainer (`spec`, propagated not
+admission-validated) also carry `extraVolumes`/`extraVolumeMounts` for mounting extra volumes into
+pods (e.g. a CA bundle) — see `doc/operator/deployment/extra-volumes.md`.
+
Generated docs: `doc/api_dump/*.md`
## Validation & Admission
@@ -78,6 +84,11 @@ spec field wins when non-zero (0 is treated as unset), else the Helm-level defau
`PROTECTION_REDUNDANCY_LEVEL` / `PROTECTION_HOT_SPARE`, values `protection.*`) fills it. Same helper
is used in `FormCluster` so validation and formation agree.
+Extra-volumes validators (`extra_volumes.go` shared core + `cluster_extra_volumes.go` /
+`client_extra_volumes.go`) reject reserved names/paths and malformed `extraVolumes` JSON;
+`client_wekahome_cacert_unverifiable.go` warns when a Weka Home CA cert cannot be verified.
+For the focused validator map, see [validation.md](validation.md).
+
## Adding Configuration
1. Add field to `internal/config/env.go`
diff --git a/.ainav/config/validation.md b/.ainav/config/validation.md
new file mode 100644
index 000000000..378c77f41
--- /dev/null
+++ b/.ainav/config/validation.md
@@ -0,0 +1,24 @@
+# Validation & Admission
+
+**Path**: `internal/validation/` + `internal/admission/`
+
+Admission-webhook validators implement the `Validator` interface (`validator.go`), are
+listed per-CRD in `registry.go`, and get a default severity in `admission/defaults.go`.
+`doc.go` holds the sizing-mode glossary and the rule-ownership map — read it before adding a rule that
+touches drive counts, container counts, or core sizing, so one condition is not reported twice.
+Add a rule = implement + register + add to the defaults table. Reuse the shared helpers rather than
+re-deriving: `role_specs.go` (`rolesForTemplate` — the six per-role sizing fields, one table for every
+per-role validator), `template_cores.go` (`templateCoreSides` — drive/compute core totals plus the
+planner-managed exclusion), `drive_role_nodes.go` (`listDriveRoleNodes` / `driveRoleNodeInfos`).
+clusterCapacity validators:
+`cluster_capacity_chunk_feasibility.go` (greenfield per-FD TLC share ≥ 384 GiB; skipped once the
+cluster has TLC-bearing drive containers) and `cluster_capacity_protection.go` (min SW≥3, RL≥2, HS≥0 /
+hotSpare optional — the `3+2+0` floor from `allocator.MinProtectionFloor`).
+Protection values are resolved via `DriveSharingConfig.EffectiveProtection` (env.go): a per-cluster
+spec field wins when non-zero (0 is treated as unset), else the Helm-level default (`PROTECTION_STRIPE_WIDTH` /
+`PROTECTION_REDUNDANCY_LEVEL` / `PROTECTION_HOT_SPARE`, values `protection.*`) fills it. Same helper
+is used in `FormCluster` so validation and formation agree.
+
+Extra-volumes validators (`extra_volumes.go` shared core + `cluster_extra_volumes.go` /
+`client_extra_volumes.go`) reject reserved names/paths and malformed `extraVolumes` JSON;
+`client_wekahome_cacert_unverifiable.go` warns when a Weka Home CA cert cannot be verified.
diff --git a/.ainav/controllers/wekaclient.md b/.ainav/controllers/wekaclient.md
index 7646b13d0..2921af7bb 100644
--- a/.ainav/controllers/wekaclient.md
+++ b/.ainav/controllers/wekaclient.md
@@ -63,3 +63,19 @@ an `auto`/`dedicated`/`dedicated_ht` container therefore replaces the `CPUReques
capacity planner charges and the DRA claim is sized from — and if only one side of a pair is named
the other follows it, so QoS stays Guaranteed but the planner's node accounting drifts. That is
accepted: naming a resource here means taking responsibility for it.
+
+## Weka Home CA cert
+
+Clients phone Weka Home directly — they do not relay through a backend except as a fallback after
+a direct attempt fails. The cluster-wide `weka_cloud_ca_cert_path` override replicates to joining
+machines, but the certificate **file** does not, so a client of a private-CA cluster must carry it
+locally or its telemetry breaks.
+
+`domain.GetWekaHomeClientCacertSecret` (`internal/pkg/domain/wekahome.go`) resolves which Secret to
+mount: client's own `spec.wekaHome.cacertSecret`, then the target cluster's own value (same
+namespace only — a cross-namespace Secret name would not resolve, which raises a throttled
+`WekaHomeCacertSecretNotInherited` warning event), then the operator-wide env default. The resolved name is carried on `UpdatableClientSpec`
+so a change reaches already-running containers, not only ones created afterwards.
+
+Clients are `--restricted` and can never set the override themselves; they only need the file
+present. Full behaviour: `doc/operator/operations/weka-home-tls.md`.
diff --git a/.ainav/controllers/wekacluster.md b/.ainav/controllers/wekacluster.md
index 44f03b7d7..61f3753c5 100644
--- a/.ainav/controllers/wekacluster.md
+++ b/.ainav/controllers/wekacluster.md
@@ -78,3 +78,12 @@ Docs: `doc/operator/deployment/act-as-daemonset.md`, `cluster-capacity.md`.
- Manages cluster-wide secrets and credentials
- Coordinates NFS/S3 protocol configuration
- Exposes management via K8s services/ingress
+
+## Weka Home CA cert scope
+
+`configureWekaHome` (`steps_post_cluster.go`) sets `weka_cloud_ca_cert_path` from a drive container.
+That override is **cluster-wide**: it replicates to every joining machine, but the certificate file
+does not, and an explicit CA replaces the OS trust store rather than adding to it. Setting
+`spec.wekaHome.cacertSecret` therefore obliges every machine that joins — including clients this
+operator does not manage — to place a PEM at `/opt/weka/k8s-runtime/vars/wh-cacert/cert.pem`. See
+`doc/operator/operations/weka-home-tls.md`.
diff --git a/.ainav/index.md b/.ainav/index.md
index c00d96474..35d7764d5 100644
--- a/.ainav/index.md
+++ b/.ainav/index.md
@@ -9,6 +9,7 @@ Entry point for AI navigation. Max 3 hops to any information.
| Controllers | [controllers/index.md](controllers/index.md) | Reconciliation logic, lifecycle management |
| Operations | [operations/index.md](operations/index.md) | Manual ops, policies, CSI, drivers |
| Config | [config/index.md](config/index.md) | Helm values, env vars, API types |
+| Validation | [config/validation.md](config/validation.md) | Admission validators, severity defaults |
| Services | [services/index.md](services/index.md) | Weka API, K8s utils, node agent |
| Examples | [examples/index.md](examples/index.md) | YAML examples for clusters, clients, policies |
| **Tasks** | [tasks.md](tasks.md) | How to add/modify features |
diff --git a/charts/weka-operator/resources/weka_runtime.py b/charts/weka-operator/resources/weka_runtime.py
index 127a7edcf..f886dd408 100644
--- a/charts/weka-operator/resources/weka_runtime.py
+++ b/charts/weka-operator/resources/weka_runtime.py
@@ -3212,11 +3212,25 @@ async def configure_persistency():
mount --make-rshared /opt/weka/external-mounts/shared-netns
fi
- if [ -f /var/run/secrets/weka-operator/wekahome-cacert/cert.pem ]; then
+ if [ -d /var/run/secrets/weka-operator/wekahome-cacert ]; then
rm -rf /opt/weka/k8s-runtime/vars/wh-cacert
mkdir -p /opt/weka/k8s-runtime/vars/wh-cacert/
- cp /var/run/secrets/weka-operator/wekahome-cacert/cert.pem /opt/weka/k8s-runtime/vars/wh-cacert/cert.pem
- chmod 400 /opt/weka/k8s-runtime/vars/wh-cacert/cert.pem
+ # Secret data-key names are arbitrary, so concatenate every mounted PEM rather
+ # than assuming one is named cert.pem (the glob skips the ..data/..2025_* dotfiles).
+ for f in /var/run/secrets/weka-operator/wekahome-cacert/*; do
+ [ -f "$f" ] || continue
+ cat "$f" >> /opt/weka/k8s-runtime/vars/wh-cacert/cert.pem
+ echo "" >> /opt/weka/k8s-runtime/vars/wh-cacert/cert.pem
+ done
+ # Test for actual PEM content, not file size: the separator above writes a newline
+ # per key, so a secret holding only empty or non-PEM values still yields a non-empty
+ # file. An explicit CA replaces the system trust store, so pointing
+ # weka_cloud_ca_cert_path at a certificate-less file breaks Weka Home silently.
+ if grep -q "BEGIN CERTIFICATE" /opt/weka/k8s-runtime/vars/wh-cacert/cert.pem 2>/dev/null; then
+ chmod 400 /opt/weka/k8s-runtime/vars/wh-cacert/cert.pem
+ else
+ rm -rf /opt/weka/k8s-runtime/vars/wh-cacert
+ fi
fi
if [ -d /host-binds/shared-configs ]; then
diff --git a/charts/weka-operator/templates/manager.yaml b/charts/weka-operator/templates/manager.yaml
index a88b6d58c..2b2d4e4e3 100644
--- a/charts/weka-operator/templates/manager.yaml
+++ b/charts/weka-operator/templates/manager.yaml
@@ -63,9 +63,18 @@ spec:
{{- with .Values.dnsPolicy }}
dnsPolicy: {{ .k8sNetwork | default "" }}
{{- end }}
+ {{- range .Values.manager.extraVolumes }}
+ {{- if eq .name "tmpdir" }}{{ fail "manager.extraVolumes: the volume name \"tmpdir\" is reserved by the operator" }}{{ end }}
+ {{- end }}
+ {{- range .Values.manager.extraVolumeMounts }}
+ {{- if eq .mountPath "/tmp" }}{{ fail "manager.extraVolumeMounts: the mount path \"/tmp\" is reserved by the operator" }}{{ end }}
+ {{- end }}
volumes:
- name: tmpdir
emptyDir: { }
+ {{- with .Values.manager.extraVolumes }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
containers:
- args:
- --secure-listen-address=0.0.0.0:8443
@@ -518,6 +527,9 @@ spec:
volumeMounts:
- mountPath: /tmp
name: tmpdir
+ {{- with .Values.manager.extraVolumeMounts }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
name: manager
securityContext:
allowPrivilegeEscalation: false
diff --git a/charts/weka-operator/values.yaml b/charts/weka-operator/values.yaml
index 333dcf90a..3ab5d9f7e 100644
--- a/charts/weka-operator/values.yaml
+++ b/charts/weka-operator/values.yaml
@@ -145,6 +145,11 @@ manager:
nodeSelector: {}
tolerations: []
labels: {}
+ # -- extra volumes added to the operator Deployment pod spec (standard PodSpec `volumes` shape).
+ # The reserved name is `tmpdir`.
+ extraVolumes: []
+ # -- extra volumeMounts for the `manager` container. The reserved path is `/tmp`.
+ extraVolumeMounts: []
loggerSettings:
level: 0
format: raw
diff --git a/doc/api_dump/api-schema.json b/doc/api_dump/api-schema.json
index 5b81b4c10..bc6da79de 100644
--- a/doc/api_dump/api-schema.json
+++ b/doc/api_dump/api-schema.json
@@ -1981,6 +1981,24 @@
"description": "affinity per container role\ntakes precedence over the `affinity` field",
"optional": true,
"pointer": true
+ },
+ {
+ "name": "ExtraVolumes",
+ "jsonName": "extraVolumes",
+ "type": "k8s:runtime.RawExtension",
+ "description": "extra volumes added to every weka pod of this cluster, in the same shape as a PodSpec's\n`volumes`. Names must not collide with operator-managed volumes; see\ndoc/operator/deployment/extra-volumes.md for the reserved names and paths.",
+ "optional": true,
+ "pointer": true
+ },
+ {
+ "name": "ExtraVolumeMounts",
+ "jsonName": "extraVolumeMounts",
+ "type": {
+ "type": "array",
+ "items": "k8s:v1.VolumeMount"
+ },
+ "description": "mounts for `extraVolumes`, applied to the weka container only (not init containers)",
+ "optional": true
}
],
"usedBy": [
@@ -3556,6 +3574,24 @@
},
"optional": true
},
+ {
+ "name": "ExtraVolumes",
+ "jsonName": "extraVolumes",
+ "type": "k8s:runtime.RawExtension",
+ "description": "extra volumes added to every client pod, in the same shape as a PodSpec's `volumes`.\nNames must not collide with operator-managed volumes; see\ndoc/operator/deployment/extra-volumes.md for the reserved names and paths.",
+ "optional": true,
+ "pointer": true
+ },
+ {
+ "name": "ExtraVolumeMounts",
+ "jsonName": "extraVolumeMounts",
+ "type": {
+ "type": "array",
+ "items": "k8s:v1.VolumeMount"
+ },
+ "description": "mounts for `extraVolumes`, applied to the weka container only (not init containers)",
+ "optional": true
+ },
{
"name": "WekaSecretRef",
"jsonName": "wekaSecretRef",
@@ -3703,15 +3739,6 @@
"optional": true,
"pointer": true
},
- {
- "name": "WekaHomeConfig",
- "jsonName": "wekaHomeConfig",
- "type": {
- "$ref": "#/definitions/WekahomeClientConfig"
- },
- "description": "DEPRECATED, kept for compatibility with old API clients, not taking any action, to be removed on new API version",
- "optional": true
- },
{
"name": "WekaHome",
"jsonName": "wekaHome",
@@ -3913,6 +3940,13 @@
"$ref": "#/definitions/ClientPrinterColumns"
},
"optional": true
+ },
+ {
+ "name": "LastAppliedPodConfigHash",
+ "jsonName": "lastAppliedPodConfigHash",
+ "type": "string",
+ "description": "Pod config version this client has adopted. Mirrors the WekaCluster field: it gates\nthe first-deploy adoption that lets tracking start on pods predating the annotation\nwithout rolling them.",
+ "optional": true
}
],
"usedBy": [
@@ -5323,6 +5357,24 @@
},
"optional": true
},
+ {
+ "name": "ExtraVolumes",
+ "jsonName": "extraVolumes",
+ "type": "k8s:runtime.RawExtension",
+ "description": "extra volumes added to this pod, in the same shape as a PodSpec's `volumes`.\nPropagated from the owning WekaCluster/WekaClient; names must not collide with\noperator-managed volumes.",
+ "optional": true,
+ "pointer": true
+ },
+ {
+ "name": "ExtraVolumeMounts",
+ "jsonName": "extraVolumeMounts",
+ "type": {
+ "type": "array",
+ "items": "k8s:v1.VolumeMount"
+ },
+ "description": "mounts for `extraVolumes`, applied to the weka container only (not init containers)",
+ "optional": true
+ },
{
"name": "Instructions",
"jsonName": "instructions",
diff --git a/doc/api_dump/wekaclient.md b/doc/api_dump/wekaclient.md
index 402235c30..923908138 100644
--- a/doc/api_dump/wekaclient.md
+++ b/doc/api_dump/wekaclient.md
@@ -45,6 +45,8 @@
| agentPort | int | if not set (0), weka will find a free port from the portRange |
| portRange | *PortRange | used for dynamic port allocation |
| nodeSelector | map[string]string | |
+| extraVolumes | *runtime.RawExtension | extra volumes added to every client pod, in the same shape as a PodSpec's `volumes`.
Names must not collide with operator-managed volumes; see
doc/operator/deployment/extra-volumes.md for the reserved names and paths. |
+| extraVolumeMounts | []v1.VolumeMount | mounts for `extraVolumes`, applied to the weka container only (not init containers) |
| wekaSecretRef | string | |
| network | Network | |
| driversDistService | string | |
@@ -64,7 +66,6 @@
| resources | *PodResourcesSpec | experimental: pod resources to be proxied as-is to the pod spec |
| hugepages | int | hugepages, value in megabytes |
| hugepagesOffset | *int | value in megabytes to offset |
-| wekaHomeConfig | WekahomeClientConfig | DEPRECATED, kept for compatibility with old API clients, not taking any action, to be removed on new API version |
| wekaHome | *WekahomeClientConfig | |
| upgradePolicy | UpgradePolicy | |
| allowHotUpgrade | bool | |
@@ -85,6 +86,7 @@
| status | WekaClientStatusEnum | |
| stats | *ClientMetrics | |
| printer | ClientPrinterColumns | |
+| lastAppliedPodConfigHash | string | Pod config version this client has adopted. Mirrors the WekaCluster field: it gates
the first-deploy adoption that lets tracking start on pods predating the annotation
without rolling them. |
---
diff --git a/doc/api_dump/wekacluster.md b/doc/api_dump/wekacluster.md
index 93348db8c..62be05e01 100644
--- a/doc/api_dump/wekacluster.md
+++ b/doc/api_dump/wekacluster.md
@@ -211,6 +211,8 @@
| roleTopologySpreadConstraints | *RoleTopologySpreadConstraints | takes precedence over the `topologySpreadConstraints` |
| affinity | *runtime.RawExtension | advanced scheduling constraints |
| roleAffinity | *RoleAffinity | affinity per container role
takes precedence over the `affinity` field |
+| extraVolumes | *runtime.RawExtension | extra volumes added to every weka pod of this cluster, in the same shape as a PodSpec's
`volumes`. Names must not collide with operator-managed volumes; see
doc/operator/deployment/extra-volumes.md for the reserved names and paths. |
+| extraVolumeMounts | []v1.VolumeMount | mounts for `extraVolumes`, applied to the weka container only (not init containers) |
---
diff --git a/doc/api_dump/wekacontainer.md b/doc/api_dump/wekacontainer.md
index 842fdc02d..ae9346ab6 100644
--- a/doc/api_dump/wekacontainer.md
+++ b/doc/api_dump/wekacontainer.md
@@ -83,6 +83,8 @@
| group | string | |
| serviceAccountName | string | |
| additionalSecrets | map[string]string | |
+| extraVolumes | *runtime.RawExtension | extra volumes added to this pod, in the same shape as a PodSpec's `volumes`.
Propagated from the owning WekaCluster/WekaClient; names must not collide with
operator-managed volumes. |
+| extraVolumeMounts | []v1.VolumeMount | mounts for `extraVolumes`, applied to the weka container only (not init containers) |
| instructions | *Instructions | |
| dropAffinityConstraints | bool | |
| uploadResultsTo | string | |
diff --git a/doc/operator/deployment/extra-volumes.md b/doc/operator/deployment/extra-volumes.md
new file mode 100644
index 000000000..06c46df75
--- /dev/null
+++ b/doc/operator/deployment/extra-volumes.md
@@ -0,0 +1,306 @@
+# Extra Volumes
+
+## Overview
+
+`extraVolumes`/`extraVolumeMounts` let you mount arbitrary Kubernetes
+volumes — a ConfigMap, a Secret, a CSI volume, a `hostPath` — into weka pods
+and into the operator's own pod, without a custom image. The motivating use
+case is mounting a private CA bundle so weka trusts a corporate proxy or a
+private Weka Home endpoint; see [weka-home-tls.md](../operations/weka-home-tls.md)
+for why a CA bundle is exactly the kind of thing that needs this.
+
+There are three independent surfaces. Each is configured and validated
+separately — setting one does not affect the others.
+
+| Surface | Fields | Applies to |
+|---|---|---|
+| WekaCluster | `spec.podConfig.extraVolumes` + `spec.podConfig.extraVolumeMounts` | Every pod of every WekaContainer owned by the cluster |
+| WekaClient | `spec.extraVolumes` + `spec.extraVolumeMounts` (flat, no `podConfig`) | Every client pod |
+| Operator (Helm) | `manager.extraVolumes` + `manager.extraVolumeMounts` in `values.yaml` | The operator's own Deployment pod |
+
+WekaContainer carries the same two fields (`spec.extraVolumes` /
+`spec.extraVolumeMounts`), but a WekaContainer is normally not
+hand-authored: WekaCluster and WekaClient propagate their values down onto
+the WekaContainers they own. See [Propagation and rollout](#propagation-and-rollout)
+below.
+
+## Validation
+
+`extraVolumes` is schemaless (`*runtime.RawExtension` with
+`+kubebuilder:pruning:PreserveUnknownFields`) and accepts whatever JSON a
+Kubernetes `volumes` entry would; `extraVolumeMounts` is a typed
+`[]corev1.VolumeMount` list, validated by the API server like any other typed
+field.
+
+Because `extraVolumes` is schemaless, **the Kubernetes API server does not
+validate its contents at all** — it accepts any JSON object shape. The
+operator's admission webhook (`cluster_extra_volumes` on WekaCluster,
+`client_extra_volumes` on WekaClient) is the only thing that parses it
+strictly, rejecting unknown fields so a typo like `mountPaht` is caught
+immediately instead of being silently dropped. Both validators are `Error`
+severity in both admission modes (strict and relaxed) — see
+[admission-control.md](../operations/admission-control.md) for what "mode"
+means here.
+
+**If you run with `enableAdmissionControl: false`** (Helm default: `true`),
+you lose that check. A structurally invalid value — e.g. an object where a
+list is expected — is still caught later, when the pod factory tries to
+parse it while building a pod: it returns an error rather than silently
+building a pod without your volume. But field-level typos inside an
+otherwise well-formed volume (like `mountPaht` above) are not re-checked
+there, because `encoding/json` ignores unrecognized fields by default; only
+admission's `DisallowUnknownFields` decoding catches those.
+
+WekaContainer itself has no admission validator for these fields — only
+WekaCluster and WekaClient are registered for `extra_volumes` validation. A
+hand-authored WekaContainer's `extraVolumes`/`extraVolumeMounts` are
+validated only by the pod factory at reconcile time.
+
+## Reserved names and paths
+
+The operator reserves certain volume names, name suffixes, and mount-path
+prefixes for its own use inside weka/client pods. An `extraVolumes` entry or
+`extraVolumeMounts` entry that collides is rejected — at admission time if
+enabled, and always by the pod factory as a final backstop. The authoritative
+lists live in
+[`internal/controllers/resources/extra_volumes.go`](../../../internal/controllers/resources/extra_volumes.go).
+
+**Reserved volume names** (`ReservedVolumeNames`) — every volume name the
+operator itself assigns anywhere in a weka pod (backend, client, or init
+container). Volumes are pod-scoped, so a name used only by an init container
+is reserved too, even though extra mounts never land in init containers (see
+[Mount scope](#mount-scope-weka-container-only) below):
+
+```
+osrelease, dev, run, sys, weka-boot-scripts, hugepages, smbw-shm,
+host-shared-netns, weka-container-persistence-dir, weka-container-shared-dir,
+weka-cluster-persistence-dir, weka-container-global-persistence-dir,
+weka-proxy-socket-dir, weka-ssdproxy-local-socket, node-info, weka-credentials,
+proc-sysrq-trigger, proc-cmdline, devenv, google-cloud-key, host-modules,
+host-usr-src, shared-weka-version, otel-packages, libmodules, usrsrc,
+gcloud-credentials, wekahome-cacert-secret
+```
+
+`wekahome-cacert-secret` is the one name in that list the operator derives at
+runtime rather than hardcodes — it comes from `spec.additionalSecrets`, which
+forms `-secret`. Only that literal name is reserved, so an ordinary user
+name like `corp-ca-secret` is fine.
+
+**Reserved mount-path prefixes** (`ReservedMountPathPrefixes`) — a mount
+cannot land on or under:
+
+```
+/dev, /sys, /host, /host-binds, /hostside, /opt/weka,
+/opt/weka-global-persistence, /var/run/secrets/weka-operator,
+/usr/local/bin/weka, /etc/wekaio, /etc/syslog-ng,
+/shared-python-packages, /shared-weka-version, /var/log, /lib/modules,
+/usr/src, /var/secrets/google
+```
+
+**Reserved exact paths** (`ReservedMountPaths`) — operator mounts that are
+files rather than directories, so the prefix rule's `/` boundary does not cover
+them:
+
+```
+/opt/weka_runtime.py, /usr/local/bin/wekaauthcli, /devenv.sh
+```
+
+**`/etc/ssl` and `/etc/pki` are deliberately not reserved.** Mounting a CA
+bundle there is exactly the motivating use case for this feature, so those
+paths are left free for you to use.
+
+The operator's own Deployment (the Helm `manager.extraVolumes` surface) uses
+a much smaller, separate reservation: the volume name `tmpdir` and the mount
+path `/tmp`, enforced by a `{{ fail ... }}` in the Helm template itself
+rather than by the admission webhook (Helm values aren't admission-checked).
+
+## Mount scope: weka container only
+
+`extraVolumeMounts` mounts land on the **weka container only** — never on any
+init container, which runs a different image and may lack the target
+directories. Init-container volume names are reserved anyway, because volumes
+are pod-scoped.
+
+## Scope
+
+- **One list per object, no per-role targeting.** A WekaCluster's
+ `extraVolumes`/`extraVolumeMounts` apply to every pod of every role;
+ a WekaClient's apply to every client pod.
+- **Mounts may only reference your own volumes.** An `extraVolumeMounts` entry
+ must name a volume declared in the same `extraVolumes` list — the operator's
+ own volumes cannot be mounted a second time somewhere else.
+
+## Propagation and rollout
+
+WekaCluster's `spec.podConfig.extraVolumes`/`extraVolumeMounts` and
+WekaClient's `spec.extraVolumes`/`extraVolumeMounts` are propagated onto the
+WekaContainer specs the cluster/client owns, and the pod factory appends them
+after every operator-managed volume/mount. A collision is a hard error, not a
+silent skip.
+
+### Pods roll; they are never patched in place
+
+The operator never patches a running pod's volumes. Instead, changing
+`extraVolumes`/`extraVolumeMounts` changes a digest that feeds into the
+cluster's or client's **pod-config hash** — the same hash that also covers
+the image and a handful of other pod-shape inputs. When the hash a
+container's pod carries no longer matches the target, the operator deletes
+the pod for recreation, honoring the cluster's/client's `spec.upgradePolicy`
+(`manual`, `all-at-once`, `rolling`, or `all-at-once-force`) the same way an
+image upgrade does — see [upgrade.md](../operations/upgrade.md).
+
+Two `omitempty` digest fields — `extraVolumesDigest` and
+`extraVolumeMountsDigest` — feed the hash only when the corresponding field
+is actually set. A cluster or client that never sets `extraVolumes`/
+`extraVolumeMounts` gets an unchanged pod-config hash, so **upgrading the
+operator itself never rolls pods that don't use this feature.**
+
+### Pre-existing pods are adopted without rolling — the gotcha
+
+Pods created before the operator tracked a pod-config hash carry no
+`weka.io/pod-config-version` annotation and no recorded
+`status.lastAppliedPodConfigHash`. The operator treats that state as a
+first-deploy adoption: it records the current target hash as already-applied
+without deleting the pod.
+
+So **the first time you set `extraVolumes` on a cluster or client whose pods
+predate pod-config-hash tracking, nothing rolls** — which looks exactly like
+the feature not working. Two ways out:
+
+1. Delete the affected pod(s) once, by hand. They come back with the extra
+ volume, carrying the annotation, and future changes roll normally.
+2. Set `allowRotateNonAnnotatedPodConfigHash: true` (Helm value; env var
+ `ALLOW_ROTATE_NON_ANNOTATED_POD_CONFIG_HASH`, default `false`), which rolls
+ unannotated pods on their first hash mismatch instead of adopting them — at
+ the cost of rolling *every* not-yet-annotated pod the next time anything
+ changes the hash.
+
+Either way this matters only once per cluster/client.
+
+## Worked examples
+
+### 1. Mounting a private CA bundle into a cluster's backend pods
+
+The generic mechanism this document describes, applied to the private Weka
+Home CA use case from [weka-home-tls.md](../operations/weka-home-tls.md). (Weka
+Home itself has a dedicated, simpler field — `wekaHome.cacertSecret`; reach for
+`extraVolumes` when the bundle also has to serve some *other* purpose inside the
+container. Example 2 combines both.)
+
+```bash
+kubectl create secret generic corp-ca-bundle \
+ --from-file=ca.crt=./corp-ca.pem
+```
+
+```yaml
+apiVersion: weka.weka.io/v1alpha1
+kind: WekaCluster
+metadata:
+ name: my-cluster
+spec:
+ podConfig:
+ extraVolumes:
+ - name: corp-ca
+ secret:
+ secretName: corp-ca-bundle
+ extraVolumeMounts:
+ - name: corp-ca
+ mountPath: /etc/ssl/certs/corp-ca.crt
+ subPath: ca.crt
+ readOnly: true
+```
+
+### 2. One Secret, both Weka Home and the rest of the container
+
+`wekaHome.cacertSecret` only makes weka's own Weka Home connection trust the
+CA. Nothing else in the container — an outbound HTTPS proxy, `curl`, any other
+TLS client — sees it. Point both fields at the same Secret:
+
+```yaml
+apiVersion: weka.weka.io/v1alpha1
+kind: WekaCluster
+metadata:
+ name: my-cluster
+spec:
+ wekaHome:
+ cacertSecret: corp-ca-bundle
+ podConfig:
+ extraVolumes:
+ - name: corp-ca-trust
+ secret:
+ secretName: corp-ca-bundle
+ extraVolumeMounts:
+ - name: corp-ca-trust
+ mountPath: /etc/ssl/certs/corp-ca.crt
+ subPath: ca.crt
+ readOnly: true
+```
+
+The two mounts are independent and both are needed. `wekaHome.cacertSecret`
+produces an operator-managed volume named `wekahome-cacert-secret` at
+`/var/run/secrets/weka-operator/wekahome-cacert`, staged to
+`/opt/weka/k8s-runtime/vars/wh-cacert/cert.pem` — that name and both paths are
+reserved, so your `extraVolumes` entry must use a different name and a
+non-reserved path. Two volumes referencing one Secret is fine.
+
+Setting `wekaHome.cacertSecret` on a WekaCluster has cluster-wide blast radius —
+read [weka-home-tls.md](../operations/weka-home-tls.md#the-ca-path-is-cluster-wide-the-file-is-not)
+before doing it.
+
+### 3. A client-only topology (`joinIpPorts`, no `targetCluster`)
+
+Per [weka-home-tls.md](../operations/weka-home-tls.md#client-only-deployments-no-targetcluster),
+a WekaClient that joins an external cluster via `spec.joinIpPorts` (instead
+of `spec.targetCluster`) has no operator-verified way to know the external
+cluster's `weka_cloud_ca_cert_path`, so the supported answer there is
+mounting into the container's OS trust store — which `extraVolumes` now
+makes declarative instead of requiring a custom image:
+
+```yaml
+apiVersion: weka.weka.io/v1alpha1
+kind: WekaClient
+metadata:
+ name: external-client
+spec:
+ joinIpPorts:
+ - "10.0.0.10:14000"
+ extraVolumes:
+ - name: corp-ca
+ secret:
+ secretName: corp-ca-bundle
+ extraVolumeMounts:
+ - name: corp-ca
+ mountPath: /etc/ssl/certs/corp-ca.crt
+ subPath: ca.crt
+ readOnly: true
+```
+
+### 4. `manager.extraVolumes` on the operator's own Deployment
+
+For needs of the operator process itself — for example, trusting a
+corporate CA for an outbound HTTP(S) proxy. The operator's own Weka Home CR
+reporter does **not** need this: it reads its CA Secret through the
+Kubernetes API directly into an in-memory certificate pool built on top of
+`x509.SystemCertPool()`, so it already has the OS trust store plus whatever
+Secret `wekahome.cacertSecret` names, with no volume or mount involved (see
+[weka-home-tls.md](../operations/weka-home-tls.md#the-three-configuration-surfaces)).
+`manager.extraVolumes` is for anything else the operator container's
+filesystem needs.
+
+```yaml
+# operator_values.yaml
+manager:
+ extraVolumes:
+ - name: corp-proxy-ca
+ configMap:
+ name: corp-proxy-ca
+ extraVolumeMounts:
+ - name: corp-proxy-ca
+ mountPath: /etc/ssl/certs/corp-ca.crt
+ subPath: ca.crt
+```
+
+```bash
+helm upgrade --install weka-operator oci://quay.io/weka.io/helm/weka-operator \
+ --namespace weka-operator-system --values operator_values.yaml
+```
diff --git a/doc/operator/deployment/helm-install.md b/doc/operator/deployment/helm-install.md
index 5c1d0c9ed..c1f51a736 100644
--- a/doc/operator/deployment/helm-install.md
+++ b/doc/operator/deployment/helm-install.md
@@ -23,3 +23,5 @@ Helm-level configure of PVC is deprecated and should be used only when explicitl
If just asked to "use pvc for cluster", use cluster spec field instead. `globalPVC` object field on cluster spec with, with `name` field on it, which references PVC
`portAllocation.startingPort` - Starting port for Weka container port allocation (default: `35000`). This is the base port from which the operator allocates port ranges for Weka clusters. Only modify if you have specific firewall or port conflict requirements.
+
+`manager.extraVolumes` / `manager.extraVolumeMounts` - extra volumes/mounts on the operator's own Deployment pod (e.g. a corporate CA for an outbound proxy). The reserved volume name is `tmpdir`, the reserved mount path is `/tmp`. See `operator/deployment/extra-volumes.md` for this and the separate, per-CR `extraVolumes` fields on WekaCluster/WekaClient.
diff --git a/doc/operator/operations/secrets-management.md b/doc/operator/operations/secrets-management.md
index 143889520..0cc0f4491 100644
--- a/doc/operator/operations/secrets-management.md
+++ b/doc/operator/operations/secrets-management.md
@@ -4,6 +4,10 @@
The Weka operator creates and manages several Kubernetes secrets for each Weka cluster. These secrets store credentials and connection information required for various components to interact with the Weka cluster. This document explains each secret's purpose, content, and how to manage them.
+This document does not cover the Secrets used for Weka Home TLS (CA
+certificates for telemetry/support connectivity) — see
+[weka-home-tls.md](weka-home-tls.md) for those.
+
## Secrets Created by the Operator
For each WekaCluster, the operator creates four distinct secrets:
diff --git a/doc/operator/operations/weka-home-tls.md b/doc/operator/operations/weka-home-tls.md
new file mode 100644
index 000000000..98b4cfdb3
--- /dev/null
+++ b/doc/operator/operations/weka-home-tls.md
@@ -0,0 +1,162 @@
+# Weka Home TLS
+
+## Overview
+
+Weka Home is Weka's telemetry and support-connectivity service. It is **not
+on the data path** — it carries events, connectivity heartbeats, and
+(optionally) performance stats, never IO. A misconfigured CA certificate
+degrades support visibility; it does not affect mounts, IO, or cluster
+availability.
+
+This document explains how Weka Home connectivity works, how its CA
+certificate is configured across the WekaCluster, WekaClient, and operator
+surfaces, and the constraint that makes this different from most other
+per-object settings: the CA **path** is cluster-wide replicated
+configuration, but the CA **file** is not distributed anywhere.
+
+For the Kubernetes Secrets the operator manages for cluster authentication
+(distinct from the Weka Home CA secrets described here), see
+[secrets-management.md](secrets-management.md).
+
+## How Weka Home connectivity works
+
+- **Every Weka machine talks to Weka Home directly.** Backends and clients
+ each open their own HTTPS connection to the configured endpoint (default
+ `api.home.weka.io:443`). A client does not hand its telemetry to a backend
+ to forward under normal conditions.
+- **A backend relay is a fallback only.** A client relays through a
+ cluster backend's management HTTP port only after its own direct attempt
+ fails — DNS failure, connection refused, timeout, or TLS handshake
+ failure — and only when the cluster permits the relay. If Weka Home is
+ reachable but rejects the upload (e.g. an application-level error), there
+ is no relay for that: relaying would not fix a rejection.
+- **Blocking clients from the internet is supported but has consequences.**
+ Each client burns a failed direct attempt, caches "unreachable" for about
+ 30 minutes, relays through a backend during that window, then re-probes
+ directly. The cluster raises an event both on entering and on leaving this
+ state, so it's observable. Relay is unavailable in the cluster's strictest
+ TLS mode, and was off by default before Weka 6.1 — in those cases, client
+ telemetry is simply lost while direct connectivity is blocked. For
+ air-gapped sites, use the supported disable-call-home cluster setting
+ instead of a firewall rule, so the cluster stops retrying and alerting on
+ something that's expected to fail.
+- **The gRPC trace channel is separate from all of this.** It's disabled by
+ default, takes its endpoint from cluster configuration, excludes client
+ containers unless explicitly opted in, and **does not verify the server
+ certificate at all** — it consults neither a custom CA nor the OS trust
+ store. Nothing in this document applies to it.
+
+## The CA path is cluster-wide; the file is not
+
+> **This is the single most important fact about Weka Home TLS
+> configuration.** `weka_cloud_ca_cert_path` is cluster-wide replicated
+> configuration — every machine that joins the cluster receives the full
+> config snapshot, including this path and the Weka Home base URL. But
+> **only the path string replicates. Nothing distributes the certificate
+> file itself.** Every machine that joins must place a PEM at that path on
+> its own.
+
+Two consequences follow directly from this:
+
+- When the path is **unset**, a machine passes no explicit CA to its Weka
+ Home connection and falls back to its container's **OS trust store** — a
+ local trust anchor that its own owner controls. This is what makes a
+ public Weka Home endpoint (a public CA) work with zero configuration on
+ every machine, managed or not.
+- When the path **is set**, an explicit CA **replaces** the system bundle
+ rather than adding to it — so the OS-trust-store fallback stops working
+ for every machine on that cluster, not just the one that set the path.
+
+This means setting `wekaCluster.spec.wekaHome.cacertSecret` imposes a
+filesystem-path contract on **every machine that joins that cluster**,
+including machines this operator does not manage: bare-metal hosts, clients
+in other Kubernetes clusters, or clients run by other teams. Each of them
+must independently place a PEM at
+`/opt/weka/k8s-runtime/vars/wh-cacert/cert.pem` — a path that is decidedly
+odd to create by hand on a bare-metal host. Weigh that blast radius before
+setting the field; the operator does not warn about it, because using a
+private CA cluster-wide is a supported configuration, not a mistake.
+
+## The three configuration surfaces
+
+| Surface | Field | Effect |
+|---|---|---|
+| WekaCluster | `spec.wekaHome.cacertSecret` | Mounts the Secret into every backend pod, stages it at `/opt/weka/k8s-runtime/vars/wh-cacert/cert.pem`, and sets the cluster-wide `weka_cloud_ca_cert_path` from a drive container. |
+| WekaClient | `spec.wekaHome.cacertSecret` | Places the same file on the client pod at the same path. When `targetCluster` is set and the cluster is in the **same namespace**, this is **derived automatically from the target cluster's own `cacertSecret`** — set it explicitly only to override that default. A cluster in another namespace is not inherited from: only the Secret *name* would be copied, and it would not resolve in the client's namespace, so the client emits a warning event and you must set the field yourself. |
+| Operator (Helm) | `wekahome.cacertSecret` | Used only by the operator's own CR reporter (the process that periodically reports CRs to Weka Home). It reads the Secret through the Kubernetes API into an in-memory certificate pool at request time. **No volume, no mount, no container filesystem is involved.** |
+
+Every PEM in the Secret's data is concatenated into the destination file or
+pool — the data-key name does not matter, for either the WekaCluster/
+WekaClient mount path or the operator's in-memory pool. Secrets keyed
+`cert.pem` work unchanged.
+
+The operator's own reporter starts from the system cert pool and adds the
+Secret's certificates on top of it, so it never loses the OS trust store the
+way the container-side path does.
+
+Clients run in Weka's `--restricted` mode and can never set cluster
+configuration themselves — they only need the certificate file present at
+the expected path; the cluster's own configuration is what tells them (and
+everything else) which endpoint and CA to use.
+
+### Secret shape and example
+
+```bash
+kubectl create secret generic weka-home-ca \
+ --from-file=cert.pem=./private-weka-home-ca.pem
+```
+
+```yaml
+apiVersion: weka.weka.io/v1alpha1
+kind: WekaCluster
+metadata:
+ name: my-cluster
+spec:
+ wekaHome:
+ cacertSecret: weka-home-ca
+```
+
+Because any key name works, `--from-file=ca.crt=...` or a multi-key Secret
+with several PEMs under different keys is equally valid — all of them end up
+concatenated into the one trust bundle on disk.
+
+## Client-only deployments (no `targetCluster`)
+
+When a WekaClient connects to an external backend via `spec.joinIpPorts`
+instead of `spec.targetCluster`, the operator has no visibility into that
+external cluster and cannot see or verify its `weka_cloud_ca_cert_path`.
+
+Setting `spec.wekaHome.cacertSecret` on such a client places a PEM at the
+*operator's own* path
+(`/opt/weka/k8s-runtime/vars/wh-cacert/cert.pem`) — which only helps if the
+external cluster's administrator happens to have set the same path
+convention. The operator cannot confirm this, so it emits an admission
+warning (`client_wekahome_cacert_unverifiable`) on any WekaClient that sets
+`wekaHome.cacertSecret` while leaving `targetCluster` unset.
+
+**The supported answer for this topology is the container's OS trust
+store**, per Weka's own recommendation for machines outside the cluster's
+management scope: bake the CA into the client image, or mount a CA bundle
+over `/etc/ssl/certs` or `/etc/pki` in the container. This works precisely
+when the external cluster leaves its own `weka_cloud_ca_cert_path` unset, so
+every machine — managed or not — falls back to its own OS trust store.
+
+`spec.extraVolumes`/`spec.extraVolumeMounts` on the WekaClient is the
+declarative way to do that without a custom image — see
+[extra-volumes.md](../deployment/extra-volumes.md), which carries a worked
+example for exactly this topology. Baking the CA into a custom client image
+also works.
+
+## Rotation
+
+Rotating the CA Secret's content requires a pod restart on both paths:
+
+- The operator's in-memory cert pool is built once when the reporter's HTTP
+ client is constructed, so the operator pod must restart to pick up a
+ changed Secret.
+- The container-side file at
+ `/opt/weka/k8s-runtime/vars/wh-cacert/cert.pem` is copied in at container
+ boot, so backend and client pods need to restart as well.
+
+Updating the Secret's contents in place does not propagate to either side
+until the corresponding pod restarts.
diff --git a/doc/summary.xml b/doc/summary.xml
index 59ed4742f..bb42c8846 100644
--- a/doc/summary.xml
+++ b/doc/summary.xml
@@ -57,6 +57,10 @@
embedded CSI, CSI deployment, CSI driver, StorageClass, CSI controller, CSI node server, targetCluster, csiConfig, lifecycle management, migration
Comprehensive guide for the operator-embedded CSI deployment capabilities, covering CSI driver naming conventions, StorageClass creation, controller and node server deployment, configuration options, update flows, and migration from separate CSI to embedded CSI.
+
+ extraVolumes, extraVolumeMounts, podConfig, manager.extraVolumes, RawExtension, schemaless, VolumeMount, cluster_extra_volumes, client_extra_volumes, ReservedVolumeNames, ReservedMountPathPrefixes, DisallowUnknownFields, pod-config-version, podConfigHash, allowRotateNonAnnotatedPodConfigHash, upgradePolicy, CA bundle, tmpdir
+ Documents the three extra-volumes surfaces — WekaCluster spec.podConfig.extraVolumes/extraVolumeMounts (all pods of the cluster), WekaClient spec.extraVolumes/extraVolumeMounts (all client pods, flat spec, no podConfig), and the operator's own Helm manager.extraVolumes/extraVolumeMounts — for mounting arbitrary volumes (e.g. a private CA bundle) without a custom image. Explains that extraVolumes is a schemaless RawExtension while extraVolumeMounts is a typed VolumeMount list, and the resulting gap that only admission's DisallowUnknownFields decoding (cluster_extra_volumes/client_extra_volumes, Error at both severities) catches typos when the API server itself will not. Lists the reserved volume names/suffixes and mount-path prefixes (noting /etc/ssl and /etc/pki are deliberately not reserved), and that mounts land on the weka container only, never init containers. Covers propagation from WekaCluster/WekaClient onto owned WekaContainer specs, and the pod-config-hash rollout mechanics: pods are deleted and recreated (never patched) when the hash changes, unused digests are omitempty so non-adopters see no churn on operator upgrade, and pods that predate pod-config-hash tracking are adopted without rolling — so the first extraVolumes change on such a pod silently does nothing until it's deleted once or allowRotateNonAnnotatedPodConfigHash is set. Includes worked YAML for a cluster CA-bundle mount, a client-only (joinIpPorts, no targetCluster) OS-trust-store mount, and manager.extraVolumes for the operator's own Deployment.
+
@@ -81,6 +85,10 @@
secrets, credentials, operator secret, admin secret, client secret, CSI secret, authentication
Detailed guide on the Kubernetes secrets created and managed by the Weka operator, including their purpose, content, naming conventions, and usage in different components.
+
+ weka home, wekahome, telemetry, call home, TLS, CA certificate, cacertSecret, weka_cloud_ca_cert_path, wh-cacert, OS trust store, restricted mode, relay, client_wekahome_cacert_unverifiable, targetCluster, joinIpPorts, gRPC trace channel, rotation
+ Explains how Weka Home telemetry/support connectivity works (direct per-machine HTTPS, backend relay as a fallback only, air-gapped handling, and the separate unverified gRPC trace channel), and how its TLS CA certificate is configured across the three surfaces: WekaCluster spec.wekaHome.cacertSecret (mounts into backend pods and sets the cluster-wide weka_cloud_ca_cert_path), WekaClient spec.wekaHome.cacertSecret (now derived from the target cluster by default), and the operator's own Helm wekahome.cacertSecret (in-memory cert pool for its CR reporter, no volume). Emphasizes that the CA path replicates cluster-wide but the certificate file itself never does, so every joining machine — including ones the operator doesn't manage — must place the PEM itself, with the client_wekahome_cacert_unverifiable admission warning flagging the unverifiable-external-cluster case. Covers the client-only/no-targetCluster topology where the OS-trust-store route is the supported answer, reached declaratively via WekaClient spec.extraVolumes (see extra-volumes.md), and the pod-restart requirement to pick up a rotated CA.
+
s3, weka s3, s3 status, s3 containers, object storage
S3 functionality guide detailing container types, status monitoring, provisioning S3 capabilities, and accessing S3 storage in Weka clusters.
diff --git a/internal/admission/defaults.go b/internal/admission/defaults.go
index 4cd9f02ab..32ec8228c 100644
--- a/internal/admission/defaults.go
+++ b/internal/admission/defaults.go
@@ -42,11 +42,14 @@ var (
"cluster_capacity_chunk_feasibility": {Strict: Error, Relaxed: Error},
"cluster_skip_default_fs": {Strict: Warn, Relaxed: Warn},
"cluster_podspec_syntax": {Strict: Error, Relaxed: Error},
+ "cluster_extra_volumes": {Strict: Error, Relaxed: Error},
}
wekaClientDefaults = map[string]PolicyDefaults{
- "client_target_cluster_exists": {Strict: Error, Relaxed: Warn},
- "client_podspec_syntax": {Strict: Error, Relaxed: Error},
+ "client_target_cluster_exists": {Strict: Error, Relaxed: Warn},
+ "client_podspec_syntax": {Strict: Error, Relaxed: Error},
+ "client_wekahome_cacert_unverifiable": {Strict: Warn, Relaxed: Warn},
+ "client_extra_volumes": {Strict: Error, Relaxed: Error},
}
// Update-only defaults: cores-decrease checks are always Error regardless
diff --git a/internal/controllers/factory/container_factory.go b/internal/controllers/factory/container_factory.go
index accab96d0..7529bf2b1 100644
--- a/internal/controllers/factory/container_factory.go
+++ b/internal/controllers/factory/container_factory.go
@@ -149,6 +149,8 @@ func NewWekaContainerForWekaCluster(cluster *wekav1alpha1.WekaCluster,
DriversBuildId: cluster.Spec.GetOverrides().DriversBuildId,
PVC: resources.GetPvcConfig(cluster.Spec.GlobalPVC),
DpdkBaseMemoryMb: dpdkBaseMemoryMb,
+ ExtraVolumes: resources.NormalizeExtraVolumes(cluster.GetRawExtraVolumes()),
+ ExtraVolumeMounts: cluster.GetExtraVolumeMounts(),
Overrides: &wekav1alpha1.WekaContainerSpecOverrides{
MachineIdentifierNodeRef: cluster.Spec.GetOverrides().MachineIdentifierNodeRef,
NoReserveSpace: cluster.Spec.GetOverrides().NoReserveSpace,
diff --git a/internal/controllers/resources/extra_volumes.go b/internal/controllers/resources/extra_volumes.go
new file mode 100644
index 000000000..ef5672cf4
--- /dev/null
+++ b/internal/controllers/resources/extra_volumes.go
@@ -0,0 +1,128 @@
+package resources
+
+import (
+ "bytes"
+ "encoding/json"
+ "path"
+ "strings"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+
+ "github.com/weka/weka-operator/pkg/util"
+)
+
+// ReservedVolumeNames are volume names the operator itself assigns somewhere in a weka pod
+// (backend, client, or init container). Volumes are pod-scoped, so a name used only by an init
+// container is reserved too: a collision would break that init container even though extras are
+// never mounted into it (applyExtraVolumes mounts into the weka container only).
+var ReservedVolumeNames = []string{
+ "osrelease", "dev", "run", "sys", "weka-boot-scripts", "hugepages", "smbw-shm",
+ "host-shared-netns", "weka-container-persistence-dir", "weka-container-shared-dir",
+ "weka-cluster-persistence-dir", "weka-container-global-persistence-dir",
+ "weka-proxy-socket-dir", "weka-ssdproxy-local-socket", "node-info", "weka-credentials",
+ "proc-sysrq-trigger", "proc-cmdline", "devenv", "google-cloud-key", "host-modules",
+ "host-usr-src", "shared-weka-version", "otel-packages",
+ // The drivers container declares these alongside, not instead of, the host-* pair above;
+ // all are live volume names, so all are reserved (see resources/drivers.go).
+ "libmodules", "usrsrc", "gcloud-credentials",
+ // AdditionalSecrets has exactly one entry today and it becomes "-secret" (pod.go).
+ // Reserve that literal derived name rather than banning every "-secret" suffix, which would
+ // also reject plausible user volume names like "corp-ca-secret".
+ "wekahome-cacert-secret",
+}
+
+// ReservedMountPaths are mount paths the operator manages. A path is reserved when it equals an
+// entry or falls under one at a /-boundary, so an entry naming a file reserves only itself.
+// /etc/ssl and /etc/pki are deliberately absent: mounting a CA bundle there is the motivating
+// use case.
+var ReservedMountPaths = []string{
+ "/dev", "/sys", "/host", "/host-binds", "/hostside", "/opt/weka",
+ "/opt/weka-global-persistence", "/var/run/secrets/weka-operator", "/usr/local/bin/weka",
+ "/etc/wekaio", "/etc/syslog-ng", "/shared-python-packages", "/shared-weka-version",
+ "/var/log", "/lib/modules", "/usr/src", "/var/secrets/google",
+ // Files, not directories: the /-boundary rule means "/opt/weka" does not cover a sibling
+ // like "/opt/weka_runtime.py", so each of these needs its own entry.
+ "/opt/weka_runtime.py", // pod.go: weka_runtime.py mount
+ "/usr/local/bin/wekaauthcli", // pod.go: wekaauthcli mount
+ "/devenv.sh", // drivers.go: COS dev-env script mount
+}
+
+var reservedVolumeNameSet = func() map[string]struct{} {
+ set := make(map[string]struct{}, len(ReservedVolumeNames))
+ for _, name := range ReservedVolumeNames {
+ set[name] = struct{}{}
+ }
+ return set
+}()
+
+// IsReservedVolumeName reports whether name collides with an operator-managed volume.
+func IsReservedVolumeName(name string) bool {
+ _, ok := reservedVolumeNameSet[name]
+ return ok
+}
+
+// IsReservedMountPath reports whether p is, or falls under, an operator-managed mount path.
+func IsReservedMountPath(p string) bool {
+ clean := path.Clean(p)
+ for _, reserved := range ReservedMountPaths {
+ if clean == reserved || strings.HasPrefix(clean, reserved+"/") {
+ return true
+ }
+ }
+ return false
+}
+
+// NormalizeExtraVolumes round-trips raw through []corev1.Volume and re-marshals it to canonical
+// JSON, so key order and whitespace differences hash identically. Unset, JSON null, and an empty
+// array all collapse to nil so none of the three churns the spec digest against the others.
+//
+// Decoding is strict, matching admission's own validator: the CRD field is PreserveUnknownFields,
+// so a lenient decode would silently drop a field the vendored corev1.Volume does not know about -
+// permanently, since the normalized form is what gets written onto the WekaContainer spec. On any
+// decode failure the raw bytes are copied through unchanged rather than collapsing to nil, so
+// malformed input still reaches the pod-build path and fails loudly in applyExtraVolumes instead
+// of vanishing before it gets there.
+//
+// The returned RawExtension always wraps freshly allocated bytes, never the caller's own slice,
+// because normalized specs get written back to the API server.
+func NormalizeExtraVolumes(raw *runtime.RawExtension) *runtime.RawExtension {
+ if raw == nil || len(raw.Raw) == 0 {
+ return nil
+ }
+ var volumes []corev1.Volume
+ dec := json.NewDecoder(bytes.NewReader(raw.Raw))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&volumes); err != nil {
+ return &runtime.RawExtension{Raw: bytes.Clone(raw.Raw)}
+ }
+ if len(volumes) == 0 {
+ return nil
+ }
+ normalized, _ := json.Marshal(volumes) //nolint:errcheck // volumes just decoded from JSON
+ return &runtime.RawExtension{Raw: normalized}
+}
+
+// ExtraVolumesDigest hashes the normalized form of raw. JSON, unlike the gob encoding behind
+// util.HashStruct, sorts map keys and ignores none of a Volume's fields, so a change buried in
+// e.g. emptyDir.sizeLimit (a resource.Quantity, whose value lives in unexported fields gob
+// skips) or csi.volumeAttributes (a map, which gob refuses outright) still changes the digest.
+func ExtraVolumesDigest(raw *runtime.RawExtension) string {
+ normalized := NormalizeExtraVolumes(raw)
+ if normalized == nil {
+ return ""
+ }
+ return util.GetHash(string(normalized.Raw), 16)
+}
+
+// ExtraVolumeMountsDigest hashes mounts the same way ExtraVolumesDigest hashes volumes.
+func ExtraVolumeMountsDigest(mounts []corev1.VolumeMount) string {
+ if len(mounts) == 0 {
+ return ""
+ }
+ data, err := json.Marshal(mounts)
+ if err != nil {
+ return ""
+ }
+ return util.GetHash(string(data), 16)
+}
diff --git a/internal/controllers/resources/extra_volumes_test.go b/internal/controllers/resources/extra_volumes_test.go
new file mode 100644
index 000000000..e8fd28088
--- /dev/null
+++ b/internal/controllers/resources/extra_volumes_test.go
@@ -0,0 +1,580 @@
+package resources
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ weka "github.com/weka/weka-k8s-api/api/v1alpha1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+
+ "github.com/weka/weka-operator/internal/config"
+ "github.com/weka/weka-operator/internal/pkg/domain"
+ "github.com/weka/weka-operator/internal/services/discovery"
+)
+
+// rawVolumes marshals volumes into the RawExtension shape ExtraVolumes expects.
+func rawVolumes(t *testing.T, volumes []corev1.Volume) *runtime.RawExtension {
+ t.Helper()
+ data, err := json.Marshal(volumes)
+ if err != nil {
+ t.Fatalf("failed to marshal test volumes: %v", err)
+ }
+ return &runtime.RawExtension{Raw: data}
+}
+
+// createTestPod builds a container with the given mode/extras and runs it through the real
+// PodFactory.Create pipeline, so collision checks exercise the pod's actual, final volume set
+// (including mode- and config-dependent volumes) rather than a hand-picked approximation of it.
+//
+// ConfigureEnv (the normal source of config.Config defaults, e.g. Smbw.ShmSize) reads required
+// env vars and is not meant to run in a unit test, so smbw-mode callers must set
+// config.Config.Smbw.ShmSize themselves.
+func createTestPod(t *testing.T, mode string, mutate func(*weka.WekaContainerSpec)) (*corev1.Pod, error) {
+ t.Helper()
+ return createTestPodOnNode(t, mode, &discovery.DiscoveryNodeInfo{}, mutate)
+}
+
+// createTestPodOnNode is createTestPod with an explicit node info, for cases (COS driver
+// dependencies) that branch on it.
+func createTestPodOnNode(t *testing.T, mode string, nodeInfo *discovery.DiscoveryNodeInfo, mutate func(*weka.WekaContainerSpec)) (*corev1.Pod, error) {
+ t.Helper()
+ spec := weka.WekaContainerSpec{
+ Mode: mode,
+ Image: "test-image:latest",
+ NumCores: 1,
+ CpuPolicy: weka.CpuPolicyAuto,
+ Hugepages: 4000,
+ }
+ if mutate != nil {
+ mutate(&spec)
+ }
+ container := &weka.WekaContainer{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-container", UID: "test-uid"},
+ Spec: spec,
+ }
+ factory := NewPodFactory(container, nodeInfo, &domain.FeatureFlags{})
+ return factory.Create(context.Background(), nil)
+}
+
+func TestApplyExtraVolumes_AppendedAfterBaseVolumesAndMainContainerOnly(t *testing.T) {
+ // Force an init container (otel-packages-installer) into the pod so we can assert extras
+ // never leak into it.
+ original := config.Config.Otel.PythonPackagesInstallerImage
+ config.Config.Otel.PythonPackagesInstallerImage = "otel-installer:latest"
+ defer func() { config.Config.Otel.PythonPackagesInstallerImage = original }()
+
+ pod, err := createTestPod(t, weka.WekaContainerModeCompute, func(spec *weka.WekaContainerSpec) {
+ spec.ExtraVolumes = rawVolumes(t, []corev1.Volume{
+ {Name: "my-extra", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
+ })
+ spec.ExtraVolumeMounts = []corev1.VolumeMount{
+ {Name: "my-extra", MountPath: "/mnt/my-extra"},
+ }
+ })
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+
+ if len(pod.Spec.InitContainers) == 0 {
+ t.Fatalf("expected at least one init container (otel-packages-installer) in this fixture")
+ }
+
+ lastVolume := pod.Spec.Volumes[len(pod.Spec.Volumes)-1]
+ if lastVolume.Name != "my-extra" {
+ t.Errorf("extra volume was not appended after base volumes: last volume is %q", lastVolume.Name)
+ }
+
+ lastMount := pod.Spec.Containers[0].VolumeMounts[len(pod.Spec.Containers[0].VolumeMounts)-1]
+ if lastMount.Name != "my-extra" || lastMount.MountPath != "/mnt/my-extra" {
+ t.Errorf("extra mount not appended to main container as expected, got %+v", lastMount)
+ }
+
+ for _, ic := range pod.Spec.InitContainers {
+ for _, m := range ic.VolumeMounts {
+ if m.Name == "my-extra" {
+ t.Errorf("extra volume mount leaked into init container %q", ic.Name)
+ }
+ }
+ }
+}
+
+func TestApplyExtraVolumes_CollisionErrors(t *testing.T) {
+ t.Run("plain reserved name", func(t *testing.T) {
+ _, err := createTestPod(t, weka.WekaContainerModeCompute, func(spec *weka.WekaContainerSpec) {
+ spec.ExtraVolumes = rawVolumes(t, []corev1.Volume{
+ {Name: "dev", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
+ })
+ })
+ if err == nil {
+ t.Fatal("expected error for reserved volume name \"dev\", got nil")
+ }
+ })
+
+ t.Run("mode-dependent name: smbw-shm on an smbw container", func(t *testing.T) {
+ originalShmSize := config.Config.Smbw.ShmSize
+ config.Config.Smbw.ShmSize = "8Gi"
+ defer func() { config.Config.Smbw.ShmSize = originalShmSize }()
+
+ _, err := createTestPod(t, weka.WekaContainerModeSmbw, func(spec *weka.WekaContainerSpec) {
+ spec.ExtraVolumes = rawVolumes(t, []corev1.Volume{
+ {Name: "smbw-shm", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
+ })
+ })
+ if err == nil {
+ t.Fatal("expected error for smbw-shm colliding on an smbw container, got nil")
+ }
+ })
+
+ t.Run("config-dependent name: otel-packages with installer image configured", func(t *testing.T) {
+ original := config.Config.Otel.PythonPackagesInstallerImage
+ config.Config.Otel.PythonPackagesInstallerImage = "otel-installer:latest"
+ defer func() { config.Config.Otel.PythonPackagesInstallerImage = original }()
+
+ _, err := createTestPod(t, weka.WekaContainerModeCompute, func(spec *weka.WekaContainerSpec) {
+ spec.ExtraVolumes = rawVolumes(t, []corev1.Volume{
+ {Name: "otel-packages", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
+ })
+ })
+ if err == nil {
+ t.Fatal("expected error for otel-packages colliding when the installer image is configured, got nil")
+ }
+ })
+
+ t.Run("AdditionalSecrets-derived name", func(t *testing.T) {
+ _, err := createTestPod(t, weka.WekaContainerModeCompute, func(spec *weka.WekaContainerSpec) {
+ spec.AdditionalSecrets = map[string]string{"wekahome-cacert": "wekahome-cacert-secret-name"}
+ spec.ExtraVolumes = rawVolumes(t, []corev1.Volume{
+ {Name: "wekahome-cacert-secret", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
+ })
+ })
+ if err == nil {
+ t.Fatal("expected error for wekahome-cacert-secret colliding with the AdditionalSecrets-derived volume, got nil")
+ }
+ })
+
+ t.Run("reserved mount path", func(t *testing.T) {
+ _, err := createTestPod(t, weka.WekaContainerModeCompute, func(spec *weka.WekaContainerSpec) {
+ spec.ExtraVolumes = rawVolumes(t, []corev1.Volume{
+ {Name: "my-vol", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
+ })
+ spec.ExtraVolumeMounts = []corev1.VolumeMount{
+ {Name: "my-vol", MountPath: "/opt/weka/foo"},
+ }
+ })
+ if err == nil {
+ t.Fatal("expected error for mount path under reserved /opt/weka, got nil")
+ }
+ })
+}
+
+func TestApplyExtraVolumes_MountReferencingUndeclaredVolumeErrors(t *testing.T) {
+ _, err := createTestPod(t, weka.WekaContainerModeCompute, func(spec *weka.WekaContainerSpec) {
+ spec.ExtraVolumeMounts = []corev1.VolumeMount{
+ {Name: "never-declared", MountPath: "/mnt/never-declared"},
+ }
+ })
+ if err == nil {
+ t.Fatal("expected error for a mount naming no declared extraVolumes entry, got nil")
+ }
+}
+
+func TestApplyExtraVolumes_EtcSslCertsAccepted(t *testing.T) {
+ pod, err := createTestPod(t, weka.WekaContainerModeCompute, func(spec *weka.WekaContainerSpec) {
+ spec.ExtraVolumes = rawVolumes(t, []corev1.Volume{
+ {Name: "ca-bundle", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
+ })
+ spec.ExtraVolumeMounts = []corev1.VolumeMount{
+ {Name: "ca-bundle", MountPath: "/etc/ssl/certs"},
+ }
+ })
+ if err != nil {
+ t.Fatalf("expected /etc/ssl/certs mount to be accepted, got error: %v", err)
+ }
+
+ found := false
+ for _, m := range pod.Spec.Containers[0].VolumeMounts {
+ if m.Name == "ca-bundle" && m.MountPath == "/etc/ssl/certs" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("expected ca-bundle mount at /etc/ssl/certs on the main container, not found")
+ }
+}
+
+func TestNormalizeExtraVolumes_UnsetNullEmptyCollapseToNil(t *testing.T) {
+ cases := []struct {
+ name string
+ raw *runtime.RawExtension
+ }{
+ {"nil", nil},
+ {"nil Raw bytes", &runtime.RawExtension{}},
+ {"JSON null", &runtime.RawExtension{Raw: []byte("null")}},
+ {"empty array", &runtime.RawExtension{Raw: []byte("[]")}},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := NormalizeExtraVolumes(c.raw); got != nil {
+ t.Errorf("NormalizeExtraVolumes(%s) = %+v, want nil", c.name, got)
+ }
+ if got := ExtraVolumesDigest(c.raw); got != "" {
+ t.Errorf("ExtraVolumesDigest(%s) = %q, want \"\"", c.name, got)
+ }
+ })
+ }
+}
+
+// TestNormalizeExtraVolumes_MalformedInputSurvives guards the reject-don't-skip guarantee
+// against a structurally invalid extraVolumes value (a map where a list belongs), not merely an
+// unknown-field typo — json.Unmarshal ignores unknown fields regardless, so catching those is
+// admission's DisallowUnknownFields job, not this function's. Returning nil here would look
+// tidy — "couldn't parse it, so there's nothing to normalize" — but nil is exactly what an
+// admission-disabled cluster's WekaContainer spec would then carry forward: the pod factory
+// would silently build a pod without the user's volumes instead of erroring. Preserving the
+// bytes keeps them reachable by GetExtraVolumes on the pod-build path, where the same malformed
+// input is turned into a real, visible error (see TestApplyExtraVolumes_MalformedInputErrors).
+func TestNormalizeExtraVolumes_MalformedInputSurvives(t *testing.T) {
+ malformed := &runtime.RawExtension{Raw: []byte(`{"not":"a list"}`)}
+ inputCopy := append([]byte(nil), malformed.Raw...)
+
+ normalized := NormalizeExtraVolumes(malformed)
+ if normalized == nil {
+ t.Fatal("NormalizeExtraVolumes(malformed) = nil, want the input preserved (not dropped)")
+ }
+
+ digest := ExtraVolumesDigest(malformed)
+ if digest == "" {
+ t.Fatal("ExtraVolumesDigest(malformed) = \"\", want a non-empty digest distinct from an empty spec")
+ }
+ if emptyDigest := ExtraVolumesDigest(nil); digest == emptyDigest {
+ t.Errorf("ExtraVolumesDigest(malformed) = %q, collides with the empty-spec digest %q", digest, emptyDigest)
+ }
+
+ // The error path must not alias the caller's slice either: mutating the result must not
+ // mutate malformed.Raw.
+ normalized.Raw[0] = 'X'
+ if string(malformed.Raw) != string(inputCopy) {
+ t.Fatal("mutating the result of NormalizeExtraVolumes(malformed) mutated the caller's input")
+ }
+}
+
+// TestApplyExtraVolumes_MalformedInputErrors is the half that actually matters: it proves the
+// error surfaces on the path that builds real pods, so the backstop still fires for a user
+// running with enableAdmissionControl: false and no other layer to catch a structurally invalid
+// extraVolumes value.
+func TestApplyExtraVolumes_MalformedInputErrors(t *testing.T) {
+ malformed := &runtime.RawExtension{Raw: []byte(`{"not":"a list"}`)}
+
+ _, err := createTestPod(t, weka.WekaContainerModeCompute, func(spec *weka.WekaContainerSpec) {
+ spec.ExtraVolumes = malformed
+ })
+ if err == nil {
+ t.Fatal("expected applyExtraVolumes (via Create) to error on malformed extraVolumes, got nil")
+ }
+}
+
+// TestNormalizeExtraVolumes_UnknownFieldSurvives is Fix C's guard: strict decoding now treats an
+// unknown field as a decode failure (same branch as MalformedInputSurvives above), so it is
+// preserved as-is instead of silently dropped through a lenient re-marshal — the CRD's
+// PreserveUnknownFields means the API server accepted that field legitimately.
+func TestNormalizeExtraVolumes_UnknownFieldSurvives(t *testing.T) {
+ raw := &runtime.RawExtension{Raw: []byte(`[{"name":"v1","emptyDir":{},"unknownField":"x"}]`)}
+
+ normalized := NormalizeExtraVolumes(raw)
+ if normalized == nil {
+ t.Fatal("NormalizeExtraVolumes(unknown field) = nil, want the input preserved (not dropped)")
+ }
+ if string(normalized.Raw) != string(raw.Raw) {
+ t.Errorf("NormalizeExtraVolumes(unknown field) = %s, want raw bytes preserved unchanged: %s", normalized.Raw, raw.Raw)
+ }
+}
+
+func TestExtraVolumeMountsDigest_EmptyAndNil(t *testing.T) {
+ if got := ExtraVolumeMountsDigest(nil); got != "" {
+ t.Errorf("ExtraVolumeMountsDigest(nil) = %q, want \"\"", got)
+ }
+ if got := ExtraVolumeMountsDigest([]corev1.VolumeMount{}); got != "" {
+ t.Errorf("ExtraVolumeMountsDigest(empty) = %q, want \"\"", got)
+ }
+}
+
+func TestExtraVolumesDigest_WhitespaceAndKeyOrderAreIgnored(t *testing.T) {
+ compact := &runtime.RawExtension{Raw: []byte(
+ `[{"name":"v1","emptyDir":{}}]`,
+ )}
+ reordered := &runtime.RawExtension{Raw: []byte(`
+ [
+ {
+ "emptyDir": {},
+ "name": "v1"
+ }
+ ]
+ `)}
+
+ got1 := ExtraVolumesDigest(compact)
+ got2 := ExtraVolumesDigest(reordered)
+ if got1 == "" {
+ t.Fatal("expected a non-empty digest")
+ }
+ if got1 != got2 {
+ t.Errorf("digest changed under whitespace/key-order variation: %q != %q", got1, got2)
+ }
+}
+
+// TestExtraVolumesDigest_EmptyDirSizeLimitChangeIsDetected is the exact case gob-based
+// util.HashStruct would miss: resource.Quantity keeps its value in unexported fields, so gob
+// encodes every Quantity identically regardless of its value. The JSON-based digest must not
+// share that blind spot.
+func TestExtraVolumesDigest_EmptyDirSizeLimitChangeIsDetected(t *testing.T) {
+ base := &runtime.RawExtension{Raw: []byte(
+ `[{"name":"v1","emptyDir":{"sizeLimit":"1Gi"}}]`,
+ )}
+ changed := &runtime.RawExtension{Raw: []byte(
+ `[{"name":"v1","emptyDir":{"sizeLimit":"2Gi"}}]`,
+ )}
+
+ got1 := ExtraVolumesDigest(base)
+ got2 := ExtraVolumesDigest(changed)
+ if got1 == "" || got2 == "" {
+ t.Fatal("expected non-empty digests")
+ }
+ if got1 == got2 {
+ t.Error("digest did not change when emptyDir.sizeLimit changed")
+ }
+}
+
+func TestExtraVolumesDigest_CsiVolumeAttributesChangeIsDetected(t *testing.T) {
+ base := &runtime.RawExtension{Raw: []byte(
+ `[{"name":"v1","csi":{"driver":"example.csi","volumeAttributes":{"key":"a"}}}]`,
+ )}
+ changed := &runtime.RawExtension{Raw: []byte(
+ `[{"name":"v1","csi":{"driver":"example.csi","volumeAttributes":{"key":"b"}}}]`,
+ )}
+
+ got1 := ExtraVolumesDigest(base)
+ got2 := ExtraVolumesDigest(changed)
+ if got1 == "" || got2 == "" {
+ t.Fatal("expected non-empty digests")
+ }
+ if got1 == got2 {
+ t.Error("digest did not change when csi.volumeAttributes changed")
+ }
+}
+
+func TestNormalizeExtraVolumes_DoesNotAliasInput(t *testing.T) {
+ input := &runtime.RawExtension{Raw: []byte(`[{"name":"v1","emptyDir":{}}]`)}
+ inputCopy := append([]byte(nil), input.Raw...)
+
+ got := NormalizeExtraVolumes(input)
+ if got == nil {
+ t.Fatal("expected a non-nil normalized result")
+ }
+ if &got.Raw[0] == &input.Raw[0] {
+ t.Fatal("NormalizeExtraVolumes aliased the input's byte slice")
+ }
+
+ // Mutating the returned bytes must not affect the caller's original RawExtension.
+ got.Raw[0] = 'X'
+ if !strings.HasPrefix(string(input.Raw), string(inputCopy[:1])) {
+ t.Fatal("mutating the normalized result mutated the caller's input")
+ }
+}
+
+// collectVolumeAndMountNames walks pod for every volume/mount name the operator itself assigns:
+// pod.Spec.Volumes, plus every VolumeMount on every container and init container. Container names
+// (e.g. "copy-cli" the init container, "otel-packages-installer", "uio-loader-init") are
+// deliberately excluded: extraVolumes can never collide with a container name, only with a
+// volume/mount name, and ReservedVolumeNames exists to protect the latter.
+func collectVolumeAndMountNames(pod *corev1.Pod) []string {
+ var names []string
+ for _, v := range pod.Spec.Volumes {
+ names = append(names, v.Name)
+ }
+ for _, c := range pod.Spec.Containers {
+ for _, m := range c.VolumeMounts {
+ names = append(names, m.Name)
+ }
+ }
+ for _, c := range pod.Spec.InitContainers {
+ for _, m := range c.VolumeMounts {
+ names = append(names, m.Name)
+ }
+ }
+ return names
+}
+
+// collectMountPaths walks pod for every VolumeMount path the operator itself creates, across the
+// main container and every init container.
+func collectMountPaths(pod *corev1.Pod) []string {
+ var paths []string
+ for _, c := range pod.Spec.Containers {
+ for _, m := range c.VolumeMounts {
+ paths = append(paths, m.MountPath)
+ }
+ }
+ for _, c := range pod.Spec.InitContainers {
+ for _, m := range c.VolumeMounts {
+ paths = append(paths, m.MountPath)
+ }
+ }
+ return paths
+}
+
+// assertAllReserved fails with the required drift-guard message for any volume/mount name or
+// mount path PodFactory.Create emitted that IsReservedVolumeName/IsReservedMountPath does not
+// recognize. The mount-path check is what would have caught #2: the name-only check has always
+// passed even when a mount path (e.g. /opt/weka_runtime.py) was left uncovered.
+func assertAllReserved(t *testing.T, caseName string, pod *corev1.Pod) {
+ t.Helper()
+ for _, name := range collectVolumeAndMountNames(pod) {
+ if !IsReservedVolumeName(name) {
+ t.Errorf("%s: a new operator volume was added without reserving its name; user extraVolumes could now collide with it (volume/mount name %q)", caseName, name)
+ }
+ }
+ for _, p := range collectMountPaths(pod) {
+ if !IsReservedMountPath(p) {
+ t.Errorf("%s: a new operator mount path was added without reserving it; user extraVolumeMounts could now collide with it (mount path %q)", caseName, p)
+ }
+ }
+}
+
+// TestOperatorVolumesAreAllReserved is the drift guard for ReservedVolumeNames: it builds real
+// pods (via PodFactory.Create, not a hand-maintained approximation of it) across every mode/config
+// combination known to add its own volumes or mounts, and asserts every resulting volume and
+// mount name is covered by IsReservedVolumeName. A future change that adds an operator-managed
+// volume without reserving its name breaks this test instead of silently leaving a name that
+// extraVolumes could collide with.
+//
+// Cases and why each is here:
+// - compute: the baseline pod, no mode-specific volumes.
+// - smbw: adds smbw-shm (requires config.Config.Smbw.ShmSize, ConfigureEnv's usual source of it
+// does not run in a unit test).
+// - discovery: a distinct mode with its own base-volume wiring.
+// - drivers-loader, non-COS node: setDriverDependencies' non-COS branch (libmodules, usrsrc).
+// - drivers-loader, COS node: setDriverDependencies' COS branch (host-modules via
+// addUIOLoaderInitContainer, proc-sysrq-trigger, proc-cmdline).
+// - drivers-builder, COS node: COS branch plus IsDriversBuilder()'s extra gcloud-credentials.
+// - drivers-loader with a copy-weka-files-to-driver-loader instruction: copyWekaVersionToContainer
+// (shared-weka-version), reached unconditionally off IsDriversContainer() regardless of COS.
+// - ssdproxy: needsProxyMount's first disjunct (weka-proxy-socket-dir).
+// - drive with DriveCapacity>0 (UsesDriveSharing): needsProxyMount's second disjunct, same
+// volume name, different mode.
+// - adhoc-op with a sign-drives instruction carrying ssd_proxy_container_uuid:
+// getSsdUidForAdhocOp's consumer (weka-ssdproxy-local-socket).
+// - otel packages installer configured: the otel-packages-installer init container's volume
+// (otel-packages), gated by config.Config.Otel.PythonPackagesInstallerImage.
+func TestOperatorVolumesAreAllReserved(t *testing.T) {
+ t.Run("compute", func(t *testing.T) {
+ pod, err := createTestPod(t, weka.WekaContainerModeCompute, nil)
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "compute", pod)
+ })
+
+ t.Run("smbw", func(t *testing.T) {
+ originalShmSize := config.Config.Smbw.ShmSize
+ config.Config.Smbw.ShmSize = "8Gi"
+ defer func() { config.Config.Smbw.ShmSize = originalShmSize }()
+
+ pod, err := createTestPod(t, weka.WekaContainerModeSmbw, nil)
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "smbw", pod)
+ })
+
+ t.Run("discovery", func(t *testing.T) {
+ pod, err := createTestPod(t, weka.WekaContainerModeDiscovery, nil)
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "discovery", pod)
+ })
+
+ t.Run("drivers-loader non-COS", func(t *testing.T) {
+ pod, err := createTestPodOnNode(t, weka.WekaContainerModeDriversLoader, &discovery.DiscoveryNodeInfo{}, nil)
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "drivers-loader non-COS", pod)
+ })
+
+ t.Run("drivers-loader COS", func(t *testing.T) {
+ nodeInfo := &discovery.DiscoveryNodeInfo{Os: weka.OsNameCos}
+ pod, err := createTestPodOnNode(t, weka.WekaContainerModeDriversLoader, nodeInfo, nil)
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "drivers-loader COS", pod)
+ })
+
+ t.Run("drivers-builder COS", func(t *testing.T) {
+ nodeInfo := &discovery.DiscoveryNodeInfo{Os: weka.OsNameCos}
+ pod, err := createTestPodOnNode(t, weka.WekaContainerModeDriversBuilder, nodeInfo, nil)
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "drivers-builder COS", pod)
+ })
+
+ t.Run("drivers-loader with copy-weka-files instruction", func(t *testing.T) {
+ pod, err := createTestPod(t, weka.WekaContainerModeDriversLoader, func(spec *weka.WekaContainerSpec) {
+ spec.Instructions = &weka.Instructions{
+ Type: weka.InstructionCopyWekaFilesToDriverLoader,
+ Payload: `{"targetImage":"img:1","cliImage":"img:1"}`,
+ }
+ })
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "drivers-loader with copy-weka-files instruction", pod)
+ })
+
+ t.Run("ssdproxy", func(t *testing.T) {
+ pod, err := createTestPod(t, weka.WekaContainerModeSSDProxy, nil)
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "ssdproxy", pod)
+ })
+
+ t.Run("drive with drive sharing", func(t *testing.T) {
+ pod, err := createTestPod(t, weka.WekaContainerModeDrive, func(spec *weka.WekaContainerSpec) {
+ spec.DriveCapacity = 100
+ })
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "drive with drive sharing", pod)
+ })
+
+ t.Run("adhoc-op sign-drives with ssd proxy uuid", func(t *testing.T) {
+ pod, err := createTestPod(t, weka.WekaContainerModeAdhocOp, func(spec *weka.WekaContainerSpec) {
+ spec.Instructions = &weka.Instructions{
+ Type: weka.InstructionTypeSignDrives,
+ Payload: `{"ssd_proxy_container_uuid":"11111111-1111-1111-1111-111111111111"}`,
+ }
+ })
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "adhoc-op sign-drives with ssd proxy uuid", pod)
+ })
+
+ t.Run("otel packages installer configured", func(t *testing.T) {
+ original := config.Config.Otel.PythonPackagesInstallerImage
+ config.Config.Otel.PythonPackagesInstallerImage = "otel-installer:latest"
+ defer func() { config.Config.Otel.PythonPackagesInstallerImage = original }()
+
+ pod, err := createTestPod(t, weka.WekaContainerModeCompute, nil)
+ if err != nil {
+ t.Fatalf("Create returned unexpected error: %v", err)
+ }
+ assertAllReserved(t, "otel packages installer configured", pod)
+ })
+}
diff --git a/internal/controllers/resources/pod.go b/internal/controllers/resources/pod.go
index 19863a8a8..7391ea9df 100644
--- a/internal/controllers/resources/pod.go
+++ b/internal/controllers/resources/pod.go
@@ -940,6 +940,13 @@ echo "=== OTEL Init Container Completed ==="`,
})
}
+ // Runs last, right before affinities: only here has the pod accumulated its mode-dependent
+ // (smbw-shm) and config-dependent (otel-packages) volumes, so the collision scan below sees
+ // the full, final set the operator actually uses rather than a static approximation of it.
+ if err = f.applyExtraVolumes(pod); err != nil {
+ return nil, err
+ }
+
err = f.setAffinities(ctx, pod)
if err != nil {
return nil, err
@@ -950,6 +957,65 @@ echo "=== OTEL Init Container Completed ==="`,
return pod, nil
}
+// applyExtraVolumes appends WekaContainerSpec.ExtraVolumes/ExtraVolumeMounts to the pod. It
+// mounts into the weka container only: init containers run different images with hand-picked
+// mounts and may lack the target directories.
+//
+// A collision is a hard error, not a silent skip: dropping the mount would leave a user's CA
+// absent and weka trusting nothing, so it has to surface rather than degrade quietly.
+func (f *PodFactory) applyExtraVolumes(pod *corev1.Pod) error {
+ volumes, err := f.container.Spec.GetExtraVolumes()
+ if err != nil {
+ return fmt.Errorf("failed to parse extraVolumes: %w", err)
+ }
+ mounts := f.container.Spec.ExtraVolumeMounts
+ if len(volumes) == 0 && len(mounts) == 0 {
+ return nil
+ }
+
+ existingNames := make(map[string]struct{}, len(pod.Spec.Volumes))
+ for _, v := range pod.Spec.Volumes {
+ existingNames[v.Name] = struct{}{}
+ }
+ existingPaths := make(map[string]struct{}, len(pod.Spec.Containers[0].VolumeMounts))
+ for _, m := range pod.Spec.Containers[0].VolumeMounts {
+ existingPaths[path.Clean(m.MountPath)] = struct{}{}
+ }
+
+ declared := make(map[string]struct{}, len(volumes))
+ for _, v := range volumes {
+ // Belt and braces with admission, which a user can disable.
+ if IsReservedVolumeName(v.Name) {
+ return fmt.Errorf("extraVolumes: %q is a reserved volume name", v.Name)
+ }
+ if _, exists := existingNames[v.Name]; exists {
+ return fmt.Errorf("extraVolumes: %q collides with an existing pod volume", v.Name)
+ }
+ if _, dup := declared[v.Name]; dup {
+ return fmt.Errorf("extraVolumes: %q is declared more than once", v.Name)
+ }
+ declared[v.Name] = struct{}{}
+ }
+
+ for _, m := range mounts {
+ if _, ok := declared[m.Name]; !ok {
+ return fmt.Errorf("extraVolumeMounts: %q does not name a declared extraVolumes entry", m.Name)
+ }
+ cleanPath := path.Clean(m.MountPath)
+ if IsReservedMountPath(cleanPath) {
+ return fmt.Errorf("extraVolumeMounts: %q is a reserved mount path", m.MountPath)
+ }
+ if _, exists := existingPaths[cleanPath]; exists {
+ return fmt.Errorf("extraVolumeMounts: %q collides with an existing mount path", m.MountPath)
+ }
+ existingPaths[cleanPath] = struct{}{}
+ }
+
+ pod.Spec.Volumes = append(pod.Spec.Volumes, volumes...)
+ pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, mounts...)
+ return nil
+}
+
func getUnhealthyTolerations() []corev1.Toleration {
return []corev1.Toleration{
{
diff --git a/internal/controllers/wekaclient/client_numa_test.go b/internal/controllers/wekaclient/client_numa_test.go
index d77c1088e..f70cf67e1 100644
--- a/internal/controllers/wekaclient/client_numa_test.go
+++ b/internal/controllers/wekaclient/client_numa_test.go
@@ -52,7 +52,7 @@ func TestUpdateContainerIfChanged_Numa(t *testing.T) {
ctx := context.Background()
// No numa set: container must stay nil after settle.
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("initial settle failed: %v", err)
}
if container.Spec.Numa != nil {
@@ -65,7 +65,7 @@ func TestUpdateContainerIfChanged_Numa(t *testing.T) {
Region: ®ion1,
Method: weka.WekaNumaMethodDevicePlugin,
}
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("set numa failed: %v", err)
}
if container.Spec.Numa == nil || !container.Spec.Numa.Single || container.Spec.Numa.Region == nil ||
@@ -75,7 +75,7 @@ func TestUpdateContainerIfChanged_Numa(t *testing.T) {
// Clear numa: must also propagate back to nil.
wekaClient.Spec.Numa = nil
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("clear numa failed: %v", err)
}
if container.Spec.Numa != nil {
diff --git a/internal/controllers/wekaclient/client_reconciler_loop.go b/internal/controllers/wekaclient/client_reconciler_loop.go
index 921a13bce..d7dae65d9 100644
--- a/internal/controllers/wekaclient/client_reconciler_loop.go
+++ b/internal/controllers/wekaclient/client_reconciler_loop.go
@@ -6,6 +6,7 @@ import (
"fmt"
"maps"
"reflect"
+ "slices"
"strings"
"time"
@@ -35,6 +36,7 @@ import (
"github.com/weka/weka-operator/internal/controllers/resources"
"github.com/weka/weka-operator/internal/controllers/upgrade"
"github.com/weka/weka-operator/internal/controllers/utils"
+ "github.com/weka/weka-operator/internal/pkg/domain"
"github.com/weka/weka-operator/internal/services"
"github.com/weka/weka-operator/internal/services/discovery"
"github.com/weka/weka-operator/internal/services/exec"
@@ -461,25 +463,34 @@ func (c *clientReconcilerLoop) updateClientLabels(ctx context.Context, expected,
return nil
}
+// clientAdditionalSecrets builds the container's AdditionalSecrets map from the resolved wekaHome
+// cacert secret name. Never nil, so an unset secret compares equal across create and update.
+func clientAdditionalSecrets(wekaHomeCacertSecret string) map[string]string {
+ secrets := map[string]string{}
+ if wekaHomeCacertSecret != "" {
+ secrets["wekahome-cacert"] = wekaHomeCacertSecret
+ }
+ return secrets
+}
+
func (c *clientReconcilerLoop) buildClientWekaContainer(ctx context.Context, nodeName string) (*weka.WekaContainer, error) {
ctx, logger := instrumentation.CreateLogSpan(ctx, "buildClientWekaContainer", "node", nodeName)
defer logger.End()
wekaClient := c.wekaClient
- additionalSecrets := map[string]string{}
+ whCaCert, crossNamespaceSkipped := domain.GetWekaHomeClientCacertSecret(wekaClient, c.targetCluster)
- whCaCert := ""
- if wekaClient.Spec.WekaHome != nil {
- whCaCert = wekaClient.Spec.WekaHome.CacertSecret
- if whCaCert == "" {
- whCaCert = config.Config.WekaHome.CacertSecret
- }
+ if crossNamespaceSkipped {
+ _ = c.RecordEventThrottled(v1.EventTypeWarning, "WekaHomeCacertSecretNotInherited", consts.ActionValidateClientVersion, fmt.Sprintf( //nolint:errcheck // best effort
+ "target cluster %q's wekaHome.cacertSecret %q lives in namespace %q; not inheriting it here because that "+
+ "secret name would not resolve in this client's namespace %q. Set spec.wekaHome.cacertSecret on this "+
+ "WekaClient explicitly if it needs to trust the same CA.",
+ c.targetCluster.Name, c.targetCluster.Spec.WekaHome.CacertSecret, c.targetCluster.Namespace, wekaClient.Namespace,
+ ), time.Minute)
}
- if whCaCert != "" {
- additionalSecrets["wekahome-cacert"] = whCaCert
- }
+ additionalSecrets := clientAdditionalSecrets(whCaCert)
tolerations := util.ExpandTolerations([]v1.Toleration{}, wekaClient.Spec.Tolerations, wekaClient.Spec.RawTolerations)
clientName, err := c.getClientContainerName(ctx, nodeName)
@@ -560,7 +571,10 @@ func (c *clientReconcilerLoop) buildClientWekaContainer(ctx context.Context, nod
NoAffinityConstraints: wekaClient.Spec.GetOverrides().DropAffinityConstraints,
// NodeSelector is propagated from WekaClient for container-level node selector mismatch validation.
// Note: Clients use NodeAffinity for scheduling, but NodeSelector is used for validation.
- NodeSelector: wekaClient.Spec.NodeSelector,
+ NodeSelector: wekaClient.Spec.NodeSelector,
+ ExtraVolumes: resources.NormalizeExtraVolumes(wekaClient.Spec.ExtraVolumes),
+ ExtraVolumeMounts: wekaClient.Spec.ExtraVolumeMounts,
+ PodConfigHash: CalcClientPodConfigVersion(&wekaClient.Spec),
},
}
@@ -615,7 +629,7 @@ func (c *clientReconcilerLoop) resolveJoinIps(ctx context.Context) error {
func (c *clientReconcilerLoop) HandleSpecUpdates(ctx context.Context) error {
logger := instrumentation.CurrentSpanLogger(ctx)
- updatableSpec := NewUpdatableClientSpec(c.wekaClient)
+ updatableSpec := NewUpdatableClientSpec(c.wekaClient, c.targetCluster)
specHash, err := util2.HashStruct(updatableSpec)
if err != nil {
return err
@@ -677,6 +691,16 @@ func (c *clientReconcilerLoop) updateContainerIfChanged(ctx context.Context, con
changed = true
}
+ if resources.ExtraVolumesDigest(container.Spec.ExtraVolumes) != newClientSpec.ExtraVolumesDigest {
+ container.Spec.ExtraVolumes = newClientSpec.ExtraVolumes.DeepCopy()
+ changed = true
+ }
+
+ if resources.ExtraVolumeMountsDigest(container.Spec.ExtraVolumeMounts) != newClientSpec.ExtraVolumeMountsDigest {
+ container.Spec.ExtraVolumeMounts = slices.Clone(newClientSpec.ExtraVolumeMounts)
+ changed = true
+ }
+
if container.Spec.UpgradePolicyType != newClientSpec.UpgradePolicy.Type {
container.Spec.UpgradePolicyType = newClientSpec.UpgradePolicy.Type
if container.Spec.Overrides == nil {
@@ -822,6 +846,13 @@ func (c *clientReconcilerLoop) updateContainerIfChanged(ctx context.Context, con
changed = true
}
+ // Propagate the resolved wekaHome cacertSecret so a change reaches already-running containers,
+ // not only ones created after the change (buildClientWekaContainer only runs on container-create).
+ if container.Spec.AdditionalSecrets["wekahome-cacert"] != newClientSpec.WekaHomeCacertSecret {
+ container.Spec.AdditionalSecrets = clientAdditionalSecrets(newClientSpec.WekaHomeCacertSecret)
+ changed = true
+ }
+
if container.Spec.NoAffinityConstraints != newClientSpec.DropAffinityConstraints {
container.Spec.NoAffinityConstraints = newClientSpec.DropAffinityConstraints
changed = true
@@ -1043,15 +1074,50 @@ func (c *clientReconcilerLoop) HandleUpgrade(ctx context.Context) error {
ctx, logger := instrumentation.CreateLogSpan(ctx, "HandleUpgrade")
defer logger.End()
- // Client upgrade uses image-based tracking (TargetPodConfigHash="" triggers image fallback)
- uController := upgrade.NewUpgradeController(c.Client, c.containers, c.wekaClient.Spec.Image, "")
+ targetPodConfigHash := CalcClientPodConfigVersion(&c.wekaClient.Spec)
+
+ // Nothing to do when the client already carries this pod config. The hash covers the image
+ // too, so an image change produces a new hash and still falls through.
+ if targetPodConfigHash == c.wekaClient.Status.LastAppliedPodConfigHash {
+ return nil
+ }
+
+ // First deploy of pod config version tracking for this client: adopt current state without
+ // rolling, unless allowRotateNonAnnotated is set. Without this, every client that predates
+ // tracking would be patched to the new hash, and its containers could never report it applied
+ // (the pod-level guard in funcs_pod_ensure.go declines to roll a pod that has no
+ // PodConfigVersionAnnotation and never writes the status), so AreUpgraded stays false and the
+ // client sits in Upgrading forever. Mirrors handleUpgrade in the wekacluster controller.
+ if c.wekaClient.Status.LastAppliedPodConfigHash == "" && !config.Config.AllowRotateNonAnnotatedPodConfigHash {
+ logger.Info("Adopting current pod config version (first deploy)", "targetPodConfigHash", targetPodConfigHash)
+ // The containers are adopted too, not just the client. handleSpecVersionMismatch declines to
+ // roll a pod whose annotation and container status are both empty, so a client adopted only
+ // at the parent level would, on its next real config change, get podConfigHash patched onto
+ // its containers, never roll their pods, and sit in Upgrading forever.
+ for _, container := range c.containers {
+ if container.Status.LastAppliedPodConfigHash != "" {
+ continue
+ }
+ container.Status.LastAppliedPodConfigHash = targetPodConfigHash
+ if err := c.Status().Update(ctx, container); err != nil {
+ return fmt.Errorf("failed to adopt pod config version on container %s: %w", container.Name, err)
+ }
+ }
+ c.wekaClient.Status.LastAppliedPodConfigHash = targetPodConfigHash
+ return c.Status().Update(ctx, c.wekaClient)
+ }
+
+ uController := upgrade.NewUpgradeController(c.Client, c.containers, c.wekaClient.Spec.Image, targetPodConfigHash)
if uController.AreUpgraded() {
// Clear pre-pull annotation after successful upgrade
err := c.clearPrePullAnnotationForClient(ctx)
if err != nil {
logger.Warn("Failed to clear pre-pull annotation", "error", err)
}
- return nil
+ // Record the applied config so the next reconcile short-circuits above instead of
+ // re-running the upgrade path, matching the wekacluster controller.
+ c.wekaClient.Status.LastAppliedPodConfigHash = targetPodConfigHash
+ return c.Status().Update(ctx, c.wekaClient)
}
if config.Config.Upgrade.ImagePrePullEnabled {
@@ -1244,7 +1310,11 @@ type UpdatableClientSpec struct {
Annotations *util2.HashableMap
// NodeSelector is propagated to client containers for container-level node selector
// mismatch validation. Not used for scheduling (clients use NodeAffinity).
- NodeSelector *util2.HashableMap
+ NodeSelector *util2.HashableMap
+ // WekaHomeCacertSecret is the resolved wekaHome cacertSecret (see GetWekaHomeClientCacertSecret),
+ // carried here so a change is visible in specHash and reaches already-running containers, not
+ // only ones created afterwards.
+ WekaHomeCacertSecret string
AutoRemoveTimeout metav1.Duration
ForceDrain bool
SkipActiveMountsCheck bool
@@ -1260,6 +1330,16 @@ type UpdatableClientSpec struct {
// resource.Quantity keeps its value in unexported fields, so without a textual form a
// changed quantity leaves the spec hash identical and the update never fires.
ResourcesDigest string
+ // ExtraVolumes is normalized (see resources.NormalizeExtraVolumes) so an unset/null/empty
+ // spec always compares equal across reconciles.
+ ExtraVolumes *runtime.RawExtension
+ // ExtraVolumesDigest/ExtraVolumeMountsDigest carry the volumes/mounts as text for the same
+ // reason ResourcesDigest does: HashStruct gob-encodes this struct, which hard-errors on the
+ // map hiding inside a CSI volumeAttributes and silently ignores a Quantity's unexported
+ // fields, so either form would make ExtraVolumes/ExtraVolumeMounts unsafe or unhashable here.
+ ExtraVolumesDigest string
+ ExtraVolumeMounts []v1.VolumeMount
+ ExtraVolumeMountsDigest string
}
// podResourcesDigest renders a resources spec as text for hashing and comparison. Unset and
@@ -1289,11 +1369,13 @@ func normalizePodResources(r *weka.PodResourcesSpec) *weka.PodResourcesSpec {
return r
}
-func NewUpdatableClientSpec(wekaClient *weka.WekaClient) *UpdatableClientSpec {
+func NewUpdatableClientSpec(wekaClient *weka.WekaClient, targetCluster *weka.WekaCluster) *UpdatableClientSpec {
labels := util2.NewHashableMap(factory.BuildClientContainerLabels(wekaClient))
spec := wekaClient.Spec
meta := wekaClient.ObjectMeta
+ whCaCert, _ := domain.GetWekaHomeClientCacertSecret(wekaClient, targetCluster)
+
return &UpdatableClientSpec{
DriversDistService: spec.DriversDistService,
DriversBuildId: spec.GetOverrides().DriversBuildId,
@@ -1314,6 +1396,7 @@ func NewUpdatableClientSpec(wekaClient *weka.WekaClient) *UpdatableClientSpec {
Labels: labels,
Annotations: util2.NewHashableMap(util2.RemoveKeysStartingWithPrefix(meta.Annotations, "weka.io/prepull-")),
NodeSelector: util2.NewHashableMap(spec.NodeSelector),
+ WekaHomeCacertSecret: whCaCert,
AutoRemoveTimeout: spec.AutoRemoveTimeout,
ForceDrain: spec.GetOverrides().ForceDrain,
SkipActiveMountsCheck: spec.GetOverrides().SkipActiveMountsCheck,
@@ -1326,6 +1409,10 @@ func NewUpdatableClientSpec(wekaClient *weka.WekaClient) *UpdatableClientSpec {
DpdkBaseMemoryMb: spec.GetOverrides().DpdkBaseMemoryMb,
Resources: normalizePodResources(spec.Resources),
ResourcesDigest: podResourcesDigest(spec.Resources),
+ ExtraVolumes: resources.NormalizeExtraVolumes(spec.ExtraVolumes),
+ ExtraVolumesDigest: resources.ExtraVolumesDigest(spec.ExtraVolumes),
+ ExtraVolumeMounts: spec.ExtraVolumeMounts,
+ ExtraVolumeMountsDigest: resources.ExtraVolumeMountsDigest(spec.ExtraVolumeMounts),
}
}
diff --git a/internal/controllers/wekaclient/client_reconciler_loop_test.go b/internal/controllers/wekaclient/client_reconciler_loop_test.go
index 2487526ab..90b4c79d6 100644
--- a/internal/controllers/wekaclient/client_reconciler_loop_test.go
+++ b/internal/controllers/wekaclient/client_reconciler_loop_test.go
@@ -4,6 +4,7 @@ import (
"context"
"testing"
+ "github.com/weka/go-steps-engine/lifecycle"
weka "github.com/weka/weka-k8s-api/api/v1alpha1"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
@@ -11,6 +12,8 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ util2 "github.com/weka/weka-operator/pkg/util"
)
// TestUpdateContainerIfChanged_ExtraCores asserts that changing extraCores on a WekaClient, in
@@ -54,7 +57,7 @@ func TestUpdateContainerIfChanged_ExtraCores(t *testing.T) {
ctx := context.Background()
// Settle the container to match the initial wekaClient spec (extraCores=1).
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("initial settle failed: %v", err)
}
if container.Spec.ExtraCores != 1 {
@@ -63,7 +66,7 @@ func TestUpdateContainerIfChanged_ExtraCores(t *testing.T) {
// Increase extraCores: must propagate.
wekaClient.Spec.ExtraCores = 3
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("increase failed: %v", err)
}
if container.Spec.ExtraCores != 3 {
@@ -72,7 +75,7 @@ func TestUpdateContainerIfChanged_ExtraCores(t *testing.T) {
// Decrease extraCores: must also propagate (unlike coresNum, decreasing is allowed).
wekaClient.Spec.ExtraCores = 0
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("decrease failed: %v", err)
}
if container.Spec.ExtraCores != 0 {
@@ -123,7 +126,7 @@ func TestUpdateContainerIfChanged_Resources(t *testing.T) {
ctx := context.Background()
// Settle the container to match the initial wekaClient spec (memory request 4Gi).
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("initial settle failed: %v", err)
}
if container.Spec.Resources == nil || container.Spec.Resources.Requests.Memory.Cmp(resource.MustParse("4Gi")) != 0 {
@@ -134,7 +137,7 @@ func TestUpdateContainerIfChanged_Resources(t *testing.T) {
wekaClient.Spec.Resources = &weka.PodResourcesSpec{
Requests: weka.PodResources{Memory: resource.MustParse("64Gi")},
}
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("update failed: %v", err)
}
if container.Spec.Resources.Requests.Memory.Cmp(resource.MustParse("64Gi")) != 0 {
@@ -142,7 +145,7 @@ func TestUpdateContainerIfChanged_Resources(t *testing.T) {
}
// Re-settle with the identical spec: must be a no-op (no error, value unchanged).
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("no-op re-settle failed: %v", err)
}
if container.Spec.Resources.Requests.Memory.Cmp(resource.MustParse("64Gi")) != 0 {
@@ -186,7 +189,7 @@ func TestUpdateContainerIfChanged_ResourcesNilVsEmptyNoChurn(t *testing.T) {
ctx := context.Background()
// Settle with nil resources: container stays nil.
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("initial settle (nil resources) failed: %v", err)
}
if container.Spec.Resources != nil {
@@ -195,7 +198,7 @@ func TestUpdateContainerIfChanged_ResourcesNilVsEmptyNoChurn(t *testing.T) {
// Client now specifies an explicit empty struct: must not churn the container.
wekaClient.Spec.Resources = &weka.PodResourcesSpec{}
- if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient)); err != nil {
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
t.Fatalf("settle with empty-struct resources failed: %v", err)
}
if container.Spec.Resources != nil {
@@ -229,3 +232,250 @@ func TestPodResourcesDigest_NilAndEmptyAreEqual(t *testing.T) {
t.Fatalf("expected podResourcesDigest to be stable across calls: %q != %q", again, nonZeroDigest)
}
}
+
+// TestUpdateContainerIfChanged_ExtraVolumes asserts that changing WekaClient.Spec.ExtraVolumes/
+// ExtraVolumeMounts propagates to an existing WekaContainer, and that re-settling with an
+// unchanged spec is a no-op (no churn).
+func TestUpdateContainerIfChanged_ExtraVolumes(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := weka.AddToScheme(scheme); err != nil {
+ t.Fatalf("failed to add weka scheme: %v", err)
+ }
+
+ wekaClient := &weka.WekaClient{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-client", Namespace: "default"},
+ Spec: weka.WekaClientSpec{CoresNumber: 2},
+ }
+
+ container := &weka.WekaContainer{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-client-container", Namespace: "default"},
+ Spec: weka.WekaContainerSpec{
+ WekaSecretRef: v1.EnvVarSource{SecretKeyRef: &v1.SecretKeySelector{}},
+ },
+ }
+
+ fakeClient := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(container).
+ Build()
+
+ c := &clientReconcilerLoop{
+ Client: fakeClient,
+ Recorder: events.NewFakeRecorder(10),
+ wekaClient: wekaClient,
+ }
+
+ ctx := context.Background()
+
+ // Settle with no extra volumes configured: must remain unset, no churn.
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
+ t.Fatalf("initial settle failed: %v", err)
+ }
+ if container.Spec.ExtraVolumes != nil || len(container.Spec.ExtraVolumeMounts) != 0 {
+ t.Fatalf("expected no extra volumes after initial settle, got volumes=%+v mounts=%+v", container.Spec.ExtraVolumes, container.Spec.ExtraVolumeMounts)
+ }
+
+ // Add extra volumes/mounts on the client: must propagate.
+ wekaClient.Spec.ExtraVolumes = &runtime.RawExtension{
+ Raw: []byte(`[{"name":"ca-bundle","secret":{"secretName":"ca-bundle"}}]`),
+ }
+ wekaClient.Spec.ExtraVolumeMounts = []v1.VolumeMount{
+ {Name: "ca-bundle", MountPath: "/etc/ssl/ca-bundle"},
+ }
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
+ t.Fatalf("update failed: %v", err)
+ }
+ if container.Spec.ExtraVolumes == nil || len(container.Spec.ExtraVolumeMounts) != 1 {
+ t.Fatalf("expected extra volumes/mounts to propagate, got volumes=%+v mounts=%+v", container.Spec.ExtraVolumes, container.Spec.ExtraVolumeMounts)
+ }
+ settledVolumesRaw := string(container.Spec.ExtraVolumes.Raw)
+
+ // Re-settle with the identical spec: must be a no-op (value unchanged, no churn).
+ if err := c.updateContainerIfChanged(ctx, container, NewUpdatableClientSpec(wekaClient, c.targetCluster)); err != nil {
+ t.Fatalf("no-op re-settle failed: %v", err)
+ }
+ if string(container.Spec.ExtraVolumes.Raw) != settledVolumesRaw {
+ t.Fatalf("expected extra volumes to remain unchanged after no-op re-settle, got %s", container.Spec.ExtraVolumes.Raw)
+ }
+ if len(container.Spec.ExtraVolumeMounts) != 1 || container.Spec.ExtraVolumeMounts[0].Name != "ca-bundle" {
+ t.Fatalf("expected extra volume mounts to remain unchanged after no-op re-settle, got %+v", container.Spec.ExtraVolumeMounts)
+ }
+}
+
+// TestNewUpdatableClientSpec_HashStructSurvivesCsiVolumeAttributesMap is the critical regression
+// test for this feature: util.HashStruct hard-errors on any map reachable through the struct it
+// hashes (pkg/util/hashes.go), and a CSI volume's volumeAttributes is exactly such a map. Because
+// UpdatableClientSpec carries ExtraVolumes as *runtime.RawExtension (Raw is []byte, Object a nil
+// interface) rather than a typed []corev1.Volume, that map never reaches HashStruct's reflection
+// walk. If this test starts failing, HandleSpecUpdates would hard-error in production for the
+// first WekaClient with a CSI extra volume.
+func TestNewUpdatableClientSpec_HashStructSurvivesCsiVolumeAttributesMap(t *testing.T) {
+ wekaClient := &weka.WekaClient{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-client", Namespace: "default"},
+ Spec: weka.WekaClientSpec{
+ CoresNumber: 2,
+ ExtraVolumes: &runtime.RawExtension{
+ Raw: []byte(`[{"name":"csi-vol","csi":{"driver":"csi.example.com","volumeAttributes":{"key1":"value1","key2":"value2"}}}]`),
+ },
+ },
+ }
+
+ updatableSpec := NewUpdatableClientSpec(wekaClient, nil)
+ if _, err := util2.HashStruct(updatableSpec); err != nil {
+ t.Fatalf("HashStruct must not error on a CSI extra volume with volumeAttributes, got: %v", err)
+ }
+}
+
+// TestHandleUpgrade_AdoptsPodConfigHashWithoutRolling covers the transition where a WekaClient
+// that predates pod-config-version tracking first meets an operator that has it.
+//
+// Without the adoption guard, HandleUpgrade computes a non-empty hash, the upgrade controller
+// patches it onto every container, and those containers can never report it applied: the pod-level
+// guard in funcs_pod_ensure.go declines to roll a pod carrying no PodConfigVersionAnnotation and
+// returns before writing LastAppliedPodConfigHash. AreUpgraded() then stays false forever, so the
+// client is pinned in Upgrading and HandleUpgrade errors on every reconcile. This mirrors the
+// wekacluster controller's first-deploy adoption.
+//
+// Adoption stamps the containers' status as well as the client's, without touching their spec.
+// Adopting only the client leaves that same pod-level guard armed - both the pod annotation and
+// the container status stay empty - so the *next* real config change patches podConfigHash onto
+// the containers, still fails to roll their pods, and strands the client after all.
+func TestHandleUpgrade_AdoptsPodConfigHashWithoutRolling(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := weka.AddToScheme(scheme); err != nil {
+ t.Fatalf("failed to add weka scheme: %v", err)
+ }
+
+ wekaClient := &weka.WekaClient{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-client", Namespace: "default"},
+ Spec: weka.WekaClientSpec{Image: "quay.io/weka.io/weka-in-container:4.5.0"},
+ }
+ // Pre-existing container: hash never set, pod predates the annotation.
+ container := &weka.WekaContainer{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-client-container", Namespace: "default"},
+ Spec: weka.WekaContainerSpec{Image: wekaClient.Spec.Image},
+ }
+
+ fakeClient := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(wekaClient, container).
+ WithStatusSubresource(wekaClient, container).
+ Build()
+
+ c := &clientReconcilerLoop{
+ Client: fakeClient,
+ Recorder: events.NewFakeRecorder(10),
+ wekaClient: wekaClient,
+ containers: []*weka.WekaContainer{container},
+ }
+
+ ctx := context.Background()
+ want := CalcClientPodConfigVersion(&wekaClient.Spec)
+
+ if err := c.HandleUpgrade(ctx); err != nil {
+ t.Fatalf("first deploy must adopt without erroring, got: %v", err)
+ }
+ if wekaClient.Status.LastAppliedPodConfigHash != want {
+ t.Fatalf("expected adoption to record %q, got %q", want, wekaClient.Status.LastAppliedPodConfigHash)
+ }
+ // Adoption must not touch container *spec* - patching that is what strands the client.
+ if container.Spec.PodConfigHash != "" {
+ t.Fatalf("adoption must not patch container podConfigHash, got %q", container.Spec.PodConfigHash)
+ }
+ // It must stamp container *status*, which is what disarms the pod-level non-annotated guard
+ // so a later config change can actually roll the pods.
+ if container.Status.LastAppliedPodConfigHash != want {
+ t.Fatalf("adoption must stamp container status with %q, got %q", want, container.Status.LastAppliedPodConfigHash)
+ }
+
+ // Subsequent reconciles short-circuit instead of re-entering the upgrade path.
+ if err := c.HandleUpgrade(ctx); err != nil {
+ t.Fatalf("steady-state reconcile must be a no-op, got: %v", err)
+ }
+ if c.upgradeInProgress {
+ t.Error("client must not be marked upgrading in steady state")
+ }
+}
+
+// TestHandleSpecUpdates_ImageChangeAdvancesContainerImage is the regression test for the bug where
+// updateContainerIfChanged used to stamp container.Spec.PodConfigHash to match the new spec's hash
+// directly. Since that hash already covers the new image, doing so made
+// upgrade.UpgradeController.isContainerAligned (which prefers the hash comparison whenever
+// TargetPodConfigHash is set) report the container as already aligned before HandleUpgrade ever
+// patched Spec.Image, so UpdateContainer short-circuited and a plain image upgrade never
+// progressed. Only the upgrade path (via UpdateContainer's specPatch) may write Spec.PodConfigHash,
+// always alongside the image patch.
+func TestHandleSpecUpdates_ImageChangeAdvancesContainerImage(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := weka.AddToScheme(scheme); err != nil {
+ t.Fatalf("failed to add weka scheme: %v", err)
+ }
+
+ oldImage := "quay.io/weka.io/weka-in-container:4.5.0"
+ newImage := "quay.io/weka.io/weka-in-container:4.6.0"
+
+ wekaClient := &weka.WekaClient{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-client", Namespace: "default"},
+ Spec: weka.WekaClientSpec{Image: oldImage},
+ }
+ oldHash := CalcClientPodConfigVersion(&wekaClient.Spec)
+ wekaClient.Status.LastAppliedPodConfigHash = oldHash
+
+ // Pre-existing container, fully settled on the old image before the upgrade begins.
+ container := &weka.WekaContainer{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-client-container", Namespace: "default"},
+ Spec: weka.WekaContainerSpec{
+ Image: oldImage,
+ PodConfigHash: oldHash,
+ // updateContainerIfChanged dereferences SecretKeyRef unconditionally.
+ WekaSecretRef: v1.EnvVarSource{SecretKeyRef: &v1.SecretKeySelector{}},
+ },
+ Status: weka.WekaContainerStatus{
+ LastAppliedImage: oldImage,
+ LastAppliedPodConfigHash: oldHash,
+ },
+ }
+
+ fakeClient := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(wekaClient, container).
+ WithStatusSubresource(wekaClient, container).
+ Build()
+
+ c := &clientReconcilerLoop{
+ Client: fakeClient,
+ Recorder: events.NewFakeRecorder(10),
+ wekaClient: wekaClient,
+ containers: []*weka.WekaContainer{container},
+ }
+
+ ctx := context.Background()
+
+ // Change the image, as a plain image upgrade would. Must also be persisted to the fake client:
+ // HandleSpecUpdates's own Status().Update() below re-syncs c.wekaClient's Spec from the tracker,
+ // so an in-memory-only Spec change would otherwise be clobbered back to the old image.
+ wekaClient.Spec.Image = newImage
+ if err := fakeClient.Update(ctx, wekaClient); err != nil {
+ t.Fatalf("failed to persist image change: %v", err)
+ }
+
+ if err := c.HandleSpecUpdates(ctx); err != nil {
+ t.Fatalf("HandleSpecUpdates failed: %v", err)
+ }
+ // HandleSpecUpdates must not itself advance the container to the new image or hash - only the
+ // upgrade path may do that, alongside each other.
+ if container.Spec.Image != oldImage {
+ t.Fatalf("HandleSpecUpdates must not patch Spec.Image, got %q", container.Spec.Image)
+ }
+
+ if err := c.HandleUpgrade(ctx); err != nil {
+ if _, ok := err.(*lifecycle.ExpectedError); !ok {
+ t.Fatalf("HandleUpgrade returned unexpected error: %v", err)
+ }
+ }
+
+ if container.Spec.Image != newImage {
+ t.Fatalf("expected container Spec.Image to advance to %q, got %q (PodConfigHash=%q)",
+ newImage, container.Spec.Image, container.Spec.PodConfigHash)
+ }
+}
diff --git a/internal/controllers/wekaclient/pod_config_version.go b/internal/controllers/wekaclient/pod_config_version.go
new file mode 100644
index 000000000..c982d60f9
--- /dev/null
+++ b/internal/controllers/wekaclient/pod_config_version.go
@@ -0,0 +1,45 @@
+package wekaclient
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+
+ weka "github.com/weka/weka-k8s-api/api/v1alpha1"
+
+ "github.com/weka/weka-operator/internal/config"
+ "github.com/weka/weka-operator/internal/consts"
+ "github.com/weka/weka-operator/internal/controllers/resources"
+)
+
+// ClientPodConfigVersionInputs defines the fields tracked for spec version calculation.
+// Changing any of these fields produces a different spec version hash,
+// which triggers coordinated rolling pod rotation for all containers owned by the client.
+type ClientPodConfigVersionInputs struct {
+ // Operator-level constants
+ PodConfigVersion string `json:"podConfigVersion"`
+ WekaRuntimeVersion string `json:"wekaRuntimeVersion"`
+
+ // WekaClientSpec fields
+ Image string `json:"image"`
+
+ // ExtraVolumes/ExtraVolumeMounts digests. omitempty keeps the hash unchanged for clients
+ // not using the feature, so upgrading the operator does not force a pod roll.
+ ExtraVolumesDigest string `json:"extraVolumesDigest,omitempty"`
+ ExtraVolumeMountsDigest string `json:"extraVolumeMountsDigest,omitempty"`
+}
+
+func CalcClientPodConfigVersion(spec *weka.WekaClientSpec) string {
+ inputs := ClientPodConfigVersionInputs{
+ PodConfigVersion: config.Config.PodConfigVersion,
+ Image: spec.Image,
+ ExtraVolumesDigest: resources.ExtraVolumesDigest(spec.ExtraVolumes),
+ ExtraVolumeMountsDigest: resources.ExtraVolumeMountsDigest(spec.ExtraVolumeMounts),
+ }
+ if config.Config.EnablePodConfigCodeVersionRotation {
+ inputs.WekaRuntimeVersion = consts.PodConfigCodeVersion
+ }
+ raw, _ := json.Marshal(inputs) //nolint:errcheck // json.Marshal cannot fail on a plain struct
+ hash := sha256.Sum256(raw)
+ return fmt.Sprintf("%x", hash)[:8]
+}
diff --git a/internal/controllers/wekaclient/pod_config_version_test.go b/internal/controllers/wekaclient/pod_config_version_test.go
new file mode 100644
index 000000000..3f13674c6
--- /dev/null
+++ b/internal/controllers/wekaclient/pod_config_version_test.go
@@ -0,0 +1,125 @@
+package wekaclient
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ weka "github.com/weka/weka-k8s-api/api/v1alpha1"
+ v1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+
+ "github.com/weka/weka-operator/internal/config"
+)
+
+func TestCalcClientPodConfigVersion_ToggleWekaRuntimeVersionRotation(t *testing.T) {
+ config.Config.PodConfigVersion = "1"
+ spec := &weka.WekaClientSpec{Image: "quay.io/weka.io/weka-in-container:4.5.0"}
+
+ config.Config.EnablePodConfigCodeVersionRotation = false
+ hashWithout := CalcClientPodConfigVersion(spec)
+
+ config.Config.EnablePodConfigCodeVersionRotation = true
+ hashWith := CalcClientPodConfigVersion(spec)
+
+ if hashWithout == hashWith {
+ t.Errorf("enabling EnablePodConfigCodeVersionRotation should change the hash, got %s == %s", hashWithout, hashWith)
+ }
+}
+
+func TestCalcClientPodConfigVersion_StableWhenDisabled(t *testing.T) {
+ config.Config.PodConfigVersion = "1"
+ config.Config.EnablePodConfigCodeVersionRotation = false
+ spec := &weka.WekaClientSpec{Image: "quay.io/weka.io/weka-in-container:4.5.0"}
+
+ hash1 := CalcClientPodConfigVersion(spec)
+ hash2 := CalcClientPodConfigVersion(spec)
+
+ if hash1 != hash2 {
+ t.Errorf("hash should be stable across calls, got %s != %s", hash1, hash2)
+ }
+}
+
+func TestCalcClientPodConfigVersion_ChangesWhenImageChanges(t *testing.T) {
+ config.Config.PodConfigVersion = "1"
+ config.Config.EnablePodConfigCodeVersionRotation = false
+
+ spec1 := &weka.WekaClientSpec{Image: "quay.io/weka.io/weka-in-container:4.5.0"}
+ spec2 := &weka.WekaClientSpec{Image: "quay.io/weka.io/weka-in-container:4.6.0"}
+
+ if CalcClientPodConfigVersion(spec1) == CalcClientPodConfigVersion(spec2) {
+ t.Error("hash should differ when image changes")
+ }
+}
+
+func TestCalcClientPodConfigVersion_ChangesWhenExtraVolumesChange(t *testing.T) {
+ config.Config.PodConfigVersion = "1"
+ config.Config.EnablePodConfigCodeVersionRotation = false
+
+ baseSpec := &weka.WekaClientSpec{Image: "quay.io/weka.io/weka-in-container:4.5.0"}
+ hashUnset := CalcClientPodConfigVersion(baseSpec)
+
+ specWithVolumes := &weka.WekaClientSpec{
+ Image: "quay.io/weka.io/weka-in-container:4.5.0",
+ ExtraVolumes: &runtime.RawExtension{
+ Raw: []byte(`[{"name":"ca-bundle","secret":{"secretName":"ca-bundle"}}]`),
+ },
+ ExtraVolumeMounts: []v1.VolumeMount{
+ {Name: "ca-bundle", MountPath: "/etc/ssl/ca-bundle"},
+ },
+ }
+ hashWithVolumes := CalcClientPodConfigVersion(specWithVolumes)
+
+ if hashUnset == hashWithVolumes {
+ t.Error("hash should differ once extraVolumes/extraVolumeMounts are set")
+ }
+
+ specWithOtherVolumes := &weka.WekaClientSpec{
+ Image: "quay.io/weka.io/weka-in-container:4.5.0",
+ ExtraVolumes: &runtime.RawExtension{
+ Raw: []byte(`[{"name":"other","secret":{"secretName":"other"}}]`),
+ },
+ ExtraVolumeMounts: []v1.VolumeMount{
+ {Name: "other", MountPath: "/etc/ssl/other"},
+ },
+ }
+ hashWithOtherVolumes := CalcClientPodConfigVersion(specWithOtherVolumes)
+
+ if hashWithVolumes == hashWithOtherVolumes {
+ t.Error("hash should differ when extraVolumes content changes")
+ }
+}
+
+// TestCalcClientPodConfigVersion_UnchangedWhenExtraVolumesUnset is the no-forced-roll-on-upgrade
+// guarantee: omitempty on the digest fields must keep the hash identical to the pre-feature value
+// for any client not using extra volumes, so upgrading the operator to a version that knows about
+// this feature never rotates pods on its own.
+func TestCalcClientPodConfigVersion_UnchangedWhenExtraVolumesUnset(t *testing.T) {
+ config.Config.PodConfigVersion = "1"
+ config.Config.EnablePodConfigCodeVersionRotation = false
+
+ spec := &weka.WekaClientSpec{Image: "quay.io/weka.io/weka-in-container:4.5.0"}
+ got := CalcClientPodConfigVersion(spec)
+
+ // Pre-feature shape: the two new digest fields didn't exist, so their omitempty tags must
+ // make CalcClientPodConfigVersion's JSON marshal identical to this literal for an unset spec.
+ preFeature := struct {
+ PodConfigVersion string `json:"podConfigVersion"`
+ WekaRuntimeVersion string `json:"wekaRuntimeVersion"`
+ Image string `json:"image"`
+ }{
+ PodConfigVersion: config.Config.PodConfigVersion,
+ Image: spec.Image,
+ }
+ raw, err := json.Marshal(preFeature)
+ if err != nil {
+ t.Fatalf("failed to marshal pre-feature inputs: %v", err)
+ }
+ hash := sha256.Sum256(raw)
+ want := fmt.Sprintf("%x", hash)[:8]
+
+ if got != want {
+ t.Errorf("hash for a spec with no extra volumes must match the pre-feature value %q, got %q", want, got)
+ }
+}
diff --git a/internal/controllers/wekacluster/funcs_upgrade.go b/internal/controllers/wekacluster/funcs_upgrade.go
index e297cf6a1..12470a82c 100644
--- a/internal/controllers/wekacluster/funcs_upgrade.go
+++ b/internal/controllers/wekacluster/funcs_upgrade.go
@@ -19,6 +19,7 @@ import (
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -100,6 +101,12 @@ type UpdatableClusterSpec struct {
NfsHugepages allocator.ContainerHugepages
DataServicesHugepages allocator.ContainerHugepages
SmbwHugepages allocator.ContainerHugepages
+ // ExtraVolumes is stored normalized (see resources.NormalizeExtraVolumes) so its bytes agree
+ // with ExtraVolumesDigest, which HandleSpecUpdates compares against instead of the struct.
+ ExtraVolumes *runtime.RawExtension
+ ExtraVolumesDigest string
+ ExtraVolumeMounts []v1.VolumeMount
+ ExtraVolumeMountsDigest string
}
// forRole returns the role-specific values for a given container mode.
@@ -210,6 +217,10 @@ func NewUpdatableClusterSpec(ctx context.Context, k8sClient client.Client, spec
RoleNetworkSelector: spec.RoleNetworkSelector,
PvcConfig: resources.GetPvcConfig(spec.GlobalPVC),
TracesConfiguration: spec.TracesConfiguration,
+ ExtraVolumes: resources.NormalizeExtraVolumes(clusterForHp.GetRawExtraVolumes()),
+ ExtraVolumesDigest: resources.ExtraVolumesDigest(clusterForHp.GetRawExtraVolumes()),
+ ExtraVolumeMounts: clusterForHp.GetExtraVolumeMounts(),
+ ExtraVolumeMountsDigest: resources.ExtraVolumeMountsDigest(clusterForHp.GetExtraVolumeMounts()),
RoleCoreIds: spec.RoleCoreIds,
RoleNonDatapathCoreIds: spec.RoleNonDatapathCoreIds,
CpuPolicy: spec.CpuPolicy,
@@ -361,6 +372,16 @@ func (r *wekaClusterReconcilerLoop) HandleSpecUpdates(ctx context.Context) error
container.Spec.TracesConfiguration = updatableSpec.TracesConfiguration
}
+ // Compare digests, not the structs: HashStruct's gob encoding cannot see into a
+ // RawExtension's parsed contents, and gob refuses the maps a CSI volume reaches via
+ // volumeAttributes. DeepCopy and slices.Clone are nil-safe, so unset stays unset.
+ if resources.ExtraVolumesDigest(container.Spec.ExtraVolumes) != updatableSpec.ExtraVolumesDigest {
+ container.Spec.ExtraVolumes = updatableSpec.ExtraVolumes.DeepCopy()
+ }
+ if resources.ExtraVolumeMountsDigest(container.Spec.ExtraVolumeMounts) != updatableSpec.ExtraVolumeMountsDigest {
+ container.Spec.ExtraVolumeMounts = slices.Clone(updatableSpec.ExtraVolumeMounts)
+ }
+
if container.IsDriveContainer() {
if updatableSpec.UpgradeForceReplaceDrives { // above check will reset to common flag, so we dont need to put reversal direction here
overrides.UpgradeForceReplace = updatableSpec.UpgradeForceReplaceDrives
diff --git a/internal/controllers/wekacluster/funcs_upgrade_test.go b/internal/controllers/wekacluster/funcs_upgrade_test.go
index af099b700..ea28d296a 100644
--- a/internal/controllers/wekacluster/funcs_upgrade_test.go
+++ b/internal/controllers/wekacluster/funcs_upgrade_test.go
@@ -10,6 +10,7 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -17,6 +18,8 @@ import (
globalconfig "github.com/weka/weka-operator/internal/config"
"github.com/weka/weka-operator/internal/controllers/allocator"
+ "github.com/weka/weka-operator/internal/controllers/resources"
+ "github.com/weka/weka-operator/pkg/util"
)
// fakeManagerWithClient, unlike fakeManagerNilClient (funcs_fd_planning_test.go), returns a real fake
@@ -413,3 +416,148 @@ func TestHandleSpecUpdates_StaticClusterStillPropagatesHugepages(t *testing.T) {
t.Errorf("HugepagesOffset: want propagated template-derived value 728, got %d (static-cluster propagation regressed)", got.Spec.HugepagesOffset)
}
}
+
+// csiExtraVolumeRaw is a CSI volume with a volumeAttributes map — the shape that would break
+// util.HashStruct (which hard-errors on any map field) if UpdatableClusterSpec carried extra
+// volumes as a typed []corev1.Volume instead of the normalized *runtime.RawExtension.
+const csiExtraVolumeRaw = `[{"name":"csi-vol","csi":{"driver":"csi.example.com","volumeAttributes":{"key":"value"}}}]`
+
+// TestNewUpdatableClusterSpec_HashStructSucceedsWithCSIExtraVolumeMap is the critical regression
+// test: without normalization, a CSI extraVolumes entry's volumeAttributes map would make
+// util.HashStruct error out on every reconcile, silently breaking HandleSpecUpdates in production
+// for any user with a CSI extra volume.
+func TestNewUpdatableClusterSpec_HashStructSucceedsWithCSIExtraVolumeMap(t *testing.T) {
+ cluster := &weka.WekaCluster{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-cluster", Namespace: "default", UID: types.UID("test-uid")},
+ Spec: weka.WekaClusterSpec{
+ Dynamic: &weka.WekaClusterTemplate{ClusterCapacity: "10TiB"},
+ PodConfig: &weka.PodConfiguration{
+ ExtraVolumes: &runtime.RawExtension{Raw: []byte(csiExtraVolumeRaw)},
+ },
+ },
+ }
+
+ fakeClient := newFakeClient(t)
+
+ updatableSpec, err := NewUpdatableClusterSpec(context.Background(), fakeClient, &cluster.Spec, &cluster.ObjectMeta, nil)
+ if err != nil {
+ t.Fatalf("NewUpdatableClusterSpec returned unexpected error: %v", err)
+ }
+
+ if _, err := util.HashStruct(updatableSpec); err != nil {
+ t.Fatalf("util.HashStruct(NewUpdatableClusterSpec(cluster)) must not error for a CSI extraVolumes map, got: %v", err)
+ }
+}
+
+// TestHandleSpecUpdates_PropagatesExtraVolumesToContainer verifies that a container whose extra
+// volumes/mounts predate the feature (nil) is patched to match the cluster's normalized value.
+func TestHandleSpecUpdates_PropagatesExtraVolumesToContainer(t *testing.T) {
+ rawVolumes := &runtime.RawExtension{Raw: []byte(`[{"name":"cacert","secret":{"secretName":"cacert"}}]`)}
+ mounts := []corev1.VolumeMount{{Name: "cacert", MountPath: "/etc/ssl/cacert"}}
+
+ cluster := &weka.WekaCluster{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-cluster", Namespace: "default", UID: types.UID("test-uid")},
+ Spec: weka.WekaClusterSpec{
+ Dynamic: &weka.WekaClusterTemplate{ClusterCapacity: "10TiB"},
+ PodConfig: &weka.PodConfiguration{
+ ExtraVolumes: rawVolumes,
+ ExtraVolumeMounts: mounts,
+ },
+ },
+ }
+
+ staleContainer := &weka.WekaContainer{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-cluster-s3-0", Namespace: "default"},
+ Spec: weka.WekaContainerSpec{Mode: weka.WekaContainerModeS3},
+ }
+
+ loop := newUpgradeLoop(t, cluster, []*weka.WekaContainer{staleContainer})
+ fakeClient := loop.getClient()
+
+ if err := loop.HandleSpecUpdates(context.Background()); err != nil {
+ t.Fatalf("HandleSpecUpdates returned unexpected error: %v", err)
+ }
+
+ got := &weka.WekaContainer{}
+ if err := fakeClient.Get(context.Background(), client.ObjectKeyFromObject(staleContainer), got); err != nil {
+ t.Fatalf("failed to fetch container: %v", err)
+ }
+
+ wantDigest := resources.ExtraVolumesDigest(rawVolumes)
+ if gotDigest := resources.ExtraVolumesDigest(got.Spec.ExtraVolumes); gotDigest != wantDigest {
+ t.Errorf("container.Spec.ExtraVolumes digest = %q, want %q (raw=%v)", gotDigest, wantDigest, got.Spec.ExtraVolumes)
+ }
+ wantMountsDigest := resources.ExtraVolumeMountsDigest(mounts)
+ if gotMountsDigest := resources.ExtraVolumeMountsDigest(got.Spec.ExtraVolumeMounts); gotMountsDigest != wantMountsDigest {
+ t.Errorf("container.Spec.ExtraVolumeMounts digest = %q, want %q (mounts=%v)", gotMountsDigest, wantMountsDigest, got.Spec.ExtraVolumeMounts)
+ }
+}
+
+// TestHandleSpecUpdates_ExtraVolumesUnchanged_NoChurn verifies that a container whose extra
+// volumes/mounts already match the cluster's normalized value is left untouched: the digest
+// comparison must not treat an already-normalized, already-equal value as a diff and rewrite it.
+func TestHandleSpecUpdates_ExtraVolumesUnchanged_NoChurn(t *testing.T) {
+ rawVolumes := &runtime.RawExtension{Raw: []byte(`[{"name":"cacert","secret":{"secretName":"cacert"}}]`)}
+ mounts := []corev1.VolumeMount{{Name: "cacert", MountPath: "/etc/ssl/cacert"}}
+ normalized := resources.NormalizeExtraVolumes(rawVolumes)
+
+ cluster := &weka.WekaCluster{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-cluster", Namespace: "default", UID: types.UID("test-uid")},
+ Spec: weka.WekaClusterSpec{
+ Dynamic: &weka.WekaClusterTemplate{ClusterCapacity: "10TiB"},
+ PodConfig: &weka.PodConfiguration{
+ ExtraVolumes: rawVolumes,
+ ExtraVolumeMounts: mounts,
+ },
+ },
+ }
+
+ upToDateContainer := &weka.WekaContainer{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-cluster-s3-0", Namespace: "default"},
+ Spec: weka.WekaContainerSpec{
+ Mode: weka.WekaContainerModeS3,
+ ExtraVolumes: normalized.DeepCopy(),
+ ExtraVolumeMounts: append([]corev1.VolumeMount{}, mounts...),
+ },
+ }
+
+ loop := newUpgradeLoop(t, cluster, []*weka.WekaContainer{upToDateContainer})
+ fakeClient := loop.getClient()
+
+ // First pass: LastAppliedSpec starts empty, so the full body runs (and sets it) even though
+ // extra volumes/mounts are already correct — the digest comparison must not touch them.
+ if err := loop.HandleSpecUpdates(context.Background()); err != nil {
+ t.Fatalf("HandleSpecUpdates returned unexpected error: %v", err)
+ }
+
+ got := &weka.WekaContainer{}
+ if err := fakeClient.Get(context.Background(), client.ObjectKeyFromObject(upToDateContainer), got); err != nil {
+ t.Fatalf("failed to fetch container: %v", err)
+ }
+ if got.Spec.ExtraVolumes == nil || string(got.Spec.ExtraVolumes.Raw) != string(normalized.Raw) {
+ t.Errorf("container.Spec.ExtraVolumes = %v, want unchanged normalized value %v", got.Spec.ExtraVolumes, normalized)
+ }
+ if resources.ExtraVolumeMountsDigest(got.Spec.ExtraVolumeMounts) != resources.ExtraVolumeMountsDigest(mounts) {
+ t.Errorf("container.Spec.ExtraVolumeMounts = %v, want unchanged %v", got.Spec.ExtraVolumeMounts, mounts)
+ }
+
+ // Second pass: loop.containers still holds the pre-first-pass object, so reset it to the
+ // now-synced state (LastAppliedSpec included) fetched above, then reconcile again. Because
+ // everything — extra volumes included — is already correct, LastAppliedSpec now equals the
+ // spec hash and the per-container closure short-circuits before issuing any patch at all.
+ loop.containers = []*weka.WekaContainer{got}
+ beforeResourceVersion := got.ResourceVersion
+
+ if err := loop.HandleSpecUpdates(context.Background()); err != nil {
+ t.Fatalf("HandleSpecUpdates (second pass) returned unexpected error: %v", err)
+ }
+
+ gotAfterSecondPass := &weka.WekaContainer{}
+ if err := fakeClient.Get(context.Background(), client.ObjectKeyFromObject(upToDateContainer), gotAfterSecondPass); err != nil {
+ t.Fatalf("failed to fetch container after second pass: %v", err)
+ }
+ if gotAfterSecondPass.ResourceVersion != beforeResourceVersion {
+ t.Errorf("container.ResourceVersion changed from %q to %q on a reconcile where nothing (including extra volumes/mounts) had changed — unwanted churn",
+ beforeResourceVersion, gotAfterSecondPass.ResourceVersion)
+ }
+}
diff --git a/internal/controllers/wekacluster/pod_config_version.go b/internal/controllers/wekacluster/pod_config_version.go
index d00ede029..121d625a3 100644
--- a/internal/controllers/wekacluster/pod_config_version.go
+++ b/internal/controllers/wekacluster/pod_config_version.go
@@ -9,6 +9,7 @@ import (
"github.com/weka/weka-operator/internal/config"
"github.com/weka/weka-operator/internal/consts"
+ "github.com/weka/weka-operator/internal/controllers/resources"
)
// ClusterPodConfigVersionInputs defines the fields tracked for spec version calculation.
@@ -21,12 +22,20 @@ type ClusterPodConfigVersionInputs struct {
// WekaClusterSpec fields
Image string `json:"image"`
+
+ // ExtraVolumes/ExtraVolumeMounts digests. omitempty keeps the hash unchanged for clusters
+ // not using the feature, so upgrading the operator does not force a pod roll.
+ ExtraVolumesDigest string `json:"extraVolumesDigest,omitempty"`
+ ExtraVolumeMountsDigest string `json:"extraVolumeMountsDigest,omitempty"`
}
func CalcClusterPodConfigVersion(spec *weka.WekaClusterSpec) string {
+ cluster := &weka.WekaCluster{Spec: *spec}
inputs := ClusterPodConfigVersionInputs{
- PodConfigVersion: config.Config.PodConfigVersion,
- Image: spec.Image,
+ PodConfigVersion: config.Config.PodConfigVersion,
+ Image: spec.Image,
+ ExtraVolumesDigest: resources.ExtraVolumesDigest(cluster.GetRawExtraVolumes()),
+ ExtraVolumeMountsDigest: resources.ExtraVolumeMountsDigest(cluster.GetExtraVolumeMounts()),
}
if config.Config.EnablePodConfigCodeVersionRotation {
inputs.WekaRuntimeVersion = consts.PodConfigCodeVersion
diff --git a/internal/controllers/wekacluster/pod_config_version_test.go b/internal/controllers/wekacluster/pod_config_version_test.go
index 50fb2743b..fd2b7c522 100644
--- a/internal/controllers/wekacluster/pod_config_version_test.go
+++ b/internal/controllers/wekacluster/pod_config_version_test.go
@@ -1,9 +1,14 @@
package wekacluster
import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
"testing"
weka "github.com/weka/weka-k8s-api/api/v1alpha1"
+ v1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/runtime"
"github.com/weka/weka-operator/internal/config"
)
@@ -62,3 +67,78 @@ func TestCalcClusterPodConfigVersion_ChangesWhenPodConfigVersionChanges(t *testi
t.Error("hash should differ when PodConfigVersion changes")
}
}
+
+func TestCalcClusterPodConfigVersion_ChangesWhenExtraVolumesChange(t *testing.T) {
+ config.Config.PodConfigVersion = "1"
+ config.Config.EnablePodConfigCodeVersionRotation = false
+
+ baseSpec := &weka.WekaClusterSpec{Image: "quay.io/weka.io/weka-in-container:4.5.0"}
+ hashUnset := CalcClusterPodConfigVersion(baseSpec)
+
+ specWithVolumes := &weka.WekaClusterSpec{
+ Image: "quay.io/weka.io/weka-in-container:4.5.0",
+ PodConfig: &weka.PodConfiguration{
+ ExtraVolumes: &runtime.RawExtension{
+ Raw: []byte(`[{"name":"ca-bundle","secret":{"secretName":"ca-bundle"}}]`),
+ },
+ ExtraVolumeMounts: []v1.VolumeMount{
+ {Name: "ca-bundle", MountPath: "/etc/ssl/ca-bundle"},
+ },
+ },
+ }
+ hashWithVolumes := CalcClusterPodConfigVersion(specWithVolumes)
+
+ if hashUnset == hashWithVolumes {
+ t.Error("hash should differ once extraVolumes/extraVolumeMounts are set")
+ }
+
+ specWithOtherVolumes := &weka.WekaClusterSpec{
+ Image: "quay.io/weka.io/weka-in-container:4.5.0",
+ PodConfig: &weka.PodConfiguration{
+ ExtraVolumes: &runtime.RawExtension{
+ Raw: []byte(`[{"name":"other","secret":{"secretName":"other"}}]`),
+ },
+ ExtraVolumeMounts: []v1.VolumeMount{
+ {Name: "other", MountPath: "/etc/ssl/other"},
+ },
+ },
+ }
+ hashWithOtherVolumes := CalcClusterPodConfigVersion(specWithOtherVolumes)
+
+ if hashWithVolumes == hashWithOtherVolumes {
+ t.Error("hash should differ when extraVolumes content changes")
+ }
+}
+
+// TestCalcClusterPodConfigVersion_UnchangedWhenExtraVolumesUnset is the no-forced-roll guarantee:
+// omitempty on the digest fields must keep the hash identical to the pre-feature value for any
+// cluster not using extra volumes, so upgrading the operator to a version that knows about this
+// feature never rotates pods on its own.
+func TestCalcClusterPodConfigVersion_UnchangedWhenExtraVolumesUnset(t *testing.T) {
+ config.Config.PodConfigVersion = "1"
+ config.Config.EnablePodConfigCodeVersionRotation = false
+
+ spec := &weka.WekaClusterSpec{Image: "quay.io/weka.io/weka-in-container:4.5.0"}
+ got := CalcClusterPodConfigVersion(spec)
+
+ // Pre-feature shape: the two new digest fields didn't exist, so their omitempty tags must
+ // make CalcClusterPodConfigVersion's JSON marshal identical to this literal for an unset spec.
+ preFeature := struct {
+ PodConfigVersion string `json:"podConfigVersion"`
+ WekaRuntimeVersion string `json:"wekaRuntimeVersion"`
+ Image string `json:"image"`
+ }{
+ PodConfigVersion: config.Config.PodConfigVersion,
+ Image: spec.Image,
+ }
+ raw, err := json.Marshal(preFeature)
+ if err != nil {
+ t.Fatalf("failed to marshal pre-feature inputs: %v", err)
+ }
+ hash := sha256.Sum256(raw)
+ want := fmt.Sprintf("%x", hash)[:8]
+
+ if got != want {
+ t.Errorf("hash for a spec with no extra volumes must match the pre-feature value %q, got %q", want, got)
+ }
+}
diff --git a/internal/pkg/domain/wekahome.go b/internal/pkg/domain/wekahome.go
index 28ae19a3f..03251a474 100644
--- a/internal/pkg/domain/wekahome.go
+++ b/internal/pkg/domain/wekahome.go
@@ -49,3 +49,39 @@ func GetWekaHomeSecretRef(config v1alpha1.WekaHomeConfig) *string {
return nil
}
+
+// GetWekaHomeClientCacertSecret resolves the WekaHome CA cert secret a WekaClient should mount.
+// Precedence: the client's own value, then the target cluster's (only when the cluster shares the
+// client's namespace - the secret is mounted by name, so a cross-namespace name would not resolve),
+// then the operator-wide default. The cluster fallback exists because only the cluster-wide
+// weka_cloud_ca_cert_path replicates to joining machines, never the certificate file, so a client of
+// a private-CA cluster must place the same PEM itself.
+//
+// crossNamespaceSkipped reports that the cluster has its own cacertSecret but it was not inherited
+// because the cluster lives in a different namespace than the client.
+func GetWekaHomeClientCacertSecret(c *v1alpha1.WekaClient, targetCluster *v1alpha1.WekaCluster) (secret string, crossNamespaceSkipped bool) {
+ clientSecret := ""
+ if c.Spec.WekaHome != nil {
+ clientSecret = c.Spec.WekaHome.CacertSecret
+ }
+
+ sameNamespace := targetCluster != nil && targetCluster.Namespace == c.Namespace
+
+ clusterSecret := ""
+ if sameNamespace && targetCluster.Spec.WekaHome != nil {
+ clusterSecret = targetCluster.Spec.WekaHome.CacertSecret
+ }
+
+ if clientSecret != "" {
+ return clientSecret, false
+ }
+ if clusterSecret != "" {
+ return clusterSecret, false
+ }
+
+ if !sameNamespace && targetCluster != nil && targetCluster.Spec.WekaHome != nil && targetCluster.Spec.WekaHome.CacertSecret != "" {
+ crossNamespaceSkipped = true
+ }
+
+ return env.Config.WekaHome.CacertSecret, crossNamespaceSkipped
+}
diff --git a/internal/pkg/domain/wekahome_test.go b/internal/pkg/domain/wekahome_test.go
new file mode 100644
index 000000000..17ee5ead9
--- /dev/null
+++ b/internal/pkg/domain/wekahome_test.go
@@ -0,0 +1,152 @@
+package domain
+
+import (
+ "testing"
+
+ "github.com/weka/weka-k8s-api/api/v1alpha1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ env "github.com/weka/weka-operator/internal/config"
+)
+
+func TestGetWekaHomeClientCacertSecret(t *testing.T) {
+ origCacertSecret := env.Config.WekaHome.CacertSecret
+ defer func() { env.Config.WekaHome.CacertSecret = origCacertSecret }()
+
+ tests := []struct {
+ name string
+ client *v1alpha1.WekaClient
+ targetCluster *v1alpha1.WekaCluster
+ envCacertSecret string
+ wantSecret string
+ wantCrossNamespaceSkipped bool
+ }{
+ {
+ name: "client-only topology, no explicit value, no env default",
+ client: &v1alpha1.WekaClient{},
+ targetCluster: nil,
+ wantSecret: "",
+ },
+ {
+ name: "client-only topology, explicit client value",
+ client: &v1alpha1.WekaClient{Spec: v1alpha1.WekaClientSpec{
+ WekaHome: &v1alpha1.WekahomeClientConfig{CacertSecret: "client-secret"},
+ }},
+ targetCluster: nil,
+ wantSecret: "client-secret",
+ },
+ {
+ name: "explicit client value wins over cluster value",
+ client: &v1alpha1.WekaClient{Spec: v1alpha1.WekaClientSpec{
+ WekaHome: &v1alpha1.WekahomeClientConfig{CacertSecret: "client-secret"},
+ }},
+ targetCluster: &v1alpha1.WekaCluster{Spec: v1alpha1.WekaClusterSpec{
+ WekaHome: &v1alpha1.WekaHomeConfig{CacertSecret: "cluster-secret"},
+ }},
+ wantSecret: "client-secret",
+ },
+ {
+ name: "cluster value used when client sets nothing",
+ client: &v1alpha1.WekaClient{},
+ targetCluster: &v1alpha1.WekaCluster{Spec: v1alpha1.WekaClusterSpec{
+ WekaHome: &v1alpha1.WekaHomeConfig{CacertSecret: "cluster-secret"},
+ }},
+ wantSecret: "cluster-secret",
+ },
+ {
+ name: "env default used when neither client nor cluster set it",
+ client: &v1alpha1.WekaClient{},
+ targetCluster: &v1alpha1.WekaCluster{},
+ envCacertSecret: "env-secret",
+ wantSecret: "env-secret",
+ },
+ {
+ name: "spec.wekaHome entirely absent still gets the env default (defect-1 regression)",
+ client: &v1alpha1.WekaClient{Spec: v1alpha1.WekaClientSpec{
+ WekaHome: nil,
+ }},
+ targetCluster: nil,
+ envCacertSecret: "env-secret",
+ wantSecret: "env-secret",
+ },
+ {
+ name: "client value wins when it matches the cluster",
+ client: &v1alpha1.WekaClient{Spec: v1alpha1.WekaClientSpec{
+ WekaHome: &v1alpha1.WekahomeClientConfig{CacertSecret: "same-secret"},
+ }},
+ targetCluster: &v1alpha1.WekaCluster{Spec: v1alpha1.WekaClusterSpec{
+ WekaHome: &v1alpha1.WekaHomeConfig{CacertSecret: "same-secret"},
+ }},
+ wantSecret: "same-secret",
+ },
+ {
+ name: "client value used when cluster has none",
+ client: &v1alpha1.WekaClient{Spec: v1alpha1.WekaClientSpec{
+ WekaHome: &v1alpha1.WekahomeClientConfig{CacertSecret: "client-secret"},
+ }},
+ targetCluster: &v1alpha1.WekaCluster{},
+ wantSecret: "client-secret",
+ },
+ {
+ name: "cluster value inherited when client has none",
+ client: &v1alpha1.WekaClient{},
+ targetCluster: &v1alpha1.WekaCluster{Spec: v1alpha1.WekaClusterSpec{
+ WekaHome: &v1alpha1.WekaHomeConfig{CacertSecret: "cluster-secret"},
+ }},
+ wantSecret: "cluster-secret",
+ },
+ {
+ // GetWekahomeConfig folds the env default into the cluster's resolved CacertSecret, but
+ // divergence must be judged against the cluster's *own* explicit value - a client overriding
+ // an operator-wide default it never disagreed with the cluster about is not a divergence.
+ name: "client overrides env default, cluster sets nothing: no false divergence",
+ client: &v1alpha1.WekaClient{Spec: v1alpha1.WekaClientSpec{
+ WekaHome: &v1alpha1.WekahomeClientConfig{CacertSecret: "client-ca"},
+ }},
+ targetCluster: &v1alpha1.WekaCluster{},
+ envCacertSecret: "global-ca",
+ wantSecret: "client-ca",
+ },
+ {
+ name: "neither client nor cluster set it: env default used, no divergence",
+ client: &v1alpha1.WekaClient{},
+ targetCluster: &v1alpha1.WekaCluster{},
+ envCacertSecret: "global-ca",
+ wantSecret: "global-ca",
+ },
+ {
+ name: "cross-namespace cluster cacertSecret is not inherited",
+ client: &v1alpha1.WekaClient{ObjectMeta: metav1.ObjectMeta{Namespace: "clients-ns"}},
+ targetCluster: &v1alpha1.WekaCluster{
+ ObjectMeta: metav1.ObjectMeta{Namespace: "cluster-ns"},
+ Spec: v1alpha1.WekaClusterSpec{WekaHome: &v1alpha1.WekaHomeConfig{CacertSecret: "cluster-secret"}},
+ },
+ wantSecret: "",
+ wantCrossNamespaceSkipped: true,
+ },
+ {
+ name: "same-namespace cluster cacertSecret is still inherited",
+ client: &v1alpha1.WekaClient{ObjectMeta: metav1.ObjectMeta{Namespace: "shared-ns"}},
+ targetCluster: &v1alpha1.WekaCluster{
+ ObjectMeta: metav1.ObjectMeta{Namespace: "shared-ns"},
+ Spec: v1alpha1.WekaClusterSpec{WekaHome: &v1alpha1.WekaHomeConfig{CacertSecret: "cluster-secret"}},
+ },
+ wantSecret: "cluster-secret",
+ wantCrossNamespaceSkipped: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ env.Config.WekaHome.CacertSecret = tt.envCacertSecret
+
+ secret, crossNamespaceSkipped := GetWekaHomeClientCacertSecret(tt.client, tt.targetCluster)
+ if secret != tt.wantSecret {
+ t.Errorf("secret = %q, want %q", secret, tt.wantSecret)
+ }
+ if crossNamespaceSkipped != tt.wantCrossNamespaceSkipped {
+ t.Errorf("crossNamespaceSkipped = %v, want %v", crossNamespaceSkipped, tt.wantCrossNamespaceSkipped)
+ }
+ })
+ }
+}
diff --git a/internal/validation/client_extra_volumes.go b/internal/validation/client_extra_volumes.go
new file mode 100644
index 000000000..615dc142d
--- /dev/null
+++ b/internal/validation/client_extra_volumes.go
@@ -0,0 +1,30 @@
+package validation
+
+import (
+ "context"
+
+ wekav1alpha1 "github.com/weka/weka-k8s-api/api/v1alpha1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// clientExtraVolumes validates spec.extraVolumes and spec.extraVolumeMounts.
+// See validateExtraVolumes for the rule body, shared with clusterExtraVolumes.
+type clientExtraVolumes struct{}
+
+func (clientExtraVolumes) ID() string {
+ return "client_extra_volumes"
+}
+
+func (clientExtraVolumes) Validate(_ context.Context, _ client.Client, obj runtime.Object) field.ErrorList {
+ wc, ok := obj.(*wekav1alpha1.WekaClient)
+ if !ok {
+ return nil
+ }
+ return validateExtraVolumes(
+ wc.Spec.ExtraVolumes,
+ wc.Spec.ExtraVolumeMounts,
+ field.NewPath("spec"),
+ )
+}
diff --git a/internal/validation/client_wekahome_cacert_unverifiable.go b/internal/validation/client_wekahome_cacert_unverifiable.go
new file mode 100644
index 000000000..5db20520d
--- /dev/null
+++ b/internal/validation/client_wekahome_cacert_unverifiable.go
@@ -0,0 +1,46 @@
+package validation
+
+import (
+ "context"
+
+ wekav1alpha1 "github.com/weka/weka-k8s-api/api/v1alpha1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// clientWekahomeCacertUnverifiable warns when a client-only WekaClient (no
+// targetCluster, so the backend is an external cluster out of band) sets
+// spec.wekaHome.cacertSecret. The operator has no visibility into that
+// external cluster's weka_cloud_ca_cert_path, so this only helps if the two
+// happen to agree. When targetCluster is set, the operator manages the
+// cluster side too and the override is legitimate, so this stays silent.
+type clientWekahomeCacertUnverifiable struct{}
+
+func (clientWekahomeCacertUnverifiable) ID() string {
+ return "client_wekahome_cacert_unverifiable"
+}
+
+func (clientWekahomeCacertUnverifiable) Validate(_ context.Context, _ client.Client, obj runtime.Object) field.ErrorList {
+ wc, ok := obj.(*wekav1alpha1.WekaClient)
+ if !ok {
+ return nil
+ }
+ if wc.Spec.WekaHome == nil || wc.Spec.WekaHome.CacertSecret == "" {
+ return nil
+ }
+ if wc.Spec.TargetCluster.Name != "" {
+ return nil
+ }
+
+ path := field.NewPath("spec", "wekaHome", "cacertSecret")
+ return field.ErrorList{
+ field.Invalid(path, wc.Spec.WekaHome.CacertSecret,
+ "has no targetCluster, so the backend is an external cluster the operator cannot "+
+ "inspect. It cannot confirm that cluster's weka_cloud_ca_cert_path matches "+
+ "/opt/weka/k8s-runtime/vars/wh-cacert/cert.pem, so this setting only helps by "+
+ "coincidence. For this topology, mount the CA bundle into the container's OS trust "+
+ "store instead, per Weka's recommendation.",
+ ),
+ }
+}
diff --git a/internal/validation/client_wekahome_cacert_unverifiable_test.go b/internal/validation/client_wekahome_cacert_unverifiable_test.go
new file mode 100644
index 000000000..62746fa32
--- /dev/null
+++ b/internal/validation/client_wekahome_cacert_unverifiable_test.go
@@ -0,0 +1,58 @@
+package validation
+
+import (
+ "context"
+ "testing"
+
+ weka "github.com/weka/weka-k8s-api/api/v1alpha1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestClientWekahomeCacertUnverifiable(t *testing.T) {
+ cases := []struct {
+ name string
+ wekaHome *weka.WekahomeClientConfig
+ targetCluster weka.ObjectReference
+ wantErrs int
+ }{
+ {name: "wekaHome unset", wekaHome: nil, wantErrs: 0},
+ {name: "wekaHome set, cacertSecret empty", wekaHome: &weka.WekahomeClientConfig{}, wantErrs: 0},
+ {
+ name: "cacertSecret set, no targetCluster",
+ wekaHome: &weka.WekahomeClientConfig{CacertSecret: "my-ca"},
+ wantErrs: 1,
+ },
+ {
+ name: "cacertSecret set, targetCluster set",
+ wekaHome: &weka.WekahomeClientConfig{CacertSecret: "my-ca"},
+ targetCluster: weka.ObjectReference{Name: "cluster", Namespace: "ns"},
+ wantErrs: 0,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ wc := &weka.WekaClient{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
+ wc.Spec.WekaHome = tc.wekaHome
+ wc.Spec.TargetCluster = tc.targetCluster
+
+ v := clientWekahomeCacertUnverifiable{}
+ errs := v.Validate(context.Background(), nil, wc)
+ if len(errs) != tc.wantErrs {
+ t.Fatalf("expected %d violations, got %d: %v", tc.wantErrs, len(errs), errs)
+ }
+ for _, e := range errs {
+ if e.Field != "spec.wekaHome.cacertSecret" {
+ t.Errorf("unexpected field path %q", e.Field)
+ }
+ }
+ })
+ }
+}
+
+func TestClientWekahomeCacertUnverifiable_WrongType(t *testing.T) {
+ v := clientWekahomeCacertUnverifiable{}
+ if errs := v.Validate(context.Background(), nil, &weka.WekaCluster{}); errs != nil {
+ t.Fatalf("expected nil for non-WekaClient object, got %v", errs)
+ }
+}
diff --git a/internal/validation/cluster_extra_volumes.go b/internal/validation/cluster_extra_volumes.go
new file mode 100644
index 000000000..bf07038dd
--- /dev/null
+++ b/internal/validation/cluster_extra_volumes.go
@@ -0,0 +1,34 @@
+package validation
+
+import (
+ "context"
+
+ wekav1alpha1 "github.com/weka/weka-k8s-api/api/v1alpha1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// clusterExtraVolumes validates spec.podConfig.extraVolumes and
+// spec.podConfig.extraVolumeMounts. See validateExtraVolumes for the rule body,
+// shared with clientExtraVolumes.
+type clusterExtraVolumes struct{}
+
+func (clusterExtraVolumes) ID() string {
+ return "cluster_extra_volumes"
+}
+
+func (clusterExtraVolumes) Validate(_ context.Context, _ client.Client, obj runtime.Object) field.ErrorList {
+ cluster, ok := obj.(*wekav1alpha1.WekaCluster)
+ if !ok {
+ return nil
+ }
+ if cluster.Spec.PodConfig == nil {
+ return nil
+ }
+ return validateExtraVolumes(
+ cluster.Spec.PodConfig.ExtraVolumes,
+ cluster.Spec.PodConfig.ExtraVolumeMounts,
+ field.NewPath("spec", "podConfig"),
+ )
+}
diff --git a/internal/validation/extra_volumes.go b/internal/validation/extra_volumes.go
new file mode 100644
index 000000000..743cfe3b8
--- /dev/null
+++ b/internal/validation/extra_volumes.go
@@ -0,0 +1,105 @@
+package validation
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "path"
+ "strings"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ k8svalidation "k8s.io/apimachinery/pkg/util/validation"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+
+ "github.com/weka/weka-operator/internal/controllers/resources"
+)
+
+// validateExtraVolumes is the shared rule body behind clusterExtraVolumes and
+// clientExtraVolumes. extraVolumes is schemaless
+// (x-kubernetes-preserve-unknown-fields), so the API server does no validation
+// of it at all; this is the only thing standing between a user and a
+// silently-dropped typo, which is why unknown fields are rejected outright
+// rather than ignored.
+func validateExtraVolumes(raw *runtime.RawExtension, mounts []corev1.VolumeMount, basePath *field.Path) field.ErrorList {
+ volumesPath := basePath.Child("extraVolumes")
+ mountsPath := basePath.Child("extraVolumeMounts")
+
+ if raw == nil || len(raw.Raw) == 0 {
+ // No volumes declared, so every mount is by definition mounting something undeclared.
+ return validateExtraVolumeMounts(nil, mounts, mountsPath)
+ }
+
+ var volumes []corev1.Volume
+ dec := json.NewDecoder(bytes.NewReader(raw.Raw))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&volumes); err != nil {
+ return field.ErrorList{field.Invalid(volumesPath, string(raw.Raw),
+ fmt.Sprintf("does not parse as a list of pod volumes: %v", err))}
+ }
+
+ var errs field.ErrorList
+ seenNames := make(map[string]struct{}, len(volumes))
+ declaredNames := make(map[string]struct{}, len(volumes))
+
+ for i, v := range volumes {
+ namePath := volumesPath.Index(i).Child("name")
+
+ if reasons := k8svalidation.IsDNS1123Label(v.Name); len(reasons) > 0 {
+ errs = append(errs, field.Invalid(namePath, v.Name, strings.Join(reasons, ", ")))
+ } else {
+ if _, dup := seenNames[v.Name]; dup {
+ errs = append(errs, field.Duplicate(namePath, v.Name))
+ }
+ seenNames[v.Name] = struct{}{}
+
+ if resources.IsReservedVolumeName(v.Name) {
+ errs = append(errs, field.Invalid(namePath, v.Name,
+ "is reserved for an operator-managed volume"))
+ }
+ }
+ // Recorded even when invalid, so a mount naming this entry isn't also flagged
+ // as pointing at nothing - the name error above is enough.
+ declaredNames[v.Name] = struct{}{}
+ }
+
+ errs = append(errs, validateExtraVolumeMounts(declaredNames, mounts, mountsPath)...)
+ return errs
+}
+
+// validateExtraVolumeMounts checks extraVolumeMounts against the volume names declared in
+// extraVolumes. declaredNames is nil when extraVolumes itself is unset or empty, so every
+// mount then fails the "names a declared volume" check.
+func validateExtraVolumeMounts(declaredNames map[string]struct{}, mounts []corev1.VolumeMount, mountsPath *field.Path) field.ErrorList {
+ var errs field.ErrorList
+ seenPaths := make(map[string]struct{}, len(mounts))
+
+ for i, m := range mounts {
+ idxPath := mountsPath.Index(i)
+
+ if _, ok := declaredNames[m.Name]; !ok {
+ errs = append(errs, field.Invalid(idxPath.Child("name"), m.Name,
+ "does not match any entry in extraVolumes; mounting an operator-managed base "+
+ "volume at a second path is not supported"))
+ }
+
+ switch {
+ case !path.IsAbs(m.MountPath):
+ errs = append(errs, field.Invalid(idxPath.Child("mountPath"), m.MountPath,
+ "must be an absolute path"))
+ case path.Clean(m.MountPath) != m.MountPath:
+ errs = append(errs, field.Invalid(idxPath.Child("mountPath"), m.MountPath,
+ fmt.Sprintf("must be a cleaned path; use %q", path.Clean(m.MountPath))))
+ default:
+ if resources.IsReservedMountPath(m.MountPath) {
+ errs = append(errs, field.Invalid(idxPath.Child("mountPath"), m.MountPath,
+ "is reserved for an operator-managed mount"))
+ }
+ if _, dup := seenPaths[m.MountPath]; dup {
+ errs = append(errs, field.Duplicate(idxPath.Child("mountPath"), m.MountPath))
+ }
+ }
+ seenPaths[m.MountPath] = struct{}{}
+ }
+ return errs
+}
diff --git a/internal/validation/extra_volumes_test.go b/internal/validation/extra_volumes_test.go
new file mode 100644
index 000000000..46f5fd449
--- /dev/null
+++ b/internal/validation/extra_volumes_test.go
@@ -0,0 +1,257 @@
+package validation
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ weka "github.com/weka/weka-k8s-api/api/v1alpha1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+)
+
+// extraVolumesCase is run against both clusterExtraVolumes and
+// clientExtraVolumes: the two are thin wrappers over the same rule body
+// (validateExtraVolumes), so one table exercises every rule for both.
+type extraVolumesCase struct {
+ name string
+ volumesRaw string // JSON for extraVolumes; "" leaves it unset
+ mounts []corev1.VolumeMount
+ wantErrs int
+}
+
+func extraVolumesCases() []extraVolumesCase {
+ return []extraVolumesCase{
+ {name: "unset extraVolumes, no mounts", wantErrs: 0},
+ {name: "empty extraVolumes array", volumesRaw: `[]`, wantErrs: 0},
+ {
+ name: "unknown field rejected",
+ volumesRaw: `[{"name":"ca","mountPatch":"typo","secret":{"secretName":"ca"}}]`,
+ wantErrs: 1,
+ },
+ {
+ name: "empty name",
+ volumesRaw: `[{"name":"","secret":{"secretName":"ca"}}]`,
+ wantErrs: 1,
+ },
+ {
+ name: "invalid DNS-1123 name",
+ volumesRaw: `[{"name":"Not_Valid","secret":{"secretName":"ca"}}]`,
+ wantErrs: 1,
+ },
+ {
+ name: "duplicate names",
+ volumesRaw: `[{"name":"ca","secret":{"secretName":"ca1"}},
+ {"name":"ca","secret":{"secretName":"ca2"}}]`,
+ wantErrs: 1,
+ },
+ {
+ name: "reserved name",
+ volumesRaw: `[{"name":"dev","emptyDir":{}}]`,
+ wantErrs: 1,
+ },
+ {
+ // wekahome-cacert-secret is the one literal name AdditionalSecrets derives
+ // (pod.go); a plausible user name like foo-secret must NOT be caught by this.
+ name: "reserved derived secret volume name",
+ volumesRaw: `[{"name":"wekahome-cacert-secret","secret":{"secretName":"ca"}}]`,
+ wantErrs: 1,
+ },
+ {
+ name: "non-reserved -secret suffix is accepted",
+ volumesRaw: `[{"name":"foo-secret","secret":{"secretName":"ca"}}]`,
+ wantErrs: 0,
+ },
+ {
+ name: "mount names an undeclared volume",
+ volumesRaw: `[{"name":"ca","secret":{"secretName":"ca"}}]`,
+ mounts: []corev1.VolumeMount{{Name: "other", MountPath: "/etc/ssl/certs/corp-ca.crt"}},
+ wantErrs: 1,
+ },
+ {
+ name: "relative mount path",
+ volumesRaw: `[{"name":"ca","secret":{"secretName":"ca"}}]`,
+ mounts: []corev1.VolumeMount{{Name: "ca", MountPath: "etc/ssl/certs/corp-ca.crt"}},
+ wantErrs: 1,
+ },
+ {
+ name: "uncleaned mount path",
+ volumesRaw: `[{"name":"ca","secret":{"secretName":"ca"}}]`,
+ mounts: []corev1.VolumeMount{{Name: "ca", MountPath: "/etc/ssl/../ssl/certs/corp-ca.crt"}},
+ wantErrs: 1,
+ },
+ {
+ name: "duplicate mount path",
+ volumesRaw: `[{"name":"ca","secret":{"secretName":"ca1"}},
+ {"name":"ca2","secret":{"secretName":"ca2"}}]`,
+ mounts: []corev1.VolumeMount{
+ {Name: "ca", MountPath: "/etc/ssl/certs/corp-ca.crt"},
+ {Name: "ca2", MountPath: "/etc/ssl/certs/corp-ca.crt"},
+ },
+ wantErrs: 1,
+ },
+ {
+ name: "reserved mount path",
+ volumesRaw: `[{"name":"ca","secret":{"secretName":"ca"}}]`,
+ mounts: []corev1.VolumeMount{{Name: "ca", MountPath: "/opt/weka/foo"}},
+ wantErrs: 1,
+ },
+ {
+ // The motivating use case: mounting a CA bundle under /etc/ssl. If this
+ // case fails, the feature has no reason to exist.
+ name: "valid CA bundle mount passes",
+ volumesRaw: `[{"name":"corp-ca","secret":{"secretName":"corp-ca-bundle"}}]`,
+ mounts: []corev1.VolumeMount{
+ {Name: "corp-ca", MountPath: "/etc/ssl/certs/corp-ca.crt", SubPath: "ca.crt", ReadOnly: true},
+ },
+ wantErrs: 0,
+ },
+ {
+ name: "multiple simultaneous violations",
+ volumesRaw: `[{"name":"","secret":{"secretName":"a"}},
+ {"name":"dev","secret":{"secretName":"b"}}]`,
+ mounts: []corev1.VolumeMount{{Name: "missing", MountPath: "relative/path"}},
+ // extraVolumes[0].name (empty), extraVolumes[1].name (reserved),
+ // extraVolumeMounts[0].name (undeclared), extraVolumeMounts[0].mountPath (relative)
+ wantErrs: 4,
+ },
+ }
+}
+
+// assertFieldPaths checks that errs covers exactly the field paths keyed in want, order
+// ignored. want's values are unused; it doubles as the set of paths still to be seen.
+func assertFieldPaths(t *testing.T, errs field.ErrorList, want map[string]bool) {
+ t.Helper()
+ if len(errs) != len(want) {
+ t.Fatalf("expected %d violations, got %d: %v", len(want), len(errs), errs)
+ }
+ for _, e := range errs {
+ if _, ok := want[e.Field]; !ok {
+ t.Errorf("unexpected field path %q", e.Field)
+ continue
+ }
+ want[e.Field] = true
+ }
+ for f, seen := range want {
+ if !seen {
+ t.Errorf("expected a violation at field path %q, got none", f)
+ }
+ }
+}
+
+func rawExtraVolumes(jsonStr string) *runtime.RawExtension {
+ if jsonStr == "" {
+ return nil
+ }
+ return &runtime.RawExtension{Raw: []byte(jsonStr)}
+}
+
+func TestClusterExtraVolumes(t *testing.T) {
+ v := clusterExtraVolumes{}
+ for _, tc := range extraVolumesCases() {
+ t.Run(tc.name, func(t *testing.T) {
+ cluster := &weka.WekaCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
+ cluster.Spec.PodConfig = &weka.PodConfiguration{
+ ExtraVolumes: rawExtraVolumes(tc.volumesRaw),
+ ExtraVolumeMounts: tc.mounts,
+ }
+
+ errs := v.Validate(context.Background(), nil, cluster)
+ if len(errs) != tc.wantErrs {
+ t.Fatalf("expected %d violations, got %d: %v", tc.wantErrs, len(errs), errs)
+ }
+ for _, e := range errs {
+ if e.Field != "spec.podConfig.extraVolumes" &&
+ !strings.HasPrefix(e.Field, "spec.podConfig.extraVolumes[") &&
+ !strings.HasPrefix(e.Field, "spec.podConfig.extraVolumeMounts[") {
+ t.Errorf("unexpected field path %q", e.Field)
+ }
+ }
+ })
+ }
+}
+
+func TestClusterExtraVolumes_NoPodConfig(t *testing.T) {
+ v := clusterExtraVolumes{}
+ cluster := &weka.WekaCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
+ if errs := v.Validate(context.Background(), nil, cluster); errs != nil {
+ t.Fatalf("expected nil when podConfig is unset, got %v", errs)
+ }
+}
+
+func TestClusterExtraVolumes_WrongType(t *testing.T) {
+ v := clusterExtraVolumes{}
+ if errs := v.Validate(context.Background(), nil, &weka.WekaClient{}); errs != nil {
+ t.Fatalf("expected nil for non-WekaCluster object, got %v", errs)
+ }
+}
+
+func TestClusterExtraVolumes_ViolationFieldPaths(t *testing.T) {
+ cluster := &weka.WekaCluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
+ cluster.Spec.PodConfig = &weka.PodConfiguration{
+ ExtraVolumes: rawExtraVolumes(`[{"name":"","secret":{"secretName":"a"}},
+ {"name":"dev","secret":{"secretName":"b"}}]`),
+ ExtraVolumeMounts: []corev1.VolumeMount{{Name: "missing", MountPath: "relative/path"}},
+ }
+
+ v := clusterExtraVolumes{}
+ errs := v.Validate(context.Background(), nil, cluster)
+
+ wantFields := map[string]bool{
+ "spec.podConfig.extraVolumes[0].name": false,
+ "spec.podConfig.extraVolumes[1].name": false,
+ "spec.podConfig.extraVolumeMounts[0].name": false,
+ "spec.podConfig.extraVolumeMounts[0].mountPath": false,
+ }
+ assertFieldPaths(t, errs, wantFields)
+}
+
+func TestClientExtraVolumes(t *testing.T) {
+ v := clientExtraVolumes{}
+ for _, tc := range extraVolumesCases() {
+ t.Run(tc.name, func(t *testing.T) {
+ wc := &weka.WekaClient{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
+ wc.Spec.ExtraVolumes = rawExtraVolumes(tc.volumesRaw)
+ wc.Spec.ExtraVolumeMounts = tc.mounts
+
+ errs := v.Validate(context.Background(), nil, wc)
+ if len(errs) != tc.wantErrs {
+ t.Fatalf("expected %d violations, got %d: %v", tc.wantErrs, len(errs), errs)
+ }
+ for _, e := range errs {
+ if e.Field != "spec.extraVolumes" &&
+ !strings.HasPrefix(e.Field, "spec.extraVolumes[") &&
+ !strings.HasPrefix(e.Field, "spec.extraVolumeMounts[") {
+ t.Errorf("unexpected field path %q", e.Field)
+ }
+ }
+ })
+ }
+}
+
+func TestClientExtraVolumes_WrongType(t *testing.T) {
+ v := clientExtraVolumes{}
+ if errs := v.Validate(context.Background(), nil, &weka.WekaCluster{}); errs != nil {
+ t.Fatalf("expected nil for non-WekaClient object, got %v", errs)
+ }
+}
+
+func TestClientExtraVolumes_ViolationFieldPaths(t *testing.T) {
+ wc := &weka.WekaClient{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "ns"}}
+ wc.Spec.ExtraVolumes = rawExtraVolumes(`[{"name":"","secret":{"secretName":"a"}},
+ {"name":"dev","secret":{"secretName":"b"}}]`)
+ wc.Spec.ExtraVolumeMounts = []corev1.VolumeMount{{Name: "missing", MountPath: "relative/path"}}
+
+ v := clientExtraVolumes{}
+ errs := v.Validate(context.Background(), nil, wc)
+
+ wantFields := map[string]bool{
+ "spec.extraVolumes[0].name": false,
+ "spec.extraVolumes[1].name": false,
+ "spec.extraVolumeMounts[0].name": false,
+ "spec.extraVolumeMounts[0].mountPath": false,
+ }
+ assertFieldPaths(t, errs, wantFields)
+}
diff --git a/internal/validation/registry.go b/internal/validation/registry.go
index df4df1b1d..156343766 100644
--- a/internal/validation/registry.go
+++ b/internal/validation/registry.go
@@ -29,10 +29,13 @@ var (
&clusterCapacityChunkFeasibility{},
&clusterSkipDefaultFs{},
&clusterPodspecSyntax{},
+ &clusterExtraVolumes{},
}
WekaClient = []Validator{
&clientTargetClusterExists{},
&clientPodspecSyntax{},
+ &clientWekahomeCacertUnverifiable{},
+ &clientExtraVolumes{},
}
// Update-only registries: validators that require both old and new objects.
diff --git a/pkg/weka-k8s-api b/pkg/weka-k8s-api
index 506609455..bc534dc7e 160000
--- a/pkg/weka-k8s-api
+++ b/pkg/weka-k8s-api
@@ -1 +1 @@
-Subproject commit 50660945556f320426e0eb000dcd5d2e25c5b3ec
+Subproject commit bc534dc7e19ddba2e50ffaebbec343885a1d3862