493 lines
17 KiB
Python
Executable File
493 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")
|
|
|
|
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": "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": "kubernetes",
|
|
"managed_by": "Argo CD / apps/heimdall",
|
|
"local": f"http://{traefik_ip}/",
|
|
"public": f"https://heimdall.{domain}/",
|
|
"docs": "apps/heimdall/",
|
|
},
|
|
{
|
|
"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())
|