diff --git a/orchestration/ansible/roles/inventory-cli/files/inventory-cli b/orchestration/ansible/roles/inventory-cli/files/inventory-cli index 1378df8..2df9e62 100755 Binary files a/orchestration/ansible/roles/inventory-cli/files/inventory-cli and b/orchestration/ansible/roles/inventory-cli/files/inventory-cli differ diff --git a/services/device-inventory/build-cli.sh b/services/device-inventory/build-cli.sh new file mode 100755 index 0000000..48b0add --- /dev/null +++ b/services/device-inventory/build-cli.sh @@ -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" diff --git a/services/device-inventory/src/client/discovery.cpp b/services/device-inventory/src/client/discovery.cpp index 86028ea..008e3f6 100644 --- a/services/device-inventory/src/client/discovery.cpp +++ b/services/device-inventory/src/client/discovery.cpp @@ -1125,6 +1125,7 @@ std::vector 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; diff --git a/services/device-inventory/src/client/main.cpp b/services/device-inventory/src/client/main.cpp index 3bffa82..b6f991f 100644 --- a/services/device-inventory/src/client/main.cpp +++ b/services/device-inventory/src/client/main.cpp @@ -3,52 +3,169 @@ #include "common/models.h" #include "common/protocol.h" #include +#include #include #include #include #include +#include #include -// ── usage ───────────────────────────────────────────────────────────────────── -static void usage() { +// ── help ────────────────────────────────────────────────────────────────────── +static void print_help() { std::cout << -R"(Usage: inventory-cli [--host HOST] [--port PORT] [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] [args...] + inventory-cli --help -Server management: - add-server - list-servers - edit-server - fields: name | hostname | location | description - remove-server +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. -Part type management: - add-part-type - list-part-types - edit-part-type - fields: name | description - remove-part-type + Hardware discovery reads from dmidecode (CPU and memory), /sys/block + (disks), and /sys/class/net with lspci (NICs). Root privileges are + required for dmidecode. -Part management (manual): - add-part [key=value ...] - upsert-part [key=value ...] - edit-part [key=value ...] - remove-part - list-parts [--type ] - get-part +GLOBAL OPTIONS + --host HOST, -H HOST + Connect to inventory server at HOST. Default: 127.0.0.1 - 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 + --port PORT, -p PORT + Connect to inventory server on PORT. Default: 9876 -Discovery: - discover [--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 + --help, -h + Print this help and exit. + +COMMANDS + Server Management + add-server + Register a new server in the inventory. + + list-servers + List all registered servers. + + edit-server + Update a field on an existing server. FIELD is one of: + name | hostname | location | description + + remove-server + Permanently delete a server and all its parts. + + Part Type Management + add-part-type + Define a new part type label (e.g. "gpu", "psu"). + + list-part-types + List all part type labels. + + edit-part-type + Update a part type. FIELD is one of: name | description + + remove-part-type + Delete a part type label. + + Part Management + add-part [key=value ...] + Add a new hardware part to a server. + + upsert-part [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 [key=value ...] + Update fields on an existing part. + + remove-part + Delete a part by ID. + + list-parts [--type ] + List all parts for a server, optionally filtered by type. + + get-part + Show all fields of a single part. + + Discovery + discover [--type ] [--dry-run] + 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 ] + 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& 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 parts = filter_type.empty() + ? disc.discover_all() + : disc.discover(filter_type); + + // Bucket by type + std::map> 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 order = { + PTYPE_CPU, PTYPE_CPU_SLOT, + PTYPE_MEMORY_STICK, PTYPE_MEMORY_SLOT, + PTYPE_DISK, PTYPE_NIC + }; + static const std::map 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 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(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 [--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; }