Files
computer-playbook/plugins/filter/dotenv.py
T
kevinveenbirkenbachandClaude Opus 5 8e535f2ccf fix(env): hand a swarm env value to the container verbatim
_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>
2026-09-24 13:40:10 +02:00

80 lines
2.4 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)
class FilterModule:
def filters(self) -> dict[str, Any]:
return {
"dotenv_quote": dotenv_quote,
}