Files
computer-playbook/plugins/filter/dotenv.py
T
kevinveenbirkenbach 3a74897a19 fix(env-templates): make dotenv_quote swarm-aware (compose strips, swarm preserves)
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.
2026-06-14 19:08:45 +02:00

79 lines
2.3 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.
#
# 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)
# Compose interpolates $VAR; $$ becomes literal $.
s = s.replace("$", "$$")
# Escape backslash first, then double quotes.
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:
# Called as `dotenv_quote(value)` outside a Jinja render.
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)
class FilterModule:
def filters(self) -> dict[str, Any]:
return {
"dotenv_quote": dotenv_quote,
}