Skip to content

feat: announce cluster nodes by hostname for TLS-friendly discovery - #296

Draft
daanvinken wants to merge 5 commits into
valkey-io:mainfrom
daanvinken:feat/announce-hostname
Draft

feat: announce cluster nodes by hostname for TLS-friendly discovery#296
daanvinken wants to merge 5 commits into
valkey-io:mainfrom
daanvinken:feat/announce-hostname

Conversation

@daanvinken

@daanvinken daanvinken commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

fixes #297

Add spec.networking.preferredEndpointType (IP | Hostname, default IP) to ValkeyCluster, mirroring valkey's own cluster-preferred-endpoint-type directive, so operators of TLS clusters can have nodes advertise DNS-resolvable FQDNs instead of pod IPs.

Motivation

Standard TLS clients (go-redis, Lettuce, redis-py, …) verify SNI against the connection host. A cluster-mode client seeds against the headless Service, receives CLUSTER SLOTS, and re-dials each node by the announced value. The operator has always used --cluster-announce-ip $(POD_IP), which forces those re-dials to pod IPs. Server certificates usually only carry DNS SANs — pod IPs are ephemeral and not practical to list — so every re-dial fails SNI verification unless the client sets InsecureSkipVerify. The workaround defeats most of the point of TLS.

Implementation

New NetworkingSpec on ValkeyClusterSpec, mirrored on ValkeyNodeSpec and populated by the cluster controller. When PreferredEndpointType == Hostname:

  • pod.spec.subdomain is set to the cluster's headless Service name so kubelet publishes <pod>.<svc>.<ns>.svc.cluster.local DNS records.
  • Server container's command becomes --cluster-announce-hostname <FQDN> using $(POD_NAME) from the downward API.
  • cluster-preferred-endpoint-type: hostname is added to valkey.conf via buildManagedConfig.

A cert-manager Certificate that covers *.<headless-svc>.<ns>.svc.cluster.local then satisfies SNI on every dial.

Limitations

  • Switching an existing cluster from IP to Hostname rolls all pods (subdomain is part of the pod template hash). Called out in the docs.
  • Assumes the cluster domain is cluster.local. Non-default cluster domains would need this constant lifted out.
  • No API change required for downstream ValkeyCluster consumers on the IP path; the field is optional and defaults to IP.

Testing

  • internal/controller/valkeynode_resources_test.go: table-driven coverage of valkeyAnnounceArgsAndEnv for nil / IP / Hostname, plus TestBuildValkeyNodePodTemplateSpec_HostnameSetsSubdomain and a matching negative case asserting IP-mode leaves Subdomain empty (so operator upgrades don't roll existing clusters).
  • internal/controller/config_test.go: buildManagedConfig emits cluster-preferred-endpoint-type only on Hostname.
  • internal/controller/valkeycluster_controller_test.go: buildClusterValkeyNode propagates Networking from cluster to ValkeyNode.
  • make test passes locally; pre-commit run --all-files passes.

Checklist

  • API change: new optional spec.networking.preferredEndpointType with kubebuilder default
  • make generate manifests regenerated zz_generated.deepcopy.go and CRDs
  • Backwards compatible: existing clusters without spec.networking unchanged
  • Unit tests added for both modes
  • Sample under config/samples/
  • Docs updated in docs/valkeycluster.md
  • DCO sign-off

Generated with Claude Code Opus 4.8.

@melancholictheory

Copy link
Copy Markdown
Contributor

this is squarely the TLS-discovery gap, nice to see it addressed. the shape looks good: defaulting to IP so existing clusters don't roll, wiring pod.spec.subdomain only in Hostname mode, and keeping the announce flags per-node.

two things from having run hostname-announce with TLS on a parallel operator:

the FQDN hardcodes svc.cluster.local (valkeyAnnounceArgsAndEnv). #297 lists the DNS domain as in-scope later, but for this PR it's more than cosmetic: on a cluster with a non-default kubelet --cluster-domain (plenty of on-prem and managed clusters use one), the announced name doesn't resolve, and even if it did it wouldn't match the cert SAN, so SNI fails, which is the exact thing this PR is fixing, one level up. i'd either make the domain configurable now (the DNS-domain field from #297) or, if that's deferred, document cluster.local as a hard prerequisite so it fails loudly rather than silently.

the announced value is a per-pod FQDN, so the server cert has to carry each pod's name as a SAN. a single wildcard *.<svc>.<ns>.svc.<domain> covers them all (a wildcard matches one label, and the pod name is exactly one label under the subdomain), so it's worth showing in the sample cert / docs. otherwise users wire up a DNS-SAN cert, still hit SNI failures, and it's not obvious why.

minor: does the per-pod FQDN resolve under workloadType: Deployment? stable <pod>.<svc> DNS is really a StatefulSet property; if Hostname mode only makes sense with StatefulSets, might be worth guarding or noting.

@daanvinken

Copy link
Copy Markdown
Contributor Author

@melancholictheory incredibly useful comment, thanks.

1. Hardcoded cluster.local.
You're right, breaks on non-default kubelet --cluster-domain. Two options:
I'd lean toward folding in spec.networking.clusterDomain it's small, and shipping Hostname mode without it means anyone on a custom cluster domain gets a broken announced FQDN.

2. Wildcard SAN guidance.
Fair. The sample references a Secret but never says "issue the cert with *.<svc>.<ns>.svc.<domain> as SAN". I'll add that to both the sample comment and the docs section.

3. Deployment workloadType.
True that. Pod names under workloadType: Deployment aren't stable across restarts, so the announced FQDN would change on every roll and cluster gossip would treat the node as new. I'll add a CEL validation rejecting preferredEndpointType: Hostname combined with workloadType: Deployment, plus a docs note.

@melancholictheory

Copy link
Copy Markdown
Contributor

all three sound right. keeping clusterDomain defaulted to cluster.local means it stays a no-op for everyone already on the default and only the custom-domain folks touch it. and the CEL reject for Hostname + Deployment is the right call, stable per-pod DNS just isn't a thing without a StatefulSet. thanks for turning these around so fast.

Standard TLS clients verify SNI against the connection host, so once a
cluster-mode client seeds against the headless Service and receives
CLUSTER SLOTS, it re-dials each node by the announced value. The
operator has always used --cluster-announce-ip $(POD_IP), which forces
those follow-up dials to a pod IP. Server certificates typically carry
only DNS SANs (pod IPs are ephemeral and not practical to list), so
every re-dial fails SNI verification unless the client sets
InsecureSkipVerify.

Add spec.networking.preferredEndpointType (IP | Hostname, default IP)
mirroring valkey's cluster-preferred-endpoint-type directive. When
Hostname:

  * pod.spec.subdomain is set to the cluster's headless Service name so
    kubelet publishes <pod>.<svc>.<ns>.svc.cluster.local DNS records;
  * the server container gets --cluster-announce-hostname pointing at
    that FQDN (POD_NAME resolved from the downward API);
  * cluster-preferred-endpoint-type: hostname is added to valkey.conf.

Defaults preserve current behaviour: existing clusters without a
Networking block keep announcing IPs and do not roll on operator
upgrade (subdomain is only populated in Hostname mode).

Signed-off-by: Daan Vinken <daanvinken@tythus.com>
Kubelet's --cluster-domain is user-configurable (defaults to
cluster.local, but many operators run mycompany.internal or similar).
Hardcoding the suffix in the announced FQDN produces a name that
doesn't resolve on those clusters, so Hostname mode fails at DNS
lookup before TLS ever comes into play.

Add spec.networking.clusterDomain (default cluster.local) and use it
when constructing the --cluster-announce-hostname value.

Signed-off-by: Daan Vinken <daanvinken@tythus.com>
Deployment pods don't have stable names across restarts, so the
--cluster-announce-hostname value would change on every roll and
cluster gossip would treat the recreated node as a brand-new member.
Reject the combination at admission time with a CEL XValidation on
ValkeyClusterSpec.

Signed-off-by: Daan Vinken <daanvinken@tythus.com>
… mode

Reviewers pointed out the sample and docs didn't say what the server
cert actually has to cover. Add explicit wildcard-SAN guidance
(*.<headless-svc>.<namespace>.svc.<clusterDomain>) to both the sample
YAML and the TLS section of the docs. Also document the clusterDomain
knob and the Hostname-requires-StatefulSet constraint that the CRD now
enforces via CEL.

Signed-off-by: Daan Vinken <daanvinken@tythus.com>
Cover the path that breaks with announce-by-IP and DNS-only certs:
3 shards, preferredEndpointType=Hostname, wildcard SAN, assert
CLUSTER SLOTS advertises pod FQDNs and multi-key cluster SETs work
over TLS after topology refresh.

Testing
- make test
- go test -c -tags e2e ./test/e2e/

Signed-off-by: daanvinken <daanvinken@tythus.com>
@daanvinken
daanvinken force-pushed the feat/announce-hostname branch from 3ae4817 to 80758e1 Compare July 15, 2026 11:28
@daanvinken

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (scheduling move, grace period, PDB struct, etc.) and added e2e coverage for the rediscovery path:

test/e2e/valkeycluster_tls_hostname_test.go

  • multi-shard (shards: 3) TLS cluster with preferredEndpointType: Hostname
  • cert with wildcard SAN for pod FQDNs
  • asserts pod.spec.subdomain is the headless Service
  • asserts CLUSTER SLOTS announces hostnames (not pod IPs)
  • multi-key valkey-cli -c SETs over TLS via Service DNS after topology refresh

Still draft until e2e is green in CI if we want a final pass there.

jdheyburn pushed a commit that referenced this pull request Aug 5, 2026
## Summary

Phase 1a of #318: introduce `spec.networking` and move TLS to
`spec.networking.tls`.

**Hard break (no dual-read):** top-level `spec.tls` is removed from the
API. `GetTLS()` only reads `spec.networking.tls`. There is no fallback
and no deprecation warning path. When TLS is set,
`certificate.secretName` is required.

```yaml
spec:
  networking:
    tls:
      certificate:
        secretName: valkey-tls
```

## Breaking upgrade / release note

Same approach as the scheduling move (v0.4.0): document in **release
notes** + docs. For TLS the failure mode is worse than scheduling
(security downgrade), so the wording must state the consequence, not
only that the field moved.

**Migrate every ValkeyCluster to `spec.networking.tls` before rolling
the new CRD/operator.**

After the CRD drops top-level `tls`, the API server prunes that field
from objects already in etcd. The operator then sees no TLS config and
reconciles the cluster **without TLS (plaintext)**. Clients that
expected TLS fail. That is intentional 0.x behaviour; the release note
is the protection, not a dual-read.

### Draft for the next operator GitHub release

Copy into the release body (do not use a soft “fields moved” line
alone):

```markdown
### Breaking: TLS under `spec.networking`

- Top-level `spec.tls` is **removed**. Use `spec.networking.tls`.
- **Upgrade order:** migrate every ValkeyCluster to `spec.networking.tls`
  **before** applying the new CRD / operator image.
- **If you upgrade with only top-level `spec.tls` still set, TLS is turned
  off.** The unknown field is dropped from the object; the cluster comes
  back up serving **plaintext**. This is not a silent rename of a working
  field—migrate first, then roll CRDs.

```yaml
# before
spec:
  tls:
    certificate:
      secretName: valkey-tls

# after
spec:
  networking:
    tls:
      certificate:
        secretName: valkey-tls
```
```

Also in-repo: `docs/valkeycluster.md` (TLS section) has the same
upgrade-order warning.

## Behaviour

| Spec after this change | Result |
|---|---|
| `networking.tls` set | TLS used |
| only old top-level `tls` (pre-migration) | pruned after CRD roll →
**no TLS** |
| neither | no TLS |

`ValkeyNode` still uses top-level `node.Spec.TLS` as an **internal**
primitive filled from the cluster (not a mirrored `NetworkingSpec`).

## Out of scope

- Hostname discovery (`networking.discovery`, `clusterDomain`) — #296 /
follow-up under #318
- External access (`networking.external`) — #276
- Split-TLS / mTLS under `networking.tls` — design follow-up
- Dual-read of top-level `spec.tls` (explicitly not done; see review
thread)

## Testing

- `go test ./api/v1alpha1/ ./internal/controller/`
- `make generate manifests`
- e2e TLS manifest uses `networking.tls`

Umbrella #318 stays open for discovery/external (sub-issues next).

Signed-off-by: daanvinken <daanvinken@tythus.com>
@daanvinken

Copy link
Copy Markdown
Contributor Author

Tracking issue for the networking-tree API is #365 (under epic #318). This PR will need reshape onto spec.networking.discovery.preferredEndpointType + root spec.networking.clusterDomain, and no full NetworkingSpec on ValkeyNode (cluster owns the API; node gets announce primitives only).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Umbrella: spec.networking for in-cluster endpoint & connectivity configuration

2 participants