diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4a3820a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**vm_manager** is a Python tool for managing Virtual Machines on the SEAPATH platform. It operates in two modes, auto-detected at import time (`__init__.py`): +- **Standalone mode** (`vm_manager_libvirt.py`): manages VMs via KVM/libvirt only +- **Cluster mode** (`vm_manager_cluster.py`): manages VMs on a Pacemaker HA cluster with Ceph RBD storage + +## Build & Install + +```bash +# Install locally (or use cqfd for containerized builds) +pip install . + +# Using cqfd (Docker-based build wrapper, config in .cqfdrc) +cqfd +``` + +## Linting & Formatting + +```bash +# Format with Black (line length 79, Python 3.8 target) +black -l 79 -t py38 . + +# Check formatting without modifying +black -l 79 -t py38 --check . + +# Flake8 +python3 -m flake8 --ignore=E501,W503 + +# Pylint +pylint pacemaker_helper rbd_helper vm_manager + +# Via cqfd flavors +cqfd -b check_format # check formatting +cqfd -b format # auto-format +cqfd -b flake # flake8 +cqfd -b check # pylint +``` + +## Tests + +Tests are integration scripts requiring a real Ceph/Pacemaker cluster. Run individually: + +```bash +python3 -m vm_manager.helpers.tests.rbd_manager.clone_rbd +python3 -m vm_manager.helpers.tests.pacemaker.add_vm +``` + +Test scripts are in `vm_manager/helpers/tests/pacemaker/` and `vm_manager/helpers/tests/rbd_manager/`. + +## Architecture + +**Entry points** (defined in `pyproject.toml [project.scripts]`): +- `vm_manager_cmd` — CLI (argparse) exposing all VM operations as subcommands +- `libvirt_cmd` — Standalone CLI for libvirt-only operations +- `vm_manager_api.py` — Flask REST API (`/`, `/status/`, `/stop/`, `/start/`) + +**Mode selection** (`__init__.py`): tries to import `RbdManager` and `Pacemaker`. If both succeed, public API functions (`list_vms`, `create`, `remove`, `start`, `stop`, etc.) come from `vm_manager_cluster.py`; otherwise from `vm_manager_libvirt.py`. + +**Helper classes** (all used as context managers with `with` statements): +- `LibVirtManager` (`helpers/libvirt.py`): wraps `libvirt-python` for domain management +- `Pacemaker` (`helpers/pacemaker.py`): wraps `crm` CLI via `subprocess.run()` +- `RbdManager` (`helpers/rbd_manager.py`): wraps Ceph `rados`/`rbd` Python bindings + +## Conventions + +- **Python target**: 3.8+ +- **Line length**: 79 characters +- **License**: Apache-2.0 (all source files have copyright headers) +- **Code review**: Gerrit (`.gitreview` → `g1.sfl.team`) +- **VM naming**: alphanumeric only, validated by `_check_name()` +- **Disk naming**: system disks prefixed with `system_` (`OS_DISK_PREFIX` constant) +- **Flake8 config** (`.flake8`): ignores F401 in `__init__.py`, E501 and W503 globally +- **Custom exceptions**: `RbdException`, `PacemakerException` diff --git a/vm_manager/__init__.py b/vm_manager/__init__.py index 30857a2..5f0ee17 100644 --- a/vm_manager/__init__.py +++ b/vm_manager/__init__.py @@ -43,4 +43,5 @@ start, stop, status, + autostart, ) diff --git a/vm_manager/helpers/libvirt.py b/vm_manager/helpers/libvirt.py index 2e149f0..5fbd3a9 100644 --- a/vm_manager/helpers/libvirt.py +++ b/vm_manager/helpers/libvirt.py @@ -82,6 +82,14 @@ def undefine(self, vm_name): domain = self._conn.lookupByName(vm_name) domain.undefineFlags(libvirt.VIR_DOMAIN_UNDEFINE_NVRAM) + def set_autostart(self, vm_name, enabled): + """ + Set the autostart flag on a VM + :param vm_name: the VM name + :param enabled: True to enable autostart, False to disable + """ + self._conn.lookupByName(vm_name).setAutostart(1 if enabled else 0) + def start(self, vm_name): """ Start a VM diff --git a/vm_manager/helpers/libvirt_cmd.py b/vm_manager/helpers/libvirt_cmd.py index ccf76bc..7988baf 100755 --- a/vm_manager/helpers/libvirt_cmd.py +++ b/vm_manager/helpers/libvirt_cmd.py @@ -9,6 +9,7 @@ import argparse from vm_manager.helpers.libvirt import LibVirtManager + def main(): parser = argparse.ArgumentParser(description="libvirt helper cli wrapper") subparsers = parser.add_subparsers( @@ -48,5 +49,6 @@ def main(): elif args.command == "export": LibVirtManager.export_configuration(args.domain, args.destination) + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/pacemaker.py b/vm_manager/helpers/pacemaker.py index f48057e..47bdf10 100644 --- a/vm_manager/helpers/pacemaker.py +++ b/vm_manager/helpers/pacemaker.py @@ -428,10 +428,12 @@ def find_resource(resource): :return: the node where the resource is running or None if the resource is not running or not found """ - command = "crm status | " + \ - f'grep -E "^ \* {resource}\\b" | ' + \ - "grep Started | " + \ - "awk 'NF>1{print $NF}'" + command = ( + "crm status | " + + f'grep -E "^ \* {resource}\\b" | ' + + "grep Started | " + + "awk 'NF>1{print $NF}'" + ) ret = subprocess.run(command, shell=True, stdout=subprocess.PIPE) host = ret.stdout.decode().strip() if host == "": diff --git a/vm_manager/helpers/rbd_manager.py b/vm_manager/helpers/rbd_manager.py index e427275..1aad0ea 100644 --- a/vm_manager/helpers/rbd_manager.py +++ b/vm_manager/helpers/rbd_manager.py @@ -145,7 +145,7 @@ def create_image(self, img, size, overwrite=True): if not isinstance(size, int): unit_list = ["B", "K", "M", "G", "T"] i = unit_list.index(size[-1]) - size = int(size[:-1]) * 1024**i + size = int(size[:-1]) * 1024 ** i if self.image_exists(img): if overwrite: diff --git a/vm_manager/helpers/tests/pacemaker/add_vm.py b/vm_manager/helpers/tests/pacemaker/add_vm.py index 9db964c..423b153 100755 --- a/vm_manager/helpers/tests/pacemaker/add_vm.py +++ b/vm_manager/helpers/tests/pacemaker/add_vm.py @@ -20,6 +20,7 @@ MIGRATE_FROM_TIMEOUT = "60" MIGRATE_TO_TIMEOUT = "120" + def main(): with Pacemaker(VM_NAME) as p: @@ -49,5 +50,6 @@ def main(): p.wait_for("Started") + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/pacemaker/remove_vm.py b/vm_manager/helpers/tests/pacemaker/remove_vm.py index 48a7eb0..00e3160 100755 --- a/vm_manager/helpers/tests/pacemaker/remove_vm.py +++ b/vm_manager/helpers/tests/pacemaker/remove_vm.py @@ -14,6 +14,7 @@ SLEEP = 1 VM_NAME = "vm1" + def main(): with Pacemaker(VM_NAME) as p: @@ -36,5 +37,6 @@ def main(): if VM_NAME in resources: raise Exception("Resource " + VM_NAME + " was not removed") + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/pacemaker/start_vm.py b/vm_manager/helpers/tests/pacemaker/start_vm.py index 0274263..a5b2584 100755 --- a/vm_manager/helpers/tests/pacemaker/start_vm.py +++ b/vm_manager/helpers/tests/pacemaker/start_vm.py @@ -11,6 +11,7 @@ VM_NAME = "vm1" SLEEP = 1 + def main(): with Pacemaker(VM_NAME) as p: @@ -26,5 +27,6 @@ def main(): else: raise Exception("VM is already started") + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/pacemaker/stop_vm.py b/vm_manager/helpers/tests/pacemaker/stop_vm.py index 2dbcce2..450ec30 100755 --- a/vm_manager/helpers/tests/pacemaker/stop_vm.py +++ b/vm_manager/helpers/tests/pacemaker/stop_vm.py @@ -11,6 +11,7 @@ VM_NAME = "vm1" SLEEP = 1 + def main(): with Pacemaker(VM_NAME) as p: @@ -25,5 +26,6 @@ def main(): else: raise Exception("Machine is already stopped") + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/rbd_manager/clone_rbd.py b/vm_manager/helpers/tests/rbd_manager/clone_rbd.py index 8353f87..eeeda28 100755 --- a/vm_manager/helpers/tests/rbd_manager/clone_rbd.py +++ b/vm_manager/helpers/tests/rbd_manager/clone_rbd.py @@ -18,6 +18,7 @@ SNAP = "snap" TEXT = "Hello world" + def main(): with RbdManager(CEPH_CONF, POOL_NAME) as rbd: @@ -119,5 +120,6 @@ def main(): rbd.remove_image(img) print("Image list: " + str(rbd.list_images())) + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/rbd_manager/create_rbd_group.py b/vm_manager/helpers/tests/rbd_manager/create_rbd_group.py index 587278c..a2b5e37 100755 --- a/vm_manager/helpers/tests/rbd_manager/create_rbd_group.py +++ b/vm_manager/helpers/tests/rbd_manager/create_rbd_group.py @@ -16,6 +16,7 @@ IMG_NAME = "img1" GROUP = "group1" + def main(): with RbdManager(CEPH_CONF, POOL_NAME) as rbd: @@ -105,5 +106,6 @@ def main(): rbd.remove_image(IMG_NAME) print("Image list: " + str(rbd.list_images())) + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/rbd_manager/create_rbd_namespace.py b/vm_manager/helpers/tests/rbd_manager/create_rbd_namespace.py index 7bcc8e2..ba6a455 100755 --- a/vm_manager/helpers/tests/rbd_manager/create_rbd_namespace.py +++ b/vm_manager/helpers/tests/rbd_manager/create_rbd_namespace.py @@ -18,6 +18,7 @@ NS1 = "namespace1" NS2 = "namespace2" + def main(): with RbdManager(CEPH_CONF, POOL_NAME) as rbd: @@ -129,5 +130,6 @@ def main(): rbd.remove_namespace(ns) print("Namespace list: " + str(rbd.list_namespaces())) + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/rbd_manager/metadata_rbd.py b/vm_manager/helpers/tests/rbd_manager/metadata_rbd.py index 7eac725..9616c84 100755 --- a/vm_manager/helpers/tests/rbd_manager/metadata_rbd.py +++ b/vm_manager/helpers/tests/rbd_manager/metadata_rbd.py @@ -20,6 +20,7 @@ "test4": "metadatatest4", } + def main(): with RbdManager(CEPH_CONF, POOL_NAME) as rbd: @@ -71,5 +72,6 @@ def main(): rbd.remove_image(IMG_NAME) print("Image list: " + str(rbd.list_images())) + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/rbd_manager/purge_rbd.py b/vm_manager/helpers/tests/rbd_manager/purge_rbd.py index 2500cac..3f622f8 100755 --- a/vm_manager/helpers/tests/rbd_manager/purge_rbd.py +++ b/vm_manager/helpers/tests/rbd_manager/purge_rbd.py @@ -19,6 +19,7 @@ SNAPS = ["snap0", "snap1", "snap2", "snap3", "snap4", "snap5"] GROUP = "group1" + def main(): with RbdManager(CEPH_CONF, POOL_NAME) as rbd: @@ -163,5 +164,6 @@ def main(): rbd.remove_image(IMG_NAME) # remove forces purge print("Image list: " + str(rbd.list_images())) + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/rbd_manager/rollback_rbd.py b/vm_manager/helpers/tests/rbd_manager/rollback_rbd.py index 1d5a072..c473651 100755 --- a/vm_manager/helpers/tests/rbd_manager/rollback_rbd.py +++ b/vm_manager/helpers/tests/rbd_manager/rollback_rbd.py @@ -172,5 +172,6 @@ def main(): finally: cleanup(rbd) + if __name__ == "__main__": main() diff --git a/vm_manager/helpers/tests/rbd_manager/write_rbd.py b/vm_manager/helpers/tests/rbd_manager/write_rbd.py index d8444fa..935e20e 100755 --- a/vm_manager/helpers/tests/rbd_manager/write_rbd.py +++ b/vm_manager/helpers/tests/rbd_manager/write_rbd.py @@ -16,6 +16,7 @@ IMG_NAME = "img1" TEXT = "Hello world" + def main(): with RbdManager(CEPH_CONF, POOL_NAME) as rbd: @@ -60,5 +61,6 @@ def main(): rbd.remove_image(IMG_NAME) print("Image list: " + str(rbd.list_images())) + if __name__ == "__main__": main() diff --git a/vm_manager/vm_manager_api.py b/vm_manager/vm_manager_api.py index d195eb1..27d14c1 100755 --- a/vm_manager/vm_manager_api.py +++ b/vm_manager/vm_manager_api.py @@ -46,5 +46,6 @@ def start_vm(guest): def main(): app.run(host="0.0.0.0") + if __name__ == "__main__": main() diff --git a/vm_manager/vm_manager_cluster.py b/vm_manager/vm_manager_cluster.py index 56d2a81..e6d9b76 100644 --- a/vm_manager/vm_manager_cluster.py +++ b/vm_manager/vm_manager_cluster.py @@ -71,6 +71,7 @@ def _create_vm_group(vm_name, force=False): logger.info("VM group " + vm_name + " created successfully") + def _get_ceph_hosts_xml(port=6789): """ Runs 'ceph orch host ls' to retrieve hostnames and returns @@ -79,14 +80,23 @@ def _get_ceph_hosts_xml(port=6789): :param port: Port number to include in the XML (default 6789) :return: str - XML lines """ - cmd = ["bash", "-c", "ceph orch host ls -f json-pretty | jq -r '.[].hostname'"] + cmd = [ + "bash", + "-c", + "ceph orch host ls -f json-pretty | jq -r '.[].hostname'", + ] result = subprocess.run(cmd, capture_output=True, text=True, check=True) - hostnames = [line.strip() for line in result.stdout.splitlines() if line.strip()] + hostnames = [ + line.strip() for line in result.stdout.splitlines() if line.strip() + ] - xml_lines = "\n".join(f'' for h in hostnames) + xml_lines = "\n".join( + f'' for h in hostnames + ) return xml_lines + def _create_xml(xml, vm_name, target_disk_bus="virtio"): """ Creates a libvirt configuration file according to xml and @@ -116,7 +126,8 @@ def _create_xml(xml, vm_name, target_disk_bus="virtio"): rbd_secret = secret_value if not rbd_secret: raise Exception("Can't found rbd secret") - disk_xml = ElementTree.fromstring(""" + disk_xml = ElementTree.fromstring( + """ @@ -135,13 +146,16 @@ def _create_xml(xml, vm_name, target_disk_bus="virtio"): ElementTree.indent(xml_root, space=" ") return ElementTree.tostring(xml_root, encoding="unicode") + def _configure_vm(vm_options): """ Configure VM vm_name: set initial metadata, define libvirt xml configuration and add it on Pacemaker if enable is set to True. """ - xml = _create_xml(vm_options["base_xml"], vm_options["name"], vm_options["disk_bus"]) + xml = _create_xml( + vm_options["base_xml"], vm_options["name"], vm_options["disk_bus"] + ) # Add to group and set initial metadata with RbdManager(CEPH_CONF, POOL_NAME, NAMESPACE) as rbd: @@ -245,21 +259,23 @@ def _get_observer_host(): else: return None + def _get_remote_nodes(): """ Get the remote nodes from the crm_mon xml """ - pacemaker_xml = ElementTree.fromstring(subprocess.getoutput("crm_mon --output-as xml")) - nodes = pacemaker_xml.find('nodes') + pacemaker_xml = ElementTree.fromstring( + subprocess.getoutput("crm_mon --output-as xml") + ) + nodes = pacemaker_xml.find("nodes") remote_nodes = [] for node in nodes: - if(node.get('type') == "remote"): - remote_nodes.append(node.get('name')) + if node.get("type") == "remote": + remote_nodes.append(node.get("name")) return remote_nodes - def list_vms(enabled=False): """ Return a list of the VMs. @@ -551,7 +567,7 @@ def enable_vm(vm_name, nostart=False): "custom_params": custom_params, "custom_utilization": custom_utilization, } - p.add_vm(vm_options,nostart) + p.add_vm(vm_options, nostart) if vm_name not in p.list_resources(): raise Exception( @@ -735,7 +751,9 @@ def clone(vm_options_with_nones): src_disk, "_base_xml" ) except KeyError as e: - logger.error(f"Could not get xml libvirt configuration, {src_disk} has no _base_xml metadata") + logger.error( + f"Could not get xml libvirt configuration, {src_disk} has no _base_xml metadata" + ) raise e if ( "clear_constraint" not in vm_options @@ -758,11 +776,15 @@ def clone(vm_options_with_nones): if "pinned_host" in vm_options and not Pacemaker.is_valid_host( vm_options["pinned_host"] ): - raise ValueError(f"{vm_options['pinned_host']} is not valid hypervisor") + raise ValueError( + f"{vm_options['pinned_host']} is not valid hypervisor" + ) elif "preferred_host" in vm_options and not Pacemaker.is_valid_host( vm_options["preferred_host"] ): - raise ValueError(f"{vm_options['preferred_host']} is not valid hypervisor") + raise ValueError( + f"{vm_options['preferred_host']} is not valid hypervisor" + ) for pacemaker_arg in ( "pacemaker_meta", "pacemaker_params", @@ -833,9 +855,13 @@ def clone(vm_options_with_nones): ) try: - vm_options["disk_bus"] = rbd.get_image_metadata(src_disk, "_disk_bus") + vm_options["disk_bus"] = rbd.get_image_metadata( + src_disk, "_disk_bus" + ) except KeyError: - logger.warning(f"{src_disk} has no disk_bus metadata, set it to virtio for VM {dst_vm_name}") + logger.warning( + f"{src_disk} has no disk_bus metadata, set it to virtio for VM {dst_vm_name}" + ) vm_options["disk_bus"] = "virtio" # Configure VM @@ -1147,7 +1173,9 @@ def console(vm_name, ssh_user="libvirtadmin"): # First we need to get the hypervisor where the VM is running host = Pacemaker.find_resource(vm_name) if not host: - print(f"VM {vm_name} is not running on any hypervisor", file=sys.stderr) + print( + f"VM {vm_name} is not running on any hypervisor", file=sys.stderr + ) sys.exit(1) libvirt_uri = f"qemu+ssh://{ssh_user}@{host}/system" logger.debug(f"Opening console for VM {vm_name} on {libvirt_uri}") diff --git a/vm_manager/vm_manager_cmd.py b/vm_manager/vm_manager_cmd.py index f19d4ac..264e1a2 100755 --- a/vm_manager/vm_manager_cmd.py +++ b/vm_manager/vm_manager_cmd.py @@ -48,8 +48,35 @@ def main(): stop_parser = subparsers.add_parser("stop", help="Stop a VM") subparsers.add_parser("list", help="List all VMs") subparsers.add_parser("status", help="Print VM status") - console_parser = subparsers.add_parser("console", help="Connect to a VM console") + console_parser = subparsers.add_parser( + "console", help="Connect to a VM console" + ) + if not vm_manager.cluster_mode: + create_parser.add_argument( + "--no-autostart", + action="store_true", + required=False, + help="Do not enable autostart on the VM", + ) + autostart_parser = subparsers.add_parser( + "autostart", help="Set the autostart flag on a VM" + ) + autostart_group = autostart_parser.add_mutually_exclusive_group( + required=True + ) + autostart_group.add_argument( + "--enable", + action="store_true", + default=False, + help="Enable autostart", + ) + autostart_group.add_argument( + "--disable", + action="store_true", + default=False, + help="Disable autostart", + ) if vm_manager.cluster_mode: clone_parser = subparsers.add_parser("clone", help="Clone a VM") @@ -262,7 +289,7 @@ def main(): metavar="key=value", required=False, help='Set a key-value pacemaker "meta".' - " Can be used multiple times. " + " Can be used multiple times. " "(do not put spaces before or after the = sign)", nargs="+", action=ParseMetaData, @@ -273,7 +300,7 @@ def main(): metavar="key=value", required=False, help='Set a key-value pacemaker "params".' - " Can be used multiple times. " + " Can be used multiple times. " "(do not put spaces before or after the = sign)", nargs="+", action=ParseMetaData, @@ -284,7 +311,7 @@ def main(): metavar="key=value", required=False, help='Set a key-value pacemaker "utilization".' - " Can be used multiple times. " + " Can be used multiple times. " "(do not put spaces before or after the = sign)", nargs="+", action=ParseMetaData, @@ -441,7 +468,12 @@ def main(): if args.command == "list": print("\n".join(vm_manager.list_vms())) elif args.command == "start": - vm_manager.start(args.name) + if vm_manager.cluster_mode: + vm_manager.start(args.name) + else: + vm_manager.start( + args.name, autostart=not args.no_autostart + ) elif args.command == "stop": vm_manager.stop(args.name, force=args.force) elif args.command == "remove": @@ -449,12 +481,16 @@ def main(): elif args.command == "create": with open(args.xml, "r") as xml: args.base_xml = xml.read() - args.live_migration = args.enable_live_migration - args.crm_config_cmd = args.add_crm_config_cmd - if args.disable: - args.enable = not args.disable + if "live_migration" in args: + args.live_migration = args.enable_live_migration + if "add_crm_config_cmd" in args: + args.crm_config_cmd = args.add_crm_config_cmd + if "disable" in args and args.disable: + if "enable" in args: + args.enable = not args.disable else: - args.enable = True + if "enable" in args: + args.enable = True vm_manager.create(vars(args)) elif args.command == "clone": args.base_xml = None @@ -502,11 +538,14 @@ def main(): remote_node_port=args.remote_port, remote_node_timeout=args.remote_timeout, ) + elif args.command == "autostart": + vm_manager.autostart(args.name, args.enable) elif args.command == "console": if vm_manager.cluster_mode: vm_manager.console(args.name, args.ssh_user) else: vm_manager.console(args.name) + if __name__ == "__main__": main() diff --git a/vm_manager/vm_manager_libvirt.py b/vm_manager/vm_manager_libvirt.py index 305091e..f720fdd 100644 --- a/vm_manager/vm_manager_libvirt.py +++ b/vm_manager/vm_manager_libvirt.py @@ -40,19 +40,20 @@ def _create_xml(xml, vm_name): return ElementTree.tostring(xml_root).decode() -def create(vm_name, base_xml, *args, **kwargs): +def create(args): """ Create a new VM :param vm_name: the VM name :param base_xml: the VM libvirt xml configuration """ - - xml = _create_xml(base_xml, vm_name) + xml = _create_xml(args.get("base_xml"), args.get("name")) with LibVirtManager() as lvm: lvm.define(xml) + if args.get("autostart"): + lvm.set_autostart(args.get("name"), True) - logger.info("VM " + vm_name + " created successfully") + logger.info("VM " + args.get("name") + " created successfully") def remove(vm_name): @@ -75,6 +76,7 @@ def start(vm_name): Start or resume a stopped or paused VM The VM must enabled before being started :param vm_name: the VM to be started + :param autostart: if True, enable autostart on the VM """ with LibVirtManager() as lvm: lvm.start(vm_name) @@ -82,6 +84,19 @@ def start(vm_name): logger.info("VM " + vm_name + " started") +def autostart(vm_name, enabled): + """ + Set the autostart flag on a VM + :param vm_name: the VM name + :param enabled: True to enable autostart, False to disable + """ + with LibVirtManager() as lvm: + lvm.set_autostart(vm_name, enabled) + + state = "enabled" if enabled else "disabled" + logger.info("VM " + vm_name + " autostart " + state) + + def stop(vm_name, force=False): """ Stop a VM @@ -107,6 +122,7 @@ def status(vm_name): with LibVirtManager() as lvm: return lvm.status(vm_name) + def console(vm_name): """ Open a virsh console for the given VM