Add generated homelab service catalog

This commit is contained in:
juvdiaz 2026-06-27 17:34:55 -06:00
parent e3a1c05a2e
commit 23dd03b0ef
9 changed files with 956 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -19,6 +19,7 @@
</section>
<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="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>

View File

@ -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 {

View File

@ -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"
},

56
docs/service-catalog.md Normal file
View File

@ -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`

View File

@ -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}"
}

492
scripts/render-service-catalog Executable file
View File

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