mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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>
135 lines
4.2 KiB
Python
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,
|
|
}
|