10 KiB
📇 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.
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:
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 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.
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 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 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, docs/dashy-soc-board.md, docs/cron-scheduler.md, docs/notifications.md · Stacks: stacks/platform/nocodb/, stacks/platform/node-red/, stacks/platform/dashy/, stacks/platform/kestra/



