feat(inventory-cli): build script, manpage help, discover-only command

build-cli.sh
  Simple shell script that builds inventory-cli inside Docker and
  extracts the binary to build/ (or a custom path).  Replaces the
  need to use the heavier build-and-load.sh just to compile the CLI.

--help
  Replaced the terse usage() stub with a full UNIX man-page style
  reference covering NAME, SYNOPSIS, DESCRIPTION, GLOBAL OPTIONS,
  COMMANDS (grouped by area), PART TYPES, FIELD KEYS, EXAMPLES,
  and NOTES.

discover-only [--type <type>]
  New command that runs local hardware discovery without contacting
  the inventory server and prints results as an ASCII tree rooted at
  the hostname.  Each section (CPUs, CPU Slots, Memory Sticks, Memory
  Slots, Disks, NICs) lists discovered components with key attributes
  inline.  Useful for inspection and troubleshooting.

discovery.cpp: store interface name in K_NAME for NICs
  ifname (e.g. "nic0", "eno1") is now emitted so discover-only and
  the server-side UI can display the kernel device name.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Dan V 2026-04-01 00:49:17 +02:00
parent 7a8ea3e88f
commit bf7a7937e0
4 changed files with 353 additions and 36 deletions

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Build the inventory-cli binary inside Docker and extract it to build/
# Usage: ./build-cli.sh [output-path]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT="${1:-$SCRIPT_DIR/build/inventory-cli}"
echo "=== Building inventory-cli:latest ==="
docker build -t inventory-cli:latest -f "$SCRIPT_DIR/Dockerfile.cli" "$SCRIPT_DIR"
mkdir -p "$(dirname "$OUTPUT")"
echo "=== Extracting binary → $OUTPUT ==="
docker create --name tmp-extract-cli inventory-cli:latest
docker cp tmp-extract-cli:/usr/local/bin/inventory-cli "$OUTPUT"
docker rm tmp-extract-cli
echo "Done: $OUTPUT"

View file

@ -1125,6 +1125,7 @@ std::vector<DiscoveredPart> Discovery::discover_nics() {
DiscoveredPart p;
p.type_name = PTYPE_NIC;
p.kv[K_NAME] = ifname;
p.kv[K_MAC] = mac;
p.kv[K_MODEL] = pci_model.empty() ? link_type : pci_model;
if (!pci_vendor.empty()) p.kv[K_MANUFACTURER] = pci_vendor;

View file

@ -3,52 +3,169 @@
#include "common/models.h"
#include "common/protocol.h"
#include <algorithm>
#include <cstdio>
#include <iostream>
#include <map>
#include <set>
#include <string>
#include <unistd.h>
#include <vector>
// ── usage ─────────────────────────────────────────────────────────────────────
static void usage() {
// ── help ──────────────────────────────────────────────────────────────────────
static void print_help() {
std::cout <<
R"(Usage: inventory-cli [--host HOST] [--port PORT] <command> [args...]
R"(NAME
inventory-cli device inventory client and hardware discovery agent
Global options:
--host HOST Server hostname (default: 127.0.0.1)
--port PORT Server port (default: 9876)
SYNOPSIS
inventory-cli [--host HOST] [--port PORT] <command> [args...]
inventory-cli --help
Server management:
DESCRIPTION
inventory-cli is a command-line client for the device-inventory server.
It manages servers and hardware parts in the inventory, and can run
local hardware discovery to populate the inventory automatically.
Hardware discovery reads from dmidecode (CPU and memory), /sys/block
(disks), and /sys/class/net with lspci (NICs). Root privileges are
required for dmidecode.
GLOBAL OPTIONS
--host HOST, -H HOST
Connect to inventory server at HOST. Default: 127.0.0.1
--port PORT, -p PORT
Connect to inventory server on PORT. Default: 9876
--help, -h
Print this help and exit.
COMMANDS
Server Management
add-server <name> <hostname> <location> <description>
Register a new server in the inventory.
list-servers
List all registered servers.
edit-server <id> <field> <value>
fields: name | hostname | location | description
Update a field on an existing server. FIELD is one of:
name | hostname | location | description
remove-server <id>
Permanently delete a server and all its parts.
Part type management:
Part Type Management
add-part-type <name> <description>
Define a new part type label (e.g. "gpu", "psu").
list-part-types
List all part type labels.
edit-part-type <id> <field> <value>
fields: name | description
Update a part type. FIELD is one of: name | description
remove-part-type <id>
Delete a part type label.
Part management (manual):
Part Management
add-part <type> <server_id> [key=value ...]
Add a new hardware part to a server.
upsert-part <type> <server_id> [key=value ...]
Insert or update a part, matching on serial number or natural
key (locator for memory, socket for cpu, device_name for disk).
edit-part <part_id> [key=value ...]
Update fields on an existing part.
remove-part <part_id>
Delete a part by ID.
list-parts <server_id> [--type <type>]
List all parts for a server, optionally filtered by type.
get-part <part_id>
Show all fields of a single part.
Types: memory_stick | memory_slot | cpu | cpu_slot | disk | nic
Keys: any K_* field from the protocol (e.g. serial, name, manufacturer, ...)
Example: add-part cpu 1 name="Intel Xeon" cores=8 threads=16 speed_ghz=3.2
Discovery:
Discovery
discover <server_id> [--type <type>] [--dry-run]
Auto-detect hardware on this machine and register with server.
--type Only discover one part type (memory_stick|memory_slot|cpu|cpu_slot|disk|nic)
--dry-run Print discovered parts without sending to server
Detect hardware on this machine and upsert it into the server
inventory under server_id.
--type TYPE Only discover one part type (see PART TYPES).
--dry-run Print discovered parts without contacting server.
discover-only [--type <type>]
Detect hardware on this machine and print a tree summary.
Does not contact the inventory server. Useful for inspection
and troubleshooting.
--type TYPE Only discover one part type.
PART TYPES
memory_stick A physical RAM module installed in a slot.
memory_slot A DIMM slot on the motherboard.
cpu A physical processor installed in a socket.
cpu_slot A CPU socket on the motherboard.
disk A storage device (HDD, SSD, NVMe, virtual disk).
nic A physical network interface card.
FIELD KEYS
serial Hardware serial number.
name Human-readable name or kernel interface name.
manufacturer Manufacturer or vendor name.
locator DIMM slot locator string (memory_stick/memory_slot).
socket_designation CPU socket label, e.g. "Proc 1" (cpu/cpu_slot).
device_name Kernel device name, e.g. sda, nvme0n1 (disk).
speed_mhz Memory speed in MHz.
size_mb Memory module size in megabytes.
allowed_speed_mhz Maximum supported speed for a memory slot.
allowed_size_mb Maximum module size supported by a slot.
speed_ghz CPU clock speed in GHz.
cores Number of physical CPU cores.
threads Number of logical CPU threads.
disk_size_gb Disk capacity in gigabytes.
disk_type Disk technology: ssd | hdd | nvme | virtual | ...
connection_type Interface type: SATA | NVMe | SAS | ethernet | ...
connection_speed_mbps Link speed in megabits per second.
mac_address MAC address of a NIC.
ip_addresses 0x02-separated list of CIDR addresses.
dhcp "true" if the interface uses DHCP.
EXAMPLES
Register this machine:
inventory-cli --host 10.0.0.1 add-server my-host my-host.local \
"rack 4" "Primary web server"
Run full hardware discovery and submit to server 3:
inventory-cli --host 10.0.0.1 discover 3
Preview discovery results locally without contacting the server:
inventory-cli discover-only
Discover only disks:
inventory-cli discover-only --type disk
List all parts for server 3:
inventory-cli --host 10.0.0.1 list-parts 3
List only memory sticks for server 3:
inventory-cli --host 10.0.0.1 list-parts 3 --type memory_stick
NOTES
Hardware discovery requires root privileges or the CAP_SYS_ADMIN
capability for dmidecode access. Run as root or via sudo.
CPU "ID" fields from dmidecode (CPUID family/model/stepping) are not
used as serial numbers because they are identical across processors of
the same model. Socket designation is used as the deduplication key.
Memory slot blocks without a Locator field (sometimes emitted by HP
ProLiant firmware) are silently skipped during discovery.
AUTHOR
Part of the homelab device-inventory project.
)";
}
@ -89,6 +206,172 @@ static bool parse_kv_args(int argc, char* argv[], int start,
return true;
}
// ── discover-only: tree-printing helpers ──────────────────────────────────────
static std::string kv_get(const std::map<std::string,std::string>& m,
const std::string& key,
const std::string& def = "") {
auto it = m.find(key);
return (it != m.end() && !it->second.empty() && it->second != "NULL")
? it->second : def;
}
// Replace LS (0x02) separators with ", " for display
static std::string ls_join(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
if (c == LS) out += ", ";
else out += c;
}
return out;
}
static int cmd_discover_only(const std::string& filter_type) {
Discovery disc;
std::vector<DiscoveredPart> parts = filter_type.empty()
? disc.discover_all()
: disc.discover(filter_type);
// Bucket by type
std::map<std::string, std::vector<const DiscoveredPart*>> by_type;
for (auto& p : parts) by_type[p.type_name].push_back(&p);
// Hostname as root of the tree
char hbuf[256] = {};
gethostname(hbuf, sizeof(hbuf) - 1);
std::cout << hbuf << "\n";
// Logical display order
const std::vector<std::string> order = {
PTYPE_CPU, PTYPE_CPU_SLOT,
PTYPE_MEMORY_STICK, PTYPE_MEMORY_SLOT,
PTYPE_DISK, PTYPE_NIC
};
static const std::map<std::string, std::string> labels = {
{PTYPE_CPU, "CPUs"},
{PTYPE_CPU_SLOT, "CPU Slots"},
{PTYPE_MEMORY_STICK, "Memory Sticks"},
{PTYPE_MEMORY_SLOT, "Memory Slots"},
{PTYPE_DISK, "Disks"},
{PTYPE_NIC, "NICs"},
};
// Collect sections that actually have data
std::vector<std::string> present;
for (auto& t : order)
if (by_type.count(t) && !by_type.at(t).empty())
present.push_back(t);
for (size_t si = 0; si < present.size(); ++si) {
bool sec_last = (si + 1 == present.size());
const auto& type = present[si];
auto& items = by_type.at(type);
std::string sec_pfx = sec_last ? "└── " : "├── ";
std::string child_pfx = sec_last ? " " : "";
std::cout << sec_pfx << labels.at(type)
<< " (" << items.size() << ")\n";
for (size_t ii = 0; ii < items.size(); ++ii) {
bool item_last = (ii + 1 == items.size());
auto& m = items[ii]->kv;
std::string line;
if (type == PTYPE_CPU) {
std::string sock = kv_get(m, K_SOCKET);
std::string name = kv_get(m, K_NAME);
std::string speed = kv_get(m, K_SPEED_GHZ);
std::string cores = kv_get(m, K_CORES);
std::string threads = kv_get(m, K_THREADS);
if (!sock.empty()) line += "[" + sock + "] ";
line += name.empty() ? "(unknown)" : name;
// Only append speed if the name doesn't already embed it (e.g. "@ 2.27GHz")
if (!speed.empty() && name.find('@') == std::string::npos)
line += " @ " + speed + " GHz";
if (!cores.empty() || !threads.empty())
line += " · " + (cores.empty() ? "?" : cores)
+ " cores / " + (threads.empty() ? "?" : threads) + " threads";
} else if (type == PTYPE_CPU_SLOT) {
std::string sock = kv_get(m, K_SOCKET);
std::string name = kv_get(m, K_NAME);
if (!sock.empty()) line += "[" + sock + "]";
if (!name.empty()) line += " " + name;
} else if (type == PTYPE_MEMORY_STICK) {
std::string loc = kv_get(m, K_LOCATOR);
std::string size = kv_get(m, K_SIZE_MB);
std::string mfr = kv_get(m, K_MANUFACTURER);
std::string speed = kv_get(m, K_SPEED_MHZ);
if (!loc.empty()) line += "[" + loc + "] ";
if (!size.empty()) {
uint64_t mb = std::stoull(size);
if (mb >= 1024)
line += std::to_string(mb / 1024) + " GB";
else
line += std::to_string(mb) + " MB";
}
if (!mfr.empty()) line += " " + mfr;
if (!speed.empty()) line += " @ " + speed + " MHz";
} else if (type == PTYPE_MEMORY_SLOT) {
std::string loc = kv_get(m, K_LOCATOR);
std::string max_size = kv_get(m, K_ALLOWED_SIZE);
std::string max_spd = kv_get(m, K_ALLOWED_SPEED);
if (!loc.empty()) line += "[" + loc + "]";
if (!max_size.empty()) {
uint64_t mb = std::stoull(max_size);
line += " up to ";
if (mb >= 1024)
line += std::to_string(mb / 1024) + " GB";
else
line += std::to_string(mb) + " MB";
}
if (!max_spd.empty()) line += " @ " + max_spd + " MHz";
} else if (type == PTYPE_DISK) {
std::string dev = kv_get(m, K_DEVICE_NAME);
std::string size = kv_get(m, K_DISK_SIZE);
std::string dtype = kv_get(m, K_DISK_TYPE);
std::string model = kv_get(m, K_MODEL);
std::string serial = kv_get(m, K_SERIAL);
if (!dev.empty()) line += dev + " ";
if (!size.empty()) {
char buf[32];
snprintf(buf, sizeof(buf), "%.1f GB", std::stod(size));
line += buf;
}
if (!dtype.empty()) line += " (" + dtype + ")";
if (!model.empty()) line += " " + model;
if (!serial.empty()) line += " s/n: " + serial;
} else if (type == PTYPE_NIC) {
std::string ifname = kv_get(m, K_NAME);
std::string model = kv_get(m, K_MODEL);
std::string mfr = kv_get(m, K_MANUFACTURER);
std::string mac = kv_get(m, K_MAC);
std::string ips = kv_get(m, K_IPS);
std::string speed = kv_get(m, K_CONN_SPEED);
if (!ifname.empty()) line += ifname + " ";
if (!model.empty()) line += model + " ";
else if (!mfr.empty()) line += mfr + " ";
if (!mac.empty()) line += mac + " ";
if (!ips.empty()) line += ls_join(ips) + " ";
if (!speed.empty() && speed != "0")
line += speed + " Mbps";
}
// Trim trailing spaces
while (!line.empty() && line.back() == ' ') line.pop_back();
std::cout << child_pfx << (item_last ? "└── " : "├── ") << line << "\n";
}
}
return 0;
}
// ── discover subcommand ───────────────────────────────────────────────────────
static int cmd_discover(Client& client, const std::string& server_id,
const std::string& filter_type, bool dry_run) {
@ -148,13 +431,13 @@ int main(int argc, char* argv[]) {
} else if ((arg == "--port" || arg == "-p") && i + 1 < argc) {
port = static_cast<uint16_t>(std::stoul(argv[++i]));
} else if (arg == "--help" || arg == "-h") {
usage(); return 0;
print_help(); return 0;
} else {
break;
}
}
if (i >= argc) { usage(); return 1; }
if (i >= argc) { print_help(); return 1; }
std::string sub = argv[i++];
@ -255,6 +538,20 @@ int main(int argc, char* argv[]) {
}
// ── Discovery ─────────────────────────────────────────────────────────────
if (sub == "discover-only") {
std::string filter_type;
while (i < argc) {
std::string a = argv[i++];
if (a == "--type" && i < argc) {
filter_type = argv[i++];
} else {
std::cerr << "error: unknown discover-only option: " << a << "\n";
return 1;
}
}
return cmd_discover_only(filter_type);
}
if (sub == "discover") {
if (i >= argc) {
std::cerr << "usage: discover <server_id> [--type <type>] [--dry-run]\n";
@ -281,6 +578,6 @@ int main(int argc, char* argv[]) {
}
std::cerr << "error: unknown command '" << sub << "'\n\n";
usage();
print_help();
return 1;
}