From 1c4e10e0d855f9b646b05ac7963830937234391e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 2 Sep 2026 01:23:38 +0200 Subject: [PATCH] The cache keeps no ACL file, and the watch keeps the cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lab's diagnostics said it in one line: AUTH called without any password configured for the default user. With an aclfile configured, redis takes the default user from the file and quietly ignores requirepass — so the empty seed this module shipped left the store without any password at all, politely refusing the credential the mesh had sealed for it. And the file could never have worked here anyway: it was host-declared content, which the host reconciles, so every re-apply would have wiped what ACL SAVE wrote — a fight between two reconcilers with the tenants as the ball. So no file. requirepass alone does what it says, and durability moves to the watch, which now checks the store and not only its inputs: a restarted store comes back empty and is re-granted within a tick, because reconciling is against reality, not against a diff of instructions. A run that failed leaves last empty, so the next tick retries instead of believing the inputs were handled. --- examples/modules/redis.json | 10 +---- examples/redis-provisioner/main.go | 64 ++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/examples/modules/redis.json b/examples/modules/redis.json index 74045d9..5f223c6 100644 --- a/examples/modules/redis.json +++ b/examples/modules/redis.json @@ -55,15 +55,7 @@ "type": "file", "path": "/var/lib/redis-module/redis.conf", "mode": "0600", - "content": "requirepass ${secret:default}\naclfile /data/users.acl\nappendonly yes\ndir /data\n", - "owner": "999:999" - }, - { - "id": "acl-seed", - "type": "file", - "path": "/services/redis/data/users.acl", - "mode": "0600", - "content": "", + "content": "requirepass ${secret:default}\nappendonly yes\ndir /data\n", "owner": "999:999" }, { diff --git a/examples/redis-provisioner/main.go b/examples/redis-provisioner/main.go index 371f920..92ca3d1 100644 --- a/examples/redis-provisioner/main.go +++ b/examples/redis-provisioner/main.go @@ -86,9 +86,15 @@ func watch(ctx context.Context) error { switch { case err != nil: fmt.Fprintf(os.Stderr, "cannot read what was granted: %v\n", err) - case state != last: + case state != last || !granted(ctx): + // Re-run when the inputs changed — or when the store no longer holds what was + // granted. The store keeps its users in memory, so a restart forgets every tenant; + // nothing rewrites the grants when that happens, and a watch that only re-read its + // inputs would leave the store empty until the next unrelated change. Reconciling is + // against reality, not against a diff of instructions. if err := run(ctx); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) + last = "" } else { last = state } @@ -207,16 +213,58 @@ func run(ctx context.Context) error { fmt.Printf("revoked %s\n", user) } - // Persisted, or the next restart forgets every grant. The server runs with an aclfile for - // exactly this; a server without one refuses the save, and saying so beats a cache that - // silently loses its tenants on restart. - if _, err := r.do("ACL", "SAVE"); err != nil { - return fmt.Errorf("the grants were applied and could not be persisted — a restart would "+ - "forget them: %w", err) - } + // Not persisted in the store, on purpose. An ACL file was tried and lost twice over: with + // one configured, redis takes the default user from the file and quietly ignores + // `requirepass`, so an empty seed left the store without any password at all — and the file + // is host-declared content, which the host reconciles, so every re-apply would have wiped + // what ACL SAVE wrote. Durability lives in the watch instead: a restarted store comes back + // empty and is re-granted within a tick, because the watch checks the store and not only + // its inputs. return nil } +// granted says whether the store still holds every user the mesh has granted — cheaply, so the +// watch can ask every tick. +func granted(ctx context.Context) bool { + raw, err := os.ReadFile(filepath.Join(grantsDir(), "mesh.json")) + if err != nil { + // Nothing granted (or nothing readable): nothing to be missing. + return true + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + return true + } + wanted := map[string]bool{} + for _, c := range m.Given { + if c.Node != "" && strings.HasPrefix(c.As, mark) { + wanted[c.As] = true + } + } + if len(wanted) == 0 { + return true + } + r, err := connect(ctx) + if err != nil { + return false + } + defer r.Close() + users, err := r.strings("ACL", "USERS") + if err != nil { + return false + } + holds := map[string]bool{} + for _, u := range users { + holds[u] = true + } + for u := range wanted { + if !holds[u] { + return false + } + } + return true +} + // resp is the five commands this needs, spoken directly. type resp struct { conn net.Conn