From: Max Carrara <m.carrara@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH v2 pve-esxi-import-tools 4/7] listvms: add arg parser, context manager for connections, fetch helper
Date: Fri, 22 Mar 2024 19:06:21 +0100 [thread overview]
Message-ID: <20240322180624.441185-5-m.carrara@proxmox.com> (raw)
In-Reply-To: <20240322180624.441185-1-m.carrara@proxmox.com>
In order to make the CLI interface more friendly to humans, Python's
`argparse` [0] module from the standard library is used to parse the
arguments provided to the script. Each option and positional argument
also contain a short help text that is shown when running the script
with either "-h" or "--help".
Additionally, this commit also adds a context manager [1] for
establishing connections to an ESXi host. The context manager ensures
that the connection is closed in its inner `finally` block.
The inner part of the VM-data-fetching loop in `main()` is factored
out into a separate helper function, which now raises a `RuntimeError`
if the datacenter of a VM cannot be looked up.
In general, should any exception be thrown inside the loop, its output
is subsequently logged to stderr. The loop then just continues like
before.
Any exception that is not caught inside of `main()` is now printed to
stderr, followed by exiting with `1`.
Overall, the script's behaviour and output on successful operations
remains the same, except regarding unsuccessful argument parsing and
displaying error messages. In other words, invocations prior to this
patch should result in the same JSON output (if successful).
This was tested by piping the outputs of this script before and after
this commit through `jq` and then comparing the outputs with `diff`.
[0]: https://docs.python.org/3.11/library/argparse.html
[1]: https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager
Signed-off-by: Max Carrara <m.carrara@proxmox.com>
---
Changes v1 --> v2:
* rebase onto master
* use `Generator` as return type for the ESXi connection context
manager
* do not strip all whitespace from the read password file and retain
original behaviour of only removing a trailing newline
listvms.py | 195 ++++++++++++++++++++++++++++++++++++++---------------
1 file changed, 142 insertions(+), 53 deletions(-)
diff --git a/listvms.py b/listvms.py
index fe257a4..354844b 100755
--- a/listvms.py
+++ b/listvms.py
@@ -1,18 +1,113 @@
#!/usr/bin/python3
+import argparse
import dataclasses
import json
import ssl
import sys
+import textwrap
+from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
-from typing import Any
+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.",
+ )
+
+ def _squeeze_and_wrap(text: str) -> str:
+ """Makes it easier to write help text using multiline strings."""
+ text = " ".join(text.split())
+
+ return "\n".join(textwrap.wrap(text, 60, break_on_hyphens=False))
+
+ parser.add_argument(
+ "--skip-cert-verification",
+ help=_squeeze_and_wrap(
+ """Skip the verification of TLS certs, e.g. to allow self-signed
+ certs."""
+ ),
+ action="store_true",
+ )
+
+ parser.add_argument(
+ "hostname",
+ help=_squeeze_and_wrap("""The name or address of the ESXi host."""),
+ )
+
+ parser.add_argument(
+ "username",
+ help=_squeeze_and_wrap("""The name of the user to connect with."""),
+ )
+
+ parser.add_argument(
+ "password_file",
+ help=_squeeze_and_wrap(
+ """The file which contains the password for the provided
+ username."""
+ ),
+ type=Path,
+ )
+
+ return parser.parse_args()
+
+
+@dataclass
+class EsxiConnectonArgs:
+ hostname: str
+ 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
+ )
+
+ with open(args.password_file) as pw_file:
+ password = pw_file.read()
+ if password.endswith("\n"):
+ password = password[:-1]
+
+ connection = None
+
+ try:
+ connection = SmartConnect(
+ host=args.hostname,
+ 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",
+ )
+
+ finally:
+ if connection is not None:
+ Disconnect(connection)
+
+
@dataclass
class VmVmxInfo:
datastore: str
@@ -112,65 +207,59 @@ def get_all_datacenters(service_instance: vim.ServiceInstance) -> list[vim.Datac
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():
- if sys.argv[1] == '--skip-cert-verification':
- del sys.argv[1]
- ssl_context = ssl._create_unverified_context()
- else:
- ssl_context = None
-
- esxi_host = sys.argv[1]
- esxi_user = sys.argv[2]
- esxi_password_file = sys.argv[3]
-
- esxi_password = ''
- with open(esxi_password_file) as f:
- esxi_password = f.read()
- if esxi_password.endswith('\n'):
- esxi_password = esxi_password[:-1]
+ args = parse_args()
- try:
- si = SmartConnect(
- host=esxi_host,
- user=esxi_user,
- pwd=esxi_password,
- sslContext=ssl_context,
- )
- except ssl.SSLCertVerificationError as err:
- print("failed to verify certificate - add the CA of your ESXi to the system trust store or skip verification", file=sys.stderr)
- sys.exit(1)
- except Exception as err:
- print(f"failed to connect: {err}", file=sys.stderr)
- sys.exit(1)
+ connection_args = EsxiConnectonArgs(
+ hostname=args.hostname,
+ username=args.username,
+ password_file=args.password_file,
+ skip_cert_verification=args.skip_cert_verification,
+ )
- try:
- vms = list_vms(si)
+ with connect_to_esxi_host(connection_args) as connection:
data = {}
- for vm in vms:
- name = 'vm ' + vm.name
+ for vm in list_vms(connection):
try:
- dc = get_datacenter_of_vm(vm)
- if dc is None:
- print(
- f"Failed to get datacenter for {name}",
- file=sys.stderr
- )
-
- vm_info = VmInfo(
- config=get_vm_vmx_info(vm),
- disks=get_vm_disk_info(vm),
- power=vm.runtime.powerState,
+ 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,
)
- datastore_info = {ds.name: ds.url for ds in vm.config.datastoreUrl}
- data.setdefault(dc.name, {}).setdefault('vms', {})[vm.name] = vm_info
- data.setdefault(dc.name, {}).setdefault('datastores', {}).update(datastore_info)
- except Exception as err:
- print("failed to get info for", name, ':', err, file=sys.stderr)
+ print(json.dumps(data, indent=2, default=json_dump_helper))
- print(json.dumps(data, indent=2, default=json_dump_helper))
- finally:
- Disconnect(si)
if __name__ == "__main__":
- main()
+ try:
+ main()
+ except Exception as err:
+ print(f"Encountered unexpected error: {err}", file=sys.stderr)
+ sys.exit(1)
--
2.39.2
next prev parent reply other threads:[~2024-03-22 18:07 UTC|newest]
Thread overview: 9+ messages / expand[flat|nested] mbox.gz Atom feed top
2024-03-22 18:06 [pve-devel] [PATCH v2 pve-esxi-import-tools 0/7] Improve listvms.py Max Carrara
2024-03-22 18:06 ` [pve-devel] [PATCH v2 pve-esxi-import-tools 1/7] listvms: remove unused import and variable Max Carrara
2024-03-22 18:06 ` [pve-devel] [PATCH v2 pve-esxi-import-tools 2/7] listvms: reorder imports Max Carrara
2024-03-22 18:06 ` [pve-devel] [PATCH v2 pve-esxi-import-tools 3/7] listvms: improve typing and add dataclasses to represent dicts Max Carrara
2024-03-22 18:06 ` Max Carrara [this message]
2024-03-22 18:06 ` [pve-devel] [PATCH v2 pve-esxi-import-tools 5/7] listvms: dump json directly to stdout Max Carrara
2024-03-22 18:06 ` [pve-devel] [PATCH v2 pve-esxi-import-tools 6/7] listvms: run formatter Max Carrara
2024-03-22 18:06 ` [pve-devel] [PATCH v2 pve-esxi-import-tools 7/7] use mypy for automatic type checks in Python Max Carrara
2024-03-27 10:50 ` [pve-devel] applied-series: [PATCH v2 pve-esxi-import-tools 0/7] Improve listvms.py Wolfgang Bumiller
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20240322180624.441185-5-m.carrara@proxmox.com \
--to=m.carrara@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox