pve-esxi-import-tools/listvms.py
Daniel Herzig d793cd1834 listvms: exclude VMs without datastore_name from manifest.json
On newer ESXI-8x versions, the diskless vCLS machines in an
ESXI-cluster are not stored on a datastore anymore.

Instead, they are placed under `/var/run/crx` on the ESXI-hosts'
filesystem. This can lead to issues with the ESXI-storage not being
activated on the PVE-side [0].

This commit prevents these machines from being included in `manifest.json`.

It also excludes VMs without a datastore string in its configuration.

[0] https://forum.proxmox.com/threads/new-import-wizard-available-for-migrating-vmware-esxi-based-virtual-machines.144023/post-759288
2025-04-08 17:13:37 +02:00

299 lines
7.9 KiB
Python
Executable File

#!/usr/bin/python3
import argparse
import dataclasses
import json
import ssl
import sys
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Generator
from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="listvms",
description="List VMs on an ESXi host.",
)
parser.add_argument(
"--skip-cert-verification",
help="Skip the verification of TLS certs, e.g. to allow self-signed"
" certs.",
action="store_true",
)
parser.add_argument(
"--port",
type=int,
metavar='PORT',
default=443,
help="Use a port other than 443."
)
parser.add_argument(
"hostname",
help="The name or address of the ESXi host.",
)
parser.add_argument(
"username",
help="The name of the user to connect with.",
)
parser.add_argument(
"password_file",
help="The file which contains the password for the provided username.",
type=Path,
)
return parser.parse_args()
@dataclass
class EsxiConnectonArgs:
hostname: str
port: int
username: str
password_file: Path
skip_cert_verification: bool = False
@contextmanager
def connect_to_esxi_host(
args: EsxiConnectonArgs,
) -> Generator[vim.ServiceInstance, None, None]:
"""Opens a connection to an ESXi host with the given username and password
contained in the password file.
"""
ssl_context = (
ssl._create_unverified_context()
if args.skip_cert_verification
else None
)
try:
with open(args.password_file) as pw_file:
password = pw_file.read()
if password.endswith("\n"):
password = password[:-1]
except FileNotFoundError:
raise Exception(f"failed to find password file {args.password_file}")
connection = None
try:
connection = SmartConnect(
host=args.hostname,
port=args.port,
user=args.username,
pwd=password,
sslContext=ssl_context,
)
yield connection
except ssl.SSLCertVerificationError:
raise ConnectionError(
"Failed to verify certificate - add the CA of your ESXi to the "
"system trust store or skip verification",
)
except vim.fault.InvalidLogin:
raise ConnectionError(
"failed to login due to an incorrect username or password",
)
finally:
if connection is not None:
Disconnect(connection)
@dataclass
class VmVmxInfo:
datastore: str
path: str
checksum: str
@dataclass
class VmDiskInfo:
datastore: str
path: str
capacity: int
@dataclass
class VmInfo:
config: VmVmxInfo
disks: list[VmDiskInfo]
power: str
def json_dump_helper(obj: Any) -> Any:
"""Converts otherwise unserializable objects to types that can be
serialized as JSON.
Raises:
TypeError: If the conversion of the object is not supported.
"""
if dataclasses.is_dataclass(obj):
return dataclasses.asdict(obj)
raise TypeError(
f"Can't make object of type {type(obj)} JSON-serializable: {repr(obj)}"
)
def get_datacenter_of_vm(vm: vim.VirtualMachine) -> vim.Datacenter | None:
"""Find the Datacenter object a VM belongs to."""
current = vm.parent
while current:
if isinstance(current, vim.Datacenter):
return current
current = current.parent
return None
def list_vms(service_instance: vim.ServiceInstance) -> list[vim.VirtualMachine]:
"""List all VMs on the ESXi/vCenter server."""
content = service_instance.content
vm_view: Any = content.viewManager.CreateContainerView(
content.rootFolder,
[vim.VirtualMachine],
True,
)
vms = vm_view.view
vm_view.Destroy()
return vms
def parse_file_path(path) -> tuple[str, str]:
"""Parse a path of the form '[datastore] file/path'"""
datastore_name, relative_path = path.split("] ", 1)
datastore_name = datastore_name.strip("[")
return (datastore_name, relative_path)
def get_vm_vmx_info(vm: vim.VirtualMachine) -> VmVmxInfo:
"""Extract VMX file path and checksum from a VM object."""
datastore_name, relative_vmx_path = parse_file_path(
vm.config.files.vmPathName
)
return VmVmxInfo(
datastore=datastore_name,
path=relative_vmx_path,
checksum=vm.config.vmxConfigChecksum.hex()
if vm.config.vmxConfigChecksum
else "N/A",
)
def get_vm_disk_info(vm: vim.VirtualMachine) -> list[VmDiskInfo]:
disks = []
for device in vm.config.hardware.device:
if isinstance(device, vim.vm.device.VirtualDisk):
try:
(datastore, path) = parse_file_path(device.backing.fileName)
capacity = device.capacityInBytes
disks.append(VmDiskInfo(datastore, path, capacity))
except Exception as err:
# if we can't figure out the disk stuff that's fine...
print(
"failed to get disk information for esxi vm: ",
err,
file=sys.stderr,
)
return disks
def get_all_datacenters(
service_instance: vim.ServiceInstance,
) -> list[vim.Datacenter]:
"""Retrieve all datacenters from the ESXi/vCenter server."""
content = service_instance.content
dc_view: Any = content.viewManager.CreateContainerView(
content.rootFolder, [vim.Datacenter], True
)
datacenters = dc_view.view
dc_view.Destroy()
return datacenters
def fetch_and_update_vm_data(vm: vim.VirtualMachine, data: dict[Any, Any]):
"""Fetches all required VM, datastore and datacenter information, and
then updates the given `dict`.
Raises:
RuntimeError: If looking up the datacenter for the given VM fails.
"""
datacenter = get_datacenter_of_vm(vm)
if datacenter is None:
raise RuntimeError(f"Failed to lookup datacenter for VM {vm.name}")
data.setdefault(datacenter.name, {})
vms = data[datacenter.name].setdefault("vms", {})
datastores = data[datacenter.name].setdefault("datastores", {})
vms[vm.name] = VmInfo(
config=get_vm_vmx_info(vm),
disks=get_vm_disk_info(vm),
power=str(vm.runtime.powerState),
)
datastores.update({ds.name: ds.url for ds in vm.config.datastoreUrl})
def main():
args = parse_args()
connection_args = EsxiConnectonArgs(
hostname=args.hostname,
port=args.port,
username=args.username,
password_file=args.password_file,
skip_cert_verification=args.skip_cert_verification,
)
with connect_to_esxi_host(connection_args) as connection:
data = {}
for vm in list_vms(connection):
# drop vCLS machines
vCLS = any(cfg.key == "HDCS.agent"
and cfg.value.lower() == "true"
for cfg in vm.config.extraConfig)
if vCLS:
continue
# drop vms with empty datastore
datastore_name, relative_vmx_path = parse_file_path(
vm.config.files.vmPathName
)
if not datastore_name:
print(f"Skipping VM (no datastore value): {vm.name}",
file=sys.stderr)
continue
try:
fetch_and_update_vm_data(vm, data)
except Exception as err:
print(
f"Failed to get info for VM {vm.name}: {err}",
file=sys.stderr,
)
json.dump(data, sys.stdout, indent=2, default=json_dump_helper)
if __name__ == "__main__":
try:
main()
except Exception as err:
print(err, file=sys.stderr)
sys.exit(1)