my-homelab-configs/scripts/render-service-catalog

502 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""Render homelab service catalog artifacts from homelab.yml."""
from __future__ import annotations
import argparse
import html
import pathlib
import re
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
INVENTORY = REPO_ROOT / "homelab.yml"
DOC_OUTPUT = REPO_ROOT / "docs" / "service-catalog.md"
HTML_OUTPUT = REPO_ROOT / "apps" / "demos-static" / "public" / "homelab-catalog" / "index.html"
def parse_inventory(path: pathlib.Path) -> dict[str, object]:
values: dict[str, object] = {}
stack: list[tuple[int, str]] = []
current_list_path: str | None = None
pattern = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*):(?:\s*(.*?))?\s*$")
with path.open(encoding="utf-8") as handle:
for raw_line in handle:
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("- "):
if current_list_path:
if not isinstance(values.get(current_list_path), list):
values[current_list_path] = []
values[current_list_path].append(stripped[2:].strip().strip("\"'"))
continue
match = pattern.match(raw_line.rstrip("\n"))
if not match:
current_list_path = None
continue
indent = len(match.group(1))
key = match.group(2)
value = (match.group(3) or "").strip()
while stack and stack[-1][0] >= indent:
stack.pop()
path_key = ".".join([item[1] for item in stack] + [key])
if value == "":
stack.append((indent, key))
values.setdefault(path_key, {})
current_list_path = path_key
continue
if " #" in value:
value = value.split(" #", 1)[0].strip()
values[path_key] = value.strip("\"'")
current_list_path = None
return values
def value(values: dict[str, object], path: str, default: str = "") -> str:
raw_value = values.get(path, default)
if isinstance(raw_value, list):
return ", ".join(str(item) for item in raw_value)
if isinstance(raw_value, dict):
return default
return str(raw_value)
def host_ip(values: dict[str, object], host_name: str) -> str:
if host_name == "kubernetes":
return value(values, "services.traefik.load_balancer_ip")
return value(values, f"hosts.{host_name}.lan_ip")
def host_label(values: dict[str, object], host_name: str) -> str:
if host_name == "kubernetes":
return "Kubernetes"
ip = host_ip(values, host_name)
role = value(values, f"hosts.{host_name}.role")
if ip and role:
return f"{host_name} ({role}, {ip})"
if ip:
return f"{host_name} ({ip})"
return host_name
def service_rows(values: dict[str, object]) -> list[dict[str, str]]:
domain = value(values, "domain.base")
public_url = value(values, "domain.public_url")
debian_ip = value(values, "hosts.debian.lan_ip")
rpi_ip = value(values, "hosts.rpi4.lan_ip")
traefik_ip = value(values, "network.metallb.traefik_ip")
registry = value(values, "services.local_registry.endpoint")
heimdall_host = value(values, "services.heimdall.host", "debian")
rows = [
{
"name": "Website",
"host": "kubernetes",
"managed_by": "Argo CD / apps/website",
"local": f"http://{traefik_ip}/",
"public": public_url,
"docs": "apps/website/README.md",
},
{
"name": "Demo Apps",
"host": "kubernetes",
"managed_by": "Argo CD / apps/demos-static",
"local": f"http://{traefik_ip}/demo-apps/",
"public": f"https://demos.{domain}/",
"docs": "apps/demos-static/",
},
{
"name": "Service Catalog",
"host": "kubernetes",
"managed_by": "Generated from homelab.yml",
"local": f"http://{traefik_ip}/demo-apps/homelab-catalog/",
"public": f"https://demos.{domain}/homelab-catalog/",
"docs": "docs/service-catalog.md",
},
{
"name": "Gitea",
"host": value(values, "services.gitea.host", "debian"),
"managed_by": "Docker Compose / jeannie deploy-gitea",
"local": f"http://{debian_ip}:{value(values, 'services.gitea.http_port', '3000')}/",
"public": value(values, "services.gitea.root_url"),
"docs": "infra/gitea/README.md",
},
{
"name": "GitOps mirror",
"host": value(values, "services.gitops_mirror.host", "debian"),
"managed_by": "Bare Git repository",
"local": value(values, "services.gitops_mirror.path"),
"public": value(values, "services.gitops_mirror.ssh_url"),
"docs": "README.md#gitops-and-gitea-actions",
},
{
"name": "Container Registry",
"host": value(values, "services.local_registry.host", "debian"),
"managed_by": "Argo CD / apps/container-registry",
"local": f"http://{registry}/v2/_catalog",
"public": "",
"docs": "apps/container-registry/",
},
{
"name": "Traefik",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": public_url,
"docs": "README.md#loadbalancer-services",
},
{
"name": "Pi-hole",
"host": "rpi4",
"managed_by": "Docker Compose / jeannie rpi-services",
"local": f"http://{rpi_ip}:{value(values, 'services.rpi_dns.pihole_web_port', '8081')}/admin/",
"public": "",
"docs": "infra/rpi-services/README.md",
},
{
"name": "Unbound",
"host": "rpi4",
"managed_by": "Docker Compose / jeannie rpi-services",
"local": f"{rpi_ip}:53",
"public": "",
"docs": "infra/rpi-services/README.md",
},
{
"name": "Uptime Kuma",
"host": "rpi4",
"managed_by": "Docker Compose / jeannie rpi-services",
"local": f"http://{rpi_ip}:{value(values, 'services.rpi_dns.uptime_kuma_port', '3001')}/",
"public": "",
"docs": "infra/rpi-services/README.md",
},
{
"name": "Provisioning PXE/HTTP",
"host": "debian",
"managed_by": "OpenTofu / bootstrap/provisioning",
"local": f"http://{debian_ip}:{value(values, 'services.provisioning.http_port', '8088')}/",
"public": "",
"docs": "bootstrap/provisioning/README.md",
},
{
"name": "PXE Rescue Toolkit",
"host": "debian",
"managed_by": "OpenTofu / bootstrap/provisioning",
"local": value(values, "services.provisioning.rescue_url"),
"public": "",
"docs": "bootstrap/provisioning/README.md",
},
{
"name": "Ollama",
"host": "debian",
"managed_by": "Local Debian service",
"local": value(values, "services.ollama.url"),
"public": "",
"docs": "apps/website/ollama/README.md",
},
{
"name": "OCI Edge",
"host": "oci_edge",
"managed_by": "OpenTofu / bootstrap/edge",
"local": value(values, "hosts.oci_edge.install_dir"),
"public": public_url,
"docs": "docs/runbooks/edge-failures.md",
},
{
"name": "Heimdall",
"host": heimdall_host,
"managed_by": "Docker Compose / jeannie deploy-heimdall",
"local": f"http://{host_ip(values, heimdall_host)}:{value(values, 'services.heimdall.http_port', '8082')}/",
"public": f"https://heimdall.{domain}/",
"docs": "infra/heimdall/README.md",
},
{
"name": "n8n",
"host": "kubernetes",
"managed_by": "Argo CD / apps/n8n",
"local": f"http://{traefik_ip}/",
"public": f"https://n8n.{domain}/",
"docs": "apps/n8n/",
},
{
"name": "Argo CD",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": f"https://argocd.{domain}/",
"docs": "bootstrap/platform/",
},
{
"name": "Grafana",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": f"https://grafana.{domain}/",
"docs": "bootstrap/platform/",
},
{
"name": "Prometheus",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": f"https://prometheus.{domain}/",
"docs": "bootstrap/platform/",
},
{
"name": "Alertmanager",
"host": "kubernetes",
"managed_by": "OpenTofu / bootstrap/platform",
"local": f"http://{traefik_ip}/",
"public": f"https://alertmanager.{domain}/",
"docs": "bootstrap/platform/",
},
]
for service_name in [
"prowlarr",
"sonarr",
"radarr",
"qbittorrent",
"kapowarr",
"suwayomi",
"maintainerr",
"flaresolverr",
]:
display_name = {
"qbittorrent": "qBittorrent",
"suwayomi": "Suwayomi",
"maintainerr": "Maintainerr",
"flaresolverr": "FlareSolverr",
}.get(service_name, service_name.capitalize())
port = value(values, f"services.arr.{service_name}_port")
rows.append(
{
"name": display_name,
"host": value(values, "services.arr.host", "debian"),
"managed_by": "Docker Compose / infra/arr-stack",
"local": f"http://{debian_ip}:{port}/" if port else "",
"public": f"https://{service_name}.{domain}/",
"docs": "infra/arr-stack/README.md",
}
)
return rows
def host_rows(values: dict[str, object]) -> list[dict[str, str]]:
rows = []
for host in ["debian", "rpi4", "opi5_pimox", "oci_edge"]:
rows.append(
{
"name": host,
"role": value(values, f"hosts.{host}.role"),
"lan": value(values, f"hosts.{host}.lan_ip") or value(values, f"hosts.{host}.public_ip"),
"tailscale": value(values, f"hosts.{host}.tailscale_ip"),
"storage": value(values, f"hosts.{host}.docker_root")
or value(values, f"hosts.{host}.docker_nvme_root")
or value(values, f"hosts.{host}.worker_storage"),
}
)
return rows
def markdown_table(headers: list[str], rows: list[list[str]]) -> str:
lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
for row in rows:
lines.append("| " + " | ".join(cell.replace("\n", " ") for cell in row) + " |")
return "\n".join(lines)
def md_link(label: str, target: str) -> str:
if not target:
return ""
if target.startswith(("http://", "https://", "ssh://")):
return f"[{label}]({target})"
return f"`{target}`"
def render_markdown(values: dict[str, object]) -> str:
hosts = host_rows(values)
services = service_rows(values)
host_table = markdown_table(
["Host", "Role", "Address", "Tailscale", "Storage"],
[[row["name"], row["role"], row["lan"], row["tailscale"], row["storage"]] for row in hosts],
)
service_table = markdown_table(
["Service", "Host", "Managed By", "Local", "Public", "Docs"],
[
[
row["name"],
host_label(values, row["host"]),
row["managed_by"],
md_link("local", row["local"]),
md_link("public", row["public"]),
md_link("docs", row["docs"]),
]
for row in services
],
)
return f"""# Homelab Service Catalog
<!-- Generated by scripts/render-service-catalog from homelab.yml. Do not edit by hand. -->
This catalog is the human-readable view of the non-secret homelab inventory.
Update `homelab.yml`, then run `scripts/render-service-catalog`.
## Hosts
{host_table}
## Services
{service_table}
## Focused Checks
- `./jeannie status`
- `./jeannie preflight`
- `./jeannie doctor-edge`
- `./jeannie doctor-gitea`
- `./jeannie doctor-rpi`
- `./jeannie doctor-cluster`
"""
def html_link(target: str) -> str:
if not target:
return '<span class="muted">not exposed</span>'
escaped = html.escape(target)
if target.startswith(("http://", "https://", "ssh://")):
return f'<a href="{escaped}">{escaped}</a>'
return f"<code>{escaped}</code>"
def render_html(values: dict[str, object]) -> str:
hosts = host_rows(values)
services = service_rows(values)
host_cards = "\n".join(
f""" <article class="catalog-card service-host">
<span>{html.escape(row["role"])}</span>
<h2>{html.escape(row["name"])}</h2>
<p><strong>Address:</strong> {html.escape(row["lan"] or "n/a")}</p>
<p><strong>Tailscale:</strong> {html.escape(row["tailscale"] or "n/a")}</p>
<p><strong>Storage:</strong> {html.escape(row["storage"] or "n/a")}</p>
</article>"""
for row in hosts
)
service_rows_html = "\n".join(
f""" <tr>
<td>{html.escape(row["name"])}</td>
<td>{html.escape(host_label(values, row["host"]))}</td>
<td>{html.escape(row["managed_by"])}</td>
<td>{html_link(row["local"])}</td>
<td>{html_link(row["public"])}</td>
<td>{html_link(row["docs"])}</td>
</tr>"""
for row in services
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab Service Catalog</title>
<link rel="stylesheet" href="../shared.css">
</head>
<body class="theme-dark">
<main class="shell catalog-shell">
<nav class="topline">
<a href="../">All demos</a>
<a href="/demos.php">Website catalog</a>
</nav>
<section class="hero catalog-hero">
<p class="kicker">Inventory view</p>
<h1>Homelab Service Catalog</h1>
<p>Generated from <code>homelab.yml</code>. Use this as the operational map for hosts, ports, public routes, local URLs, ownership, and runbooks.</p>
</section>
<section class="grid host-grid" aria-label="Homelab hosts">
{host_cards}
</section>
<section class="panel service-table-panel" aria-label="Homelab services">
<div class="table-scroll">
<table class="service-table">
<thead>
<tr>
<th>Service</th>
<th>Host</th>
<th>Managed by</th>
<th>Local</th>
<th>Public</th>
<th>Docs</th>
</tr>
</thead>
<tbody>
{service_rows_html}
</tbody>
</table>
</div>
</section>
<section class="panel command-panel">
<h2>Focused Checks</h2>
<p><code>./jeannie status</code> <code>./jeannie preflight</code> <code>./jeannie doctor-edge</code> <code>./jeannie doctor-gitea</code> <code>./jeannie doctor-rpi</code> <code>./jeannie doctor-cluster</code></p>
</section>
</main>
<script src="../theme.js"></script>
</body>
</html>
"""
def write_if_changed(path: pathlib.Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def check_file(path: pathlib.Path, content: str) -> bool:
if not path.exists():
return False
return path.read_text(encoding="utf-8") == content
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="fail if generated catalog artifacts are stale")
args = parser.parse_args()
values = parse_inventory(INVENTORY)
markdown = render_markdown(values)
catalog_html = render_html(values)
if args.check:
stale = []
if not check_file(DOC_OUTPUT, markdown):
stale.append(str(DOC_OUTPUT.relative_to(REPO_ROOT)))
if not check_file(HTML_OUTPUT, catalog_html):
stale.append(str(HTML_OUTPUT.relative_to(REPO_ROOT)))
if stale:
print("Generated service catalog artifacts are stale:", file=sys.stderr)
for path in stale:
print(f" {path}", file=sys.stderr)
return 1
print("service catalog is current")
return 0
write_if_changed(DOC_OUTPUT, markdown)
write_if_changed(HTML_OUTPUT, catalog_html)
print(f"Rendered {DOC_OUTPUT.relative_to(REPO_ROOT)}")
print(f"Rendered {HTML_OUTPUT.relative_to(REPO_ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())