fix(coredns): substitute the Corefile in-process instead of shelling to envsubst

Two workspace jobs of run 33414875521 died the same way, on debian and on fedora:

    RuntimeError: envsubst not found. Install gettext-base (Ubuntu/Debian) or gettext (Arch).
    make: *** [Makefile:252: compose-up] Error 1
    make: *** [Makefile:778: swarm-zombie] Error 1

swarm-zombie runs the deploy with INFINITO_APP_DISCOVERY_RUNNER=host, so the README
replay step calls compose-up on the act runner, and that image ships no gettext. A
two-variable substitution took the whole deploy down with it, and the failure reads
like a broken host rather than a missing package.

string.Template.safe_substitute handles ${VAR} exactly as envsubst does for this
template, including the four bare `$` that end the match lines -- both leave them
alone. The rendered Corefile is byte-identical, checked over three re-renders, and
getent resolves inside the deploy container afterwards.

The concurrency property from f3826589f2 is untouched: the render still writes into
the descriptor from tempfile.mkstemp and lands through an atomic replace. The read
goes through utils.cache.files.read_text, as the uncached-read lint requires.

Nothing else in the replay step failed -- one traceback, one raise, one message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-31 21:52:40 +02:00
1 parent 83c1c729de
commit 00478b8ead
1 file changed
+12 -27
@@ -2,31 +2,34 @@ from __future__ import annotations
import itertools
import os
import shutil
import subprocess
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 using envsubst.
Render compose/coredns/Corefile from compose/coredns/Corefile.tmpl.
What this does:
- Reads variables from env file (default: .env, generated from default.env)
- Runs `envsubst` to substitute variables into the Corefile template
- 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 envsubst is missing
- Fails if rendered file is empty
"""
@@ -50,15 +53,6 @@ class CoreDNSCorefileRenderer:
if not path.is_file():
raise RuntimeError(f"{label} is not a file: {path}")
def _require_envsubst(self) -> str:
p = shutil.which("envsubst")
if not p:
raise RuntimeError(
"envsubst not found. Install gettext-base (Ubuntu/Debian) or gettext (Arch)."
)
self._log(f"Using envsubst: {p}")
return p
def _ensure_output_parent(self, out_file: Path) -> None:
parent = out_file.parent
@@ -110,7 +104,6 @@ class CoreDNSCorefileRenderer:
self._require_file(tmpl_file, label="template file")
self._require_output_target(out_file)
self._ensure_output_parent(out_file)
envsubst = self._require_envsubst()
env = self._load_env_file(env_file)
@@ -119,19 +112,11 @@ class CoreDNSCorefileRenderer:
)
tmp_file = Path(tmp_name)
self._log("Rendering Corefile via envsubst (atomic write)")
self._log("Rendering Corefile (atomic write)")
try:
with (
tmpl_file.open("r", encoding="utf-8") as fin,
os.fdopen(fd, "w", encoding="utf-8") as fout,
):
subprocess.check_call(
[envsubst],
stdin=fin,
stdout=fout,
env=env,
cwd=self.repo_root,
)
rendered = Template(read_text(tmpl_file)).safe_substitute(env)
with os.fdopen(fd, "w", encoding="utf-8") as fout:
fout.write(rendered)
size = tmp_file.stat().st_size
if size == 0: