Files
computer-playbook/cli/administration/deploy/development/coredns.py
T
kevinveenbirkenbachandClaude Opus 4.7 a26bfc4e94 refactor(cli): reorg admin/contributing tree + per-folder help system
Module moves (with imports, paths, argv literals, scripts, docs, and
parents[N] formulas updated in lockstep):

- cli/deploy             -> cli/administration/deploy
- cli/validate/inventory -> cli/administration/inventory/validate
- cli/vault              -> cli/administration/secrets/vault
- cli/administration/inventory/encrypt -> cli/administration/secrets/seal
- cli/mirror             -> cli/contributing/mirror

The new cli/administration/secrets cluster collocates the whole-file
ansible-vault wrapper next to the inventory-aware per-field sealer.
The user-facing CLI subcommand paths follow the package layout, e.g.
"infinito administration deploy dedicated <inv>" and
"infinito administration inventory validate <inv>".

Help system redesign in cli/core/{discovery,help,app}.py:

- Discovery now distinguishes runnable commands (directories with
  __main__.py) from category folders (directories that only group
  further commands). Empty support packages are filtered out.
- Each category folder MAY carry a README.md; its H1 heading is used
  as the title and its first paragraph as the one-line description in
  the help output.
- "infinito" / "infinito --help" prints only the top-level entries
  (one level deep). "infinito <category>" navigates into a folder and
  shows its sub-folders and commands one level deep, with the README
  title as the page heading and the invocation as a dim subtitle.
- New "--tree [N]" flag walks the tree under the (optional) path
  prefix recursively. N caps the depth (1-based); omit N for an
  unbounded walk. The output uses Unicode tree connectors, an emoji
  per entry (folder for categories, gear for commands), a globally
  aligned description column, and multi-line wrapped descriptions
  with tree-continuity bars on continuation lines.
- Category and command listings use the full invocation
  ("infinito meta callorder") rather than the bare leaf name, so the
  output is copy-pasteable.

Convention doc + 17 CLI READMEs:

- New docs/contributing/artefact/files/cli/readme_md.md describes the
  H1+paragraph layout the help system reads, and how it differs from
  role/docs READMEs.
- README.md added for cli/{administration, build, contributing, meta}
  and their immediate sub-folders, plus cli/meta/roles/applications
  and its resolution sub-folder. Each follows the "title with trailing
  emoji + single descriptive paragraph" convention.

Mirror resolver/sync description strings (the help system reads them
from argparse --help output) added so they no longer surface as
"options:" in the global index. cli/mirror/cleanup already had a
description and is unchanged.

Fixes uncovered by the move:

- import unittest.mock added to four dedicated-deploy tests so
  unittest.mock.patch resolves under stdlib unittest discovery
  (Python 3.13 no longer surfaces unittest.mock through "import
  unittest" alone).
- parents[N] index in the new
  cli/administration/inventory/validate/__init__.py corrected from
  3 to 4 to match the deeper layout.
- test_paths.INVENTORY_VALIDATOR_PATH assertion and test_runner
  substring assertions updated to match the new validator path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:17:09 +02:00

161 lines
5.4 KiB
Python

from __future__ import annotations
import itertools
import os
import shutil
import subprocess
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
@dataclass(frozen=True)
class CoreDNSCorefileRenderer:
"""
Render compose/coredns/Corefile from compose/coredns/Corefile.tmpl using envsubst.
What this does:
- Reads variables from env file (default: env.ci)
- Runs `envsubst` to substitute variables into the Corefile template
- Writes the output atomically (tmp -> rename)
- Optionally prints a preview of the first N lines
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
"""
repo_root: Path
env_filename: str = "env.ci"
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 _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
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)
loaded = 0
with env_file.open("r", encoding="utf-8") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k] = v
loaded += 1
self._log(f"Loaded {loaded} 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)
envsubst = self._require_envsubst()
env = self._load_env_file(env_file)
tmp_file = out_file.with_suffix(out_file.suffix + ".tmp")
self._log("Rendering Corefile via envsubst (atomic write)")
try:
with (
tmpl_file.open("r", encoding="utf-8") as fin,
tmp_file.open("w", encoding="utf-8") as fout,
):
subprocess.check_call(
[envsubst],
stdin=fin,
stdout=fout,
env=env,
cwd=self.repo_root,
)
except OSError as exc:
raise RuntimeError(
f"Failed to write temporary Corefile: {tmp_file}"
) from exc
if not tmp_file.exists():
raise RuntimeError("envsubst did not produce an output file")
size = tmp_file.stat().st_size
if size == 0:
raise RuntimeError(f"Rendered Corefile is empty: {tmp_file}")
tmp_file.replace(out_file)
self._log(f"Rendered successfully ({size} bytes)")
if show_preview:
self._preview(out_file, max_lines=int(preview_lines))
return out_file