Skip to content

feat: apply ACL changes live without rolling pods - #319

Merged
jdheyburn merged 8 commits into
valkey-io:mainfrom
melancholictheory:feat/live-acl
Aug 10, 2026
Merged

jdheyburn merged 8 commits into
valkey-io:mainfrom
melancholictheory:feat/live-acl

Conversation

@melancholictheory

Copy link
Copy Markdown
Contributor

Closes #212

Summary

ACL changes only took effect when a pod restarted and reloaded the aclfile. The ValkeyNode controller now applies them in place with ACL LOAD, so a password rotation or a permission change no longer needs a roll.

Features / Behaviour Changes

  • ACL changes reach the running server without a pod roll, eventually consistent with the mounted Secret.
  • New ACLApplied condition on ValkeyNode reporting whether the server's ACL matches the aclfile Secret.
  • No API changes.

Implementation

Per the direction on #212: ACL LOAD of the mounted file at the end of the node reconcile, since that file is already the full desired state and is exactly what a restart would load.

Two things shaped the rest:

ACL SAVE is out. The aclfile is mounted read-only from its Secret, so it isn't available, and it isn't needed either: the Secret the cluster controller writes is what a restarting pod loads. So the Secret stays the source of truth for restart, and ACL LOAD is purely the live path.

The mounted copy lags. kubelet refreshes it lazily, so an ACL LOAD issued right after the Secret changes can read the previous contents and silently do nothing. The operator can't read the pod's file without an exec, so the running server is the source of truth for verification instead: the controller compares the password hashes from ACL GETUSER against the aclfile Secret, and only issues a reload when they differ, then re-checks. That makes a premature reload self-correcting, it leaves the node ACLApplied=False with reason PendingPropagation rather than claiming applied, and the requeue retries once the volume catches up. A steady-state reconcile issues no writes at all, only the comparison.

ACLApplied deliberately does not gate the cluster's rolling update the way LiveConfigApplied does: a node whose ACL is still propagating keeps serving with its previous credentials, and a roll would load the new file regardless.

Rotating without an auth gap

Documented in docs/valkeycluster.md: a Valkey user can hold several passwords at once, and passwordSecret.keys already accepts multiple keys, so adding the new password alongside the old, waiting for ACLApplied=True, moving clients over, then dropping the old key keeps every credential valid throughout the window. Replacing the key outright locks out any client still holding the old password until it catches up.

Testing

  • Unit tests for the aclfile parsing (multi-password users, nopass, system users, non-user lines) and the in-sync comparison.
  • envtest coverage for applyLiveACL: no secret, missing secret, already in sync (asserts no ACL LOAD is issued), reload that converges, reload against a stale volume (asserts it reports not-synced rather than applied), and a failing reload.
  • make test and make lint pass; no generated drift.

Checklist

  • This Pull Request is related to one issue.
  • Commit message explains what changed and why
  • Tests are added or updated.
  • Documentation files are updated.
  • I have run pre-commit locally (ran make test and make lint instead)

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This change applies ACL Secret updates to running Valkey nodes without restarting pods and reports their application status. A permissions-only ACL update can currently be reported as applied while a node still serves the previous mounted ACL, so command revocations need a verifiable loaded-revision signal before the status is set to true.

Confidence Score: 3/5

A verified authorization-status failure remains: the operator can report that an ACL revocation is active before the server has loaded it.

One security-relevant blocking failure remains in the ACL application confirmation path.

Files Needing Attention: internal/controller/valkeynode_acl.go

Security Review

A command revocation may remain authorized during projected-volume propagation even after ACLApplied=True is published. The affected status check in internal/controller/valkeynode_acl.go verifies user names and password hashes, but cannot verify that the current permission rules were loaded.

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex produced proofs for the posted P1 findings, including a focused Go reproduction source for stale ACL projection, the initial focused ACL test patch, and a follow-up proof without artifacts.
  • Validation of the contract logic confirms the reproduction path and shows the password-change and permissions-only stale projection controls passing, with code paths excluding ACL from the workload revision.
  • The relevant ACL validation code paths were inspected, including exclusions and the user/hash comparison, and the controller path that maps the result to ACLApplied.
  • An initial envtest/Ginkgo attempt could not start kube-apiserver, so a direct focused Go test flow was used instead and it passed.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (1)

  1. General comment

    P1 Permissions-only stale ACL projection can set ACLApplied=True

    • Bug
      • If a Secret edit only revokes or otherwise changes commands while preserving all usernames and password hashes, an immediate ACL LOAD can read the old projected file, leave the old permission live, and still return ACLApplied=True. The status therefore claims the desired ACL is live when the revoked command may remain authorized until a later reconcile/load.
    • Cause
      • aclObservablyInSync deliberately observes only user names and password hashes; the valkeyConfigClient does not expose permissions and there is no desired ACL revision or mounted-file freshness sentinel. applyLiveACL calls the comparator after a successful ACL LOAD, even when that load silently consumed the old projected file.
    • Fix
      • Do not use the users-and-hashes-only result as a universal ACLApplied=True assertion for permission-only changes. Add an observable desired revision/sentinel that the mounted ACL and server can expose/verify, or separate the condition semantics so it only asserts password/user convergence. Continue requeuing until a reliable permission/freshness signal confirms the updated projection was consumed.

    T-Rex Ran code and verified through T-Rex

Reviews (18): Last reviewed commit: "test(e2e): scope the acl-hash migration ..." | Re-trigger Greptile

Comment thread internal/controller/valkeynode_acl.go Outdated
@melancholictheory

Copy link
Copy Markdown
Contributor Author

good catch, this was a real bug and both repros are right. fixed in 522ec5e.

skipping the reload when the password hashes matched meant exactly what you describe: a permissions-only edit, or a user removed from the aclfile, kept every desired hash intact, so the node skipped ACL LOAD and sat on the old ACL while ACLApplied claimed True. the PR advertised live permission changes and didn't deliver them.

the comparison can't just be widened, which is what led me down this path in the first place. ACL GETUSER returns Valkey's normalised rendering of the rules, while the operator only has the aclfile text, so comparing the two properly would mean reimplementing Valkey's ACL parser and keeping it in step. password hashes are the one part that compares exactly.

so the reload is unconditional now. it's idempotent and cheap, and it converges every kind of change once the mounted volume catches up, which is also what was originally proposed on #212. the hash comparison stays, but only to drive ACLApplied, and the condition is now scoped to what it can actually verify: whether the desired passwords are live on that node. that's the signal the rotation procedure waits on, and the docs no longer imply it covers permission edits.

added coverage for the case you found: passwords already matching must still issue the reload.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

@jdheyburn jdheyburn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for taking this on, just a couple of comments.

Comment thread internal/controller/valkeynode_acl.go Outdated
hashes = append(hashes, h)
}
}
slices.Sort(hashes)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Shall we dedupe hashes too? In case the user duplicated passwords? I think Valkey stores hashed passwords as a set on the backend, so we should do the same otherwise we'd be stuck in reconciliation forever.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

you're right, and it's worse than cosmetic: two Secret keys pointing at the same password put a repeated hash in the aclfile, Valkey reports it once, and the comparison could never converge, so the node would sit at ACLApplied=False and requeue every 10s forever. sorted and deduped on both sides now, with a test for the repeated-hash case. 1844eab.

Comment thread internal/controller/valkeynode_acl.go Outdated
if err := c.LoadACL(ctx); err != nil {
return false, err
}
return passwordsInSync(ctx, c, desired)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do we only check if passwords are in sync, and not other dimensions like users added/removed, permissions updated, etc.?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good push, and you're right i drew the line too conservatively. usernames compare exactly, so the user set is part of the check now (via ACL USERS): a user added to or removed from the aclfile that the server hasn't picked up reads as out of sync instead of being invisible to the condition.

permission rules are the part i still left out, and it isn't laziness: ACL GETUSER returns Valkey's normalised rendering of the rules, while the operator only holds the aclfile text, so comparing them properly means reimplementing Valkey's ACL parser and keeping it in step with the server.

worth stressing none of this gates the apply: the reload is unconditional (Greptile's catch earlier), so permission edits converge regardless. the check only scopes what ACLApplied can honestly claim, and the docs now say exactly that. 1844eab.

if err != nil {
return nil, fmt.Errorf("ACL GETUSER %s passwords: %w", username, err)
}
slices.Sort(hashes)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same here re deduplicating.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done, UserPasswordHashes normalises through the same helper (sort + compact), so both sides are in the same shape. 1844eab.

// Permissions are deliberately not compared. ACL GETUSER returns Valkey's
// normalised rendering of the rules, while the operator only holds the aclfile
// text, so comparing the two would mean reimplementing Valkey's own ACL parser
// and keeping it in step with the server. Correctness of the apply does not

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Are we able to reuse some of the below to make comparisons?

  • func buildUserAcl(user valkeyiov1alpha1.UserAclSpec, passwords []string) string {
    // Holds the ACL as we build it
    var acl strings.Builder
    // Initial acl
    fmt.Fprintf(&acl, "user %s ", user.Name)
    // Is the user enabled?
    if user.Enabled {
    acl.WriteString("on")
    } else {
    acl.WriteString("off")
    }
    // If resetpass flag is false, then add password(s)/nopass flag to the ACL
    if !user.ResetPass {
    // If enabled, append password(s), which should already be prefix-hashed
    if user.NoPassword {
    fmt.Fprintf(&acl, " nopass")
    } else {
    appendAcl(&acl, passwords, "#")
    }
    }
    // Add key restrictions
    appendAcl(&acl, user.Keys.ReadWrite, "~")
    appendAcl(&acl, user.Keys.ReadOnly, "%R~")
    appendAcl(&acl, user.Keys.WriteOnly, "%W~")
    // Add channel restrictions
    if len(user.Channels.Patterns) > 0 {
    acl.WriteString(" resetchannels")
    appendAcl(&acl, user.Channels.Patterns, "&")
    }
    // Build command ACLs
    appendAcl(&acl, user.Commands.Allow, "+")
    appendAcl(&acl, user.Commands.Deny, "-")
    // Append remaining/raw permissions
    fmt.Fprintf(&acl, " %s", user.RawAcl)
    return acl.String()
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i looked at reusing buildUserAcl for this, and it's the same wall the passwords-only check ran into. buildUserAcl renders the operator's form of the ACL, while ACL GETUSER returns Valkey's normalised form (rule order, folded categories, implied defaults like sanitize-payload, resetchannels handling). they don't compare byte-for-byte, so lining them up would still mean running our string through Valkey's own normaliser, which is the parser reimplementation i was trying to avoid.

where the reuse does help is the exact parts: the user set and the password hashes. those i already take from the aclfile the cluster controller built with buildUserAcl, and check against ACL USERS / ACL GETUSER, so the desired side is the same source of truth, just compared on the dimensions that survive normalisation. and since the reload is unconditional, the rest converges without needing a full comparison at all.

@melancholictheory

Copy link
Copy Markdown
Contributor Author

gentle nudge on this one, it's been ready since the last review round. CI is green, and the points from that pass are addressed: password hashes are deduped on both sides, and the sync check now covers user membership (add/remove), not just passwords. happy to rebase past the module rename in #316 whenever it's useful, or i'll do it if it goes dirty. no rush, just flagging it's waiting on another look.

@jdheyburn

Copy link
Copy Markdown
Collaborator

Sorry for the delay. I just tried to test it in a kind cluster locally, and I got these errors in the logs:

2026-07-28T18:40:42Z    DEBUG   events  Failed to apply live ACL: ACL LOAD: NOPERM User _operator has no permissions to run the 'acl|load' command      {"type": "Warning", "object": "nil", "action": "ApplyLiveACL", "reason": "LiveACLApplyFailed"}

It doesn't look like you've updated users.go to support the feature. Are you able to add that and perform local testing to see if it works as expected? Thank you!

@melancholictheory

Copy link
Copy Markdown
Contributor Author

good catch, and thanks for running it on a real cluster. that's exactly the gap: _operator had +config|set for the live-config path but none of the acl subcommands, so ACL LOAD hit NOPERM. the envtest suite uses a fake config client that doesn't enforce ACL permissions, so the miss stayed green in CI.

fixed by granting the three subcommands the feature actually needs on _operator in users.go:

+acl|load      // reload the aclfile
+acl|getuser   // read back a user's hashes to verify the reload landed
+acl|users     // read the user set to verify membership

getuser/users are in there because the reload is verified server-side (re-read the users and their hashes after ACL LOAD), not fired blind.

verified locally against a real valkey 9.0, not envtest:

  • a user with only those three grants runs ACL LOAD / ACL GETUSER / ACL USERS with no NOPERM
  • the same user is still denied ACL SETUSER, so it stays load-and-read only, no user management crept in
  • dropping the grants reproduces the exact NOPERM ... 'acl|load' you saw

pushed to the branch. mind giving it another spin when you get a moment?

// A permissions-only change, or a removed user, leaves every desired
// password hash untouched. The reload must not be skipped on that basis
// or those edits would never reach the server.
fake := &fakeConfigClient{aclHashes: desiredHashes}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can u update e2e test to check if acl test valkeycluster_test.go is actually applied live without rollout restart.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, this is covered by the "live ACL propagation" spec. It brings a cluster up, records each server pod's UID, adds a user through the spec, then checks the new user shows up in a live ACL LIST and can authenticate while every pod UID stays the same afterwards (with a Consistently window, so a rollout restart would fail it). Every ValkeyNode also has to report ACLApplied=True.

@melancholictheory

Copy link
Copy Markdown
Contributor Author

@sandeepkunusoth good call on the e2e test, it earned its keep. writing it surfaced a gap the unit tests couldn't see.

the live reload works, but adding a user to a running cluster still rolled every pod. the reason is pre-existing: buildPodTemplateAnnotations stamps the internal ACL secret's hash onto the pod template (from #164, back when rolling the pods was how an ACL change got applied). #319 added the live ACL LOAD but left that annotation in place, so both fired: the ACL applied live AND the pods rolled. the "without a pod roll" part was never true.

fixed in this PR:

  • drop the ACL hash from the pod template, so an ACL edit no longer rolls the pods. the server-config hash stays, since a non-live config change still needs a restart. the ACL secret read in ensureStatefulSet/ensureDeployment only existed to feed that hash, so it goes too.
  • to keep the live path prompt without the roll as its trigger, the node controller now watches the internal ACL secret and reconciles the nodes that mount it, which runs applyLiveACL.

the e2e test is the one you asked for: it adds a user to a running cluster and asserts the new user is usable (present in ACL LIST, authenticates against the server) with ACLApplied=True on every node, while the server pods keep their UIDs. verified locally on kind: without the fix it fails with all six pods recreated ~35s after the ACL change, and with the fix it passes.

Comment thread internal/controller/valkeynode_acl.go
@melancholictheory

Copy link
Copy Markdown
Contributor Author

following up on my last comment: my first cut dropped the ACL-hash roll entirely, and that was too aggressive. running the full e2e suite, not just the new spec, caught it.

the roll was also the only recovery path for a change that locks the operator out. delete the system-passwords secret and the operator regenerates it with a fresh password; the running server still has the old one, so the operator can neither CONFIG SET nor ACL LOAD, and with no roll the pod never reloads the new aclfile from disk. the cluster just sat in Reconciling, looping on WRONGPASS.

so the fix is narrower than "drop the roll": apply ACL changes live, and roll a node only when the live apply fails with WRONGPASS. on that signal the operator stamps the current ACL hash onto the pod template, the pod restarts and re-reads the aclfile, and the node recovers. the hash is preserved across reconciles so a recovered node doesn't roll again, and it's never stamped on the happy path, so an ordinary user add or permission change still applies live without touching the pods.

e2e covers both paths now: adding a user to a running cluster is live with no roll (the pods keep their UIDs), and deleting the password secret recovers through a roll (the cluster returns to Ready and the locked-out pods are recreated). both green locally on kind.

@jdheyburn

Copy link
Copy Markdown
Collaborator

Thanks, I ran another test locally with the latest commit and deployed a cluster with below:

kubectl apply -f config/samples/v1alpha1_valkeycluster.yaml

I came across a bug where there the unmanaged default ACL created by the server provides a mismatch in comparison of what new users need to be applied - so there is an infinite loop in trying to applying the ACL.

2026-07-30T09:19:34Z    DEBUG   desired ACL passwords not live yet, waiting for the aclfile volume to propagate {"controller": "valkeynode", "controllerGroup": "valkey.io", "controllerKind": "ValkeyNode", "ValkeyNode": {"name":"cluster-sample-2-0","namespace":"valkey-operator-system"}, "namespace": "valkey-operator-system", "name": "cluster-sample-2-0", "reconcileID": "4e401416-8812-4496-8c5b-1328f2a63848"}
2026-07-30T09:19:34Z    DEBUG   reconciling ValkeyNode  {"controller": "valkeynode", "controllerGroup": "valkey.io", "controllerKind": "ValkeyNode", "ValkeyNode": {"name":"cluster-sample-2-1","namespace":"valkey-operator-system"}, "namespace": "valkey-operator-system", "name": "cluster-sample-2-1", "reconcileID": "a08bf3e1-a7d1-4c38-8c13-7e6137af05b6"}
2026-07-30T09:19:34Z    DEBUG   reconciled StatefulSet  {"controller": "valkeynode", "controllerGroup": "valkey.io", "controllerKind": "ValkeyNode", "ValkeyNode": {"name":"cluster-sample-2-1","namespace":"valkey-operator-system"}, "namespace": "valkey-operator-system", "name": "cluster-sample-2-1", "reconcileID": "a08bf3e1-a7d1-4c38-8c13-7e6137af05b6", "result": "updated", "name": "valkey-cluster-sample-2-1"}
2026-07-30T09:19:34Z    DEBUG   desired ACL passwords not live yet, waiting for the aclfile volume to propagate {"controller": "valkeynode", "controllerGroup": "valkey.io", "controllerKind": "ValkeyNode", "ValkeyNode": {"name":"cluster-sample-2-1","namespace":"valkey-operator-system"}, "namespace": "valkey-operator-system", "name": "cluster-sample-2-1", "reconcileID": "a08bf3e1-a7d1-4c38-8c13-7e6137af05b6"}

Are you able to add a test for this case, before adding the fix?

Not for this PR, but I wonder if its worth making the default ACL always managed (that is, it exists in spec.users as enabled).

@melancholictheory

Copy link
Copy Markdown
Contributor Author

good find, and thanks for the test-first nudge. reproduced it, added the test, then the fix.

the mismatch is the unmanaged default. a cluster with no custom users has an aclfile of system users only, but the server always has its own default, and ACL LOAD can't remove it. aclObservablyInSync compared the two user sets for exact equality, so default sat on the server side forever, the node never read as synced, and it looped on "not live yet". the sync check now ignores default when the aclfile doesn't manage it (unit cases for both: unmanaged default ignored, managed default still compared), plus an e2e assertion that a plain cluster reaches ACLApplied=True.

while i was in there i took greptile's two summary points, since they land on the same recovery path:

  • the WRONGPASS recovery now also fires on NOPERM and NOAUTH. that covers the upgrade case it flagged: existing pods run the old _operator ACL, so ACL LOAD returns NOPERM (or NOAUTH if _operator predates this), and the roll is what reloads the aclfile with the new grants. the hash stamp is idempotent, so a node that still can't be reached rolls once instead of looping.
  • currentACLHash reads through the APIReader now, like applyLiveACL, so the recovery roll can't miss on a stale cached hash.

on always-managing default: agree it's worth doing, and it would drop the special-case here, but it's a spec and behaviour change, so happy to keep it out of this PR and pick it up as the follow-up you mentioned.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The controller applies ACL changes live with ACL LOAD, reports ACLApplied, watches referenced Secrets, and excludes ACL data from workload roll revisions. Tests cover synchronization, reconciliation, failover behavior, documentation, and live cluster propagation.

Changes

Live ACL synchronization

Layer / File(s) Summary
ACL contracts and synchronization logic
api/v1alpha1/valkeynode_types.go, internal/controller/users.go, internal/controller/valkeynode_acl.go, internal/controller/valkeynode_controller.go, internal/controller/valkeynode_acl_test.go
Adds the ACLApplied condition, ACL command permissions, ACL parsing, hash normalization, live comparison, and synchronization tests.
ACL reconciliation and Secret watching
internal/controller/valkeynode_controller.go, internal/controller/valkeynode_controller_test.go
Reconciliation reloads mounted ACL files, updates status, waits for volume propagation, handles errors, and watches referenced Secrets.
ACL-independent workload revisions
internal/controller/workload_roll.go, internal/controller/valkeynode_controller.go, internal/controller/valkeycluster_controller.go, internal/controller/failover.go, internal/controller/*_test.go
Removes ACL Secrets from workload revision computation and related reconciliation and failover signatures.
End-to-end ACL propagation and documentation
test/e2e/valkeycluster_test.go, docs/status-conditions.md, docs/valkeycluster.md
Verifies live user propagation, authentication, ACLApplied=True, unchanged server pod identities, and documents the new behavior.

Sequence Diagram(s)

sequenceDiagram
  participant ACLSecret
  participant Controller
  participant ValkeyClient
  participant ValkeyServer
  ACLSecret->>Controller: Secret update event
  Controller->>ValkeyClient: Load mounted ACL file
  ValkeyClient->>ValkeyServer: ACL LOAD
  ValkeyClient->>ValkeyServer: Query users and password hashes
  ValkeyServer-->>Controller: ACL state
  Controller-->>Controller: Set ACLApplied or requeue
Loading

Possibly related PRs

Suggested reviewers: bjosv

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: applying ACL changes live without rolling pods.
Description check ✅ Passed The description covers the change, behavior, implementation, testing, checklist, and linked issue, but omits the optional Limitations section.
Linked Issues check ✅ Passed The implementation satisfies issue #212 by applying ACL changes live through ACL LOAD without requiring pod restarts.
Out of Scope Changes check ✅ Passed The supporting controller, permission, status, documentation, revision, and test changes are directly related to live ACL application.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@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: 4

🧹 Nitpick comments (6)
internal/controller/valkeynode_controller.go (3)

862-864: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Log the List failure instead of dropping the event silently.

If List fails, the function returns no requests and the ACL Secret change produces no reconcile. The node then waits for the 60s periodic requeue, and nothing records why. Log the error so the delay is diagnosable.

🔧 Proposed fix
 	var nodes valkeyiov1alpha1.ValkeyNodeList
 	if err := r.List(ctx, &nodes, client.InNamespace(secret.GetNamespace())); err != nil {
+		logf.FromContext(ctx).Error(err, "failed to list ValkeyNodes for ACL Secret event",
+			"secret", secret.GetName(), "namespace", secret.GetNamespace())
 		return nil
 	}
🤖 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 `@internal/controller/valkeynode_controller.go` around lines 862 - 864, Update
the List error branch in the controller function containing client.List to log
the encountered error before returning nil, using the controller’s existing
logger and preserving the current no-request return behavior.

263-282: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared condition-patch helper.

setACLCondition and setLiveConfigCondition (Lines 284-303) are identical except for the condition type and the error strings. clearLiveConfigCondition (Lines 309-322) repeats the same Get/DeepCopy/Patch shape. A single helper parameterised by condition type removes two copies and keeps the patch semantics in one place.

♻️ Proposed refactor
func (r *ValkeyNodeReconciler) patchCondition(
	ctx context.Context,
	node *valkeyiov1alpha1.ValkeyNode,
	condType string,
	status metav1.ConditionStatus,
	reason, message string,
) error {
	current := &valkeyiov1alpha1.ValkeyNode{}
	if err := r.Get(ctx, client.ObjectKeyFromObject(node), current); err != nil {
		return fmt.Errorf("get ValkeyNode: %w", err)
	}
	patchBase := current.DeepCopy()
	if !meta.SetStatusCondition(&current.Status.Conditions, metav1.Condition{
		Type:               condType,
		Status:             status,
		Reason:             reason,
		Message:            message,
		ObservedGeneration: current.Generation,
	}) {
		return nil
	}
	if err := r.Status().Patch(ctx, current, client.MergeFrom(patchBase)); err != nil {
		return fmt.Errorf("patch %s condition: %w", condType, err)
	}
	return nil
}

Then setACLCondition becomes a one-line call with valkeyiov1alpha1.ValkeyNodeConditionACLApplied.

🤖 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 `@internal/controller/valkeynode_controller.go` around lines 263 - 282, Extract
the shared Get, DeepCopy, SetStatusCondition, and status Patch logic from
setACLCondition, setLiveConfigCondition, and clearLiveConfigCondition into a
parameterized patchCondition helper accepting the condition type, status,
reason, and message. Update each existing method to delegate to patchCondition
with its appropriate condition type while preserving current error context and
patch semantics.

460-469: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicated roll-hash preservation logic in both workload paths. ensureStatefulSet and ensureDeployment contain the same five-line block that reads the existing hashAnnotationKey and falls back to it when aclRollHash is empty. The shared root cause is one preservation rule implemented twice, so a future change to the recovery-stamp semantics must be applied in two places.

  • internal/controller/valkeynode_controller.go#L460-L469: replace the inline fallback with a call to a shared helper, for example resolveACLRollHash(sts.Spec.Template.Annotations, aclRollHash).
  • internal/controller/valkeynode_controller.go#L492-L501: call the same helper with dep.Spec.Template.Annotations.

Keep the helper call before the Spec assignment in both functions. The current ordering is load-bearing, because assigning desired.Spec discards the existing annotations.

♻️ Proposed helper
// resolveACLRollHash keeps any ACL-roll hash a prior recovery stamped, unless
// the caller is forcing a recovery roll with a fresh hash.
func resolveACLRollHash(existing map[string]string, aclRollHash string) string {
	if aclRollHash != "" {
		return aclRollHash
	}
	return existing[hashAnnotationKey]
}

Then in each workload function:

-		rollHash := aclRollHash
-		if rollHash == "" {
-			rollHash = sts.Spec.Template.Annotations[hashAnnotationKey]
-		}
+		rollHash := resolveACLRollHash(sts.Spec.Template.Annotations, aclRollHash)
 		sts.Labels = desired.Labels
 		sts.Spec = desired.Spec
🤖 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 `@internal/controller/valkeynode_controller.go` around lines 460 - 469, The
roll-hash preservation logic is duplicated across both workload paths. In
internal/controller/valkeynode_controller.go:460-469, add and use a shared
resolveACLRollHash helper with sts.Spec.Template.Annotations before assigning
desired.Spec; in internal/controller/valkeynode_controller.go:492-501, replace
the duplicate fallback with the same helper using dep.Spec.Template.Annotations
before its Spec assignment. The helper must prefer a non-empty aclRollHash and
otherwise return the existing hashAnnotationKey value.
docs/valkeycluster.md (1)

316-321: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a convergence wait after the old key is removed.

The procedure ends at step 4. The removal of the old key is also propagated lazily, so the old password stays valid on some nodes for a period after step 4. State that the rotation completes only after every ValkeyNode reports ACLApplied=True again.

📝 Proposed documentation change
 1. Add the new password as a second key in `passwordSecret.keys`, keeping the old one.
 2. Wait for every ValkeyNode to report `ACLApplied=True`.
 3. Move your clients to the new password.
 4. Remove the old key.
+5. Wait for every ValkeyNode to report `ACLApplied=True` again. Until then, some nodes still accept the old password.
🤖 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 `@docs/valkeycluster.md` around lines 316 - 321, Update the password rotation
procedure in the ValkeyCluster documentation to add a final convergence wait
after removing the old key. State that rotation is complete only when every
ValkeyNode reports ACLApplied=True again, reflecting propagation of the key
removal.
internal/controller/valkeynode_acl.go (1)

107-157: 🚀 Performance & Scalability | 🔵 Trivial

Consider bounding the steady-state reload rate.

applyLiveACL runs on every reconcile, so each node issues ACL LOAD plus one ACL USERS and one ACL GETUSER per user every 60 seconds, indefinitely, even when nothing changed. The unconditional reload is deliberate and correct for convergence, so this is not a defect.

Two operational suggestions:

  • Add a metric or counter for reloads and for ACLApplied transitions. A silent reload loop is hard to observe today, and the PendingPropagation path requeues every 10 seconds.
  • If the steady-state cost becomes visible on large ACLs, gate the reload on a change signal (Secret resource version observed in status) while keeping an unconditional reload on a longer interval as a safety net.
🤖 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 `@internal/controller/valkeynode_acl.go` around lines 107 - 157, Add
observability around applyLiveACL: record a metric or counter for each ACL LOAD
and for ACLApplied state transitions, including the PendingPropagation requeue
path as appropriate. Preserve the current unconditional reload and convergence
behavior; do not gate reloads or change reconciliation semantics.
internal/controller/valkeynode_controller_test.go (1)

1269-1353: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the ACL condition transitions in Reconcile.

These specs call applyLiveACL directly, so the reconcile-level behavior added at valkeynode_controller.go Lines 230-256 stays untested:

  • ACLApplied=False with reason PendingPropagation and a 10 second RequeueAfter when the mounted file is stale.
  • ACLApplied=True with reason Applied and the 60 second RequeueAfter.
  • ACLApplied=False with reason ApplyFailed and the LiveACLApplyFailed warning event.

The existing Reconcile specs cannot reach that code, because they return early at the !node.Status.Ready check on Line 198.

Two smaller points:

  • BeforeEach creates the live-acl-users Secret but no AfterEach deletes it. The Secret outlives the block in the shared default namespace.
  • No spec covers a Secret that exists without the aclFilename key. That path returns true at valkeynode_acl.go Line 144.
🤖 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 `@internal/controller/valkeynode_controller_test.go` around lines 1269 - 1353,
Extend the applyLiveACL specs with Reconcile-level coverage by providing a ready
node and asserting ACLApplied PendingPropagation with a 10-second requeue for
stale files, Applied with a 60-second requeue after convergence, and ApplyFailed
plus the LiveACLApplyFailed warning event on load failure. Add AfterEach cleanup
for the live-acl-users Secret. Also add coverage for an existing Secret missing
aclFilename and verify applyLiveACL treats it as successfully handled.
🤖 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 `@internal/controller/valkeynode_controller.go`:
- Around line 401-428: Update recoverFromAuthLockout and the workload template
stamping used by ensureWorkload/ensureStatefulSet so recovery rolls are keyed by
a distinct lockout event rather than only the ACL Secret hash. Persist the hash
together with a truncated observed lockout timestamp or recovery counter,
reusing the same stamp for repeated reconciles within the chosen startup window
but changing it for a later lockout even when the ACL hash repeats.

In `@test/e2e/valkeycluster_test.go`:
- Around line 115-138: Move the ACL convergence assertion block containing
verifyACLApplied to immediately after the cluster-ready assertion using
verifyCrStatus, so pod startup and cluster formation are complete before
checking ACLApplied. Keep the existing validation logic and use a generous
timeout appropriate for ACL convergence after readiness.
- Around line 377-392: Wrap the pod identity comparison in the recovery
validation around podIdentities(Default) with Eventually so it waits until the
identities differ from beforePods, instead of checking once immediately after
the initial Ready state. Also validate that the cluster reaches
ClusterStateReady after the roll completes, while preserving the existing error
handling and timeout conventions.
- Around line 1736-1745: Increase the Eventually timeout from 3 minutes to 5
minutes for all three propagation-dependent assertions in the surrounding test,
including the ACL list check, credential authentication check, and the
ACLApplied=True block. Keep the existing 5-second polling interval and assertion
behavior unchanged.

---

Nitpick comments:
In `@docs/valkeycluster.md`:
- Around line 316-321: Update the password rotation procedure in the
ValkeyCluster documentation to add a final convergence wait after removing the
old key. State that rotation is complete only when every ValkeyNode reports
ACLApplied=True again, reflecting propagation of the key removal.

In `@internal/controller/valkeynode_acl.go`:
- Around line 107-157: Add observability around applyLiveACL: record a metric or
counter for each ACL LOAD and for ACLApplied state transitions, including the
PendingPropagation requeue path as appropriate. Preserve the current
unconditional reload and convergence behavior; do not gate reloads or change
reconciliation semantics.

In `@internal/controller/valkeynode_controller_test.go`:
- Around line 1269-1353: Extend the applyLiveACL specs with Reconcile-level
coverage by providing a ready node and asserting ACLApplied PendingPropagation
with a 10-second requeue for stale files, Applied with a 60-second requeue after
convergence, and ApplyFailed plus the LiveACLApplyFailed warning event on load
failure. Add AfterEach cleanup for the live-acl-users Secret. Also add coverage
for an existing Secret missing aclFilename and verify applyLiveACL treats it as
successfully handled.

In `@internal/controller/valkeynode_controller.go`:
- Around line 862-864: Update the List error branch in the controller function
containing client.List to log the encountered error before returning nil, using
the controller’s existing logger and preserving the current no-request return
behavior.
- Around line 263-282: Extract the shared Get, DeepCopy, SetStatusCondition, and
status Patch logic from setACLCondition, setLiveConfigCondition, and
clearLiveConfigCondition into a parameterized patchCondition helper accepting
the condition type, status, reason, and message. Update each existing method to
delegate to patchCondition with its appropriate condition type while preserving
current error context and patch semantics.
- Around line 460-469: The roll-hash preservation logic is duplicated across
both workload paths. In internal/controller/valkeynode_controller.go:460-469,
add and use a shared resolveACLRollHash helper with
sts.Spec.Template.Annotations before assigning desired.Spec; in
internal/controller/valkeynode_controller.go:492-501, replace the duplicate
fallback with the same helper using dep.Spec.Template.Annotations before its
Spec assignment. The helper must prefer a non-empty aclRollHash and otherwise
return the existing hashAnnotationKey value.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7dc78736-4531-44cb-885d-b5a8e7bcded3

📥 Commits

Reviewing files that changed from the base of the PR and between a7fa7bb and 9f73aac.

📒 Files selected for processing (9)
  • api/v1alpha1/valkeynode_types.go
  • docs/status-conditions.md
  • docs/valkeycluster.md
  • internal/controller/users.go
  • internal/controller/valkeynode_acl.go
  • internal/controller/valkeynode_acl_test.go
  • internal/controller/valkeynode_controller.go
  • internal/controller/valkeynode_controller_test.go
  • test/e2e/valkeycluster_test.go

Comment thread internal/controller/valkeynode_controller.go Outdated
Comment thread test/e2e/valkeycluster_test.go Outdated
Comment thread test/e2e/valkeycluster_test.go Outdated
Comment thread test/e2e/valkeycluster_test.go Outdated
@jdheyburn

Copy link
Copy Markdown
Collaborator

@melancholictheory We're going to prioritise the below PR as its a bug fix, I believe it blocks your PR since it includes logic on rolling pods if ACL has been updated. Your changes would undo that, so I think we will want to wait for this to be merged, then your change rebased against it. How does that sound? Appreciate the time you've put into this already.

@melancholictheory

Copy link
Copy Markdown
Contributor Author

sounds right, and thanks for flagging it early. #338 is the better base: it makes the pod-template apply path staged and cluster-permitted, and #319 rewrites that same path (the ACL hash on the template, the recovery roll), so landing them the other way around would just have #319 stomping the permit gating. happy to wait and rebase on top.

one thing i'll want to reconcile on the rebase, flagging it now so it isn't a surprise: #319's recovery roll fires when the operator is locked out of a node (WRONGPASS/NOPERM/NOAUTH after a credential change) and stamps the ACL hash to force a restart, because a restart is the only way the node reloads the aclfile and comes back. under #338 that stamp goes through the cluster permit like any other template change. that's fine for the common case, but a locked-out node is already broken, so i'll want to check the permit doesn't hold the recovery behind a health gate the broken node can't satisfy. i'll work that out against #338's actual mechanism rather than guess at it now.

no rush on my end. ping me when it lands and i'll rebase.

@jdheyburn

Copy link
Copy Markdown
Collaborator

@melancholictheory #338 is now merged in, if you would like to rebase and remove the ACL component from the WorkloadRevision hash.

My understanding is that ACLs should be taken out of the WorkloadRevision, because they are applied live via ACL LOAD - correct me if I am wrong.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@melancholictheory

Copy link
Copy Markdown
Contributor Author

Thanks, rebased onto #338. You've got it right: ACL is out of the WorkloadRevision now. It's applied live with ACL LOAD instead, so an ACL edit no longer rolls the pods.

What's in this revision:

  • buildPodTemplateAnnotations no longer stamps the ACL hash, so ACL changes don't enter the workload revision.
  • The node controller reloads the mounted aclfile on reconcile and watches the internal ACL Secret, and reports an ACLApplied condition once the desired users and their password hashes are observably live.
  • The operator user gains +acl|load / +acl|getuser / +acl|users.
  • Dropped the ACL-secret threading that used to feed the revision through the cluster controller and the failover preflight.

Verified on a 3-shard cluster: adding a user shows up in ACL LIST on every node and authenticates, with no pod restarts, and ACLApplied goes True across the nodes.

One thing I pulled out into a follow-up rather than fold in here: recovering an operator that has locked itself out. A deleted or rotated password Secret leaves it on WRONGPASS, so it can't run ACL LOAD to fix itself. Previously the ACL-hash roll happened to recover that; without the roll it needs its own path. Two ways I can see:

A. The locked-out node records the lockout and the cluster controller factors it into WorkloadRevision, so recovery goes through the same staged, one-at-a-time, failover-aware roll as everything else.

B. A targeted delete of just the locked-out pod: node-local, without touching WorkloadRevision.

I lean towards A, since it keeps recovery inside the staging model from #338 rather than adding an out-of-band pod delete, but B is simpler if you'd rather keep the lockout path self-contained. Which would you prefer? I also removed the old secret-deletion e2e assertion (it asserted a roll); it comes back with whichever recovery path we land on.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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: 2

🤖 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 `@internal/controller/valkeynode_acl_test.go`:
- Around line 28-37: Update the ACL fixture used by desiredUserPasswordHashes so
alice’s password hashes appear in reverse order, such as `#bbb` before `#aaa`, while
keeping the expected result as ["aaa", "bbb"]. This ensures the test verifies
sorting rather than preserving ACL-file order.

In `@test/e2e/valkeycluster_test.go`:
- Around line 1964-1970: Add an assertion to the existing plain-cluster e2e
scenario, after the cluster reaches Ready, that verifies every ValkeyNode has
the ACLApplied condition set to True. Keep the current managed default-user
setup unchanged and use the scenario’s existing cluster/node assertion helpers.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 248a2f8c-4ebe-4236-8f86-819b7cd51594

📥 Commits

Reviewing files that changed from the base of the PR and between 709bf53 and c7ee0c7.

📒 Files selected for processing (13)
  • api/v1alpha1/valkeynode_types.go
  • internal/controller/failover.go
  • internal/controller/failover_test.go
  • internal/controller/users.go
  • internal/controller/valkeycluster_controller.go
  • internal/controller/valkeycluster_controller_test.go
  • internal/controller/valkeynode_acl.go
  • internal/controller/valkeynode_acl_test.go
  • internal/controller/valkeynode_controller.go
  • internal/controller/valkeynode_controller_test.go
  • internal/controller/workload_roll.go
  • internal/controller/workload_roll_test.go
  • test/e2e/valkeycluster_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • api/v1alpha1/valkeynode_types.go
  • internal/controller/valkeynode_acl.go
  • internal/controller/users.go
  • internal/controller/valkeynode_controller.go

Comment thread internal/controller/valkeynode_acl_test.go Outdated
Comment thread test/e2e/valkeycluster_test.go
Comment thread internal/controller/valkeynode_acl.go

@bjosv bjosv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In docs/valkeycluster.md we mention that configurations can be applied live without rolling pods. We could add info about this new handling as well? (under ### Users?)

Comment thread api/v1alpha1/valkeynode_types.go

@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: 1

🤖 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 `@docs/status-conditions.md`:
- Around line 212-215: Update the ACL status documentation near the
ACLApplied=False reasons to explicitly name the LiveACLApplyFailed Kubernetes
warning event, preferably alongside ApplyFailed or in the ACL event table, so
operators can correlate live ACL application failures with emitted events.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 536de949-3f87-4f85-8b65-653a09cb1d8b

📥 Commits

Reviewing files that changed from the base of the PR and between c7ee0c7 and bebad5e.

📒 Files selected for processing (4)
  • docs/status-conditions.md
  • docs/valkeycluster.md
  • internal/controller/valkeynode_acl_test.go
  • test/e2e/valkeycluster_test.go

Comment thread docs/status-conditions.md
Comment thread internal/controller/valkeynode_controller.go Outdated
Comment thread internal/controller/valkeynode_acl.go Outdated
Comment thread internal/controller/valkeynode_acl.go
ACL was carried in the pod template annotations, so an ACL edit changed
Spec.WorkloadRevision and rolled every node one at a time (valkey-io#338). The
operator can reload the ACL on a running server with ACL LOAD, so a roll
is unnecessary: this takes ACL out of the WorkloadRevision hash and
reloads the mounted aclfile live instead.

- buildPodTemplateAnnotations no longer stamps the ACL hash, so ACL edits
  do not enter the workload revision and never roll a pod.
- the ValkeyNode controller reloads the mounted aclfile (ACL LOAD) on
  reconcile and watches the internal ACL Secret, so an edit is picked up
  promptly rather than on the next resync. It reports an ACLApplied
  condition once the desired user set and password hashes are observably
  live.
- the operator user gains +acl|load, +acl|getuser and +acl|users.
- drop the now-dead ACL-secret threading through the cluster controller
  and the failover roll preflight.

Recovering an operator locked out by a deleted password Secret needs a
staged recovery through the cluster controller and is left as a follow-up.

Signed-off-by: melancholictheory <selimvhorst@gmail.com>
- The desiredUserPasswordHashes test fed already-sorted hashes, so it
  passed whether or not the parser sorted. Feed them out of order so the
  test actually exercises the sort.
- The plain-cluster e2e sets no custom users, so its only server user is
  Valkey's unmanaged `default`. Assert every node still reaches
  ACLApplied=True there, covering the unmanaged-default path that the
  managed-user specs never hit.

Signed-off-by: melancholictheory <selimvhorst@gmail.com>
Add an ACLApplied section to docs/status-conditions.md and a note under
Users in docs/valkeycluster.md explaining that ACL changes apply live via
ACL LOAD without a pod roll, matching the existing live-config wording.

Also correct a stale example that still listed an ACL secret hash as a
workload-revision trigger; ACL no longer enters the pod template.

Signed-off-by: melancholictheory <selimvhorst@gmail.com>
- Remove the leftover pre-existing comment above buildPodTemplateAnnotations;
  only the rewritten one (ACL hash deliberately absent) applies now.
- Rename normaliseHashes to normalizeHashes to match normalizePodTemplate and
  the rest of the tree.
- Document the LiveACLApplyFailed warning event in docs/status-conditions.md,
  both in the ACL event table and the ACLApplied notes.

Signed-off-by: melancholictheory <selimvhorst@gmail.com>
Comment thread internal/controller/valkeynode_acl.go
melancholictheory and others added 2 commits August 8, 2026 21:57
Removing the ACL hash from the pod template means an upgraded cluster rolls
once to drop the now-unused annotation, and that roll is what reloads the
aclfile and grants the operator user the ACL commands live application needs.

- Document the upgrade behaviour under Users, including the one case that
  needs a manual restart: a cluster old enough to predate the annotation.
- Add an e2e that stamps the legacy annotation on a running cluster and
  asserts the operator strips it (the migration roll) while the cluster stays
  Ready with ACLApplied=True.

Signed-off-by: melancholictheory <selimvhorst@gmail.com>
@jdheyburn

Copy link
Copy Markdown
Collaborator

@melancholictheory I did some testing on this to verify it. I noticed with changing some fields that aren't "observed" that we lose their visibility on the ACLApplied condition. For example, I set up this command to watch for the condition:

kubectl get valkeynodes \
  -o custom-columns='NAME:.metadata.name,ACL:.status.conditions[?(@.type=="ACLApplied")].status,REASON:.status.conditions[?(@.type=="ACLApplied")].reason' -w

NAME                 ACL    REASON
cluster-sample-0-0   True   Applied
cluster-sample-0-1   True   Applied
cluster-sample-1-0   True   Applied
cluster-sample-1-1   True   Applied
cluster-sample-2-0   True   Applied
cluster-sample-2-1   True   Applied

Then I changed fields under spec.users. The below all changed to PendingPropagation and one-by-one the ValkeyNodes went back to Applied when it was synced.

  • Adding/removing a user
  • Adding/removing a user password

For these actions, they did not change to PendingPropagation, but they did eventually get applied

  • Changing enabled
  • Changing permissions

I believe you called out this limitation earlier in the thread. Perhaps we can document the live ACL apply limitation for now and raise issues to fix these in the future - how does that sound? I am aware a lot of good work has gone into this PR and don't want to hold it out.

@melancholictheory

Copy link
Copy Markdown
Contributor Author

Sounds good, and thanks for testing it. That matches the design: ACLApplied compares the user set and password hashes exactly, so adding or removing a user or a password shows PendingPropagation then Applied. enabled and permission edits still apply through the same unconditional ACL LOAD, but the condition doesn't reflect them in transit, since comparing rules would mean reparsing Valkey's normalized ACL form.

I'll document that in this PR: what the condition observes exactly, and that enabled/permission changes converge without a PendingPropagation step.

For the follow-up, the revision sentinel I prototyped above closes exactly this gap (a hash of the whole managed ACL, so enabled and permission edits move the condition too), and I verified it live. Happy to raise an issue for it, or fold it in later, whichever you prefer. Documenting the current behaviour here and not holding the PR sounds right to me.

Comment thread test/e2e/valkeycluster_test.go Outdated
Comment on lines +2279 to +2333
It("removes a legacy internal-acl-hash annotation so an upgraded cluster migrates to live ACL", Label("acl-hash-migration"), func() {
defer func() {
_, _ = utils.Run(exec.Command("kubectl", "delete", "valkeycluster", clusterName, "--ignore-not-found=true", "--wait=false"))
_, _ = utils.Run(exec.Command("kubectl", "delete", "secret", usersSecret, "--ignore-not-found=true", "--wait=false"))
}()

By("creating a ValkeyCluster with a custom user set")
cmd := exec.Command("kubectl", "apply", "-f", "-")
cmd.Stdin = strings.NewReader(manifest)
_, err := utils.Run(cmd)
Expect(err).NotTo(HaveOccurred())

By("waiting for the cluster to become Ready with live ACL applied")
Eventually(func(g Gomega) {
cr, err := utils.GetValkeyClusterStatus(clusterName)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(cr.Status.State).To(Equal(valkeyiov1alpha1.ClusterStateReady))
g.Expect(cr.Status.ReadyShards).To(Equal(int32(3)))
}, 10*time.Minute, 5*time.Second).Should(Succeed())
Eventually(expectACLAppliedTrue, 5*time.Minute, 5*time.Second).Should(Succeed())

By("stamping the legacy internal-acl-hash annotation on every server StatefulSet")
// Operator versions before live ACL stamped the ACL hash on the pod
// template. Reproduce that pre-upgrade state, then assert the current
// operator migrates off it: removing the annotation is the one-time
// roll that reloads the aclfile and grants _operator the ACL commands.
var stsNames []string
Eventually(func(g Gomega) {
stsNames = serverStatefulSets(g)
g.Expect(stsNames).To(HaveLen(6))
}).Should(Succeed())
for _, sts := range stsNames {
_, err := utils.Run(exec.Command("kubectl", "patch", "statefulset", sts, "--type", "merge",
"-p", `{"spec":{"template":{"metadata":{"annotations":{"valkey.io/internal-acl-hash":"simulated-legacy"}}}}}`))
Expect(err).NotTo(HaveOccurred())
}

By("the operator strips the annotation from every StatefulSet (the migration roll)")
Eventually(func(g Gomega) {
for _, sts := range serverStatefulSets(g) {
out, err := utils.Run(exec.Command("kubectl", "get", "statefulset", sts,
"-o", "jsonpath={.spec.template.metadata.annotations.valkey\\.io/internal-acl-hash}"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(strings.TrimSpace(out)).To(BeEmpty(),
"operator must strip the legacy ACL-hash annotation from %s", sts)
}
}, 5*time.Minute, 5*time.Second).Should(Succeed())

By("the cluster returns to Ready and ACL stays live after the migration")
Eventually(func(g Gomega) {
cr, err := utils.GetValkeyClusterStatus(clusterName)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(cr.Status.State).To(Equal(valkeyiov1alpha1.ClusterStateReady))
}, 10*time.Minute, 5*time.Second).Should(Succeed())
Eventually(expectACLAppliedTrue, 5*time.Minute, 5*time.Second).Should(Succeed())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Legacy ACL migration is not exercised

This test creates the cluster with the current _operator ACL, waits for ACLApplied=True, and only then adds the legacy workload annotation. The current ACL already grants acl|load, acl|getuser, and acl|users, so removing the annotation verifies template reconciliation but not the upgrade behavior this test is intended to protect: a legacy-running pod must be replaced and start with those newly granted commands. Construct the old operator ACL without these permissions, prove the rollout replaces the pods, and verify the three ACL commands succeed as _operator after replacement.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Targeted ACL migration coverage check source

  • Authored executable that runs the focused ACL tests and checks whether the migration spec constructs the legacy permission state, showing the precise validation logic used.

Focused ACL tests and migration coverage check output

  • Captured output from the authored check: controller ACL tests pass, while the inspected migration setup is shown to start with the current ACL and therefore not cover legacy missing permissions.

ACL-hash migration e2e attempt blocked before execution

  • Captured targeted Ginkgo e2e invocation showing the suite failed in BeforeSuite because kubectl is missing and ran zero migration specs.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point, and you're right about what it covers. The spec runs against the current operator, whose _operator already holds acl|load/acl|getuser/acl|users, so it exercises the reconcile trigger (the operator strips the legacy annotation and ACL stays live across the roll) rather than the permission bootstrap itself.

I tried building the missing-permission state in-test by downgrading _operator with ACL SETUSER _operator -acl|load ..., but it isn't faithful: the operator's unconditional ACL LOAD reloads the aclfile (which still grants those commands) and heals the downgrade within a reconcile, without any roll. A real upgrade has no such window, the server boots from the old aclfile without the commands, so every ACL LOAD is NOPERM and the roll is the only thing that grants them. Reproducing that faithfully needs a second, pre-live-ACL operator build deployed first, which the single-image e2e harness can't do.

I did validate the full bootstrap end to end outside the suite: created a cluster on the operator built from current main (_operator without the grants, annotation present), upgraded to this build, and confirmed the pods rolled once, ACL GETUSER _operator then showed all three commands, and a later ACL edit applied live. That's the run I described above.

I've reworded the spec's title and comment so it claims only the trigger and the live-ACL survival, not the grant. If you'd rather I drop the spec, happy to do that too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That's a solid explanation and the constraint is real — you can't synthesize the pre-upgrade boot state without a second operator image, and the in-test downgrade approach correctly identified that ACL LOAD would immediately heal it before any roll could happen.

Narrowing the spec's claimed scope to "trigger + live-ACL survival" is the right call. Keeping it is fine; it does cover a real code path (the annotation strip triggers a reconcile, the cluster survives the roll, and live ACL resumes), and the comment now accurately describes what it exercises. No need to drop it.

Withdrawing the original concern.

Per review, spell out what the condition observes: only the user set and
password hashes drive it through PendingPropagation, so a change to a user's
enabled flag or permissions applies live but does not show a transient
PendingPropagation. Tracking every field is left as a follow-up.

Signed-off-by: melancholictheory <selimvhorst@gmail.com>
@jdheyburn

Copy link
Copy Markdown
Collaborator

@melancholictheory Thanks, I agree with this:

  1. Documenting the limitations in this PR
  2. Follow up PR to improve on the limitations

The spec runs against the current operator, whose _operator user already
holds the ACL commands, so it cannot exercise the permission bootstrap
(that needs a pre-live-ACL operator build). Reword the title and comment so
it claims only what it checks: the operator strips the legacy annotation and
ACL stays live across the resulting roll.

Signed-off-by: melancholictheory <selimvhorst@gmail.com>
Comment on lines +67 to +104
// aclObservablyInSync reports whether the parts of the ACL that can be compared
// exactly are live on the server: the set of users, and each user's password
// hashes.
//
// Permissions are deliberately not compared. ACL GETUSER returns Valkey's
// normalized rendering of the rules, while the operator only holds the aclfile
// text, so comparing the two would mean reimplementing Valkey's own ACL parser
// and keeping it in step with the server. Correctness of the apply does not
// depend on this check either way: the reload is unconditional, so permission
// edits converge regardless. This only scopes what ACLApplied can honestly
// claim.
func aclObservablyInSync(ctx context.Context, c valkeyConfigClient, desired map[string][]string) (bool, error) {
actualUsers, err := c.UserNames(ctx)
if err != nil {
return false, err
}
serverUsers := slices.Sorted(slices.Values(actualUsers))
// Valkey always keeps a `default` user of its own, and ACL LOAD cannot
// remove it. When the aclfile does not manage `default` (it is absent from
// spec.users), ignore the server's copy; otherwise the sets never match and
// the node loops forever reporting the ACL as not yet live.
if _, managed := desired["default"]; !managed {
serverUsers = slices.DeleteFunc(serverUsers, func(u string) bool { return u == "default" })
}
if !slices.Equal(serverUsers, slices.Sorted(maps.Keys(desired))) {
// A user was added or removed and the server has not picked it up yet.
return false, nil
}
for _, user := range slices.Sorted(maps.Keys(desired)) {
actual, err := c.UserPasswordHashes(ctx, user)
if err != nil {
return false, err
}
if !slices.Equal(actual, desired[user]) {
return false, nil
}
}
return true, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security ACL permission revocations are reported as applied

A permissions-only ACL update can set ACLApplied=True before the kubelet has refreshed the mounted Secret. When ACL LOAD reads the old file, aclObservablyInSync only compares user names and password hashes; unchanged identities therefore appear synchronized even if the old server ACL still grants a command that was just revoked. Consumers can act on the successful condition while the revoked permission remains available until a later reconcile.

Track a revision that is observable in the mounted ACL and loaded server state, or otherwise avoid reporting ACLApplied=True until the server can prove it consumed the current ACL content.

Artifacts

Focused Go reproduction source for stale ACL projection

  • A temporary focused Go test harness creates a fake Secret reader and config client that models a successful ACL LOAD reading the old projected file, with the takeaway that password-only observation cannot see retained permissions.

Password-change stale projection control output

  • The executed focused Go control simulates a stale projected ACL with a changed hash and passes after confirming it is not synchronized, with the takeaway that hash changes are detected.

Permissions-only stale projection reproduction output

  • The executed focused Go reproduction simulates a command revocation with unchanged users and hashes and passes after confirming current code reports it synchronized, with the takeaway that ACLApplied can become true while old permissions remain live.

Initial focused ACL test patch

  • The captured authored patch documents the initial function-level test scenario used to target stale mounted ACL behavior, with the takeaway that the validation remained limited to the ACL apply path.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the permissions-only window we've been working through with @jdheyburn above. He tested it and confirmed exactly this: user and password changes move ACLApplied through PendingPropagation, while enabled and permission edits apply but don't drive the condition. The call for this PR is to document the limitation and fix it in a follow-up, which is where it stands: the ACLApplied docs now spell out that only the user set and password hashes drive the condition, and the revision sentinel I prototyped earlier in the thread closes the gap (a hash of the whole managed ACL, so permission edits move the condition too).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the context — that's clear. The docs caveat plus a tracked follow-up with the revision sentinel is the right call here; no need to block the PR on it. Acknowledged.

@jdheyburn jdheyburn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks once again for taking this on!

@jdheyburn
jdheyburn merged commit eb1d467 into valkey-io:main Aug 10, 2026
10 checks passed
@daanvinken

Copy link
Copy Markdown
Contributor

For the upgrade 0.4 -> 0.5 I noticed clusters that already had _operator from before this PR log:

ACL LOAD: NOPERM User _operator has no permissions to run the 'acl|load' command

The Secret already includes +acl|load, but the running process still has the old ACL. Until each pod restarts once and reloads users.acl, live load cannot grant itself that permission.

Clears after the staged roll (or a one-off pod restart). Not blocking for us; but we may want to post that on the release notes. The errors stood out during upgrade.

@melancholictheory

Copy link
Copy Markdown
Contributor Author

That's the expected upgrade path, thanks for flagging it. This version drops the ACL-hash annotation from the pod template, so every pre-existing cluster rolls once, and that roll is what restarts each pod onto the new users.acl and grants _operator the acl|load command. The NOPERM lines are the window before a pod has rolled; they clear as the staged roll moves through, or with a manual restart as you saw.

It's written up under Users in docs/valkeycluster.md (the upgrade note), and a release-notes line calling out the transient NOPERM on 0.4 to 0.5 is a good idea.

@jdheyburn

Copy link
Copy Markdown
Collaborator

I've updated the release notes with the same: https://github.com/valkey-io/valkey-operator/releases/tag/v0.5.0

@melancholictheory

Copy link
Copy Markdown
Contributor Author

One small thing on the release-note wording: "manual restart required" reads a bit stronger than what actually happens. On upgrade the operator drops the ACL-hash annotation from the pod template, which triggers the one-time staged roll on its own, and that roll reloads users.acl and clears the NOPERM. So for a normal cluster it self-resolves as the roll moves through, without a manual step.

A manual restart only helps if you want it cleared immediately, or for a cluster old enough to predate that annotation (no annotation to drop means no automatic roll). Might be worth softening the note to something like: "the one-time staged roll on upgrade clears this on its own; a manual restart is only needed to clear it immediately, or for clusters that predate the ACL-hash annotation."

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.

[feat] Apply ACL changes live

5 participants