From 3bb1e31c370877c8cc6ed325c376856aafe77b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20Dupr=C3=A9?= Date: Thu, 19 Feb 2026 09:37:24 +0100 Subject: [PATCH 1/4] Add CLAUDE.md with project guidance for Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Mathieu Dupré --- CLAUDE.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 CLAUDE.md 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` From a4419d54ab1557274f423120fc7a4545453b7782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20Dupr=C3=A9?= Date: Thu, 19 Feb 2026 09:42:20 +0100 Subject: [PATCH 2/4] cosmetic: reformat code with black MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use cqfd -b format to reformat the code. Signed-off-by: Mathieu Dupré --- vm_manager/helpers/libvirt_cmd.py | 2 + vm_manager/helpers/pacemaker.py | 10 +-- vm_manager/helpers/rbd_manager.py | 2 +- vm_manager/helpers/tests/pacemaker/add_vm.py | 2 + .../helpers/tests/pacemaker/remove_vm.py | 2 + .../helpers/tests/pacemaker/start_vm.py | 2 + vm_manager/helpers/tests/pacemaker/stop_vm.py | 2 + .../helpers/tests/rbd_manager/clone_rbd.py | 2 + .../tests/rbd_manager/create_rbd_group.py | 2 + .../tests/rbd_manager/create_rbd_namespace.py | 2 + .../helpers/tests/rbd_manager/metadata_rbd.py | 2 + .../helpers/tests/rbd_manager/purge_rbd.py | 2 + .../helpers/tests/rbd_manager/rollback_rbd.py | 1 + .../helpers/tests/rbd_manager/write_rbd.py | 2 + vm_manager/vm_manager_api.py | 1 + vm_manager/vm_manager_cluster.py | 62 ++++++++++++++----- vm_manager/vm_manager_cmd.py | 12 ++-- vm_manager/vm_manager_libvirt.py | 1 + 18 files changed, 84 insertions(+), 27 deletions(-) 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..2637dab 100755 --- a/vm_manager/vm_manager_cmd.py +++ b/vm_manager/vm_manager_cmd.py @@ -48,8 +48,9 @@ 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 vm_manager.cluster_mode: clone_parser = subparsers.add_parser("clone", help="Clone a VM") @@ -262,7 +263,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 +274,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 +285,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, @@ -508,5 +509,6 @@ def main(): 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..71ca50c 100644 --- a/vm_manager/vm_manager_libvirt.py +++ b/vm_manager/vm_manager_libvirt.py @@ -107,6 +107,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 From c4f7eea3eab6a014a27671006f7df2dfe69e1c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20Dupr=C3=A9?= Date: Thu, 19 Feb 2026 10:23:21 +0100 Subject: [PATCH 3/4] vm_manager: fix libvirt create command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The libvirt create command was not working because of the way the arguments were handled in the vm_manager_cmd.py file. The arguments were being set in a way that caused issues when the create command was executed in standalone. This commit fixes the issue by checking for the presence of the arguments before setting them, and by ensuring that the enable argument is set correctly based on the disable argument. Signed-off-by: Mathieu Dupré --- vm_manager/vm_manager_cmd.py | 14 +++++++++----- vm_manager/vm_manager_libvirt.py | 7 +++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/vm_manager/vm_manager_cmd.py b/vm_manager/vm_manager_cmd.py index 2637dab..f7bc5c8 100755 --- a/vm_manager/vm_manager_cmd.py +++ b/vm_manager/vm_manager_cmd.py @@ -450,12 +450,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 diff --git a/vm_manager/vm_manager_libvirt.py b/vm_manager/vm_manager_libvirt.py index 71ca50c..cf9cecf 100644 --- a/vm_manager/vm_manager_libvirt.py +++ b/vm_manager/vm_manager_libvirt.py @@ -40,19 +40,18 @@ 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) - logger.info("VM " + vm_name + " created successfully") + logger.info("VM " + args.get("name") + " created successfully") def remove(vm_name): From 693cd96222d513fa619234752d2fb087d8fbad3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20Dupr=C3=A9?= Date: Thu, 19 Feb 2026 09:37:12 +0100 Subject: [PATCH 4/4] Add autostart support for standalone mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a set_autostart method to LibVirtManager and expose it through a new autostart() function and CLI subcommand (--enable/--disable). The create command now enables autostart by default (use --no-autostart to opt out). Co-Authored-By: Claude Opus 4.6 Signed-off-by: Mathieu Dupré --- vm_manager/__init__.py | 1 + vm_manager/helpers/libvirt.py | 8 ++++++++ vm_manager/vm_manager_cmd.py | 35 +++++++++++++++++++++++++++++++- vm_manager/vm_manager_libvirt.py | 16 +++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) 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/vm_manager_cmd.py b/vm_manager/vm_manager_cmd.py index f7bc5c8..264e1a2 100755 --- a/vm_manager/vm_manager_cmd.py +++ b/vm_manager/vm_manager_cmd.py @@ -52,6 +52,32 @@ def main(): "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") enable_parser = subparsers.add_parser("enable", help="Enable a VM") @@ -442,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": @@ -507,6 +538,8 @@ 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) diff --git a/vm_manager/vm_manager_libvirt.py b/vm_manager/vm_manager_libvirt.py index cf9cecf..f720fdd 100644 --- a/vm_manager/vm_manager_libvirt.py +++ b/vm_manager/vm_manager_libvirt.py @@ -50,6 +50,8 @@ def create(args): with LibVirtManager() as lvm: lvm.define(xml) + if args.get("autostart"): + lvm.set_autostart(args.get("name"), True) logger.info("VM " + args.get("name") + " created successfully") @@ -74,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) @@ -81,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