This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new e6ec86950 feat(helm): deploy a cluster with one release per node 
(#4035)
e6ec86950 is described below

commit e6ec869500c3b362bf7f4b85650b7fc6df9a5fa1
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Wed Sep 2 16:17:27 2026 +0200

    feat(helm): deploy a cluster with one release per node (#4035)
---
 helm/charts/iggy/Chart.yaml                    |   4 +-
 helm/charts/iggy/README.md                     | 211 ++++++++++++++++++++--
 helm/charts/iggy/README.md.gotmpl              | 170 +++++++++++++++++-
 helm/charts/iggy/examples/cluster-3-node.yaml  | 130 ++++++++++++++
 helm/charts/iggy/templates/_helpers.tpl        | 234 +++++++++++++++++++++++++
 helm/charts/iggy/templates/deployment.yaml     |  75 +++++++-
 helm/charts/iggy/templates/hpa.yaml            |  61 -------
 helm/charts/iggy/templates/server-secrets.yaml |  44 +++++
 helm/charts/iggy/templates/service.yaml        |   6 +-
 helm/charts/iggy/values.yaml                   | 132 ++++++++++++--
 scripts/ci/test-helm.sh                        |  72 ++++++--
 11 files changed, 1019 insertions(+), 120 deletions(-)

diff --git a/helm/charts/iggy/Chart.yaml b/helm/charts/iggy/Chart.yaml
index 08f86a803..a52150924 100644
--- a/helm/charts/iggy/Chart.yaml
+++ b/helm/charts/iggy/Chart.yaml
@@ -20,8 +20,8 @@ apiVersion: v2
 name: iggy
 description: A Helm chart for Apache Iggy server and web-ui
 type: application
-version: 0.5.0
-appVersion: "0.7.0"
+version: 0.6.0
+appVersion: "0.9.0-edge.6"
 sources:
   - https://github.com/apache/iggy
 keywords:
diff --git a/helm/charts/iggy/README.md b/helm/charts/iggy/README.md
index bfd38cb7a..8026d99ab 100644
--- a/helm/charts/iggy/README.md
+++ b/helm/charts/iggy/README.md
@@ -2,7 +2,7 @@
 
 A Helm chart for Apache Iggy server and web-ui
 
-![Version: 
0.5.0](https://img.shields.io/badge/Version-0.5.0-informational?style=flat-square)
 ![Type: 
application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
 ![AppVersion: 
0.7.0](https://img.shields.io/badge/AppVersion-0.7.0-informational?style=flat-square)
+![Version: 
0.6.0](https://img.shields.io/badge/Version-0.6.0-informational?style=flat-square)
 ![Type: 
application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
 ![AppVersion: 
0.9.0-edge.6](https://img.shields.io/badge/AppVersion-0.9.0--edge.6-informational?style=flat-square)
 
 ## Prerequisites
 
@@ -83,6 +83,156 @@ If Prometheus Operator is installed and you want 
monitoring, set
 `server.serviceMonitor.enabled=true` in `custom-values.yaml` or pass it on the
 command line with `--set server.serviceMonitor.enabled=true`.
 
+## Cluster Mode
+
+The server runs a Viewstamped Replication cluster when `cluster.enabled` is set
+and each node is told which roster entry it is. The chart models this as **one
+release per node**: every release is handed the same roster and overrides only
+its own identity.
+
+`helm/charts/iggy/examples/cluster-3-node.yaml` is a ready three-node roster.
+Point the `ip` values at the nodes you will pin the releases to, then:
+
+```bash
+for i in 0 1 2; do
+  helm upgrade --install "iggy-n$i" ./helm/charts/iggy \
+    -f ./helm/charts/iggy/examples/cluster-3-node.yaml \
+    --set "server.cluster.selfReplicaId=$i" \
+    --set "server.nodeSelector.kubernetes\.io/hostname=node-$i"
+done
+```
+
+The chart turns `server.cluster` into the server's `IGGY_CLUSTER_*` environment
+variables and passes `--replica-id`, so no configuration file is mounted. The
+values mirror the server's `[[cluster.nodes]]` config one for one.
+
+### Why hostNetwork and node pinning are required
+
+The server's consensus listener binds `cluster.nodes[*].ip` **verbatim**, 
unlike
+the client listeners, which keep their own bind address and take only the port
+from the roster. A pod therefore has to own the address its roster entry names.
+Pod IPs are neither stable nor known before install, and a Service ClusterIP is
+not an address a pod can bind, so the workable layout today is `hostNetwork:
+true` with each release pinned to a node and its roster `ip` set to that node's
+IP. Node IPs are known up front and survive a pod restart, which is what lets a
+replica come back and rejoin.
+
+The cost is real: the server pod shares the node's network namespace and its
+ports. Weigh it before running this in a shared cluster.
+
+If a release lands on a node whose IP is not the one in its roster entry, the
+server refuses to start rather than misbehaving quietly:
+
+```text
+Error: ShardJoinFailures { failures: [ShardJoinFailure { shard_id: 0,
+  kind: Error(Iggy(CannotBindToSocket("10.0.1.11:9090"))) }] }
+```
+
+Check the `nodeSelector` on that release against the `ip` in its roster entry.
+
+### Upgrades
+
+Host ports make a rolling update impossible: the replacement pod cannot bind
+ports the outgoing pod still holds, so it stays `Pending` while the Deployment
+waits for it to become ready. The chart therefore switches the server to the
+`Recreate` strategy whenever `hostNetwork` is set, which takes the node down
+for the length of the restart. Roll **one release at a time** and let the
+cluster regain quorum before starting the next.
+
+Turning `hostNetwork` on for a release that already exists moves the Deployment
+from `RollingUpdate` to `Recreate`. Under Helm 4's server-side apply that is
+rejected, because the patch cannot drop the `rollingUpdate` block the API 
server
+defaulted in:
+
+```text
+Deployment.apps "iggy-n0" is invalid: spec.strategy.rollingUpdate: Forbidden:
+  may not be specified when strategy `type` is 'Recreate'
+```
+
+On Helm 4, run that one upgrade with `helm upgrade --server-side=false`, which
+replaces the strategy in place. Helm 3 has no such flag, since it does not use
+server-side apply. Set `server.strategy` explicitly if you want a different
+strategy.
+
+### Roster rules
+
+* Every node runs the **identical** `server.cluster.nodes` list. Only
+  `selfReplicaId` differs between releases.
+* `replicaId` values are unique and cover `0..N-1` for an `N`-node roster.
+* `ports.tcpReplica` is required on every entry. In cluster mode the server
+  takes every listener port from the roster and will not fall back to defaults,
+  because two nodes on one host would otherwise race for the same socket. The
+  remaining ports default to `server.ports`.
+* `server.cluster.name` is hashed into the on-disk cluster id on first boot.
+  Changing it later makes the server refuse to start against existing data.
+* `ip` must be a literal IP. Hostnames are rejected. Use
+  `advertisedAddress` for the name clients dial, which does accept DNS.
+
+### Secrets
+
+Four settings have to carry the byte-identical value on every node, so they
+belong in one Secret that all releases reference rather than in each release's
+values. Create it in the release namespace before the first install:
+
+```bash
+JWT="$(head -c 32 /dev/urandom | base64)"
+kubectl create secret generic iggy-cluster-secrets \
+  --from-literal=username=iggy \
+  --from-literal=password="$(head -c 24 /dev/urandom | base64)" \
+  --from-literal=clusterSharedSecret="$(head -c 32 /dev/urandom | base64)" \
+  --from-literal=jwtEncodingSecret="$JWT" \
+  --from-literal=jwtDecodingSecret="$JWT" \
+  --from-literal=encryptionKey="$(head -c 32 /dev/urandom | base64)"
+```
+
+`examples/cluster-3-node.yaml` points `server.users.root`, `server.encryption`,
+`server.jwt` and `server.cluster.auth` at that one Secret. All four also accept
+an inline value, which the chart turns into a Secret of its own:
+`<release>-root-credentials` for the root username and password,
+`<release>-secrets` for the other three. That is convenient for a single node
+and a poor fit for a cluster, since the value then lives in every release's
+stored values.
+
+* **`server.users.root`** seeds the root user. Every node creates it locally
+  from its own `IGGY_ROOT_USERNAME` and `IGGY_ROOT_PASSWORD`, so a password 
that
+  differs on one release logs in on that node only, and a first cluster boot
+  refuses to start without both.
+* **`server.cluster.auth`** makes every replica connection complete an
+  authenticated handshake or be rejected. The key is at least 32 bytes of
+  CSPRNG output. Turning it on or off is a coordinated restart of the whole
+  cluster: a node that authenticates cannot talk to one that does not.
+* **`server.encryption`** encrypts message payloads and state commands at rest
+  with AES-256-GCM, under a 32-byte base64 key. A node holding a different key
+  cannot read what its peers wrote.
+* **`server.jwt`** makes HTTP bearer tokens valid on every node and survive a
+  restart. With `cluster.auth` enabled the server already derives a 
cluster-wide
+  key from the replica PSK, so setting the JWT secrets is an alternative to 
that
+  rather than an addition; setting them anyway keeps tokens working if auth is
+  later turned off.
+
+None of the encryption, JWT and replica-auth values is written to the data
+directory, and each is masked as `******` in the startup log.
+
+### Storage
+
+A replica cannot move between nodes without invalidating its roster entry, so
+give each release storage that stays on its node, and size
+`server.persistence` per node rather than for the cluster. A replica that 
starts
+on an empty volume rejoins by state transfer, which is correct but re-reads the
+whole dataset from its peers. `server.persistence.enabled: false` puts the
+replica on `emptyDir` and forces exactly that on every pod replacement, so the
+cluster example enables persistence and leaves `storageClass` for you to point
+at a node-local provisioner.
+
+### What the chart refuses
+
+`server.replicaCount > 1` fails at render time. Scaling the server Deployment
+produces N independent servers behind one Service, all writing the same PVC
+subpath with no lock between them, which corrupts the data directory while
+`helm --wait` still reports success. The chart ships no HorizontalPodAutoscaler
+for the same reason. Cluster size is a roster decision, so add a node to
+`server.cluster.nodes` and install another release instead.
+
 ## Uninstallation
 
 ```bash
@@ -148,7 +298,7 @@ If a previous local smoke install failed and left resources 
behind, reset the sm
 scripts/ci/test-helm.sh cleanup-smoke
 ```
 
-On Apple Silicon hosts, the released `apache/iggy:0.7.0` `arm64` image may 
still fail during the runtime smoke path in kind. If your Docker setup supports 
amd64 emulation well enough, you can try recreating the dedicated smoke cluster 
with:
+On Apple Silicon hosts, the released `arm64` server image may still fail 
during the runtime smoke path in kind. If your Docker setup supports amd64 
emulation well enough, you can try recreating the dedicated smoke cluster with:
 
 ```bash
 HELM_SMOKE_KIND_PLATFORM=linux/amd64 scripts/ci/setup-helm-smoke-cluster.sh
@@ -219,12 +369,18 @@ Ensure the server binds to `0.0.0.0` instead of 
`127.0.0.1`. This is configured
 
 A wildcard bind says which interfaces accept connections, not where clients
 reach the pod, so the server also needs the address to publish in cluster
-metadata. Server builds that carry the setting refuse to start without it;
-older ones, including the `0.7.0` this chart pins by default, have no such
-refusal and log the variable as unknown and ignored. Either way the chart
-sets `IGGY_NODE_ADVERTISED_ADDRESS` to the in-cluster Service DNS name;
-override it with `server.advertisedAddress` when clients arrive through a
-LoadBalancer or an Ingress.
+metadata. Server builds that carry the setting refuse to start without it.
+`0.9.0-edge.6`, the image this chart pins, predates it:
+that build logs `IGGY_NODE_ADVERTISED_ADDRESS` as an unknown variable and
+publishes the bind address, so the chart's default stays inert until the pinned
+image moves past it. Either way the chart sets `IGGY_NODE_ADVERTISED_ADDRESS` 
to
+the in-cluster Service DNS name; override it with `server.advertisedAddress`
+when clients arrive through a LoadBalancer or an Ingress.
+
+In cluster mode the server ignores the variable altogether and publishes each
+node's roster `ip`, or its `advertisedAddress` when the entry carries one, so
+the chart leaves the variable out there and refuses a render that sets
+`server.advertisedAddress` alongside `server.cluster.enabled`.
 
 Declaring `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` yourself works too:
 the chart then leaves its own default out, so the variable is declared once.
@@ -325,10 +481,6 @@ pre-commit install
 | Key | Type | Default | Description |
 |-----|------|---------|-------------|
 | additionalLabels | object | `{}` | Additional labels for all resources |
-| autoscaling.enabled | bool | `false` | Enable horizontal pod autoscaling |
-| autoscaling.maxReplicas | int | `100` | Maximum replicas for autoscaling |
-| autoscaling.minReplicas | int | `1` | Minimum replicas for autoscaling |
-| autoscaling.targetCPUUtilizationPercentage | int | `80` | Target CPU 
utilization for autoscaling |
 | fullnameOverride | string | `""` | Override full release name |
 | imagePullSecrets | list | `[]` | Image pull secrets for private registries |
 | nameOverride | string | `""` | Override chart name |
@@ -336,19 +488,44 @@ pre-commit install
 | podSecurityContext | object | `{"seccompProfile":{"type":"Unconfined"}}` | 
Pod security context (server uses io_uring, requires unconfined seccomp) |
 | resources | object | `{}` | Resource limits and requests for server |
 | securityContext | object | `{"capabilities":{"add":["IPC_LOCK"]}}` | 
Container security context (server requires IPC_LOCK for io_uring) |
-| server | object | 
`{"advertisedAddress":"","affinity":{},"enabled":true,"env":[{"name":"RUST_LOG","value":"info"},{"name":"IGGY_HTTP_ADDRESS","value":"0.0.0.0:3000"},{"name":"IGGY_TCP_ADDRESS","value":"0.0.0.0:8090"},{"name":"IGGY_QUIC_ADDRESS","value":"0.0.0.0:8080"},{"name":"IGGY_WEBSOCKET_ADDRESS","value":"0.0.0.0:8092"}],"image":{"pullPolicy":"Always","repository":"apache/iggy","tag":"0.7.0"},"ingress":{"annotations":{},"className":"","enabled":false,"hosts":[{"host":"chart-example
 [...]
-| server.advertisedAddress | string | `""` | Client-facing address published 
in cluster metadata. Declaring `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` 
instead also works, but setting both is refused at render time. Empty falls 
back to the in-cluster Service DNS name. |
+| server | object | 
`{"advertisedAddress":"","affinity":{},"cluster":{"auth":{"enabled":false,"existingSecret":{"name":"","previousSharedSecretKey":"clusterPreviousSharedSecret","sharedSecretKey":"clusterSharedSecret"},"previousSharedSecret":"","sharedSecret":""},"enabled":false,"name":"iggy-cluster","nodes":[],"requireHostNetwork":true,"selfReplicaId":0},"enabled":true,"encryption":{"enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""},"env":[{"name":"RUST_LOG","v
 [...]
+| server.advertisedAddress | string | `""` | Client-facing address published 
in cluster metadata. Declaring `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` 
instead also works, but setting both is refused at render time. Empty falls 
back to the in-cluster Service DNS name. Ignored in cluster mode, where the 
address comes from the node's roster entry, so setting both is refused there 
too. |
 | server.affinity | object | `{}` | Affinity rules for server pods |
+| server.cluster.auth | object | 
`{"enabled":false,"existingSecret":{"name":"","previousSharedSecretKey":"clusterPreviousSharedSecret","sharedSecretKey":"clusterSharedSecret"},"previousSharedSecret":"","sharedSecret":""}`
 | Replica-to-replica authentication on the consensus port. When enabled every 
peer must complete an authenticated handshake or be rejected, and a shared 
secret becomes mandatory. Enabling it on a running cluster is a 
coordinated-restart change, not a rolling one. |
+| server.cluster.auth.enabled | bool | `false` | Require the authenticated 
replica handshake |
+| server.cluster.auth.existingSecret.name | string | `""` | Name of an 
existing Secret holding the pre-shared keys |
+| server.cluster.auth.existingSecret.previousSharedSecretKey | string | 
`"clusterPreviousSharedSecret"` | Key inside that Secret holding the retiring 
shared secret |
+| server.cluster.auth.existingSecret.sharedSecretKey | string | 
`"clusterSharedSecret"` | Key inside that Secret holding the active shared 
secret |
+| server.cluster.auth.previousSharedSecret | string | `""` | Retiring key, 
accepted for verification only while a rotation is in flight. Leave empty 
outside a rotation. |
+| server.cluster.auth.sharedSecret | string | `""` | Cluster-wide pre-shared 
key, at least 32 bytes of CSPRNG output, byte-identical on every node. Ignored 
when `existingSecret.name` is set. |
+| server.cluster.enabled | bool | `false` | Enable cluster (VSR consensus) 
mode. One Helm release per node: every release shares the same `nodes` roster 
and overrides only `selfReplicaId`. See the Cluster Mode section of the chart 
README. |
+| server.cluster.name | string | `"iggy-cluster"` | Cluster name, 
byte-identical on every node. Hashed into the on-disk cluster id on first boot, 
so changing it later means starting from an empty data directory. |
+| server.cluster.nodes | list | `[]` | Cluster roster, mirroring the server's 
`[[cluster.nodes]]` config. Every node runs the identical list. `ip` is the 
replica-plane address: this node's consensus listener binds it verbatim and 
every peer dials it verbatim, so it must be a literal IP that the pod itself 
owns. With `server.hostNetwork` that is the node IP. `ports.tcpReplica` is 
required on every entry; the remaining ports default to `server.ports`. |
+| server.cluster.requireHostNetwork | bool | `true` | Refuse to render a 
cluster node without `server.hostNetwork`. The replica listener binds the 
roster `ip` verbatim, which no pod owns on the cluster network, so the pod 
would die at boot with `CannotBindToSocket`. Set this to false only when the 
roster `ip` is an address the pod itself holds. |
+| server.cluster.selfReplicaId | int | `0` | Which `nodes` entry this release 
runs, matched against `replicaId`. |
 | server.enabled | bool | `true` | Enable the Iggy server deployment |
+| server.encryption | object | 
`{"enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""}` 
| Server-side encryption of message payloads and state commands, using 
AES-256-GCM. Every node of a cluster must hold the identical key, or it cannot 
read data another node wrote. |
+| server.encryption.enabled | bool | `false` | Enable encryption at rest |
+| server.encryption.existingSecret.key | string | `"encryptionKey"` | Key 
inside that Secret |
+| server.encryption.existingSecret.name | string | `""` | Name of an existing 
Secret holding the encryption key |
+| server.encryption.key | string | `""` | 32-byte key, base64 encoded. Ignored 
when `existingSecret.name` is set. Prefer `existingSecret` outside development: 
a value here is stored in the Helm release and readable by anyone who can read 
it. |
 | server.env | list | 
`[{"name":"RUST_LOG","value":"info"},{"name":"IGGY_HTTP_ADDRESS","value":"0.0.0.0:3000"},{"name":"IGGY_TCP_ADDRESS","value":"0.0.0.0:8090"},{"name":"IGGY_QUIC_ADDRESS","value":"0.0.0.0:8080"},{"name":"IGGY_WEBSOCKET_ADDRESS","value":"0.0.0.0:8092"}]`
 | Environment variables for the server container |
+| server.extraArgs | list | `[]` | Extra command-line arguments appended to 
the server entrypoint, e.g. `["--with-default-root-credentials"]` for a 
throwaway development install. `--replica-id` is not one of them: the chart 
passes it already whenever `cluster.enabled` is set. |
+| server.hostNetwork | bool | `false` | Run the server pod in the host network 
namespace. Required for cluster mode, where the replica listener binds the 
roster IP verbatim. |
 | server.image.pullPolicy | string | `"Always"` | Image pull policy |
 | server.image.repository | string | `"apache/iggy"` | Server image repository 
|
-| server.image.tag | string | `"0.7.0"` | Server image tag (overrides chart 
appVersion) |
+| server.image.tag | string | `""` | Server image tag. Empty uses the chart 
appVersion. |
 | server.ingress.annotations | object | `{}` | Ingress annotations 
(controller-specific) |
 | server.ingress.className | string | `""` | Ingress class name 
(controller-neutral) |
 | server.ingress.enabled | bool | `false` | Enable ingress for the server |
 | server.ingress.hosts | list | 
`[{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}]`
 | Ingress hosts configuration |
 | server.ingress.tls | list | `[]` | Ingress TLS configuration |
+| server.jwt | object | 
`{"decodingSecret":"","encodingSecret":"","existingSecret":{"decodingSecretKey":"jwtDecodingSecret","encodingSecretKey":"jwtEncodingSecret","name":""}}`
 | Secrets used to sign and validate HTTP bearer tokens. Left unset, each node 
generates a random secret on every start, which invalidates tokens across 
restarts and keeps them node-local. Setting the identical secret on every node 
makes bearers valid cluster-wide and activates follower to primary HTTP 
forwarding;  [...]
+| server.jwt.decodingSecret | string | `""` | Decoding secret. Ignored when 
`existingSecret.name` is set. |
+| server.jwt.encodingSecret | string | `""` | Encoding secret. Ignored when 
`existingSecret.name` is set. |
+| server.jwt.existingSecret.decodingSecretKey | string | `"jwtDecodingSecret"` 
| Key inside that Secret holding the decoding secret |
+| server.jwt.existingSecret.encodingSecretKey | string | `"jwtEncodingSecret"` 
| Key inside that Secret holding the encoding secret |
+| server.jwt.existingSecret.name | string | `""` | Name of an existing Secret 
holding the JWT secrets |
 | server.nodeSelector | object | `{}` | Node selector for server pods |
 | server.persistence.accessMode | string | `"ReadWriteOnce"` | PVC access mode 
|
 | server.persistence.annotations | object | `{}` | PVC annotations |
@@ -357,8 +534,9 @@ pre-commit install
 | server.persistence.size | string | `"8Gi"` | PVC storage size |
 | server.persistence.storageClass | string | `""` | Storage class for PVC 
(empty uses default provisioner) |
 | server.ports.http | int | `3000` | HTTP API port |
-| server.ports.quic | int | `8080` | QUIC protocol port |
+| server.ports.quic | int | `8080` | QUIC protocol port (UDP) |
 | server.ports.tcp | int | `8090` | TCP protocol port |
+| server.ports.websocket | int | `8092` | WebSocket protocol port |
 | server.replicaCount | int | `1` | Number of server replicas |
 | server.service.port | int | `3000` | Service port for the server |
 | server.service.type | string | `"ClusterIP"` | Service type for the server |
@@ -370,6 +548,7 @@ pre-commit install
 | server.serviceMonitor.namespace | string | `""` | Namespace to deploy the 
ServiceMonitor |
 | server.serviceMonitor.path | string | `"/metrics"` | Path to scrape metrics 
from |
 | server.serviceMonitor.scrapeTimeout | string | `"10s"` | Timeout for scrape 
metrics request |
+| server.strategy | object | `{}` | Deployment update strategy. Empty lets the 
chart choose: `Recreate` when `hostNetwork` is set, because a rolling update 
would wait forever for a replacement pod that cannot bind host ports the 
outgoing pod still holds, and the Kubernetes default otherwise. |
 | server.tolerations | list | `[]` | Tolerations for server pods |
 | server.users.root.createSecret | bool | `true` | Create a secret for the 
root user credentials |
 | server.users.root.existingSecret.name | string | `""` | Name of existing 
secret for root credentials |
diff --git a/helm/charts/iggy/README.md.gotmpl 
b/helm/charts/iggy/README.md.gotmpl
index a36d30adc..ec1fb50d1 100644
--- a/helm/charts/iggy/README.md.gotmpl
+++ b/helm/charts/iggy/README.md.gotmpl
@@ -101,6 +101,156 @@ If Prometheus Operator is installed and you want 
monitoring, set
 `server.serviceMonitor.enabled=true` in `custom-values.yaml` or pass it on the
 command line with `--set server.serviceMonitor.enabled=true`.
 
+## Cluster Mode
+
+The server runs a Viewstamped Replication cluster when `cluster.enabled` is set
+and each node is told which roster entry it is. The chart models this as **one
+release per node**: every release is handed the same roster and overrides only
+its own identity.
+
+`helm/charts/iggy/examples/cluster-3-node.yaml` is a ready three-node roster.
+Point the `ip` values at the nodes you will pin the releases to, then:
+
+```bash
+for i in 0 1 2; do
+  helm upgrade --install "iggy-n$i" ./helm/charts/iggy \
+    -f ./helm/charts/iggy/examples/cluster-3-node.yaml \
+    --set "server.cluster.selfReplicaId=$i" \
+    --set "server.nodeSelector.kubernetes\.io/hostname=node-$i"
+done
+```
+
+The chart turns `server.cluster` into the server's `IGGY_CLUSTER_*` environment
+variables and passes `--replica-id`, so no configuration file is mounted. The
+values mirror the server's `[[cluster.nodes]]` config one for one.
+
+### Why hostNetwork and node pinning are required
+
+The server's consensus listener binds `cluster.nodes[*].ip` **verbatim**, 
unlike
+the client listeners, which keep their own bind address and take only the port
+from the roster. A pod therefore has to own the address its roster entry names.
+Pod IPs are neither stable nor known before install, and a Service ClusterIP is
+not an address a pod can bind, so the workable layout today is `hostNetwork:
+true` with each release pinned to a node and its roster `ip` set to that node's
+IP. Node IPs are known up front and survive a pod restart, which is what lets a
+replica come back and rejoin.
+
+The cost is real: the server pod shares the node's network namespace and its
+ports. Weigh it before running this in a shared cluster.
+
+If a release lands on a node whose IP is not the one in its roster entry, the
+server refuses to start rather than misbehaving quietly:
+
+```text
+Error: ShardJoinFailures { failures: [ShardJoinFailure { shard_id: 0,
+  kind: Error(Iggy(CannotBindToSocket("10.0.1.11:9090"))) }] }
+```
+
+Check the `nodeSelector` on that release against the `ip` in its roster entry.
+
+### Upgrades
+
+Host ports make a rolling update impossible: the replacement pod cannot bind
+ports the outgoing pod still holds, so it stays `Pending` while the Deployment
+waits for it to become ready. The chart therefore switches the server to the
+`Recreate` strategy whenever `hostNetwork` is set, which takes the node down
+for the length of the restart. Roll **one release at a time** and let the
+cluster regain quorum before starting the next.
+
+Turning `hostNetwork` on for a release that already exists moves the Deployment
+from `RollingUpdate` to `Recreate`. Under Helm 4's server-side apply that is
+rejected, because the patch cannot drop the `rollingUpdate` block the API 
server
+defaulted in:
+
+```text
+Deployment.apps "iggy-n0" is invalid: spec.strategy.rollingUpdate: Forbidden:
+  may not be specified when strategy `type` is 'Recreate'
+```
+
+On Helm 4, run that one upgrade with `helm upgrade --server-side=false`, which
+replaces the strategy in place. Helm 3 has no such flag, since it does not use
+server-side apply. Set `server.strategy` explicitly if you want a different
+strategy.
+
+### Roster rules
+
+* Every node runs the **identical** `server.cluster.nodes` list. Only
+  `selfReplicaId` differs between releases.
+* `replicaId` values are unique and cover `0..N-1` for an `N`-node roster.
+* `ports.tcpReplica` is required on every entry. In cluster mode the server
+  takes every listener port from the roster and will not fall back to defaults,
+  because two nodes on one host would otherwise race for the same socket. The
+  remaining ports default to `server.ports`.
+* `server.cluster.name` is hashed into the on-disk cluster id on first boot.
+  Changing it later makes the server refuse to start against existing data.
+* `ip` must be a literal IP. Hostnames are rejected. Use
+  `advertisedAddress` for the name clients dial, which does accept DNS.
+
+### Secrets
+
+Four settings have to carry the byte-identical value on every node, so they
+belong in one Secret that all releases reference rather than in each release's
+values. Create it in the release namespace before the first install:
+
+```bash
+JWT="$(head -c 32 /dev/urandom | base64)"
+kubectl create secret generic iggy-cluster-secrets \
+  --from-literal=username=iggy \
+  --from-literal=password="$(head -c 24 /dev/urandom | base64)" \
+  --from-literal=clusterSharedSecret="$(head -c 32 /dev/urandom | base64)" \
+  --from-literal=jwtEncodingSecret="$JWT" \
+  --from-literal=jwtDecodingSecret="$JWT" \
+  --from-literal=encryptionKey="$(head -c 32 /dev/urandom | base64)"
+```
+
+`examples/cluster-3-node.yaml` points `server.users.root`, `server.encryption`,
+`server.jwt` and `server.cluster.auth` at that one Secret. All four also accept
+an inline value, which the chart turns into a Secret of its own:
+`<release>-root-credentials` for the root username and password,
+`<release>-secrets` for the other three. That is convenient for a single node
+and a poor fit for a cluster, since the value then lives in every release's
+stored values.
+
+* **`server.users.root`** seeds the root user. Every node creates it locally
+  from its own `IGGY_ROOT_USERNAME` and `IGGY_ROOT_PASSWORD`, so a password 
that
+  differs on one release logs in on that node only, and a first cluster boot
+  refuses to start without both.
+* **`server.cluster.auth`** makes every replica connection complete an
+  authenticated handshake or be rejected. The key is at least 32 bytes of
+  CSPRNG output. Turning it on or off is a coordinated restart of the whole
+  cluster: a node that authenticates cannot talk to one that does not.
+* **`server.encryption`** encrypts message payloads and state commands at rest
+  with AES-256-GCM, under a 32-byte base64 key. A node holding a different key
+  cannot read what its peers wrote.
+* **`server.jwt`** makes HTTP bearer tokens valid on every node and survive a
+  restart. With `cluster.auth` enabled the server already derives a 
cluster-wide
+  key from the replica PSK, so setting the JWT secrets is an alternative to 
that
+  rather than an addition; setting them anyway keeps tokens working if auth is
+  later turned off.
+
+None of the encryption, JWT and replica-auth values is written to the data
+directory, and each is masked as `******` in the startup log.
+
+### Storage
+
+A replica cannot move between nodes without invalidating its roster entry, so
+give each release storage that stays on its node, and size
+`server.persistence` per node rather than for the cluster. A replica that 
starts
+on an empty volume rejoins by state transfer, which is correct but re-reads the
+whole dataset from its peers. `server.persistence.enabled: false` puts the
+replica on `emptyDir` and forces exactly that on every pod replacement, so the
+cluster example enables persistence and leaves `storageClass` for you to point
+at a node-local provisioner.
+
+### What the chart refuses
+
+`server.replicaCount > 1` fails at render time. Scaling the server Deployment
+produces N independent servers behind one Service, all writing the same PVC
+subpath with no lock between them, which corrupts the data directory while
+`helm --wait` still reports success. The chart ships no HorizontalPodAutoscaler
+for the same reason. Cluster size is a roster decision, so add a node to
+`server.cluster.nodes` and install another release instead.
+
 ## Uninstallation
 
 ```bash
@@ -166,7 +316,7 @@ If a previous local smoke install failed and left resources 
behind, reset the sm
 scripts/ci/test-helm.sh cleanup-smoke
 ```
 
-On Apple Silicon hosts, the released `apache/iggy:0.7.0` `arm64` image may 
still fail during the runtime smoke path in kind. If your Docker setup supports 
amd64 emulation well enough, you can try recreating the dedicated smoke cluster 
with:
+On Apple Silicon hosts, the released `arm64` server image may still fail 
during the runtime smoke path in kind. If your Docker setup supports amd64 
emulation well enough, you can try recreating the dedicated smoke cluster with:
 
 ```bash
 HELM_SMOKE_KIND_PLATFORM=linux/amd64 scripts/ci/setup-helm-smoke-cluster.sh
@@ -237,12 +387,18 @@ Ensure the server binds to `0.0.0.0` instead of 
`127.0.0.1`. This is configured
 
 A wildcard bind says which interfaces accept connections, not where clients
 reach the pod, so the server also needs the address to publish in cluster
-metadata. Server builds that carry the setting refuse to start without it;
-older ones, including the `0.7.0` this chart pins by default, have no such
-refusal and log the variable as unknown and ignored. Either way the chart
-sets `IGGY_NODE_ADVERTISED_ADDRESS` to the in-cluster Service DNS name;
-override it with `server.advertisedAddress` when clients arrive through a
-LoadBalancer or an Ingress.
+metadata. Server builds that carry the setting refuse to start without it.
+`{{ template "chart.appVersion" . }}`, the image this chart pins, predates it:
+that build logs `IGGY_NODE_ADVERTISED_ADDRESS` as an unknown variable and
+publishes the bind address, so the chart's default stays inert until the pinned
+image moves past it. Either way the chart sets `IGGY_NODE_ADVERTISED_ADDRESS` 
to
+the in-cluster Service DNS name; override it with `server.advertisedAddress`
+when clients arrive through a LoadBalancer or an Ingress.
+
+In cluster mode the server ignores the variable altogether and publishes each
+node's roster `ip`, or its `advertisedAddress` when the entry carries one, so
+the chart leaves the variable out there and refuses a render that sets
+`server.advertisedAddress` alongside `server.cluster.enabled`.
 
 Declaring `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` yourself works too:
 the chart then leaves its own default out, so the variable is declared once.
diff --git a/helm/charts/iggy/examples/cluster-3-node.yaml 
b/helm/charts/iggy/examples/cluster-3-node.yaml
new file mode 100644
index 000000000..96a9329ed
--- /dev/null
+++ b/helm/charts/iggy/examples/cluster-3-node.yaml
@@ -0,0 +1,130 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Shared roster for a three-node Iggy cluster. Install one release per node,
+# overriding only the identity:
+#
+#   for i in 0 1 2; do
+#     helm upgrade --install "iggy-n$i" ./helm/charts/iggy \
+#       -f ./helm/charts/iggy/examples/cluster-3-node.yaml \
+#       --set "server.cluster.selfReplicaId=$i" \
+#       --set "server.nodeSelector.kubernetes\.io/hostname=node-$i"
+#   done
+#
+# Replace the `ip` values with the IPs of the nodes you pin each release to.
+# The server binds this address verbatim for replica traffic, so with
+# hostNetwork it must be the node's own IP.
+#
+# Every secret below has to be byte-identical on every node, so they live in 
one
+# Secret that all three releases reference rather than in this file. Create it
+# once, in the release namespace, before the first install:
+#
+#   JWT="$(head -c 32 /dev/urandom | base64)"
+#   kubectl create secret generic iggy-cluster-secrets \
+#     --from-literal=username=iggy \
+#     --from-literal=password="$(head -c 24 /dev/urandom | base64)" \
+#     --from-literal=clusterSharedSecret="$(head -c 32 /dev/urandom | base64)" 
\
+#     --from-literal=jwtEncodingSecret="$JWT" \
+#     --from-literal=jwtDecodingSecret="$JWT" \
+#     --from-literal=encryptionKey="$(head -c 32 /dev/urandom | base64)"
+#
+# The JWT encoding and decoding secrets are one HMAC key, which is why both
+# entries carry the same value. Two different values make every token from
+# /users/login fail with 401, and the server only warns about the mismatch.
+
+ui:
+  enabled: false
+
+server:
+  hostNetwork: true
+
+  # The root user is created locally on each node out of that node's own
+  # IGGY_ROOT_* environment, so the credentials have to match across the
+  # releases the same way the keys below do.
+  users:
+    root:
+      createSecret: false
+      existingSecret:
+        name: iggy-cluster-secrets
+
+  # A release is pinned to one node, so its storage has to stay on that node.
+  # On emptyDir every pod replacement wipes the replica, which then refetches
+  # the whole dataset from its peers.
+  persistence:
+    enabled: true
+    # Name a node-local class here (local-path, topolvm, a local volume
+    # provisioner). Empty takes the default provisioner, which may hand out
+    # storage that does not stay on the pinned node.
+    storageClass: ""
+    size: 8Gi
+
+  # Encrypts message payloads and state commands at rest with AES-256-GCM. The
+  # key is cluster-wide: a node that holds a different one cannot read what its
+  # peers wrote.
+  encryption:
+    enabled: true
+    existingSecret:
+      name: iggy-cluster-secrets
+
+  # Makes bearer tokens valid on every node and survive a restart. Redundant
+  # while cluster.auth is enabled, which derives the same key from the replica
+  # PSK, but set explicitly here so tokens keep working if auth is turned off.
+  jwt:
+    existingSecret:
+      name: iggy-cluster-secrets
+
+  cluster:
+    enabled: true
+    name: iggy-cluster
+
+    # Every replica connection completes an authenticated handshake or is
+    # rejected. Turning this on or off is a coordinated restart of the whole
+    # cluster, not a rolling one.
+    auth:
+      enabled: true
+      existingSecret:
+        name: iggy-cluster-secrets
+    nodes:
+      - name: iggy-node-0
+        ip: 10.0.1.10
+        replicaId: 0
+        ports:
+          tcpReplica: 9090
+      - name: iggy-node-1
+        ip: 10.0.1.11
+        replicaId: 1
+        ports:
+          tcpReplica: 9090
+      - name: iggy-node-2
+        ip: 10.0.1.12
+        replicaId: 2
+        ports:
+          tcpReplica: 9090
+
+  env:
+    - name: RUST_LOG
+      value: info
+    # Bind interface only: in cluster mode every listener port comes from the
+    # roster entry for this node.
+    - name: IGGY_HTTP_ADDRESS
+      value: "0.0.0.0:3000"
+    - name: IGGY_TCP_ADDRESS
+      value: "0.0.0.0:8090"
+    - name: IGGY_QUIC_ADDRESS
+      value: "0.0.0.0:8080"
+    - name: IGGY_WEBSOCKET_ADDRESS
+      value: "0.0.0.0:8092"
diff --git a/helm/charts/iggy/templates/_helpers.tpl 
b/helm/charts/iggy/templates/_helpers.tpl
index 92f69780e..f6943c8c4 100644
--- a/helm/charts/iggy/templates/_helpers.tpl
+++ b/helm/charts/iggy/templates/_helpers.tpl
@@ -97,3 +97,237 @@ Create the name of the service account to use
 {{- default "default" .Values.serviceAccount.name }}
   {{- end }}
 {{- end }}
+
+{{/*
+Validate the cluster roster and fail the render with an actionable message.
+Every check here is one the server would otherwise only reject at boot, after
+the pod is already scheduled.
+*/}}
+{{- define "iggy.validateCluster" -}}
+  {{- $cluster := .Values.server.cluster }}
+  {{- if not $cluster.nodes }}
+    {{- fail "server.cluster.enabled is true but server.cluster.nodes is 
empty. Every node runs the identical roster; see the Cluster Mode section of 
the chart README." }}
+  {{- end }}
+  {{- if and $cluster.requireHostNetwork (not .Values.server.hostNetwork) }}
+    {{- fail "server.cluster.enabled is true but server.hostNetwork is false. 
The replica listener binds the roster ip verbatim, which is not an address a 
pod owns on the cluster network, so the pod dies at boot with 
CannotBindToSocket. Set server.hostNetwork to true, or 
server.cluster.requireHostNetwork to false if the roster ip really is one this 
pod owns." }}
+  {{- end }}
+  {{- $root := .Values.server.users.root }}
+  {{- if and (not $root.createSecret) (not $root.existingSecret.name) }}
+    {{- fail "server.users.root has neither createSecret nor 
existingSecret.name, so the container receives no IGGY_ROOT_USERNAME or 
IGGY_ROOT_PASSWORD. A first cluster boot refuses to start without both, because 
every node creates root locally and the credentials have to come out identical 
on all of them." }}
+  {{- end }}
+  {{- $count := len $cluster.nodes }}
+  {{- $seen := dict }}
+  {{- range $cluster.nodes }}
+    {{- if not .name }}
+      {{- fail "every server.cluster.nodes entry needs a name" }}
+    {{- end }}
+    {{- if not .ip }}
+      {{- fail (printf "server.cluster.nodes entry %q has no ip. The replica 
listener binds this address verbatim, so it must be a literal IP the pod owns." 
.name) }}
+    {{- end }}
+    {{- $ip := toString .ip }}
+    {{- if not (regexMatch "^[0-9a-fA-F.:]+$" $ip) }}
+      {{- fail (printf "server.cluster.nodes entry %q has ip %q, which the 
server parses as an IP address and rejects. Use 
server.cluster.nodes[*].advertisedAddress for the hostname clients dial." .name 
$ip) }}
+    {{- end }}
+    {{- if or (eq $ip "0.0.0.0") (eq $ip "::") }}
+      {{- fail (printf "server.cluster.nodes entry %q has the wildcard ip %q. 
Every peer dials this address verbatim, so it has to name one interface." .name 
$ip) }}
+    {{- end }}
+    {{- if kindIs "invalid" .replicaId }}
+      {{- fail (printf "server.cluster.nodes entry %q has no replicaId" .name) 
}}
+    {{- end }}
+    {{- $id := int .replicaId }}
+    {{- if or (lt $id 0) (ge $id $count) }}
+      {{- fail (printf "server.cluster.nodes entry %q has replicaId %d, which 
is outside 0..%d for a %d-node roster" .name $id (sub $count 1) $count) }}
+    {{- end }}
+    {{- if hasKey $seen (printf "%d" $id) }}
+      {{- fail (printf "server.cluster.nodes has two entries with replicaId 
%d; ids must be unique" $id) }}
+    {{- end }}
+    {{- $_ := set $seen (printf "%d" $id) .name }}
+    {{- if not (and .ports .ports.tcpReplica) }}
+      {{- fail (printf "server.cluster.nodes entry %q has no ports.tcpReplica. 
In cluster mode the server takes every listener port from the roster and 
refuses to start without it." .name) }}
+    {{- end }}
+  {{- end }}
+  {{- if not (hasKey $seen (printf "%d" (int $cluster.selfReplicaId))) }}
+    {{- fail (printf "server.cluster.selfReplicaId is %d but no 
server.cluster.nodes entry declares that replicaId. Each release picks its own 
identity out of the shared roster." (int $cluster.selfReplicaId)) }}
+  {{- end }}
+{{- end }}
+
+{{/*
+The ports this release's server binds, as a JSON object. In cluster mode the
+server takes every listener port from its own roster entry and never falls back
+to the top-level ones, so a node whose entry names other ports has to reach the
+container ports, the probes and the Service targets as well. `server.ports`
+supplies the per-field default there and the whole answer outside cluster mode.
+`tcpReplica` has no top-level default because the roster owns it: it is
+mandatory on every entry and there is no replica listener outside cluster mode.
+*/}}
+{{- define "iggy.serverPorts" -}}
+  {{- $ports := .Values.server.ports }}
+  {{- $resolved := dict "http" $ports.http "quic" $ports.quic "tcp" $ports.tcp 
"websocket" $ports.websocket }}
+  {{- if .Values.server.cluster.enabled }}
+    {{- $selfReplicaId := int .Values.server.cluster.selfReplicaId }}
+    {{- range .Values.server.cluster.nodes }}
+      {{- if eq (int .replicaId) $selfReplicaId }}
+        {{- range $name, $port := (default (dict) .ports) }}
+          {{- if $port }}
+            {{- $_ := set $resolved $name $port }}
+          {{- end }}
+        {{- end }}
+      {{- end }}
+    {{- end }}
+  {{- end }}
+  {{- $resolved | toJson }}
+{{- end }}
+
+{{/*
+Render the roster as IGGY_CLUSTER_* environment variables. The server accepts
+the whole cluster config this way, so the chart needs no config file mount.
+*/}}
+{{- define "iggy.clusterEnv" -}}
+  {{- $ports := .Values.server.ports }}
+- name: IGGY_CLUSTER_ENABLED
+  value: "true"
+- name: IGGY_CLUSTER_NAME
+  value: {{ .Values.server.cluster.name | quote }}
+  {{- range .Values.server.cluster.nodes }}
+    {{- $id := int .replicaId }}
+- name: IGGY_CLUSTER_NODES_{{ $id }}_NAME
+  value: {{ .name | quote }}
+- name: IGGY_CLUSTER_NODES_{{ $id }}_IP
+  value: {{ .ip | quote }}
+- name: IGGY_CLUSTER_NODES_{{ $id }}_REPLICA_ID
+  value: {{ $id | quote }}
+    {{- if .advertisedAddress }}
+- name: IGGY_CLUSTER_NODES_{{ $id }}_ADVERTISED_ADDRESS
+  value: {{ .advertisedAddress | quote }}
+    {{- end }}
+- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_TCP
+  value: {{ (default $ports.tcp (and .ports .ports.tcp)) | quote }}
+- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_QUIC
+  value: {{ (default $ports.quic (and .ports .ports.quic)) | quote }}
+- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_HTTP
+  value: {{ (default $ports.http (and .ports .ports.http)) | quote }}
+- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_WEBSOCKET
+  value: {{ (default $ports.websocket (and .ports .ports.websocket)) | quote }}
+- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_TCP_REPLICA
+  value: {{ .ports.tcpReplica | quote }}
+  {{- end }}
+{{- end }}
+
+{{/*
+Name of the Secret holding the cluster-wide secrets the chart generates from
+inline values. Each of encryption, JWT and replica auth may instead point at a
+Secret the operator made, which is the path a multi-node cluster wants: the
+values have to be byte-identical on every node, so they belong in one object
+every release references rather than in each release's values.
+*/}}
+{{- define "iggy.secretName" -}}
+  {{- printf "%s-secrets" (include "iggy.fullname" .) }}
+{{- end }}
+
+{{/*
+True when any secret has to be generated by the chart, i.e. an inline value is
+set for a feature that is switched on and no existing Secret was named for it.
+*/}}
+{{- define "iggy.createsSecret" -}}
+  {{- $server := .Values.server }}
+  {{- $create := false }}
+  {{- if and $server.encryption.enabled (not 
$server.encryption.existingSecret.name) }}
+    {{- $create = true }}
+  {{- end }}
+  {{- if and (or $server.jwt.encodingSecret $server.jwt.decodingSecret) (not 
$server.jwt.existingSecret.name) }}
+    {{- $create = true }}
+  {{- end }}
+  {{- if and $server.cluster.auth.enabled (not 
$server.cluster.auth.existingSecret.name) }}
+    {{- $create = true }}
+  {{- end }}
+  {{- if $create }}true{{ end }}
+{{- end }}
+
+{{/*
+Validate the secret configuration. The server enforces all of this at boot, so
+catching it during render only saves a scheduling round trip, but a cluster
+whose PSK differs between nodes fails as a handshake rejection rather than as
+anything that names the cause.
+*/}}
+{{- define "iggy.validateSecrets" -}}
+  {{- $server := .Values.server }}
+  {{- if $server.encryption.enabled }}
+    {{- if and (not $server.encryption.key) (not 
$server.encryption.existingSecret.name) }}
+      {{- fail "server.encryption.enabled is true but no key was given. Set 
server.encryption.key to a base64-encoded 32-byte key, or point 
server.encryption.existingSecret.name at a Secret holding one." }}
+    {{- end }}
+    {{- if and $server.encryption.key (not 
$server.encryption.existingSecret.name) }}
+      {{- if ne (len (b64dec $server.encryption.key)) 32 }}
+        {{- fail (printf "server.encryption.key decodes to %d bytes. 
AES-256-GCM takes a base64-encoded 32-byte key, and the server fails the boot 
with 'Invalid encryption key' on anything else." (len (b64dec 
$server.encryption.key))) }}
+      {{- end }}
+    {{- end }}
+  {{- end }}
+  {{- if $server.cluster.auth.enabled }}
+    {{- if not $server.cluster.enabled }}
+      {{- fail "server.cluster.auth.enabled is true but server.cluster.enabled 
is false. Replica authentication only applies to the consensus port, which 
exists in cluster mode." }}
+    {{- end }}
+    {{- if and (not $server.cluster.auth.sharedSecret) (not 
$server.cluster.auth.existingSecret.name) }}
+      {{- fail "server.cluster.auth.enabled is true but no shared secret was 
given. Set server.cluster.auth.sharedSecret, or point 
server.cluster.auth.existingSecret.name at a Secret holding one. Every node 
needs the byte-identical value." }}
+    {{- end }}
+    {{- if and $server.cluster.auth.sharedSecret (lt (len 
$server.cluster.auth.sharedSecret) 32) }}
+      {{- fail (printf "server.cluster.auth.sharedSecret is %d bytes; the 
server requires at least 32 bytes of CSPRNG output." (len 
$server.cluster.auth.sharedSecret)) }}
+    {{- end }}
+    {{- if $server.cluster.auth.previousSharedSecret }}
+      {{- if lt (len $server.cluster.auth.previousSharedSecret) 32 }}
+        {{- fail (printf "server.cluster.auth.previousSharedSecret is %d 
bytes; the server requires at least 32 bytes of CSPRNG output." (len 
$server.cluster.auth.previousSharedSecret)) }}
+      {{- end }}
+      {{- if eq $server.cluster.auth.previousSharedSecret 
$server.cluster.auth.sharedSecret }}
+        {{- fail "server.cluster.auth.previousSharedSecret equals 
server.cluster.auth.sharedSecret, which the server rejects as a no-op rotation 
window. Leave it empty outside a rotation." }}
+      {{- end }}
+    {{- end }}
+  {{- end }}
+{{- end }}
+
+{{/*
+Environment entries for the secret-backed settings, each sourced from whichever
+Secret owns it.
+*/}}
+{{- define "iggy.secretEnv" -}}
+  {{- $server := .Values.server }}
+  {{- $generated := include "iggy.secretName" . }}
+  {{- if $server.encryption.enabled }}
+- name: IGGY_SYSTEM_ENCRYPTION_ENABLED
+  value: "true"
+- name: IGGY_SYSTEM_ENCRYPTION_KEY
+  valueFrom:
+    secretKeyRef:
+      name: {{ default $generated $server.encryption.existingSecret.name }}
+      key: {{ ternary $server.encryption.existingSecret.key "encryptionKey" 
(ne $server.encryption.existingSecret.name "") }}
+  {{- end }}
+  {{- if or $server.jwt.existingSecret.name $server.jwt.encodingSecret }}
+- name: IGGY_HTTP_JWT_ENCODING_SECRET
+  valueFrom:
+    secretKeyRef:
+      name: {{ default $generated $server.jwt.existingSecret.name }}
+      key: {{ ternary $server.jwt.existingSecret.encodingSecretKey 
"jwtEncodingSecret" (ne $server.jwt.existingSecret.name "") }}
+  {{- end }}
+  {{- if or $server.jwt.existingSecret.name $server.jwt.decodingSecret }}
+- name: IGGY_HTTP_JWT_DECODING_SECRET
+  valueFrom:
+    secretKeyRef:
+      name: {{ default $generated $server.jwt.existingSecret.name }}
+      key: {{ ternary $server.jwt.existingSecret.decodingSecretKey 
"jwtDecodingSecret" (ne $server.jwt.existingSecret.name "") }}
+      optional: true
+  {{- end }}
+  {{- if $server.cluster.auth.enabled }}
+- name: IGGY_CLUSTER_AUTH_ENABLED
+  value: "true"
+- name: IGGY_CLUSTER_AUTH_SHARED_SECRET
+  valueFrom:
+    secretKeyRef:
+      name: {{ default $generated $server.cluster.auth.existingSecret.name }}
+      key: {{ ternary $server.cluster.auth.existingSecret.sharedSecretKey 
"clusterSharedSecret" (ne $server.cluster.auth.existingSecret.name "") }}
+    {{- if or $server.cluster.auth.previousSharedSecret 
$server.cluster.auth.existingSecret.name }}
+- name: IGGY_CLUSTER_AUTH_PREVIOUS_SHARED_SECRET
+  valueFrom:
+    secretKeyRef:
+      name: {{ default $generated $server.cluster.auth.existingSecret.name }}
+      key: {{ ternary 
$server.cluster.auth.existingSecret.previousSharedSecretKey 
"clusterPreviousSharedSecret" (ne $server.cluster.auth.existingSecret.name "") 
}}
+      optional: true
+    {{- end }}
+  {{- end }}
+{{- end }}
diff --git a/helm/charts/iggy/templates/deployment.yaml 
b/helm/charts/iggy/templates/deployment.yaml
index 4e7b6751e..c99ac9dc9 100644
--- a/helm/charts/iggy/templates/deployment.yaml
+++ b/helm/charts/iggy/templates/deployment.yaml
@@ -19,6 +19,14 @@
   {{- if hasKey .Values.server "podSecurityContext" }}
     {{- fail "server.podSecurityContext has been moved to podSecurityContext 
(root level). Please update your values." }}
   {{- end }}
+  {{- if gt (int .Values.server.replicaCount) 1 }}
+    {{- fail "server.replicaCount > 1 is refused: the replicas share one PVC 
at one subPath and one advertised address, so they would run as independent 
servers writing the same data directory, and the server takes no lock against 
that. Run cluster mode instead: one release per node, each with 
server.cluster.enabled and its own server.cluster.selfReplicaId. See the 
Cluster Mode section of the chart README." }}
+  {{- end }}
+  {{- if .Values.server.cluster.enabled }}
+    {{- include "iggy.validateCluster" . }}
+  {{- end }}
+  {{- include "iggy.validateSecrets" . }}
+  {{- $ports := include "iggy.serverPorts" . | fromJson }}
 ---
 apiVersion: apps/v1
 kind: Deployment
@@ -27,17 +35,38 @@ metadata:
   labels:
     {{- include "iggy.labels" . | nindent 4 }}
 spec:
-  {{- if not .Values.autoscaling.enabled }}
   replicas: {{ .Values.server.replicaCount }}
+  {{- if .Values.server.strategy }}
+  strategy:
+    {{- toYaml .Values.server.strategy | nindent 4 }}
+  {{- else if .Values.server.hostNetwork }}
+  strategy:
+    type: Recreate
   {{- end }}
   selector:
     matchLabels:
       {{- include "iggy.selectorLabels" . | nindent 6 }}
   template:
     metadata:
-  {{- with .Values.podAnnotations }}
+  {{- $secretsChecksum := "" }}
+  {{- if (include "iggy.createsSecret" .) }}
+    {{- $secretsChecksum = (include (print $.Template.BasePath 
"/server-secrets.yaml") . | sha256sum) }}
+  {{- end }}
+  {{- $rootChecksum := "" }}
+  {{- if and .Values.server.users.root.createSecret (not 
.Values.server.users.root.existingSecret.name) }}
+    {{- $rootChecksum = (include (print $.Template.BasePath 
"/root-user-credentials.yaml") . | sha256sum) }}
+  {{- end }}
+  {{- if or $secretsChecksum $rootChecksum .Values.podAnnotations }}
       annotations:
+    {{- if $secretsChecksum }}
+        checksum/server-secrets: {{ $secretsChecksum }}
+    {{- end }}
+    {{- if $rootChecksum }}
+        checksum/root-user-credentials: {{ $rootChecksum }}
+    {{- end }}
+    {{- with .Values.podAnnotations }}
         {{- toYaml . | nindent 8 }}
+    {{- end }}
   {{- end }}
       labels:
         {{- include "iggy.labels" . | nindent 8 }}
@@ -47,6 +76,11 @@ spec:
         {{- toYaml . | nindent 8 }}
   {{- end }}
       serviceAccountName: {{ include "iggy.serviceAccountName" . }}
+      enableServiceLinks: false
+  {{- if .Values.server.hostNetwork }}
+      hostNetwork: true
+      dnsPolicy: ClusterFirstWithHostNet
+  {{- end }}
       securityContext:
         {{- toYaml .Values.podSecurityContext | nindent 8 }}
       containers:
@@ -55,16 +89,34 @@ spec:
             {{- toYaml .Values.securityContext | nindent 12 }}
           image: "{{ .Values.server.image.repository }}:{{ 
.Values.server.image.tag | default .Chart.AppVersion }}"
           imagePullPolicy: {{ .Values.server.image.pullPolicy }}
+  {{- if or .Values.server.cluster.enabled .Values.server.extraArgs }}
+          args:
+    {{- if .Values.server.cluster.enabled }}
+            - "--replica-id"
+            - {{ .Values.server.cluster.selfReplicaId | quote }}
+    {{- end }}
+    {{- range .Values.server.extraArgs }}
+            - {{ . | quote }}
+    {{- end }}
+  {{- end }}
           ports:
             - name: http
-              containerPort: {{ .Values.server.ports.http }}
+              containerPort: {{ int $ports.http }}
               protocol: TCP
             - name: tcp
-              containerPort: {{ .Values.server.ports.tcp }}
+              containerPort: {{ int $ports.tcp }}
+              protocol: TCP
+            - name: websocket
+              containerPort: {{ int $ports.websocket }}
               protocol: TCP
             - name: quic
-              containerPort: {{ .Values.server.ports.quic }}
+              containerPort: {{ int $ports.quic }}
+              protocol: UDP
+  {{- if .Values.server.cluster.enabled }}
+            - name: tcp-replica
+              containerPort: {{ int $ports.tcpReplica }}
               protocol: TCP
+  {{- end }}
           env:
   {{- if .Values.server.users.root.existingSecret.name }}
             - name: IGGY_ROOT_USERNAME
@@ -98,13 +150,22 @@ spec:
       {{- $declaredInEnv = true }}
     {{- end }}
   {{- end }}
+  {{- if and .Values.server.cluster.enabled .Values.server.advertisedAddress }}
+    {{- fail "server.advertisedAddress is set together with 
server.cluster.enabled. In cluster mode the server ignores 
IGGY_NODE_ADVERTISED_ADDRESS and publishes the roster entry's ip, or its 
advertisedAddress when one is given, so set it on this node's 
server.cluster.nodes entry instead." }}
+  {{- end }}
   {{- if and $declaredInEnv .Values.server.advertisedAddress }}
     {{- fail "IGGY_NODE_ADVERTISED_ADDRESS is set in server.env and 
server.advertisedAddress is also set. The server.env entry wins and 
server.advertisedAddress is ignored. Please set only one." }}
   {{- end }}
-  {{- if not $declaredInEnv }}
+  {{- if and (not $declaredInEnv) (not .Values.server.cluster.enabled) }}
             - name: IGGY_NODE_ADVERTISED_ADDRESS
               value: {{ .Values.server.advertisedAddress | default (printf 
"%s.%s.svc.cluster.local" (include "iggy.fullname" .) .Release.Namespace) | 
quote }}
   {{- end }}
+  {{- if .Values.server.cluster.enabled }}
+            {{- include "iggy.clusterEnv" . | nindent 12 }}
+  {{- end }}
+  {{- with (include "iggy.secretEnv" . | trim) }}
+            {{- . | nindent 12 }}
+  {{- end }}
   {{- if .Values.server.env }}
     {{- range .Values.server.env }}
             - name: {{ .name }}
@@ -173,7 +234,7 @@ spec:
         {{- toYaml . | nindent 8 }}
   {{- end }}
       labels:
-        {{- include "iggy-ui.labels" . | nindent 8 }}-ui
+        {{- include "iggy-ui.labels" . | nindent 8 }}
     spec:
   {{- with .Values.imagePullSecrets }}
       imagePullSecrets:
diff --git a/helm/charts/iggy/templates/hpa.yaml 
b/helm/charts/iggy/templates/hpa.yaml
deleted file mode 100644
index d1f658129..000000000
--- a/helm/charts/iggy/templates/hpa.yaml
+++ /dev/null
@@ -1,61 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-{{ if .Values.autoscaling.enabled -}}
-  {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion -}}
-apiVersion: autoscaling/v2
-  {{- else -}}
-apiVersion: autoscaling/v2beta2
-  {{- end }}
-kind: HorizontalPodAutoscaler
-metadata:
-  name: {{ include "iggy.fullname" . }}
-  labels:
-    {{- include "iggy.labels" . | nindent 4 }}
-spec:
-  scaleTargetRef:
-    apiVersion: apps/v1
-    kind: Deployment
-    name: {{ include "iggy.fullname" . }}
-  minReplicas: {{ .Values.autoscaling.minReplicas }}
-  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
-  metrics:
-  {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
-    - type: Resource
-      resource:
-        name: cpu
-    {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
-        target:
-          type: Utilization
-          averageUtilization: {{ 
.Values.autoscaling.targetCPUUtilizationPercentage }}
-    {{- else }}
-        targetAverageUtilization: {{ 
.Values.autoscaling.targetCPUUtilizationPercentage }}
-    {{- end }}
-  {{- end }}
-  {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
-    - type: Resource
-      resource:
-        name: memory
-    {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }}
-        target:
-          type: Utilization
-          averageUtilization: {{ 
.Values.autoscaling.targetMemoryUtilizationPercentage }}
-    {{- else }}
-        targetAverageUtilization: {{ 
.Values.autoscaling.targetMemoryUtilizationPercentage }}
-    {{- end }}
-  {{- end }}
-{{- end }}
diff --git a/helm/charts/iggy/templates/server-secrets.yaml 
b/helm/charts/iggy/templates/server-secrets.yaml
new file mode 100644
index 000000000..e75e6e498
--- /dev/null
+++ b/helm/charts/iggy/templates/server-secrets.yaml
@@ -0,0 +1,44 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+{{- if and .Values.server.enabled (include "iggy.createsSecret" .) }}
+apiVersion: v1
+kind: Secret
+metadata:
+  name: {{ include "iggy.secretName" . }}
+  labels:
+    {{- include "iggy.labels" . | nindent 4 }}
+type: Opaque
+stringData:
+  {{- if and .Values.server.encryption.enabled (not 
.Values.server.encryption.existingSecret.name) }}
+  encryptionKey: {{ .Values.server.encryption.key | quote }}
+  {{- end }}
+  {{- if not .Values.server.jwt.existingSecret.name }}
+    {{- with .Values.server.jwt.encodingSecret }}
+  jwtEncodingSecret: {{ . | quote }}
+    {{- end }}
+    {{- with .Values.server.jwt.decodingSecret }}
+  jwtDecodingSecret: {{ . | quote }}
+    {{- end }}
+  {{- end }}
+  {{- if and .Values.server.cluster.auth.enabled (not 
.Values.server.cluster.auth.existingSecret.name) }}
+  clusterSharedSecret: {{ .Values.server.cluster.auth.sharedSecret | quote }}
+    {{- with .Values.server.cluster.auth.previousSharedSecret }}
+  clusterPreviousSharedSecret: {{ . | quote }}
+    {{- end }}
+  {{- end }}
+{{- end }}
diff --git a/helm/charts/iggy/templates/service.yaml 
b/helm/charts/iggy/templates/service.yaml
index 982004424..4294e3828 100644
--- a/helm/charts/iggy/templates/service.yaml
+++ b/helm/charts/iggy/templates/service.yaml
@@ -32,11 +32,15 @@ spec:
     - name: quic
       port: {{ .Values.server.ports.quic }}
       targetPort: quic
-      protocol: TCP
+      protocol: UDP
     - name: tcp
       port: {{ .Values.server.ports.tcp }}
       targetPort: tcp
       protocol: TCP
+    - name: websocket
+      port: {{ .Values.server.ports.websocket }}
+      targetPort: websocket
+      protocol: TCP
   selector:
     {{- include "iggy.selectorLabels" . | nindent 4 }}
 {{- end }}
diff --git a/helm/charts/iggy/values.yaml b/helm/charts/iggy/values.yaml
index be9757156..ab1a5f728 100644
--- a/helm/charts/iggy/values.yaml
+++ b/helm/charts/iggy/values.yaml
@@ -20,7 +20,8 @@ server:
   # -- Client-facing address published in cluster metadata. Declaring
   # `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` instead also works, but
   # setting both is refused at render time. Empty falls back to the in-cluster
-  # Service DNS name.
+  # Service DNS name. Ignored in cluster mode, where the address comes from the
+  # node's roster entry, so setting both is refused there too.
   advertisedAddress: ""
   # -- Enable the Iggy server deployment
   enabled: true
@@ -31,15 +32,17 @@ server:
     repository: apache/iggy
     # -- Image pull policy
     pullPolicy: Always
-    # -- Server image tag (overrides chart appVersion)
-    tag: "0.7.0"
+    # -- Server image tag. Empty uses the chart appVersion.
+    tag: ""
   ports:
     # -- HTTP API port
     http: 3000
-    # -- QUIC protocol port
+    # -- QUIC protocol port (UDP)
     quic: 8080
     # -- TCP protocol port
     tcp: 8090
+    # -- WebSocket protocol port
+    websocket: 8092
 
   service:
     # -- Service type for the server
@@ -100,6 +103,115 @@ server:
     # -- PVC storage size
     size: 8Gi
 
+  # -- Server-side encryption of message payloads and state commands, using
+  # AES-256-GCM. Every node of a cluster must hold the identical key, or it
+  # cannot read data another node wrote.
+  encryption:
+    # -- Enable encryption at rest
+    enabled: false
+    # -- 32-byte key, base64 encoded. Ignored when `existingSecret.name` is 
set.
+    # Prefer `existingSecret` outside development: a value here is stored in 
the
+    # Helm release and readable by anyone who can read it.
+    key: ""
+    existingSecret:
+      # -- Name of an existing Secret holding the encryption key
+      name: ""
+      # -- Key inside that Secret
+      key: encryptionKey
+
+  # -- Secrets used to sign and validate HTTP bearer tokens. Left unset, each
+  # node generates a random secret on every start, which invalidates tokens
+  # across restarts and keeps them node-local. Setting the identical secret on
+  # every node makes bearers valid cluster-wide and activates follower to
+  # primary HTTP forwarding; `cluster.auth.enabled` derives the same thing from
+  # the replica PSK, so it is an alternative rather than an addition.
+  jwt:
+    # -- Encoding secret. Ignored when `existingSecret.name` is set.
+    encodingSecret: ""
+    # -- Decoding secret. Ignored when `existingSecret.name` is set.
+    decodingSecret: ""
+    existingSecret:
+      # -- Name of an existing Secret holding the JWT secrets
+      name: ""
+      # -- Key inside that Secret holding the encoding secret
+      encodingSecretKey: jwtEncodingSecret
+      # -- Key inside that Secret holding the decoding secret
+      decodingSecretKey: jwtDecodingSecret
+
+  cluster:
+    # -- Enable cluster (VSR consensus) mode. One Helm release per node: every
+    # release shares the same `nodes` roster and overrides only 
`selfReplicaId`.
+    # See the Cluster Mode section of the chart README.
+    enabled: false
+    # -- Cluster name, byte-identical on every node. Hashed into the on-disk
+    # cluster id on first boot, so changing it later means starting from an
+    # empty data directory.
+    name: iggy-cluster
+    # -- Which `nodes` entry this release runs, matched against `replicaId`.
+    selfReplicaId: 0
+    # -- Refuse to render a cluster node without `server.hostNetwork`. The
+    # replica listener binds the roster `ip` verbatim, which no pod owns on the
+    # cluster network, so the pod would die at boot with `CannotBindToSocket`.
+    # Set this to false only when the roster `ip` is an address the pod itself
+    # holds.
+    requireHostNetwork: true
+    # -- Replica-to-replica authentication on the consensus port. When enabled
+    # every peer must complete an authenticated handshake or be rejected, and a
+    # shared secret becomes mandatory. Enabling it on a running cluster is a
+    # coordinated-restart change, not a rolling one.
+    auth:
+      # -- Require the authenticated replica handshake
+      enabled: false
+      # -- Cluster-wide pre-shared key, at least 32 bytes of CSPRNG output,
+      # byte-identical on every node. Ignored when `existingSecret.name` is 
set.
+      sharedSecret: ""
+      # -- Retiring key, accepted for verification only while a rotation is in
+      # flight. Leave empty outside a rotation.
+      previousSharedSecret: ""
+      existingSecret:
+        # -- Name of an existing Secret holding the pre-shared keys
+        name: ""
+        # -- Key inside that Secret holding the active shared secret
+        sharedSecretKey: clusterSharedSecret
+        # -- Key inside that Secret holding the retiring shared secret
+        previousSharedSecretKey: clusterPreviousSharedSecret
+
+    # -- Cluster roster, mirroring the server's `[[cluster.nodes]]` config.
+    # Every node runs the identical list. `ip` is the replica-plane address:
+    # this node's consensus listener binds it verbatim and every peer dials it
+    # verbatim, so it must be a literal IP that the pod itself owns. With
+    # `server.hostNetwork` that is the node IP. `ports.tcpReplica` is required
+    # on every entry; the remaining ports default to `server.ports`.
+    nodes: []
+    # nodes:
+    #   - name: iggy-node-0
+    #     ip: 10.0.1.5
+    #     replicaId: 0
+    #     # -- Optional client-facing address, a literal IP or a DNS hostname.
+    #     advertisedAddress: ""
+    #     ports:
+    #       tcp: 8090
+    #       quic: 8080
+    #       http: 3000
+    #       websocket: 8092
+    #       tcpReplica: 9090
+
+  # -- Run the server pod in the host network namespace. Required for cluster
+  # mode, where the replica listener binds the roster IP verbatim.
+  hostNetwork: false
+
+  # -- Deployment update strategy. Empty lets the chart choose: `Recreate` when
+  # `hostNetwork` is set, because a rolling update would wait forever for a
+  # replacement pod that cannot bind host ports the outgoing pod still holds,
+  # and the Kubernetes default otherwise.
+  strategy: {}
+
+  # -- Extra command-line arguments appended to the server entrypoint, e.g.
+  # `["--with-default-root-credentials"]` for a throwaway development install.
+  # `--replica-id` is not one of them: the chart passes it already whenever
+  # `cluster.enabled` is set.
+  extraArgs: []
+
   # -- Environment variables for the server container
   env:
     - name: RUST_LOG
@@ -232,15 +344,3 @@ securityContext:
 
 # -- Resource limits and requests for server
 resources: {}
-
-autoscaling:
-  # -- Enable horizontal pod autoscaling
-  enabled: false
-  # -- Minimum replicas for autoscaling
-  minReplicas: 1
-  # -- Maximum replicas for autoscaling
-  maxReplicas: 100
-  # -- Target CPU utilization for autoscaling
-  targetCPUUtilizationPercentage: 80
-  # -- Target memory utilization for autoscaling (optional)
-  # targetMemoryUtilizationPercentage: 80
diff --git a/scripts/ci/test-helm.sh b/scripts/ci/test-helm.sh
index 370b2f00b..531522314 100755
--- a/scripts/ci/test-helm.sh
+++ b/scripts/ci/test-helm.sh
@@ -53,6 +53,9 @@ 
HELM_SMOKE_GATEWAY_NAME="${HELM_SMOKE_GATEWAY_NAME:-iggy-smoke-gateway}"
 HELM_SMOKE_GATEWAY_PF_PORT="${HELM_SMOKE_GATEWAY_PF_PORT:-8080}"
 HELM_SMOKE_KIND_NAME="${HELM_SMOKE_KIND_NAME:-iggy-helm-smoke}"
 HELM_SMOKE_SERVER_CPU_ALLOCATION="${HELM_SMOKE_SERVER_CPU_ALLOCATION:-1}"
+# A well-formed 32-byte AES-256-GCM key, base64 encoded. Render-only: it never
+# reaches a running server.
+HELM_TEST_ENCRYPTION_KEY="AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
 
 # HELM_SMOKE_GATEWAY_NAMESPACE and HELM_SMOKE_GATEWAY_NAME must match the
 # defaults in scripts/ci/setup-helm-smoke-cluster.sh - the HTTPRoute
@@ -197,12 +200,10 @@ validate() {
 
   local chart_version
   local chart_app_version
-  local server_image_tag
   local ui_image_tag
 
   chart_version="$(extract_chart_field version)"
   chart_app_version="$(extract_chart_field appVersion)"
-  server_image_tag="$(extract_values_tag server)"
   ui_image_tag="$(extract_values_tag ui)"
 
   prepare_render_dir
@@ -217,19 +218,16 @@ validate() {
   grep -q "helm.sh/chart: iggy-${chart_version}" 
"$HELM_RENDER_DIR/default.yaml"
   grep -q "helm.sh/chart: iggy-ui-${chart_version}" 
"$HELM_RENDER_DIR/default.yaml"
   grep -q "app.kubernetes.io/version: \"${chart_app_version}\"" 
"$HELM_RENDER_DIR/default.yaml"
-  grep -q "image: \"apache/iggy:${server_image_tag}\"" 
"$HELM_RENDER_DIR/default.yaml"
+  grep -q "image: \"apache/iggy:${chart_app_version}\"" 
"$HELM_RENDER_DIR/default.yaml"
   grep -q "image: \"apache/iggy-web-ui:${ui_image_tag}\"" 
"$HELM_RENDER_DIR/default.yaml"
 
   helm template iggy "$CHART_DIR" \
     --set server.persistence.enabled=true \
-    --set autoscaling.enabled=true \
-    --set autoscaling.targetCPUUtilizationPercentage=80 \
     --set server.ingress.enabled=true \
     --set ui.ingress.enabled=true \
     --set server.serviceMonitor.enabled=true \
     > "$HELM_RENDER_DIR/all-features.yaml"
   grep -q '^kind: PersistentVolumeClaim$' "$HELM_RENDER_DIR/all-features.yaml"
-  grep -q '^kind: HorizontalPodAutoscaler$' 
"$HELM_RENDER_DIR/all-features.yaml"
   test "$(grep -c '^kind: Ingress$' "$HELM_RENDER_DIR/all-features.yaml")" -eq 
2
   test "$(extract_kind_names "$HELM_RENDER_DIR/all-features.yaml" Ingress | 
sort -u | wc -l | tr -d ' ')" -eq 2
   extract_kind_names "$HELM_RENDER_DIR/all-features.yaml" Ingress | grep -qx 
'iggy'
@@ -239,14 +237,10 @@ validate() {
   helm template iggy "$CHART_DIR" \
     --kube-version 1.18.0 \
     --api-versions networking.k8s.io/v1beta1 \
-    --api-versions autoscaling/v2beta2 \
-    --set autoscaling.enabled=true \
-    --set autoscaling.targetCPUUtilizationPercentage=80 \
     --set server.ingress.enabled=true \
     --set ui.ingress.enabled=true \
     > "$HELM_RENDER_DIR/legacy-k8s-1.18.yaml"
   test "$(grep -c '^apiVersion: networking.k8s.io/v1beta1$' 
"$HELM_RENDER_DIR/legacy-k8s-1.18.yaml")" -eq 2
-  grep -q '^apiVersion: autoscaling/v2beta2$' 
"$HELM_RENDER_DIR/legacy-k8s-1.18.yaml"
 
   helm template iggy "$CHART_DIR" --set ui.enabled=false > 
"$HELM_RENDER_DIR/server-only.yaml"
   test "$(grep -c '^kind: Deployment$' "$HELM_RENDER_DIR/server-only.yaml")" 
-eq 1
@@ -266,11 +260,69 @@ validate() {
   fi
   grep -q 'name: supersecret' "$HELM_RENDER_DIR/existing-secret.yaml"
 
+  helm template iggy "$CHART_DIR" \
+    -f "$CHART_DIR/examples/cluster-3-node.yaml" \
+    --set server.cluster.selfReplicaId=1 \
+    > "$HELM_RENDER_DIR/cluster.yaml"
+  grep -q 'name: IGGY_CLUSTER_ENABLED' "$HELM_RENDER_DIR/cluster.yaml"
+  grep -q 'name: IGGY_CLUSTER_NODES_2_PORTS_TCP_REPLICA' 
"$HELM_RENDER_DIR/cluster.yaml"
+  grep -q -- '- "--replica-id"' "$HELM_RENDER_DIR/cluster.yaml"
+  grep -q '^      hostNetwork: true$' "$HELM_RENDER_DIR/cluster.yaml"
+  grep -q 'name: tcp-replica' "$HELM_RENDER_DIR/cluster.yaml"
+
+  grep -q 'name: IGGY_CLUSTER_AUTH_SHARED_SECRET' 
"$HELM_RENDER_DIR/cluster.yaml"
+  grep -q 'name: IGGY_SYSTEM_ENCRYPTION_KEY' "$HELM_RENDER_DIR/cluster.yaml"
+  grep -q 'name: IGGY_HTTP_JWT_ENCODING_SECRET' "$HELM_RENDER_DIR/cluster.yaml"
+  if grep -qE '^ +(encryptionKey|clusterSharedSecret|jwtEncodingSecret):' 
"$HELM_RENDER_DIR/cluster.yaml"; then
+    echo "Error: cluster render inlined a secret value instead of referencing 
the existing Secret" >&2
+    exit 1
+  fi
+
+  helm template iggy "$CHART_DIR" \
+    --set server.encryption.enabled=true \
+    --set-string server.encryption.key="$HELM_TEST_ENCRYPTION_KEY" \
+    > "$HELM_RENDER_DIR/generated-secret.yaml"
+  grep -q "^  encryptionKey: \"${HELM_TEST_ENCRYPTION_KEY}\"$" 
"$HELM_RENDER_DIR/generated-secret.yaml"
+  grep -q '^  name: iggy-secrets$' "$HELM_RENDER_DIR/generated-secret.yaml"
+  grep -q '^                  name: iggy-secrets$' 
"$HELM_RENDER_DIR/generated-secret.yaml"
+  grep -q '^                  key: encryptionKey$' 
"$HELM_RENDER_DIR/generated-secret.yaml"
+  test "$(grep -c '^kind: Secret$' "$HELM_RENDER_DIR/generated-secret.yaml")" 
-eq 2
+
+  assert_render_rejected "server.replicaCount=3" --set server.replicaCount=3
+  assert_render_rejected "encryption without a key" --set 
server.encryption.enabled=true
+  assert_render_rejected "an encryption key that is not 32 bytes" \
+    --set server.encryption.enabled=true \
+    --set-string server.encryption.key=bm90LTMyLWJ5dGVz
+  assert_render_rejected "replica auth without a secret" \
+    -f "$CHART_DIR/examples/cluster-3-node.yaml" \
+    --set server.cluster.auth.existingSecret.name="" \
+    --set server.cluster.selfReplicaId=0
+  assert_render_rejected "a shared secret under 32 bytes" \
+    -f "$CHART_DIR/examples/cluster-3-node.yaml" \
+    --set server.cluster.auth.existingSecret.name="" \
+    --set-string server.cluster.auth.sharedSecret=tooshort \
+    --set server.cluster.selfReplicaId=0
+  assert_render_rejected "cluster without a roster" --set 
server.cluster.enabled=true
+  assert_render_rejected "selfReplicaId outside the roster" \
+    -f "$CHART_DIR/examples/cluster-3-node.yaml" --set 
server.cluster.selfReplicaId=9
+
   validate_yamllint
   validate_helmfmt
   validate_helm_docs
 }
 
+# Assert that `helm template` refuses a values combination the chart guards
+# against. A guard that stops failing is a silent data-corruption regression,
+# so the render succeeding is the error case here.
+assert_render_rejected() {
+  local description="$1"
+  shift
+  if helm template iggy "$CHART_DIR" "$@" > /dev/null 2>&1; then
+    echo "Error: chart rendered ${description}, but that combination must be 
refused" >&2
+    exit 1
+  fi
+}
+
 # PID of the kubectl port-forward process started by smoke().
 # Stored at script scope so the EXIT trap can kill it on any code path.
 HELM_SMOKE_GW_PF_PID=""

Reply via email to