Add generated homelab service catalog
This commit is contained in:
parent
e3a1c05a2e
commit
23dd03b0ef
|
|
@ -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
|
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,
|
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.
|
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
|
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
|
script intentionally refuses to run from non-Debian machines so a laptop cannot
|
||||||
|
|
|
||||||
|
|
@ -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
|
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,
|
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.
|
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
|
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
|
script intentionally refuses to run from non-Debian machines so a laptop cannot
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
<!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">
|
||||||
|
<article class="catalog-card service-host">
|
||||||
|
<span>control-plane</span>
|
||||||
|
<h2>debian</h2>
|
||||||
|
<p><strong>Address:</strong> 192.168.100.73</p>
|
||||||
|
<p><strong>Tailscale:</strong> 100.85.138.30</p>
|
||||||
|
<p><strong>Storage:</strong> /data/docker</p>
|
||||||
|
</article>
|
||||||
|
<article class="catalog-card service-host">
|
||||||
|
<span>dns-edge-worker</span>
|
||||||
|
<h2>rpi4</h2>
|
||||||
|
<p><strong>Address:</strong> 192.168.100.89</p>
|
||||||
|
<p><strong>Tailscale:</strong> 100.77.80.72</p>
|
||||||
|
<p><strong>Storage:</strong> /nvme-storage/docker</p>
|
||||||
|
</article>
|
||||||
|
<article class="catalog-card service-host">
|
||||||
|
<span>pimox</span>
|
||||||
|
<h2>opi5_pimox</h2>
|
||||||
|
<p><strong>Address:</strong> 192.168.100.80</p>
|
||||||
|
<p><strong>Tailscale:</strong> n/a</p>
|
||||||
|
<p><strong>Storage:</strong> opi5_ssd</p>
|
||||||
|
</article>
|
||||||
|
<article class="catalog-card service-host">
|
||||||
|
<span>public-edge</span>
|
||||||
|
<h2>oci_edge</h2>
|
||||||
|
<p><strong>Address:</strong> 132.145.170.74</p>
|
||||||
|
<p><strong>Tailscale:</strong> n/a</p>
|
||||||
|
<p><strong>Storage:</strong> n/a</p>
|
||||||
|
</article>
|
||||||
|
</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>
|
||||||
|
<tr>
|
||||||
|
<td>Website</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>Argo CD / apps/website</td>
|
||||||
|
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
|
||||||
|
<td><a href="https://lab2025.duckdns.org">https://lab2025.duckdns.org</a></td>
|
||||||
|
<td><code>apps/website/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Demo Apps</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>Argo CD / apps/demos-static</td>
|
||||||
|
<td><a href="http://192.168.100.240/demo-apps/">http://192.168.100.240/demo-apps/</a></td>
|
||||||
|
<td><a href="https://demos.lab2025.duckdns.org/">https://demos.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>apps/demos-static/</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Service Catalog</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>Generated from homelab.yml</td>
|
||||||
|
<td><a href="http://192.168.100.240/demo-apps/homelab-catalog/">http://192.168.100.240/demo-apps/homelab-catalog/</a></td>
|
||||||
|
<td><a href="https://demos.lab2025.duckdns.org/homelab-catalog/">https://demos.lab2025.duckdns.org/homelab-catalog/</a></td>
|
||||||
|
<td><code>docs/service-catalog.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Gitea</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Docker Compose / jeannie deploy-gitea</td>
|
||||||
|
<td><a href="http://192.168.100.73:3000/">http://192.168.100.73:3000/</a></td>
|
||||||
|
<td><a href="https://lab2025.duckdns.org/git/">https://lab2025.duckdns.org/git/</a></td>
|
||||||
|
<td><code>infra/gitea/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>GitOps mirror</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Bare Git repository</td>
|
||||||
|
<td><code>/home/jv/git-server/my-homelab-configs.git</code></td>
|
||||||
|
<td><a href="ssh://jv@192.168.100.73/home/jv/git-server/my-homelab-configs.git">ssh://jv@192.168.100.73/home/jv/git-server/my-homelab-configs.git</a></td>
|
||||||
|
<td><code>README.md#gitops-and-gitea-actions</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Container Registry</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Argo CD / apps/container-registry</td>
|
||||||
|
<td><a href="http://192.168.100.73:30500/v2/_catalog">http://192.168.100.73:30500/v2/_catalog</a></td>
|
||||||
|
<td><span class="muted">not exposed</span></td>
|
||||||
|
<td><code>apps/container-registry/</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Traefik</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>OpenTofu / bootstrap/platform</td>
|
||||||
|
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
|
||||||
|
<td><a href="https://lab2025.duckdns.org">https://lab2025.duckdns.org</a></td>
|
||||||
|
<td><code>README.md#loadbalancer-services</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pi-hole</td>
|
||||||
|
<td>rpi4 (dns-edge-worker, 192.168.100.89)</td>
|
||||||
|
<td>Docker Compose / jeannie rpi-services</td>
|
||||||
|
<td><a href="http://192.168.100.89:8081/admin/">http://192.168.100.89:8081/admin/</a></td>
|
||||||
|
<td><span class="muted">not exposed</span></td>
|
||||||
|
<td><code>infra/rpi-services/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Unbound</td>
|
||||||
|
<td>rpi4 (dns-edge-worker, 192.168.100.89)</td>
|
||||||
|
<td>Docker Compose / jeannie rpi-services</td>
|
||||||
|
<td><code>192.168.100.89:53</code></td>
|
||||||
|
<td><span class="muted">not exposed</span></td>
|
||||||
|
<td><code>infra/rpi-services/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Uptime Kuma</td>
|
||||||
|
<td>rpi4 (dns-edge-worker, 192.168.100.89)</td>
|
||||||
|
<td>Docker Compose / jeannie rpi-services</td>
|
||||||
|
<td><a href="http://192.168.100.89:3001/">http://192.168.100.89:3001/</a></td>
|
||||||
|
<td><span class="muted">not exposed</span></td>
|
||||||
|
<td><code>infra/rpi-services/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Provisioning PXE/HTTP</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>OpenTofu / bootstrap/provisioning</td>
|
||||||
|
<td><a href="http://192.168.100.73:8088/">http://192.168.100.73:8088/</a></td>
|
||||||
|
<td><span class="muted">not exposed</span></td>
|
||||||
|
<td><code>bootstrap/provisioning/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Ollama</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Local Debian service</td>
|
||||||
|
<td><a href="http://192.168.100.73:11434">http://192.168.100.73:11434</a></td>
|
||||||
|
<td><span class="muted">not exposed</span></td>
|
||||||
|
<td><code>apps/website/ollama/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>OCI Edge</td>
|
||||||
|
<td>oci_edge</td>
|
||||||
|
<td>OpenTofu / bootstrap/edge</td>
|
||||||
|
<td><code>/opt/homelab-edge</code></td>
|
||||||
|
<td><a href="https://lab2025.duckdns.org">https://lab2025.duckdns.org</a></td>
|
||||||
|
<td><code>docs/runbooks/edge-failures.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Heimdall</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>Argo CD / apps/heimdall</td>
|
||||||
|
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
|
||||||
|
<td><a href="https://heimdall.lab2025.duckdns.org/">https://heimdall.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>apps/heimdall/</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>n8n</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>Argo CD / apps/n8n</td>
|
||||||
|
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
|
||||||
|
<td><a href="https://n8n.lab2025.duckdns.org/">https://n8n.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>apps/n8n/</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Argo CD</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>OpenTofu / bootstrap/platform</td>
|
||||||
|
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
|
||||||
|
<td><a href="https://argocd.lab2025.duckdns.org/">https://argocd.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>bootstrap/platform/</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Grafana</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>OpenTofu / bootstrap/platform</td>
|
||||||
|
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
|
||||||
|
<td><a href="https://grafana.lab2025.duckdns.org/">https://grafana.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>bootstrap/platform/</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Prometheus</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>OpenTofu / bootstrap/platform</td>
|
||||||
|
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
|
||||||
|
<td><a href="https://prometheus.lab2025.duckdns.org/">https://prometheus.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>bootstrap/platform/</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Alertmanager</td>
|
||||||
|
<td>Kubernetes</td>
|
||||||
|
<td>OpenTofu / bootstrap/platform</td>
|
||||||
|
<td><a href="http://192.168.100.240/">http://192.168.100.240/</a></td>
|
||||||
|
<td><a href="https://alertmanager.lab2025.duckdns.org/">https://alertmanager.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>bootstrap/platform/</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Prowlarr</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Docker Compose / infra/arr-stack</td>
|
||||||
|
<td><a href="http://192.168.100.73:9696/">http://192.168.100.73:9696/</a></td>
|
||||||
|
<td><a href="https://prowlarr.lab2025.duckdns.org/">https://prowlarr.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>infra/arr-stack/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sonarr</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Docker Compose / infra/arr-stack</td>
|
||||||
|
<td><a href="http://192.168.100.73:8989/">http://192.168.100.73:8989/</a></td>
|
||||||
|
<td><a href="https://sonarr.lab2025.duckdns.org/">https://sonarr.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>infra/arr-stack/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Radarr</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Docker Compose / infra/arr-stack</td>
|
||||||
|
<td><a href="http://192.168.100.73:7878/">http://192.168.100.73:7878/</a></td>
|
||||||
|
<td><a href="https://radarr.lab2025.duckdns.org/">https://radarr.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>infra/arr-stack/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>qBittorrent</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Docker Compose / infra/arr-stack</td>
|
||||||
|
<td><a href="http://192.168.100.73:8080/">http://192.168.100.73:8080/</a></td>
|
||||||
|
<td><a href="https://qbittorrent.lab2025.duckdns.org/">https://qbittorrent.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>infra/arr-stack/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Kapowarr</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Docker Compose / infra/arr-stack</td>
|
||||||
|
<td><a href="http://192.168.100.73:5656/">http://192.168.100.73:5656/</a></td>
|
||||||
|
<td><a href="https://kapowarr.lab2025.duckdns.org/">https://kapowarr.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>infra/arr-stack/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Suwayomi</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Docker Compose / infra/arr-stack</td>
|
||||||
|
<td><a href="http://192.168.100.73:4567/">http://192.168.100.73:4567/</a></td>
|
||||||
|
<td><a href="https://suwayomi.lab2025.duckdns.org/">https://suwayomi.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>infra/arr-stack/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Maintainerr</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Docker Compose / infra/arr-stack</td>
|
||||||
|
<td><a href="http://192.168.100.73:6246/">http://192.168.100.73:6246/</a></td>
|
||||||
|
<td><a href="https://maintainerr.lab2025.duckdns.org/">https://maintainerr.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>infra/arr-stack/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FlareSolverr</td>
|
||||||
|
<td>debian (control-plane, 192.168.100.73)</td>
|
||||||
|
<td>Docker Compose / infra/arr-stack</td>
|
||||||
|
<td><a href="http://192.168.100.73:8191/">http://192.168.100.73:8191/</a></td>
|
||||||
|
<td><a href="https://flaresolverr.lab2025.duckdns.org/">https://flaresolverr.lab2025.duckdns.org/</a></td>
|
||||||
|
<td><code>infra/arr-stack/README.md</code></td>
|
||||||
|
</tr>
|
||||||
|
</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>
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid">
|
<section class="grid">
|
||||||
|
<a class="catalog-card" href="homelab-catalog/"><span>Ops 01</span><h2>Service Catalog</h2><p>Generated host, service, route, and runbook map from homelab.yml.</p></a>
|
||||||
<a class="catalog-card" href="media-cruncher/"><span>Demo 01</span><h2>Media Cruncher</h2><p>Local image compression and conversion.</p></a>
|
<a class="catalog-card" href="media-cruncher/"><span>Demo 01</span><h2>Media Cruncher</h2><p>Local image compression and conversion.</p></a>
|
||||||
<a class="catalog-card" href="network-quality/"><span>Demo 02</span><h2>Internet Quality</h2><p>Latency, jitter, and stability graph.</p></a>
|
<a class="catalog-card" href="network-quality/"><span>Demo 02</span><h2>Internet Quality</h2><p>Latency, jitter, and stability graph.</p></a>
|
||||||
<a class="catalog-card" href="dev-toolbelt/"><span>Demo 03</span><h2>Log and JSON Toolbelt</h2><p>JSON, JWT, URL, and log utilities.</p></a>
|
<a class="catalog-card" href="dev-toolbelt/"><span>Demo 03</span><h2>Log and JSON Toolbelt</h2><p>JSON, JWT, URL, and log utilities.</p></a>
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,71 @@ h1 {
|
||||||
line-height: 1.5;
|
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 {
|
.drop-zone {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
@ -437,6 +502,25 @@ body.theme-dark .stats span {
|
||||||
box-shadow: 0 0 16px rgba(57, 255, 20, 0.16);
|
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 button,
|
||||||
body.theme-dark .download-link,
|
body.theme-dark .download-link,
|
||||||
body.theme-dark .site-theme-option {
|
body.theme-dark .site-theme-option {
|
||||||
|
|
@ -497,6 +581,18 @@ body.theme-light .stats span {
|
||||||
box-shadow: none;
|
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 button,
|
||||||
body.theme-light .download-link,
|
body.theme-light .download-link,
|
||||||
body.theme-light .site-theme-option {
|
body.theme-light .site-theme-option {
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,17 @@ data:
|
||||||
"colour": "#2563eb",
|
"colour": "#2563eb",
|
||||||
"class": "Demos"
|
"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",
|
"title": "Gitea",
|
||||||
"url": "https://lab2025.duckdns.org/git/",
|
"url": "https://lab2025.duckdns.org/git/",
|
||||||
"description": "External Git service on the Raspberry Pi",
|
"description": "External Git service on the Debian host",
|
||||||
"colour": "#609926",
|
"colour": "#609926",
|
||||||
"class": "Gitea"
|
"class": "Gitea"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# 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 | 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`
|
||||||
|
|
@ -109,6 +109,7 @@ main() {
|
||||||
render_readme "${main_script}" >"${rendered}"
|
render_readme "${main_script}" >"${rendered}"
|
||||||
|
|
||||||
if [[ "${mode}" == "check" ]]; then
|
if [[ "${mode}" == "check" ]]; then
|
||||||
|
"${REPO_ROOT}/scripts/render-service-catalog" --check
|
||||||
if ! cmp -s "${rendered}" "${README_OUTPUT}"; then
|
if ! cmp -s "${rendered}" "${README_OUTPUT}"; then
|
||||||
printf 'Generated README.md is stale. Run scripts/render-docs.\n' >&2
|
printf 'Generated README.md is stale. Run scripts/render-docs.\n' >&2
|
||||||
diff -u "${README_OUTPUT}" "${rendered}" || true
|
diff -u "${README_OUTPUT}" "${rendered}" || true
|
||||||
|
|
@ -121,6 +122,7 @@ main() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mv "${rendered}" "${README_OUTPUT}"
|
mv "${rendered}" "${README_OUTPUT}"
|
||||||
|
"${REPO_ROOT}/scripts/render-service-catalog"
|
||||||
printf 'Rendered %s from %s\n' "${README_OUTPUT}" "${README_TEMPLATE}"
|
printf 'Rendered %s from %s\n' "${README_OUTPUT}" "${README_TEMPLATE}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
<!-- 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())
|
||||||
Loading…
Reference in New Issue