Merge pull request #58 from beatz174-bit/codex/add-host-topology-documentation-generator
Include VM-to-host mappings in Proxmox inventory output
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -67,6 +67,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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+20
@@ -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}"
|
||||
Executable
+189
@@ -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", "<br>")
|
||||
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())
|
||||
Reference in New Issue
Block a user