#!/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": "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": "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 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 'not exposed' escaped = html.escape(target) if target.startswith(("http://", "https://", "ssh://")): return f'{escaped}' return f"{escaped}" def render_html(values: dict[str, object]) -> str: hosts = host_rows(values) services = service_rows(values) host_cards = "\n".join( f"""
{html.escape(row["role"])}

{html.escape(row["name"])}

Address: {html.escape(row["lan"] or "n/a")}

Tailscale: {html.escape(row["tailscale"] or "n/a")}

Storage: {html.escape(row["storage"] or "n/a")}

""" for row in hosts ) service_rows_html = "\n".join( f""" {html.escape(row["name"])} {html.escape(host_label(values, row["host"]))} {html.escape(row["managed_by"])} {html_link(row["local"])} {html_link(row["public"])} {html_link(row["docs"])} """ for row in services ) return f""" Homelab Service Catalog

Inventory view

Homelab Service Catalog

Generated from homelab.yml. Use this as the operational map for hosts, ports, public routes, local URLs, ownership, and runbooks.

{host_cards}
{service_rows_html}
Service Host Managed by Local Public Docs

Focused Checks

./jeannie status ./jeannie preflight ./jeannie doctor-edge ./jeannie doctor-gitea ./jeannie doctor-rpi ./jeannie doctor-cluster

""" 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())