docs: add homelab-inventory — the machine-derived fleet inventory layer

New doc for the weekly self-deriving inventory pipeline: a three-tier
Ansible collection (hardware off the hypervisors/firewall, VMs/LXCs from
the Proxmox APIs, listening sockets off every guest) writing three linked
NocoDB tables, read live behind a Dashy page.

Covers the design decisions worth defending: ss -tlnp over systemctl for
"what does this host serve", merge-only-by-omission on Tier 1 to protect
the one hand-entered field a lying SMBIOS can't provide, API-only Tier 2
because the two Proxmox nodes aren't clustered, stdlib-only push scripts,
and the deliberate KB-vs-machine-layer separation.

Pipeline diagram + four screenshots (Dashy page, all three NocoDB tables).
Provenance comment wired to the ansible roles/playbooks, the rocky Caddy/
dashy config, and KB note O0d4fqw0mr05. Indexed in README under Systems.
This commit is contained in:
automation-bot committed 2026-09-07 08:22:29 +03:00
1 parent bcfcb0d1d5
commit e5b6e8a77d
7 files changed
+125

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
<!-- verified: 2026-09-07
ansible@9a4ba6d : roles/homelab_inventory_hardware, roles/homelab_inventory_vms, roles/homelab_inventory_services, playbooks/homelab_inventory_hardware.yml, playbooks/homelab_inventory_vms.yml, playbooks/homelab_inventory_services.yml
rocky-docker-lxc@145d3df : caddy/Caddyfile, dashy/docker-compose.yaml
trilium : O0d4fqw0mr05
-->
# 📇 Homelab Inventory
> [!NOTE]
> A live reference for every physical host, VM/LXC, and running service in the fleet — **re-derived from the machines themselves once a week**, not hand-maintained. The problem it solves is inventory rot: a wiki table of "what hardware do I have, what's running where" is accurate the day it's written and drifting by the next change. This one is structurally unable to drift, because nothing attests it — a scheduled job re-reads the real systems and overwrites the store.
The knowledge base (Trilium) stays the hand-authored layer — design rationale, gotchas, why things are the way they are. This is the **machine-derived layer** underneath it: three linked tables in a self-hosted NocoDB instance, refreshed by a three-tier Ansible collection on a weekly schedule, surfaced on a dedicated dashboard page.
![The collection pipeline: a weekly Kestra trigger runs three Ansible tiers in sequence — hardware off the hypervisors and firewall, VMs/LXCs from the Proxmox APIs, listening services off every running guest — each writing its own NocoDB table, chained by NocoDB link fields, and read live per request by three Node-RED flows behind one Dashy page](../assets/homelab-inventory-pipeline.svg)
## Three Collection Tiers, Three Different Access Models
The three tiers don't share a collection method, because the three things being inventoried don't live in the same place or answer to the same protocol.
| Tier | Where it runs | How it collects | Writes | Row write mode |
|---|---|---|---|---|
| **Hardware** | hypervisor + firewall hosts, as root | `dmidecode` / `ip -j link` / `/proc` on the Linux hosts; `kenv \| grep -i smbios` + `ifconfig -a` over the `raw` module on the firewall (no Python on that box) | Hardware Inventory | **Merge-only** — only collected fields are sent |
| **VMs / LXCs** | control node only — no SSH anywhere | each hypervisor node's own Proxmox API (`/qemu` + `/lxc`); guest IP from the LXC `net0` config or the VM's QEMU guest agent | VM/LXC Inventory, linked → Hardware | Full field-set replace every run |
| **Services** | every running guest, as root | `ss -tlnp` for listening sockets, plus `docker ps` on the Docker hosts | Services Inventory, linked → VM/LXC | Full field-set replace every run |
A few of those choices are worth pulling out, because the obvious approach was rejected in each case.
### Why `ss -tlnp` and not `systemctl`
"What services does this host run" sounds like a `systemctl list-units` question. It isn't — that answer is 40–80 units of `dbus`, `journald`, udev timers and mount units per host, none of which is a *service the host provides*. Listening sockets are a far closer match to the real question, so that's what the tier collects:
```
$ sed -n '2,6p' roles/homelab_inventory_services/tasks/main.yml
# Deliberately NOT collecting `systemctl list-units --state=running` here -
# that dumps 40-80 units per host (dbus, journald, udev timers, ...) that
# have nothing to do with "what service is this host providing." `ss -tlnp`
# only shows things with an actual open listening socket, which is a much
# closer match to what "service" means for a reference doc - see card [77].
```
### Merge-only vs full replace: protecting the one field a machine can't collect
Tiers 2 and 3 overwrite every column on a row every run — every value there is meant to always reflect current state, and there's nothing to protect. Tier 1 is different, and the difference exists for exactly one reason: the firewall appliance is a QNAP NAS whose BIOS reports the literal string `Default string` for manufacturer, model, and serial, even with full root access. Those three fields are entered by hand in NocoDB once (QNAP / TS-251A / left blank) — and the collector has to be structurally incapable of clobbering them on the next run.
The guarantee isn't "write a blank" or "skip if unchanged" — it's that a field the collector has no data for is **never included in the API payload at all**:
```
$ grep -A3 'def normalize' roles/homelab_inventory_hardware/files/push_hardware_to_nocodb.py
def normalize(record):
"""Build the NocoDB field dict - only include keys we actually have data
for. This is the merge-only guarantee: a key not added here is a key
never sent in the API call, so it can never overwrite anything."""
```
The firewall's collection path deliberately never sets `Manufacturer`/`Model`/`Serial` keys; the Linux path sets them from `dmidecode`. Same table, two code paths, one protected by omission. Here's the result — the CPU and RAM on the QNAP row *are* machine-collected (via `kenv`), only the three identity fields are hand-held:
![The NocoDB Hardware Inventory table: three physical hosts. The two Intel NUCs carry machine-read manufacturer/model/serial/CPU/RAM; the QNAP row has hand-entered manufacturer and model, a blank serial, but machine-collected CPU and RAM, plus a link out to its guests](../assets/homelab-inventory-nocodb-hardware.png)
### Tier 2 is pure API-to-API
The VM/LXC tier touches no host over SSH — it runs entirely on the control node and pulls from each hypervisor node's own Proxmox API with one dedicated read-only `PVEAuditor` token. The two nodes aren't clustered, so each API only ever returns its own guests; the tier queries both and merges. Guest IPs come from two different places depending on type — an LXC's is static in its `net0` config, a VM's has to come from the QEMU guest agent — and a stopped VM or one with no agent simply gets no IP recorded, not an error. Build templates are skipped.
## The Linked Store
Three NocoDB tables, related **Hardware ← VM/LXC ← Services**: each guest row links to its host's hardware row, each service row to its guest's VM/LXC row, set through NocoDB's Links API after each upsert. Service rows are named `<host>: <service>` — native names collide across hosts (every host has an `sshd`), and the prefix is what keeps two rows distinct and lets the viewer derive a service's host straight from its name.
Live counts, straight off the API — matching the screenshots below:
```
$ # GET /api/v2/tables/<id>/records?limit=1 → .pageInfo.totalRows
Hardware : 3 rows
VM/LXC : 12 rows
Services : 111 rows
```
![The NocoDB VM/LXC Inventory table: twelve guest rows, VM and LXC types mixed, each with VMID, status, RAM/CPU/disk, a link back to its Proxmox host and a link forward to its services](../assets/homelab-inventory-nocodb-vms.png)
![The NocoDB Services Inventory table: 111 rows. Socket-derived rows show status "listening" with no image; container-derived rows show status "running" with an image/version — the `ss` and `docker ps` sources stay distinguishable in the data. Every row links to exactly one VM/LXC](../assets/homelab-inventory-nocodb-services.png)
The push scripts use nothing but the Python standard library — they run under the control node's system `python3`, invoked straight from Ansible's `command` module, not a virtualenv with `requests` available:
```
$ grep -hE '^import |^from ' roles/homelab_inventory_*/files/*.py | sort -u
from datetime import datetime, timezone
import glob
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
```
## The Viewer
A dedicated Dashy page, **Homelab Inventory** — three collapsible sections (Hardware, VMs + LXCs, Services), each backed by its own Node-RED flow on the automation host. Each flow is a single `http-in → function → http-response` that reads its NocoDB table **live on every request with no caching** and renders a searchable click-to-expand list. Past ~100 rows a flat scroll is unusable, so the Services section carries a live text filter. Each flow gets its own unauthenticated path exception in the reverse-proxy config, the same pattern every Dashy widget on this fleet uses.
![The Dashy Homelab Inventory page: Hardware expanded to three hosts (two NUC10i3FNH, one QNAP TS-251A), VMs/LXCs collapsed, Services expanded showing 111 rows with a "filter by host or service name" box and NATIVE / CONTAINER badges](../assets/homelab-inventory-dashy-page.png)
## Scheduled, Not Triggered
A single Kestra flow, `homelab-inventory-sync`, cron `0 12 * * 4` — Thursday noon. There's no webhook: nothing about live hardware or a running-process list is an event the orchestrator would otherwise see, so a fixed weekly cadence is the whole trigger.
```
- id: schedule
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 12 * * 4"
```
One SSH task hops to the automation host and runs the three playbooks in sequence through the pinned virtualenv, capturing a per-tier exit code. A non-zero tier sends a critical Slack alert through the shared [`notifications`](notifications.md) pipeline; all-clear sends an info line. The dedicated-key SSH hop and the check→notify shape are the same model every flow in [`orchestration.md`](orchestration.md) uses.
> [!IMPORTANT]
> **This layer never writes to the knowledge base, and the knowledge base never sources from it.** They're deliberately separate: the KB is hand-authored design intent, this is a machine-derived snapshot of current reality, and wiring either one to feed the other would collapse that distinction. A separate mechanism does push a handful of per-host CPU/RAM figures into the KB for at-a-glance context, but it's a different job with its own source and its own destination — it doesn't read or write anything here.
---
Related: [`docs/orchestration.md`](orchestration.md), [`docs/dashy-soc-board.md`](dashy-soc-board.md), [`docs/cron-scheduler.md`](cron-scheduler.md), [`docs/notifications.md`](notifications.md) · Stacks: [`stacks/platform/nocodb/`](../stacks/platform/nocodb/), [`stacks/platform/node-red/`](../stacks/platform/node-red/), [`stacks/platform/dashy/`](../stacks/platform/dashy/), [`stacks/platform/kestra/`](../stacks/platform/kestra/)