Skip to content
This repository was archived by the owner on Oct 10, 2026. It is now read-only.

feat(tls): cert-manager-managed TLS for RedisFailover - #1

Merged
Arsolitt (Arsolitt) merged 29 commits into
masterfrom
feat/tls-cert-manager
May 27, 2026
Merged

Arsolitt (Arsolitt) merged 29 commits into
masterfrom
feat/tls-cert-manager

Conversation

@Arsolitt

@Arsolitt Arsolitt (Arsolitt) commented May 25, 2026 •

Copy link
Copy Markdown
Collaborator

Adds end-to-end TLS for RedisFailover by integrating cert-manager.

When spec.tls.enabled: true, the operator provisions a Certificate through the user-supplied Issuer/ClusterIssuer, mounts the resulting Secret into every Redis and Sentinel pod, and reconfigures the processes to terminate clients on --tls-port with the plaintext --port disabled. Sentinel monitoring and slave→master replication traverse the TLS listener as well, so once enabled there is no plaintext path left in the failover.

Bring-your-own-Secret remains supported: omit the certManager block and point spec.tls.certificateSecret.secretName at an externally managed Secret.

Changes proposed on the PR:

  • API: add spec.tls (enabled, certManager, certificateSecret, authClients, extraSANs) to the RedisFailover CRD with kubebuilder validation; regenerate deepcopy, clientset, informers, listers and CRD manifest via slok/kube-code-generator v0.9.0.
  • Operator: when TLS is enabled, generate --tls-port, --port 0, cert/key/CA paths and tls-replication yes for Redis pods; same treatment for Sentinel; plumb the in-process Redis client through the TLS connection so role probes, sentinel queries and reconfig RPCs use the same trust chain; reconcile a cert-manager Certificate with per-pod and per-service SANs, plus optional extraSANs for external hostnames or IPs (auto-classified into DNS vs IP SANs).
  • Chart: add cert-manager.io RBAC to the operator ClusterRole; expose an env array on the Deployment template so the WatchListClient feature gate can be flipped off where required.
  • Example: ship example/redisfailover/tls.yaml covering the typical ClusterIssuer-based topology with commented overrides for duration, renewBefore, privateKey and extraSANs.
  • Build: bump to Go 1.26 / k8s 0.36; pin alpine 3.23 in the runtime image; drop the codegen flag that v0.9.0 now infers automatically.
  • CI: introduce a .golangci.yml (golangci-lint v2 schema) so the pipeline keeps running under Go 1.26; bump the integration-test matrix to Kubernetes 1.34/1.35/1.36 (older API servers hang the cache sync because client-go 0.36 enables WatchListClient by default and earlier servers never deliver the required BOOKMARK event); refresh checkout, setup-go, setup-helm, setup-minikube and chart-releaser-action to their current major versions.

Verified end-to-end against a live Kubernetes cluster: TLS PING/PONG on the master, slave replication online over TLS, Sentinel monitoring the master over TLS, and the plaintext port refusing connections.

Backlog (out of scope for this PR)

Consider moving inter-component traffic — replication, sentinel-to-redis and operator-to-redis — to TLS unconditionally, regardless of the tls.enabled flag, and repurposing the flag to control only whether the TLS port is exposed to client connections. The proposed default would be port 6379 plaintext for clients plus port 6380 TLS for replication, sentinel and the operator, with the flag flipping the client-facing port between 6379 and 6380 in a mutually-exclusive fashion.

This would harden the default posture (replication traffic always encrypted, cert validity becomes a cluster-availability concern rather than a client UX concern) but would make cert-manager — or an in-operator PKI implementation — a hard dependency rather than optional. Worth a separate design discussion before tackling.

Summary by CodeRabbit

  • New Features

    • TLS for Redis/Sentinel with optional cert-manager integration, automatic certificate management, and example manifest; cluster-domain flag added.
  • Bug Fixes

    • Operator chart sets WatchListClient=false to avoid informer cache hangs on older Kubernetes.
  • Chores

    • Upgraded Go, Kubernetes libraries, CI/Helm workflows, Docker base images, and chart/helm tooling.
  • Tests

    • Added extensive TLS unit and generator tests; updated integration tests and mocks to cover TLS changes.

Review Change Stack

- Go: 1.20 -> 1.23.0 (toolchain 1.23.4)
- kooper: 2.4.0 -> 2.7.0 (resolves incompatibility with client-go
  leaderelection API: ConfigMapsLeasesResourceLock was removed)
- k8s.io/{api,apimachinery,client-go,apiextensions-apiserver}:
  0.27.3 -> 0.31.0 (pulled transitively by kooper)
- prometheus/client_golang: 1.16.0 -> 1.19.1
- stretchr/testify: 1.8.4 -> 1.9.0

Adjust PVC test fixtures to use VolumeResourceRequirements which
replaced ResourceRequirements as the type of
PersistentVolumeClaimSpec.Resources in k8s 1.31.

Lays the groundwork for adding cert-manager API as a dependency
for native TLS support.

Signed-off-by: Arsolitt <[email protected]>
Replace github.com/go-redis/redis/v8 with github.com/redis/go-redis/v9
(v9.19.0). The v8 module is no longer receiving updates.

No code changes required beyond the import path: every API used by
service/redis/client.go (NewClient, Options, NewSentinelClient,
NewBoolCmd, NewIntCmd, NewStatusCmd, NewSliceCmd, Process, Info,
SlaveOf, CkQuorum, ConfigSet) is preserved between v8 and v9. The
v9 breaking changes (Pipeline thread-safety, hook interface, *redis.Z,
MaxConnAge, IdleTimeout, WithContext, Pipeline.Close) are not used by
the operator.

Required for native TLS support: v9 receives ongoing maintenance and
the tls.Config option remains available on Options.

Signed-off-by: Arsolitt <[email protected]>
Adopt vendor-neutral naming for container build files
(Dockerfile -> Containerfile).

- docker/app: bump golang:1.20-alpine -> golang:1.23-alpine3.20,
  pin alpine:latest -> alpine:3.20
- docker/development: bump golang:1.20-alpine -> golang:1.23-alpine3.20
  (code-generator and mockery versions left untouched; refresh is a
  separate concern)
- Makefile: point image build targets at the renamed files
- CI workflow:
  - fix push trigger to use the actual default branch (master, not main)
  - bump actions/checkout@v3 -> @v4, actions/setup-go@v4 -> @v5,
    golangci-lint-action@v3 -> @v6 (version v1.61 supports Go 1.23),
    medyagh/[email protected] -> @v0.0.18,
    azure/setup-helm@v3 -> @v4 (helm v3.15.4)
  - bump Kubernetes test matrix to 1.29/1.30/1.31 to match the k8s
    client-go version pulled in by the previous deps bump
  - expand short flags (--yes, --filename) per project convention

Signed-off-by: Arsolitt <[email protected]>
Introduce spec.tls to opt into TLS for the Redis failover cluster.
TLS is provisioned either by cert-manager (operator creates a
Certificate CR) or by referencing an existing Secret, never both.

CRD shape:

  spec.tls.enabled           bool
  spec.tls.authClients       "no" | "optional" | "yes" (default "no")
  spec.tls.certManager.issuerRef          cmmeta.IssuerReference
  spec.tls.certManager.secretName         string  (optional override)
  spec.tls.certManager.duration           *metav1.Duration
  spec.tls.certManager.renewBefore        *metav1.Duration
  spec.tls.certManager.privateKey         *cmapi.CertificatePrivateKey
  spec.tls.certificateSecret.secretName   string

Validation:
- enabled=true requires exactly one of certManager / certificateSecret
- certManager.issuerRef.name is required; kind defaults to "Issuer"
  and group to "cert-manager.io" when empty
- certificateSecret.secretName is required
- authClients must be one of "no"/"optional"/"yes" when set

Deps:
- github.com/cert-manager/cert-manager v1.15.5 (typed CRD API; the
  operator never requires cert-manager to be installed at runtime
  when TLS is disabled, the import only brings in Go types)

DeepCopy methods for TLSSettings, CertManagerSettings and
LocalSecretReference are added by hand (the project's existing
zz_generated file is hand-maintained rather than regenerated each
commit); the cert-manager imports needed for these methods are
also wired into the existing zz_generated file.

The operator does not yet consume the new fields. Subsequent commits
plumb the settings through to the Redis/Sentinel pods, the issued
Certificate resource and the operator's own redis client.

Signed-off-by: Arsolitt <[email protected]>
When spec.tls.enabled is true the operator now emits a configuration
that drives Redis (server, replication and Sentinel traffic) entirely
over TLS:

- redis.conf and sentinel.conf get a TLS block (port 0, tls-port, the
  three /tls/{tls.crt,tls.key,ca.crt} paths, tls-auth-clients from
  spec, tls-replication yes). The plaintext port is closed
- The Redis StatefulSet and Sentinel Deployment mount the TLS Secret
  read-only at /tls. The Secret name is resolved with a clear
  precedence: spec.tls.certificateSecret.secretName takes priority,
  then spec.tls.certManager.secretName, then a generated default
  (rftls-<name>) used in the cert-manager-managed path
- The shutdown.sh and ready.sh scripts in the operator-managed
  ConfigMaps inject the matching --tls --cert --key --cacert flags
  into every redis-cli invocation
- The inline exec probes (liveness on the redis container, both
  probes on the sentinel container) get the same flags
- The redis_exporter sidecars receive REDIS_EXPORTER_TLS_* env vars
  pointing at the mounted certificate; REDIS_ADDR switches to
  rediss:// so the exporter dials TLS as well

The operator does not yet create the cert-manager Certificate
resource; that lands in a separate commit. spec.tls.certificateSecret
(bring-your-own) is already wired through.

New tests under generator_tls_test.go cover the config files, the
volume + mount layout, the exporter env, the probe scripts and the
GetTLSSecretName precedence rules. With TLS disabled all existing
test fixtures continue to pass unchanged.

Signed-off-by: Arsolitt <[email protected]>
…vers

When spec.tls.enabled is true and spec.tls.certManager is set, the
operator now creates (and reconciles) a cert-manager Certificate that
issues into the Secret the pods already mount. The Certificate is a
no-op for the bring-your-own-secret mode (spec.tls.certificateSecret)
and obviously for TLS-disabled failovers.

Wiring:
- service/k8s: new Certificate service interface and CertificateService
  implementation talking to cert-manager's typed clientset; added to
  Services so existing call sites pick it up via embedding
- cmd: CreateKubernetesClients now also builds the cert-manager client;
  main.go passes it into k8s.New
- service generator: generateRedisCertificate builds a Certificate with
  the IssuerRef from spec.tls.certManager, the resolved Secret name,
  optional duration / renewBefore / privateKey overrides, and the
  ServerAuth + ClientAuth key usages
- ensurer: EnsureRedisCertificate runs before the ConfigMaps and pods
  so the Secret cert-manager populates exists by the time pods mount it

SAN coverage on the issued certificate:
- the four Service names (redis headless, redis master, redis slave,
  sentinel) each in short, namespaced, .svc and .svc.cluster.local form
- a wildcard against the redis headless service so per-pod DNS records
  (<pod>.rfr-<name>.<ns>.svc.cluster.local) validate without re-issuing
  when replica count changes
- no IP SANs — pod IPs are unstable; the operator client validates
  pod-IP-targeted dials via ServerName override against the headless
  DNS SAN

Mocks for Services and RedisFailoverClient are extended by hand with
the new methods (the project's mocks/ tree is hand-maintained between
mockery runs).

Tests:
- EnsureRedisCertificate creates a Certificate with the right SANs,
  issuer ref, secret name, duration/renewBefore and usages
- EnsureRedisCertificate is a no-op when TLS is disabled
- EnsureRedisCertificate is a no-op for bring-your-own-secret mode
- The existing TestEnsure suite now stubs EnsureRedisCertificate

The operator's internal redis client still uses plaintext; TLS in
service/redis/client.go lands in the next commit.

Signed-off-by: Arsolitt <[email protected]>
The operator's own redis/sentinel client (service/redis/client.go)
now dials TLS when the failover has spec.tls.enabled.

Approach: every Client method gains a final *tls.Config parameter
that is fed straight into rediscli.Options.TLSConfig. A nil config
preserves the historical plaintext behavior, so disabled-TLS clusters
are unaffected.

Plumbing:
- service/redis/tls.go: new BuildTLSConfig helper. Loads the CA out
  of the ca.crt key of the cluster Secret (the one cert-manager or
  the user populates), produces a tls.Config with the supplied
  ServerName for SAN-based validation, MinVersion TLS 1.2, no
  InsecureSkipVerify.
- operator/redisfailover/service/check.go: new tlsConfigFor helper
  reads the Secret named by GetTLSSecretName and feeds BuildTLSConfig
  with ServerName = GetRedisName(rf) (the redis headless service,
  which is the SAN every issued cert covers).
- check.go and heal.go: every method that already loaded the password
  now also calls tlsConfigFor and passes the resulting config to the
  client. tlsConfigFor returns nil for TLS-disabled failovers so the
  pre-existing call sites keep their plaintext semantics.

Interface changes:
- service/redis.Client: each method gains a trailing *tls.Config
- service.RedisFailoverCheck.CheckSentinelMonitor: gains a leading
  *redisfailoverv1.RedisFailover so tlsConfigFor has access to it
- service.RedisFailoverHeal.RestoreSentinel: same, leading rf

Callers in operator/redisfailover/checker.go updated to pass rf.

Why ServerName, not InsecureSkipVerify: pod IPs are unstable, but the
issued cert covers the headless service DNS name. Dialing pod IP +
ServerName override gives strong validation against the SAN list
without juggling IP SANs that would churn on every reschedule.

Mocks (mocks/service/redis/Client.go, the two RedisFailover* mocks)
are updated by hand to match the new signatures. The check_test and
heal_test fixtures are updated en masse (mock.Anything for the new
trailing tlsConfig arg). New tests cover BuildTLSConfig: nil/empty
CA, missing ServerName, malformed PEM, success path.

Signed-off-by: Arsolitt <[email protected]>
…xample

The operator now requires write access to cert-manager Certificate
resources whenever a RedisFailover opts into TLS with the
spec.tls.certManager block. Grant create/delete/get/list/patch/
update/watch on cert-manager.io/certificates from the operator's
ClusterRole. The rule is always present; the operator only issues
requests when a failover has TLS enabled, so installations that do
not have cert-manager installed remain unaffected.

Add example/redisfailover/tls.yaml that demonstrates the cert-manager
managed path and documents the bring-your-own-Secret alternative in
a comment.

Signed-off-by: Arsolitt <[email protected]>
Bump to the current upstream stable versions:
- Go directive: 1.26.0 (was 1.23). The previous CODEGEN_IMAGE could
  not parse three-digit minor versions, which broke generate-crd.
- k8s.io/{api,apimachinery,client-go,apiextensions-apiserver}:
  0.31.0 -> 0.36.1
- kooper: 2.7.0 -> 2.10.0
- cert-manager and prometheus client_golang were already at latest
  via earlier transitive bumps

Update the codegen tooling:
- CODEGEN_IMAGE: slok/kube-code-generator v1.27.0 -> v0.9.0. The
  upstream project rewrote its CLI in the v0 line (drop update-*.sh
  scripts, single Go binary configured with flags), so the Makefile
  targets are rewritten to call it directly via flags rather than
  through env-vars + a bash wrapper. The new image's bundled Go is
  older than our go.mod directive, so GOTOOLCHAIN=auto is set on the
  docker run to let it download a matching toolchain on demand.

Regenerate the CRD against the new TLSSettings types so installs
through helm/kubectl pick up the new spec.tls fields out of the box.
The chart's crds/ copy and the kustomize base copy are also updated
by the same generate-crd target.

Drive-by: fix non-constant format string in GetNumberMasters which
go vet started flagging under newer toolchains.

Signed-off-by: Arsolitt <[email protected]>
Adds an env: [] knob to charts/redisoperator/values.yaml and renders the
corresponding env block in the Deployment when set. Documents
KUBE_FEATURE_WatchListClient=false as the workaround when running
against API servers that do not deliver BOOKMARK events on the initial
list — without it the controller hangs forever in
"Waiting for caches to sync".

Signed-off-by: Arsolitt <[email protected]>
Aligns the operator image with the go 1.26 toolchain required by go.mod
and refreshes the alpine base in both the build and runtime stages.
Earlier golang:1.23-alpine3.20 / alpine:3.20 stack could no longer
compile after the dependency bump to k8s 0.36 / cert-manager 1.20.

Signed-off-by: Arsolitt <[email protected]>
Adds spec.tls.certManager.extraSANs: a list of additional Subject
Alternative Names appended to the service and per-pod SANs the operator
already computes. Entries are auto-classified by net.ParseIP — IPv4 and
IPv6 strings become IP SANs, everything else becomes a DNS SAN. The
merge is purely additive so in-cluster connectivity is preserved when
operators add external hostnames or LoadBalancer VIPs for off-cluster
clients.

Includes regenerated deepcopy (now produced by controller-gen, which
also reformats prior entries) and the regenerated CRD manifest in all
three published locations: chart crds, kustomize base, and manifests
root.

The go directive moves from 1.26.0 to 1.26.3 as a side-effect of
running the generator under that toolchain.

Signed-off-by: Arsolitt <[email protected]>
slok/kube-code-generator v0.9.0 dropped the explicit --client-package
flag; the generator now infers the client package from --go-gen-out
combined with the go.mod module path. Passing the removed flag caused
update-codegen to fail with "unknown long flag '--client-package'".

Signed-off-by: Arsolitt <[email protected]>
Re-runs make update-codegen and make generate-crd against the canonical
toolchain after the Makefile fix. The diff captures three shifts:

  - deepcopy: refreshed by deepcopy-gen, replacing the temporary
    controller-gen output that landed alongside the extraSANs change.
    Format and import aliases match the upstream style again.
  - clientset: regenerated; the public surface contracts in Get/List/
    Watch/Create/Update/Patch are unchanged, but the typed fake clients
    shrunk to the generic client-go testing wrappers and a stale doc.go
    is dropped.
  - informers / listers: newly generated tree under client/k8s. Not
    consumed by the operator itself yet, but kept versioned to keep
    downstream consumers and future controller refactors free of an
    extra generation step.
  - CRD manifest: regenerated and synced to manifests/, kustomize base
    and the chart crds directory.

Signed-off-by: Arsolitt <[email protected]>
Apply goimports with -local=github.com/spotahome/redis-operator and the
gofmt struct-literal alignment that golangci-lint v2 enforces by default.
Pure formatting — no behaviour or API changes.

The realignments in service/redis/client.go are a follow-up to the TLS
plumbing change (62ac27b): adding TLSConfig to the existing rediscli
options literals shifted the longest field name, which gofmt then expects
all sibling colons to track.

Signed-off-by: Arsolitt <[email protected]>
golangci-lint v2 reorganised its CLI and configuration schema. Inline
flags like '-E goimports --timeout 3m' no longer work, and v2's default
'standard' linter set bundles many staticcheck families (QF/ST/S1/SA1019)
that the previous v1.61 default excluded. Out-of-the-box this would
surface a flood of issues in upstream code that pre-dates this fork and
is unrelated to the work happening here.

Lock the staticcheck check list down to the SA* bug-class families (minus
SA1019, since the kooper-style SlaveOf usage in service/redis/client.go
is deliberate) so the noise level matches what v1.61 produced. Move the
in-repo errcheck exception for upstream's deferred rClient.Close() calls
into the same config. Goimports becomes a v2 'formatter' rather than a
'-E' flag, configured with the project's local prefix.

scripts/check.sh stops passing v1-only flags and just delegates to the
new config.

Signed-off-by: Arsolitt <[email protected]>
The fork now builds against Go 1.26 and client-go 0.36, which makes the
CI pipeline that shipped with the upstream repository unusable:

  * golangci-lint v1.61 predates Go 1.26 and rejects the toolchain. Bump
    to v2.12.2 with the matching golangci/golangci-lint-action@v9 (v2
    requires the action's major version 9 or newer).

  * The integration-test matrix targeted Kubernetes 1.29/1.30/1.31. With
    client-go 0.36, the WatchListClient feature gate is enabled by
    default, and against API servers older than 1.36 the initial events
    stream never delivers the BOOKMARK event the client waits for —
    informers hang on cache sync until the job times out. Bump the
    matrix to 1.34.8 / 1.35.5 / 1.36.1 (current supported releases) and
    pass KUBE_FEATURE_WatchListClient=false to the integration-test step
    so older API servers in the matrix still pass.

  * Refresh setup-minikube, setup-helm, setup-go, and actions/checkout
    to their current major versions; bump the helm CLI used by the
    chart-test job to v3.21.0.

Release Charts workflow gets the same setup-helm/checkout/helm CLI
bumps plus chart-releaser-action v1.7.0 so it keeps releasing chart
artefacts after the toolchain upgrade.

Signed-off-by: Arsolitt <[email protected]>
@coderabbitai

coderabbitai Bot commented May 25, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d3f53a6c-a1c7-4c08-b2fb-8a6b3077e3db

📥 Commits

Reviewing files that changed from the base of the PR and between 5c14bfb and 6e4b262.

📒 Files selected for processing (10)
  • cmd/utils/flags.go
  • operator/redisfailover/config.go
  • operator/redisfailover/factory.go
  • operator/redisfailover/service/client.go
  • operator/redisfailover/service/generator.go
  • operator/redisfailover/service/generator_test.go
  • operator/redisfailover/service/generator_tls_test.go
  • service/redis/tls.go
  • service/redis/tls_test.go
  • test/integration/redisfailover/creation_test.go

📝 Walkthrough

<review_stack_artifact>

</review_stack_artifact>

Walkthrough

Adds native TLS support and cert-manager integration: CRD TLS fields and validation, cert-manager Certificate generation and service, Redis client TLS, operator wiring, generated clients/informers, chart and CI updates, and extensive TLS tests.

Changes

TLS Feature Implementation

Layer / File(s) Summary
TLS API Schema and Validation
api/redisfailover/v1/types.go, api/redisfailover/v1/defaults.go, api/redisfailover/v1/validate.go, api/redisfailover/v1/validate_tls_test.go, api/redisfailover/v1/zz_generated.deepcopy.go
RedisFailoverSpec extended with spec.tls; TLS types, cert-manager and BYO secret models added; validation enforces mutual exclusivity and defaults; deepcopy regenerated and tests added.
Kubernetes Generated Clients & Informers
Makefile, client/k8s/clientset/versioned/*, client/k8s/informers/externalversions/*, client/k8s/listers/redisfailover/*
Makefile updated for kube-code-generator CLI; typed clients refactored to generic/gentype patterns; generated informers, factories, and listers added.
Certificate Service & K8s Wiring
service/k8s/certificate.go, service/k8s/k8s.go, cmd/utils/k8s.go, cmd/redisoperator/main.go
New Certificate interface/service wrapping cert-manager client; Services interface and wiring include Certificate; CreateKubernetesClients returns cert-manager client; main passes cmClient into services.
Redis TLS Client & TLS Builder
service/redis/client.go, service/redis/tls.go, service/redis/tls_test.go
Redis client methods accept *tls.Config; BuildTLSConfig supports CA and optional client cert/key, enforces serverName and TLS1.2+, and has unit tests for CA and client cert scenarios.
Operator generator, ensure/heal/checker updates
operator/redisfailover/service/generator.go, operator/redisfailover/service/client.go, operator/redisfailover/service/names.go, operator/redisfailover/service/constants.go, operator/redisfailover/service/heal.go, operator/redisfailover/service/check.go, operator/redisfailover/checker.go, operator/redisfailover/ensurer.go
Generator templates and helper functions produce TLS-aware configs/scripts/probes; Ensure invokes certificate ensure; healer/checker accept RedisFailover for TLS context; helper functions for secret/cert naming added.
Mocks & Tests
operator/redisfailover/service/generator_tls_test.go, various mocks under mocks/*, updated unit/integration tests
Extensive TLS-focused tests added; mocks updated for new method signatures and cert-manager interactions.

Build, Charts, CI, and Misc

Layer / File(s) Summary
Build, toolchain, and codegen
Makefile, docker/app/Containerfile, docker/development/Containerfile, go.mod
Updated codegen image and CLI flags, Containerfile base images bumped, go.mod bumped to Go 1.26.3 and dependency upgrades (k8s.io/*, redis v9, cert-manager).
Linting and scripts
.golangci.yml, scripts/check.sh
Added golangci-lint config; removed explicit goimports exclusion and hard timeout in check script.
CI and Helm workflows
.github/workflows/ci.yaml, .github/workflows/helm.yml
Pinned newer actions, added job permissions, updated integration matrix, set KUBE_FEATURE_WatchListClient=false for pre-1.36 clusters, and upgraded Helm setup action.
Helm chart updates
charts/redisoperator/templates/deployment.yaml, charts/redisoperator/templates/service-account.yaml, charts/redisoperator/values.yaml, example/redisfailover/tls.yaml
Deployment template conditionally renders args/env; RBAC extended for cert-manager certificates; values.yaml adds KUBE_FEATURE_WatchListClient; example TLS manifest added.
Minor refactors and import reordering
cmd/utils/flags.go, service/k8s/secret.go, service/k8s/util.go, client/k8s/clientset/versioned/clientset.go, metrics/metrics.go
Non-functional import/order changes and small test updates (PVC types, hostname->localhost in probes).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Poem

🐰 With whiskers twitching at certificates bright,
We wove cert-manager magic, TLS just right.
Secrets tucked in /tls, exporters sip rediss with cheer,
Probes and clients now whisper securely, far and near.
Hops and carrots — the operator hums encrypted and clear.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tls-cert-manager

@Arsolitt
Arsolitt (Arsolitt) marked this pull request as ready for review May 25, 2026 08:28
@Arsolitt Arsolitt (Arsolitt) self-assigned this May 25, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces TLS support for the Redis Failover operator, allowing for encrypted communication between Redis, Sentinel, and the operator itself. It integrates with cert-manager for automated certificate provisioning while also supporting user-provided secrets. The changes include updates to the CRD API, configuration templates for Redis and Sentinel, and the internal Redis client to handle TLS handshakes. Critical feedback was provided regarding the lack of mutual TLS (mTLS) support in the operator's internal client; specifically, the operator needs to present its own client certificate when tls-auth-clients is enabled. Additionally, several Sentinel-specific TLS configuration directives were identified as missing or incorrectly formatted in the generation templates.

Comment on lines +35 to +38
caPEM, ok := secret.Data["ca.crt"]
if !ok {
return nil, fmt.Errorf("tls: secret %s/%s does not contain ca.crt", rf.Namespace, secretName)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The operator only extracts the CA certificate from the Secret. If spec.tls.authClients is set to yes (mutual TLS), the operator's internal Redis client will fail to authenticate because it is not presenting a client certificate. The operator should load tls.crt and tls.key from the same Secret and use them in the tls.Config.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6827d2d. tlsConfigFor now reads tls.crt and tls.key from the same Secret when spec.tls.authClients is yes, and the operator presents them as a client certificate via tls.Config.Certificates. It fails fast with a descriptive error if either entry is missing or empty in mutual-TLS mode.

Comment thread service/redis/tls.go Outdated
//
// Returns (nil, nil) when caPEM is empty: callers treat a nil config as
// "no TLS" and dial in plaintext.
func BuildTLSConfig(caPEM []byte, serverName string) (*tls.Config, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The BuildTLSConfig function should be updated to accept client certificate and key PEM data. This is necessary to support mutual TLS (mTLS) when tls-auth-clients is enabled on the Redis/Sentinel side, allowing the operator to authenticate itself.

Suggested change
func BuildTLSConfig(caPEM []byte, serverName string) (*tls.Config, error) {
func BuildTLSConfig(caPEM, certPEM, keyPEM []byte, serverName string) (*tls.Config, error) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6827d2d. BuildTLSConfig now accepts optional certPEM and keyPEM PEM blobs; when both are supplied it attaches the resulting tls.Certificate to tls.Config.Certificates. Supplying only one of the two is rejected with an explicit error so a call site cannot silently drop the client cert.

Comment thread service/redis/tls.go Outdated
Comment on lines +33 to +37
return &tls.Config{
RootCAs: pool,
ServerName: serverName,
MinVersion: tls.VersionTLS12,
}, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Load the client certificate into the tls.Config if provided. This ensures the operator can connect when the cluster requires client certificate validation.

    config := &tls.Config{
        RootCAs:    pool,
        ServerName: serverName,
        MinVersion: tls.VersionTLS12,
    }
    if len(certPEM) > 0 && len(keyPEM) > 0 {
        cert, err := tls.X509KeyPair(certPEM, keyPEM)
        if err != nil {
            return nil, fmt.Errorf("tls: failed to load client certificate: %w", err)
        }
        config.Certificates = []tls.Certificate{cert}
    }
    return config, nil

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6827d2d. BuildTLSConfig builds the tls.Certificate via tls.X509KeyPair after constructing the base tls.Config and returns a wrapped error if the PEMs are malformed. Unit tests cover the happy path, the half-supplied (cert without key / key without cert) error cases, and a malformed-PEM case.

tls-key-file ` + tlsKeyFile + `
tls-ca-cert-file ` + tlsCAFile + `
tls-auth-clients {{ .Spec.TLS.AuthClients }}
tls-replication yes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Sentinel configuration, the directive to enable TLS for connections to Redis instances must be prefixed with sentinel. Using tls-replication yes directly may be ignored or cause a configuration error in Sentinel.

Suggested change
tls-replication yes
sentinel tls-replication yes

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This directive does not exist in Redis Sentinel. The official sentinel.conf template uses tls-replication yes at the top level (no sentinel prefix) to enable TLS for outbound replication-protocol connections, which is what the operator emits. The current configuration was verified end-to-end against a live cluster: Sentinel monitors the master over TLS, the slave replicates over TLS, and the plaintext port refuses connections. Prefixing the directive with sentinel would make redis-sentinel reject the config at startup.

tls-key-file ` + tlsKeyFile + `
tls-ca-cert-file ` + tlsCAFile + `
tls-auth-clients {{ .Spec.TLS.AuthClients }}
tls-replication yes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When TLS is enabled, Sentinel-to-Sentinel communication should also be encrypted. You are missing the sentinel sentinel-tls-replication yes directive in the template, which is required for Sentinels to communicate with each other over TLS.

Suggested change
tls-replication yes
sentinel tls-replication yes
sentinel sentinel-tls-replication yes

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sentinel sentinel-tls-replication yes is not a real Redis directive. Sentinel-to-Sentinel TLS communication uses the same tls-port, tls-cert-file, tls-key-file and tls-ca-cert-file directives that are already emitted in the sentinel.conf template — each sentinel listens on its TLS port and the others dial it through that port. The live-cluster verification covered three sentinels reaching quorum over TLS without any additional directive.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yaml:
- Around line 9-23: Pin third-party actions to immutable commit SHAs, add a
minimal workflow permissions block, and disable checkout credential persistence:
replace tag-based usages (actions/checkout@v6, actions/setup-go@v6,
golangci/golangci-lint-action@v9 and the other usages
medyagh/[email protected], azure/setup-helm@v5) with their verified commit
SHAs, add a top-level permissions: block restricting GITHUB_TOKEN to least
privilege needed (for example permissions: contents: read) and set
persist-credentials: false on every actions/checkout step (the steps using
actions/checkout in the job) so credentials are not left in the workspace.

In @.github/workflows/helm.yml:
- Around line 12-14: The workflow is using tag-based action refs
(actions/checkout@v6, azure/setup-helm@v5, helm/[email protected])
and missing persist-credentials on checkout; update those action refs to
specific commit SHAs (replace the tag refs with the corresponding commit SHA
pinned refs) and add persist-credentials: false to the actions/checkout step
(alongside fetch-depth: 0) to disable persisted checkout credentials and harden
the token usage.

In `@charts/redisoperator/templates/service-account.yaml`:
- Around line 102-117: The RBAC rule granting create/delete/update/patch/watch
on cert-manager.io certificates should be guarded by a Helm value to avoid
unnecessary privileges; update the service-account template that currently lists
apiGroups: [cert-manager.io] and resources: [certificates] so it is wrapped in a
conditional (e.g. check .Values.rbac.certManager.enabled) and only renders those
verbs and the cert-manager rule when the value is true, leaving other RBAC rules
untouched and defaulting the new value to false in values.yaml.

In `@docker/development/Containerfile`:
- Line 1: The Containerfile currently pins the base image as "FROM
golang:1.23-alpine3.20" which is out of sync with go.mod and the app
Containerfile; update that FROM value to the repo's bumped Go toolchain (e.g.,
"golang:1.26.3-alpine3.23") so the development image matches go.mod and
docker/app/Containerfile, then rebuild/test to ensure tooling/lint/codegen
behavior is aligned.

In `@operator/redisfailover/service/check.go`:
- Around line 31-39: The current tlsConfigFor logic accepts a present but empty
ca.crt and lets redis.BuildTLSConfig return (nil, nil), causing callers to fall
back to plaintext; update tlsConfigFor (the block using s.GetSecret, caPEM,
redis.BuildTLSConfig and GetRedisName) to check len(caPEM) == 0 after retrieving
secret.Data["ca.crt"] and return a descriptive error (e.g., "tls: secret
<namespace>/<secretName> contains empty ca.crt") instead of calling
redis.BuildTLSConfig when the CA is empty so we fail fast and avoid silent
plaintext dialing.
- Around line 51-52: The CheckSentinelMonitor function exposes a variadic
monitor parameter but still indexes monitor[0], which can panic when no
arguments are passed; update the API by either making the first monitor a
required parameter (change signature from CheckSentinelMonitor(rFailover
*redisfailoverv1.RedisFailover, sentinel string, monitor ...string) to accept
monitor string) or add an explicit guard at the start of CheckSentinelMonitor
that returns a clear error when len(monitor) == 0; apply the same fix to the
other occurrence of CheckSentinelMonitor usage/definition found in the file so
no path can call it without a monitor and cause a panic.

In `@operator/redisfailover/service/generator.go`:
- Around line 1050-1052: The decorated init/extra containers created by
getInitContainersWithRedisEnv and getExtraContainersWithRedisEnv currently only
receive REDIS_ADDR but not TLS volume mounts or certificate env vars, so when
TLSEnabled(rf) is true you must mirror the exporter handling: add
tlsVolumeMount() to their VolumeMounts and inject the same cert/CA env vars (the
ones used for the exporter) into their Env lists; update the code paths in
getInitContainersWithRedisEnv and getExtraContainersWithRedisEnv to check
TLSEnabled(rf) and append tlsVolumeMount() plus the cert-related env entries so
sidecars/init containers can use rediss:// properly (also apply the same fix in
the other similar block around the 1323-1330 region).

In `@service/redis/client.go`:
- Around line 281-290: MakeSlaveOfWithPort is dialing net.JoinHostPort(ip,
masterPort) which incorrectly uses the upstream master's port for the local
connection; change the dial address to use the local Redis port (e.g.,
net.JoinHostPort(ip, redisPort)) while still sending the SLAVEOF command with
masterIP and masterPort so the local client connects to the local instance but
configures it to follow the external master. Update the creation of
rediscli.Options Addr in MakeSlaveOfWithPort (and ensure MakeSlaveOf still
delegates correctly) so only the SLAVEOF uses masterPort.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9ad5b95e-93ef-47bd-8499-47c33114960c

📥 Commits

Reviewing files that changed from the base of the PR and between 632aa3d and 0338e19.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (67)
  • .github/workflows/ci.yaml
  • .github/workflows/helm.yml
  • .golangci.yml
  • Makefile
  • api/redisfailover/v1/defaults.go
  • api/redisfailover/v1/types.go
  • api/redisfailover/v1/validate.go
  • api/redisfailover/v1/validate_tls_test.go
  • api/redisfailover/v1/zz_generated.deepcopy.go
  • charts/redisoperator/crds/databases.spotahome.com_redisfailovers.yaml
  • charts/redisoperator/templates/deployment.yaml
  • charts/redisoperator/templates/service-account.yaml
  • charts/redisoperator/values.yaml
  • client/k8s/clientset/versioned/clientset.go
  • client/k8s/clientset/versioned/doc.go
  • client/k8s/clientset/versioned/fake/clientset_generated.go
  • client/k8s/clientset/versioned/typed/redisfailover/v1/fake/fake_redisfailover.go
  • client/k8s/clientset/versioned/typed/redisfailover/v1/fake/fake_redisfailover_client.go
  • client/k8s/clientset/versioned/typed/redisfailover/v1/redisfailover.go
  • client/k8s/clientset/versioned/typed/redisfailover/v1/redisfailover_client.go
  • client/k8s/informers/externalversions/factory.go
  • client/k8s/informers/externalversions/generic.go
  • client/k8s/informers/externalversions/internalinterfaces/factory_interfaces.go
  • client/k8s/informers/externalversions/redisfailover/interface.go
  • client/k8s/informers/externalversions/redisfailover/v1/interface.go
  • client/k8s/informers/externalversions/redisfailover/v1/redisfailover.go
  • client/k8s/listers/redisfailover/v1/expansion_generated.go
  • client/k8s/listers/redisfailover/v1/redisfailover.go
  • cmd/redisoperator/main.go
  • cmd/utils/flags.go
  • cmd/utils/k8s.go
  • docker/app/Containerfile
  • docker/development/Containerfile
  • example/redisfailover/tls.yaml
  • go.mod
  • manifests/databases.spotahome.com_redisfailovers.yaml
  • manifests/kustomize/base/databases.spotahome.com_redisfailovers.yaml
  • metrics/metrics.go
  • mocks/operator/redisfailover/service/RedisFailoverCheck.go
  • mocks/operator/redisfailover/service/RedisFailoverClient.go
  • mocks/operator/redisfailover/service/RedisFailoverHeal.go
  • mocks/service/k8s/Services.go
  • mocks/service/redis/Client.go
  • operator/redisfailover/checker.go
  • operator/redisfailover/checker_test.go
  • operator/redisfailover/ensurer.go
  • operator/redisfailover/ensurer_test.go
  • operator/redisfailover/service/check.go
  • operator/redisfailover/service/check_test.go
  • operator/redisfailover/service/client.go
  • operator/redisfailover/service/constants.go
  • operator/redisfailover/service/generator.go
  • operator/redisfailover/service/generator_test.go
  • operator/redisfailover/service/generator_tls_test.go
  • operator/redisfailover/service/heal.go
  • operator/redisfailover/service/heal_test.go
  • operator/redisfailover/service/names.go
  • scripts/check.sh
  • service/k8s/certificate.go
  • service/k8s/k8s.go
  • service/k8s/secret.go
  • service/k8s/secret_test.go
  • service/k8s/statefulset_test.go
  • service/k8s/util.go
  • service/redis/client.go
  • service/redis/tls.go
  • service/redis/tls_test.go
💤 Files with no reviewable changes (1)
  • client/k8s/clientset/versioned/doc.go

Comment thread .github/workflows/ci.yaml
Comment thread .github/workflows/helm.yml Outdated
Comment thread charts/redisoperator/templates/service-account.yaml
Comment thread docker/development/Containerfile Outdated
Comment thread operator/redisfailover/service/check.go Outdated
Comment on lines +51 to 52
CheckSentinelMonitor(rFailover *redisfailoverv1.RedisFailover, sentinel string, monitor ...string) error
GetMasterIP(rFailover *redisfailoverv1.RedisFailover) (string, error)

@coderabbitai coderabbitai Bot May 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t keep a variadic API that panics on empty input.

CheckSentinelMonitor() still does monitor[0] unconditionally, so the public interface can panic if a caller omits the variadic arg. Either make the first monitor value required in the signature or return an error when len(monitor) == 0.

Also applies to: 293-297

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator/redisfailover/service/check.go` around lines 51 - 52, The
CheckSentinelMonitor function exposes a variadic monitor parameter but still
indexes monitor[0], which can panic when no arguments are passed; update the API
by either making the first monitor a required parameter (change signature from
CheckSentinelMonitor(rFailover *redisfailoverv1.RedisFailover, sentinel string,
monitor ...string) to accept monitor string) or add an explicit guard at the
start of CheckSentinelMonitor that returns a clear error when len(monitor) == 0;
apply the same fix to the other occurrence of CheckSentinelMonitor
usage/definition found in the file so no path can call it without a monitor and
cause a panic.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pre-existing behaviour in the upstream Spotahome operator, not a change introduced by this PR — the variadic signature predates the TLS work. Reshaping the interface (either making the first monitor argument required or adding an explicit guard) would touch every caller of CheckSentinelMonitor, which is outside the scope of a TLS feature PR. Worth filing as a separate cleanup issue upstream.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Arsolitt, that's a fair point — the panic risk pre-dates this PR and fixing it properly would require reshaping every call site, which is clearly out of scope here. Would you like me to open a GitHub issue to track this cleanup so it doesn't get lost?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for offering. The pre-existing variadic panic is fairly easy to spot from a search of the upstream repository, and reshaping it touches every caller across both the upstream Spotahome project and this fork — better tracked there directly than as a downstream-fork issue that would always lag the upstream interface. I'll keep the note in this thread as the record. No issue needed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Arsolitt, understood — tracking it upstream makes much more sense given the interface is shared across the Spotahome project and all its forks. The thread note here is a reasonable record. Thanks for the context!

(´・ω・`) 🐇

Comment on lines +1050 to +1052
if TLSEnabled(rf) {
volumeMounts = append(volumeMounts, tlsVolumeMount())
}

@coderabbitai coderabbitai Bot May 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Propagate TLS material to the containers that inherit REDIS_ADDR.

getInitContainersWithRedisEnv() / getExtraContainersWithRedisEnv() only append env vars, so with this change those containers can now receive REDIS_ADDR=rediss://... without getting tlsVolumeMount() or the cert/CA env needed to use it. Any sidecar or init container that already relies on the injected Redis env will break as soon as TLS is enabled. Mirror the exporter path for those decorated containers when TLSEnabled(rf) is true.

Also applies to: 1323-1330

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator/redisfailover/service/generator.go` around lines 1050 - 1052, The
decorated init/extra containers created by getInitContainersWithRedisEnv and
getExtraContainersWithRedisEnv currently only receive REDIS_ADDR but not TLS
volume mounts or certificate env vars, so when TLSEnabled(rf) is true you must
mirror the exporter handling: add tlsVolumeMount() to their VolumeMounts and
inject the same cert/CA env vars (the ones used for the exporter) into their Env
lists; update the code paths in getInitContainersWithRedisEnv and
getExtraContainersWithRedisEnv to check TLSEnabled(rf) and append
tlsVolumeMount() plus the cert-related env entries so sidecars/init containers
can use rediss:// properly (also apply the same fix in the other similar block
around the 1323-1330 region).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Init and extra containers are an extension point the user owns. The operator already wires REDIS_ADDR (scheme adjusted to rediss:// when TLS is on), but the certificate material and any extra mount points are the user's responsibility — sidecars commonly bring their own auth mechanism (for example a redis-exporter image baked with its own CA bundle, or a sidecar that does not talk to Redis at all). Forcing a tlsVolumeMount() into every user-supplied container would also break containers that already mount their own /tls.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Arsolitt, that's a clear and well-reasoned design boundary — user-supplied init/extra containers are intentionally opaque to the operator, and injecting tlsVolumeMount() unconditionally would conflict with containers that already manage their own /tls mount or that don't communicate with Redis at all. The rediss:// scheme in REDIS_ADDR is enough signal for containers that do care about TLS to act on. Understood, I'll drop the suggestion.


✏️ Learnings added
Learnt from: Arsolitt
Repo: cozystack/redis-operator PR: 1
File: operator/redisfailover/service/generator.go:1050-1052
Timestamp: 2026-05-25T09:17:24.839Z
Learning: In `operator/redisfailover/service/generator.go` (redis-operator project, Go), `getInitContainersWithRedisEnv` and `getExtraContainersWithRedisEnv` intentionally do NOT inject `tlsVolumeMount()` or TLS cert env vars (e.g. `REDIS_EXPORTER_TLS_*`) into user-supplied init/extra containers, even when TLS is enabled. These containers are a user-owned extension point; the operator only adjusts `REDIS_ADDR` to use the `rediss://` scheme. Forcing TLS volume mounts would break sidecars that bring their own CA bundles, don't talk to Redis, or already mount `/tls` themselves. Do not flag this as a missing TLS propagation issue in future reviews.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread service/redis/client.go
Address review feedback from coderabbitai on operator/redisfailover/service/check.go:39:
tlsConfigFor only verified that the ca.crt key existed in the TLS Secret,
not that its value was non-empty. When the key was present but empty,
redis.BuildTLSConfig returns (nil, nil), which callers interpret as
"no TLS" and dial in plaintext — silently downgrading the connection
even though spec.tls.enabled is true. Fail fast instead.

Signed-off-by: Arsolitt <[email protected]>
Address review feedback from gemini-code-assist on
service/redis/tls.go:22, service/redis/tls.go:37, and
operator/redisfailover/service/check.go:38:
the operator only loaded the CA bundle from the TLS Secret, so its
internal Redis client could connect to a server that ran in
non-mutual-TLS mode (tls-auth-clients: no) but had no way to satisfy
servers that ran in mutual mode — the handshake would be rejected with
no client certificate to validate.

BuildTLSConfig now takes optional certPEM/keyPEM PEM blobs and attaches
them as tls.Config.Certificates when both are supplied; supplying only
one is rejected. tlsConfigFor reads the leaf certificate and key from
the same cert-manager Secret (the tls.crt / tls.key entries already
populated by the issued Certificate, which carries serverAuth and
clientAuth EKUs) when spec.tls.authClients is yes, and fails fast with
a descriptive error if either entry is missing or empty.

Unit tests cover the new branches: client cert loaded, mismatched
cert/key arguments, and malformed cert/key PEMs.

Signed-off-by: Arsolitt <[email protected]>
Address review feedback from coderabbitai on docker/development/Containerfile:1:
the dev image still pinned golang:1.23-alpine3.20 while go.mod targets
go 1.26.3 and docker/app/Containerfile already used golang:1.26.3-alpine3.23.
Match the dev image so local builds, codegen and lint share the same
toolchain as the production image.

Signed-off-by: Arsolitt <[email protected]>
Address review feedback from coderabbitai on .github/workflows/ci.yaml:23
and .github/workflows/helm.yml:14:

- Pin every third-party action to an immutable commit SHA (with the
  resolved version pinned in a trailing comment for human readability).
  Tag references are mutable and let a compromised action push
  arbitrary code into the workflow on the next run.
- Add a top-level permissions block to each workflow. CI runs with the
  minimum contents: read; the Release Charts workflow needs
  contents: write because chart-releaser-action pushes tags, releases
  and the gh-pages branch. Both replace GitHub's default broad
  GITHUB_TOKEN scope.
- Set persist-credentials: false on every actions/checkout step so the
  short-lived GITHUB_TOKEN is not left behind in .git/config for later
  steps to abuse.

Signed-off-by: Arsolitt <[email protected]>
Update test/integration/redisfailover/creation_test.go call sites that
the TLS work changed but did not migrate:

- CreateKubernetesClients now returns the cert-manager client too;
  destructure the 5th return into cmClient.
- k8s.New takes that cert-manager client as an extra argument.
- IsMaster and GetSentinelMonitor now require a *tls.Config; pass nil
  since the integration suite still talks to Redis in plaintext.

Restores `go test -tags=integration` compile so the integration matrix
turns green again across all three Kubernetes versions.

Signed-off-by: Arsolitt <[email protected]>
CreateOrUpdateService used to overwrite the live Service's metadata
with whatever the operator generated, dropping any labels and
annotations that other actors (admission webhooks, service meshes,
cluster operators stamping metadata) had attached. Merge the stored
metadata with the operator-managed metadata before issuing the update
so external annotations survive every reconcile.

This is the patch we have been carrying as an out-of-tree fix on
in-cluster builds; folding it into the fork so the same image works
without a sidecar patch step.

Signed-off-by: Arsolitt <[email protected]>
Address review feedback from coderabbitai on service/redis/client.go:290:
MakeSlaveOfWithPort dialed the local Redis instance at
net.JoinHostPort(ip, masterPort) and sent SLAVEOF masterIP masterPort,
collapsing two distinct ports into one. In same-cluster failover the
two are equal so the bug was dormant, but SetExternalMasterOnAll
threads the external master's port through this API — when that port
differs from the local Redis port the operator dials the local pod
on the wrong port and the slave-of reconfig fails for every external
master takeover.

The function now takes localPort and masterPort as independent
arguments: the dial uses ip:localPort, the SLAVEOF command keeps
masterIP:masterPort. SetExternalMasterOnAll resolves localPort from
spec.Redis.Port before iterating pods; the same-cluster callers
(SetOldestAsMaster, SetMasterOnAll) pass the same port for both
values since their masterPort always equals localPort.

This supersedes the original WONTFIX on PR #1 review thread 12 — the
fix turned out cheap enough to fold into the TLS branch rather than
defer.

Signed-off-by: Arsolitt <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
service/redis/tls_test.go (1)

16-24: ⚡ Quick win

Add a regression case for client keypair + empty CA.

Given the new mTLS parameters, add coverage for certPEM/keyPEM present with empty caPEM to ensure this path errors instead of falling back.

🧪 Proposed test addition
 func TestBuildTLSConfigNilOnEmptyCA(t *testing.T) {
 	cfg, err := BuildTLSConfig(nil, nil, nil, "redis.example.com")
 	assert.NoError(t, err)
 	assert.Nil(t, cfg)

 	cfg, err = BuildTLSConfig([]byte{}, nil, nil, "redis.example.com")
 	assert.NoError(t, err)
 	assert.Nil(t, cfg)
+
+	clientCert, clientKey := selfSignedClientPEM(t)
+	_, err = BuildTLSConfig(nil, clientCert, clientKey, "redis.example.com")
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "caPEM must be supplied")
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/redis/tls_test.go` around lines 16 - 24, Add a regression test that
verifies BuildTLSConfig returns an error (and no config) when a client cert/key
pair is provided but caPEM is empty; create a new test (e.g.,
TestBuildTLSConfigErrorOnClientCertWithoutCA) in service/redis/tls_test.go that
calls BuildTLSConfig with non-empty certPEM and keyPEM bytes and nil or empty
caPEM and asserts an error is returned and cfg is nil, referencing
BuildTLSConfig to validate the mTLS parameter path does not silently fall back.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@service/redis/tls.go`:
- Around line 28-31: The current BuildTLSConfig returns (nil, nil) when caPEM is
empty which allows non-empty certPEM/keyPEM to be ignored and silently disables
TLS; change the early-return logic in BuildTLSConfig so that if caPEM is empty
but either certPEM or keyPEM is non-empty you return a non-nil error (with a
clear message) instead of (nil, nil); keep the existing nil return only when all
three PEMs are empty, and surface the error via BuildTLSConfig's error return so
callers cannot silently fall back to plaintext.

In `@test/integration/redisfailover/creation_test.go`:
- Around line 249-250: The call to c.redisClient.GetSentinelMonitor can return
an error and the code then appends master to the masters slice and later
accesses masters[0] without guarding for emptiness; update the calls to capture
and handle the returned error from GetSentinelMonitor (e.g., check err != nil
and fail or retry/log appropriately) and before any access to masters[0] ensure
that len(masters) > 0 (or handle the empty case) so the test cannot panic;
reference the GetSentinelMonitor call, the local variables master/err, and the
masters slice when making these changes.
- Around line 226-227: The loop currently ignores the error returned by
c.redisClient.IsMaster; update the check in creation_test.go so you capture and
handle the error from IsMaster(ip, "6379", testPass, nil) (e.g., if err != nil {
t.Fatalf("IsMaster failed for %s: %v", ip, err) } or t.Errorf and return),
instead of discarding it, so connectivity/auth failures are surfaced and the
masters slice is not miscounted; refer to the call to IsMaster and the masters
append logic to locate and modify the code.

---

Nitpick comments:
In `@service/redis/tls_test.go`:
- Around line 16-24: Add a regression test that verifies BuildTLSConfig returns
an error (and no config) when a client cert/key pair is provided but caPEM is
empty; create a new test (e.g., TestBuildTLSConfigErrorOnClientCertWithoutCA) in
service/redis/tls_test.go that calls BuildTLSConfig with non-empty certPEM and
keyPEM bytes and nil or empty caPEM and asserts an error is returned and cfg is
nil, referencing BuildTLSConfig to validate the mTLS parameter path does not
silently fall back.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fdc29f2a-340c-4207-b555-2273a2baef3a

📥 Commits

Reviewing files that changed from the base of the PR and between 0338e19 and 5c14bfb.

📒 Files selected for processing (12)
  • .github/workflows/ci.yaml
  • .github/workflows/helm.yml
  • docker/development/Containerfile
  • mocks/service/redis/Client.go
  • operator/redisfailover/service/check.go
  • operator/redisfailover/service/heal.go
  • operator/redisfailover/service/heal_test.go
  • service/k8s/service.go
  • service/redis/client.go
  • service/redis/tls.go
  • service/redis/tls_test.go
  • test/integration/redisfailover/creation_test.go

Comment thread service/redis/tls.go
Comment thread test/integration/redisfailover/creation_test.go Outdated
Comment thread test/integration/redisfailover/creation_test.go Outdated
Address review feedback from coderabbitai on service/redis/tls.go:31:
the early (nil, nil) return on len(caPEM) == 0 also accepted non-empty
certPEM/keyPEM, which would silently disable TLS in a misconfigured
mTLS path — exactly the kind of silent plaintext fallback we already
guard against on the caller side. Fail fast in BuildTLSConfig instead.

New test TestBuildTLSConfigRejectsClientMaterialWithoutCA covers both
cert+key without CA and cert-only without CA.

Signed-off-by: Arsolitt <[email protected]>
Address review feedback from coderabbitai on
test/integration/redisfailover/creation_test.go:227:
the loop in testRedisMaster discarded the IsMaster error, so a
connectivity, auth or replication-state failure on any single probe
would silently leave that pod out of the master count, masking the
underlying failure mode. Assert NoError and skip the pod on error so
the failure surfaces in the test report instead of producing a wrong
count downstream.

Signed-off-by: Arsolitt <[email protected]>
Address review feedback from coderabbitai on
test/integration/redisfailover/creation_test.go:250:
GetSentinelMonitor errors were silently dropped in the sentinel
monitoring loop, and the test then read masters[0] unconditionally —
both a transient probe failure and an unexpectedly empty sentinel
list would surface as a confusing index-out-of-range panic rather
than a clear assertion. Assert NoError on each probe, skip the pod
when it fails, and bail out with assert.NotEmpty before indexing.

Signed-off-by: Arsolitt <[email protected]>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM — two functional gaps in TLS-mode operation: the in-pod self-connections (liveness probe via $(hostname), redis_exporter via 127.0.0.1, sentinel monitor target 127.0.0.1) hit hosts that aren't covered by the generated certificate's SAN/IPSAN list, and the SAN list itself hardcodes cluster.local so any cluster running a custom cluster-domain (cozystack's default is cozy.local) gets a SAN mismatch on every legitimate FQDN dial.

The API surface, validation, TLS-config builder (service/redis/tls.go), Certificate spec (correct digital signature + server auth + client auth usages), and mTLS plumbing all look solid. The findings below are about which hosts the in-cluster dials use and which DNS forms the cert SAN list actually covers.

Blockers

B1: in-pod TLS dials use names that are not in the cert SAN list

Three call sites:

  • Liveness probe (operator/redisfailover/service/generator.go:590):
    redis-cli --tls ... -h $(hostname) -p $PORT ping
    
    In a StatefulSet pod $(hostname) returns the bare pod name (rfr-<release>-0). The SAN list provides *.rfr-<release> (and the namespaced/svc/FQDN forms of that wildcard) — DNS wildcards in X.509 must consume exactly one label, so the bare rfr-<release>-0 does NOT match *.rfr-<release> or any of its FQDN siblings. redis-cli ≥ 6 verifies the server cert against the host in -h by default.
  • Exporter (operator/redisfailover/service/generator.go:1327-1329, asserted by TestRedisExporterUsesTLS in generator_tls_test.go):
    REDIS_ADDR=rediss://127.0.0.1:6379
    REDIS_EXPORTER_TLS_CA_CERT_FILE=/tls/ca.crt
    
    oliver006/redis_exporter verifies the server cert by default (no REDIS_EXPORTER_SKIP_TLS_VERIFICATION is set). 127.0.0.1 is not in the cert's IPAddresses field — only DNS SANs are emitted.
  • Sentinel config (redisConfigTemplate / sentinel config in generator.go):
    sentinel monitor mymaster 127.0.0.1 ${PORT} 2
    
    Each Sentinel dials its local Redis at 127.0.0.1 to monitor it. Same IPSAN miss.

Impact: every TLS-enabled failover should fail at startup once probes / exporter / sentinel monitor begin verifying the cert. The PR body says "Verified end-to-end against a live Kubernetes cluster: TLS PING/PONG on the master, ..." — that doesn't align with my read of the code. Possibilities:

  1. The test environment used a redis-cli / redis_exporter version with default --insecure / SKIP_TLS_VERIFICATION=true — that would mask the issue.
  2. The test exercised only the client-facing TLS port via an external client with the proper SAN, not the in-pod probes / exporter / sentinel-monitor path.
  3. I'm missing a flag.

Fix (any of these is sufficient; pick the simplest):

  • Add localhost to DNSNames and 127.0.0.1 (+ ::1) to IPAddresses in redisCertificateDNSNames. The exporter and sentinel-monitor would then verify cleanly against 127.0.0.1, and the probe could switch to -h localhost.
  • Alternatively, switch the probe to a full FQDN that the existing wildcard covers (e.g. ${POD_NAME}.${SERVICE_NAME}.${NAMESPACE}.svc.${CLUSTER_DOMAIN}) via downward-API env vars, and add 127.0.0.1 IPSAN just for the exporter/sentinel paths.

B2: SAN list hardcodes cluster.local — breaks every non-default cluster-domain

File: operator/redisfailover/service/generator.go:167 (and the wildcard equivalents):

fmt.Sprintf("%s.%s.svc.cluster.local", svc, ns)

Kubernetes clusters routinely run with a different --cluster-domain (cozystack's default is cozy.local; many on-prem clusters use cluster.internal, custom values are common). On such clusters, the FQDN form rfr-<release>.<ns>.svc.<custom-domain> is what clients actually resolve, but the cert SAN says cluster.local — TLS verify fails.

Impact: this PR is the upstream that cozystack#2729 consumes, and cozystack's default cluster-domain is cozy.local. Any cozystack user enabling the operator-managed TLS path (spec.tls.certManager) hits a SAN mismatch for every routed FQDN. The cozystack chart works around this in cozystack#2729 by using the certificateSecret (bring-your-own-Secret) mode where the chart renders its own cert-manager Certificate with the configurable cluster-domain — but the operator-managed path stays broken for non-default clusters.

Fix: read the cluster domain from an operator flag (e.g. --cluster-domain, defaulting to cluster.local for backward compat) or from a configurable field on the TLSSettings/CertManagerSettings block, and template the SAN list accordingly. The kubelet's --cluster-domain is the canonical source; passing it through as a Helm chart value is the standard pattern (cert-manager, coredns, etc. all expose this).

Non-blocking observations

  1. BuildTLSConfig in service/redis/tls.go is clean: TLS 1.2 minimum, proper RootCAs, ServerName required, mismatched cert/key supply rejected, mTLS support. Good shape.
  2. validateTLS correctly enforces certManager XOR certificateSecret and defaults IssuerRef.Group/Kind. Good.
  3. extraSANs IP/DNS classification via net.ParseIP is fine; consider warning when a user supplies obviously private/dangerous values like 0.0.0.0 or ::, but that's polish.
  4. CRD regeneration is consistent across the two databases.spotahome.com_redisfailovers.yaml files (charts/redisoperator/crds/ and manifests/kustomize/base/). No drift.
  5. tls-replication yes in the redis config means slave→master replication traverses TLS — good default, matches the README/backlog intent.
  6. The backlog note in the PR body about flipping the flag from "enable TLS" to "expose TLS port to clients" while always running TLS for inter-component traffic is a sound direction — worth tracking separately. Doesn't gate this PR.

Address review feedback from lexfrei (Blocker B1) on
operator/redisfailover/service/generator.go: in-pod self-dials hit
hosts that were not in the cert SAN list, breaking startup whenever
the verifying party (redis-cli >= 6, oliver006/redis_exporter, the
Sentinel monitor target) actually checks the certificate against the
dialed host.

Three call sites were affected:

- Liveness probe dialed `$(hostname)` (the bare StatefulSet pod name,
  e.g. `rfr-myrf-0`). The wildcard `*.<headless>` SAN does not match
  a bare label: X.509 wildcards consume exactly one label.
- redis_exporter / sentinel-exporter dialed `127.0.0.1`, and the
  Sentinel monitor config statement `sentinel monitor mymaster
  127.0.0.1 ...` did the same. 127.0.0.1 was not in the cert's
  IPAddresses field — only DNS SANs were emitted.

Fix:

- redisCertificateSANs (renamed from redisCertificateDNSNames) now
  returns both DNSNames and IPAddresses. localhost joins the DNS set
  so the redis-cli liveness probe verifies cleanly; 127.0.0.1 and ::1
  join the IP set so the in-pod exporter and Sentinel monitor target
  verify against the same cert.
- The Redis and Sentinel liveness/readiness probes switch from
  `-h $(hostname)` to `-h localhost`, which the new DNS SAN covers.

Tests:

- Updated TestEnsureRedisCertificate* to assert the new default IP
  SANs and the localhost DNS SAN, and to guard against silent
  regression of the default IPAddresses slice.
- Strengthened TestRedisLivenessProbeUsesTLS / TestSentinelProbeUsesTLS
  to assert `-h localhost` and the absence of `$(hostname)`.
- Updated the plain-text probe expectations in generator_test.go so
  the non-TLS path also dials localhost — the SAN-mismatch concern
  does not apply there, but consistency between TLS and plaintext
  probes keeps the codepath shared.

Signed-off-by: Arsolitt <[email protected]>
Address review feedback from lexfrei (Blocker B2) on
operator/redisfailover/service/generator.go: the FQDN SAN entries
hardcoded ".svc.cluster.local", so on any Kubernetes cluster running
with a non-default --cluster-domain (cozystack defaults to
"cozy.local"; on-prem clusters routinely use "cluster.internal" or a
site-specific value) the cert-manager Certificate produced by the
operator-managed path did not cover the FQDNs clients actually
resolve, and TLS verification failed for every routed FQDN dial.

The bring-your-own-Secret path was unaffected because the chart
renders its own Certificate with the configurable domain, but the
operator-managed path stayed broken for non-default clusters.

Plumbing:

- New `--cluster-domain` CLI flag (default "cluster.local") on the
  operator binary.
- Threaded through the existing `redisfailover.Config` struct, the
  factory, and `NewRedisFailoverKubeClient`, which now takes the
  cluster domain as a constructor argument and stores it on the
  client.
- `generateRedisCertificate` and `redisCertificateSANs` accept the
  cluster domain and template the `.svc.<domain>` FQDN SAN entries
  accordingly. An empty value falls back to "cluster.local" so
  deployments that never set the flag keep working.

Tests:

- All `NewRedisFailoverKubeClient` test callers updated to pass the
  default "cluster.local" — purely mechanical.
- New TestEnsureRedisCertificateUsesConfiguredClusterDomain asserts
  that with --cluster-domain=cozy.local the FQDN SANs use ".cozy.local"
  and that the default ".cluster.local" form is NOT silently added in
  addition (which would mask SAN-mismatch bugs).

Signed-off-by: Arsolitt <[email protected]>
@Arsolitt

Copy link
Copy Markdown
Collaborator Author

Aleksei Sviridkin (@lexfrei) thanks for the detailed read. Both blockers are addressed:

B1 — in-pod TLS self-dials. Commit d6ee49a reshapes the SAN helper (renamed to redisCertificateSANs) to return both DNSNames and IPAddresses. localhost joins the DNS set, 127.0.0.1 and ::1 join the IP set, so the redis_exporter sidecar (REDIS_ADDR=rediss://127.0.0.1:...), the sentinel monitor target (sentinel monitor mymaster 127.0.0.1 ...) and the sentinel-exporter all verify against the same cert. The Redis and Sentinel liveness/readiness probes switch from -h $(hostname) to -h localhost — your reading of the wildcard semantics is right, the bare pod name was never going to match *.<headless>. Probe tests now assert -h localhost and the absence of $(hostname).

The end-to-end PING/PONG line in the original PR body did mask this: the test client dialled the operator-facing TLS port from outside the pod, where the headless DNS SAN applied. The in-pod probes / exporter / sentinel-monitor path was not exercised separately.

B2 — hardcoded cluster domain. Commit 6e4b262 adds a new --cluster-domain CLI flag (default cluster.local for backward compat) on the operator binary. It is threaded through redisfailover.Config and NewRedisFailoverKubeClient, and the .svc.<domain> FQDN SANs are templated against it. The new test TestEnsureRedisCertificateUsesConfiguredClusterDomain runs with cozy.local and asserts both that the cozy.local FQDNs are present and that the cluster.local form is NOT silently added in addition (which would mask SAN-mismatch bugs on non-default clusters).

Non-blocking observations 1–6 acknowledged, no changes needed on this PR; the backlog item about flipping the flag semantics is worth tracking separately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM on 6e4b2623. Both blockers from my prior review are addressed in two surgical commits.

  • d6ee49a fix(tls): cover in-pod TLS self-dials in the generated cert SAN list — closes B1 (liveness probe via $(hostname), redis_exporter via 127.0.0.1, sentinel monitor target 127.0.0.1 failing TLS hostname verification). redisCertificateDNSNames rename → redisCertificateSANs now returns both dnsNames and ipAddresses; localhost joins the DNS SAN list and 127.0.0.1 + ::1 join the IP SAN list. Pod IPs are still excluded with an explicit comment ("unstable, ServerName override handles them") — correct call. 58 new lines of test coverage in generator_tls_test.go for the in-pod self-dial path.

  • 6e4b262 feat(tls): add --cluster-domain flag for cert SAN templating — closes B2 (hardcoded cluster.local breaking non-default cluster-domain setups like cozystack's cozy.local). New --cluster-domain operator flag plumbs through CMDFlags → Config → operator factory → generateRedisCertificate → redisCertificateSANs(rf, clusterDomain). Default is cluster.local so existing deployments that never set the flag keep working. Tests cover the parameterization (generator_tls_test.go +77 lines).

API surface, validation, BuildTLSConfig, mTLS plumbing, cert usages (digital signature + server auth + client auth + key encipherment) all carry over from prior review. The fork is ready to tag — once v1.4.0 lands, the downstream cozystack/cozystack#2729 can also move on its B1.

@Arsolitt
Arsolitt (Arsolitt) merged commit 04efaf3 into master May 27, 2026
9 of 12 checks passed
@Arsolitt
Arsolitt (Arsolitt) deleted the feat/tls-cert-manager branch May 27, 2026 08:00
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants