feat: direct clients to external shard endpoints - #280
Conversation
### Summary Introduce an optional `externalAccess` block on ValkeyCluster as the foundation for exposing a cluster to clients outside Kubernetes. When the field is omitted the cluster is internal-only and renders identically to before. As the first capability under this flag, enabling external access announces a human-readable node name so cluster events reference the ValkeyNode name instead of only the opaque node ID. ### Implementation - Added `ExternalAccessSpec` (currently `enabled`) to `ValkeyClusterSpec` and mirrored the field onto `ValkeyNodeSpec`, copied verbatim in `buildClusterValkeyNode` alongside the other propagated spec fields. - When external access is enabled, `buildContainersDef` appends `--cluster-announce-human-nodename <node name>` to the server command, reusing the existing CLI-arg seam that already sets `--cluster-announce-ip`. Node-to-node traffic is unaffected. - `cluster-announce-human-nodename` is a Valkey 9.0+ directive, which matches the operator's documented baseline. ### Limitations This change only adds the API and the human-nodename announce. Per-shard Services, external hostnames, and client endpoint selection are added in follow-up changes. ### Testing - Unit tests assert the human nodename is announced when enabled and that a nil or disabled `externalAccess` leaves the rendered command unchanged. - `make test` and `make lint` pass locally. Signed-off-by: Steven Crothers <steven@scrothers.com>
### Summary When external access is enabled, create one Service per shard that exposes each node on its own port, and report the resulting external ports per shard under `status.externalEndpoints`. This is the networking layer that makes a cluster reachable from outside Kubernetes; node-to-node traffic is unaffected. ### Features / Behaviour Changes - `externalAccess` gains `serviceType` (NodePort default, or LoadBalancer), `externalTrafficPolicy`, and `serviceAnnotations`. - `status.externalEndpoints` reports each shard's external ports, indexed by node, so users can discover Kubernetes-allocated NodePorts. ### Implementation - `reconcileShardServices` upserts a Service per shard, selecting that shard's pods. Each Service has one port per node whose `targetPort` references a node-unique container port name (`vk-n<idx>`), so a port resolves to exactly one pod. The server container port is renamed accordingly when external access is enabled. - NodePort ports are allocated by Kubernetes and read back from the Service (preserved across updates); LoadBalancer ports are `6379 + nodeIndex`. - Shard Services carry the standard labels (including managed-by, so the manager cache sees them) and are owned by the cluster. Services for shards beyond the desired count, or all of them when disabled, are deleted. - `updateStatus` persists `externalEndpoints` alongside the conditions. ### Limitations External hostnames and client endpoint selection (so cross-shard MOVED redirects resolve externally) are added in follow-up changes. DNS is the user's responsibility; the operator only sets the configured annotations. ### Testing - Unit tests cover the per-node port layout, NodePort preservation, and the NodePort vs LoadBalancer endpoint reporting. - envtest covers per-shard Service creation, the managed-by label, a no-op second reconcile with stable ports, and scale-in / disable teardown. - An e2e spec exercises NodePort allocation, status reporting, and single-endpoint-per-port resolution on a kind cluster. - `make test` and `make lint` pass locally. Signed-off-by: Steven Crothers <steven@scrothers.com>
### Summary Add a per-shard client-facing hostname to external access. When a domain is configured, every node in a shard announces `<hostnamePrefix>-<shardIndex>.<domain>` so clients can be directed to a stable name. This is announced as metadata only; switching clients to it is a follow-up change. Node-to-node traffic continues to use pod IPs. ### Features / Behaviour Changes - `externalAccess` gains `hostnamePrefix` (default `shard`) and `domain`. - With `domain` set, each shard announces `<hostnamePrefix>-<shardIndex>.<domain>`. ### Implementation - `buildContainersDef` appends `--cluster-announce-hostname` with the shard hostname when external access is enabled and a domain is set, reusing the same CLI-arg path as the other announce flags. - `hostnamePrefix` is validated as a DNS label via a kubebuilder pattern. ### Limitations The hostname is metadata until clients are switched to it (a follow-up sets `cluster-preferred-endpoint-type`). DNS records that resolve the hostnames to the shard Services are the user's responsibility. When TLS is enabled, the certificate must additionally cover every shard hostname. ### Testing - Unit tests cover the hostname format and that no hostname is announced without a domain. - A regression test confirms the CLUSTER NODES address parser still extracts the node IP when an announced hostname is appended to the address field, so the reconciler's pod-IP correlation is unaffected. - The e2e spec verifies the announced hostname appears in CLUSTER NODES. - `make test` and `make lint` pass locally. Signed-off-by: Steven Crothers <steven@scrothers.com>
### Summary Complete external access by directing clients to the per-shard hostname and its external port. Each node announces the external client port allocated by its shard Service, and `preferredEndpointType: hostname` makes MOVED/ASK and CLUSTER SLOTS hand clients the shard hostname. Node-to-node traffic still uses pod IPs, so the operator's reconciler is unaffected. ### Features / Behaviour Changes - `externalAccess` gains `preferredEndpointType` (`ip` default, or `hostname`). - With `hostname`, clients connecting to any shard receive cross-shard redirects to `<hostnamePrefix>-<shardIndex>.<domain>` and the node's external port. ### Implementation - The cluster controller resolves each node's external port from `status.externalEndpoints` (populated by the shard Service reconcile) and sets it on the ValkeyNode spec. `buildContainersDef` announces it with `--cluster-announce-client-port`, or `--cluster-announce-client-tls-port` when TLS is enabled (a TLS cluster runs the client listener on the TLS port). - `cluster-preferred-endpoint-type hostname` is added to the shared base config when selected, so it rolls through the existing config-hash path. It is cluster-wide, so it is not per-node. - A CEL rule requires `domain` when `preferredEndpointType` is `hostname`. ### Limitations `preferredEndpointType` is cluster-wide, so in-cluster clients are also directed to the hostname; resolve it internally with split-horizon DNS or accept the hairpin. To disable external access, set `preferredEndpointType: ip` before removing `domain` or disabling, so clients are moved off the hostnames first. ### Testing - Unit tests cover the client-port flag selection (plain vs TLS) and that `cluster-preferred-endpoint-type` is rendered only when hostname is selected. - `make test` and `make lint` pass locally. Signed-off-by: Steven Crothers <steven@scrothers.com>
There was a problem hiding this comment.
Pull request overview
This PR completes the “external cluster access” series by making Valkey redirect external clients to routable shard endpoints (hostname + externally allocated port) while keeping node-to-node traffic on pod IPs, preserving existing reconciliation and failover behavior.
Changes:
- Adds per-shard Services + status plumbing to discover and publish externally reachable node ports, and wires those ports into per-node announce flags.
- Introduces
externalAccess.preferredEndpointType(ipdefault /hostname) and renderscluster-preferred-endpoint-type hostnamecluster-wide when selected. - Updates unit/e2e tests and documentation to cover the external-access workflow and examples.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/e2e/valkeycluster_external_access_test.go | New e2e coverage for shard Services + status ports + announced hostname behavior. |
| internal/valkey/clusterstate_test.go | Adds regression test for parsing CLUSTER NODES address field with appended hostname. |
| internal/controller/valkeynode_resources.go | Announces human nodename, shard hostname, and external client(-tls) port when external access is enabled. |
| internal/controller/valkeynode_resources_test.go | Unit tests for new announce flags, port naming, and TLS vs non-TLS client-port announce selection. |
| internal/controller/valkeycluster_controller.go | Reconciles per-shard Services, publishes status.externalEndpoints, and plumbs per-node external client port into ValkeyNode specs. |
| internal/controller/utils.go | Adds helpers for per-node client port names and shard hostname formatting. |
| internal/controller/shard_services_unit_test.go | Unit tests for ServicePort generation and endpoint extraction logic. |
| internal/controller/shard_services_test.go | Controller-level tests for shard Service reconciliation lifecycle and NodePort stability. |
| internal/controller/config.go | Conditionally renders cluster-preferred-endpoint-type hostname into the base config. |
| internal/controller/config_test.go | Tests default omission and hostname-only rendering of cluster-preferred-endpoint-type. |
| docs/valkeycluster.md | Documents externalAccess fields, operational notes, and TLS considerations. |
| config/samples/v1alpha1_valkeycluster-external-access.yaml | Adds a sample ValkeyCluster manifest enabling external access + hostname endpoint preference. |
| config/samples/kustomization.yaml | Includes the new external-access sample in kustomize samples. |
| config/crd/bases/valkey.io_valkeynodes.yaml | CRD schema updates for ValkeyNode externalAccess + externalAccessClientPort fields. |
| config/crd/bases/valkey.io_valkeyclusters.yaml | CRD schema updates for ValkeyCluster externalAccess + status.externalEndpoints + CEL validation. |
| api/v1alpha1/zz_generated.deepcopy.go | Generated deepcopy updates for new API types/fields. |
| api/v1alpha1/valkeynode_types.go | Adds spec.externalAccess and spec.externalAccessClientPort to ValkeyNode. |
| api/v1alpha1/valkeycluster_types.go | Adds externalAccess API, ShardEndpoint status type, and CEL rule for domain requirement. |
Files not reviewed (1)
- api/v1alpha1/zz_generated.deepcopy.go: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // +kubebuilder:validation:XValidation:rule="has(oldSelf.persistence) || !has(self.persistence)",message="persistence cannot be added after creation" | ||
| // +kubebuilder:validation:XValidation:rule="!has(self.persistence) || !has(oldSelf.persistence) || quantity(self.persistence.size).compareTo(quantity(oldSelf.persistence.size)) >= 0",message="persistence.size may only be expanded" | ||
| // +kubebuilder:validation:XValidation:rule="!has(self.persistence) || !has(oldSelf.persistence) || ((!has(self.persistence.storageClassName) && !has(oldSelf.persistence.storageClassName)) || (has(self.persistence.storageClassName) && has(oldSelf.persistence.storageClassName) && self.persistence.storageClassName == oldSelf.persistence.storageClassName))",message="persistence.storageClassName is immutable" | ||
| // +kubebuilder:validation:XValidation:rule="!has(self.externalAccess) || self.externalAccess.preferredEndpointType != 'hostname' || has(self.externalAccess.domain)",message="externalAccess.domain is required when preferredEndpointType is hostname" |
| if shardIndex >= desiredShards { | ||
| if err := r.Delete(ctx, svc); err != nil && !apierrors.IsNotFound(err) { | ||
| return fmt.Errorf("delete excess shard Service %s: %w", svc.Name, err) | ||
| } | ||
| log.V(1).Info("deleted excess shard Service", "name", svc.Name, "shard", shardIndex) | ||
| } |
| "-o", "jsonpath={range .items[*].endpoints[*]}{.addresses[0]}{\"\\n\"}{end}") | ||
| output, err := utils.Run(cmd) | ||
| g.Expect(err).NotTo(HaveOccurred()) | ||
| g.Expect(utils.GetNonEmptyLines(output)).NotTo(BeEmpty()) |
|
| Filename | Overview |
|---|---|
| internal/controller/valkeycluster_controller.go | Adds reconcileShardServices and externalClientPort; wires endpoints into cluster status before ValkeyNode reconciliation so the port is available on first create. |
| internal/controller/valkeynode_resources.go | Adds external-access announce flags and renames the client container port to the node-unique name required by shard Service targetPort matching. |
| api/v1alpha1/valkeycluster_types.go | Adds ExternalAccessSpec, ShardEndpoint, and PreferredEndpointType with CEL cross-field validation. |
| internal/controller/config.go | Injects cluster-preferred-endpoint-type hostname into base config; correctly guarded behind Enabled check. |
| internal/controller/shard_services_test.go | Integration tests cover NodePort creation, scale-in cleanup, and idempotency. |
| internal/controller/shard_services_unit_test.go | Unit tests verify NodePort preservation and NodePort-vs-LoadBalancer branching. |
| internal/controller/utils.go | Adds shardClientPortName and shardHostname helpers with correct port-name length comments. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Rec as ClusterController
participant K8s as Kubernetes API
participant VNRec as ValkeyNodeController
participant Pod as Valkey Pod
Rec->>K8s: CreateOrUpdate shard Service (NodePort)
K8s-->>Rec: svc with allocated NodePorts
Rec->>Rec: "cluster.Status.ExternalEndpoints = endpoints"
Rec->>K8s: CreateOrUpdate ValkeyNode with ExternalAccessClientPort
Rec->>K8s: updateStatus persists ExternalEndpoints
VNRec->>K8s: reconcile ValkeyNode update StatefulSet
K8s->>Pod: restart with cluster-announce-client-port NodePort
note over Pod,K8s: Named port vk-nN routes each NodePort to exactly one pod
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Rec as ClusterController
participant K8s as Kubernetes API
participant VNRec as ValkeyNodeController
participant Pod as Valkey Pod
Rec->>K8s: CreateOrUpdate shard Service (NodePort)
K8s-->>Rec: svc with allocated NodePorts
Rec->>Rec: "cluster.Status.ExternalEndpoints = endpoints"
Rec->>K8s: CreateOrUpdate ValkeyNode with ExternalAccessClientPort
Rec->>K8s: updateStatus persists ExternalEndpoints
VNRec->>K8s: reconcile ValkeyNode update StatefulSet
K8s->>Pod: restart with cluster-announce-client-port NodePort
note over Pod,K8s: Named port vk-nN routes each NodePort to exactly one pod
Reviews (1): Last reviewed commit: "feat: direct clients to external shard e..." | Re-trigger Greptile
| _, err := controllerutil.CreateOrUpdate(ctx, r.Client, svc, func() error { | ||
| svc.Labels = labels(cluster) | ||
| svc.Labels[LabelCluster] = cluster.Name | ||
| svc.Labels[LabelShardIndex] = shardSelector | ||
| svc.Annotations = maps.Clone(ea.ServiceAnnotations) | ||
| svc.Spec.Type = serviceType |
There was a problem hiding this comment.
Annotation replacement on every reconcile wipes externally-managed annotations.
maps.Clone(ea.ServiceAnnotations) unconditionally replaces the entire annotation map on every CreateOrUpdate pass. For LoadBalancer services, cloud controllers (e.g. GKE NEG, AWS LB controller) add status annotations that will be cleared on each operator reconcile, causing those controllers to churn. Merging into the existing map rather than replacing it would preserve externally-added annotations while still keeping user-specified ones current.
| _, err := controllerutil.CreateOrUpdate(ctx, r.Client, svc, func() error { | |
| svc.Labels = labels(cluster) | |
| svc.Labels[LabelCluster] = cluster.Name | |
| svc.Labels[LabelShardIndex] = shardSelector | |
| svc.Annotations = maps.Clone(ea.ServiceAnnotations) | |
| svc.Spec.Type = serviceType | |
| _, err := controllerutil.CreateOrUpdate(ctx, r.Client, svc, func() error { | |
| svc.Labels = labels(cluster) | |
| svc.Labels[LabelCluster] = cluster.Name | |
| svc.Labels[LabelShardIndex] = shardSelector | |
| if svc.Annotations == nil { | |
| svc.Annotations = make(map[string]string, len(ea.ServiceAnnotations)) | |
| } | |
| for k, v := range ea.ServiceAnnotations { | |
| svc.Annotations[k] = v | |
| } | |
| svc.Spec.Type = serviceType |
| if ea := node.Spec.ExternalAccess; ea != nil && ea.Enabled { | ||
| containers[0].Command = append(containers[0].Command, | ||
| "--cluster-announce-human-nodename", node.Name) | ||
| containers[0].Ports[0].Name = shardClientPortName(node.Labels[LabelNodeIndex]) |
There was a problem hiding this comment.
Silent mismatch when
LabelNodeIndex label is absent. node.Labels[LabelNodeIndex] returns "" for a ValkeyNode missing the label, producing the port name "vk-n" instead of (e.g.) "vk-n0". The corresponding shard Service port has targetPort: "vk-n0", so the EndpointSlice controller cannot match the container port and external traffic never reaches the right pod. In practice the cluster controller always stamps this label, but a defensive check would surface the misconfiguration early rather than silently dropping external traffic.
| for _, ep := range cluster.Status.ExternalEndpoints { | ||
| if int(ep.ShardIndex) == shardIndex && nodeIndex < len(ep.NodePorts) { | ||
| return ep.NodePorts[nodeIndex] | ||
| } | ||
| } | ||
| return 0 | ||
| } |
There was a problem hiding this comment.
externalClientPort convergence comment. On the initial reconcile cluster.Status.ExternalEndpoints starts empty (fetched from API); reconcileShardServices populates it in-memory just before this call so the NodePorts are available. The comment that "the port is normally available on the same reconcile that creates the node" implicitly relies on controllerutil.CreateOrUpdate populating svc.Spec.Ports[i].NodePort with the API-server-assigned value after Create. The downstream guard if node.Spec.ExternalAccessClientPort > 0 handles the zero case gracefully by skipping the announcement flag, so the cluster converges after the next reconcile — a short comment to that effect would make this two-phase convergence easier to follow during review.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Part of the external cluster access effort (umbrella #276). Stacked on #279 (
external-clusters/announce-hostnames), and it completes the series.Summary
This is the piece that actually sends external clients to the right place. Each node announces the external client port that its shard Service allocated, and
preferredEndpointType: hostnamemakes MOVED/ASK and CLUSTER SLOTS hand clients the shard hostname instead of the pod IP. With that in place, a client connecting to any shard gets correct cross-shard redirects to a reachablehostname:port.Node-to-node traffic still rides on pod IPs, so none of the operator's failover, slot, or topology logic changes.
Features / Behaviour Changes
externalAccessgainspreferredEndpointType(ipdefault, orhostname).hostname, clients are redirected to the announced shard hostname and the node's external port.Implementation
status.externalEndpoints(populated by the shard Service reconcile) and sets it on the ValkeyNode spec.buildContainersDefannounces it with--cluster-announce-client-port, or--cluster-announce-client-tls-portwhen TLS is enabled, since a TLS cluster runs its client listener on the TLS port.cluster-preferred-endpoint-type hostnamegoes into the shared base config when selected, so it rolls through the existing config-hash path. It's cluster-wide rather than per-node.domainwhenpreferredEndpointTypeishostname.Limitations
preferredEndpointTypeis cluster-wide, so in-cluster clients are redirected to the hostname too. You can resolve it to the shard Service internally with split-horizon DNS, or accept that the traffic hairpins out and back. To turn external access off, setpreferredEndpointType: ipfirst so clients move off the hostnames, then removedomainor disable. The docs spell out that order.Testing
cluster-preferred-endpoint-typeis only rendered when hostname is selected.make testandmake lintpass locally.ExternalAccess-labelled e2e specs: thatpreferred-endpoint-type=hostnameround-trips with the TLS client port, and full cross-shard external routing withvalkey-cli -c. Seedocs/valkeycluster.mdfor the end-to-end example.Checklist
make testandmake lintinstead)