Repository navigation
fix(redis): two renderers write one Redis config for the same host group, and only one emits the TLS block #17441
Description
Activity
Same family, found while delivering #17434: the provisioning playbook reports two paths that are both wrong
setup-redis-stack.ymlis the playbookapi/infrastructure.py:132calls — its own comment calls it "the real Redis Stack provisioning playbook". It sets:redis_data_dir: /var/lib/redis # :29 redis_log_dir: /var/log/redis # :30
and consumes them only in a debug banner:
Data: {{ redis_data_dir }} # :81 Logs: {{ redis_log_dir }} # :82
Nothing else reads either. The role renders
dir /var/lib/redis-stackdirectly and, after #17434, logs to/var/log/redis-stack. So an operator provisioning a Redis node through the API is told the data and log directories are/var/lib/redisand/var/log/redis, and neither is where anything ends up. It is not a functional break — which is exactly why it would survive indefinitely.roles/redis/defaults/main.yml:19carries the same shape:redis_data_dir: "/var/lib/redis"with no consumer inside the role, a false value on the role's public interface. That is the identical pattern #17434 fixed forredis_config_file, which was/etc/redis/redis.confon the same interface and was inherited byroles/redis-replicationin nine tasks.deploy-database.ymlis the counter-example that shows the variable is usable: it setsredis_log_dir: "/var/log/redis-stack"and actually creates it (:103,:314).Recording here rather than as a separate issue because it is the same convergence and the same renderer-ownership question — whoever consolidates the renderers should make these two variables either authoritative or absent, not decorative.
- added a commit that references this issue
on Sep 26, 2026 A third renderer, found while clearing #17444's review
The table above lists two renderers. There is a third, and it is the one that
still writes to a dead path after #17434.Renderer Task Target roles/redistasks/main.yml:165,src: redis-stack.conf.j2{{ redis_config_file }}→/etc/redis-stack.confplaybooks/deploy-native-services.ymlinline content:block{{ redis_config_file }}→/etc/redis-stack.confplaybooks/deploy-database.yml:125src: ../templates/redis/redis-stack.conf.j2{{ redis_config_dir }}/redis-stack.conf→/etc/redis-stack/redis-stack.confredis_config_dir: "/etc/redis-stack"is set atdeploy-database.yml:17. That
directory is one of the four spellings #17434 enumerated as never read, so this
render is inert today — the same way the native play's was before #17434
pointed it at the canonical path. It is a dead fork waiting to become a live
one the moment someone "fixes" it by pointing it at the canonical path too.Note this is a different template from the role's.
templates/redis/redis-stack.conf.j2
is notroles/redis/templates/redis-stack.conf.j2; the two have the same
basename and different content.The third renderer's template would abort startup if it were ever read
templates/redis/redis-stack.conf.j2:113-121carries fiveloadmodulelines.
Two independent reasons they cannot be handed to the packaged wrapper:- The wrapper already loads every module.
/opt/redis-stack/bin/redis-stack-server
appends--loadmodulefor all six Stack modules after the config file it is
given. A module named in the config is therefore loaded twice, and the second
load aborts Redis at startup. redisgraph.sodoes not exist. The provisioned module directory holds
redisbloom.so,rediscompat.so,redisearch.so,redisgears.so,
redistimeseries.so,rejson.so,libredisgears_v8_plugin.so— no
redisgraph.so(RedisGraph is end-of-life and is not shipped in current
Redis Stack). Aloadmodulenaming a missing file is fatal on its own.
roles/redis/templates/redis-stack.conf.j2lists no modules, which is the
correct precedent and what #17444 just converged the native play onto.A blind spot in the #17434 guard, stated rather than left implicit
repo_tests/redis_config_path_is_canonical_17434_test.pysweeps for three
literal spellings, line by line. It does not catch this renderer, because
the path is assembled from a variable:deploy-database.yml:17holds
/etc/redis-stackand:126holds{{ redis_config_dir }}/redis-stack.conf,
and neither line contains a forbidden literal on its own.So the guard currently reports a clean sweep over a live instance of the defect
it exists to catch — exactly the failure its own_EXEMPTdocstring warns
about, arriving through indirection rather than exemption.I did not widen the guard in #17444. Widening it makes it fail on
deploy-database.yml, and the fix for that is this issue's AC 1 (one renderer),
not a patch in the config-path PR. Suggest adding to this issue's ACs:- The security(redis): the role's Redis Stack config is never read — requirepass is empty on a provisioned node, and one path has four spellings #17434 guard resolves single-hop Ansible variables before matching, so
a config path assembled fromredis_config_diris caught; contrast pair
proves it trips on the assembled spelling and stays quiet on the canonical one.
Evidence gathered at
8f7c2a3226.- The wrapper already loads every module.
Two facts for this issue's scope, found reviewing PR #17444 (#17434)
Recording them here rather than filing, because this issue is already the single home for "two
renderers write one Redis config for the same host group" — these are the same defect, measured.1. The second renderer writes a third spelling, assembled rather than literal
playbooks/deploy-database.ymlrendersansible/templates/redis/redis-stack.conf.j2to
{{ redis_config_dir }}/redis-stack.conf(:126), whereredis_config_dir: "/etc/redis-stack"is a
play var (:17). The play creates that directory (:93-104), so the render lands — in a
directory #17434 establishes no node otherwise has.Meanwhile the unit that same play installs (
:164) now execs
{{ redis_config_file }}=/etc/redis-stack.confafter PR #17444. So this play writes one file
and starts the daemon against another, and #17444's newExecStartPre=/usr/bin/test -rmakes that a
refusal to start rather than the old silent fallback to the packaged 23-byte default.Note for whoever converges this:
repo_tests/redis_config_path_is_canonical_17434_test.pysweeps
deploy-database.ymland passes over it, because the forbidden path exists only as
redis_config_dirplus a suffix and never as a literal. The guard is not blind here by declaration —
this form is inside its swept set — so converging the renderers should also teach it the
variable-plus-suffix shape, or nothing will notice the next one.2. Converging them activates five
loadmodulelinesansible/templates/redis/redis-stack.conf.j2:113-121:loadmodule /opt/redis-stack/lib/redisearch.so loadmodule /opt/redis-stack/lib/rejson.so loadmodule /opt/redis-stack/lib/redisgraph.so loadmodule /opt/redis-stack/lib/redistimeseries.so loadmodule /opt/redis-stack/lib/redisbloom.soPR #17444 removed the same trap from
playbooks/deploy-native-services.ymland wrote down why:The packaged wrapper appends
--loadmodulefor all six Stack modules after the config it is
handed, so naming four of them here loads each twice and Redis exits at startup. They were inert
only while nothing read the file.This template has five, and one of them is
redisgraph.so, which Redis Stack dropped after 7.0 — if
that.sois absent from the installed build,loadmoduleon a missing file is fatal on its own, no
double-load required.These lines are harmless today for precisely the reason this issue exists: the file nothing reads.
The convergence is what arms them. So the ordering constraint for this issue is: strip the
loadmoduleblock before or in the same change as pointing the daemon at this renderer's output —
never after.roles/redis/templates/redis-stack.conf.j2has never listed a module, which is the behaviour to keep.- added a commit that references this issue
on Sep 26, 2026 Sighting: there is a third renderer, and it is invisible to the AC-2 guard as written
Found while scoping #17500. This issue inventories two renderers; there is a third.
playbooks/deploy-database.yml:123-125renderstemplates/redis/redis-stack.conf.j2— a full Redis Stack config, distinct content again from both renderers already listed — to{{ redis_config_dir }}/redis-stack.conf, wheredeploy-database.yml:17setsredis_config_dir: "/etc/redis-stack". On the deploy path viadeploy.sh:273andplaybooks/deploy-full.yml:110.Why it matters to AC 2 specifically. The criterion is a guard that fails when more than one task writes the canonical Redis config path. This third renderer writes
/etc/redis-stack/redis-stack.conf— the directory, not the canonical file/etc/redis-stack.conf— so a guard keyed on the canonical path would pass with three renderers present. Worth widening the criterion to "more than one task renders a Redis Stack config", independent of destination, since a near-miss destination is its own defect (→ #17434) rather than a reason to ignore the renderer.It also cannot render today:
templates/redis/redis-stack.conf.j2:128dereferencesredis.passwordunder a true guard, and noredis:dict in the inventory definespassword.Labelling
blocked: observation-gatedbehind #17434.A cross-issue sweep found this issue's criteria depend on an environment fact that #17434 owns establishing and has not yet established: which Redis config file the running daemon actually reads, answerable by one
redis-cli INFO serveron the database-role node.Why this was not visible from reading this issue alone. A gate is a property of a pair, and it can be written in either member. #17434's AC6 names some of these issues from the outside; this one instead assumes the answer from the inside, without naming #17434. Only the first kind is findable by reading the issue being classified, which is why one-at-a-time triage missed it — not because the triage was sloppy, but because the edge is not written here.
The specific hazard, and it is worth stating because it inverts the design rather than merely delaying it: the assumed premise is that the daemon never reads
/etc/redis/redis.conf. #17434's AC1 says exactly that fact must be established byredis-cli INFO serverand must not be chosen before it. So work here would pin a path #17434 has not settled, from a premise #17434 forbids assuming. If the premise is wrong, the design does not need adjusting — it reverses.That shape is a premise with no measurement behind it, carried forward as settled. It is the same error as reading an empty apt candidate list as proof a repository is broken.
One observation unblocks six issues: #17434, #16627, #16628, #17441, #16625, and transitively whatever #13568 holds. That is currently the strongest single-command argument in the backlog.
- addedblocked: observation-gatedCode remains, but cannot be written until one observation answers a questionCode remains, but cannot be written until one observation answers a question
on Oct 3, 2026 This issue assumes the answer to #17434's AC1 — proposed criterion wording below
Labelled
blocked: observation-gated, which tells a triager. This comment is for the implementer, who is the one who would otherwise build on the premise.The premise. This issue's body states, as settled:
Before #17434 this was invisible in a different way: the playbook wrote to
/etc/redis/redis.conf, which the daemon never reads, so its render was inert and could not clobber anything.#17434's AC1 holds that exact fact open, and forbids choosing before it:
AC1 Establish, with
redis-cli INFO serverfrom the actualdatabase-role node, which path that node reads. The fix depends on the answer and must not be chosen before it.This is not a scheduling dependency. If the daemon does read
/etc/redis/redis.conf, then the playbook's render was never inert, the "invisible before #17434" history is wrong, and this issue's conclusion about which renderer clobbers which inverts rather than slips. A premise with no measurement behind it, carried forward as settled, is worse than a criterion that merely waits.It also reaches AC2 directly: pinning "the canonical Redis config path" requires knowing which path is canonical, and #17434's AC2 says "AC1 decides" that.
Proposed wording, ready to paste
For the body's premise paragraph, add after the sentence quoted above:
Unverified: that the daemon never reads
/etc/redis/redis.confis assumed here, not measured. #17434's AC1 owns establishing it (redis-cli INFO serveron thedatabase-role node) and states the fix must not be chosen before the answer. If the daemon does read that path, this issue's clobbering analysis inverts — the playbook's render was never inert.For AC2, replace with:
- A
repo_tests/guard fails when more than one task writes the canonical Redis config path, so a third renderer cannot appear quietly. The canonical path is whatever security(redis): the role's Redis Stack config is never read — requirepass is empty on a provisioned node, and one path has four spellings #17434's AC1 establishes — this guard cannot be written against an assumed path, since pinning the wrong one makes the guard enforce the defect.
Modelled on #17537, which carries an unestablished premise correctly: it flags
**Unverified:**inside the criterion and names the mechanism by which the premise might be false. A label routes the issue; the clause stops the work being done on sand.Not editing the body — the wording is the owner's call. Posting it ready to paste.
- A
Premise CONFIRMED — removing
blocked: observation-gated. The gate is answered, not waiting.#17434's AC1 has been established by direct observation on the running host:
config_file:/etc/redis-stack.conf executable:/opt/redis-stack/bin/redis-serverCorroborated independently by
systemctl show redis-server, whoseExecStartpasses/etc/redis-stack.conf— so this is two surfaces agreeing, not one reading. The daemon does not read/etc/redis/redis.conf.This issue's assumed premise was right, so its design does not invert. Worth stating precisely, because the distinction survives the good outcome: the premise being correct does not make assuming it correct. What changes is the remedy — from rework to confirmation. The
Unverified:clause recorded on this issue did its job: it named the premise, named the issue that owned establishing it, and named the mechanism by which it could have been false. Had the answer gone the other way, that clause is what would have stopped the work being done on sand.Scope of the measurement, stated rather than glossed: the host carries every role directory under the install root, so it is an all-in-one deployment and this answer is for this deployment. The design-time inventory declares 7 host entries across 7 role groups and its own comment says the topology is operator-dependent, so the fleet size is between 1 and 7 and the repository cannot settle it. The canonical path for a role-separated deployment is the same by construction — the unit's argument is fixed by the Redis Stack package — but that is an inference from the packaging, not a second measurement.
One nuance that narrows the work here: the role is not uniformly wrong about the path.
roles/redis/tasks/code_only.yml:26already documents/etc/redis-stack.conf, andmain.yml:237records thatrequirepassis deliberately not touched there. So the remaining work is a consolidation of several spellings, some of which are already correct — and any guard counting them should expect the correct spelling to be present rather than treating every occurrence as a defect.Full evidence on #17434.
- removedblocked: observation-gatedCode remains, but cannot be written until one observation answers a questionCode remains, but cannot be written until one observation answers a question
on Oct 3, 2026
Verdict
Two independent things render a full Redis Stack configuration for the same host group, from different content, and after #17434 they write to the same file. Whichever runs last wins, silently.
roles/redistasks/main.yml"Redis | Configure Redis Stack"{{ redis_config_file }}playbooks/deploy-native-services.ymlhosts: database{{ redis_config_file }}Before #17434 this was invisible in a different way: the playbook wrote to
/etc/redis/redis.conf, which the daemon never reads, so its render was inert and could not clobber anything. #17434 pointed it at the canonical path — which is correct in isolation, because a provisioning mode that rendersrequirepassinto a file nothing opens produces an unauthenticated store — but it turns a dead fork into a live one.Why this is not just tidiness
The two renders are not equivalent.
roles/redisemits thetls-*block from the stat facts registered by_shared/tasks/stat_tls_certs.yml; the playbook's inline config has no TLS directives at all. So running the playbook after the role on a TLS-enabled node silently removes TLS from the data store's config — the exact class of regressionroles/redis/tasks/code_only.yml:22-35was written to prevent, arriving through a different door.roles/redisalso rendersmaxmemory,maxmemory-policy, thelazyfree-*set and the AOF settings that the playbook's version does not.Acceptance criteria
roles/redis(include_role) or is retired with its remaining unique steps moved into the role — decided with evidence about which provisioning mode is actually used, not by preference.repo_tests/guard fails when more than one task writes the canonical Redis config path, so a third renderer cannot appear quietly. The same shape asredis_config_path_is_canonical_17434_test.py, one field over. The canonical path is whatever security(redis): the role's Redis Stack config is never read — requirepass is empty on a provisioned node, and one path has four spellings #17434's AC1 establishes — this guard cannot be written against an assumed path, since pinning the wrong one makes the guard enforce the defect.tls-*block from the same stat facts, or the two cannot both run.Evidence
roles/redis/tasks/main.yml— "Redis | Configure Redis Stack",notify: restart redis-stackplaybooks/deploy-native-services.yml— play "Deploy Redis Stack Natively",hosts: database,copy:with an inlinecontent:blockroles/redis/templates/redis-stack.conf.j2— thetls-*block absent from the playbook's versionroles/redis/tasks/code_only.yml:22-35— the prior ruling on exactly this failure modeFound while delivering #17434, which is where the shared target path comes from.
Related: #17434 · #16627 · #16628