Keycloak stores the permanent admin's password in its own database. When the
inventory value rotates, the next deploy renders a credential that
authenticates against nothing, and the role cannot reach the instance it just
restarted. The bootstrap account is no way back either: Keycloak writes
KC_BOOTSTRAP_ADMIN_* once, on an empty database, and upstream states it is
meant to be removed once a permanent admin exists.
The role now keeps the credential its predecessor used. Before sys-stk-full
re-renders the environment file with force: true, utils/capture_previous_admin.yml
reads KEYCLOAK_PERMANENT_ADMIN_PASSWORD out of the copy still on disk. When the
login after the restart is refused, utils/converge_previous_admin.yml signs in
with that captured value and moves the account onto the newly rendered one,
which the restarted container already carries in its environment. The bootstrap
fallback stays what its name says: the path for an instance that has never been
deployed. A host with no previous file captures nothing and goes straight there.
Reading the file back needs the inverse of dotenv_quote, which did not exist.
dotenv_unquote switches on the same DEPLOYMENT_MODE as the forward direction, so
a value written in compose is unescaped and a value written in swarm is taken
verbatim; dotenv_value pulls one assignment's raw right-hand side out of the
rendered text. The password never reaches argv: the convergence login passes it
through container exec -e plus the task's environment:.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_passthrough_swarm quoted and escaped any value containing whitespace,
which contradicts the contract stated at the top of the same module:
docker stack deploy does not strip outer quotes, so the value reaches the
container with them.
Ansible's to_json emits the default ", " separators, so AGENT_PLATFORMS
always carried spaces and arrived as "{\"hermes\": ...}". json.loads then
returned a str rather than a mapping, and sorted(PLATFORMS) in the broker
iterated its characters: the broker advertised one model per character and
never hermes or openclaw, which is why Open WebUI's model list held neither
and the agent-broker specs failed before reaching a completion. The same
encoding mangled AGENT_CONSTRAINT, whose placement expression would have
failed once an agent was reachable.
The branch existed for the opposite consumer: test.env is sourced by bash,
where KEY=one two leaves the variable unset under set -u. Quoting is the
wrong instrument for that, because one filter cannot serve both contracts
keyed on DEPLOYMENT_MODE alone. Both values that join on a space now join
on a comma and their consumers split on one, so no rendered env value needs
quoting to survive the shell.
Two unit tests asserted the quoting while a sibling in the same class
asserted that swarm does not escape a double quote; the pair could not both
hold. They are replaced by one test that renders real JSON and checks the
container receives a mapping. The lint that flags a space-joined value no
longer accepts dotenv_quote as the remedy, since in swarm it no longer
quotes, and its message names the comma instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The swarm branch passed values through raw, so a value with interior
whitespace (MAIL_FROM_NAME='Infinito.Nexus by ...') landed unquoted in
the rendered .env and broke the env parser: the snipe-it service
crashlooped at 0/3 until the converge wait failed in every variant.
Quote (and escape backslash/double-quote) only when the value contains
space/tab/newline; everything else stays raw, so the swarm invariants
hold: no compose $$-doubling, raw $ and double quotes preserved,
and the playwright --env-file path (docker CLI keeps quotes literal)
is unaffected because no playwright env value can contain whitespace.
Compose quoting is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docker stack deploy passes env-file values verbatim, including outer
double quotes - the container reads KEY="value" instead of KEY=value
and DB auth, URL parsers, and int(os.environ['KEY']) consumers break.
Compose keeps stripping the quotes as before.
- plugins/filter/dotenv.py: read DEPLOYMENT_MODE from the render
context via @jinja2.pass_context. compose mode keeps the old quoting
(escape $$, \, "). swarm mode passes the value through verbatim.
Missing or unknown mode falls back to compose quoting so a typo
never silently leaks unquoted values into compose. Direct Python
callers without a Jinja context keep the compose-style default.
- tests/unit/plugins/filter/test_dotenv_quote.py: keep the 12
direct-call cases, add 8 TestDotenvQuoteModeAware cases rendering
the filter through a real jinja2.Environment.
- 4 OIDC_TITLE templates (bookwyrm, confluence, jira, pretix): replace
the manual "{{ X | replace('\"','\\\"') }}" Compose-only escape
with {{ X | dotenv_quote }}.
- 3 env.j2 templates (nextcloud, taiga, wordpress): drop literal
outer quotes that were Compose-only decoration; keep | dotenv_quote
on the password / token assignments.
- sys-svc-rdbms/templates/env/mariadb.env.j2: strip literal quotes
unconditionally - the file name is not env.j2 so the dotenv-quote
integration test does not require the filter and the generated DB
passwords are alphanumeric.
Removed leading `# filter_plugins/...py` / `# lookup_plugins/...py`
comments that referenced the old plugin paths. The plugins now live
under plugins/filter and plugins/lookup, so these headers are both
inaccurate and redundant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>