From 3facf399e85e42e6f464ba4d70a35f1c5d04d123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20Dupr=C3=A9?= Date: Thu, 19 Feb 2026 10:40:23 +0100 Subject: [PATCH 1/3] Add GitHub Actions CI workflow for formatting and linting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run black formatting check and flake8 linting via cqfd on pushes and pull requests to main. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Mathieu Dupré --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a828b67 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install cqfd + run: | + git clone https://github.com/savoirfairelinux/cqfd.git /tmp/cqfd + cd /tmp/cqfd + sudo make install + + - name: Initialize cqfd container + run: cqfd init + + - name: Check formatting + run: cqfd -b check_format + + - name: Run flake8 + run: cqfd -b flake From f286abd6dbe04c1076e16968f9a0d231feab8600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20Dupr=C3=A9?= Date: Thu, 19 Feb 2026 10:46:09 +0100 Subject: [PATCH 2/3] check: fix linting issues and configure flake8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mathieu Dupré --- .flake8 | 5 ++++- vm_manager/helpers/pacemaker.py | 2 +- vm_manager/helpers/tests/rbd_manager/rollback_rbd.py | 1 + vm_manager/vm_manager_cmd.py | 4 +--- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.flake8 b/.flake8 index 80676bc..74aad7c 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,5 @@ [flake8] -per-file-ignores = __init__.py:F401 +exclude = build +per-file-ignores = + __init__.py:F401 + vm_manager_cmd.py:F841 diff --git a/vm_manager/helpers/pacemaker.py b/vm_manager/helpers/pacemaker.py index 47bdf10..fc6b0db 100644 --- a/vm_manager/helpers/pacemaker.py +++ b/vm_manager/helpers/pacemaker.py @@ -430,7 +430,7 @@ def find_resource(resource): """ command = ( "crm status | " - + f'grep -E "^ \* {resource}\\b" | ' + + f'grep -E "^ \\* {resource}\\b" | ' + "grep Started | " + "awk 'NF>1{print $NF}'" ) diff --git a/vm_manager/helpers/tests/rbd_manager/rollback_rbd.py b/vm_manager/helpers/tests/rbd_manager/rollback_rbd.py index c473651..1c71f99 100755 --- a/vm_manager/helpers/tests/rbd_manager/rollback_rbd.py +++ b/vm_manager/helpers/tests/rbd_manager/rollback_rbd.py @@ -85,6 +85,7 @@ def main(): ts = rbd.get_image_snapshot_timestamp(IMG_NAME, SNAP) if ts.tzinfo is None: from datetime import timezone + ts = ts.replace(tzinfo=timezone.utc) print("Snapshot " + SNAP + " timestamp: " + str(ts)) if ( diff --git a/vm_manager/vm_manager_cmd.py b/vm_manager/vm_manager_cmd.py index 264e1a2..8cab7f3 100755 --- a/vm_manager/vm_manager_cmd.py +++ b/vm_manager/vm_manager_cmd.py @@ -471,9 +471,7 @@ def main(): if vm_manager.cluster_mode: vm_manager.start(args.name) else: - vm_manager.start( - args.name, autostart=not args.no_autostart - ) + 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": From ce6272754faba7095f783d5a5baa941a66f1b90a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20Dupr=C3=A9?= Date: Thu, 19 Feb 2026 11:24:55 +0100 Subject: [PATCH 3/3] Add libvirt/QEMU pytest tests in GitHub Actions CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add real (non-mocked) pytest tests for standalone mode running against qemu:///system. Tests cover LibVirtManager helpers and the public vm_manager_libvirt API (create, remove, start, stop, status, autostart). - Add tests/conftest.py with shared fixtures (vm_name, libvirt_conn) - Add tests/test_libvirt_manager.py (10 tests) - Add tests/test_vm_manager_libvirt.py (12 tests) - Add CI test job with libvirt + QEMU on Ubuntu 24.04 - Add pytest as optional test dependency - Use qemu64 CPU model in testdata XML for CI portability Co-Authored-By: Claude Opus 4.6 Signed-off-by: Mathieu Dupré --- .github/workflows/ci.yml | 29 ++++++ pyproject.toml | 3 + tests/conftest.py | 45 +++++++++ tests/test_libvirt_manager.py | 113 +++++++++++++++++++++ tests/test_vm_manager_libvirt.py | 127 ++++++++++++++++++++++++ vm_manager/testdata/vm.xml | 4 +- vm_manager/testdata/wrong_vm_config.xml | 4 +- 7 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_libvirt_manager.py create mode 100644 tests/test_vm_manager_libvirt.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a828b67..b91e13d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,32 @@ jobs: - name: Run flake8 run: cqfd -b flake + + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + qemu-system-x86 \ + libvirt-daemon-system \ + libvirt-dev \ + pkg-config + + - name: Start libvirtd and grant access + run: | + sudo systemctl start libvirtd + sudo usermod -aG libvirt "$USER" + + - name: Install package with test deps + run: pip install ".[test]" + + - name: Run tests + run: sg libvirt -c "pytest tests/ -v --tb=short" diff --git a/pyproject.toml b/pyproject.toml index 7e0c4c9..4d2f303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ ] readme = "README.md" +[project.optional-dependencies] +test = ["pytest>=7.0"] + [tool.setuptools] packages = ["vm_manager", "vm_manager.helpers", "vm_manager.helpers.tests.pacemaker", "vm_manager.helpers.tests.rbd_manager"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c7cf36b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +import os +import secrets + +import pytest + +from vm_manager.helpers.libvirt import LibVirtManager + + +@pytest.fixture +def vm_name(): + """Generate a unique VM name and ensure cleanup after test.""" + name = "testvm" + secrets.token_hex(4) + yield name + with LibVirtManager() as lvm: + if name in lvm.list(): + try: + lvm.force_stop(name) + except Exception: + pass + try: + lvm.undefine(name) + except Exception: + pass + + +@pytest.fixture +def libvirt_conn(): + """Provide a LibVirtManager connection for the test.""" + with LibVirtManager() as lvm: + yield lvm + + +@pytest.fixture +def vm_xml_path(): + """Return the path to the test VM XML template.""" + return os.path.join( + os.path.dirname(__file__), + "..", + "vm_manager", + "testdata", + "vm.xml", + ) diff --git a/tests/test_libvirt_manager.py b/tests/test_libvirt_manager.py new file mode 100644 index 0000000..4ebc8bb --- /dev/null +++ b/tests/test_libvirt_manager.py @@ -0,0 +1,113 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +import libvirt +import pytest + +from vm_manager.helpers.libvirt import LibVirtManager + + +class TestConnection: + def test_context_manager(self): + with LibVirtManager() as lvm: + assert lvm._conn is not None + assert lvm._conn.isAlive() + + def test_close(self): + lvm = LibVirtManager() + lvm.close() + + +class TestList: + def test_list_returns_list(self, libvirt_conn): + result = libvirt_conn.list() + assert isinstance(result, list) + + def test_list_contains_defined_vm( + self, libvirt_conn, vm_name, vm_xml_path + ): + with open(vm_xml_path) as f: + xml = f.read() + xml = xml.replace("test0", vm_name) + libvirt_conn.define(xml) + assert vm_name in libvirt_conn.list() + + +class TestDefine: + def test_define_valid_xml(self, libvirt_conn, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + xml = xml.replace("test0", vm_name) + libvirt_conn.define(xml) + assert vm_name in libvirt_conn.list() + + def test_define_invalid_xml_raises(self, libvirt_conn): + with pytest.raises(libvirt.libvirtError): + libvirt_conn.define("") + + +class TestUndefine: + def test_undefine_removes_domain(self, libvirt_conn, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + xml = xml.replace("test0", vm_name) + libvirt_conn.define(xml) + libvirt_conn.undefine(vm_name) + assert vm_name not in libvirt_conn.list() + + def test_undefine_nonexistent_raises(self, libvirt_conn): + with pytest.raises(libvirt.libvirtError): + libvirt_conn.undefine("nonexistent_vm_xyz") + + +class TestStartStop: + def test_start_and_force_stop(self, libvirt_conn, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + xml = xml.replace("test0", vm_name) + libvirt_conn.define(xml) + libvirt_conn.start(vm_name) + assert libvirt_conn.status(vm_name) == "Started" + libvirt_conn.force_stop(vm_name) + assert libvirt_conn.status(vm_name) == "Stopped" + + +class TestStatus: + def test_status_stopped(self, libvirt_conn, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + xml = xml.replace("test0", vm_name) + libvirt_conn.define(xml) + assert libvirt_conn.status(vm_name) == "Stopped" + + def test_status_started(self, libvirt_conn, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + xml = xml.replace("test0", vm_name) + libvirt_conn.define(xml) + libvirt_conn.start(vm_name) + assert libvirt_conn.status(vm_name) == "Started" + + def test_status_undefined(self, libvirt_conn): + assert libvirt_conn.status("nonexistent_vm_xyz") == "Undefined" + + +class TestAutostart: + def test_enable_autostart(self, libvirt_conn, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + xml = xml.replace("test0", vm_name) + libvirt_conn.define(xml) + libvirt_conn.set_autostart(vm_name, True) + domain = libvirt_conn._conn.lookupByName(vm_name) + assert domain.autostart() == 1 + + def test_disable_autostart(self, libvirt_conn, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + xml = xml.replace("test0", vm_name) + libvirt_conn.define(xml) + libvirt_conn.set_autostart(vm_name, True) + libvirt_conn.set_autostart(vm_name, False) + domain = libvirt_conn._conn.lookupByName(vm_name) + assert domain.autostart() == 0 diff --git a/tests/test_vm_manager_libvirt.py b/tests/test_vm_manager_libvirt.py new file mode 100644 index 0000000..eeb1301 --- /dev/null +++ b/tests/test_vm_manager_libvirt.py @@ -0,0 +1,127 @@ +# Copyright (C) 2025, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +import libvirt + +from vm_manager import vm_manager_libvirt as vml + + +class TestListVms: + def test_returns_list(self): + result = vml.list_vms() + assert isinstance(result, list) + + +class TestCreateXml: + def test_name_replaced(self, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + result = vml._create_xml(xml, "myvm") + assert "myvm" in result + + def test_uuid_replaced(self, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + result = vml._create_xml(xml, "myvm") + # Original UUID should be gone + assert "7b48b1fe-066a-41a6-aef4-f0a9c028f719" not in result + assert "" in result + + +class TestCreate: + def test_create_vm(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + args = { + "base_xml": xml, + "name": vm_name, + "autostart": False, + } + vml.create(args) + assert vm_name in vml.list_vms() + + def test_create_with_autostart(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + args = { + "base_xml": xml, + "name": vm_name, + "autostart": True, + } + vml.create(args) + assert vm_name in vml.list_vms() + conn = libvirt.open("qemu:///system") + domain = conn.lookupByName(vm_name) + assert domain.autostart() == 1 + conn.close() + + +class TestRemove: + def test_remove_stopped_vm(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + vml.create({"base_xml": xml, "name": vm_name, "autostart": False}) + vml.remove(vm_name) + assert vm_name not in vml.list_vms() + + def test_remove_running_vm(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + vml.create({"base_xml": xml, "name": vm_name, "autostart": False}) + vml.start(vm_name) + vml.remove(vm_name) + assert vm_name not in vml.list_vms() + + +class TestStartStop: + def test_start(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + vml.create({"base_xml": xml, "name": vm_name, "autostart": False}) + vml.start(vm_name) + assert vml.status(vm_name) == "Started" + + def test_force_stop(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + vml.create({"base_xml": xml, "name": vm_name, "autostart": False}) + vml.start(vm_name) + vml.stop(vm_name, force=True) + assert vml.status(vm_name) == "Stopped" + + +class TestStatus: + def test_stopped_after_create(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + vml.create({"base_xml": xml, "name": vm_name, "autostart": False}) + assert vml.status(vm_name) == "Stopped" + + def test_started_after_start(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + vml.create({"base_xml": xml, "name": vm_name, "autostart": False}) + vml.start(vm_name) + assert vml.status(vm_name) == "Started" + + +class TestAutostart: + def test_enable_autostart(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + vml.create({"base_xml": xml, "name": vm_name, "autostart": False}) + vml.autostart(vm_name, True) + conn = libvirt.open("qemu:///system") + domain = conn.lookupByName(vm_name) + assert domain.autostart() == 1 + conn.close() + + def test_disable_autostart(self, vm_name, vm_xml_path): + with open(vm_xml_path) as f: + xml = f.read() + vml.create({"base_xml": xml, "name": vm_name, "autostart": True}) + vml.autostart(vm_name, False) + conn = libvirt.open("qemu:///system") + domain = conn.lookupByName(vm_name) + assert domain.autostart() == 0 + conn.close() diff --git a/vm_manager/testdata/vm.xml b/vm_manager/testdata/vm.xml index aef1aee..00ac1c8 100644 --- a/vm_manager/testdata/vm.xml +++ b/vm_manager/testdata/vm.xml @@ -5,7 +5,9 @@ hvm - + + qemu64 + diff --git a/vm_manager/testdata/wrong_vm_config.xml b/vm_manager/testdata/wrong_vm_config.xml index 597c32a..25cc22d 100644 --- a/vm_manager/testdata/wrong_vm_config.xml +++ b/vm_manager/testdata/wrong_vm_config.xml @@ -3,7 +3,9 @@ hvm - + + qemu64 +