Add optional Dynu DNS inventory to architecture doc generator
This commit is contained in:
@@ -120,6 +120,21 @@ flowchart TB
|
|||||||
|
|
||||||
For request-flow and network detail, see [docs/architecture.md](docs/architecture.md).
|
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
|
## Codex setup and maintenance scripts
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--diagrams-dir", default="docs/diagrams", help="Diagram output directory.")
|
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("--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("--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("--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("--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.")
|
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
|
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]:
|
def merged_labels(target: dict[str, Any]) -> dict[str, str]:
|
||||||
discovered = target.get("discovered_labels") or {}
|
discovered = target.get("discovered_labels") or {}
|
||||||
labels = target.get("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)
|
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 []}))
|
summaries = summarize_targets(targets, normalize_targets({"targets": inventory.get("unhealthy_targets") or []}))
|
||||||
notes = inventory.get("notes") or []
|
notes = inventory.get("notes") or []
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"## Runtime visibility from Prometheus",
|
"## Runtime and infrastructure inventory",
|
||||||
"",
|
"",
|
||||||
GENERATED_BEGIN,
|
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"]],
|
[[job, str(data["active"]), str(data["unhealthy"])] for job, data in summaries["by_job"].items()] or [["none", "0", "0"]],
|
||||||
),
|
),
|
||||||
"",
|
"",
|
||||||
"### Data sources",
|
|
||||||
"",
|
|
||||||
"- `docs/runtime/prometheus-inventory.json` (normalized runtime export)",
|
|
||||||
"- Prometheus scrape metadata (`targets` + label sets)",
|
|
||||||
"- Existing repository architecture docs for declared topology",
|
|
||||||
]
|
]
|
||||||
|
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:
|
if notes:
|
||||||
lines.extend(["", "### Notes from inventory", ""])
|
lines.extend(["", "### Notes from inventory", ""])
|
||||||
for note in notes:
|
for note in notes:
|
||||||
@@ -291,7 +436,9 @@ def upsert_generated_section(path: Path, section_markdown: str, dry_run: bool, v
|
|||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
replacement = "\n".join(
|
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)
|
updated = pattern.sub(replacement.strip(), existing)
|
||||||
else:
|
else:
|
||||||
@@ -384,6 +531,7 @@ def main() -> int:
|
|||||||
inventory_path = Path(args.inventory_file)
|
inventory_path = Path(args.inventory_file)
|
||||||
docs_dir = Path(args.docs_dir)
|
docs_dir = Path(args.docs_dir)
|
||||||
diagrams_dir = Path(args.diagrams_dir)
|
diagrams_dir = Path(args.diagrams_dir)
|
||||||
|
dynu_inventory_path = Path(args.dynu_dns_inventory_file)
|
||||||
|
|
||||||
inventory = load_json(inventory_path)
|
inventory = load_json(inventory_path)
|
||||||
targets = normalize_targets(inventory)
|
targets = normalize_targets(inventory)
|
||||||
@@ -394,7 +542,25 @@ def main() -> int:
|
|||||||
|
|
||||||
coverage_md = render_monitoring_coverage(inventory, targets)
|
coverage_md = render_monitoring_coverage(inventory, targets)
|
||||||
network_md = render_network_doc(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)
|
monitoring_mmd = render_monitoring_mermaid(targets)
|
||||||
architecture_mmd = render_architecture_mermaid(targets)
|
architecture_mmd = render_architecture_mermaid(targets)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user