From 2d9940a1e7279b134306d95a97c92cc4515a0841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Tue, 29 Apr 2025 15:28:27 +0200 Subject: [PATCH 01/10] Introduce "absent" instead of "undefine" and add several fixes We add several tests to be executed into a mgmtvm or dom0. --- .gitignore | 1 + EXAMPLES.md | 4 +- plugins/modules/qubesos.py | 34 ++-- tests/tests.py | 313 +++++++++++++++++++++++++++++++++++++ 4 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 tests/tests.py diff --git a/.gitignore b/.gitignore index 485dee6..6f0c9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea +**__pycache__ diff --git a/EXAMPLES.md b/EXAMPLES.md index e7ce372..f55cf86 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -213,10 +213,10 @@ The module supports the following states: - **pause** - **running** - **shutdown** -- **undefine** +- **absent** - **present** -**Warning:** The `undefine` state will remove the qube and all associated data. Use with caution. +**Warning:** The `absent` state will remove the qube and all associated data. Use with caution. ## Different available commands diff --git a/plugins/modules/qubesos.py b/plugins/modules/qubesos.py index 77093d3..defa4ef 100644 --- a/plugins/modules/qubesos.py +++ b/plugins/modules/qubesos.py @@ -50,8 +50,8 @@ - When set to C(shutdown), ensures the VM is stopped. - When set to C(destroyed), forces the VM to shut down. - When set to C(pause), pauses a running VM. - - When set to C(undefine), removes the VM definition. - choices: [ present, running, shutdown, destroyed, pause, undefine ] + - When set to C(absent), removes the VM definition. + choices: [ present, running, shutdown, destroyed, pause, absent ] command: description: - Non-idempotent command to execute on the VM. @@ -147,6 +147,7 @@ "destroy", "pause", "shutdown", + "remove", "status", "start", "stop", @@ -333,11 +334,11 @@ def destroy(self, vmname): """Pull the virtual power from the virtual domain, giving it virtually no time to virtually shut down.""" vm = self.get_vm(vmname) - vm.force_shutdown() + vm.kill() return 0 def properties(self, vmname, prefs, vmtype, label, vmtemplate): - "Sets the given properties to the VM" + """Sets the given properties to the VM""" changed = False values_changed = [] try: @@ -455,14 +456,13 @@ def properties(self, vmname, prefs, vmtype, label, vmtemplate): return changed, values_changed - def undefine(self, vmname): - """Stop a domain, and then wipe it from the face of the earth. (delete disk/config file)""" + def remove(self, vmname): + """Stop a domain, and then wipe it from the face of the earth. (delete disk/config file)""" try: self.destroy(vmname) except QubesVMNotStartedError: - pass # Because it is not running - + pass while True: if self.__get_state(vmname) == "shutdown": break @@ -477,7 +477,7 @@ def status(self, vmname): return self.__get_state(vmname) def tags(self, vmname, tags): - "Adds a list of tags to the vm" + """Adds a list of tags to the vm""" vm = self.get_vm(vmname) for tag in tags: vm.tags.add(tag) @@ -615,7 +615,6 @@ def core(module): if not isinstance(res, dict): res = {command: res} return VIRT_SUCCESS, res - elif hasattr(v, command): res = getattr(v, command)() if not isinstance(res, dict): @@ -627,8 +626,7 @@ def core(module): if state: if not guest: - module.fail_json(msg="state change requires a guest specified") - + module.fail_json(msg="State change requires a guest specified") if state == "running": if v.status(guest) is "paused": res["changed"] = True @@ -648,16 +646,16 @@ def core(module): if v.status(guest) is "running": res["changed"] = True res["msg"] = v.pause(guest) - elif state == "undefine": - if v.status(guest) is not "shutdown": + elif state == "absent": + if v.status(guest) is "shutdown": res["changed"] = True - res["msg"] = v.undefine(guest) + res["msg"] = v.remove(guest) else: - module.fail_json(msg="unexpected state") + module.fail_json(msg="Unexpected state") return VIRT_SUCCESS, res - module.fail_json(msg="expected state or command parameter to be specified") + module.fail_json(msg="Expected state or command parameter to be specified") def main(): @@ -671,7 +669,7 @@ def main(): "pause", "running", "shutdown", - "undefine", + "absent", "present", ], ), diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..525c583 --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,313 @@ +import os +import pytest +import uuid +import time + +import qubesadmin +from plugins.modules.qubesos import core, VIRT_SUCCESS, VIRT_FAILED + + +# Helper to run the module core function +class Module: + def __init__(self, params): + self.params = params + + def fail_json(self, **kwargs): + pytest.fail(f"Module failed: {kwargs}") + + def exit_json(self, **kwargs): + print(kwargs) + + +@pytest.fixture(scope="function") +def qubes(): + """Return a Qubes app instance""" + try: + return qubesadmin.Qubes() + except Exception as e: + pytest.skip(f"Qubes API not available: {e}") + + +@pytest.fixture(scope="function") +def vmname(): + """Generate a random VM name for testing""" + return f"test-vm-{uuid.uuid4().hex[:8]}" + + +@pytest.fixture(autouse=True) +def cleanup_vm(qubes, request): + """Ensure any test VM is removed after test""" + created = [] + + def mark(name): + created.append(name) + + request.node.mark_vm_created = mark + yield + # Teardown (remove VMs) + for name in created: + try: + core(Module({"command": "remove", "name": name})) + except Exception: + pass + + +def test_create_start_shutdown_destroy_remove(qubes, vmname, request): + + request.node.mark_vm_created(vmname) + + # Create + rc, _ = core( + Module({"command": "create", "name": vmname, "vmtype": "AppVM"}) + ) + assert rc == VIRT_SUCCESS + assert vmname in qubes.domains + + # Start + rc, _ = core(Module({"command": "start", "name": vmname})) + assert rc == VIRT_SUCCESS + vm = qubes.domains[vmname] + assert vm.is_running() + + # Shutdown + rc, _ = core(Module({"command": "shutdown", "name": vmname})) + assert rc == VIRT_SUCCESS + time.sleep(5) + assert vm.is_halted() + + # Remove + rc, _ = core(Module({"command": "remove", "name": vmname})) + assert rc == VIRT_SUCCESS + qubes.domains.refresh_cache(force=True) + assert vmname not in qubes.domains + + +def test_create_and_absent(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + # Create + rc, _ = core( + Module({"command": "create", "name": vmname, "vmtype": "AppVM"}) + ) + assert rc == VIRT_SUCCESS + assert vmname in qubes.domains + + # Absent + rc, _ = core(Module({"state": "absent", "name": vmname})) + assert rc == VIRT_SUCCESS + qubes.domains.refresh_cache(force=True) + assert vmname not in qubes.domains + + +def test_pause_and_unpause(qubes, vmname, request): + request.node.mark_vm_created(vmname) + core(Module({"command": "create", "name": vmname, "vmtype": "AppVM"})) + core(Module({"command": "start", "name": vmname})) + time.sleep(1) + + rc, _ = core(Module({"command": "pause", "name": vmname})) + assert rc == VIRT_SUCCESS + assert qubes.domains[vmname].is_paused() + + rc, _ = core(Module({"command": "unpause", "name": vmname})) + assert rc == VIRT_SUCCESS + assert qubes.domains[vmname].is_running() + + # Clean up + core(Module({"command": "destroy", "name": vmname})) + core(Module({"state": "absent", "name": vmname})) + + +def test_status_command(qubes, vmname, request): + request.node.mark_vm_created(vmname) + core(Module({"command": "create", "name": vmname, "vmtype": "AppVM"})) + rc, state = core(Module({"command": "status", "name": vmname})) + assert rc == VIRT_SUCCESS + assert state["status"] == "shutdown" + + core(Module({"command": "start", "name": vmname})) + rc, state = core(Module({"command": "status", "name": vmname})) + assert state["status"] == "running" + + core(Module({"command": "destroy", "name": vmname})) + rc, state = core(Module({"command": "status", "name": vmname})) + assert state["status"] == "shutdown" + + core(Module({"state": "absent", "name": vmname})) + + +def test_list_info_and_inventory(tmp_path, qubes): + # Use a temporary directory for inventory + os.chdir(tmp_path) + + # Collect expected VMs by class + expected = {} + for vm in qubes.domains.values(): + if vm.name == "dom0": + continue + expected.setdefault(vm.klass, []).append(vm.name) + + # Run createinventory + rc, res = core(Module({"command": "createinventory"})) + assert rc == VIRT_SUCCESS + assert res["status"] == "successful" + + inv_file = tmp_path / "inventory" + assert inv_file.exists() + lines = inv_file.read_text().splitlines() + + # Helper to extract section values + def section(name): + start = lines.index(f"[{name}]") + 1 + # find next section header + for i, line in enumerate(lines[start:], start=start): + if line.startswith("["): + end = i + break + else: + end = len(lines) + return [l for l in lines[start:end] if l.strip()] + + appvms = section("appvms") + templatevms = section("templatevms") + standalonevms = section("standalonevms") + + assert set(appvms) == set(expected.get("AppVM", [])) + assert set(templatevms) == set(expected.get("TemplateVM", [])) + assert set(standalonevms) == set(expected.get("StandaloneVM", [])) + + +def test_properties_and_tags(qubes, vmname, request): + request.node.mark_vm_created(vmname) + props = {"autostart": True, "debug": True, "memory": 256} + tags = ["tag1", "tag2"] + params = { + "state": "present", + "name": vmname, + "properties": props, + "tags": tags, + } + rc, res = core(Module(params)) + assert rc == VIRT_SUCCESS + changed_values = res["Properties updated"] + assert "autostart" in changed_values + assert qubes.domains[vmname].autostart is True + for t in tags: + assert t in qubes.domains[vmname].tags + # cleanup + core(Module({"state": "absent", "name": vmname})) + + +def test_invalid_property_key(qubes): + # Unknown property should fail + rc, res = core( + Module( + {"state": "present", "name": "dom0", "properties": {"titi": "toto"}} + ) + ) + assert rc == VIRT_FAILED + assert "Invalid property" in res + + +def test_invalid_property_type(qubes, vmname, request): + # Wrong type for memory + rc, res = core( + Module( + { + "state": "present", + "name": vmname, + "properties": {"memory": "toto"}, + } + ) + ) + assert rc == VIRT_FAILED + assert "Invalid property value type" in res + + +def test_missing_netvm(qubes, vmname, request): + # netvm does not exist + rc, res = core( + Module( + { + "state": "present", + "name": vmname, + "properties": {"netvm": "toto"}, + } + ) + ) + assert rc == VIRT_FAILED + assert "Missing netvm" in res + + +def test_missing_default_dispvm(qubes): + # default_dispvm does not exist + rc, res = core( + Module( + { + "state": "present", + "name": "dom0", + "properties": {"default_dispvm": "toto"}, + } + ) + ) + assert rc == VIRT_FAILED + assert "Missing default_dispvm" in res + + +def test_wrong_volume_name(qubes, vmname, request): + # volume name not allowed for AppVM + rc, res = core( + Module( + { + "state": "present", + "name": vmname, + "properties": {"volume": {"name": "root", "size": 10}}, + } + ) + ) + assert rc == VIRT_FAILED + assert "Wrong volume name" in res + + +def test_missing_volume_fields(qubes, vmname, request): + # Missing name + rc1, res1 = core( + Module( + { + "state": "present", + "name": vmname, + "properties": {"volume": {"size": 10}}, + } + ) + ) + assert rc1 == VIRT_FAILED + assert "Missing name for the volume" in res1 + + # Missing size + rc2, res2 = core( + Module( + { + "state": "present", + "name": vmname, + "properties": {"volume": {"name": "private"}}, + } + ) + ) + assert rc2 == VIRT_FAILED + assert "Missing size for the volume" in res2 + + +def test_removetags_without_tags(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + # Create + rc, _ = core( + Module({"command": "create", "name": vmname, "vmtype": "AppVM"}) + ) + assert rc == VIRT_SUCCESS + assert vmname in qubes.domains + + # Remove tags + rc, res = core(Module({"command": "removetags", "name": vmname})) + assert rc == VIRT_FAILED + assert "Missing tag" in res.get("Error", "") From bb37e025e8f9def55c9ac732869c7ed9693abacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Tue, 29 Apr 2025 15:49:14 +0200 Subject: [PATCH 02/10] module: don't speficy 'default' in template Closes QubesOS/qubes-issues#9922 --- plugins/modules/qubesos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/qubesos.py b/plugins/modules/qubesos.py index defa4ef..48c6170 100644 --- a/plugins/modules/qubesos.py +++ b/plugins/modules/qubesos.py @@ -676,7 +676,7 @@ def main(): command=dict(type="str", choices=ALL_COMMANDS), label=dict(type="str", default="red"), vmtype=dict(type="str", default="AppVM"), - template=dict(type="str", default="default"), + template=dict(type="str", default=None), properties=dict(type="dict", default={}), tags=dict(type="list", default=[]), ), From 0b14a9cf3f1a735d8f140edab2677179d3555d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Tue, 29 Apr 2025 16:04:14 +0200 Subject: [PATCH 03/10] Improve tests and add tests of generated playbooks --- tests/cli.py | 146 ++++++++++++++++++++++++++++++++++ tests/conftest.py | 51 ++++++++++++ tests/{tests.py => module.py} | 48 +---------- 3 files changed, 198 insertions(+), 47 deletions(-) create mode 100644 tests/cli.py create mode 100644 tests/conftest.py rename tests/{tests.py => module.py} (87%) diff --git a/tests/cli.py b/tests/cli.py new file mode 100644 index 0000000..849ddd4 --- /dev/null +++ b/tests/cli.py @@ -0,0 +1,146 @@ +import os +import subprocess +import uuid +from typing import List + +import pytest +from pathlib import Path + +PLUGIN_PATH = Path(__file__).parent / "plugins" / "modules" + + +@pytest.fixture +def run_playbook(tmp_path): + """ + Helper to write a playbook and execute it with ansible-playbook. + """ + + def _run(playbook_content: List[dict]): + # Create playbook file + pb_file = tmp_path / "playbook.yml" + import yaml + + pb_file.write_text(yaml.dump(playbook_content)) + # Run ansible-playbook + cmd = [ + "ansible-playbook", + "-vvv", + "-i", + "localhost,", + "-c", + "local", + "-M", + str(PLUGIN_PATH), + str(pb_file), + ] + result = subprocess.run( + cmd, cwd=tmp_path, capture_output=True, text=True + ) + return result + + return _run + + +def test_create_and_destroy_vm(run_playbook, request): + name = f"test-vm-{uuid.uuid4().hex[:6]}" + request.node.mark_vm_created(name) + + playbook = [ + { + "hosts": "localhost", + "tasks": [ + { + "name": "Create AppVM", + "qubesos": { + "name": name, + "command": "create", + "vmtype": "AppVM", + }, + }, + { + "name": "Start AppVM", + "qubesos": { + "name": name, + "command": "start", + }, + }, + { + "name": "Destroy AppVM", + "qubesos": {"name": name, "command": "destroy"}, + }, + { + "name": "Remove AppVM", + "qubesos": {"name": name, "command": "remove"}, + }, + ], + } + ] + result = run_playbook(playbook) + # Playbook should run successfully + assert result.returncode == 0, result.stderr + + +def test_properties_and_tags_playbook(run_playbook, request): + name = f"test-vm-{uuid.uuid4().hex[:6]}" + request.node.mark_vm_created(name) + + playbook = [ + { + "hosts": "localhost", + "tasks": [ + { + "name": "Create VM with properties", + "qubesos": { + "name": name, + "state": "present", + "properties": {"autostart": True, "memory": 128}, + "tags": ["tag1", "tag2"], + }, + }, + { + "name": "Validate VM state", + "qubesos": {"name": name, "command": "status"}, + }, + { + "name": "Cleanup", + "qubesos": {"name": name, "state": "absent"}, + }, + ], + } + ] + result = run_playbook(playbook) + assert result.returncode == 0, result.stderr + + # Ensure properties and tags were applied + assert "changed=" in result.stdout + assert "tag1" in result.stdout and "tag2" in result.stdout + + +def test_inventory_playbook(run_playbook, tmp_path, qubes): + # Generate inventory via playbook + playbook = [ + { + "hosts": "localhost", + "tasks": [ + { + "name": "Create inventory", + "qubesos": {"command": "createinventory"}, + } + ], + } + ] + result = run_playbook(playbook) + assert result.returncode == 0, result.stderr + + # Check inventory file exists + inv_file = tmp_path / "inventory" + assert inv_file.exists() + content = inv_file.read_text() + + # Should contain at least one VM entry under [appvms] + assert "[appvms]" in content + + # Compare with qubes.domains data + for vm in qubes.domains.values(): + if vm.name != "dom0" and vm.klass == "AppVM": + assert vm.name in content diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9d95119 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +import uuid + +import pytest +import qubesadmin + +from plugins.modules.qubesos import core + + +# Helper to run the module core function +class Module: + def __init__(self, params): + self.params = params + + def fail_json(self, **kwargs): + pytest.fail(f"Module failed: {kwargs}") + + def exit_json(self, **kwargs): + print(kwargs) + + +@pytest.fixture(scope="function") +def qubes(): + """Return a Qubes app instance""" + try: + return qubesadmin.Qubes() + except Exception as e: + pytest.skip(f"Qubes API not available: {e}") + + +@pytest.fixture(scope="function") +def vmname(): + """Generate a random VM name for testing""" + return f"test-vm-{uuid.uuid4().hex[:8]}" + + +@pytest.fixture(autouse=True) +def cleanup_vm(qubes, request): + """Ensure any test VM is removed after test""" + created = [] + + def mark(name): + created.append(name) + + request.node.mark_vm_created = mark + yield + # Teardown (remove VMs) + for name in created: + try: + core(Module({"command": "remove", "name": name})) + except Exception: + pass diff --git a/tests/tests.py b/tests/module.py similarity index 87% rename from tests/tests.py rename to tests/module.py index 525c583..6356ac5 100644 --- a/tests/tests.py +++ b/tests/module.py @@ -1,55 +1,9 @@ import os import pytest -import uuid import time -import qubesadmin from plugins.modules.qubesos import core, VIRT_SUCCESS, VIRT_FAILED - - -# Helper to run the module core function -class Module: - def __init__(self, params): - self.params = params - - def fail_json(self, **kwargs): - pytest.fail(f"Module failed: {kwargs}") - - def exit_json(self, **kwargs): - print(kwargs) - - -@pytest.fixture(scope="function") -def qubes(): - """Return a Qubes app instance""" - try: - return qubesadmin.Qubes() - except Exception as e: - pytest.skip(f"Qubes API not available: {e}") - - -@pytest.fixture(scope="function") -def vmname(): - """Generate a random VM name for testing""" - return f"test-vm-{uuid.uuid4().hex[:8]}" - - -@pytest.fixture(autouse=True) -def cleanup_vm(qubes, request): - """Ensure any test VM is removed after test""" - created = [] - - def mark(name): - created.append(name) - - request.node.mark_vm_created = mark - yield - # Teardown (remove VMs) - for name in created: - try: - core(Module({"command": "remove", "name": name})) - except Exception: - pass +from tests.conftest import qubes, vmname, Module def test_create_start_shutdown_destroy_remove(qubes, vmname, request): From 5dc2cb615fccef7dc669d0285452c3abe440cd4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Tue, 29 Apr 2025 16:09:49 +0200 Subject: [PATCH 04/10] conftests: don't print kwargs in exit_json --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9d95119..7d15eac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ def fail_json(self, **kwargs): pytest.fail(f"Module failed: {kwargs}") def exit_json(self, **kwargs): - print(kwargs) + pass @pytest.fixture(scope="function") From f979039eb605a18c1d40d2b9f848e9d0ede5b76f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Fri, 2 May 2025 10:53:14 +0200 Subject: [PATCH 05/10] Add tests subpackage --- qubes-ansible.spec.in | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/qubes-ansible.spec.in b/qubes-ansible.spec.in index a663dfc..cb37206 100644 --- a/qubes-ansible.spec.in +++ b/qubes-ansible.spec.in @@ -16,6 +16,14 @@ to manage QubesOS virtual machines. The files are installed into the Ansible mod Ansible can automatically discover and use them. This package is intended to be installed in dom0 (or a management qube). +%package tests +Summary: Tests for the module and the connection +Requires: %{name} +Requires: pytest + +%description tests +Tests for the module and the connection. + %prep %autosetup @@ -25,15 +33,21 @@ Ansible can automatically discover and use them. This package is intended to be rm -rf %{buildroot} %{__mkdir} -p %{buildroot}%{_datadir}/ansible/plugins/modules %{__mkdir} -p %{buildroot}%{_datadir}/ansible/plugins/connection +%{__mkdir} -p %{buildroot}%{_datadir}/ansible/tests # Install the qubesos module and qubes connection plugin install -m 644 plugins/modules/qubesos.py %{buildroot}%{_datadir}/ansible/plugins/modules/qubesos.py install -m 644 plugins/connection/qubes.py %{buildroot}%{_datadir}/ansible/plugins/connection/qubes.py +install -m 644 tests/*.py %{buildroot}%{_datadir}/ansible/tests/ + %files %doc README.md LICENSE EXAMPLES.md %{_datadir}/ansible/plugins/modules/qubesos.py %{_datadir}/ansible/plugins/connection/qubes.py +%files tests +%{_datadir}/ansible/tests/*.py + %changelog -@CHANGELOG@ \ No newline at end of file +@CHANGELOG@ From d1016a694aa12ec73ba890543e2def2124e871ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Wed, 7 May 2025 13:47:10 +0200 Subject: [PATCH 06/10] spec: update R --- qubes-ansible.spec.in | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qubes-ansible.spec.in b/qubes-ansible.spec.in index cb37206..054b029 100644 --- a/qubes-ansible.spec.in +++ b/qubes-ansible.spec.in @@ -9,6 +9,7 @@ BuildArch: noarch Source0: %{name}-%{version}.tar.gz Requires: ansible +Requires: qubes-core-admin-client %description qubes-ansible provides an Ansible connection plugin ("qubes") and an Ansible module ("qubesos") @@ -19,7 +20,7 @@ Ansible can automatically discover and use them. This package is intended to be %package tests Summary: Tests for the module and the connection Requires: %{name} -Requires: pytest +Requires: python3-pytest %description tests Tests for the module and the connection. From e708a1043823b85da93291d811052f65c9a82cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Sat, 17 May 2025 13:15:11 +0200 Subject: [PATCH 07/10] Add CI test --- .gitlab-ci.yml | 21 +++++++++++++++++++-- tests/{cli.py => test_cli.py} | 0 tests/{module.py => test_module.py} | 0 3 files changed, 19 insertions(+), 2 deletions(-) rename tests/{cli.py => test_cli.py} (100%) rename tests/{module.py => test_module.py} (100%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 104f650..ec0d6aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,5 +5,22 @@ include: project: QubesOS/qubes-continuous-integration - file: /r4.3/gitlab-host.yml project: QubesOS/qubes-continuous-integration - - file: /r4.3/gitlab-vm.yml - project: QubesOS/qubes-continuous-integration + +.mgmt-host-template: + stage: tests + tags: + - vm-kvm + script: + # install dependencies + - sudo qubes-dom0-update -y ansible python3-pytest + # install from artifacts + - find $CI_PROJECT_DIR/artifacts/repository -name '*.noarch.rpm' -exec sudo dnf install -y {} \+ + # run ansible's tests + - PYTHONPATH=/usr/share/ansible pytest -vvv /usr/share/ansible/tests/ + +r4.3:mgmt-host: + extends: .mgmt-host-template + needs: + - r4.3:build:host-fc41 + variables: + VM_IMAGE: qubes_4.3_64bit_stable.qcow2 \ No newline at end of file diff --git a/tests/cli.py b/tests/test_cli.py similarity index 100% rename from tests/cli.py rename to tests/test_cli.py diff --git a/tests/module.py b/tests/test_module.py similarity index 100% rename from tests/module.py rename to tests/test_module.py From a682208142814dec4434fcd0fef2d70964aa4b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Mon, 19 May 2025 17:04:39 +0200 Subject: [PATCH 08/10] qubesos: change checks syntax --- plugins/modules/qubesos.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/modules/qubesos.py b/plugins/modules/qubesos.py index 48c6170..c65d431 100644 --- a/plugins/modules/qubesos.py +++ b/plugins/modules/qubesos.py @@ -628,26 +628,26 @@ def core(module): if not guest: module.fail_json(msg="State change requires a guest specified") if state == "running": - if v.status(guest) is "paused": + if v.status(guest) == "paused": res["changed"] = True res["msg"] = v.unpause(guest) - elif v.status(guest) is not "running": + elif v.status(guest) != "running": res["changed"] = True res["msg"] = v.start(guest) elif state == "shutdown": - if v.status(guest) is not "shutdown": + if v.status(guest) != "shutdown": res["changed"] = True res["msg"] = v.shutdown(guest) elif state == "destroyed": - if v.status(guest) is not "shutdown": + if v.status(guest) != "shutdown": res["changed"] = True res["msg"] = v.destroy(guest) elif state == "paused": - if v.status(guest) is "running": + if v.status(guest) == "running": res["changed"] = True res["msg"] = v.pause(guest) elif state == "absent": - if v.status(guest) is "shutdown": + if v.status(guest) == "shutdown": res["changed"] = True res["msg"] = v.remove(guest) else: From f80e10d4fccca15e5ff7c1172478951b9428c715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Mon, 19 May 2025 17:05:17 +0200 Subject: [PATCH 09/10] spec: update tests path --- qubes-ansible.spec.in | 6 +++--- tests/test_module.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/qubes-ansible.spec.in b/qubes-ansible.spec.in index 054b029..d33f43e 100644 --- a/qubes-ansible.spec.in +++ b/qubes-ansible.spec.in @@ -34,13 +34,13 @@ Tests for the module and the connection. rm -rf %{buildroot} %{__mkdir} -p %{buildroot}%{_datadir}/ansible/plugins/modules %{__mkdir} -p %{buildroot}%{_datadir}/ansible/plugins/connection -%{__mkdir} -p %{buildroot}%{_datadir}/ansible/tests +%{__mkdir} -p %{buildroot}%{_datadir}/ansible/tests/qubes # Install the qubesos module and qubes connection plugin install -m 644 plugins/modules/qubesos.py %{buildroot}%{_datadir}/ansible/plugins/modules/qubesos.py install -m 644 plugins/connection/qubes.py %{buildroot}%{_datadir}/ansible/plugins/connection/qubes.py -install -m 644 tests/*.py %{buildroot}%{_datadir}/ansible/tests/ +install -m 644 tests/*.py %{buildroot}%{_datadir}/ansible/tests/qubes/ %files %doc README.md LICENSE EXAMPLES.md @@ -48,7 +48,7 @@ install -m 644 tests/*.py %{buildroot}%{_datadir}/ansible/tests/ %{_datadir}/ansible/plugins/connection/qubes.py %files tests -%{_datadir}/ansible/tests/*.py +%{_datadir}/ansible/tests/qubes/*.py %changelog @CHANGELOG@ diff --git a/tests/test_module.py b/tests/test_module.py index 6356ac5..e702e7f 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -3,7 +3,7 @@ import time from plugins.modules.qubesos import core, VIRT_SUCCESS, VIRT_FAILED -from tests.conftest import qubes, vmname, Module +from tests.qubes.conftest import qubes, vmname, Module def test_create_start_shutdown_destroy_remove(qubes, vmname, request): From 85c45dd23640005d0224d820bbfb024427e5b1ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Pierret=20=28fepitre=29?= Date: Mon, 19 May 2025 17:05:38 +0200 Subject: [PATCH 10/10] Add test coverage --- .gitignore | 1 + .gitlab-ci.yml | 9 +++++--- ci/codecov-keys.asc | 52 +++++++++++++++++++++++++++++++++++++++++++++ ci/codecov-wrapper | 28 ++++++++++++++++++++++++ ci/coveragerc | 2 ++ 5 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 ci/codecov-keys.asc create mode 100755 ci/codecov-wrapper create mode 100644 ci/coveragerc diff --git a/.gitignore b/.gitignore index 6f0c9d1..f8685af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea **__pycache__ +.coverage diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ec0d6aa..9069980 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,13 +10,16 @@ include: stage: tests tags: - vm-kvm - script: + after_script: + - ci/codecov-wrapper -F unittests + before_script: # install dependencies - - sudo qubes-dom0-update -y ansible python3-pytest + - sudo qubes-dom0-update -y ansible python3-pytest python3-coverage perl-Digest-SHA # install from artifacts - find $CI_PROJECT_DIR/artifacts/repository -name '*.noarch.rpm' -exec sudo dnf install -y {} \+ + script: # run ansible's tests - - PYTHONPATH=/usr/share/ansible pytest -vvv /usr/share/ansible/tests/ + - cd /usr/share/ansible && sudo coverage run --data-file=$CI_PROJECT_DIR/.coverage --include=plugins/modules/qubesos.py,plugins/connection/qubes.py -m pytest -vvv tests/qubes/ r4.3:mgmt-host: extends: .mgmt-host-template diff --git a/ci/codecov-keys.asc b/ci/codecov-keys.asc new file mode 100644 index 0000000..b7dfce0 --- /dev/null +++ b/ci/codecov-keys.asc @@ -0,0 +1,52 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=ch7z +-----END PGP PUBLIC KEY BLOCK----- diff --git a/ci/codecov-wrapper b/ci/codecov-wrapper new file mode 100755 index 0000000..e50505e --- /dev/null +++ b/ci/codecov-wrapper @@ -0,0 +1,28 @@ +#!/bin/bash + +set -xe + +LOCALDIR="$(dirname "$0")" + +curl -Os https://uploader.codecov.io/latest/linux/codecov +curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM +curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig + +sqv --keyring "$LOCALDIR"/codecov-keys.asc codecov.SHA256SUM.sig codecov.SHA256SUM +shasum -a 256 -c codecov.SHA256SUM + +chmod +x codecov + +python3 -m coverage xml || : + +if [[ "$CI_COMMIT_BRANCH" =~ ^pr- ]]; then + PR=${CI_COMMIT_BRANCH#pr-} + parents=$(git show -s --format='%P %ae') + if [ $(wc -w <<<"$parents") -eq 3 ] && [ "${parents##* }" = "fepitre-bot@qubes-os.org" ]; then + commit_sha=$(cut -f 2 -d ' ' <<<"${parents}") + else + commit_sha=$(git show -s --format='%H') + fi + exec ./codecov --pr "$PR" --sha "$commit_sha" "$@" +fi +exec ./codecov "$@" diff --git a/ci/coveragerc b/ci/coveragerc new file mode 100644 index 0000000..bd4e40f --- /dev/null +++ b/ci/coveragerc @@ -0,0 +1,2 @@ +[run] +source = plugins