Add optional Dynu DNS inventory to architecture doc generator

This commit is contained in:
beatz174-bit
2026-05-13 07:31:20 +10:00
parent 9f98101c5d
commit 5f10d0366e
2 changed files with 190 additions and 9 deletions
+15
View File
@@ -120,6 +120,21 @@ flowchart TB
For request-flow and network detail, see [docs/architecture.md](docs/architecture.md).
### Regenerating architecture docs (Prometheus + Dynu DNS)
```bash
# Refresh Dynu live inventory and generated resources/import helpers
cd infrastructure/terraform/dynu
terraform apply -refresh-only
python3 scripts/generate-brownfield-records.py --overwrite
# Regenerate architecture docs from Prometheus + Dynu inventory
cd ../../..
python3 scripts/render_prometheus_docs.py \
--inventory-file docs/runtime/prometheus-inventory.json \
--dynu-dns-inventory-file infrastructure/terraform/dynu/generated/dynu_dns_records_inventory.json
```
---
## Codex setup and maintenance scripts
+170 -4
View File
@@ -26,6 +26,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--diagrams-dir", default="docs/diagrams", help="Diagram output directory.")
parser.add_argument("--readme-file", default="README.md", help="README path for regeneration notes.")
parser.add_argument("--architecture-file", default="docs/architecture.md", help="Architecture markdown path.")
parser.add_argument(
"--dynu-dns-inventory-file",
default="infrastructure/terraform/dynu/generated/dynu_dns_records_inventory.json",
help="Path to Dynu DNS brownfield inventory JSON.",
)
parser.add_argument("--skip-dynu-dns", action="store_true", help="Skip Dynu DNS inventory loading/rendering.")
parser.add_argument("--network-file", default="docs/network.md", help="Network markdown path.")
parser.add_argument("--coverage-file", default="docs/monitoring-coverage.md", help="Coverage markdown path.")
parser.add_argument("--dry-run", action="store_true", help="Print changes instead of writing files.")
@@ -41,6 +47,109 @@ def load_json(path: Path) -> dict[str, Any]:
return data
def load_optional_json(path: Path) -> Any | None:
if not path.exists():
return None
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def normalize_dynu_dns_records(payload: Any) -> list[dict[str, Any]]:
records_payload = payload
if isinstance(payload, dict):
if isinstance(payload.get("records"), list):
records_payload = payload["records"]
elif isinstance(payload.get("value"), list):
records_payload = payload["value"]
if not isinstance(records_payload, list):
raise ValueError("Dynu DNS inventory must be a list or object with list field `records`/`value`.")
normalized: list[dict[str, Any]] = []
for record in records_payload:
if not isinstance(record, dict):
continue
normalized.append(
{
"id": record.get("id"),
"domain_id": record.get("domain_id"),
"domain_name": str(record.get("domain_name") or "unknown"),
"hostname": str(record.get("hostname") or "unknown"),
"node_name": str(record.get("node_name") or "unknown"),
"record_type": str(record.get("record_type") or "unknown"),
"content": record.get("content"),
"state": record.get("state") if "state" in record else record.get("enabled"),
"ttl": record.get("ttl"),
"updated_on": record.get("updated_on"),
}
)
normalized.sort(
key=lambda r: (
str(r.get("domain_name") or ""),
str(r.get("hostname") or ""),
str(r.get("record_type") or ""),
str(r.get("id") or ""),
)
)
return normalized
def render_dynu_dns_architecture_section(records: list[dict[str, Any]], inventory_path: Path | None) -> str:
domains = sorted({r["domain_name"] for r in records if r["domain_name"] != "unknown"})
dynamic_count = 0
static_count = 0
disabled_count = 0
rows: list[list[str]] = []
for record in records:
rtype = str(record.get("record_type") or "").upper()
raw_content = record.get("content")
content = "" if raw_content is None else str(raw_content).strip()
is_dynamic = rtype in {"A", "AAAA"} and not content
mode = "dynamic" if is_dynamic else ("static" if content else ("record" if rtype not in {"A", "AAAA"} else "static"))
target = "dynamic" if is_dynamic else (content or ("unknown" if not content else content))
if is_dynamic:
dynamic_count += 1
else:
static_count += 1
enabled_value = record.get("state")
enabled_text = "unknown" if enabled_value is None else str(bool(enabled_value)).lower()
if enabled_value is False:
disabled_count += 1
rows.append(
[
str(record.get("hostname") or "unknown"),
rtype or "unknown",
target,
mode,
str(record.get("ttl") if record.get("ttl") is not None else "unknown"),
enabled_text,
str(record.get("id") if record.get("id") is not None else "unknown"),
str(record.get("updated_on") or "unknown"),
]
)
domain_text = ", ".join(domains) if domains else "unknown"
lines = [
"### Dynu DNS brownfield inventory",
"",
"Dynu DNS is managed as a brownfield reconciliation source. Terraform imports the root domain and individual DNS records into state, while generated configuration provides reviewable management intent.",
"",
f"- Inventory source: `{inventory_path}`" if inventory_path else "- Inventory source: `unknown`",
f"- Records observed: `{len(records)}`",
f"- Domains observed: `{domain_text}`",
f"- Dynamic A/AAAA records: `{dynamic_count}`",
f"- Static records: `{static_count}`",
f"- Disabled records: `{disabled_count}`",
"",
"#### DNS records",
"",
markdown_table(["hostname", "type", "target/content", "mode", "ttl", "enabled", "record id", "updated"], rows or [["none", "", "", "", "", "", "", ""]]),
"",
]
return "\n".join(lines)
def merged_labels(target: dict[str, Any]) -> dict[str, str]:
discovered = target.get("discovered_labels") or {}
labels = target.get("labels") or {}
@@ -244,12 +353,18 @@ def render_network_doc(inventory: dict[str, Any], targets: list[dict[str, Any]])
return "\n".join(lines)
def render_architecture_section(inventory: dict[str, Any], targets: list[dict[str, Any]]) -> str:
def render_architecture_section(
inventory: dict[str, Any],
targets: list[dict[str, Any]],
dynu_records: list[dict[str, Any]] | None = None,
dynu_inventory_path: Path | None = None,
dynu_inventory_missing: bool = False,
) -> str:
summaries = summarize_targets(targets, normalize_targets({"targets": inventory.get("unhealthy_targets") or []}))
notes = inventory.get("notes") or []
lines = [
"## Runtime visibility from Prometheus",
"## Runtime and infrastructure inventory",
"",
GENERATED_BEGIN,
"",
@@ -267,12 +382,42 @@ def render_architecture_section(inventory: dict[str, Any], targets: list[dict[st
[[job, str(data["active"]), str(data["unhealthy"])] for job, data in summaries["by_job"].items()] or [["none", "0", "0"]],
),
"",
]
if dynu_records is not None:
lines.extend(["", render_dynu_dns_architecture_section(dynu_records, dynu_inventory_path).rstrip(), ""])
elif dynu_inventory_missing:
lines.extend(
[
"### Dynu DNS brownfield inventory",
"",
f"Dynu DNS inventory was not found at `{dynu_inventory_path}`.",
"",
"Generate it with:",
"",
"```bash",
"cd infrastructure/terraform/dynu",
"python3 scripts/generate-brownfield-records.py --overwrite",
"```",
"",
]
)
lines.extend(
[
"### Data sources",
"",
"- `docs/runtime/prometheus-inventory.json` (normalized runtime export)",
]
)
if dynu_records is not None:
lines.append(f"- `{dynu_inventory_path}` (Dynu DNS brownfield inventory)")
elif dynu_inventory_missing:
lines.append("- Dynu DNS inventory not available; run the Dynu brownfield generator.")
lines.extend(
[
"- Prometheus scrape metadata (`targets` + label sets)",
"- Existing repository architecture docs for declared topology",
]
)
if notes:
lines.extend(["", "### Notes from inventory", ""])
for note in notes:
@@ -291,7 +436,9 @@ def upsert_generated_section(path: Path, section_markdown: str, dry_run: bool, v
re.DOTALL,
)
replacement = "\n".join(
line for line in section_body.splitlines() if line.strip() not in {"## Runtime visibility from Prometheus"}
line
for line in section_body.splitlines()
if line.strip() not in {"## Runtime visibility from Prometheus", "## Runtime and infrastructure inventory"}
)
updated = pattern.sub(replacement.strip(), existing)
else:
@@ -384,6 +531,7 @@ def main() -> int:
inventory_path = Path(args.inventory_file)
docs_dir = Path(args.docs_dir)
diagrams_dir = Path(args.diagrams_dir)
dynu_inventory_path = Path(args.dynu_dns_inventory_file)
inventory = load_json(inventory_path)
targets = normalize_targets(inventory)
@@ -394,7 +542,25 @@ def main() -> int:
coverage_md = render_monitoring_coverage(inventory, targets)
network_md = render_network_doc(inventory, targets)
architecture_section = render_architecture_section(inventory, targets)
dynu_records: list[dict[str, Any]] | None = None
dynu_inventory_missing = False
if not args.skip_dynu_dns:
try:
dynu_payload = load_optional_json(dynu_inventory_path)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in Dynu DNS inventory file {dynu_inventory_path}: {exc}") from exc
if dynu_payload is None:
dynu_inventory_missing = True
else:
dynu_records = normalize_dynu_dns_records(dynu_payload)
architecture_section = render_architecture_section(
inventory,
targets,
dynu_records=dynu_records,
dynu_inventory_path=dynu_inventory_path,
dynu_inventory_missing=dynu_inventory_missing,
)
monitoring_mmd = render_monitoring_mermaid(targets)
architecture_mmd = render_architecture_mermaid(targets)