Files
computer-playbook/plugins/filter/dotenv.py
T
kevinveenbirkenbachandClaude Opus 5 c7238adb0a fix(web-app-keycloak): absorb a rotated admin password instead of locking out
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>
2026-10-12 16:56:51 +02:00

135 lines
4.2 KiB
Python

# Dotenv-safe quoting for .env files.
#
# Behaviour is mode-aware:
#
# * Compose: wraps value in double quotes, escapes ``$`` as ``$$`` (compose
# parses interpolation), escapes ``\\`` and ``"``. Compose then strips
# the outer quotes back off when loading the env-file.
#
# * Swarm: ``docker stack deploy`` does NOT strip outer quotes and does NOT
# apply ``$$`` interpolation - the value is passed verbatim to the
# container. Wrapping the value would leak literal quotes into
# ``$KEY`` ("\"password\""), breaking DB connect strings, URL parsers,
# and any ``int(os.environ['KEY'])`` consumer.
# So in swarm mode the filter returns the value unchanged, including a
# value carrying spaces: quoting one produced ``AGENT_PLATFORMS="{...}"``
# in the broker's container, which ``json.loads`` then read as a string
# rather than a mapping. A shell-sourced file needs the opposite and must
# not contain a space at all - join such a value on a comma.
#
# The mode is read from ``DEPLOYMENT_MODE`` in the rendering context.
# When the context is missing (e.g. unit tests calling the filter
# directly), the compose-style quote is used as a safe default - the same
# behaviour the filter had before the swarm split.
from __future__ import annotations
from typing import Any
import jinja2
_MISSING: Any = object()
def _quote_compose_style(value: Any) -> str:
if value is None:
return '""'
s = str(value)
s = s.replace("$", "$$")
s = s.replace("\\", "\\\\")
s = s.replace('"', '\\"')
return f'"{s}"'
def _passthrough_swarm(value: Any) -> str:
if value is None:
return ""
return str(value)
@jinja2.pass_context
def dotenv_quote(ctx: jinja2.runtime.Context, value: Any = _MISSING) -> str:
"""Dotenv-safe quote, switched on DEPLOYMENT_MODE from the render context.
Also callable directly from Python (unit tests, helpers) without a
Jinja context: in that case the only positional argument is the
value and compose-style quoting is used.
"""
if value is _MISSING:
return _quote_compose_style(ctx)
mode = ""
try:
mode = str(ctx.resolve("DEPLOYMENT_MODE") or "").strip()
except Exception: # pragma: no cover
mode = ""
if mode == "swarm":
return _passthrough_swarm(value)
return _quote_compose_style(value)
def _unquote_compose_style(value: str) -> str:
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value = value[1:-1]
value = value.replace('\\"', '"')
value = value.replace("\\\\", "\\")
return value.replace("$$", "$")
@jinja2.pass_context
def dotenv_unquote(ctx: jinja2.runtime.Context, value: Any = _MISSING) -> str:
"""Inverse of :func:`dotenv_quote`, for reading a value back out of a
rendered ``.env`` file.
Switched on the same ``DEPLOYMENT_MODE`` as the forward direction, so a
value written in one mode is read back in that mode. Without a context
the compose-style unquote applies, matching the forward default.
"""
if value is _MISSING:
return _unquote_compose_style(str(ctx))
text = "" if value is None else str(value)
mode = ""
try:
mode = str(ctx.resolve("DEPLOYMENT_MODE") or "").strip()
except Exception: # pragma: no cover
mode = ""
if mode == "swarm":
return text
return _unquote_compose_style(text)
def dotenv_value(content: Any, key: Any) -> str:
"""Return the raw right-hand side of ``key`` in a rendered ``.env``.
Args:
content: the file's text.
key: the variable name to read.
Returns:
The value still quoted the way the file holds it, or an empty string
when the file carries no such assignment. The caller unquotes it with
:func:`dotenv_unquote`, which knows the mode the file was written in.
"""
prefix = f"{key}="
for line in str(content or "").splitlines():
stripped = line.strip()
if stripped.startswith(prefix):
return stripped[len(prefix) :]
return ""
class FilterModule:
def filters(self) -> dict[str, Any]:
return {
"dotenv_quote": dotenv_quote,
"dotenv_unquote": dotenv_unquote,
"dotenv_value": dotenv_value,
}