Files
computer-playbook/cli/administration/deploy/development/coredns.py
T
kevinveenbirkenbachandClaude Opus 5 c7d162fa88 fix(coredns): derive the domain regex from INFINITO_DOMAIN when rendering
INFINITO_DOMAIN_RE carried an already escaped regex through the env
chain: EnvBuilder.setdefault prefers a value already in the process env,
quote_for_dotenv escapes every backslash, and load.sh unescapes it again.
One inherited value with a doubled backslash was enough to write
"infinito\\\\.test" into .env, the rendered Corefile then matched a
literal backslash, and getent hosts test.infinito.test failed with rc 2.

The renderer now substitutes DOMAIN_RE = re.escape(INFINITO_DOMAIN) at
render time, so no backslash travels through the env or .env at all. The
template placeholder is renamed because it is a derived render value, not
an env key, and INFINITO_DOMAIN_RE is removed from default.env and the
passthrough keys, leaving INFINITO_DOMAIN as the single source.

A renderer test renders the real template and requires a single-escaped
match with no doubled backslash and no leftover placeholder. Verified
live: the Corefile renders ^infinito\.test\.$, test.infinito.test
resolves to 172.30.0.10 and test.infinitoXtest does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:53:19 +02:00

143 lines
5.0 KiB
Python

from __future__ import annotations
import itertools
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from string import Template
from utils.cache.files import read_text
from utils.env.parser import parse_static_env
@dataclass(frozen=True)
class CoreDNSCorefileRenderer:
"""
Render compose/coredns/Corefile from compose/coredns/Corefile.tmpl.
What this does:
- Reads variables from env file (default: .env, generated from default.env)
- Substitutes ``${VAR}`` placeholders into the Corefile template
- Writes the output atomically (tmp -> rename)
- Optionally prints a preview of the first N lines
Substitution runs in-process rather than through envsubst: the act runner and
the nested deploy containers do not all ship gettext, and a missing binary
turned a two-variable substitution into a failed deploy.
Hard guarantees:
- Fails if template/env files are missing
- Fails if output path exists and is a directory
- Fails if output directory cannot be created or is not writable
- Fails if rendered file is empty
"""
repo_root: Path
env_filename: str = ".env"
template_relpath: str = "compose/coredns/Corefile.tmpl"
output_relpath: str = "compose/coredns/Corefile"
def _log(self, msg: str) -> None:
print(f"[coredns-corefile] {msg}")
def _paths(self) -> tuple[Path, Path, Path]:
env_file = self.repo_root / self.env_filename
tmpl_file = self.repo_root / self.template_relpath
out_file = self.repo_root / self.output_relpath
return env_file, tmpl_file, out_file
def _require_file(self, path: Path, *, label: str) -> None:
if not path.exists():
raise RuntimeError(f"{label} not found: {path}")
if not path.is_file():
raise RuntimeError(f"{label} is not a file: {path}")
def _ensure_output_parent(self, out_file: Path) -> None:
parent = out_file.parent
if parent.exists() and not parent.is_dir():
raise RuntimeError(f"Output parent is not a directory: {parent}")
if not parent.exists():
self._log(f"Creating output directory: {parent}")
try:
parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise RuntimeError(
f"Failed to create output directory: {parent}"
) from exc
if not os.access(parent, os.W_OK):
raise RuntimeError(f"Output directory is not writable: {parent}")
def _require_output_target(self, out_file: Path) -> None:
if out_file.exists() and out_file.is_dir():
raise RuntimeError(f"Output path is a directory: {out_file}")
def _load_env_file(self, env_file: Path) -> dict[str, str]:
self._log(f"Loading env file: {env_file}")
env = dict(os.environ)
parsed = parse_static_env(env_file)
env.update(parsed)
self._log(f"Loaded {len(parsed)} variables from env file")
return env
def _preview(self, path: Path, *, max_lines: int) -> None:
if not path.exists() or not path.is_file():
self._log(f"Preview skipped: {path}")
return
self._log(f"Preview (first {max_lines} lines): {path}")
with path.open("r", encoding="utf-8", errors="replace") as f:
for line in itertools.islice(f, max_lines):
self._log(f" {line.rstrip()}")
def render(self, *, show_preview: bool = True, preview_lines: int = 25) -> Path:
env_file, tmpl_file, out_file = self._paths()
self._log(f"Repo root : {self.repo_root}")
self._log(f"Template : {tmpl_file}")
self._log(f"Output : {out_file}")
self._require_file(env_file, label="env file")
self._require_file(tmpl_file, label="template file")
self._require_output_target(out_file)
self._ensure_output_parent(out_file)
env = self._load_env_file(env_file)
fd, tmp_name = tempfile.mkstemp(
dir=out_file.parent, prefix=out_file.name + ".", suffix=".tmp"
)
tmp_file = Path(tmp_name)
self._log("Rendering Corefile (atomic write)")
try:
rendered = Template(read_text(tmpl_file)).safe_substitute(
env, DOMAIN_RE=re.escape(env["INFINITO_DOMAIN"])
)
with os.fdopen(fd, "w", encoding="utf-8") as fout:
fout.write(rendered)
size = tmp_file.stat().st_size
if size == 0:
raise RuntimeError(f"Rendered Corefile is empty: {tmp_file}")
tmp_file.chmod(0o644)
tmp_file.replace(out_file)
except OSError as exc:
raise RuntimeError(
f"Failed to write temporary Corefile: {tmp_file}"
) from exc
finally:
tmp_file.unlink(missing_ok=True)
self._log(f"Rendered successfully ({size} bytes)")
if show_preview:
self._preview(out_file, max_lines=int(preview_lines))
return out_file