Skip to content

fix(redis): two renderers write one Redis config for the same host group, and only one emits the TLS block #17441

Description

@mrveiss

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.

Renderer Play / task Target
roles/redis tasks/main.yml "Redis | Configure Redis Stack" {{ redis_config_file }}
playbooks/deploy-native-services.yml play "Deploy Redis Stack Natively", hosts: 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 renders requirepass into a file nothing opens produces an unauthenticated store — but it turns a dead fork into a live one.

Unverified: that the daemon never reads /etc/redis/redis.conf is assumed here, not measured. #17434's AC1 owns establishing it (redis-cli INFO server on the database-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.

Why this is not just tidiness

The two renders are not equivalent. roles/redis emits the tls-* 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 regression roles/redis/tasks/code_only.yml:22-35 was written to prevent, arriving through a different door.

roles/redis also renders maxmemory, maxmemory-policy, the lazyfree-* set and the AOF settings that the playbook's version does not.

Acceptance criteria

  • One renderer. The playbook either delegates to 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.
  • A 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 as redis_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.
  • Until the merge lands, running the playbook after the role cannot drop TLS — either the playbook's template gains the same 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-stack
  • playbooks/deploy-native-services.yml — play "Deploy Redis Stack Natively", hosts: database, copy: with an inline content: block
  • roles/redis/templates/redis-stack.conf.j2 — the tls-* block absent from the playbook's version
  • roles/redis/tasks/code_only.yml:22-35 — the prior ruling on exactly this failure mode

Found while delivering #17434, which is where the shared target path comes from.

Related: #17434 · #16627 · #16628

Activity

  1. mrveiss commented on Sep 25, 2026

    @mrveiss
    OwnerAuthor

    Same family, found while delivering #17434: the provisioning playbook reports two paths that are both wrong

    setup-redis-stack.yml is the playbook api/infrastructure.py:132 calls — 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-stack directly 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/redis and /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:19 carries 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 for redis_config_file, which was /etc/redis/redis.conf on the same interface and was inherited by roles/redis-replication in nine tasks.

    deploy-database.yml is the counter-example that shows the variable is usable: it sets redis_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.

  2. mrveiss commented on Sep 26, 2026

    @mrveiss
    OwnerAuthor

    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/redis tasks/main.yml:165, src: redis-stack.conf.j2 {{ redis_config_file }} → /etc/redis-stack.conf
    playbooks/deploy-native-services.yml inline content: block {{ redis_config_file }} → /etc/redis-stack.conf
    playbooks/deploy-database.yml:125 src: ../templates/redis/redis-stack.conf.j2 {{ redis_config_dir }}/redis-stack.conf → /etc/redis-stack/redis-stack.conf

    redis_config_dir: "/etc/redis-stack" is set at deploy-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 not roles/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-121 carries five loadmodule lines.
    Two independent reasons they cannot be handed to the packaged wrapper:

    1. The wrapper already loads every module. /opt/redis-stack/bin/redis-stack-server
      appends --loadmodule for 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.
    2. redisgraph.so does 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). A loadmodule naming a missing file is fatal on its own.

    roles/redis/templates/redis-stack.conf.j2 lists 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.py sweeps for three
    literal spellings, line by line. It does not catch this renderer, because
    the path is assembled from a variable: deploy-database.yml:17 holds
    /etc/redis-stack and :126 holds {{ 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 _EXEMPT docstring 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:

    Evidence gathered at 8f7c2a3226.

  3. mrveiss commented on Sep 26, 2026

    @mrveiss
    OwnerAuthor

    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.yml renders ansible/templates/redis/redis-stack.conf.j2 to
    {{ redis_config_dir }}/redis-stack.conf (:126), where redis_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.conf after PR #17444. So this play writes one file
    and starts the daemon against another
    , and #17444's new ExecStartPre=/usr/bin/test -r makes 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.py sweeps
    deploy-database.yml and passes over it, because the forbidden path exists only as
    redis_config_dir plus 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 loadmodule lines

    ansible/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.so
    

    PR #17444 removed the same trap from playbooks/deploy-native-services.yml and wrote down why:

    The packaged wrapper appends --loadmodule for 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 .so is absent from the installed build, loadmodule on 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
    loadmodule block before or in the same change as pointing the daemon at this renderer's output —
    never after.

    roles/redis/templates/redis-stack.conf.j2 has never listed a module, which is the behaviour to keep.

  4. mrveiss commented on Sep 27, 2026

    @mrveiss
    OwnerAuthor

    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-125 renders templates/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, where deploy-database.yml:17 sets redis_config_dir: "/etc/redis-stack". On the deploy path via deploy.sh:273 and playbooks/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:128 dereferences redis.password under a true guard, and no redis: dict in the inventory defines password.

    Full evidence in #17500 comment-5858039097. Refs #17434.

  5. mrveiss commented on Oct 3, 2026

    @mrveiss
    OwnerAuthor

    Labelling blocked: observation-gated behind #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 server on 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 by redis-cli INFO server and 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.

  6. added
    blocked: observation-gatedCode remains, but cannot be written until one observation answers a question
    on Oct 3, 2026
  7. mrveiss commented on Oct 3, 2026

    @mrveiss
    OwnerAuthor

    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 server from the actual database-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.conf is assumed here, not measured. #17434's AC1 owns establishing it (redis-cli INFO server on the database-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:

    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.

  8. mrveiss commented on Oct 3, 2026

    @mrveiss
    OwnerAuthor

    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-server
    

    Corroborated independently by systemctl show redis-server, whose ExecStart passes /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:26 already documents /etc/redis-stack.conf, and main.yml:237 records that requirepass is 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.

  9. removed
    blocked: observation-gatedCode remains, but cannot be written until one observation answers a question
    on Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions