Skip to content

Sync upstream Saremox/main (24 commits) - #29

Open
usiegj00 wants to merge 25 commits into
mainfrom
sync/upstream-2026-09-29
Open

usiegj00 wants to merge 25 commits into
mainfrom
sync/upstream-2026-09-29

Conversation

@usiegj00

Copy link
Copy Markdown
Collaborator

Brings the fork level with Saremox/main — 0 commits behind after this.

Why now

Three of these we actively want:

commit why
Saremox#196 fix(chart): set fsGroup on the operator pod, not its container the warning printed on every helm upgrade of the operator
Saremox#182 Disconnect a demoted master's clients once it leaves the master Service complements promote-first — clients move off the demoted master instead of lingering on a replica
Saremox#193 fix(failover): don't promote while a ready Redis pod doesn't answer guards the exact promotion path #26 added

Also Saremox#191 in-place pod resize, Saremox#190 operator-managed maxmemory, Saremox#185 kooper dropped, Saremox#181 reconcile on pod changes, Saremox#195 CRD fits the upgrade-hook ConfigMap.

Conflict resolutions, and the judgement calls

  • go.mod — kept cobra (our instance-manager CLI), dropped kooper since Drop the kooper dependency Saremox/redis-operator#185 removed its use. Verified nothing imports kooper; only the metric names keep the prefix, deliberately, so dashboards keep working.
  • config.go / cmd/utils/flags.go — additive both ways: kept our InstanceManagerImage and their DisconnectClientsOnDemotion/KeepClientsOnDemotion.
  • CRDs (×3) — additive: ours adds instanceManagerImage, theirs adds maxMemory. Both kept, alphabetical. Not regenerated — make generate needs a docker build that 404s on mockery locally, so "Verify generated code" in CI is the real check here.
  • checker.go — dropped our replica-quorum gate. Upstream's redisPodsSettled now arrives natively and does the same job better, by counting pods against spec.redis.replicas. This supersedes Adopt upstream's settled check in place of our replica quorum #28, which did the same port by hand.
  • checker_test.go — removed our quorum test; adopted upstream's TestUpdateRedisesPodsWaitsForTheLastReplacement, adapting only the master rows, where a stale master is promoted away rather than deleted.
  • generator_test.go — my first resolution duplicated two test functions; removed. TestRedisShutdownConfigMapRetries then failed because sentinel is off by default in this fork, so it got the operator-managed script with no retry loops. Enabled sentinel explicitly in that test — a genuine fork adaptation, not a workaround.
  • .github/workflows/e2e.yml — rebuilt as upstream's file plus our two extra jobs (e2e-probe-behavior, e2e-instance-manager). Upstream has evolved the e2e-sentinel-free job we contributed, so theirs wins for that one.
  • README.md — the merge misaligned our Instance Manager example against 178 lines of new upstream docs; reconstructed our section and placed theirs after. Also removed a duplicated ### Persistence heading my first pass created.

What stays ours

Promote-before-replacing-the-master (#26), proposed upstream as Saremox#201. If that lands, checker.go converges entirely.

Verification

Full suite green, integration test binary compiles, go vet clean with and without the integration tag. Three files are flagged by my local gofmt (health.go, health_test.go, types.go) but are already unformatted on main and Lint passes there, so I left them rather than bundle unrelated churn.

Supersedes #28 — close that in favour of this if this lands first.

usiegj00 and others added 25 commits September 21, 2026 00:04
Brings up minikube, installs the chart from the checkout and exercises a
real RedisFailover with sentinel.enabled: false - master election,
replication, an operator-driven failover, and the master Service endpoints
afterwards. Logs are collected on failure.

Operator-managed mode is the default since v4.0.0 and the recent bugs in it
(Saremox#161, Saremox#167) were both found from a live cluster's logs rather than from
CI. Saremox#165 covers the rollout at the integration level; this covers a running
cluster.

Triggers on every pull request rather than a single base branch, so a change
to the workflow can be tested on its own pull request.

Takes about 9 minutes.
…inalizer (Saremox#174)

* Fix cluster_ok metric leak by handling RedisFailover deletion via a finalizer

kooper v2's generic controller never calls Handle() on resource deletion:
by the time its DeleteFunc fires, the object is already gone from the
informer's local indexer, and the processor that resolves a dequeued key
back to an object just no-ops when the key no longer resolves (see
newIndexerProcessor in kooper/v2/controller/processor.go). Because of
this, metrics.DeleteCluster - which correctly removes a RedisFailover's
cluster_ok Prometheus gauge series - had zero callers, so cluster_ok
leaked a stale series forever after every RedisFailover deletion.

Add a finalizer (redisfailovers.databases.spotahome.com/finalizer) so a
delete becomes an ordinary object update that Handle() does receive:
the API server holds the object (DeletionTimestamp set, finalizer still
present) until we remove it. Handle() now:
- registers the finalizer on any RedisFailover that doesn't have it yet,
  before validation, so even one that never becomes valid still gets it
- when DeletionTimestamp is set and the finalizer is present, calls
  mClient.DeleteCluster and removes just the finalizer from the list
- when DeletionTimestamp is set but the finalizer is already gone,
  no-ops (cleanup already ran on a previous reconcile)

Adds RedisFailover.PatchRedisFailoverFinalizers (service/k8s) doing a
JSON merge patch against metadata.finalizers, following the same
"always send the full desired value" pattern UpdateRedisFailoverStatus
already uses for the status subresource. Mocks for both k8s.RedisFailover
and k8s.Services are hand-patched to add the new method: the pinned
mockery v2.20.0 panics on modern Go's export data, and regenerating with
a newer mockery risked unrelated style-wide diff noise, so the new
method was added by hand matching the existing generated style exactly.

This is purely additive (new interface method, new object field usage)
so it should not conflict with downstream forks that share this code's
ancestry (e.g. buildio/redis-operator).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

* Cover PatchRedisFailoverFinalizers and Handle's finalizer error paths

Codecov flagged this PR's patch coverage at 40%: PatchRedisFailoverFinalizers
(service/k8s/redisfailover.go) had zero test coverage, and Handle's two new
error-propagation branches (finalizer-add failure, deletion-cleanup finalizer-
removal failure) were untested.

- TestRedisFailoverServicePatchRedisFailoverFinalizers (service/k8s):
  success, nil-finalizers-becomes-empty-array, and not-found-returns-error,
  mirroring the existing UpdateRedisFailoverStatus test's fake-clientset style.
- TestHandleFinalizerRegistrationErrorPropagates: a failed finalizer-add patch
  stops Handle before Validate/Ensure/CheckAndHeal.
- TestHandleDeletionFinalizerRemovalErrorPropagates: a failed finalizer-removal
  patch during deletion cleanup is still propagated, after DeleteCluster has
  already fired.

Handle and PatchRedisFailoverFinalizers are now both at 100% coverage
(verified with go tool cover -func). go build/vet/test and gofmt all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

---------

Co-authored-by: Claude <noreply@anthropic.com>
…emox#177)

* Run the two integration tests in parallel to cut CI wall-clock time

TestRedisFailover (creation_test.go, sentinel-managed) and
TestRedisFailoverOperatorManagedModeRollout (operator-managed) ran back to
back in the same go test process - 206.66s + 205.84s = ~412s combined, per
a real CI run's -v output (Saremox#174, job
35540389923/106157089205).

They don't share any state that would make that unsafe:
- separate namespaces (rf-integration-tests vs
  rf-integration-tests-operator-managed)
- separate in-process operator instances, each with its own
  leader-election lease scoped to its own namespace (confirmed in the same
  CI run's logs - no lease contention between them)
- separate Secrets, separate k8s clientset instances (client-go clientsets
  are safe for concurrent use)
- both use metrics.Dummy/log.Dummy, so no shared Prometheus registry

Adding t.Parallel() as the first statement in both lets Go's test runner
execute them concurrently instead of sequentially, which should cut
roughly half of that ~412s off the Integration test job's wall time (the
long pole of the whole CI run - every other job finishes in under 3.5
minutes). The same redis:7.2.12-alpine image backs both tests' pods, so
running them concurrently doesn't double the image-pull cost either:
kubelet dedupes concurrent pulls of the same image on a node.

This can't be verified against a real cluster in this environment (no k8s
available), so validation here is build/vet only: `go build/vet ./...`
and `go build/vet -tags integration ./test/...` all pass. The real
before/after timing comparison happens in this PR's own CI run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

* ci: fix e2e pod-readiness gate and shave wall time off the CI run

- e2e.yml: kubectl rollout status only supports RollingUpdate, but the
  Redis StatefulSet uses OnDelete, so the "pods ready" gate errored out
  on every run and silently burned all 60 retries under bash's &&-list
  errexit exemption. Switch to kubectl wait on .status.readyReplicas,
  which the StatefulSet controller populates regardless of strategy.
- ci.yaml: drop integration-test's needs: [check, unit-test] gate - the
  matrix doesn't depend on either job's output, so serializing them was
  pure wall time.
- ci.yaml: pre-pull the redis image in the background right after
  checkout, only waiting on it just before the Go tests start, so the
  pull is hidden behind conntrack/minikube/CRD setup instead of paying
  for it inline while the tests poll for pods to become ready.
- operator_managed_rollout_test.go: reduce ommRedisSize to 2, matching
  creation_test.go's already-lighter footprint and cutting one pod's
  worth of startup/rollout time from the heaviest subtest.
- creation_test.go: fix a leaked operator goroutine in TestRedisFailover
  - Run() was called with context.Background() and the stopC channel
  used for "cleanup" was never wired to it, so closing stopC did
  nothing and the controller (plus its informers/leader-election) kept
  running for the rest of the test binary's life. Use a cancelable
  context instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

---------

Co-authored-by: Claude <noreply@anthropic.com>
…aremox#178)

The two integration tests construct their in-process operator with a
zero-value redisfailover.Config{}, so Config.SyncInterval is 0. That
flows into kooper's ResyncInterval, which falls back to *kooper's own*
default of 3 minutes when unset - unlike production, which always sets
one explicitly via -sync-interval (default 30, cmd/utils/flags.go).

That 3-minute fallback is reachable in practice: CheckAndHeal's deferred
status patch is sometimes byte-identical to what's already stored (a
steady-state reconcile with no health-state transition), and the
apiserver/etcd treat a truly no-op write as a no-op - no new resourceVersion,
no watch event. Since the operator only watches the RedisFailover CR itself
(not Pods/StatefulSets), that patch was the only thing re-triggering the
next Handle() call. TestRedisFailoverOperatorManagedModeRollout needs two
separate Handle() calls to replace both pods (slave, then master), so a
missed self-trigger between them stalls for the full resync interval -
matching the ~300-313s outliers seen in CI (~180s stall + ~130s real work),
always in that one subtest and nowhere else, since it's the only test that
depends on a second, chained reconcile.

Giving both operators a short explicit SyncInterval keeps that same stall
- if it happens at all - under a couple of seconds instead of 3 minutes,
which should make the rollout subtest's timing consistent across runs.

This masks the symptom in tests, matching what production already does
via its own default; it doesn't fix the underlying gap (the controller's
self-trigger depends on incidental status content changes rather than
being guaranteed). That's tracked separately.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…in permissions (Saremox#184)

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
…ce (Saremox#182)

A Service only routes new connections, so clients connected through the
master Service stay on a pod after it is demoted to a replica and keep
getting READONLY errors.

When a pod's role label changes from master to slave, the operator now
waits (in the background, up to 10s) for the pod's IP to leave the master
Service's EndpointSlices, then 2s for kube-proxy, and closes the pod's
normal and pub/sub connections (CLIENT KILL TYPE normal|pubsub) so clients
reconnect to the new master. Replication links are left alone. This covers
Sentinel-driven demotions too, which the operator only relabels.

- On by default; --disconnect-clients-on-demotion=false turns it off.
- Needs get/list on discovery.k8s.io endpointslices (chart, kustomize and
  examples updated). Without it, or on timeout, it disconnects anyway.
- e2e: a client writing through the master Service must end up on the new
  master after master and replica are swapped, in both directions.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
…x#187)

A plain `kind create cluster` fails in the sandbox, and a working cluster
can't pull images. The skill's scripts:

- patch the kind config for the sandbox's cgroup v1 kernel
  (failCgroupV1: false, containerd restrict_oom_score_adj);
- mirror docker.io and quay.io through a local registry, since the node
  can't reach any registry and the operator defaults to pullPolicy Always;
- route the pod subnet to the host for the integration tests;
- build the operator image without docker/app/Dockerfile, whose apk step
  has no network here.

.gitignore now lets .claude/skills be committed.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
build-image.sh let docker build pull alpine straight from Docker Hub,
which fails when Docker Hub rate limits (429). registry.sh already fell
back to Docker Hub's copy and mirror.gcr.io for the images it pushes.

Move that fallback into a `registry.sh pull` subcommand and use it for
alpine, the registry:2 image and the kind node image.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
…#186)

With client-go 1.35+ the informer streams its initial list and waits for
the initial-events-end bookmark. The RedisFailover watch filter dropped
it, since it has no namespace, so with a restrictive
--supported-namespaces-regex the operator never finished syncing and
never reconciled. It also dropped watch errors such as 410 Gone.

Let bookmark and error events through, and run the integration tests
with a regex that only matches their namespace.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
A rollout replaces one pod per reconcile, and each next step waited for
the resync because nothing queued the RedisFailover again.

Replace kooper's controller with one that has a single queue keyed by
RedisFailover, fed by RedisFailover events and by events on the pods of
handled RedisFailovers, so pod changes drive the next step. The pod
cache keeps metadata only, and a pod watch that can't sync doesn't block
reconciling. One queued event is counted per update unless a pod's owner
changed, as with kooper.

Because the next reconcile now follows a delete immediately:
- replace another pod only once the last replacement has settled (all
  pods present, none terminating, updated pods ready), and log at Info
  why a rollout waits;
- in operator-managed mode, wait for a terminating master that is still
  ready to exit before electing a new one. A stopping replica doesn't
  block the election;
- don't dial terminating pods in CheckAllSlavesFromMaster; their IP is
  often gone and every rollout step would stall for the dial timeout.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
Port what kooper still provided onto client-go and prometheus:
- the controller metrics, with the same kooper_controller_* names,
  labels, help texts and buckets;
- leader election, with the same lease, timings and identity, and no
  Events written.

Leader election and shutdown:
- On SIGTERM the controller stops taking queued reconciles and main waits
  up to 5s, kooper's grace period, for running ones. The lease is then
  released so another replica takes over at once; if they don't finish
  in time, the process exits without releasing it, as before. Stopping
  before the cache has synced is a clean stop.
- When the lease is lost, Run returns errLeadershipLost at once, without
  waiting for a running reconcile, and the process exits. Kooper exited
  5s after the loss.
- A lost lease is never released, a lease another replica holds is left
  alone, and a lease acquired while shutting down is released.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
…ng port (Saremox#128)

SentinelCheckQuorum: real Sentinel's CKQUORUM NOQUORUM outcome comes
back as a RESP error, not a successful string reply - so the intended
NOQUORUM handling (parsing "(error)"/"NOQUORUM" out of a successful
result) could never execute; callers only ever saw the raw driver
error. NOQUORUM is now classified from err.Error() directly, restoring
the intended "quorum Not available" message and NOQUORUM metrics tag.

MakeSlaveOfWithPort(ip, masterIP, masterPort, password) connected to
the *target* redis instance at (ip, masterPort) - using the master's
port to reach the target, with no parameter for the target's own port.
This only worked because every caller except SetExternalMasterOnAll
happens to pass the same port for target and master (both come from
this RedisFailover's own spec.redis.port). SetExternalMasterOnAll's
externally-supplied bootstrap master port can genuinely differ,
in which case the client silently connected to the wrong instance
(or, when target and master share an IP, issued SLAVEOF to the master
itself) instead of failing loudly. MakeSlaveOfWithPort now takes an
explicit port for the target, separate from masterPort; all call
sites, the mock, and MakeSlaveOf updated accordingly.

Both found and confirmed against real Redis/Sentinel 7.0.15 while
writing coverage tests for this file.

Co-authored-by: Claude <noreply@anthropic.com>
Saremox#144)

* feat(exporter): make the exporter metrics port configurable (#30)

The sentinel exporter port was hardcoded to 9355 (and the redis exporter
to 9121), so the listen port, container port and metrics service port
could not be changed. Add an optional Exporter.port field that overrides
the listen/container/service port for both the redis and sentinel
exporters, defaulting to the previous values when left at 0.

Regenerated CRD manifests for the new field.

Refs upstream spotahome#531.

Co-authored-by: Binh Nguyen <binh.nguyen@encapital.io>
(cherry picked from commit 00c37f2)

* Make the redis exporter listen on a custom port and bound exporter ports

A custom spec.redis.exporter.port only changed the declared container and
Service port; the exporter itself kept listening on 9121. Set
REDIS_EXPORTER_WEB_LISTEN_ADDRESS when a custom port is set, like the
sentinel exporter does. Reject exporter ports outside 0-65535 in the CRD
schema and in Validate().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

---------

Co-authored-by: Binh Nguyen <37066217+binhnguyenduc@users.noreply.github.com>
Co-authored-by: Binh Nguyen <binh.nguyen@encapital.io>
Co-authored-by: Claude <noreply@anthropic.com>
…ion (dnse Saremox#35) (Saremox#147)

* feat(heal): optionally protect the redis master from autoscaler eviction (Saremox#35)

Add an opt-in redis.preventMasterEviction flag. When enabled, the
operator annotates the current master pod with
cluster-autoscaler.kubernetes.io/safe-to-evict=false and marks slaves
true, so the cluster autoscaler will not drain the node running the
master and force an avoidable failover. The annotation follows the
master as the role moves, in both the checker and healer relabel paths.

Adds a Pod.UpdatePodAnnotations service method (JSON merge patch) and the
matching mock. Regenerated the CRD for the new field.

Refs upstream spotahome#689.

Co-authored-by: Binh Nguyen <binh.nguyen@encapital.io>
(cherry picked from commit 85bc2f3)

* Mark a demoted master evictable only after it is relabelled

setSlaveLabel set safe-to-evict=true before changing the role label, so a
failed relabel left a pod still labelled master, and still behind the
master Service, that the autoscaler could evict. Relabel first and annotate
afterwards. Also test UpdatePodAnnotations against the fake clientset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

* Test that a master is not relabelled when pinning it fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

---------

Co-authored-by: Binh Nguyen <37066217+binhnguyenduc@users.noreply.github.com>
Co-authored-by: Binh Nguyen <binh.nguyen@encapital.io>
Co-authored-by: Claude <noreply@anthropic.com>
… (dnse Saremox#33) (Saremox#146)

* feat(sentinel): configurable deployment strategy and PDB minAvailable (Saremox#33)

Two rollout/availability knobs that were previously hardcoded:

- sentinel.strategy overrides the sentinel Deployment update strategy
  (e.g. rollingUpdate maxSurge/maxUnavailable), avoiding a deadlocked
  rolling update when required anti-affinity plus replicas==nodes leaves
  no room to surge.
- redis/sentinel.podDisruptionBudgetMinAvailable overrides the PDB
  minAvailable (previously fixed at 2, or 1 when replicas<=2).

Regenerated CRD + deepcopy for the new fields.

Refs upstream spotahome#662, spotahome#516, spotahome#598.

Co-authored-by: Binh Nguyen <binh.nguyen@encapital.io>
(cherry picked from commit a53177d)

* Compare a configured sentinel strategy instead of discarding it

deploymentUpToDate blanked the stored strategy before comparing, so a
configured sentinel.strategy never matched and every reconcile updated
the Deployment, while removing one was never applied. Compare both
strategies with the API server's defaults filled in instead.

Also fix the sentinel PDB default docs, which derive from the sentinel
replicas rather than the redis ones, and cover the sentinel override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

---------

Co-authored-by: Binh Nguyen <37066217+binhnguyenduc@users.noreply.github.com>
Co-authored-by: Binh Nguyen <binh.nguyen@encapital.io>
Co-authored-by: Claude <noreply@anthropic.com>
…s (dnse Saremox#34) (Saremox#145)

* feat(env): allow custom env vars on redis and sentinel main containers (Saremox#34)

Only the exporter sidecar accepted custom environment variables. Add an
optional env field to the redis and sentinel specs, injected into their
main containers. On the redis container the user's env is placed before
the operator-injected vars (REDIS_ADDR/PORT/USER/PASSWORD) so those keep
precedence under Kubernetes last-wins semantics.

Regenerated CRD + deepcopy for the new fields.

Refs upstream spotahome#290.

Co-authored-by: Binh Nguyen <binh.nguyen@encapital.io>
(cherry picked from commit 4d51d46)

* Test that DeepCopy clones the redis and sentinel env vars

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

---------

Co-authored-by: Binh Nguyen <37066217+binhnguyenduc@users.noreply.github.com>
Co-authored-by: Binh Nguyen <binh.nguyen@encapital.io>
Co-authored-by: Claude <noreply@anthropic.com>
Removed ignore rule for Kubernetes dependencies in Dependabot configuration.
…#190)

Add opt-in redis.maxMemory {percent, policy}: maxmemory is
min(limit * percent / 100, limit - 32Mi) of the redis container's memory
limit, percent defaults to 75 and policy to noeviction. Applied at runtime
with CONFIG SET, so enabling it rolls no pods.

- Upgrade safety: keys set in customConfig take precedence, so instances
  are unaffected until they opt in. Without a memory limit >= 64Mi,
  maxmemory is not managed and the reason is set in the status message
  instead of stopping the reconcile. replica-ignore-maxmemory no is
  rejected, and running pods are set to yes as removing it from
  customConfig does not reset it.
- The target follows the smallest pod's limit, capped by the configured
  one (applied limit from the pod status, else the pod spec): replicas
  hold the whole dataset and any of them can be promoted. With OnDelete a
  raised limit applies once every pod runs with it, a lowered one before
  smaller pods are rolled out. While a pod runs below 64Mi, maxmemory is
  left alone.
- maxmemory is only lowered below the memory in use under allkeys-*
  (volatile-* could evict every key with a TTL and still not fit), where
  it stays while Redis evicts down to it. Each lowering is verified on the
  master and rolled back if it stopped being master or, without eviction,
  the usage does not fit. A master without maxmemory, e.g. after a restart, whose
  data does not fit the target is capped at the smallest running
  container less 32Mi, or at the memory in use.
- While a stale pod is about to be replaced with a smaller limit and the
  master's maxmemory or memory in use does not fit it yet, the rollout is held
  and the reason is set in the status message. Pods count as stale until
  the StatefulSet controller has observed the latest spec.
- When raising, maxmemory is set before the policy; when lowering, after.
  Bootstrap mode applies managed maxmemory before replacing pods.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
When a stale redis pod's revision differs from the update revision only in
container cpu and memory, resize it through the pods/resize subresource
instead of recreating it, then move its revision label to the update
revision once the kubelet reports the resources as applied (safe with
OnDelete). No restart, no data reload, no master failover; still one pod
at a time, replicas first. On by default from Kubernetes 1.33;
spec.redis.inPlaceResize: Disabled opts out.

- The resize starts from the pod's own resources and sets the update
  revision's cpu and memory values, so admission defaults (e.g. a
  LimitRange) are kept and a resize superseded by a newer revision is
  corrected. Memory decreases are detected against the applied limits.
- The pod is recreated as before when the update changes anything else
  (resource claims included), adds or removes requests or limits, lowers
  a memory limit before Kubernetes 1.35, or when the API server rejects
  the resize, e.g. for a QoS class change or without the new RBAC. It is
  also recreated when the kubelet does not report the container's
  resources, as one without in-place resize support would leave the pod
  spec, which maxmemory derives from, ahead of the container.
- Kubelet state: infeasible, deferred for more than 5 minutes, failing for
  more than 5 minutes (e.g. a memory decrease below the usage it sees,
  which includes the page cache), or not applied within 5 minutes without
  any condition falls back to recreating the pod. Timeouts count from the
  latest resize request, kept in a pod annotation. Conditions from an
  earlier pod generation are ignored. An error detecting the cluster's
  support is retried rather than recreating the pod.

The operator needs patch on pods/resize and get on controllerrevisions,
granted in the chart, the kustomize and the example manifests.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
…aremox#193)

* fix(failover): don't promote while a ready Redis pod doesn't answer

GetNumberMasters skipped a pod it could not query, so a master that was
briefly unreachable counted as absent. Zero masters drives promotion, so
the operator replaced a running master: in operator-managed mode a 20s
CLIENT PAUSE on the master was enough.

A ready pod that doesn't answer now makes the count unknown when no
master answered, and the reconcile stops. A pod Kubernetes has marked
not ready is still skipped, so failover after a node loss or a crashed
Redis goes ahead once the pod leaves the master Service.

Ported from powerhome#106.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

* fix(redis): shorten the Redis client timeouts

Every client used the go-redis defaults: a 3s read timeout, 5s dial
timeout and 3 retries. A pod that accepts connections but never answers,
like one on a frozen node, held each call for 12s, and a failover after a
node loss waited on several of them. Use 2s timeouts and one retry, so
such a call gives up after 4s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

---------

Co-authored-by: Claude <noreply@anthropic.com>
The CRD YAML is 1.09MB, over the 1MiB a ConfigMap holds, so with
crds.upgradeHook.enabled every install and upgrade failed. Store it as
compact JSON (587KB, descriptions kept) and apply it server-side, as it is
also too large for a client-side apply's last-applied annotation.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
…ox#196)

fsGroup is a PodSecurityContext field. In the container securityContext
the API server dropped it with an unknown-field warning, and strict
validation rejects the Deployment. Move it to a new podSecurityContext
value rendered on the pod spec.


Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

Co-authored-by: Claude <noreply@anthropic.com>
* fix(auth): apply a changed Redis password in place

Changing the password in the auth secret, or adding or removing
auth.secretPath, left the RedisFailover wedged: Redis reads requirepass
only at startup, so every pod refused the operator's new password,
CheckAndHeal failed on its first check, and the rolling update that
would have restarted the pods onto the secret was never reached. Each
pass also tried to promote a master and failed only on the password.

Restarting the pods one at a time can't apply it either: a restarted
replica can't authenticate to a master still on the old password.

The operator now remembers, per RedisFailover, the password every
running Redis last accepted. When the secret differs, it logs in with
that one and runs CONFIG SET masterauth and requirepass on each Redis,
then gives the Sentinels the new auth-pass. Replication and existing
connections stay up, and the rolling update then restarts the pods onto
the secret. If the operator restarted in between it has no old password;
the RedisFailover reports it and the pods have to be deleted by hand.

The readiness probe authenticates with the password its container
started with, so after the change it failed on every pod not yet
restarted, the master included. A refused password now counts as ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

* fix(auth): keep the password out of errors and converge Sentinel

The Sentinel auth-pass went through SetCustomSentinelConfig as the
config string "auth-pass <password>", and that function's parse error
quotes the string, so the password could reach the reconcile error log
(CodeQL go/clear-text-logging). Set it with a dedicated
SetSentinelAuthPass instead.

Give the Sentinels the password whenever the Redis pods all accept it,
not only when the previous password is known. Otherwise an operator
restart between changing the Redis pods and the Sentinels left the
Sentinels on the old password for good. The step only runs when the
password isn't already cached, so this is one SENTINEL SET per operator
start.

Count a Redis pod that isn't running yet as incomplete, so the password
isn't cached while a pod may still start on the old pod template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

* fix(auth): use the shared client timeouts for the password calls

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

* test(auth): cover the password change's error paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

* fix(auth): don't let one pod or Sentinel hold up a password change

- Give the Sentinels the new password as soon as every running Redis
  accepts it, instead of waiting for pods yet to start, and remember
  it separately so a Pending pod doesn't rewrite their config each sync.
- Skip an unreachable Sentinel and retry it, rather than failing the
  whole reconcile.
- With no password remembered after an operator restart, remember the
  one every running Redis accepts. A Redis without a password is changed
  without needing the old one.
- Point to putting the previous password back, not deleting the pods,
  when the old password is unknown, and document that the exporter and
  pre-stop save keep the old password until their pod restarts.
- Read the secret once per reconcile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4mmyo3sqLwf23m5FE2nBE

---------

Co-authored-by: Claude <noreply@anthropic.com>
…09-29

# Conflicts:
#	.github/workflows/e2e.yml
#	README.md
#	charts/redisoperator/crds/databases.spotahome.com_redisfailovers.yaml
#	cmd/utils/flags.go
#	go.mod
#	manifests/databases.spotahome.com_redisfailovers.yaml
#	manifests/kustomize/base/databases.spotahome.com_redisfailovers.yaml
#	operator/redisfailover/checker_test.go
#	operator/redisfailover/config.go
#	operator/redisfailover/service/generator_test.go
@codecov-commenter

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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.

3 participants