diff --git a/README.md b/README.md index a260583..fe83d78 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ If you only read one section, read **[Source-of-truth boundaries](docs/source-of - Terraform workflows (brownfield import/reconciliation): [docs/terraform-workflows.md](docs/terraform-workflows.md) - Infrastructure inventory intent and outputs: [docs/infrastructure-inventory.md](docs/infrastructure-inventory.md) - Dynu DNS read-only inventory workflow: [docs/dynu-dns-inventory.md](docs/dynu-dns-inventory.md) +- Generated host topology doc: [docs/generated/host-topology.md](docs/generated/host-topology.md) - Ansible bootstrap workflows: [docs/ansible-workflows.md](docs/ansible-workflows.md) - Deployment prerequisites and secrets setup: [docs/deployment-prerequisites.md](docs/deployment-prerequisites.md) - Secrets inventory: [docs/security-secrets.md](docs/security-secrets.md) diff --git a/docs/architecture.md b/docs/architecture.md index bae9f09..5cc4c6b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,6 +88,7 @@ Use architecture docs together with: - [docs/source-of-truth.md](source-of-truth.md) - [docs/terraform-workflows.md](terraform-workflows.md) - [docs/infrastructure-inventory.md](infrastructure-inventory.md) +- [docs/generated/host-topology.md](generated/host-topology.md) ## Notes on runtime vs declared state diff --git a/docs/generated/host-topology.md b/docs/generated/host-topology.md new file mode 100644 index 0000000..87a6653 --- /dev/null +++ b/docs/generated/host-topology.md @@ -0,0 +1,38 @@ +# Host Topology + +> Generated by `scripts/docs/generate_host_topology.py` on 2026-05-12T18:32:09+00:00. + +## Topology Diagram + +```mermaid +flowchart TD + phys_pve["pve\nphysical"] + phys_raspberrypi["raspberrypi\nphysical"] + virt_docker["docker\nvirtual"] + phys_pve --> virt_docker + virt_nix_cache["nix-cache\nvirtual"] + phys_pve --> virt_nix_cache + virt_pbs["pbs\nvirtual"] + phys_pve --> virt_pbs + virt_pihole["pihole\nvirtual"] + phys_pve --> virt_pihole + virt_server_nixos["server-nixos\nvirtual"] + phys_pve --> virt_server_nixos +``` + +## Physical Hosts + +| Name | Type | Role | Management | OS | Hypervisor | Location | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| pve | physical | proxmox | pve.sweet.home | debian | proxmox | home | Primary Proxmox VE host | +| raspberrypi | physical | edge | raspberrypi.tail13f623.ts.net | debian | | riverglades | Raspberry Pi host | + +## Virtual Hosts + +| Name | Type | Role | Parent/Node | Management | OS | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| docker | virtual | docker-host | pve | | linux | Primary Docker VM | +| nix-cache | virtual | cache | pve | | linux | Nix binary cache VM | +| pbs | virtual | backup | pve | | linux | Proxmox Backup Server VM | +| pihole | virtual | dns | pve | | linux | DNS filtering VM | +| server-nixos | virtual | nixos-server | pve | | nixos | General-purpose NixOS VM | diff --git a/docs/infrastructure-inventory.md b/docs/infrastructure-inventory.md index 89eb0ea..53c2fef 100644 --- a/docs/infrastructure-inventory.md +++ b/docs/infrastructure-inventory.md @@ -61,6 +61,7 @@ When adding Terraform outputs for documentation/tooling: ## Limitations today +- Generated host topology document: `docs/generated/host-topology.md` (via `scripts/docs/build_host_topology.sh`). - No full generated inventory document pipeline is present yet. - Some Terraform files still include generated boilerplate comments requiring ongoing cleanup. - Ansible is currently a bootstrap inventory/configuration layer and is not authoritative for full operations yet. diff --git a/infrastructure/terraform/proxmox/outputs.tf b/infrastructure/terraform/proxmox/outputs.tf index 4233b5c..f8e8266 100644 --- a/infrastructure/terraform/proxmox/outputs.tf +++ b/infrastructure/terraform/proxmox/outputs.tf @@ -13,10 +13,16 @@ output "physical_hosts" { value = local.physical_hosts } +output "virtual_hosts" { + description = "Virtual host/VM inventory used for documentation" + value = local.virtual_hosts +} + output "infrastructure_inventory" { description = "Combined infrastructure inventory" value = { physical_hosts = local.physical_hosts + virtual_hosts = local.virtual_hosts } } diff --git a/infrastructure/terraform/proxmox/pve.tf b/infrastructure/terraform/proxmox/pve.tf index a490510..8a05a11 100644 --- a/infrastructure/terraform/proxmox/pve.tf +++ b/infrastructure/terraform/proxmox/pve.tf @@ -21,4 +21,59 @@ locals { notes = "Raspberry Pi host" } } + + # Virtual host inventory for documentation output. This is intentionally + # concise and shaped for docs tooling (not a full provider object dump). + virtual_hosts = { + docker = { + name = "docker" + type = "virtual" + role = "docker-host" + proxmox_node = "pve" + vm_id = 103 + management_ip = "" + os_family = "linux" + notes = "Primary Docker VM" + } + server_nixos = { + name = "server-nixos" + type = "virtual" + role = "nixos-server" + proxmox_node = "pve" + vm_id = 104 + management_ip = "" + os_family = "nixos" + notes = "General-purpose NixOS VM" + } + nix_cache = { + name = "nix-cache" + type = "virtual" + role = "cache" + proxmox_node = "pve" + vm_id = 105 + management_ip = "" + os_family = "linux" + notes = "Nix binary cache VM" + } + pbs = { + name = "pbs" + type = "virtual" + role = "backup" + proxmox_node = "pve" + vm_id = 106 + management_ip = "" + os_family = "linux" + notes = "Proxmox Backup Server VM" + } + pihole = { + name = "pihole" + type = "virtual" + role = "dns" + proxmox_node = "pve" + vm_id = 108 + management_ip = "" + os_family = "linux" + notes = "DNS filtering VM" + } + } } diff --git a/scripts/docs/build_host_topology.sh b/scripts/docs/build_host_topology.sh new file mode 100755 index 0000000..b9248d0 --- /dev/null +++ b/scripts/docs/build_host_topology.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +inv_dir="${repo_root}/infrastructure/terraform/proxmox" +json_out="${repo_root}/data/terraform/proxmox-inventory.json" +md_out="${repo_root}/docs/generated/host-topology.md" + +mkdir -p "$(dirname "${json_out}")" "$(dirname "${md_out}")" + +( + cd "${inv_dir}" + terraform output -json infrastructure_inventory > "${json_out}" +) + +python3 "${repo_root}/scripts/docs/generate_host_topology.py" \ + --input "${json_out}" \ + --output "${md_out}" + +echo "Generated: ${md_out}" diff --git a/scripts/docs/generate_host_topology.py b/scripts/docs/generate_host_topology.py new file mode 100755 index 0000000..857f707 --- /dev/null +++ b/scripts/docs/generate_host_topology.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Generate host topology Markdown/Mermaid from Terraform inventory JSON.""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +PARENT_KEYS = [ + "node", + "node_name", + "host", + "physical_host", + "hypervisor_host", + "proxmox_node", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate Markdown topology from terraform output JSON" + ) + parser.add_argument("--input", required=True, help="Input JSON path") + parser.add_argument("--output", required=True, help="Output Markdown path") + return parser.parse_args() + + +def load_inventory(raw: dict[str, Any]) -> dict[str, Any]: + payload = raw.get("value", raw) + if not isinstance(payload, dict): + raise ValueError("Inventory payload must be an object") + return payload + + +def infer_name(key: str, item: dict[str, Any]) -> str: + for candidate in ("name", "hostname", "vm_name", "id"): + value = item.get(candidate) + if value: + return str(value) + return key + + +def to_records(host_map: Any) -> list[dict[str, Any]]: + if isinstance(host_map, dict): + records: list[dict[str, Any]] = [] + for key, val in host_map.items(): + if isinstance(val, dict): + rec = dict(val) + rec.setdefault("_key", str(key)) + rec.setdefault("name", infer_name(str(key), rec)) + records.append(rec) + return sorted(records, key=lambda x: str(x.get("name", "")).lower()) + return [] + + +def escape_cell(value: Any) -> str: + if value is None: + return "" + text = str(value).replace("|", "\\|").replace("\n", "
") + return text + + +def get_first(item: dict[str, Any], *keys: str) -> str: + for key in keys: + value = item.get(key) + if value is not None and value != "": + return str(value) + return "" + + +def detect_virtual_hosts(inv: dict[str, Any]) -> list[dict[str, Any]]: + virtual: list[dict[str, Any]] = [] + for key in ("virtual_hosts", "vms"): + virtual.extend(to_records(inv.get(key, {}))) + return sorted(virtual, key=lambda x: str(x.get("name", "")).lower()) + + +def build_mermaid(physical: list[dict[str, Any]], virtual: list[dict[str, Any]]) -> list[str]: + lines = ["```mermaid", "flowchart TD"] + for p in physical: + pname = get_first(p, "name", "hostname", "_key") + pid = f"phys_{pname.lower().replace('-', '_').replace(' ', '_')}" + lines.append(f' {pid}["{pname}\\nphysical"]') + + for v in virtual: + vname = get_first(v, "name", "hostname", "_key") + vid = f"virt_{vname.lower().replace('-', '_').replace(' ', '_')}" + parent = "" + for k in PARENT_KEYS: + parent = get_first(v, k) + if parent: + break + + lines.append(f' {vid}["{vname}\\nvirtual"]') + if parent: + pid = f"phys_{parent.lower().replace('-', '_').replace(' ', '_')}" + lines.append(f" {pid} --> {vid}") + + lines.append("```") + return lines + + +def build_table(headers: list[str], rows: list[list[str]]) -> list[str]: + out = ["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"] + for row in rows: + out.append("| " + " | ".join(escape_cell(cell) for cell in row) + " |") + return out + + +def main() -> int: + args = parse_args() + raw = json.loads(Path(args.input).read_text(encoding="utf-8")) + inv = load_inventory(raw) + + physical = to_records(inv.get("physical_hosts", {})) + virtual = detect_virtual_hosts(inv) + + now = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + lines: list[str] = [ + "# Host Topology", + "", + f"> Generated by `scripts/docs/generate_host_topology.py` on {now}.", + "", + "## Topology Diagram", + "", + ] + lines.extend(build_mermaid(physical, virtual)) + lines.extend(["", "## Physical Hosts", ""]) + + physical_rows = [ + [ + get_first(p, "name", "hostname", "_key"), + get_first(p, "type"), + get_first(p, "role"), + get_first(p, "management", "management_ip"), + get_first(p, "os", "os_family"), + get_first(p, "hypervisor"), + get_first(p, "location"), + get_first(p, "notes"), + ] + for p in physical + ] + lines.extend( + build_table( + ["Name", "Type", "Role", "Management", "OS", "Hypervisor", "Location", "Notes"], + physical_rows, + ) + ) + + lines.extend(["", "## Virtual Hosts", ""]) + if virtual: + virtual_rows = [] + for v in virtual: + parent = "" + for k in PARENT_KEYS: + parent = get_first(v, k) + if parent: + break + virtual_rows.append( + [ + get_first(v, "name", "hostname", "_key"), + get_first(v, "type"), + get_first(v, "role"), + parent, + get_first(v, "management", "management_ip", "ip"), + get_first(v, "os", "os_family"), + get_first(v, "notes"), + ] + ) + lines.extend( + build_table( + ["Name", "Type", "Role", "Parent/Node", "Management", "OS", "Notes"], + virtual_rows, + ) + ) + else: + lines.append("No VM/virtual host data found in the current Terraform inventory output.") + + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())