From 23dd03b0efe51f102711e5dfa87fed23d10eba4c Mon Sep 17 00:00:00 2001 From: juvdiaz Date: Sat, 27 Jun 2026 17:34:55 -0600 Subject: [PATCH] Add generated homelab service catalog --- README.md | 3 + README.md.tmpl | 3 + .../public/homelab-catalog/index.html | 295 +++++++++++ apps/demos-static/public/index.html | 1 + apps/demos-static/public/shared.css | 96 ++++ apps/heimdall/web-app.yaml | 9 +- docs/service-catalog.md | 56 ++ scripts/render-docs | 2 + scripts/render-service-catalog | 492 ++++++++++++++++++ 9 files changed, 956 insertions(+), 1 deletion(-) create mode 100644 apps/demos-static/public/homelab-catalog/index.html create mode 100644 docs/service-catalog.md create mode 100755 scripts/render-service-catalog diff --git a/README.md b/README.md index 70a4d20..ae1eb98 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ service docs. Secrets and tokens do not belong in that file. variables for the edge, provisioning, cluster, platform, and apps stacks. This keeps values such as the Debian LAN IP, Debian Tailscale IP, RPi LAN IP, OCI IP, Traefik MetalLB IP, and domain name from drifting across scripts and templates. +`scripts/render-docs` also renders [docs/service-catalog.md](docs/service-catalog.md) +and the static service catalog page at +`apps/demos-static/public/homelab-catalog/index.html` from the same inventory. Run `./jeannie up` and `./jeannie nuke` only from the Debian homelab server. The script intentionally refuses to run from non-Debian machines so a laptop cannot diff --git a/README.md.tmpl b/README.md.tmpl index bb959c0..eb17983 100644 --- a/README.md.tmpl +++ b/README.md.tmpl @@ -41,6 +41,9 @@ service docs. Secrets and tokens do not belong in that file. variables for the edge, provisioning, cluster, platform, and apps stacks. This keeps values such as the Debian LAN IP, Debian Tailscale IP, RPi LAN IP, OCI IP, Traefik MetalLB IP, and domain name from drifting across scripts and templates. +`scripts/render-docs` also renders [docs/service-catalog.md](docs/service-catalog.md) +and the static service catalog page at +`apps/demos-static/public/homelab-catalog/index.html` from the same inventory. Run `./{{ main_script }} up` and `./{{ main_script }} nuke` only from the Debian homelab server. The script intentionally refuses to run from non-Debian machines so a laptop cannot diff --git a/apps/demos-static/public/homelab-catalog/index.html b/apps/demos-static/public/homelab-catalog/index.html new file mode 100644 index 0000000..3558ab0 --- /dev/null +++ b/apps/demos-static/public/homelab-catalog/index.html @@ -0,0 +1,295 @@ + + + + + + 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.

+
+ +
+
+ control-plane +

debian

+

Address: 192.168.100.73

+

Tailscale: 100.85.138.30

+

Storage: /data/docker

+
+
+ dns-edge-worker +

rpi4

+

Address: 192.168.100.89

+

Tailscale: 100.77.80.72

+

Storage: /nvme-storage/docker

+
+
+ pimox +

opi5_pimox

+

Address: 192.168.100.80

+

Tailscale: n/a

+

Storage: opi5_ssd

+
+
+ public-edge +

oci_edge

+

Address: 132.145.170.74

+

Tailscale: n/a

+

Storage: n/a

+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServiceHostManaged byLocalPublicDocs
WebsiteKubernetesArgo CD / apps/websitehttp://192.168.100.240/https://lab2025.duckdns.orgapps/website/README.md
Demo AppsKubernetesArgo CD / apps/demos-statichttp://192.168.100.240/demo-apps/https://demos.lab2025.duckdns.org/apps/demos-static/
Service CatalogKubernetesGenerated from homelab.ymlhttp://192.168.100.240/demo-apps/homelab-catalog/https://demos.lab2025.duckdns.org/homelab-catalog/docs/service-catalog.md
Giteadebian (control-plane, 192.168.100.73)Docker Compose / jeannie deploy-giteahttp://192.168.100.73:3000/https://lab2025.duckdns.org/git/infra/gitea/README.md
GitOps mirrordebian (control-plane, 192.168.100.73)Bare Git repository/home/jv/git-server/my-homelab-configs.gitssh://jv@192.168.100.73/home/jv/git-server/my-homelab-configs.gitREADME.md#gitops-and-gitea-actions
Container Registrydebian (control-plane, 192.168.100.73)Argo CD / apps/container-registryhttp://192.168.100.73:30500/v2/_catalognot exposedapps/container-registry/
TraefikKubernetesOpenTofu / bootstrap/platformhttp://192.168.100.240/https://lab2025.duckdns.orgREADME.md#loadbalancer-services
Pi-holerpi4 (dns-edge-worker, 192.168.100.89)Docker Compose / jeannie rpi-serviceshttp://192.168.100.89:8081/admin/not exposedinfra/rpi-services/README.md
Unboundrpi4 (dns-edge-worker, 192.168.100.89)Docker Compose / jeannie rpi-services192.168.100.89:53not exposedinfra/rpi-services/README.md
Uptime Kumarpi4 (dns-edge-worker, 192.168.100.89)Docker Compose / jeannie rpi-serviceshttp://192.168.100.89:3001/not exposedinfra/rpi-services/README.md
Provisioning PXE/HTTPdebian (control-plane, 192.168.100.73)OpenTofu / bootstrap/provisioninghttp://192.168.100.73:8088/not exposedbootstrap/provisioning/README.md
Ollamadebian (control-plane, 192.168.100.73)Local Debian servicehttp://192.168.100.73:11434not exposedapps/website/ollama/README.md
OCI Edgeoci_edgeOpenTofu / bootstrap/edge/opt/homelab-edgehttps://lab2025.duckdns.orgdocs/runbooks/edge-failures.md
HeimdallKubernetesArgo CD / apps/heimdallhttp://192.168.100.240/https://heimdall.lab2025.duckdns.org/apps/heimdall/
n8nKubernetesArgo CD / apps/n8nhttp://192.168.100.240/https://n8n.lab2025.duckdns.org/apps/n8n/
Argo CDKubernetesOpenTofu / bootstrap/platformhttp://192.168.100.240/https://argocd.lab2025.duckdns.org/bootstrap/platform/
GrafanaKubernetesOpenTofu / bootstrap/platformhttp://192.168.100.240/https://grafana.lab2025.duckdns.org/bootstrap/platform/
PrometheusKubernetesOpenTofu / bootstrap/platformhttp://192.168.100.240/https://prometheus.lab2025.duckdns.org/bootstrap/platform/
AlertmanagerKubernetesOpenTofu / bootstrap/platformhttp://192.168.100.240/https://alertmanager.lab2025.duckdns.org/bootstrap/platform/
Prowlarrdebian (control-plane, 192.168.100.73)Docker Compose / infra/arr-stackhttp://192.168.100.73:9696/https://prowlarr.lab2025.duckdns.org/infra/arr-stack/README.md
Sonarrdebian (control-plane, 192.168.100.73)Docker Compose / infra/arr-stackhttp://192.168.100.73:8989/https://sonarr.lab2025.duckdns.org/infra/arr-stack/README.md
Radarrdebian (control-plane, 192.168.100.73)Docker Compose / infra/arr-stackhttp://192.168.100.73:7878/https://radarr.lab2025.duckdns.org/infra/arr-stack/README.md
qBittorrentdebian (control-plane, 192.168.100.73)Docker Compose / infra/arr-stackhttp://192.168.100.73:8080/https://qbittorrent.lab2025.duckdns.org/infra/arr-stack/README.md
Kapowarrdebian (control-plane, 192.168.100.73)Docker Compose / infra/arr-stackhttp://192.168.100.73:5656/https://kapowarr.lab2025.duckdns.org/infra/arr-stack/README.md
Suwayomidebian (control-plane, 192.168.100.73)Docker Compose / infra/arr-stackhttp://192.168.100.73:4567/https://suwayomi.lab2025.duckdns.org/infra/arr-stack/README.md
Maintainerrdebian (control-plane, 192.168.100.73)Docker Compose / infra/arr-stackhttp://192.168.100.73:6246/https://maintainerr.lab2025.duckdns.org/infra/arr-stack/README.md
FlareSolverrdebian (control-plane, 192.168.100.73)Docker Compose / infra/arr-stackhttp://192.168.100.73:8191/https://flaresolverr.lab2025.duckdns.org/infra/arr-stack/README.md
+
+
+ +
+

Focused Checks

+

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

+
+
+ + + diff --git a/apps/demos-static/public/index.html b/apps/demos-static/public/index.html index f7d385b..a019090 100644 --- a/apps/demos-static/public/index.html +++ b/apps/demos-static/public/index.html @@ -19,6 +19,7 @@
+ Ops 01

Service Catalog

Generated host, service, route, and runbook map from homelab.yml.

Demo 01

Media Cruncher

Local image compression and conversion.

Demo 02

Internet Quality

Latency, jitter, and stability graph.

Demo 03

Log and JSON Toolbelt

JSON, JWT, URL, and log utilities.

diff --git a/apps/demos-static/public/shared.css b/apps/demos-static/public/shared.css index 68e0876..2d43c03 100644 --- a/apps/demos-static/public/shared.css +++ b/apps/demos-static/public/shared.css @@ -115,6 +115,71 @@ h1 { line-height: 1.5; } +.catalog-shell { + width: min(1280px, calc(100% - 32px)); +} + +.catalog-hero code, +.command-panel code, +.service-table code { + background: rgba(47, 133, 90, 0.12); + border: 1px solid rgba(47, 133, 90, 0.32); + border-radius: 6px; + padding: 2px 6px; +} + +.host-grid { + margin-bottom: 18px; +} + +.service-host { + min-height: 0; +} + +.service-host p { + margin: 8px 0 0; +} + +.table-scroll { + overflow-x: auto; +} + +.service-table { + width: 100%; + border-collapse: collapse; + min-width: 980px; +} + +.service-table th, +.service-table td { + border-bottom: 1px solid #d9e2ec; + padding: 12px 10px; + text-align: left; + vertical-align: top; +} + +.service-table th { + color: #102a43; + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.service-table td { + color: #334e68; + line-height: 1.45; +} + +.service-table a { + color: #0b63ce; + overflow-wrap: anywhere; + text-decoration: none; +} + +.muted { + color: #829ab1; +} + .drop-zone { display: grid; place-items: center; @@ -437,6 +502,25 @@ body.theme-dark .stats span { box-shadow: 0 0 16px rgba(57, 255, 20, 0.16); } +body.theme-dark .service-table th, +body.theme-dark .service-table td, +body.theme-dark .service-table code, +body.theme-dark .muted { + border-color: #1fbf3a; + color: #39ff14; +} + +body.theme-dark .service-table a { + color: #7cff7c; +} + +body.theme-dark .service-table code, +body.theme-dark .catalog-hero code, +body.theme-dark .command-panel code { + background: #031503; + border-color: #1fbf3a; +} + body.theme-dark button, body.theme-dark .download-link, body.theme-dark .site-theme-option { @@ -497,6 +581,18 @@ body.theme-light .stats span { box-shadow: none; } +body.theme-light .service-table th, +body.theme-light .service-table td, +body.theme-light .service-table code, +body.theme-light .muted { + border-color: #111; + color: #1b1610; +} + +body.theme-light .service-table a { + color: #111; +} + body.theme-light button, body.theme-light .download-link, body.theme-light .site-theme-option { diff --git a/apps/heimdall/web-app.yaml b/apps/heimdall/web-app.yaml index cdafe95..9b2fc54 100644 --- a/apps/heimdall/web-app.yaml +++ b/apps/heimdall/web-app.yaml @@ -33,10 +33,17 @@ data: "colour": "#2563eb", "class": "Demos" }, + { + "title": "Service Catalog", + "url": "https://demos.lab2025.duckdns.org/homelab-catalog/", + "description": "Generated host, service, route, and runbook map from homelab.yml", + "colour": "#0891b2", + "class": "Catalog" + }, { "title": "Gitea", "url": "https://lab2025.duckdns.org/git/", - "description": "External Git service on the Raspberry Pi", + "description": "External Git service on the Debian host", "colour": "#609926", "class": "Gitea" }, diff --git a/docs/service-catalog.md b/docs/service-catalog.md new file mode 100644 index 0000000..5ca026b --- /dev/null +++ b/docs/service-catalog.md @@ -0,0 +1,56 @@ +# 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 | Role | Address | Tailscale | Storage | +| --- | --- | --- | --- | --- | +| debian | control-plane | 192.168.100.73 | 100.85.138.30 | /data/docker | +| rpi4 | dns-edge-worker | 192.168.100.89 | 100.77.80.72 | /nvme-storage/docker | +| opi5_pimox | pimox | 192.168.100.80 | | opi5_ssd | +| oci_edge | public-edge | 132.145.170.74 | | | + +## Services + +| Service | Host | Managed By | Local | Public | Docs | +| --- | --- | --- | --- | --- | --- | +| Website | Kubernetes | Argo CD / apps/website | [local](http://192.168.100.240/) | [public](https://lab2025.duckdns.org) | `apps/website/README.md` | +| Demo Apps | Kubernetes | Argo CD / apps/demos-static | [local](http://192.168.100.240/demo-apps/) | [public](https://demos.lab2025.duckdns.org/) | `apps/demos-static/` | +| Service Catalog | Kubernetes | Generated from homelab.yml | [local](http://192.168.100.240/demo-apps/homelab-catalog/) | [public](https://demos.lab2025.duckdns.org/homelab-catalog/) | `docs/service-catalog.md` | +| Gitea | debian (control-plane, 192.168.100.73) | Docker Compose / jeannie deploy-gitea | [local](http://192.168.100.73:3000/) | [public](https://lab2025.duckdns.org/git/) | `infra/gitea/README.md` | +| GitOps mirror | debian (control-plane, 192.168.100.73) | Bare Git repository | `/home/jv/git-server/my-homelab-configs.git` | [public](ssh://jv@192.168.100.73/home/jv/git-server/my-homelab-configs.git) | `README.md#gitops-and-gitea-actions` | +| Container Registry | debian (control-plane, 192.168.100.73) | Argo CD / apps/container-registry | [local](http://192.168.100.73:30500/v2/_catalog) | | `apps/container-registry/` | +| Traefik | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://lab2025.duckdns.org) | `README.md#loadbalancer-services` | +| Pi-hole | rpi4 (dns-edge-worker, 192.168.100.89) | Docker Compose / jeannie rpi-services | [local](http://192.168.100.89:8081/admin/) | | `infra/rpi-services/README.md` | +| Unbound | rpi4 (dns-edge-worker, 192.168.100.89) | Docker Compose / jeannie rpi-services | `192.168.100.89:53` | | `infra/rpi-services/README.md` | +| Uptime Kuma | rpi4 (dns-edge-worker, 192.168.100.89) | Docker Compose / jeannie rpi-services | [local](http://192.168.100.89:3001/) | | `infra/rpi-services/README.md` | +| Provisioning PXE/HTTP | debian (control-plane, 192.168.100.73) | OpenTofu / bootstrap/provisioning | [local](http://192.168.100.73:8088/) | | `bootstrap/provisioning/README.md` | +| Ollama | debian (control-plane, 192.168.100.73) | Local Debian service | [local](http://192.168.100.73:11434) | | `apps/website/ollama/README.md` | +| OCI Edge | oci_edge | OpenTofu / bootstrap/edge | `/opt/homelab-edge` | [public](https://lab2025.duckdns.org) | `docs/runbooks/edge-failures.md` | +| Heimdall | Kubernetes | Argo CD / apps/heimdall | [local](http://192.168.100.240/) | [public](https://heimdall.lab2025.duckdns.org/) | `apps/heimdall/` | +| n8n | Kubernetes | Argo CD / apps/n8n | [local](http://192.168.100.240/) | [public](https://n8n.lab2025.duckdns.org/) | `apps/n8n/` | +| Argo CD | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://argocd.lab2025.duckdns.org/) | `bootstrap/platform/` | +| Grafana | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://grafana.lab2025.duckdns.org/) | `bootstrap/platform/` | +| Prometheus | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://prometheus.lab2025.duckdns.org/) | `bootstrap/platform/` | +| Alertmanager | Kubernetes | OpenTofu / bootstrap/platform | [local](http://192.168.100.240/) | [public](https://alertmanager.lab2025.duckdns.org/) | `bootstrap/platform/` | +| Prowlarr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:9696/) | [public](https://prowlarr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` | +| Sonarr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:8989/) | [public](https://sonarr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` | +| Radarr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:7878/) | [public](https://radarr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` | +| qBittorrent | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:8080/) | [public](https://qbittorrent.lab2025.duckdns.org/) | `infra/arr-stack/README.md` | +| Kapowarr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:5656/) | [public](https://kapowarr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` | +| Suwayomi | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:4567/) | [public](https://suwayomi.lab2025.duckdns.org/) | `infra/arr-stack/README.md` | +| Maintainerr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:6246/) | [public](https://maintainerr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` | +| FlareSolverr | debian (control-plane, 192.168.100.73) | Docker Compose / infra/arr-stack | [local](http://192.168.100.73:8191/) | [public](https://flaresolverr.lab2025.duckdns.org/) | `infra/arr-stack/README.md` | + +## Focused Checks + +- `./jeannie status` +- `./jeannie preflight` +- `./jeannie doctor-edge` +- `./jeannie doctor-gitea` +- `./jeannie doctor-rpi` +- `./jeannie doctor-cluster` diff --git a/scripts/render-docs b/scripts/render-docs index 5d638bb..2424bd0 100755 --- a/scripts/render-docs +++ b/scripts/render-docs @@ -109,6 +109,7 @@ main() { render_readme "${main_script}" >"${rendered}" if [[ "${mode}" == "check" ]]; then + "${REPO_ROOT}/scripts/render-service-catalog" --check if ! cmp -s "${rendered}" "${README_OUTPUT}"; then printf 'Generated README.md is stale. Run scripts/render-docs.\n' >&2 diff -u "${README_OUTPUT}" "${rendered}" || true @@ -121,6 +122,7 @@ main() { fi mv "${rendered}" "${README_OUTPUT}" + "${REPO_ROOT}/scripts/render-service-catalog" printf 'Rendered %s from %s\n' "${README_OUTPUT}" "${README_TEMPLATE}" } diff --git a/scripts/render-service-catalog b/scripts/render-service-catalog new file mode 100755 index 0000000..13cfed1 --- /dev/null +++ b/scripts/render-service-catalog @@ -0,0 +1,492 @@ +#!/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 + + + +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} + +
ServiceHostManaged byLocalPublicDocs
+
+
+ +
+

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