[TUTORIAL] Inventory script for nodes

payne0420

Member
Mar 20, 2022
5
0
6
29
In the last couple of days I got increasingly pissed off at how much manual maintenance work I had to do every time something changed in my setup. Whether that's a new disk added, or changing a VM name, or swapping a passthrough NIC to a virtio device, etc.

So I got around and built a python script that builds a node inventory with all the VMs, their corresponding disks, the disk mount type (scsi vs passthrough), the NIC type (virtio vs passthrough).

It only works with NVME disks, since that's what all my servers are using.

In the future, this could be rewritten with some SQL tables for gathering all the data, then displaying the final list. It's probably a more better organized way of dealing with this, but for now I'm happy how it went. I know it's not a complete and bugfree solution, but I'm sharing it here anyway in hopes of helping at least partially, if people need something like this.

The script has two sorting types. Let's say the script is called `inventory.py`.
- If the script is called via `python3 inventory.py`, it displays everything in the order of the physical bays of the server (nvme). This may be helpful when you want to extract an nvme drive out of the server, and you want to triple check that the drive you're pulling out belongs to the right VM
- If the script is called via `python3 inventory.py --sort=vmid` it displays everything sorted by the VM ID column, and keeps track of which disks belong to which VMs.

Attached some pics for clarity.

1719149211892.png1719149218714.png

The script is here:


Python:
import json
import subprocess
import socket
import argparse
from tabulate import tabulate

# Function to run a shell command and return the output
def run_command(command):
    result = subprocess.run(command, shell=True, capture_output=True, text=True)
    return result.stdout.strip()

# Parse command-line arguments
parser = argparse.ArgumentParser(description='NVMe and VM Inventory')
parser.add_argument('--sort', choices=['vmid'], help='Sort table by VM ID')
args = parser.parse_args()

# Get NVMe list in JSON format and parse it for size, model, and serial number information
nvme_json = run_command('nvme list -o json')
nvme_devices = json.loads(nvme_json)["Devices"]

size_info = {}
model_info = {}
serial_info = {}

for device in nvme_devices:
    device_path = device["DevicePath"]
    physical_size = device["PhysicalSize"]
    model_number = device["ModelNumber"]
    serial_number = device["SerialNumber"]
    model = model_number.split()[0]
    size_in_tb = f"{physical_size / 10**12:.2f}TB"
    size_info[device_path] = size_in_tb
    model_info[device_path] = model
    serial_info[device_path] = serial_number

# Parse nvme list output for necessary details
nvme_list_output = run_command('nvme list')
nvme_list_lines = nvme_list_output.splitlines()

nvme_list_info = {}

for line in nvme_list_lines:
    if line.startswith('Node'):
        continue
    elif line.startswith('/dev/nvme'):
        parts = line.split()
        nvme_dev = parts[0]
        nvme_name = nvme_dev.split('/')[-1]

        pci_addr_full = run_command(f'readlink -f /sys/class/nvme/{nvme_name[:-2]}/device')
        if not pci_addr_full:
            continue
        pci_addr = pci_addr_full.split('/')[-1].lstrip('0000:')

        numa_node = run_command(f'cat /sys/class/nvme/{nvme_name[:-2]}/device/numa_node 2>/dev/null || echo "N/A"')
        mount_points = run_command(f'lsblk -no MOUNTPOINT {nvme_dev} | grep -v "^$" | xargs')
        mount_points = mount_points if mount_points else "N/A"

        size = size_info.get(nvme_dev, "Unknown")
        model = model_info.get(nvme_dev, "Unknown")
        serial = serial_info.get(nvme_dev, "Unknown")

        nvme_list_info[pci_addr] = {
            "NVMe Device": nvme_dev,
            "PCI Address": pci_addr,
            "NUMA Node": numa_node,
            "Model": model,
            "Serial": serial,
            "Size": size,
            "Mount Points": mount_points
        }

# Collect PCI addresses in order from lspci for NVMe controllers
pci_order_output = run_command('lspci | grep "Non-Volatile memory controller"')
pci_order_lines = pci_order_output.splitlines()
pci_order = [line.split()[0] for line in pci_order_lines if line.split()[0] in nvme_list_info]

# Prepare data for the JSON output
nvme_devices = [
    nvme_list_info[pci_addr] for pci_addr in pci_order if pci_addr in nvme_list_info
]

# Get the current hostname
hostname = socket.gethostname()

# Fetch VM details using pvesh
vm_details_cmd = f"pvesh get /nodes/{hostname}/qemu --output-format=json"
vm_details_output = subprocess.check_output(vm_details_cmd, shell=True)
vm_details = json.loads(vm_details_output)

# Function to fetch VM config and parse hostpci, scsi details, CPU Cores, CPU Units, CPU Affinity, RAM, and NICs
def get_vm_config(vmid):
    config_cmd = f"qm config {vmid}"
    config_output = subprocess.check_output(config_cmd, shell=True).decode('utf-8').splitlines()
    config = {}
    for line in config_output:
        if line.startswith('hostpci'):
            pci_slot = line.split(':')[0]
            pci_address = line.split()[1].split(',')[0].replace('0000:', '')
            config[pci_slot] = pci_address
            # Check if the PCI device is a NIC
            if 'net' in run_command(f'lspci -s {pci_address}'):
                if 'nics' not in config:
                    config['nics'] = []
                config['nics'].append(pci_address)
        if line.startswith('scsi'):
            scsi_slot = line.split(':')[0]
            config[scsi_slot] = line.split()[1].split(',')[0].split(':')[0]
        if line.startswith('cores'):
            config['cores'] = line.split()[1]
        if line.startswith('cpuunits'):
            config['cpuunits'] = line.split()[1]
        if line.startswith('memory'):
            config['memory'] = str(int(line.split()[1]) // 1024)  # Convert MiB to GiB
        if line.startswith('affinity'):
            config['affinity'] = line.split()[1]
    return config

# Helper function to map SCSI identifiers to mount points
def map_scsi_to_mount(scsi_identifier):
    # Expecting identifiers like 'gb3-xfs-nvme-a' or 'gb3-xfs-raid0-a'
    if 'nvme' in scsi_identifier:
        return '/mnt/nvme-' + scsi_identifier.split('-')[-1]
    if 'raid0' in scsi_identifier:
        return '/mnt/raid0-' + scsi_identifier.split('-')[-1]
    return None

# Match and format the data
final_table = []

for vm in vm_details:
    vmid = vm['vmid']
    vmname = vm['name']
    vm_config = get_vm_config(vmid)

    vm_row_added = False  # Track if the main row for the VM has been added

    # Match all PCI Addresses
    for pci_key, pci_address in vm_config.items():
        if pci_key.startswith('hostpci'):
            for device in nvme_devices:
                if device["PCI Address"] == pci_address:
                    row = {
                        "VM ID": vmid,
                        "VM Name": vmname if not (args.sort and vm_row_added) else "",
                        "CPU Cores": vm_config.get('cores', 'N/A') if not (args.sort and vm_row_added) else "",
                        "CPU Affinity": vm_config.get('affinity', 'N/A') if not (args.sort and vm_row_added) else "",
                        "CPU Units": vm_config.get('cpuunits', 'N/A') if not (args.sort and vm_row_added) else "",
                        "RAM (GiB)": vm_config.get('memory', 'N/A') if not (args.sort and vm_row_added) else "",
                        "NVMe Device": device["NVMe Device"],
                        "PCI Address": device["PCI Address"],
                        "NUMA": device["NUMA Node"],
                        "Model": device["Model"],
                        "Serial": device["Serial"],
                        "Size": device["Size"],
                        "Mount Points": "passthrough",
                        "NIC": ', '.join(vm_config.get('nics', [])) if 'nics' in vm_config else "virtio"
                    }
                    final_table.append(row)
                    vm_row_added = True

    # Match SCSI Mount Points
    for scsi_key, scsi_value in vm_config.items():
        expected_mount = map_scsi_to_mount(scsi_value)
        if expected_mount:
            for device in nvme_devices:
                if device["Mount Points"] == expected_mount:
                    row = {
                        "VM ID": vmid,
                        "VM Name": vmname if not (args.sort and vm_row_added) else "",
                        "CPU Cores": vm_config.get('cores', 'N/A') if not (args.sort and vm_row_added) else "",
                        "CPU Affinity": vm_config.get('affinity', 'N/A') if not (args.sort and vm_row_added) else "",
                        "CPU Units": vm_config.get('cpuunits', 'N/A') if not (args.sort and vm_row_added) else "",
                        "RAM (GiB)": vm_config.get('memory', 'N/A') if not (args.sort and vm_row_added) else "",
                        "NVMe Device": device["NVMe Device"],
                        "PCI Address": device["PCI Address"],
                        "NUMA": device["NUMA Node"],
                        "Model": device["Model"],
                        "Serial": device["Serial"],
                        "Size": device["Size"],
                        "Mount Points": device["Mount Points"],
                        "NIC": ', '.join(vm_config.get('nics', [])) if 'nics' in vm_config else "virtio"
                    }
                    final_table.append(row)
                    vm_row_added = True

    # Add the VM even if it has no PCI or SCSI devices
    if not vm_row_added:
        row = {
            "VM ID": vmid,
            "VM Name": vmname,
            "CPU Cores": vm_config.get('cores', 'N/A'),
            "CPU Affinity": vm_config.get('affinity', 'N/A'),
            "CPU Units": vm_config.get('cpuunits', 'N/A'),
            "RAM (GiB)": vm_config.get('memory', 'N/A'),
            "NVMe Device": "N/A",
            "PCI Address": "N/A",
            "NUMA": "N/A",
            "Model": "N/A",
            "Serial": "N/A",
            "Size": "N/A",
            "Mount Points": "N/A",
            "NIC": 'virtio'
        }
        final_table.append(row)

# Add unmounted devices
for device in nvme_devices:
    # Check if the device has already been added by VM matching
    if not any(row["NVMe Device"] == device["NVMe Device"] for row in final_table):
        row = {
            "VM ID": "N/A",
            "VM Name": "N/A",
            "CPU Cores": "N/A",
            "CPU Affinity": "N/A",
            "CPU Units": "N/A",
            "RAM (GiB)": "N/A",
            "NVMe Device": device["NVMe Device"],
            "PCI Address": device["PCI Address"],
            "NUMA": device["NUMA Node"],
            "Model": device["Model"],
            "Serial": device["Serial"],
            "Size": device["Size"],
            "Mount Points": device["Mount Points"],
            "NIC": "N/A"
        }
        final_table.append(row)

# Preserve original order based on the order in nvme_devices
device_order = {device["NVMe Device"]: index for index, device in enumerate(nvme_devices)}
final_table.sort(key=lambda x: device_order.get(x["NVMe Device"], len(nvme_devices)))

# Sort by VM ID if requested
if args.sort == 'vmid':
    final_table.sort(key=lambda x: (str(x["VM ID"]), not bool(x["VM Name"]), device_order.get(x["NVMe Device"], len(nvme_devices))))

# Generate and print the final table using tabulate
header = ["VM ID", "VM Name", "CPU Cores", "CPU Affinity", "CPU Units", "RAM (GiB)", "NVMe Device", "PCI Address", "NUMA", "Model", "Serial", "Size", "Mount Points", "NIC"]
table = [[row["VM ID"], row["VM Name"], row["CPU Cores"], row["CPU Affinity"], row["CPU Units"], row["RAM (GiB)"], row["NVMe Device"], row["PCI Address"], row["NUMA"], row["Model"], row["Serial"], row["Size"], row["Mount Points"], row["NIC"]] for row in final_table]
print(tabulate(table, headers=header, tablefmt="grid"))
 
Forgot to mention, this needs to run immediately after boot, before any of the VMs are running
 

About

The Proxmox community has been around for many years and offers help and support for Proxmox VE, Proxmox Backup Server, and Proxmox Mail Gateway.
We think our community is one of the best thanks to people like you!

Get your subscription!

The Proxmox team works very hard to make sure you are running the best software and getting stable updates and security enhancements, as well as quick enterprise support. Tens of thousands of happy customers have a Proxmox subscription. Get yours easily in our online shop.

Buy now!