debian
+Address: 192.168.100.73
+Tailscale: 100.85.138.30
+Storage: /data/docker
+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 @@ + + +
+ + +Inventory view
+Generated from homelab.yml. Use this as the operational map for hosts, ports, public routes, local URLs, ownership, and runbooks.
Address: 192.168.100.73
+Tailscale: 100.85.138.30
+Storage: /data/docker
+Address: 192.168.100.89
+Tailscale: 100.77.80.72
+Storage: /nvme-storage/docker
+Address: 192.168.100.80
+Tailscale: n/a
+Storage: opi5_ssd
+Address: 132.145.170.74
+Tailscale: n/a
+Storage: n/a
+| Service | +Host | +Managed by | +Local | +Public | +Docs | +
|---|---|---|---|---|---|
| Website | +Kubernetes | +Argo CD / apps/website | +http://192.168.100.240/ | +https://lab2025.duckdns.org | +apps/website/README.md |
+
| Demo Apps | +Kubernetes | +Argo CD / apps/demos-static | +http://192.168.100.240/demo-apps/ | +https://demos.lab2025.duckdns.org/ | +apps/demos-static/ |
+
| Service Catalog | +Kubernetes | +Generated from homelab.yml | +http://192.168.100.240/demo-apps/homelab-catalog/ | +https://demos.lab2025.duckdns.org/homelab-catalog/ | +docs/service-catalog.md |
+
| Gitea | +debian (control-plane, 192.168.100.73) | +Docker Compose / jeannie deploy-gitea | +http://192.168.100.73:3000/ | +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 |
+ 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 | +http://192.168.100.73:30500/v2/_catalog | +not exposed | +apps/container-registry/ |
+
| Traefik | +Kubernetes | +OpenTofu / bootstrap/platform | +http://192.168.100.240/ | +https://lab2025.duckdns.org | +README.md#loadbalancer-services |
+
| Pi-hole | +rpi4 (dns-edge-worker, 192.168.100.89) | +Docker Compose / jeannie rpi-services | +http://192.168.100.89:8081/admin/ | +not exposed | +infra/rpi-services/README.md |
+
| Unbound | +rpi4 (dns-edge-worker, 192.168.100.89) | +Docker Compose / jeannie rpi-services | +192.168.100.89:53 |
+ not exposed | +infra/rpi-services/README.md |
+
| Uptime Kuma | +rpi4 (dns-edge-worker, 192.168.100.89) | +Docker Compose / jeannie rpi-services | +http://192.168.100.89:3001/ | +not exposed | +infra/rpi-services/README.md |
+
| Provisioning PXE/HTTP | +debian (control-plane, 192.168.100.73) | +OpenTofu / bootstrap/provisioning | +http://192.168.100.73:8088/ | +not exposed | +bootstrap/provisioning/README.md |
+
| Ollama | +debian (control-plane, 192.168.100.73) | +Local Debian service | +http://192.168.100.73:11434 | +not exposed | +apps/website/ollama/README.md |
+
| OCI Edge | +oci_edge | +OpenTofu / bootstrap/edge | +/opt/homelab-edge |
+ https://lab2025.duckdns.org | +docs/runbooks/edge-failures.md |
+
| Heimdall | +Kubernetes | +Argo CD / apps/heimdall | +http://192.168.100.240/ | +https://heimdall.lab2025.duckdns.org/ | +apps/heimdall/ |
+
| n8n | +Kubernetes | +Argo CD / apps/n8n | +http://192.168.100.240/ | +https://n8n.lab2025.duckdns.org/ | +apps/n8n/ |
+
| Argo CD | +Kubernetes | +OpenTofu / bootstrap/platform | +http://192.168.100.240/ | +https://argocd.lab2025.duckdns.org/ | +bootstrap/platform/ |
+
| Grafana | +Kubernetes | +OpenTofu / bootstrap/platform | +http://192.168.100.240/ | +https://grafana.lab2025.duckdns.org/ | +bootstrap/platform/ |
+
| Prometheus | +Kubernetes | +OpenTofu / bootstrap/platform | +http://192.168.100.240/ | +https://prometheus.lab2025.duckdns.org/ | +bootstrap/platform/ |
+
| Alertmanager | +Kubernetes | +OpenTofu / bootstrap/platform | +http://192.168.100.240/ | +https://alertmanager.lab2025.duckdns.org/ | +bootstrap/platform/ |
+
| Prowlarr | +debian (control-plane, 192.168.100.73) | +Docker Compose / infra/arr-stack | +http://192.168.100.73:9696/ | +https://prowlarr.lab2025.duckdns.org/ | +infra/arr-stack/README.md |
+
| Sonarr | +debian (control-plane, 192.168.100.73) | +Docker Compose / infra/arr-stack | +http://192.168.100.73:8989/ | +https://sonarr.lab2025.duckdns.org/ | +infra/arr-stack/README.md |
+
| Radarr | +debian (control-plane, 192.168.100.73) | +Docker Compose / infra/arr-stack | +http://192.168.100.73:7878/ | +https://radarr.lab2025.duckdns.org/ | +infra/arr-stack/README.md |
+
| qBittorrent | +debian (control-plane, 192.168.100.73) | +Docker Compose / infra/arr-stack | +http://192.168.100.73:8080/ | +https://qbittorrent.lab2025.duckdns.org/ | +infra/arr-stack/README.md |
+
| Kapowarr | +debian (control-plane, 192.168.100.73) | +Docker Compose / infra/arr-stack | +http://192.168.100.73:5656/ | +https://kapowarr.lab2025.duckdns.org/ | +infra/arr-stack/README.md |
+
| Suwayomi | +debian (control-plane, 192.168.100.73) | +Docker Compose / infra/arr-stack | +http://192.168.100.73:4567/ | +https://suwayomi.lab2025.duckdns.org/ | +infra/arr-stack/README.md |
+
| Maintainerr | +debian (control-plane, 192.168.100.73) | +Docker Compose / infra/arr-stack | +http://192.168.100.73:6246/ | +https://maintainerr.lab2025.duckdns.org/ | +infra/arr-stack/README.md |
+
| FlareSolverr | +debian (control-plane, 192.168.100.73) | +Docker Compose / infra/arr-stack | +http://192.168.100.73:8191/ | +https://flaresolverr.lab2025.duckdns.org/ | +infra/arr-stack/README.md |
+
./jeannie status ./jeannie preflight ./jeannie doctor-edge ./jeannie doctor-gitea ./jeannie doctor-rpi ./jeannie doctor-cluster
Generated host, service, route, and runbook map from homelab.yml.
Demo 01Local image compression and conversion.
Demo 02Latency, jitter, and stability graph.
Demo 03JSON, 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""" Address: {html.escape(row["lan"] or "n/a")}
+Tailscale: {html.escape(row["tailscale"] or "n/a")}
+Storage: {html.escape(row["storage"] or "n/a")}
+Inventory view
+Generated from homelab.yml. Use this as the operational map for hosts, ports, public routes, local URLs, ownership, and runbooks.
| Service | +Host | +Managed by | +Local | +Public | +Docs | +
|---|
./jeannie status ./jeannie preflight ./jeannie doctor-edge ./jeannie doctor-gitea ./jeannie doctor-rpi ./jeannie doctor-cluster