diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b6e06b2..52b76da 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -35,3 +35,20 @@ r4.3:mgmt-host: - r4.3:build:vm-fc42 variables: VM_IMAGE: qubes_4.3_64bit_stable.qcow2 + +pages: + stage: build + image: python:3 + tags: + - docker + artifacts: + paths: + - public + script: + - mkdir public + - pip3 install sphinx sphinx-ansible-theme antsibull-docs ansible + - git submodule update --init --recursive + - mkdir build/ + - antsibull-docs sphinx-init qubesos.* --use-current --dest-dir build/ + - ANSIBLE_COLLECTIONS_PATH=$(pwd) ./build/build.sh + - mv build/build/html/* public/ diff --git a/EXAMPLES.md b/EXAMPLES.md index 84c731e..7630c4b 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,8 +1,5 @@ # Example tasks -The **qubesos** module is under active development, and its syntax and available options may evolve. -Refer to the examples below to learn more about managing Qubes OS qubes. - ## Creating an inventory file for Ansible To set up an inventory file, create a file with content similar to the following: @@ -20,13 +17,13 @@ admin-demo project-demo [appvms:vars] -ansible_connection=qubes +ansible_connection=qubesos.core.qubes [templatevms] fedora-demo [templatevms:vars] -ansible_connection=qubes +ansible_connection=qubesos.core.qubes ``` Once the inventory file is created, you can execute Ansible playbooks using: @@ -38,7 +35,7 @@ ansible-playbook -i inventory my_playbook.yaml To create an inventory file that automatically includes all Qubes, run the following command: ```bash -ansible localhost -m qubesos -a 'command=createinventory' +ansible localhost -m qubesos.core.command -a 'command=createinventory' ``` > Warning: This command **overwrites** the existing inventory file in the local directory. @@ -55,18 +52,20 @@ This is the preferred method to create a new qube if it is not already present. connection: local tasks: - name: Create a test qube - qubesos: - guest: testqube - label: blue + qubesos.core.qube: + name: testqube state: present template: "debian-12-xfce" ``` -> Remark: Only the *guest* parameter is mandatory. By default, the module uses the system default TemplateVM and NetVM, and the default label color is **red**. +> Remark: Only `name` and `state` parameter are mandatory. By default, +> the module uses the system default TemplateVM and NetVM, and the default +> label color is **red**. ## Creating multiple qubes with custom properties and tags -The following example demonstrates creating multiple qubes with specific labels, templates, properties, and a policy file for inter-qube communication. +The following example demonstrates creating multiple qubes with specific labels, +templates, properties, and a policy file for inter-qube communication. ```yaml --- @@ -74,10 +73,10 @@ The following example demonstrates creating multiple qubes with specific labels, connection: local tasks: - name: Create vault-demo with custom properties - qubesos: - guest: vault-demo - label: black + qubesos.core.qube: + name: vault-demo state: present + label: black template: "fedora-41-xfce" properties: memory: 600 @@ -85,26 +84,28 @@ The following example demonstrates creating multiple qubes with specific labels, netvm: "" - name: Create work-demo qube using a template - qubesos: - guest: work-demo - label: blue + qubesos.core.qube: + name: work-demo state: present + label: blue template: "fedora-41-xfce" - name: Create project-demo qube using a template - qubesos: - guest: project-demo - label: orange + qubesos.core.qube: + name: project-demo state: present + label: orange template: "fedora-41-xfce" - name: Create policy file for qube communications - copy: + ansible.builtin.copy: dest: /etc/qubes/policy.d/10-demo.policy content: | qubes.Gpg * work-demo vault-demo allow project.Service1 * work-demo @default allow target=project-demo mode: '0755' + owner: root + group: qubes ``` ### Available properties diff --git a/Makefile b/Makefile index faefd02..64e5796 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,33 @@ VERSION := $(shell cat version) +QUBE_COLLECTION_DIR := $(DESTDIR)/usr/share/ansible/collections/ansible_collections/qubesos install-common: + mkdir -p $(QUBE_COLLECTION_DIR)/core/plugins/connection + mkdir -p $(QUBE_COLLECTION_DIR)/core/plugins/modules + mkdir -p $(QUBE_COLLECTION_DIR)/core/plugins/module_utils + install -m 644 ansible_collections/qubesos/core/plugins/connection/qubes.py $(QUBE_COLLECTION_DIR)/core/plugins/connection/qubes.py + install -m 644 ansible_collections/qubesos/core/plugins/module_utils/*.py $(QUBE_COLLECTION_DIR)/core/plugins/module_utils/ + install -m 644 ansible_collections/qubesos/core/plugins/modules/*.py $(QUBE_COLLECTION_DIR)/core/plugins/modules/ + + # Legacy files mkdir -p $(DESTDIR)/usr/share/ansible/plugins/connection mkdir -p $(DESTDIR)/usr/share/ansible/plugins/modules + cp -P plugins/connection/qubes.py $(DESTDIR)/usr/share/ansible/plugins/connection/qubes.py install -m 644 plugins/modules/qubesos.py $(DESTDIR)/usr/share/ansible/plugins/modules/qubesos.py - install -m 644 plugins/connection/qubes.py $(DESTDIR)/usr/share/ansible/plugins/connection/qubes.py + install-dom0: mkdir -p $(DESTDIR)/usr/lib/qubes/ + mkdir -p $(QUBE_COLLECTION_DIR)/security/plugins/callback + mkdir -p $(QUBE_COLLECTION_DIR)/security/plugins/strategy + install -m 644 ansible_collections/qubesos/security/plugins/callback/qubesos_strategy_guard.py $(QUBE_COLLECTION_DIR)/security/plugins/callback/qubesos_strategy_guard.py + install -m 644 ansible_collections/qubesos/security/plugins/strategy/qubes_proxy.py $(QUBE_COLLECTION_DIR)/security/plugins/strategy/qubes_proxy.py + install -m 755 update-ansible-default-strategy $(DESTDIR)/usr/lib/qubes/update-ansible-default-strategy + mkdir -p $(DESTDIR)/usr/share/ansible/plugins/callback mkdir -p $(DESTDIR)/usr/share/ansible/plugins/strategy - install -m 644 plugins/callback/qubesos_strategy_guard.py $(DESTDIR)/usr/share/ansible/plugins/callback/qubesos_strategy_guard.py - install -m 644 plugins/strategy/qubes_proxy.py $(DESTDIR)/usr/share/ansible/plugins/strategy/qubes_proxy.py - install -m 755 update-ansible-default-strategy $(DESTDIR)/usr/lib/qubes/update-ansible-default-strategy + cp -P plugins/callback/qubesos_strategy_guard.py $(DESTDIR)/usr/share/ansible/plugins/callback/qubesos_strategy_guard.py + cp -P plugins/strategy/qubes_proxy.py $(DESTDIR)/usr/share/ansible/plugins/strategy/qubes_proxy.py install-tests: mkdir -p $(DESTDIR)/usr/share/ansible/tests/qubes diff --git a/README.md b/README.md index 0fa1e74..45b60b5 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,44 @@ # Ansible plugins for QubesOS -This project provides Ansible plugins to interact and manage your [Qubes OS](https://qubes-os.org) virtual machines (called `qubes`). -Those plugins are under active development, so the syntax and keywords may change in future releases. Contributions and feedback are welcome! +This project provides Ansible plugins to interact and manage your +[Qubes OS](https://qubes-os.org) system. -## Plugins description +Those plugins are under active development, so the syntax and keywords may +change in future releases. Contributions and feedback are welcome! -### ``qubesos`` module +## Online documentation -This module may be used to interact with the QubesOS API to manage the state -of your qubes. You can use it to create, update, remove, restart your qubes as -well as change their properties. +The documentation is generated automatically and [is available in the Gitlab +pages of this project](https://qubesos.gitlab.io/qubes-ansible). -### ``qubes`` connection plugin +## Collections and plugins -This connection plugin allows Ansible to connect to your qubes using the -[QubesOS qrexec framework](https://www.qubes-os.org/doc/qrexec/). +This project provides **QubesOS** Ansible plugins under 2 collections: + * `qubesos.core`: Everything related to **QubesOS** management (management +modules, the connection plugin...). + * `qubesos.security`: Ansible plugins related to **dom0** and **ManagementVM** +protection while executing management modules. -### ``qubes_proxy`` strategy plugin -This strategy plugin must be used when Ansible is running on dom0 to prevent any +### Modules + + * `qubesos.core.qube`: Use this module for all tasks related to qubes management (create, destroy, start, set volumes, features, labels...) + * `qubesos.core.command`: Non-idempotent commands, used for legacy compatibility, inventory generation... + * `host_devices_facts`: Use this module to gather facts about available devices on the host. You likely want +to use this module to get a list of devices to assign to a VM with the `qubes.core.qube` module. + +### ``qubesos.core.qubes`` connection plugin + +Given the QubesOS architecture, using SSH to connect to your qubes for management is not relevant. +Instead, the Ansible connection plugin `qubesos.core.qube` allows to execute all Ansible stuff on +your target hosts using the [QubesOS qrexec framework](https://www.qubes-os.org/doc/qrexec/). + +As you would do with the command `qvm_run`, Ansible will execute modules codes through the RPCs `qubes.VMShell` and +`qubes.VMRootShell`. + +### ``qubesos.secrurity.qubes_proxy`` strategy plugin + +This strategy plugin must be used when Ansible is running on dom0 or ManagementVM to prevent any security issue. The plugin acts as a router which will proxify play execution for a given qube into its management disposable VM. @@ -44,7 +64,7 @@ on templates used by your qubes management DVM (``default-mgmt-dvm`` by default) ## Usage -``qubes`` and ``qubes_proxy`` plugins work out of the box when installed using +``qubes.core.qubes`` and ``qubesos.security.qubes_proxy`` plugins work out of the box when installed using RPM. The strategy plugin will read the value of the ``hosts`` field in your playbooks and: - run the play locally when ``localhost`` is present in the list (dom0 management / ``qubesos`` module usage) @@ -60,7 +80,7 @@ strategy=qubes_proxy You can also put this line in your Play declaration: ``` -strategy: qubes_proxy +strategy: qubesos.security.qubes_proxy ``` If extra files need to be present on the disposable VM to execute the playbook, you will need @@ -87,12 +107,14 @@ ansible └── file_to_copy_to_work.txt ``` +__Note__: you can use symlink here if multiple roles need the same file. The qubes proxy will dereference +the symlink before building the archive. See the [examples](EXAMPLES.md) for sample playbooks and role tasks demonstrating common usage scenarios. ## Limitations -The proxy plugin may modify the behaviour of your playbooks. Please notice the following indications and +The proxy plugin may modify the behavior of your playbooks. Please notice the following indications and limitations: * **Access to facts and variables from other hosts is not possible**: the proxy strategy builds a single host vars file containing a merged view of the target's host variables (i.e., variables issued from command line, group vars, host vars, inventory...). @@ -132,6 +154,48 @@ admin.vm.Create.TemplateVM * mgmtvm dom0 allow admin.vm.Remove * mgmtvm @tag:created-by-mgmtvm allow target=dom0 ``` +## Legacy module `qubesos` + +In previous versions of qubes-ansible, a single `qubesos` module were provided +which has been split into the following 3 modules to improve reliability and +maintenance: + * `qubesos.core.qube` + * `qubesos.core.command` + * `qubesos.core.host_devices_facts` + +To prevent breaking changes, this module is still present in newer versions of +**qubes-ansible** but is considered deprecated and may be removed in a future +release. + +The module takes the same options and will try to translate to calls to the new +modules with the appropriate options. + +**Note**: to prevent unexpected behaviors in your playbooks, the option `wait` +has no more effect. The module will always wait for the actions (qube start, stop...) +to finish before starting a new task. + +## Legacy plugins + +Plugins from previous **qubes-ansible** versions were deployed in +`/usr/share/ansible/plugins`. Now these plugins are packaged in an Ansible collection +in `/usr/share/ansible/collections/ansible_collections/qubesos`. + +If you look into `/usr/share/ansible/plugins`, you will still find symlinks to +the new plugins (those in the collections directory) making you able to write +your playbooks with any of those syntaxes: + +``` +- hosts: appvms + connection: qubesos.core.qubes + strategy: qubesos.security.qubes_proxy + ... + + - hosts: appvms + connection: qubes + strategy: qubes_proxy + ... +``` + ## License This project is licensed under the GPLv3+ license. Please see the [LICENSE](LICENSE) file for the full license text. diff --git a/ansible_collections/qubesos/core/galaxy.yml b/ansible_collections/qubesos/core/galaxy.yml new file mode 100644 index 0000000..41e9d37 --- /dev/null +++ b/ansible_collections/qubesos/core/galaxy.yml @@ -0,0 +1,75 @@ +#SPDX-License-Identifier: GPL-3.0-or-later +### REQUIRED +# The namespace of the collection. This can be a company/brand/organization or product namespace under which all +# content lives. May only contain alphanumeric lowercase characters and underscores. Namespaces cannot start with +# underscores or numbers and cannot contain consecutive underscores +namespace: qubesos + +# The name of the collection. Has the same character restrictions as 'namespace' +name: core + +# The version of the collection. Must be compatible with semantic versioning +version: 1.0.0 + +# The path to the Markdown (.md) readme file. This path is relative to the root of the collection +readme: README.md + +# A list of the collection's content authors. Can be just the name or in the format 'Full Name (url) +# @nicks:irc/im.site#channel' +authors: +- Guillaume Chinal +- Frédéric Pierret +- Kushal Das + + +### OPTIONAL but strongly recommended +# A short summary description of the collection +description: Everything related to QubesOS management + +# Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only +# accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file' +license: +- GPL-3.0-or-later + +# The path to the license file for the collection. This path is relative to the root of the collection. This key is +# mutually exclusive with 'license' +license_file: '' + +# A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character +# requirements as 'namespace' and 'name' +tags: +- QubesOS +- VM +- management + +# Collections that this collection requires to be installed for it to be usable. The key of the dict is the +# collection label 'namespace.name'. The value is a version range +# L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version +# range specifiers can be set and are separated by ',' +dependencies: {} + +# The URL of the originating SCM repository +repository: https://github.com/QubesOS/qubes-ansible + +# The URL to any online docs +documentation: https://github.com/QubesOS/qubes-ansible + +# The URL to the homepage of the collection/project +homepage: https://github.com/QubesOS/qubes-ansible + +# The URL to the collection issue tracker +issues: https://github.com/QubesOS/qubes-issues + +# A list of file glob-like patterns used to filter any files or directories that should not be included in the build +# artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This +# uses 'fnmatch' to match the files or directories. Some directories and files like 'galaxy.yml', '*.pyc', '*.retry', +# and '.git' are always filtered. Mutually exclusive with 'manifest' +build_ignore: [] + +# A dict controlling use of manifest directives used in building the collection artifact. The key 'directives' is a +# list of MANIFEST.in style +# L(directives,https://packaging.python.org/en/latest/guides/using-manifest-in/#manifest-in-commands). The key +# 'omit_default_directives' is a boolean that controls whether the default directives are used. Mutually exclusive +# with 'build_ignore' +# manifest: null + diff --git a/ansible_collections/qubesos/core/meta/runtime.yml b/ansible_collections/qubesos/core/meta/runtime.yml new file mode 100644 index 0000000..331cefe --- /dev/null +++ b/ansible_collections/qubesos/core/meta/runtime.yml @@ -0,0 +1,53 @@ +#SPDX-License-Identifier: GPL-3.0-or-later +--- +# Collections must specify a minimum required ansible version to upload +# to galaxy +requires_ansible: '>=2.16.14' + +# Content that Ansible needs to load from another location or that has +# been deprecated/removed +# plugin_routing: +# action: +# redirected_plugin_name: +# redirect: ns.col.new_location +# deprecated_plugin_name: +# deprecation: +# removal_version: "4.0.0" +# warning_text: | +# See the porting guide on how to update your playbook to +# use ns.col.another_plugin instead. +# removed_plugin_name: +# tombstone: +# removal_version: "2.0.0" +# warning_text: | +# See the porting guide on how to update your playbook to +# use ns.col.another_plugin instead. +# become: +# cache: +# callback: +# cliconf: +# connection: +# doc_fragments: +# filter: +# httpapi: +# inventory: +# lookup: +# module_utils: +# modules: +# netconf: +# shell: +# strategy: +# terminal: +# test: +# vars: + +# Python import statements that Ansible needs to load from another location +# import_redirection: +# ansible_collections.ns.col.plugins.module_utils.old_location: +# redirect: ansible_collections.ns.col.plugins.module_utils.new_location + +# Groups of actions/modules that take a common set of options +# action_groups: +# group_name: +# - module1 +# - module2 diff --git a/ansible_collections/qubesos/core/plugins/connection/qubes.py b/ansible_collections/qubesos/core/plugins/connection/qubes.py new file mode 100644 index 0000000..96f74a9 --- /dev/null +++ b/ansible_collections/qubesos/core/plugins/connection/qubes.py @@ -0,0 +1,196 @@ +# Copyright (c) 2017 Ansible Project +# Copyright (C) 2018 Kushal Das +# Copyright (C) 2025 Frédéric Pierret (fepitre) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Based on the buildah connection plugin + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = """ + connection: qubes + short_description: Interact with an existing qube. + description: + - Run commands or put/fetch files to an existing qube using Qubes OS tools. + author: Qubes OS Team + version_added: "2.8" + options: + remote_addr: + description: + - vm name + default: inventory_hostname + vars: + - name: inventory_hostname + - name: ansible_host + remote_user: + description: + - The user to execute as inside the qube. + choices: + - user + - root + default: user + vars: + - name: ansible_user +""" + +import subprocess + +from ansible.module_utils.common.text.converters import to_bytes +from ansible.plugins.connection import ConnectionBase, ensure_connect + +try: + from __main__ import display +except ImportError: + from ansible.utils.display import Display + + display = Display() + + +class Connection(ConnectionBase): + """ + This connection plugin for Qubes OS uses the qvm-run executable + to interact with QubesVMs. + """ + + transport = "qubes" + has_pipelining = True + + def __init__(self, play_context, new_stdin, *args, **kwargs): + super(Connection, self).__init__( + play_context, new_stdin, *args, **kwargs + ) + self._remote_vmname = self._play_context.remote_addr + self._connected = False + # Use the provided remote_user if set; otherwise default to "user". + self.user = ( + self._play_context.remote_user + if self._play_context.remote_user + else "user" + ) + + def _qubes(self, cmd: str, in_data: bytes = None): + """ + Execute a command in the qube via qvm-run. + + :param cmd: Command string to execute on the remote system. + :param in_data: Additional data to pass to the remote command's stdin. + :param shell: Service type (e.g. qubes.VMShell or qubes.VMRootShell). + :return: Tuple of (returncode, stdout, stderr). + """ + display.vvvv(f"CMD: {cmd}") + if not cmd.endswith("\n"): + cmd += "\n" + + local_cmd = [ + "qvm-run", + "--no-gui", + "--pass-io", + "--service", + self._remote_vmname, + ] + # The Ansible module framework catches invalid remote_user values + if self.user == "root": + local_cmd.append("qubes.VMRootShell") + else: + local_cmd.append("qubes.VMShell") + local_cmd_bytes = [ + to_bytes(arg, errors="surrogate_or_strict") for arg in local_cmd + ] + display.vvvv(f"Local cmd: {local_cmd_bytes}") + display.vvv(f"RUN {local_cmd_bytes}", host=self._remote_vmname) + + # Combine the command and any additional input data + combined_input = to_bytes(cmd, errors="surrogate_or_strict") + if in_data: + combined_input += in_data + + try: + result = subprocess.run( + local_cmd_bytes, + input=combined_input, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except Exception as e: + display.error(f"Error executing command via qvm-run: {e}") + raise + + return result.returncode, result.stdout, result.stderr + + def _connect(self): + """ + Establish the connection (no persistent connection is maintained). + """ + super(Connection, self)._connect() + self._connected = True + + @ensure_connect + def exec_command(self, cmd, in_data=None, sudoable=False): + """ + Run the specified command in the QubesVM. + + :param cmd: Command to run. + :param in_data: Data to send to stdin. + :param sudoable: Not used in this plugin. + :return: Tuple (returncode, stdout, stderr). + """ + display.vvvv(f"CMD IS: {cmd}") + rc, stdout, stderr = self._qubes(cmd, in_data) + display.vvvvv( + f"STDOUT {stdout!r} STDERR {stderr!r}", host=self._remote_vmname + ) + return rc, stdout, stderr + + def put_file(self, in_path, out_path): + """ + Copy a local file from 'in_path' to the remote VM at 'out_path'. + """ + display.vvv(f"PUT {in_path} TO {out_path}", host=self._remote_vmname) + with open(in_path, "rb") as fobj: + source_data = fobj.read() + + retcode, _, _ = self._qubes(f'cat > "{out_path}"\n', source_data) + if retcode != 0: + raise RuntimeError(f"Failed to put_file to {out_path}") + + def fetch_file(self, in_path, out_path): + """ + Retrieve a file from the remote VM located at 'in_path' and save it to 'out_path'. + """ + display.vvv(f"FETCH {in_path} TO {out_path}", host=self._remote_vmname) + cmd_args = [ + "qvm-run", + "--pass-io", + "--no-gui", + self._remote_vmname, + f"cat {in_path}", + ] + with open(out_path, "wb") as fobj: + result = subprocess.run(cmd_args, stdout=fobj) + if result.returncode != 0: + raise RuntimeError(f"Failed to fetch file to {out_path}") + + def close(self): + """ + Close the connection. + """ + super(Connection, self).close() + self._connected = False diff --git a/ansible_collections/qubesos/core/plugins/module_utils/qubes_helper.py b/ansible_collections/qubesos/core/plugins/module_utils/qubes_helper.py new file mode 100644 index 0000000..aaf5a3c --- /dev/null +++ b/ansible_collections/qubesos/core/plugins/module_utils/qubes_helper.py @@ -0,0 +1,446 @@ +#!/usr/bin/python3 +# Copyright (c) 2017 Ansible Project +# Copyright (C) 2018 Kushal Das +# Copyright (C) 2025 Frédéric Pierret (fepitre) +# Copyright (C) 2026 Guillaume Chinal (guiiix) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from contextlib import suppress + +import asyncio +import time + +try: + import qubesadmin + import qubesadmin.events.utils + from qubesadmin.exc import ( + QubesVMNotStartedError, + QubesTagNotFoundError, + QubesVMError, + ) + from qubesadmin.device_protocol import ( + VirtualDevice, + DeviceAssignment, + ProtocolError, + ) +except ImportError: + qubesadmin = None + QubesVMNotStartedError = None + QubesTagNotFoundError = None + QubesVMError = None + + +VIRT_FAILED = 1 +VIRT_SUCCESS = 0 +VIRT_UNAVAILABLE = 2 + +VIRT_STATE_NAME_MAP = { + 0: "running", + 1: "paused", + 4: "shutdown", + 5: "shutdown", + 6: "crashed", +} + + +class QubesHelper(object): + + def __init__(self, module): + self.app = qubesadmin.Qubes() + self.module = module + + if qubesadmin is None: + module.fail_json("Failed to import the qubesadmin module.") + + def get_device_classes(self): + """List all available device classes in dom0 (excluding 'testclass').""" + return [c for c in self.app.list_deviceclass() if c != "testclass"] + + def find_devices_of_class(self, klass): + """Yield the port IDs of all devices matching a given class in dom0.""" + for dev in self.app.domains["dom0"].devices["pci"]: + if repr(dev.interfaces[0]).startswith("p" + klass): + yield f"pci:dom0:{dev.port_id}:{dev.device_id}" + + def get_vm(self, vmname): + """Retrieve a qube object by its name.""" + self.app.domains.refresh_cache(force=True) + return self.app.domains[vmname] + + def __get_state(self, vmname): + """Determine the current power state of a qube.""" + vm = self.app.domains[vmname] + if vm.is_paused(): + return "paused" + if vm.is_running(): + return "running" + if vm.is_halted(): + return "shutdown" + return None + + def get_states(self): + """Get the names and states of all qubes.""" + state = [] + for vm in self.app.domains: + state.append(f"{vm.name} {self.__get_state(vm.name)}") + return state + + def list_vms(self, state): + """List all non-dom0 qubes that match a specified state.""" + res = [] + for vm in self.app.domains: + if vm.name != "dom0" and state == self.__get_state(vm.name): + res.append(vm.name) + return res + + def all_vms(self): + """Group all non-dom0 qubes by their VM class.""" + res = {} + for vm in self.app.domains: + if vm.name == "dom0": + continue + res.setdefault(vm.klass, []).append(vm.name) + return res + + def info(self): + """Gather detailed info (state, network, label) for all non-dom0 qubes.""" + info = {} + for vm in self.app.domains: + if vm.name == "dom0": + continue + info[vm.name] = { + "state": self.__get_state(vm.name), + "provides_network": vm.provides_network, + "label": vm.label.name, + } + return info + + def shutdown(self, vmname, wait=False): + """ + Shutdown the specified qube via the given id or name, + optionally waiting until it halts. + """ + vm = self.get_vm(vmname) + with suppress(QubesVMNotStartedError): + vm.shutdown() + + if wait: + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete( + asyncio.wait_for( + qubesadmin.events.utils.wait_for_domain_shutdown([vm]), + vm.shutdown_timeout, + ) + ) + except asyncio.TimeoutError: + raise RuntimeError( + f"Timeout: VM {vmname} did not halt within {vm.shutdown_timeout}s" + ) + return 0 + + def restart(self, vmname, wait=False): + """ + Restart the specified qube via the given id or name + by shutting it down (with optional wait) and then starting it. + """ + try: + self.shutdown(vmname, wait=wait) + except RuntimeError: + raise + vm = self.get_vm(vmname) + vm.start() + return 0 + + def pause(self, vmname): + """Pause the specified qube via the given id or name.""" + vm = self.get_vm(vmname) + vm.pause() + return 0 + + def unpause(self, vmname): + """Unpause the specified qube via the given id or name.""" + vm = self.get_vm(vmname) + vm.unpause() + return 0 + + def create( + self, + vmname, + vmtype=None, + label="red", + template=None, + netvm="*default*", + ): + """Create a new qube of the given type, label, template, and network.""" + vmtype = vmtype or "AppVM" + template_vm = template or "" + if netvm == "*default*": + network_vm = qubesadmin.DEFAULT + elif not netvm: + network_vm = None + else: + network_vm = self.get_vm(netvm) + + vm = self.app.add_new_vm(vmtype, vmname, label, template=template_vm) + vm.netvm = network_vm + return 0 + + def create_or_clone( + self, + vmname, + vmtype, + label="red", + template=None, + netvm="*default*", + ): + """Create a new qube of the given type, label, template, and network.""" + template_vm = template or "" + if netvm == "*default*": + network_vm = qubesadmin.DEFAULT + elif not netvm: + network_vm = None + else: + network_vm = self.get_vm(netvm) + if vmtype == "AppVM": + if template_vm and self.get_vm(template_vm)._klass == vmtype: + vm = self.app.clone_vm( + template_vm, vmname, vmtype, ignore_devices=True + ) + else: + vm = self.app.add_new_vm( + vmtype, vmname, label, template=template_vm + ) + vm.netvm = network_vm + elif vmtype in ["StandaloneVM", "TemplateVM"] and template_vm: + vm = self.app.clone_vm( + template_vm, vmname, vmtype, ignore_devices=True + ) + vm.label = label + elif vmtype == "DispVM" and template_vm: + vm = self.app.add_new_vm( + vmtype, vmname, label, template=template_vm + ) + vm.netvm = network_vm + return 0 + + def start(self, vmname): + """Start the specified qube via the given id or name""" + vm = self.get_vm(vmname) + vm.start() + return 0 + + def destroy(self, vmname): + """Immediately kill the specified qube via the given id or name (no graceful shutdown).""" + vm = self.get_vm(vmname) + vm.kill() + return 0 + + def properties(self, vmname, prefs): + """Sets the given properties to the qube""" + changed = False + values_changed = [] + vm = self.get_vm(vmname) + + # VM-reference properties + vm_ref_keys = [ + "audiovm", + "default_dispvm", + "default_user", + "guivm", + "management_dispvm", + "netvm", + "template", + ] + + for key, val in prefs.items(): + # use of `features` nested in properties is legacy use. Drop by 2030 + if key == "features": + if self.features(vmname, val): + changed = True + if "features" not in values_changed: + values_changed.append("features") + + elif key == "services": + for svc in val: + feat = f"service.{svc}" + if vm.features.get(feat) != "1": + vm.features[feat] = "1" + changed = True + if changed and "features" not in values_changed: + values_changed.append("features") + + elif key == "volumes": + for vol in prefs.get("volumes", []): + try: + volume = vm.volumes[vol["name"]] + volume.resize(vol["size"]) + except Exception: + return VIRT_FAILED, { + "Failure in updating volume": vol["name"] + } + changed = True + values_changed.append(f"volume:{vol["name"]}") + + else: + # determine new value or default + if val in (None, ""): + new_val = "" + elif val == "*default*": + new_val = qubesadmin.DEFAULT + else: + new_val = val + # check and apply change + if new_val is qubesadmin.DEFAULT: + if not vm.property_is_default(key): + setattr(vm, key, new_val) + changed = True + values_changed.append(key) + else: + if getattr(vm, key) != new_val: + setattr(vm, key, new_val) + changed = True + values_changed.append(key) + + return changed, values_changed + + def remove(self, vmname): + """Destroy and then delete a qube's configuration and disk.""" + try: + self.destroy(vmname) + except QubesVMNotStartedError: + pass + while True: + if self.__get_state(vmname) == "shutdown": + break + time.sleep(1) + del self.app.domains[vmname] + return 0 + + def status(self, vmname): + """ + Return a state suitable for server consumption. Aka, codes.py values, not XM output. + """ + return self.__get_state(vmname) + + def parse_device(self, spec): + """Parse a device specification string into its class and VirtualDevice.""" + parts = spec.split(":", 1) + if len(parts) != 2: + self.module.fail_json(msg=f"Invalid spec {spec}") + devclass, rest = parts + if devclass not in self.get_device_classes(): + self.module.fail_json(msg=f"Invalid devclass {devclass}") + try: + device = VirtualDevice.from_str(rest, devclass, self.app.domains) + return devclass, device + except Exception as e: + self.module.fail_json(msg=f"Cannot parse device {spec}: {e}") + return None + + def list_assigned_devices(self, vmname, devclass): + """List currently assigned devices of a given class for a qube.""" + vm = self.get_vm(vmname) + current = {} + for ass in vm.devices[devclass].get_assigned_devices(): + # get the VirtualDevice + d = getattr(ass, "virtual_device", None) or ass.device + spec = f"{devclass}:{d.backend_domain}:{d.port_id}:{d.device_id}" + mode = getattr(ass, "mode", None) + opts = getattr(ass, "options", None) or {} + current[spec] = (mode, opts) + return current + + def assign(self, vmname, devclass, device_assignment): + """Assign a device to the specified qube.""" + vm = self.get_vm(vmname) + vm.devices[devclass].assign(device_assignment) + return 0 + + def unassign(self, vmname, devclass, device_assignment): + """Remove an assigned device from the specified qube.""" + vm = self.get_vm(vmname) + vm.devices[devclass].unassign(device_assignment) + return 0 + + def sync_devices(self, vmname, devclass, desired): + """Synchronize a qube's device assignments to match the desired configuration.""" + # build desired map: spec -> (vd, per_mode, opts) + desired_map = { + f"{devclass}:{vd.backend_domain}:{vd.port_id}:{vd.device_id}": ( + vd, + per_mode, + opts or {}, + ) + for vd, per_mode, opts in (desired or []) + } + + changed = False + + # current assignments: spec -> (mode, opts) + current_map = self.list_assigned_devices(vmname, devclass) + current_specs = set(current_map) + desired_specs = set(desired_map) + + # 1) Unassign anything not in desired + for spec in current_specs - desired_specs: + cls, dev = self.parse_device(spec) + self.unassign( + vmname, + cls, + DeviceAssignment(dev, frontend_domain=self.get_vm(vmname)), + ) + changed = True + + # 2) Reassign anything whose mode or options differ + for spec in current_specs & desired_specs: + existing_mode, existing_opts = current_map[spec] + vd, per_mode, opts = desired_map[spec] + # normalize desired_mode + desired_mode = per_mode or ( + "required" if devclass == "pci" else "auto-attach" + ) + if existing_mode.value != desired_mode or existing_opts != opts: + # tear down the old and set up the new + cls, dev = self.parse_device(spec) + self.unassign( + vmname, + cls, + DeviceAssignment(dev, frontend_domain=self.get_vm(vmname)), + ) + self.assign( + vmname, + devclass, + DeviceAssignment(vd, mode=desired_mode, options=opts), + ) + changed = True + + # 3) Assign any new specs + for spec in desired_specs - current_specs: + vd, per_mode, opts = desired_map[spec] + assign_mode = per_mode or ( + "required" if devclass == "pci" else "auto-attach" + ) + self.assign( + vmname, + devclass, + DeviceAssignment(vd, mode=assign_mode, options=opts), + ) + changed = True + + return changed diff --git a/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_command.py b/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_command.py new file mode 100644 index 0000000..2c0934c --- /dev/null +++ b/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_command.py @@ -0,0 +1,229 @@ +#!/usr/bin/python3 +# Copyright (c) 2017 Ansible Project +# Copyright (C) 2018 Kushal Das +# Copyright (C) 2025 Frédéric Pierret (fepitre) +# Copyright (C) 2026 Guillaume Chinal (guiiix) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from contextlib import suppress +from enum import Enum, auto +from jinja2 import Template +from typing import Optional +from functools import wraps + +from ansible_collections.qubesos.core.plugins.module_utils.qubes_helper import ( + QubesHelper, +) + +from ansible.module_utils.basic import AnsibleModule + +from qubesadmin.exc import ( + QubesTagNotFoundError, +) + + +class CommandType(Enum): + HOST = auto() + VM = auto() + + +# This dictionary is automatically populated by the command registered with +# the decorator @register_command +SUPPORTED_COMMANDS = {} + + +def get_module_param(module, param, required=True): + param_value = module.params.get(param, None) + if param_value is None and required: + module.fail_json(msg=f"Expected '{param}' parameter to be specified") + return param_value + + +def register_command( + command_name: str, command_type: Optional[CommandType] = None +): + """ + Populates SUPPORTED_COMMANDS and injecting guest function parameter + for VM commands + """ + + def decorator(func): + @wraps(func) + def wrapper(module, qubes_virt: QubesHelper): + if wrapper.__command_type__ == CommandType.VM: + guest = get_module_param(module, "name") + return func(module, qubes_virt, guest) + return func(module, qubes_virt) + + SUPPORTED_COMMANDS[command_name] = wrapper + wrapper.__command_type__ = command_type + + return func + + return decorator + + +@register_command("createinventory", CommandType.HOST) +def create_inventory(module, qubes_virt: QubesHelper): + """ + Creates the inventory file dynamically for QubesOS + """ + template_str = """[local] +dom0 +localhost + +[local:vars] +ansible_connection=local + +{% if result.AppVM %} +[appvms] +{% for item in result.AppVM %} +{{ item -}} +{% endfor %} + +[appvms:vars] +ansible_connection=qubes +{% endif %} + +{% if result.TemplateVM %} +[templatevms] +{% for item in result.TemplateVM %} +{{ item -}} +{% endfor %} + +[templatevms:vars] +ansible_connection=qubes +{% endif %} + +{% if result.StandaloneVM %} +[standalonevms] +{% for item in result.StandaloneVM %} +{{ item -}} +{% endfor %} + +[standalonevms:vars] +ansible_connection=qubes +{% endif %} +""" + result = qubes_virt.all_vms() + template = Template(template_str) + res = template.render(result=result) + with open("inventory", "w") as fobj: + fobj.write(res) + module.exit_json(status="successful") + + +@register_command("create", CommandType.VM) +def create_qube(module, qubes_virt: QubesHelper, guest: str): + try: + qubes_virt.get_vm(guest) + module.exit_json(changed=False) + except KeyError: + qubes_virt.create_or_clone( + guest, + get_module_param(module, "vmtype"), + get_module_param(module, "label", False) or "red", + get_module_param(module, "template", False), + get_module_param(module, "netvm", False), + ) + + module.exit_json(changed=True, created=guest) + + +@register_command("get_states", CommandType.HOST) +def get_states(module, qubes_virt: QubesHelper): + module.exit_json(states=qubes_virt.get_states()) + + +@register_command("list_vms", CommandType.HOST) +def list_vms(module, qubes_virt: QubesHelper): + module.exit_json( + list_vms=qubes_virt.list_vms(get_module_param(module, "state")) + ) + + +@register_command("removetags", CommandType.VM) +def remove_tags(module, qubes_virt: QubesHelper, guest: str): + vm = qubes_virt.get_vm(guest) + changed = False + tags = get_module_param(module, "tags") + + with suppress(QubesTagNotFoundError): + for tag in tags: + vm.tags.remove(tag) + changed = True + + module.exit_json(changed=changed) + + +@register_command("destroy", CommandType.VM) +@register_command("info", CommandType.HOST) +@register_command("pause", CommandType.VM) +@register_command("remove", CommandType.VM) +@register_command("shutdown", CommandType.VM) +@register_command("start", CommandType.VM) +@register_command("status", CommandType.VM) +@register_command("unpause", CommandType.VM) +def generic_command(module, qubes_virt, guest: Optional[str] = None): + command = get_module_param(module, "command") + args = [] if guest is None else [guest] + res = getattr(qubes_virt, command)(*args) + if not isinstance(res, dict): + res = {command: res} + if "changed" not in res: + res["changed"] = True + module.exit_json(**res) + + +def core(module): + command = get_module_param(module, "command") + + command_func = SUPPORTED_COMMANDS.get(command) + if command_func is None: + module.fail_json(msg=f"Command '{command}' not recognized") + + qubes_virt = QubesHelper(module) + + command_func(module, qubes_virt) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + command=dict(type="str", choices=list(SUPPORTED_COMMANDS.keys())), + label=dict(type="str", default="red"), + name=dict(type="str", aliases=["guest"]), + netvm=dict(type="str", default=None), + state=dict( + type="str", + choices=[ + "paused", + "running", + "shutdown", + ], + ), + tags=dict(type="list", default=[]), + template=dict(type="str", default=None), + vmtype=dict(type="str", default="AppVM"), + ), + ) + + core(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_host_devices_facts.py b/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_host_devices_facts.py new file mode 100644 index 0000000..72ee5f2 --- /dev/null +++ b/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_host_devices_facts.py @@ -0,0 +1,59 @@ +#!/usr/bin/python3 +# Copyright (c) 2017 Ansible Project +# Copyright (C) 2018 Kushal Das +# Copyright (C) 2025 Frédéric Pierret (fepitre) +# Copyright (C) 2026 Guillaume Chinal (guiiix) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.qubesos.core.plugins.module_utils.qubes_helper import ( + QubesHelper, +) + + +def core(module): + helper = QubesHelper(module) + module.exit_json( + ansible_facts={ + "pci_net": sorted(helper.find_devices_of_class("02")), + "pci_usb": sorted(helper.find_devices_of_class("0c03")), + "pci_audio": sorted( + list(helper.find_devices_of_class("0401")) + + list(helper.find_devices_of_class("0403")) + ), + } + ) + + +def main(): + module = AnsibleModule(argument_spec=dict()) + + try: + import qubesadmin + import qubesadmin.exc + from qubesadmin.device_protocol import DeviceAssignment + except ImportError: + qubesadmin = None + + if qubesadmin is None: + module.fail_json("Failed to import the qubesadmin module.") + + core(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_qube.py b/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_qube.py new file mode 100644 index 0000000..e8d19b0 --- /dev/null +++ b/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_qube.py @@ -0,0 +1,637 @@ +#!/usr/bin/python3 +# Copyright (c) 2017 Ansible Project +# Copyright (C) 2018 Kushal Das +# Copyright (C) 2025 Frédéric Pierret (fepitre) +# Copyright (C) 2026 Guillaume Chinal (guiiix) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.qubesos.core.plugins.module_utils.qubes_helper import ( + QubesHelper, +) + +from typing import List, Optional + +from dataclasses import dataclass +from qubesadmin.vm import QubesVM + +try: + import qubesadmin + import qubesadmin.exc + from qubesadmin.device_protocol import DeviceAssignment, AssignmentMode +except ImportError: + qubesadmin = None + + +@dataclass +class QubesWants: + clone_src: Optional[str] + devices: Optional[dict | list] + features: Optional[dict] + notes: Optional[str] + properties: Optional[dict] + services: Optional[list] + state: str + tags: Optional[List[str]] + template: Optional[str] + klass: Optional[str] + volumes: Optional[dict] + + def __post_init__(self): + """Set empty list / dict for a couple of params if their value is None""" + for attribute in [ + "features", + "properties", + "volumes", + ]: + if getattr(self, attribute) is None: + setattr(self, attribute, {}) + + for attribute in [ + "tags", + ]: + if getattr(self, attribute) is None: + setattr(self, attribute, []) + + +class QubeModule: + def __init__(self, module: AnsibleModule): + self.module = module + self.qube_name = module.params.get("name") + self.changed = False + self.created = False + self.deleted = False + self.diff = {"before": {}, "after": {}} + self.helper = QubesHelper(module) + self.qube: QubesVM = self.helper.app.domains.get(self.qube_name) + self.shutdown_if_required = module.params.get("shutdown_if_required") + self.devices_set_mode = None + + wanted_state = module.params.get("state") + # Normalize state to + # - absent + # - halted + # - paused + # - present + # - restarted + # - running + if wanted_state in ["destroyed", "shutdown"]: + wanted_state = "halted" + elif wanted_state == "pause": + wanted_state = "paused" + + self.wants = QubesWants( + clone_src=module.params.get("clone_src"), + devices=module.params.get("devices"), + features=module.params.get("features"), + notes=module.params.get("notes"), + properties=module.params.get("properties"), + state=wanted_state, + services=module.params.get("services"), + tags=module.params.get("tags"), + template=module.params.get("template"), + volumes=module.params.get("volumes"), + klass=module.params.get("klass"), + ) + + if self.wants.properties is None: + self.wants.properties = {} + + # Sync template var with template key in properties var + # No template property for TemplateVMs and StandaloneVMs + if self.wants.klass not in ("TemplateVM", "StandaloneVM"): + if self.wants.template: + self.wants.properties["template"] = self.wants.template + elif "template" in self.wants.properties: + self.wants.template = self.wants.properties["template"] + + def _shutdown_for_template_update(self): + """Change the template if required, raise an error if the qube is running and cannot be stopped""" + if self.wants.klass == "StandaloneVM": + return + + if ( + self.wants.template is not None + and self.wants.template != self.qube.template + ): + if not self.qube.is_halted(): + # We need to shutdown the qube before updating its template + # `shutdown_if_required` module params allows us stop it + # if necessary + if self.shutdown_if_required: + self.helper.shutdown(self.qube_name, wait=True) + else: + self.module.fail_json( + msg="Cannot change the template while the qube is running" + ) + + def enforce_all(self): + self.enforce_properties() + self.enforce_volumes() + self.enforce_devices() + self.enforce_features() + self.enforce_notes() + self.enforce_tags() + self.enforce_state() + + def _enforce_device_class_strict_mode(self, device_class, wants) -> bool: + return self.helper.sync_devices(self.qube_name, device_class, wants) + + def _enforce_device_class_append_mode(self, device_class, wants) -> bool: + current_map = self.helper.list_assigned_devices( + self.qube_name, device_class + ) + changed = False + for vd, per_mode, opts in wants: + spec = f"{device_class}:{vd.backend_domain}:{vd.port_id}:{vd.device_id}" + if spec in current_map: + # already present -> leave it (no mode/options change in append mode) + continue + # new device -> assign with its mode/options + assign_mode = per_mode or ( + "required" if device_class == "pci" else "auto-attach" + ) + self.helper.assign( + self.qube_name, + device_class, + DeviceAssignment(vd, mode=assign_mode, options=opts), + ) + changed = True + return changed + + def _list_all_assigned_devices(self): + return { + device_class: self.helper.list_assigned_devices( + self.qube_name, device_class + ) + for device_class in self.helper.get_device_classes() + } + + def enforce_devices(self): + def compute_devices_list(): + return { + str(dev_type): { + str(dev): ( + str(dev_conf[0].value) + if isinstance(dev_conf[0], AssignmentMode) + else str(dev_conf[0]) + ) + for dev, dev_conf in dev_list.items() + } + for dev_type, dev_list in self._list_all_assigned_devices().items() + } + + if self.wants.devices is None: + return + + changed = False + before_devices = compute_devices_list() + + for device_class in self.helper.get_device_classes(): + # gather only the entries for this class + wants = [ + (vd, per_mode, opts) + for (cls, vd, per_mode, opts) in self.wants.devices + if cls == device_class + ] + if self.devices_set_mode == "strict": + changed |= self._enforce_device_class_strict_mode( + device_class, wants + ) + elif self.devices_set_mode == "append": + changed |= self._enforce_device_class_append_mode( + device_class, wants + ) + if changed: + self.changed = True + self.diff["before"]["devices"] = before_devices + self.diff["after"]["devices"] = compute_devices_list() + + def enforce_existence(self): + """Creates or remove the qube""" + if self.wants.state == "absent": + if self.qube: + self.diff["before"][ + "state" + ] = self.qube.get_power_state().lower() + self.diff["after"]["state"] = "absent" + self.helper.remove(self.qube_name) + self.changed = True + self.deleted = True + else: + if self.qube: + self.diff["before"][ + "state" + ] = self.qube.get_power_state().lower() + if not self.qube: + if self.wants.clone_src: + if not self.wants.clone_src in self.helper.app.domains: + self.module.fail_json( + msg=f"Cannot clone the '{self.wants.clone_src}' because it doesn't exist" + ) + source_vm = self.helper.app.domains[self.wants.clone_src] + self.helper.app.clone_vm( + src_vm=source_vm, + new_name=self.qube_name, + new_cls=(self.wants.klass or "AppVM"), + ignore_devices=True, + ) + else: + self.helper.create( + vmname=self.qube_name, + vmtype=self.wants.klass, + template=self.wants.template, + ) + self.changed = True + self.created = True + self.qube = self.helper.get_vm(self.qube_name) + self.diff["before"]["state"] = "absent" + + def enforce_features(self): + for feature_name, feature_val in self.wants.features.items(): + if feature_val is None: + if feature_name in self.qube.features: + self.changed = True + self.diff["before"].setdefault("features", {}) + self.diff["after"].setdefault("features", {}) + self.diff["before"]["features"][feature_name] = ( + self.qube.features.get(feature_name) + ) + self.diff["after"]["features"][feature_name] = None + del self.qube.features[feature_name] + + elif self.qube.features.get(feature_name) != feature_val: + self.changed = True + self.diff["before"].setdefault("features", {}) + self.diff["after"].setdefault("features", {}) + self.diff["before"]["features"][feature_name] = ( + self.qube.features.get(feature_name) + ) + self.diff["after"]["features"][feature_name] = feature_val + self.qube.features[feature_name] = feature_val + + def enforce_notes(self): + if self.wants.notes is None: + return + + notes = self.qube.get_notes() + if notes != self.wants.notes: + self.changed = True + self.diff["before"]["notes"] = notes + self.diff["after"]["notes"] = self.wants.notes + self.qube.set_notes(self.wants.notes) + + def enforce_properties(self): + self._shutdown_for_template_update() + before = {} + after = {} + + for property_name, property_val in self.wants.properties.items(): + try: + before_val = getattr(self.qube, property_name) + # Useful for VMs + if hasattr(before_val, "name"): + before_val = before_val.name + + value_to_set = ( + qubesadmin.DEFAULT + if property_val == "*default*" + else property_val + ) + + if before_val != value_to_set: + setattr(self.qube, property_name, value_to_set) + before[property_name] = before_val + after[property_name] = property_val + except qubesadmin.exc.QubesNoSuchPropertyError: + self.module.fail_json( + msg=f"Invalid property: '{property_name}'" + ) + except qubesadmin.exc.QubesValueError as e: + self.module.fail_json( + msg=f"Invalid property value type for '{property_name}': {e}" + ) + except qubesadmin.exc.QubesException as e: + self.module.fail_json( + msg=f"Error while setting property '{property_name}': {e}", + ) + if before or after: + self.changed = True + self.diff["before"]["properties"] = before + self.diff["after"]["properties"] = after + + def enforce_state(self): + current_status = self.qube.get_power_state().lower() + if self.wants.state in ["present", "halted"]: + return + + if current_status == self.wants.state: + return + + if self.wants.state in ["running", "restarted"]: + self.changed = True + if current_status == "paused": + self.qube.unpause() + else: + self.qube.start() + + if self.wants.state == "paused": + self.changed = True + self.qube.pause() + + def enforce_tags(self): + """Add a list of tags to a qube, skipping any already present.""" + if self.wants.tags is None: + return + + tags_before = list(self.qube.tags) + tags_after = [] + changed = False + for tag in self.wants.tags: + if tag in self.qube.tags: + continue + self.qube.tags.add(tag) + tags_after.append(tag) + changed = True + if changed: + self.changed = True + self.diff["before"]["tags"] = tags_before + self.diff["after"]["tags"] = tags_after + + def enforce_volumes(self): + for volume_name, volume_config in self.wants.volumes.items(): + for property_name, property_value in volume_config.items(): + volume = self.qube.volumes[volume_name] + if property_name == "size": + if volume.size != int(property_value): + self.changed = True + self.diff["before"].setdefault("volumes", {}) + self.diff["after"].setdefault("volumes", {}) + self.diff["before"]["volumes"].setdefault( + volume_name, {} + ) + self.diff["after"]["volumes"].setdefault( + volume_name, {} + ) + self.diff["before"]["volumes"][volume_name][ + "size" + ] = volume.size + self.diff["after"]["volumes"][volume_name]["size"] = ( + int(property_value) + ) + volume.resize(int(property_value)) + + if property_name == "revisions_to_keep": + if volume.revisions_to_keep != int(property_value): + self.changed = True + self.diff["before"].setdefault("volumes", {}) + self.diff["after"].setdefault("volumes", {}) + self.diff["before"]["volumes"].setdefault( + volume_name, {} + ) + self.diff["after"]["volumes"].setdefault( + volume_name, {} + ) + self.diff["before"]["volumes"][volume_name][ + "revisions_to_keep" + ] = volume.revisions_to_keep + self.diff["after"]["volumes"][volume_name][ + "revisions_to_keep" + ] = int(property_value) + volume.revisions_to_keep = int(property_value) + + def validate_module_parameters(self): + """Check if the module parameters are valid""" + + # We can't change the class of a Qube + if self.qube is not None and self.wants.klass is not None: + if self.wants.klass != self.qube.klass: + self.module.fail_json( + msg=f"Current Qube type is {self.qube.klass} and cannot be " + f"changed to {self.wants.klass}" + ) + + self.validate_properties() + self.validate_volumes() + self.validate_devices() + self.validate_services() + + def validate_devices(self): + """Check and normalize devices parameter""" + if self.wants.devices is None: + return + + if isinstance(self.wants.devices, dict): + unexpected_keys = set(self.wants.devices) - {"strategy", "items"} + if unexpected_keys: + self.module.fail_json( + msg=f"Unexpected keys in 'devices' parameter: {unexpected_keys}" + ) + device_specs = self.wants.devices.get("items", []) + self.devices_set_mode = self.wants.devices.get("strategy", "strict") + if self.devices_set_mode not in ["strict", "append"]: + self.module.fail_json( + msg=f"Invalid devices strategy: {self.devices_set_mode}" + ) + elif isinstance(self.wants.devices, list): + # flat list -> always strict + device_specs = self.wants.devices + self.devices_set_mode = "strict" + + else: + self.module.fail_json( + msg=f"Invalid devices parameter: {self.wants.devices!r}" + ) + + # Now expand each spec into (class, VirtualDevice, per_mode, options) + normalized_devices = [] + for entry in device_specs: + if isinstance(entry, str): + # simple string spec -> no per-device mode or options + cls, vd = self.helper.parse_device(entry) + normalized_devices.append((cls, vd, None, [])) + elif isinstance(entry, dict): + # dict spec must have a "device" key + device_str = entry.get("device") + if not device_str: + self.module.fail_json( + msg=f"Device entry missing 'device': {entry!r}" + ) + cls, vd = self.helper.parse_device(device_str) + # optional per-device mode (e.g. "required" or "auto-attach") + per_mode = entry.get("mode") + # optional options list + opts = entry.get("options", {}) + normalized_devices.append((cls, vd, per_mode, opts)) + else: + self.module.fail_json(msg=f"Invalid device entry: {entry!r}") + self.wants.devices = normalized_devices + + def validate_properties(self): + # Check VM existence for the following properties + + for property in [ + "audiovm", + "default_dispvm", + "management_dispvm", + "netvm", + ]: + value = self.wants.properties.get(property) + if value in (None, "", "dom0", "*default*", self.qube_name): + continue + + try: + vm = self.helper.get_vm(value) + except KeyError: + self.module.fail_json( + msg=f"Cannot set value '{value}' to property '{property}': the qube doesn't exist", + ) + + # qubes that must provide network + if property in ["netvm"]: + if not vm.provides_network: + self.module.fail_json( + msg=f"Cannot set value '{value}' to property '{property}': the qube must provide network", + ) + + # qubes that must be templates for dispvms + if property in ["default_dispvm", "management_dispvm"]: + if not vm.klass == "AppVM" or not vm.template_for_dispvms: + self.module.fail_json( + msg=f"Cannot set value '{value}' to property '{property}: the qube is not a template for dispvm", + ) + + def validate_services(self): + if self.wants.services is None: + return + + if not isinstance(self.wants.services, list): + self.module.fail_json("Service must be provided as a list") + + for service in self.wants.services: + self.wants.features[f"service.{service}"] = "1" + + def validate_volumes(self): + """Validates 'volumes' module parameters (variable f""" + try: + for volume_name, volume_config in self.wants.volumes.items(): + if volume_name not in ["private", "root"]: + self.module.fail_json( + msg=f"Unsupported volume name '{volume_name}" + ) + + if self.wants.klass is not None: + vm_type = self.wants.klass + # If VM Type is not provided to the module, look at the Qube + elif self.qube: + vm_type = self.qube.klass + # If the Qube doesn't exist currently, it will be created + # using as an AppVM by default + else: + vm_type = "AppVM" + if volume_name == "root" and vm_type not in [ + "TemplateVM", + "StandaloneVM", + ]: + self.module.fail_json( + msg=f"Cannot change root volume config for '{vm_type}'" + ) + + except AssertionError as e: + self.module.fail_json(msg=str(e)) + + def run(self): + # Before doing anything, we want to be sure every module parameters + # has been set correctly. + # This is not required for absent state because we'll just have to + # delete the qube, so we can ignore supplied attributes. + if self.wants.state != "absent": + self.validate_module_parameters() + + # Create or delete the qube + self.enforce_existence() + + # We've deleted the qube so there is no remaining action to do + # when wanted absent state + if self.wants.state != "absent": + # If we need to shutdown the qube, let's do it before enforcing its + # properties to avoid errors + if self.wants.state in ["halted", "restarted"]: + if not self.qube.is_halted(): + self.changed = True + self.helper.shutdown(self.qube_name, wait=True) + + self.enforce_all() + + self.diff["after"]["state"] = self.qube.get_power_state().lower() + if self.diff["before"]["state"] == self.diff["after"]["state"]: + del self.diff["before"]["state"] + del self.diff["after"]["state"] + + if self.created: + self.diff["before"] = {} + + if self.deleted: + self.diff["after"] = {} + + self.module.exit_json( + changed=self.changed, + diff=self.diff, + created=self.created, + deleted=self.deleted, + ) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict(type="str", aliases=["guest"], required=True), + state=dict( + type="str", + choices=[ + "absent", + "destroyed", + "halted", + "pause", + "paused", + "present", + "restarted", + "running", + "shutdown", + ], + required=True, + ), + clone_src=dict(type="str", default=None), + devices=dict(type="raw", default=None), + features=dict(type="dict", default=None), + notes=dict(type="str", default=None), + properties=dict(type="dict", default=None), + services=dict(type="list", default=None), + shutdown_if_required=dict(type="bool", default=False), + tags=dict(type="list", default=[]), + template=dict(type="str", default=None), + klass=dict(type="str", default="AppVM", aliases=["vmtype"]), + volumes=dict(type="dict", default=None), + ), + ) + + return QubeModule(module).run() + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/qubesos/core/plugins/modules/command.py b/ansible_collections/qubesos/core/plugins/modules/command.py new file mode 100644 index 0000000..a6bb14f --- /dev/null +++ b/ansible_collections/qubesos/core/plugins/modules/command.py @@ -0,0 +1,78 @@ +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_command import ( + main, +) + +DOCUMENTATION = r""" +--- +module: command + +short_description: Run an action in your qubes or in your dom0. + +description: + - This module runs predefined actions in your dom0 or in your qubes. + - The actions are not idempotent. +version_added: "1.0.0" +options: + name: + description: + - Name of the Qubes OS virtual machine to manage. + - This parameter is required for operations targeting a specific VM. It can also be specified as C(guest). + state: + description: + - Desired state of the VM. + - When set to C(running), ensures the VM is started. + - When set to C(shutdown), ensures the VM is stopped. + - When set to C(paused), pauses a running VM. + choices: [ present, running, shutdown, destroyed, restarted, pause, absent ] + command: + description: + - Non-idempotent command to execute in the VM. + - "Available commands include:" + - "C(create): Create a new VM." + - "C(createinventory): Generate an inventory file for Qubes OS VMs." + - "C(destroy): Force shutdown of a VM." + - "C(get_states): Get the states of all VMs." + - "C(info): Retrieve information about all VMs." + - "C(list_vms): List VMs filtered by state." + - "C(pause): Pause a running VM." + - "C(remove): Remove a VM." + - "C(removetags): Remove specified tags from a VM." + - "C(shutdown): Gracefully shut down a VM." + - "C(start): Start a VM." + - "C(status): Retrieve the current state of a VM." + - "C(unpause): Resume a paused VM." + required: true + vmtype: + description: + - The type of VM to manage. + - Typical values include C(AppVM), C(StandaloneVM), C(TemplateVM) and C(DispVM). + - Refer to the Qubes OS Glossary for definitions of these terms. + default: "AppVM" + template: + description: + - Name of the template VM to use when creating or cloning a VM. + - For AppVMs, this is the base TemplateVM from which the VM is derived. + default: "default" + tags: + description: + - A list of tags to apply to the VM. + - Tags are used within Qubes OS for VM categorization. + type: list + default: [] + + netvm: + description: + - The netvm to set + type: str + + +requirements: + - qubesadmin +author: + - Kushal Das + - Frédéric Pierret + - Guillaume Chinal +""" + +if __name__ == "__main__": + main() diff --git a/ansible_collections/qubesos/core/plugins/modules/host_devices_facts.py b/ansible_collections/qubesos/core/plugins/modules/host_devices_facts.py new file mode 100644 index 0000000..83627fe --- /dev/null +++ b/ansible_collections/qubesos/core/plugins/modules/host_devices_facts.py @@ -0,0 +1,51 @@ +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_host_devices_facts import ( + main, +) + +DOCUMENTATION = r""" +--- +module: host_devices_facts + +short_description: Retrieve information about the host devices. + +description: + - Retrieve information related to the host devices + - You can use the gathered facts to assign devices to a qube using + qubesos.core.qube. + +version_added: "1.0.0" + +requirements: + - qubesadmin +""" + + +RETURN = r""" +ansible_facts: + description: Facts related to host devices + type: dict + returned: always + contains: + pci_net: + description: Network devices + type: list + returned: always + pci_usb: + description: USB devices + type: list + returned: always + pci_audio: + description: Audio devices + type: list + returned: always +""" + +EXAMPLES = r""" +- qubesos.core.host_devices_facts: +- ansible.builtin.debug: + var: ansible_facts.pci_net +""" + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/qubesos/core/plugins/modules/qube.py b/ansible_collections/qubesos/core/plugins/modules/qube.py new file mode 100644 index 0000000..cdc9aaa --- /dev/null +++ b/ansible_collections/qubesos/core/plugins/modules/qube.py @@ -0,0 +1,265 @@ +#!/usr/bin/python3 +# Copyright (c) 2017 Ansible Project +# Copyright (C) 2018 Kushal Das +# Copyright (C) 2025 Frédéric Pierret (fepitre) +# Copyright (C) 2026 Guillaume Chinal (guiiix) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + + +DOCUMENTATION = r""" +--- +module: qube + +short_description: Everything related to the management of your QubesOS qubes. + +description: + - Create and delete qubes. + - Control your qubes state (start, stop, pause...) . + - Manage their attributes (features, properties, label, notes, assigned devices...). + +version_added: "1.0.0" + +options: + name: + description: The name of the qube you want to manage. + required: true + type: str + + state: + description: The qubes desired state. + required: true + type: str + choices: + - absent + - present + - halted + - pause + - present + - restarted + - running + - shutdown + + clone_src: + description: + - Create a new qube using the specified qube as source. + - You can specify different types in O(clone_src) and O(klass). + - For example, specifying a O(clone_src=my-template-qube) and + O(klass=StandaloneVM) will create a StandaloneVM from a TemplateVM. + + type: str + + devices: + description: + - Device assignment configuration for the VM. + - "Supported usage patterns:" + - "1. A list (default _strict_ mode) device specs (strings or dicts). The VM's assigned devices will be exactly those listed, removing any others." + - "2. A dictionary:" + - " - strategy (str): assignment strategy to use. " + - " - C(strict) (default): enforce exact match of assigned devices to C(items). " + - " - C(append): add only new devices in C(items), leaving existing assignments intact." + - " - items (list): list of device specs (strings or dicts) to apply under the chosen strategy." + - "Device spec formats:" + - " - string: `::[:]` (e.g. C(pci:dom0:5), C(block:dom0:vdb))" + - " - dict:" + - " - device (str, required): the string spec as above." + - " - mode (str, optional):" + - " - For PCI devices defaults to C(required)." + - " - For other classes defaults to C(auto-attach)." + - " - options (dict, optional): extra Qubes device flags to pass when attaching." + - A list of strings representing the devices to assign to the qube. + - Use facts module M(qubesos.core.host_devices_facts) to retrieve the list of available devices. + + features: + description: + - Configure the features of the qube. + - You have to provide a dict with features name as dict key and the desired value as dict value. + type: dict + + notes: + description: + - Set the qube notes. + type: str + + properties: + description: + - Manage the qube properties as with the qvm-prefs command. + - The module doesn't check if the property exists and performs a very simple validation + for a couple of properties (for example, if you set the V(netvm) property, it will check if + the VM exists). + - With the exception of these few properties, it will let the qubesd service + handles errors and will fail during execution. + - As for features, the module expects a dict with properties name as key and their expected value as dict value. + type: dict + + services: + description: + - Enables the provided services on a qube. + - Provided services names are translated into the feature format + - (service.myservice=1) and added to the list of expected features. + - If a service is specified both in the O(features) and O(services) options, + - the value in O(services) is kept. + type: list + elements: str + + shutdown_if_required: + description: + - Allow Ansible to shutdown the qube if an operation requires it. + - This option currently applies only when changing the qube's template. + type: bool + default: false + + tags: + description: + - Add the provided tags to the qube. + type: list + elements: str + + template: + description: + - Set the qube template. + type: str + + klass: + description: + - Set the qube type. + - Trying to change the type of an existing qube will raise an error. + - This option is used when creating or cloning a VM. + aliases: + - vmtype + type: str + + volumes: + description: + - Change the settings of the qube volumes. + - Volume name must be specified as dict key and volume settings as subelements. + suboptions: + size: + description: + - The volume size. + revisions_to_keep: + description: + - Set the number of revisions to keep. + +requirements: + - qubesadmin +""" + +EXAMPLES = r""" +- name: Create an AppVM using the system default template + qubesos.core.qube: + name: my-app-vm + klass: AppVM + state: present + + +- name: Start a qube + qubesos.core.qube: + name: my-app-vm + state: running + +- name: Stop a qube + qubesos.core.qube: + name: my-app-vm + state: halted + +- name: Remove a qube + qubesos.core.qube: + name: my-app-vm + state: absent + +- name: Ensure my-app-vm is running and has the following attributes + qubesos.core.qube: + name: my-app-vm + klass: AppVM + state: running + features: + menu-items: "xfce4-terminal.desktop thunar.desktop firefox-esr.desktop xfce-settings-manager.desktop" + properties: + autostart: true + qrexec_timeout: 600 + provides_network: true + tags: + - my-tag + label: yellow + notes: Notes about my qube + +- name: Create an AppVM using an old template + qubesos.core.qube: + name: my-app-vm + klass: AppVM + state: present + template: my-old-template +- name: Now, update to a new template + qubesos.core.qube: + name: my-app-vm + klass: AppVM + state: present + template: my-new-template + shutdown_if_required: true + +- name: Create a StandaloneVM + qubesos.core.qube: + name: my-standalone-vm + klass: StandaloneVM + clone_src: debian-13-xfce + state: present + volumes: + private: + size: 32212254720 +""" + +RETURN = r""" +changed: + description: Indicates if the qube was changed by the module + type: bool + returned: always + +created: + description: Indicated if the qube was created by the module + type: bool + returned: always + +deleted: + description: Indicated if the qube was created by the module + type: bool + returned: always + +diff: + description: Contains the state of changed resource before and after the modification + type: dict + returned: always + contains: + before: + description: The qube properties before the modification. + type: dict + after: + description: The qube properties after the modification. + type: dict +""" + +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_qube import ( + main, +) + +if __name__ == "__main__": + main() diff --git a/ansible_collections/qubesos/security/galaxy.yml b/ansible_collections/qubesos/security/galaxy.yml new file mode 100644 index 0000000..178877a --- /dev/null +++ b/ansible_collections/qubesos/security/galaxy.yml @@ -0,0 +1,75 @@ +#SPDX-License-Identifier: GPL-3.0-or-later +### REQUIRED +# The namespace of the collection. This can be a company/brand/organization or product namespace under which all +# content lives. May only contain alphanumeric lowercase characters and underscores. Namespaces cannot start with +# underscores or numbers and cannot contain consecutive underscores +namespace: qubesos + +# The name of the collection. Has the same character restrictions as 'namespace' +name: security + +# The version of the collection. Must be compatible with semantic versioning +version: 1.0.0 + +# The path to the Markdown (.md) readme file. This path is relative to the root of the collection +readme: README.md + +# A list of the collection's content authors. Can be just the name or in the format 'Full Name (url) +# @nicks:irc/im.site#channel' +authors: +- Guillaume Chinal +- Frédéric Pierret +- Kushal Das + + +### OPTIONAL but strongly recommended +# A short summary description of the collection +description: Plugins to run ansible securely in dom0 or ManagementVM + +# Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only +# accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file' +license: +- GPL-3.0-or-later + +# The path to the license file for the collection. This path is relative to the root of the collection. This key is +# mutually exclusive with 'license' +license_file: '' + +# A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character +# requirements as 'namespace' and 'name' +tags: +- QubesOS +- VM +- management + +# Collections that this collection requires to be installed for it to be usable. The key of the dict is the +# collection label 'namespace.name'. The value is a version range +# L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version +# range specifiers can be set and are separated by ',' +dependencies: {} + +# The URL of the originating SCM repository +repository: https://github.com/QubesOS/qubes-ansible + +# The URL to any online docs +documentation: https://github.com/QubesOS/qubes-ansible + +# The URL to the homepage of the collection/project +homepage: https://github.com/QubesOS/qubes-ansible + +# The URL to the collection issue tracker +issues: https://github.com/QubesOS/qubes-issues + +# A list of file glob-like patterns used to filter any files or directories that should not be included in the build +# artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This +# uses 'fnmatch' to match the files or directories. Some directories and files like 'galaxy.yml', '*.pyc', '*.retry', +# and '.git' are always filtered. Mutually exclusive with 'manifest' +build_ignore: [] + +# A dict controlling use of manifest directives used in building the collection artifact. The key 'directives' is a +# list of MANIFEST.in style +# L(directives,https://packaging.python.org/en/latest/guides/using-manifest-in/#manifest-in-commands). The key +# 'omit_default_directives' is a boolean that controls whether the default directives are used. Mutually exclusive +# with 'build_ignore' +# manifest: null + diff --git a/ansible_collections/qubesos/security/meta/runtime.yml b/ansible_collections/qubesos/security/meta/runtime.yml new file mode 100644 index 0000000..331cefe --- /dev/null +++ b/ansible_collections/qubesos/security/meta/runtime.yml @@ -0,0 +1,53 @@ +#SPDX-License-Identifier: GPL-3.0-or-later +--- +# Collections must specify a minimum required ansible version to upload +# to galaxy +requires_ansible: '>=2.16.14' + +# Content that Ansible needs to load from another location or that has +# been deprecated/removed +# plugin_routing: +# action: +# redirected_plugin_name: +# redirect: ns.col.new_location +# deprecated_plugin_name: +# deprecation: +# removal_version: "4.0.0" +# warning_text: | +# See the porting guide on how to update your playbook to +# use ns.col.another_plugin instead. +# removed_plugin_name: +# tombstone: +# removal_version: "2.0.0" +# warning_text: | +# See the porting guide on how to update your playbook to +# use ns.col.another_plugin instead. +# become: +# cache: +# callback: +# cliconf: +# connection: +# doc_fragments: +# filter: +# httpapi: +# inventory: +# lookup: +# module_utils: +# modules: +# netconf: +# shell: +# strategy: +# terminal: +# test: +# vars: + +# Python import statements that Ansible needs to load from another location +# import_redirection: +# ansible_collections.ns.col.plugins.module_utils.old_location: +# redirect: ansible_collections.ns.col.plugins.module_utils.new_location + +# Groups of actions/modules that take a common set of options +# action_groups: +# group_name: +# - module1 +# - module2 diff --git a/ansible_collections/qubesos/security/plugins/callback/qubesos_strategy_guard.py b/ansible_collections/qubesos/security/plugins/callback/qubesos_strategy_guard.py new file mode 100644 index 0000000..767547d --- /dev/null +++ b/ansible_collections/qubesos/security/plugins/callback/qubesos_strategy_guard.py @@ -0,0 +1,105 @@ +# Copyright (C) 2025 Guillaume Chinal +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +import sys + +from ansible.plugins.callback import CallbackBase + + +DOCUMENTATION = r""" +name: qubesos_strategy_guard +type: aggregate +short_description: Ensures qubes_proxy is used in conjunction with qubes connection +description: + - Stops the execution of the playbook when qubes_proxy is not used with qubes connection. +options: + qubes_allow_insecure: + description: Do not block playbook execution when qubes connection is used without qubes_proxy + type: bool + default: false + ini: + - section: qubesos_strategy_guard + key: qubes_allow_insecure + env: + - name: QUBES_ALLOW_INSECURE + vars: + - name: qubes_allow_insecure + qubes_insecure_quiet: + description: Do not print any warning message when qubes_allow_insecure is enabled + type: bool + default: false + ini: + - section: qubesos_strategy_guard + key: qubes_insecure_quiet + env: + - name: QUBES_INSECURE_QUIET + vars: + - name: qubes_insecure_quiet +""" + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = "notification" + CALLBACK_NAME = "qubesos_strategy_guard" + + def v2_playbook_on_play_start(self, play): + self._play = play + self._variable_manager = play.get_variable_manager() + + def v2_runner_on_start(self, host, task): + if self._play.strategy == "qubes_proxy": + return + + qubes_allow_insecure = self.get_option("qubes_allow_insecure") + qubes_insecure_quiet = self.get_option("qubes_insecure_quiet") + if qubes_allow_insecure and qubes_insecure_quiet: + return + + if self._variable_manager is None: + self._display.vvv( + f"{self.CALLBACK_NAME}: Unable to retrieve VariableManager..." + ) + return + + # see TaskExecutor._execute() + current_connection = self._variable_manager.get_vars( + play=self._play, + host=host, + task=task, + include_hostvars=True, + include_delegate_to=True, + ).get("ansible_connection", task.connection) + + if current_connection == "qubes": + msg = ( + '\033[22mUsing "qubes" connection plugin without "qubes_proxy" ' + "strategy is considered insecure and may lead to dom0 " + "compromise.\n" + "\033[22mTo fix this issue, you can add " + "\033[1mstrategy: qubes_proxy\033[22m in your play or add the " + 'following setting in your "ansible.cfg" file:\n' + "\033[1m[defaults]\n\033[1mstrategy=qubes_proxy\033[22m" + ) + if self.get_option("qubes_allow_insecure"): + self._display.warning( + msg + "\n\033[22mContinue at your own risk.", formatted=True + ) + else: + self._display.error(msg, wrap_text=False) + sys.exit(1) diff --git a/ansible_collections/qubesos/security/plugins/strategy/qubes_proxy.py b/ansible_collections/qubesos/security/plugins/strategy/qubes_proxy.py new file mode 100644 index 0000000..66ed9c3 --- /dev/null +++ b/ansible_collections/qubesos/security/plugins/strategy/qubes_proxy.py @@ -0,0 +1,676 @@ +# Copyright (C) 2025 Guillaume Chinal +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import asyncio +import fcntl +import multiprocessing +import os +import re +import shutil +import tarfile +import tempfile +import traceback + +from contextlib import suppress +from pathlib import Path + +import qubesadmin +import qubesadmin.events.utils +import qubesadmin.exc +import yaml + +from ansible import context +from ansible.executor.play_iterator import PlayIterator +from ansible.plugins.strategy.linear import ( + StrategyModule as LinearStrategyModule, +) +from ansible.utils.display import Display +from ansible.plugins.vars.host_group_vars import VarsModule +from ansible.parsing.dataloader import DataLoader +from ansible.parsing.yaml.dumper import AnsibleDumper + + +DOCUMENTATION = r""" +--- +name: qubes +short_description: Execute Ansible plays inside QubesOS management DispVMs +description: + - This strategy plugin executes Ansible plays on QubesOS VMs by delegating execution + to a management DispVM. + - For each target host, a dedicated DispVM is created to run the play. + - The play, roles, inventory, and variables are packaged and transferred to the DispVM. + - Execution is performed using the Qubes RPC C(qubes.AnsibleVM). + - Local hosts (C(localhost), C(dom0)) are executed using the default linear strategy. + - Remote Qubes VMs are executed in isolation through their management DispVM. + - RPC policies required for execution are automatically added and removed. + +version_added: "1.0.0" + +author: + - Guillaume Chinal + +requirements: + - qubesadmin +""" + +display = Display() + +RPC_SYS_POLICY_FILES = ( + Path("/etc/qubes/policy.d/include/admin-local-rwx"), + Path("/etc/qubes/policy.d/include/admin-global-ro"), +) +RPC_INCLUDE_POL_FILE = Path("/etc/qubes/policy.d/include/qubes-ansible") +RPC_ANSIBLE_POL_FILE = Path("/etc/qubes/policy.d/30-qubes-ansible.policy") +DISPVM_NAME_MAXLEN = 31 + + +def run_play_executor(iterator, play_context): + return QubesPlayExecutor(iterator, play_context).run() + + +def filter_control_chars(text: bytes): + """Filter control chars from bytes, keep only foreground colors""" + new_buff = b"" + while len(text) > 0: + # Allow SGR Reset + if text[:4] == b"\x1b[0m": + new_buff += text[:4] + text = text[4:] + continue + + # Allow setting foreground colors + if ( + # starts with ESC [ ends with m + text[:2] == b"\x1b[" + and text[6:7] == b"m" + and + # normal (0), bold (1) + text[2] in (0x30, 0x31) + and + # comma + text[3] == 0x3B + and + # 30-37 + text[4] == 0x33 + and 0x30 <= text[5] <= 0x37 + ): + new_buff += text[:7] + text = text[7:] + continue + + # Filter other control chars + current_byte = text[:1] + if b"\040" <= current_byte <= b"\176" or current_byte in ( + b"\a", + b"\b", + b"\n", + b"\r", + b"\t", + ): + new_buff += current_byte + else: + new_buff += b"_" + text = text[1:] + return new_buff + + +class QubesPlayExecutor: + """Run plays on a given host through its management disposable VM""" + + def __init__(self, iterator, play_context): + self.app = qubesadmin.Qubes() + self.host = iterator._play.hosts[0] + self.loader = play_context._loader + if self.loader == None: + self.loader = DataLoader() + self.inventory = iterator._variable_manager._inventory + self.iterator = iterator + self.play = iterator._play + self.play_context = play_context + self.variable_manager = iterator._variable_manager + self.variable_manager._loader = self.loader + + self._dispvm_initially_running = False + + if hasattr(self.host.name, "_strip_unsafe"): + self.host_name = self.host.name._strip_unsafe() + else: + self.host_name = self.host.name + self.temp_dir = Path( + tempfile.TemporaryDirectory(prefix="qubes-ansible-").name + ) + self.vars_plugin = VarsModule() + self.vm = None + + @property + def dispvm_mgmt_name(self): + return f"disp-mgmt-{self.host_name}"[:DISPVM_NAME_MAXLEN] + + def _add_host_vars(self): + """Build host variables files + + We're building a file in host_vars/.yaml containing current + host variables merged from all sources (inventory, group_vars, + host_vars, extra_vars...). This is done using the variable manager. + """ + host_vars_dir = self.temp_dir / "host_vars" + host_vars_dir.mkdir() + host_vars_file_path = host_vars_dir / f"{self.host_name}.yaml" + + # We get all variable associated to the current host/play + # merged from all sources + all_vars = self.variable_manager.get_vars( + play=self.play, host=self.host, include_hostvars=True + ) + + # In those vars, we got magic vars. This should not be problematic + # as Ansible is supposed to ignore them, but we will try to remove + # them + # https://docs.ansible.com/ansible/latest/reference_appendices/special_variables.html + filtered_vars = set( + self.variable_manager._get_magic_variables( + play=self.play, host=self.host, task=None, include_hostvars=True + ).keys() + ) | { + "ansible_check_mode", + "ansible_collection_name", + "ansible_config_file", + "ansible_dependent_role_names", + "ansible_diff_mode", + "ansible_facts", + "ansible_forks", + "ansible_index_var", + "ansible_inventory_sources", + "ansible_limit", + "ansible_loop", + "ansible_loop_var", + "ansible_parent_role_names", + "ansible_parent_role_paths", + "ansible_play_batch", + "ansible_play_hosts", + "ansible_play_hosts_all", + "ansible_play_name", + "ansible_play_role_names", + "ansible_playbook_python", + "ansible_role_name", + "ansible_role_names", + "ansible_run_tags", + "ansible_search_path", + "ansible_skip_tags", + "ansible_verbosity", + "ansible_version", + "group_names", + "groups", + "hostvars", + "inventory_dir", + "inventory_hostname", + "inventory_hostname_short", + "inventory_file", + "omit", + "play_hosts", + "playbook_dir", + "role_name", + "role_names", + "role_path", + "vars", + } + + target_vars_names = set(all_vars.keys()) - filtered_vars + target_vars = {name: all_vars[name] for name in target_vars_names} + + if target_vars: + with open(host_vars_file_path, "w") as host_vars_file: + yaml.dump( + target_vars, + host_vars_file, + Dumper=AnsibleDumper, + default_flow_style=False, + ) + + def _add_play(self, play): + """Builds the playbook that will be executed on DispVM + + :param play: current Play object + + We need to build a YAML file containing only the current play that + will be passed to the disposable VM. + + `get_path` method from Ansible Play object is very useful for + our needs as this returns the path to the file containing the play + and the line at which it is declared. + + We just have to parse this YAML file starting from that line to get + the list of remaining plays and keep only the next one (i.e. first + of the list). + """ + play_yaml = self._get_first_play_yaml(*play.get_path().split(":")) + play_yaml["hosts"] = [str(self.host_name)] + play_yaml["strategy"] = "linear" + playbook_chunk_path = self.temp_dir / "playbook.yaml" + with playbook_chunk_path.open("w") as playbook_chunk_file: + yaml.dump( + [play_yaml], + playbook_chunk_file, + Dumper=AnsibleDumper, + default_flow_style=False, + ) + + def _add_inventory(self): + """Build pseudo inventory for DispVM + + This allows to use the correct group assignments from group_vars in the DispVM + """ + + inventory_data = "" + default_ansible_groups = ["all", "ungrouped"] + + for group in self.host.get_groups(): + if group.name in default_ansible_groups: + continue + + # create Inventory entry per group (other vars from inventory are not supported yet) + inventory_data += f"[{group.name}]\n{self.host}\n\n[{group.name}:vars]\nansible_connection=qubes\n\n" + + # if no group assignment, fallback to default appvms + if not inventory_data: + inventory_data = f"[appvms]\n{self.host}\n\n[appvms:vars]\nansible_connection=qubes\n\n" + + with open(self.temp_dir / "inventory", "w") as inventory_file: + inventory_file.write(inventory_data) + + def _add_roles(self, play): + """Adds play role + + :param play: current Play object + + We are using `get_roles` method from Ansible internal Play object + to check if there are associated roles to the current play. + If so, we can get the path to every roles directory using + `get_role_path` method. + + Then, we just have to copy every role in a destination "roles" + folder + """ + dest_roles_path = self.temp_dir / "roles" + dest_roles_path.mkdir() + + for role in play.get_roles(): + role_path = Path(role.get_role_path()) + shutil.copytree(role_path, dest_roles_path / role_path.name) + + def _add_rpc_policies(self): + src = self.dispvm_mgmt_name + dst = self.vm.name + + while True: + with RPC_INCLUDE_POL_FILE.open("a+") as pol_file: + fcntl.lockf(pol_file.fileno(), fcntl.LOCK_EX) + try: + if os.fstat(pol_file.fileno()) != os.stat( + RPC_INCLUDE_POL_FILE + ): + continue + except FileNotFoundError: + continue + + pol_file.write(f"{src} {dst} allow target=dom0\n") + try: + shutil.chown(RPC_INCLUDE_POL_FILE, group="qubes") + except PermissionError: + pass + break + + while True: + with RPC_ANSIBLE_POL_FILE.open("a+") as pol_file: + fcntl.lockf(pol_file.fileno(), fcntl.LOCK_EX) + try: + if os.fstat(pol_file.fileno()) != os.stat( + RPC_ANSIBLE_POL_FILE + ): + continue + except FileNotFoundError: + continue + + pol_file.write( + f"qubes.Filecopy * {src} {dst} allow\n" + f"qubes.WaitForSession * {src} {dst} allow\n" + f"qubes.VMShell * {src} {dst} allow\n" + f"qubes.VMRootShell * {src} {dst} allow\n" + f"admin.vm.List * {src} dom0 allow\n" + ) + try: + shutil.chown(RPC_ANSIBLE_POL_FILE, group="qubes") + except PermissionError: + pass + break + + @staticmethod + def _build_ansible_args(): + args = [] + current_args = context.CLIARGS + + verbosity = current_args.get("verbosity") + if verbosity: + args.append(f"-{'v'*display.verbosity}") + + tags = current_args.get("tags") + if tags: + for tag in tags: + args += ["-t", tag] + + skip_tags = current_args.get("skip_tags") + if skip_tags: + for tag in skip_tags: + args += ["--skip-tags", tag] + + for boolean_arg in ["check", "diff", "force_handlers", "flush_cache"]: + if current_args.get(boolean_arg): + args.append(f"--{boolean_arg.replace('_', '-')}") + + return args + + def _build_tar(self): + tar_file_path = self.temp_dir.parent / f"{self.temp_dir.name}.tar" + old_path = os.getcwd() + os.chdir(self.temp_dir) + with tarfile.open(tar_file_path, "w") as tar_file: + tar_file.add(".", recursive=True) + os.chdir(old_path) + return tar_file_path + + @staticmethod + def _get_first_play_yaml(path, start_line): + with open(path, "r") as playbook_file: + return yaml.safe_load( + "".join(playbook_file.readlines()[int(start_line) - 1 :]) + )[0] + + def _remove_rpc_policies(self): + src = self.dispvm_mgmt_name + dst = self.vm.name + + with RPC_INCLUDE_POL_FILE.open("a+") as pol_file: + fcntl.lockf(pol_file.fileno(), fcntl.LOCK_EX) + with suppress(FileNotFoundError): + os.stat(RPC_INCLUDE_POL_FILE) + pol_file.seek(0) + new_file_lines = [ + line + for line in pol_file.readlines() + if not re.match( + rf"^\s*{re.escape(src)}\s+{re.escape(dst)}\s+", + line, + ) + ] + pol_file.seek(0) + pol_file.truncate() + pol_file.write("".join(new_file_lines)) + pol_file.flush() + + with RPC_ANSIBLE_POL_FILE.open("a+") as pol_file: + fcntl.lockf(pol_file.fileno(), fcntl.LOCK_EX) + with suppress(FileNotFoundError): + pol_file.seek(0) + os.stat(RPC_ANSIBLE_POL_FILE) + new_file_lines = [ + line + for line in pol_file.readlines() + if not re.match( + rf"^\s*\S+\s+\S+\s+{re.escape(src)}\s+", + line, + ) + ] + pol_file.seek(0) + pol_file.truncate() + pol_file.write("".join(new_file_lines)) + pol_file.flush() + + def _start_mgmt_disp_vm(self): + self.vvv("Lookup for dispvm_mgmt") + dispvm = self.app.domains.get(self.dispvm_mgmt_name) + self.vvv(f"Found dispvm: {dispvm}") + if dispvm is None: + self.vvv(f"Creating dispvm {self.dispvm_mgmt_name}") + dispvm = self.app.add_new_vm( + "DispVM", + template=self.vm.management_dispvm, + label=self.vm.management_dispvm.label, + name=self.dispvm_mgmt_name, + ) + dispvm.features["internal"] = True + dispvm.features["gui"] = False + dispvm.netvm = None + dispvm.auto_cleanup = True + self.vvv(f"Dispvm <{dispvm.name}> was running: {dispvm.is_running()}") + self._dispvm_initially_running = dispvm.is_running() + if not dispvm.is_running(): + dispvm.start() + return dispvm + + def run(self): + """Runs the given play on the mgmt dispvm of the host""" + self.vvv(f"Running play {self.play}") + self.vm = self.app.domains.get(self.host_name) + if not self.vm: + raise KeyError(f"Host {self.host.name} not found") + self.vvv(f"Found VM {self.vm}") + + dispvm = self._start_mgmt_disp_vm() + + self._add_rpc_policies() + self.temp_dir.mkdir() + + try: + self._add_play(self.play) + self._add_roles(self.play) + self._add_host_vars() + self._add_inventory() + tar_file_path = self._build_tar() + ansible_args = self._build_ansible_args() + + self.vvv(f"Copying {tar_file_path} to {self.vm}") + dispvm.run_service( + "qubes.Filecopy", + localcmd="/usr/lib/qubes/qfile-dom0-agent {}".format( + tar_file_path + ), + ).wait() + + self.vvv(f"Running qubes.AnsibleVM on {self.vm}") + p = dispvm.run_service("qubes.AnsibleVM") + rpc_args = ( + tar_file_path.name + + "\n" + + self.host_name + + "\n" + + "\n".join(ansible_args) + + "\n" + ).encode() + self.vvvv(f"RPC args: {rpc_args}") + (untrusted_stdout, untrusted_stderr) = p.communicate(rpc_args) + self.vvvv(f"stdout: {untrusted_stdout}") + self.vvvv(f"stderr: {untrusted_stderr}") + self.vvvv(f"return code: {p.returncode}") + return ( + self.host, + p.returncode, + filter_control_chars(untrusted_stdout).decode("utf-8"), + filter_control_chars(untrusted_stderr).decode("utf-8"), + self.dispvm_mgmt_name, + self.play.name, + ) + + finally: + self._remove_rpc_policies() + shutil.rmtree(self.temp_dir) + if not self._dispvm_initially_running: + self.vvv(f"Stopping {dispvm.name}") + dispvm.shutdown() + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete( + asyncio.wait_for( + qubesadmin.events.utils.wait_for_domain_shutdown( + [dispvm] + ), + dispvm.shutdown_timeout, + ) + ) + + except asyncio.TimeoutError: + try: + display.warning(f" killing {dispvm.name}") + dispvm.kill() + except qubesadmin.exc.QubesVMNotStartedError: + pass + + def _verbose(self, msg: str, level: int): + getattr(display, "v" * level)(f"<{self.host_name}> {msg}") + + def v(self, msg): + self._verbose(msg, 1) + + def vv(self, msg): + self._verbose(msg, 2) + + def vvv(self, msg): + self._verbose(msg, 3) + + def vvvv(self, msg): + self._verbose(msg, 4) + + def vvvvv(self, msg): + self._verbose(msg, 5) + + def vvvvvv(self, msg): + self._verbose(msg, 6) + + +class StrategyModule(LinearStrategyModule): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._setup_rpc_policies() + + def _new_play_iterator_with_hosts(self, iterator, play_context, hosts): + new_play = iterator._play.copy() + new_play.hosts = hosts + return PlayIterator( + inventory=self._inventory, + play=new_play, + play_context=play_context, + variable_manager=self._variable_manager, + all_vars=self._variable_manager.get_vars(play=new_play), + start_at_done=self._tqm._start_at_done, + ) + + @staticmethod + def _setup_rpc_policies(): + Path(RPC_INCLUDE_POL_FILE).touch() + for policy_file in RPC_SYS_POLICY_FILES: + policy_lines = [ + line.strip() for line in policy_file.read_text().split("\n") + ] + if "!include include/qubes-ansible" not in policy_lines: + with policy_file.open("a") as policy_fd: + policy_fd.write("!include include/qubes-ansible\n") + + @staticmethod + def collect_error(error): + display.display(f"[ERROR]: {str(error)}", "red") + display.display( + "".join( + traceback.format_exception(None, error, error.__traceback__) + ), + "red", + ) + + def collect_result(self, result_tuple): + host, retcode, stdout, stderr, dispvm, play_name = result_tuple + display.banner(f"QUBESOS [{dispvm}: PLAY {play_name}]") + if stderr: + display.display(str(stderr), "red") + if stdout: + display.display(str(stdout), "bright blue") + self.qubes_results[host] = retcode + + def proxy_run(self, iterator, play_context): + play = iterator._play + display.vvv( + f" Running play {play} " f"with {self._tqm._forks} forks" + ) + pool = multiprocessing.Pool(self._tqm._forks) + + self.qubes_results = {} + for host in self._inventory.get_hosts(play.hosts): + self.qubes_results[host] = 255 + + new_iterator = self._new_play_iterator_with_hosts( + iterator, play_context, [host] + ) + pool.apply_async( + run_play_executor, + (new_iterator, play_context), + callback=self.collect_result, + error_callback=self.collect_error, + ) + pool.close() + pool.join() + + stats = self._tqm._stats + for host, result in self.qubes_results.items(): + if result == 0: + stats.increment("ok", host.name) + else: + stats.increment("failures", host.name) + + return max(self.qubes_results.values()) + + def run(self, iterator, play_context): + play = iterator._play + + target_hosts = self._inventory.get_hosts(play.hosts) + local_hosts = [ + host for host in target_hosts if host.name in ["localhost", "dom0"] + ] + retval_local_exec = self._tqm.RUN_OK + + remote_hosts = [ + host + for host in target_hosts + if host.name not in ["localhost", "dom0"] + ] + retval_remote_exec = self._tqm.RUN_OK + + if local_hosts: + retval_local_exec = super().run( + self._new_play_iterator_with_hosts( + iterator, play_context, local_hosts + ), + play_context, + ) + + # For other host, we need to start a disp mgmt and run the play inside + if remote_hosts: + retval_remote_exec = self.proxy_run( + self._new_play_iterator_with_hosts( + iterator, play_context, remote_hosts + ), + play_context, + ) + + return max(retval_local_exec, retval_remote_exec) diff --git a/debian/install b/debian/install index 1e5f521..159ad76 100644 --- a/debian/install +++ b/debian/install @@ -1,3 +1,11 @@ /etc/qubes-rpc/qubes.AnsibleVM /usr/share/ansible/plugins/modules/qubesos.py -/usr/share/ansible/plugins/connection/qubes.py \ No newline at end of file +/usr/share/ansible/plugins/connection/qubes.py +/usr/share/ansible/collections/ansible_collections/qubesos/core/plugins/connection/qubes.py +/usr/share/ansible/collections/ansible_collections/qubesos/core/plugins/module_utils/qubes_helper.py +/usr/share/ansible/collections/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_command.py +/usr/share/ansible/collections/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_host_devices_facts.py +/usr/share/ansible/collections/ansible_collections/qubesos/core/plugins/module_utils/qubes_module_qube.py +/usr/share/ansible/collections/ansible_collections/qubesos/core/plugins/modules/command.py +/usr/share/ansible/collections/ansible_collections/qubesos/core/plugins/modules/host_devices_facts.py +/usr/share/ansible/collections/ansible_collections/qubesos/core/plugins/modules/qube.py \ No newline at end of file diff --git a/plugins/callback/qubesos_strategy_guard.py b/plugins/callback/qubesos_strategy_guard.py deleted file mode 100644 index 767547d..0000000 --- a/plugins/callback/qubesos_strategy_guard.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright (C) 2025 Guillaume Chinal -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -# SPDX-License-Identifier: GPL-3.0-or-later - - -import sys - -from ansible.plugins.callback import CallbackBase - - -DOCUMENTATION = r""" -name: qubesos_strategy_guard -type: aggregate -short_description: Ensures qubes_proxy is used in conjunction with qubes connection -description: - - Stops the execution of the playbook when qubes_proxy is not used with qubes connection. -options: - qubes_allow_insecure: - description: Do not block playbook execution when qubes connection is used without qubes_proxy - type: bool - default: false - ini: - - section: qubesos_strategy_guard - key: qubes_allow_insecure - env: - - name: QUBES_ALLOW_INSECURE - vars: - - name: qubes_allow_insecure - qubes_insecure_quiet: - description: Do not print any warning message when qubes_allow_insecure is enabled - type: bool - default: false - ini: - - section: qubesos_strategy_guard - key: qubes_insecure_quiet - env: - - name: QUBES_INSECURE_QUIET - vars: - - name: qubes_insecure_quiet -""" - - -class CallbackModule(CallbackBase): - CALLBACK_VERSION = 2.0 - CALLBACK_TYPE = "notification" - CALLBACK_NAME = "qubesos_strategy_guard" - - def v2_playbook_on_play_start(self, play): - self._play = play - self._variable_manager = play.get_variable_manager() - - def v2_runner_on_start(self, host, task): - if self._play.strategy == "qubes_proxy": - return - - qubes_allow_insecure = self.get_option("qubes_allow_insecure") - qubes_insecure_quiet = self.get_option("qubes_insecure_quiet") - if qubes_allow_insecure and qubes_insecure_quiet: - return - - if self._variable_manager is None: - self._display.vvv( - f"{self.CALLBACK_NAME}: Unable to retrieve VariableManager..." - ) - return - - # see TaskExecutor._execute() - current_connection = self._variable_manager.get_vars( - play=self._play, - host=host, - task=task, - include_hostvars=True, - include_delegate_to=True, - ).get("ansible_connection", task.connection) - - if current_connection == "qubes": - msg = ( - '\033[22mUsing "qubes" connection plugin without "qubes_proxy" ' - "strategy is considered insecure and may lead to dom0 " - "compromise.\n" - "\033[22mTo fix this issue, you can add " - "\033[1mstrategy: qubes_proxy\033[22m in your play or add the " - 'following setting in your "ansible.cfg" file:\n' - "\033[1m[defaults]\n\033[1mstrategy=qubes_proxy\033[22m" - ) - if self.get_option("qubes_allow_insecure"): - self._display.warning( - msg + "\n\033[22mContinue at your own risk.", formatted=True - ) - else: - self._display.error(msg, wrap_text=False) - sys.exit(1) diff --git a/plugins/callback/qubesos_strategy_guard.py b/plugins/callback/qubesos_strategy_guard.py new file mode 120000 index 0000000..b71ce40 --- /dev/null +++ b/plugins/callback/qubesos_strategy_guard.py @@ -0,0 +1 @@ +/usr/share/ansible/collections/ansible_collections/qubesos/security/plugins/callback/qubesos_strategy_guard.py \ No newline at end of file diff --git a/plugins/connection/qubes.py b/plugins/connection/qubes.py deleted file mode 100644 index 96f74a9..0000000 --- a/plugins/connection/qubes.py +++ /dev/null @@ -1,196 +0,0 @@ -# Copyright (c) 2017 Ansible Project -# Copyright (C) 2018 Kushal Das -# Copyright (C) 2025 Frédéric Pierret (fepitre) -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -# SPDX-License-Identifier: GPL-3.0-or-later - -# Based on the buildah connection plugin - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - - -DOCUMENTATION = """ - connection: qubes - short_description: Interact with an existing qube. - description: - - Run commands or put/fetch files to an existing qube using Qubes OS tools. - author: Qubes OS Team - version_added: "2.8" - options: - remote_addr: - description: - - vm name - default: inventory_hostname - vars: - - name: inventory_hostname - - name: ansible_host - remote_user: - description: - - The user to execute as inside the qube. - choices: - - user - - root - default: user - vars: - - name: ansible_user -""" - -import subprocess - -from ansible.module_utils.common.text.converters import to_bytes -from ansible.plugins.connection import ConnectionBase, ensure_connect - -try: - from __main__ import display -except ImportError: - from ansible.utils.display import Display - - display = Display() - - -class Connection(ConnectionBase): - """ - This connection plugin for Qubes OS uses the qvm-run executable - to interact with QubesVMs. - """ - - transport = "qubes" - has_pipelining = True - - def __init__(self, play_context, new_stdin, *args, **kwargs): - super(Connection, self).__init__( - play_context, new_stdin, *args, **kwargs - ) - self._remote_vmname = self._play_context.remote_addr - self._connected = False - # Use the provided remote_user if set; otherwise default to "user". - self.user = ( - self._play_context.remote_user - if self._play_context.remote_user - else "user" - ) - - def _qubes(self, cmd: str, in_data: bytes = None): - """ - Execute a command in the qube via qvm-run. - - :param cmd: Command string to execute on the remote system. - :param in_data: Additional data to pass to the remote command's stdin. - :param shell: Service type (e.g. qubes.VMShell or qubes.VMRootShell). - :return: Tuple of (returncode, stdout, stderr). - """ - display.vvvv(f"CMD: {cmd}") - if not cmd.endswith("\n"): - cmd += "\n" - - local_cmd = [ - "qvm-run", - "--no-gui", - "--pass-io", - "--service", - self._remote_vmname, - ] - # The Ansible module framework catches invalid remote_user values - if self.user == "root": - local_cmd.append("qubes.VMRootShell") - else: - local_cmd.append("qubes.VMShell") - local_cmd_bytes = [ - to_bytes(arg, errors="surrogate_or_strict") for arg in local_cmd - ] - display.vvvv(f"Local cmd: {local_cmd_bytes}") - display.vvv(f"RUN {local_cmd_bytes}", host=self._remote_vmname) - - # Combine the command and any additional input data - combined_input = to_bytes(cmd, errors="surrogate_or_strict") - if in_data: - combined_input += in_data - - try: - result = subprocess.run( - local_cmd_bytes, - input=combined_input, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - except Exception as e: - display.error(f"Error executing command via qvm-run: {e}") - raise - - return result.returncode, result.stdout, result.stderr - - def _connect(self): - """ - Establish the connection (no persistent connection is maintained). - """ - super(Connection, self)._connect() - self._connected = True - - @ensure_connect - def exec_command(self, cmd, in_data=None, sudoable=False): - """ - Run the specified command in the QubesVM. - - :param cmd: Command to run. - :param in_data: Data to send to stdin. - :param sudoable: Not used in this plugin. - :return: Tuple (returncode, stdout, stderr). - """ - display.vvvv(f"CMD IS: {cmd}") - rc, stdout, stderr = self._qubes(cmd, in_data) - display.vvvvv( - f"STDOUT {stdout!r} STDERR {stderr!r}", host=self._remote_vmname - ) - return rc, stdout, stderr - - def put_file(self, in_path, out_path): - """ - Copy a local file from 'in_path' to the remote VM at 'out_path'. - """ - display.vvv(f"PUT {in_path} TO {out_path}", host=self._remote_vmname) - with open(in_path, "rb") as fobj: - source_data = fobj.read() - - retcode, _, _ = self._qubes(f'cat > "{out_path}"\n', source_data) - if retcode != 0: - raise RuntimeError(f"Failed to put_file to {out_path}") - - def fetch_file(self, in_path, out_path): - """ - Retrieve a file from the remote VM located at 'in_path' and save it to 'out_path'. - """ - display.vvv(f"FETCH {in_path} TO {out_path}", host=self._remote_vmname) - cmd_args = [ - "qvm-run", - "--pass-io", - "--no-gui", - self._remote_vmname, - f"cat {in_path}", - ] - with open(out_path, "wb") as fobj: - result = subprocess.run(cmd_args, stdout=fobj) - if result.returncode != 0: - raise RuntimeError(f"Failed to fetch file to {out_path}") - - def close(self): - """ - Close the connection. - """ - super(Connection, self).close() - self._connected = False diff --git a/plugins/connection/qubes.py b/plugins/connection/qubes.py new file mode 120000 index 0000000..e24ce36 --- /dev/null +++ b/plugins/connection/qubes.py @@ -0,0 +1 @@ +/usr/share/ansible/collections/ansible_collections/qubesos/core/plugins/connection/qubes.py \ No newline at end of file diff --git a/plugins/modules/qubesos.py b/plugins/modules/qubesos.py index 89847c8..e898e17 100644 --- a/plugins/modules/qubesos.py +++ b/plugins/modules/qubesos.py @@ -2,6 +2,7 @@ # Copyright (c) 2017 Ansible Project # Copyright (C) 2018 Kushal Das # Copyright (C) 2025 Frédéric Pierret (fepitre) +# Copyright (C) 2026 Guillaume Chinal (guiiix) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -165,11 +166,26 @@ - Frédéric Pierret """ -import asyncio -import time +from ansible_collections.qubesos.core.plugins.module_utils.qubes_helper import ( + QubesHelper, + VIRT_FAILED, + VIRT_SUCCESS, +) + +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_host_devices_facts import ( + core as module_host_devices_facts, +) + +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_qube import ( + QubeModule, +) +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_command import ( + core as module_command, +) + + import traceback -from contextlib import suppress try: import qubesadmin @@ -195,9 +211,6 @@ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_native -VIRT_FAILED = 1 -VIRT_SUCCESS = 0 -VIRT_UNAVAILABLE = 2 ALL_COMMANDS = [] VM_COMMANDS = [ @@ -216,14 +229,6 @@ ALL_COMMANDS.extend(VM_COMMANDS) ALL_COMMANDS.extend(HOST_COMMANDS) -VIRT_STATE_NAME_MAP = { - 0: "running", - 1: "paused", - 4: "shutdown", - 5: "shutdown", - 6: "crashed", -} - PROPS = { "autostart": bool, "debug": bool, @@ -255,6 +260,50 @@ } +class ModuleExitWithError(Exception): + def __init__(self, reasons): + self.reasons = reasons + + +class ValidationFailure(ValueError): + """Exception raised for errors when validating module input""" + + def __init__(self, reasons): + self.reasons = reasons + + +# Use the same wrapper class as tests to call new module and catch errors +class FakeModule: + def __init__(self, params): + self.params = params + self.returned_data = None + + def fail_json(self, **kwargs): + self.returned_data = kwargs + raise ModuleExitWithError(kwargs) + + def exit_json(self, **kwargs): + self.returned_data = kwargs + + +def _run_module_host_devices_facts(): + fake_module = FakeModule({}) + module_host_devices_facts(fake_module) + return fake_module + + +def _run_module_qube(params): + fake_module = FakeModule(params) + QubeModule(fake_module).run() + return fake_module + + +def _run_module_command(params): + fake_module = FakeModule(params) + module_command(fake_module) + return fake_module + + def create_inventory(result): """ Creates the inventory file dynamically for QubesOS @@ -302,727 +351,299 @@ def create_inventory(result): fobj.write(res) -class QubesVirt(object): - - def __init__(self, module): - self.module = module - self.app = qubesadmin.Qubes() - - def get_device_classes(self): - """List all available device classes in dom0 (excluding 'testclass').""" - return [c for c in self.app.list_deviceclass() if c != "testclass"] - - def find_devices_of_class(self, klass): - """Yield the port IDs of all devices matching a given class in dom0.""" - for dev in self.app.domains["dom0"].devices["pci"]: - if repr(dev.interfaces[0]).startswith("p" + klass): - yield f"pci:dom0:{dev.port_id}:{dev.device_id}" - - def get_vm(self, vmname): - """Retrieve a qube object by its name.""" - return self.app.domains[vmname] - - def __get_state(self, vmname): - """Determine the current power state of a qube.""" - try: - vm = self.app.domains[vmname] - if vm.is_paused(): - return "paused" - if vm.is_running(): - return "running" - if vm.is_halted(): - return "shutdown" - return None - except KeyError: - return "absent" - - def get_states(self): - """Get the names and states of all qubes.""" - state = [] - for vm in self.app.domains: - state.append(f"{vm.name} {self.__get_state(vm.name)}") - return state - - def list_vms(self, state): - """List all non-dom0 qubes that match a specified state.""" - res = [] - for vm in self.app.domains: - if vm.name != "dom0" and state == self.__get_state(vm.name): - res.append(vm.name) - return res - - def all_vms(self): - """Group all non-dom0 qubes by their VM class.""" - res = {} - for vm in self.app.domains: - if vm.name == "dom0": - continue - res.setdefault(vm.klass, []).append(vm.name) - return res - - def info(self): - """Gather detailed info (state, network, label) for all non-dom0 qubes.""" - info = {} - for vm in self.app.domains: - if vm.name == "dom0": - continue - info[vm.name] = { - "state": self.__get_state(vm.name), - "provides_network": vm.provides_network, - "label": vm.label.name, - } - return info - - def shutdown(self, vmname, wait=False): - """ - Shutdown the specified qube via the given id or name, - optionally waiting until it halts. - """ - vm = self.get_vm(vmname) - with suppress(QubesVMNotStartedError): - vm.shutdown() - - if wait: - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete( - asyncio.wait_for( - qubesadmin.events.utils.wait_for_domain_shutdown([vm]), - vm.shutdown_timeout, - ) - ) - except asyncio.TimeoutError: - raise RuntimeError( - f"Timeout: VM {vmname} did not halt within {vm.shutdown_timeout}s" - ) - return 0 - - def restart(self, vmname, wait=False): - """ - Restart the specified qube via the given id or name - by shutting it down (with optional wait) and then starting it. - """ - try: - self.shutdown(vmname, wait=wait) - except RuntimeError: - raise - vm = self.get_vm(vmname) - vm.start() - return 0 - - def pause(self, vmname): - """Pause the specified qube via the given id or name.""" - vm = self.get_vm(vmname) - vm.pause() - return 0 - - def unpause(self, vmname): - """Unpause the specified qube via the given id or name.""" - vm = self.get_vm(vmname) - vm.unpause() - return 0 - - def create( - self, - vmname, - vmtype, - label="red", - template=None, - netvm="*default*", - ): - """Create a new qube of the given type, label, template, and network.""" - template_vm = template or "" - if netvm == "*default*": - network_vm = qubesadmin.DEFAULT - elif not netvm: - network_vm = None - else: - network_vm = self.get_vm(netvm) - if vmtype == "AppVM": - if template_vm and self.get_vm(template_vm)._klass == vmtype: - vm = self.app.clone_vm( - template_vm, vmname, vmtype, ignore_devices=True - ) - else: - vm = self.app.add_new_vm( - vmtype, vmname, label, template=template_vm - ) - vm.netvm = network_vm - elif vmtype in ["StandaloneVM", "TemplateVM"] and template_vm: - vm = self.app.clone_vm( - template_vm, vmname, vmtype, ignore_devices=True - ) - vm.label = label - elif vmtype == "DispVM" and template_vm: - vm = self.app.add_new_vm( - vmtype, vmname, label, template=template_vm - ) - vm.netvm = network_vm - return 0 - - def start(self, vmname): - """Start the specified qube via the given id or name""" - vm = self.get_vm(vmname) - vm.start() - return 0 - - def destroy(self, vmname): - """Immediately kill the specified qube via the given id or name (no graceful shutdown).""" - vm = self.get_vm(vmname) - vm.kill() - return 0 - - def properties(self, vmname, prefs): - """Sets the given properties to the qube""" - changed = False - values_changed = [] - vm = self.get_vm(vmname) - - # VM-reference properties - vm_ref_keys = [ - "audiovm", - "default_dispvm", - "default_user", - "guivm", - "management_dispvm", - "netvm", - "template", - ] - - for key, val in prefs.items(): - # use of `features` nested in properties is legacy use. Drop by 2030 - if key == "features": - if self.features(vmname, val): - changed = True - if "features" not in values_changed: - values_changed.append("features") - - elif key == "services": - for svc in val: - feat = f"service.{svc}" - if vm.features.get(feat) != "1": - vm.features[feat] = "1" - changed = True - if changed and "features" not in values_changed: - values_changed.append("features") - - elif key == "volumes": - for vol in prefs.get("volumes", []): - try: - volume = vm.volumes[vol["name"]] - volume.resize(vol["size"]) - except Exception: - return VIRT_FAILED, { - "Failure in updating volume": vol["name"] - } - changed = True - values_changed.append(f"volume:{vol["name"]}") - - else: - # determine new value or default - if val in (None, ""): - new_val = "" - elif val == "*default*": - new_val = qubesadmin.DEFAULT - else: - new_val = val - # check and apply change - if new_val is qubesadmin.DEFAULT: - if not vm.property_is_default(key): - setattr(vm, key, new_val) - changed = True - values_changed.append(key) - else: - if getattr(vm, key) != new_val: - setattr(vm, key, new_val) - changed = True - values_changed.append(key) - - return changed, values_changed - - def notes(self, vmname: str, new_notes: str) -> bool: - """Set the qube notes. Returns true if changed""" - vm = self.get_vm(vmname) - current_notes = vm.get_notes() - if current_notes == new_notes: - return False - vm.set_notes(new_notes) - return True - - def features(self, vmname: str, feats: dict[str, str | None]) -> list: - """Sets the given featuress to the qube""" - features_changed = [] - vm = self.get_vm(vmname) - for fkey, fval in feats.items(): - if fval is None: - if fkey in vm.features: - del vm.features[fkey] - features_changed.append(fkey) - elif vm.features.get(fkey) != fval: - vm.features[fkey] = fval - features_changed.append(fkey) - return features_changed - - def remove(self, vmname): - """Destroy and then delete a qube's configuration and disk.""" - try: - self.destroy(vmname) - except QubesVMNotStartedError: - pass - while True: - if self.__get_state(vmname) == "shutdown": - break - time.sleep(1) - del self.app.domains[vmname] - return 0 - - def status(self, vmname): - """ - Return a state suitable for server consumption. Aka, codes.py values, not XM output. - """ - return self.__get_state(vmname) - - def tags(self, vmname, tags): - """Add a list of tags to a qube, skipping any already present.""" - vm = self.get_vm(vmname) - updated_tags = [] - for tag in tags: - if tag in vm.tags: - continue - vm.tags.add(tag) - updated_tags.append(tag) - return updated_tags - - def parse_device(self, spec): - """Parse a device specification string into its class and VirtualDevice.""" - parts = spec.split(":", 1) - if len(parts) != 2: - self.module.fail_json(msg=f"Invalid spec {spec}") - devclass, rest = parts - if devclass not in self.get_device_classes(): - self.module.fail_json(msg=f"Invalid devclass {devclass}") - try: - device = VirtualDevice.from_str(rest, devclass, self.app.domains) - return devclass, device - except Exception as e: - self.module.fail_json(msg=f"Cannot parse device {spec}: {e}") - return None - - def list_assigned_devices(self, vmname, devclass): - """List currently assigned devices of a given class for a qube.""" - vm = self.get_vm(vmname) - current = {} - for ass in vm.devices[devclass].get_assigned_devices(): - # get the VirtualDevice - d = getattr(ass, "virtual_device", None) or ass.device - spec = f"{devclass}:{d.backend_domain}:{d.port_id}:{d.device_id}" - mode = getattr(ass, "mode", None) - opts = getattr(ass, "options", None) or {} - current[spec] = (mode, opts) - return current - - def assign(self, vmname, devclass, device_assignment): - """Assign a device to the specified qube.""" - vm = self.get_vm(vmname) - vm.devices[devclass].assign(device_assignment) - return 0 - - def unassign(self, vmname, devclass, device_assignment): - """Remove an assigned device from the specified qube.""" - vm = self.get_vm(vmname) - vm.devices[devclass].unassign(device_assignment) - return 0 - - def sync_devices(self, vmname, devclass, desired): - """Synchronize a qube's device assignments to match the desired configuration.""" - # build desired map: spec -> (vd, per_mode, opts) - desired_map = { - f"{devclass}:{vd.backend_domain}:{vd.port_id}:{vd.device_id}": ( - vd, - per_mode, - opts or {}, - ) - for vd, per_mode, opts in (desired or []) - } - - changed = False - - # current assignments: spec -> (mode, opts) - current_map = self.list_assigned_devices(vmname, devclass) - current_specs = set(current_map) - desired_specs = set(desired_map) - - # 1) Unassign anything not in desired - for spec in current_specs - desired_specs: - cls, dev = self.parse_device(spec) - self.unassign( - vmname, - cls, - DeviceAssignment(dev, frontend_domain=self.get_vm(vmname)), - ) - changed = True - - # 2) Reassign anything whose mode or options differ - for spec in current_specs & desired_specs: - existing_mode, existing_opts = current_map[spec] - vd, per_mode, opts = desired_map[spec] - # normalize desired_mode - desired_mode = per_mode or ( - "required" if devclass == "pci" else "auto-attach" - ) - if existing_mode.value != desired_mode or existing_opts != opts: - # tear down the old and set up the new - cls, dev = self.parse_device(spec) - self.unassign( - vmname, - cls, - DeviceAssignment(dev, frontend_domain=self.get_vm(vmname)), - ) - self.assign( - vmname, - devclass, - DeviceAssignment(vd, mode=desired_mode, options=opts), - ) - changed = True - - # 3) Assign any new specs - for spec in desired_specs - current_specs: - vd, per_mode, opts = desired_map[spec] - assign_mode = per_mode or ( - "required" if devclass == "pci" else "auto-attach" - ) - self.assign( - vmname, - devclass, - DeviceAssignment(vd, mode=assign_mode, options=opts), - ) - changed = True - - return changed - - -def core(module): - state = module.params.get("state", None) - guest = module.params.get("name", None) - command = module.params.get("command", None) - vmtype = module.params.get("vmtype", None) - label = module.params.get("label", "red") - template = module.params.get("template", None) - properties = module.params.get("properties", {}) - features = module.params.get("features", {}) - tags = module.params.get("tags", []) - devices = module.params.get("devices", None) - notes = module.params.get("notes", None) - netvm = None - res = {} - device_specs = [] - - v = QubesVirt(module) - - # Normalize devices into (set_mode, device_specs) - if isinstance(devices, dict): - set_mode = devices.get("strategy", "strict") - device_specs = devices.get("items") or [] - elif isinstance(devices, list): - # flat list -> always strict - set_mode = "strict" - device_specs = devices - elif devices is None: - device_specs = [] - else: - module.fail_json(msg=f"Invalid devices parameter: {devices!r}") - - # Now expand each spec into (class, VirtualDevice, per_mode, options) - normalized_devices = [] - for entry in device_specs: - if isinstance(entry, str): - # simple string spec -> no per-device mode or options - cls, vd = v.parse_device(entry) - normalized_devices.append((cls, vd, None, [])) - elif isinstance(entry, dict): - # dict spec must have a "device" key - device_str = entry.get("device") - if not device_str: - module.fail_json( - msg=f"Device entry missing 'device': {entry!r}" - ) - cls, vd = v.parse_device(device_str) - # optional per-device mode (e.g. "required" or "auto-attach") - per_mode = entry.get("mode") - # optional options list - opts = entry.get("options", {}) - normalized_devices.append((cls, vd, per_mode, opts)) - else: - module.fail_json(msg=f"Invalid device entry: {entry!r}") - - def apply_devices(vmname): - devices_changed = False - for device_class in v.get_device_classes(): - # gather only the entries for this class - wants = [ - (vd, per_mode, opts) - for (cls, vd, per_mode, opts) in normalized_devices - if cls == device_class - ] - if set_mode == "strict": - devices_changed |= v.sync_devices(vmname, device_class, wants) - elif set_mode == "append": - current_map = v.list_assigned_devices(vmname, device_class) - for vd, per_mode, opts in wants: - spec = f"{device_class}:{vd.backend_domain}:{vd.port_id}:{vd.device_id}" - if spec in current_map: - # already present -> leave it (no mode/options change in append mode) - continue - # new device -> assign with its mode/options - assign_mode = per_mode or ( - "required" if device_class == "pci" else "auto-attach" - ) - v.assign( - vmname, - device_class, - DeviceAssignment(vd, mode=assign_mode, options=opts), - ) - devices_changed = True - else: - module.fail_json(msg=f"Invalid devices strategy: {set_mode}") - return devices_changed - - # gather device facts - if module.params.get("gather_device_facts", False): - facts = { - "pci_net": sorted(v.find_devices_of_class("02")), - "pci_usb": sorted(v.find_devices_of_class("0c03")), - "pci_audio": sorted( - list(v.find_devices_of_class("0401")) - + list(v.find_devices_of_class("0403")) - ), - } - return VIRT_SUCCESS, {"changed": False, "ansible_facts": facts} - - if state == "present" and guest: - try: - vm = v.get_vm(guest) - vmtype = vm.klass - except KeyError: - # Set default vmtype to AppVM if vmtype is not provided - vmtype = vmtype or "AppVM" - v.create(guest, vmtype, label, template) - +def _validate_properties(guest, helper, properties, vmtype): # properties will only work with state=present + # Check properties exist (PROPS) + # Check netvm exist + # Check volume properties + # This is only verification: should be conserved as format is different from + # qubesos.core.qube if properties: for key, val in properties.items(): if key not in PROPS: - return VIRT_FAILED, {"Invalid property": key} - if type(val) != PROPS[key]: - return VIRT_FAILED, {"Invalid property value type": key} + raise ValidationFailure({"Invalid property": key}) + if val is not None and type(val) != PROPS[key]: + raise ValidationFailure({"Invalid property value type": key}) # Make sure that the netvm exists - if key == "netvm" and val not in ["*default*", "", "none", "None"]: + if key == "netvm" and val not in [ + "*default*", + "", + "none", + "None", + None, + guest, + ]: try: - vm = v.get_vm(val) + vm = helper.get_vm(val) except KeyError: - return VIRT_FAILED, {"Missing netvm": val} + raise ValidationFailure({"Missing netvm": val}) # Also the vm should provide network if not vm.provides_network: - return VIRT_FAILED, {"Missing netvm capability": val} + raise ValidationFailure({"Missing netvm capability": val}) netvm = vm # Make sure volume has both name and value if key == "volumes": if not isinstance(val, list): - return VIRT_FAILED, {"Invalid volumes provided": val} + raise ValidationFailure({"Invalid volumes provided": val}) for vol in val: try: if "name" not in vol: - return VIRT_FAILED, { - "Missing name for the volume": vol - } + raise ValidationFailure( + {"Missing name for the volume": vol} + ) if "size" not in vol: - return VIRT_FAILED, { - "Missing size for the volume": vol - } + raise ValidationFailure( + {"Missing size for the volume": vol} + ) if not vol["name"] in ["root", "private"]: - return VIRT_FAILED, { - "Wrong volume name": vol["name"] - } + raise ValidationFailure( + {"Wrong volume name": vol["name"]} + ) if vol["name"] == "root" and vmtype not in [ "TemplateVM", "StandaloneVM", ]: - return VIRT_FAILED, { - f"Cannot change root volume size for '{vmtype}'" - } + raise ValidationFailure( + { + f"Cannot change root volume size for '{vmtype}'" + } + ) except KeyError: - return VIRT_FAILED, {"Invalid volume provided": vol} + raise ValidationFailure( + {"Invalid volume provided": vol} + ) # Make sure that the default_dispvm exists - if key == "default_dispvm": + if key == "default_dispvm" and val != guest: try: - vm = v.get_vm(val) + vm = helper.get_vm(val) except KeyError: - return VIRT_FAILED, {"Missing default_dispvm": val} + raise ValidationFailure({"Missing default_dispvm": val}) # Also the vm should provide network if not vm.template_for_dispvms: - return VIRT_FAILED, {"Missing dispvm capability": val} - - if state == "present" and guest and vmtype: - prop_changed, prop_vals = v.properties(guest, properties) - # Apply the tags - tags_changed = [] - if tags: - tags_changed = v.tags(guest, tags) - feats_changed = [] - if features: - feats_changed = v.features(guest, features) - if devices is not None: - dev_changed = apply_devices(guest) - else: - dev_changed = False - res = {"changed": prop_changed or dev_changed} - if tags_changed: - res["Tags updated"] = tags_changed - if feats_changed: - res["Features updated"] = feats_changed - if prop_changed: - res["Properties updated"] = prop_vals - if dev_changed: - res["Devices updated"] = True - if notes: - res["Notes updated"] = v.notes(guest, notes) - return VIRT_SUCCESS, res - - # notes will only work with state=present - if notes and state == "present" and guest and vmtype: - result = v.notes(guest, notes) - return VIRT_SUCCESS, {"changed": result, "Notes updated": result} - - # features will only work with state=present - if features and state == "present" and guest and vmtype: - res = v.features(guest, features) - return VIRT_SUCCESS, {"changed": bool(res), "Features updated": res} - - # This is without any properties + raise ValidationFailure({"Missing dispvm capability": val}) + + +def core(module): + state = module.params.get("state", None) + guest = module.params.get("name", None) + command = module.params.get("command", None) + vmtype = module.params.get("vmtype", None) + label = module.params.get("label", "red") + template = module.params.get("template", None) + properties = module.params.get("properties", {}) + features = module.params.get("features", {}) + tags = module.params.get("tags", []) + devices = module.params.get("devices", None) + notes = module.params.get("notes", None) + + v = QubesHelper(module) + + if module.params.get("wait") == False: + module.warn( + f"usage of 'wait' parameter in qubesos module is no more supported." + ) + if state == "present" and guest: try: - v.get_vm(guest) - dev_changed = apply_devices(guest) - res = {"changed": dev_changed} + vm = v.get_vm(guest) + vmtype = vm.klass except KeyError: - v.create(guest, vmtype, label, template) - # Apply the tags - tags_changed = [] - if tags: - tags_changed = v.tags(guest, tags) - apply_devices(guest) - res = {"changed": True, "created": guest, "devices": devices} - if tags_changed: - res["tags"] = tags_changed - return VIRT_SUCCESS, res - - # list_vms, get_states, createinventory commands - if state and command == "list_vms": - res = v.list_vms(state=state) - if not isinstance(res, dict): - res = {command: res} - return VIRT_SUCCESS, res - - if command == "get_states": - states = v.get_states() - res = {"states": states} - return VIRT_SUCCESS, res - - if command == "createinventory": - result = v.all_vms() - create_inventory(result) - return VIRT_SUCCESS, {"status": "successful"} - - # single-command VM operations - if command: - if command in VM_COMMANDS: - if not guest: - module.fail_json(msg=f"{command} requires 1 argument: guest") - if command == "create": - try: - v.get_vm(guest) - except KeyError: - v.create(guest, vmtype, label, template, netvm) - res = {"changed": True, "created": guest} - return VIRT_SUCCESS, res - elif command == "removetags": - vm = v.get_vm(guest) - changed = False - if not tags: - return VIRT_FAILED, {"Error": "Missing tag(s) to remove."} - for tag in tags: - try: - vm.tags.remove(tag) - changed = True - except QubesTagNotFoundError: - pass - return VIRT_SUCCESS, { - "Message": "Removed the tag(s).", - "changed": changed, - } - res = getattr(v, command)(guest) - 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): - res = {command: res} - return VIRT_SUCCESS, res + # Set default vmtype to AppVM if vmtype is not provided + vmtype = vmtype or "AppVM" + + # Validation + try: + _validate_properties(guest, v, properties, vmtype) + except ValidationFailure as e: + return VIRT_FAILED, e.reasons + + # gather device facts + # here, we just need to call the relevant module and retrieve the facts + if module.params.get("gather_device_facts", False): + fake_module = _run_module_host_devices_facts() + return VIRT_SUCCESS, { + "changed": False, + "ansible_facts": fake_module.returned_data["ansible_facts"], + } + # Okay, we have a state and a target, we'll have to call qubesos.core.qube + # Let's re-arrange the args to make him happy + if state == "present" and guest: + # Process conversion from legacy module format to new module format + + # template / clone_src + # Legacy module was cloning VMs in those cases + if ( + vmtype == "AppVM" + and template + and v.get_vm(template)._klass == "AppVM" + ): + # Clone VM when specifying + clone_src = template + + # Don't mange the template, will be set automatically when cloning + template = None + elif vmtype in ["StandaloneVM", "TemplateVM"] and template: + clone_src = template + template = None else: - module.fail_json(msg=f"Command {command} not recognized") + clone_src = None + + # properties / features / services / volumes + volumes = None + services = None + if properties: + # Features + # extract features from the properties key + properties_feature = properties.pop("features", {}) + if properties_feature or features: + if features is None: + features = {} + + for feat_item, feat_val in properties_feature.items(): + # In legacy module, features from high level feature key + # were enforced after features in set in sub element of + # properties + if feat_item not in features: + features[feat_item] = feat_val + + # Services + # just extract the key from properties. No modification is needed + services = properties.pop("services", None) + + # Volumes + properties_volumes = properties.pop("volumes", []) + if properties_volumes: + volumes = {} + for vol in properties_volumes: + volumes[vol["name"]] = {"size": vol["size"]} + + # Label is not a module param for the new module + if not properties: + properties = {} + properties["label"] = label + + # Call the module + try: + fake_module = _run_module_qube( + { + "clone_src": clone_src, + "devices": devices, + "features": features, + "klass": vmtype, + "name": guest, + "notes": notes, + "properties": { + prop_name: None if prop_val == "None" else prop_val + for prop_name, prop_val in properties.items() + }, + "services": services, + "shutdown_if_required": False, + "state": state, + "tags": tags, + "template": template, + "volumes": volumes, + } + ) + + # Now, try to translate new returned data to legacy ones + res_properties_updates = [] + returned_data = fake_module.returned_data + + # Tags + tags_updates = ( + fake_module.returned_data.get("diff", {}) + .get("after", {}) + .get("tags") + ) + if tags_updates: + returned_data["Tags updated"] = tags_updates + + # Features + features_updates = ( + fake_module.returned_data.get("diff", {}) + .get("after", {}) + .get("features") + ) + if features_updates: + returned_data["Features updated"] = list( + features_updates.keys() + ) + res_properties_updates.append("features") + + # Volumes (properties) + volume_updates = ( + fake_module.returned_data.get("diff", {}) + .get("after", {}) + .get("volumes") + ) + if volume_updates: + res_properties_updates += [ + f"volume:{volume}" for volume in volume_updates + ] + + # Properties + properties_updates = ( + fake_module.returned_data.get("diff", {}) + .get("after", {}) + .get("properties") + ) + if properties_updates: + res_properties_updates += list(properties_updates.keys()) + if res_properties_updates: + returned_data["Properties updated"] = res_properties_updates + + # Devices + devices_updates = ( + fake_module.returned_data.get("diff", {}) + .get("after", {}) + .get("devices") + ) + if devices_updates: + returned_data["Devices updated"] = True + + notes_updates = ( + fake_module.returned_data.get("diff", {}) + .get("after", {}) + .get("notes") + ) + if notes: + returned_data["Notes updated"] = True + return VIRT_SUCCESS, fake_module.returned_data + + except ModuleExitWithError as e: + return VIRT_FAILED, e.reasons + + # Commands + if command: + try: + fake_module = _run_module_command(module.params) + return VIRT_SUCCESS, fake_module.returned_data + except ModuleExitWithError as e: + return VIRT_FAILED, e.reasons if state: if not guest: module.fail_json(msg="State change requires a guest specified") - current = v.status(guest) - if state == "running": - if current == "paused": - res["changed"] = True - res["msg"] = v.unpause(guest) - elif current != "running": - res["changed"] = True - res["msg"] = v.start(guest) - elif state == "shutdown": - if current != "shutdown": - res["changed"] = True - try: - v.shutdown(guest, wait=module.params.get("wait", False)) - except RuntimeError as e: - module.fail_json(msg=str(e)) - elif state == "restarted": - res["changed"] = True - try: - v.restart(guest, wait=module.params.get("wait", False)) - res["msg"] = "restarted" - except RuntimeError as e: - module.fail_json(msg=str(e)) - elif state == "destroyed": - if current != "shutdown": - res["changed"] = True - res["msg"] = v.destroy(guest) - elif state == "pause": - if current == "running": - res["changed"] = True - res["msg"] = v.pause(guest) - elif state == "absent": - if current == "shutdown": - res["changed"] = True - res["msg"] = v.remove(guest) - else: - module.fail_json(msg="Unexpected state") - return VIRT_SUCCESS, res + try: + fake_module = _run_module_qube( + { + "name": guest, + "state": state, + } + ) + return VIRT_SUCCESS, fake_module.returned_data + except RuntimeError as e: + module.fail_json(msg=str(e)) + except ModuleExitWithError as e: + return VIRT_FAILED, e.reasons module.fail_json(msg="Expected state or command parameter to be specified") - return None - def main(): module = AnsibleModule( @@ -1040,7 +661,7 @@ def main(): "present", ], ), - wait=dict(type="bool", default=False), + wait=dict(type="bool", default=True), command=dict(type="str", choices=ALL_COMMANDS), label=dict(type="str", default="red"), vmtype=dict(type="str", default="AppVM"), @@ -1054,6 +675,11 @@ def main(): ), ) + module.deprecate( + "Usage of this module is deprecated and support will be dropped in a " + "future release. Consider switching to qubes.core.qubes module instead.", + ) + if not qubesadmin: module.fail_json( msg="The `qubesos` module is not importable. Check the requirements." diff --git a/plugins/strategy/qubes_proxy.py b/plugins/strategy/qubes_proxy.py deleted file mode 100644 index a77c4ef..0000000 --- a/plugins/strategy/qubes_proxy.py +++ /dev/null @@ -1,653 +0,0 @@ -# Copyright (C) 2025 Guillaume Chinal -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -# SPDX-License-Identifier: GPL-3.0-or-later - -import asyncio -import fcntl -import multiprocessing -import os -import re -import shutil -import tarfile -import tempfile -import traceback - -from contextlib import suppress -from pathlib import Path - -import qubesadmin -import qubesadmin.events.utils -import qubesadmin.exc -import yaml - -from ansible import context -from ansible.executor.play_iterator import PlayIterator -from ansible.plugins.strategy.linear import ( - StrategyModule as LinearStrategyModule, -) -from ansible.utils.display import Display -from ansible.plugins.vars.host_group_vars import VarsModule -from ansible.parsing.dataloader import DataLoader -from ansible.parsing.yaml.dumper import AnsibleDumper - - -display = Display() - -RPC_SYS_POLICY_FILES = ( - Path("/etc/qubes/policy.d/include/admin-local-rwx"), - Path("/etc/qubes/policy.d/include/admin-global-ro"), -) -RPC_INCLUDE_POL_FILE = Path("/etc/qubes/policy.d/include/qubes-ansible") -RPC_ANSIBLE_POL_FILE = Path("/etc/qubes/policy.d/30-qubes-ansible.policy") -DISPVM_NAME_MAXLEN = 31 - - -def run_play_executor(iterator, play_context): - return QubesPlayExecutor(iterator, play_context).run() - - -def filter_control_chars(text: bytes): - """Filter control chars from bytes, keep only foreground colors""" - new_buff = b"" - while len(text) > 0: - # Allow SGR Reset - if text[:4] == b"\x1b[0m": - new_buff += text[:4] - text = text[4:] - continue - - # Allow setting foreground colors - if ( - # starts with ESC [ ends with m - text[:2] == b"\x1b[" - and text[6:7] == b"m" - and - # normal (0), bold (1) - text[2] in (0x30, 0x31) - and - # comma - text[3] == 0x3B - and - # 30-37 - text[4] == 0x33 - and 0x30 <= text[5] <= 0x37 - ): - new_buff += text[:7] - text = text[7:] - continue - - # Filter other control chars - current_byte = text[:1] - if b"\040" <= current_byte <= b"\176" or current_byte in ( - b"\a", - b"\b", - b"\n", - b"\r", - b"\t", - ): - new_buff += current_byte - else: - new_buff += b"_" - text = text[1:] - return new_buff - - -class QubesPlayExecutor: - """Run plays on a given host through its management disposable VM""" - - def __init__(self, iterator, play_context): - self.app = qubesadmin.Qubes() - self.host = iterator._play.hosts[0] - self.loader = play_context._loader - if self.loader == None: - self.loader = DataLoader() - self.inventory = iterator._variable_manager._inventory - self.iterator = iterator - self.play = iterator._play - self.play_context = play_context - self.variable_manager = iterator._variable_manager - self.variable_manager._loader = self.loader - - self._dispvm_initially_running = False - - if hasattr(self.host.name, "_strip_unsafe"): - self.host_name = self.host.name._strip_unsafe() - else: - self.host_name = self.host.name - self.temp_dir = Path( - tempfile.TemporaryDirectory(prefix="qubes-ansible-").name - ) - self.vars_plugin = VarsModule() - self.vm = None - - @property - def dispvm_mgmt_name(self): - return f"disp-mgmt-{self.host_name}"[:DISPVM_NAME_MAXLEN] - - def _add_host_vars(self): - """Build host variables files - - We're building a file in host_vars/.yaml containing current - host variables merged from all sources (inventory, group_vars, - host_vars, extra_vars...). This is done using the variable manager. - """ - host_vars_dir = self.temp_dir / "host_vars" - host_vars_dir.mkdir() - host_vars_file_path = host_vars_dir / f"{self.host_name}.yaml" - - # We get all variable associated to the current host/play - # merged from all sources - all_vars = self.variable_manager.get_vars( - play=self.play, host=self.host, include_hostvars=True - ) - - # In those vars, we got magic vars. This should not be problematic - # as Ansible is supposed to ignore them, but we will try to remove - # them - # https://docs.ansible.com/ansible/latest/reference_appendices/special_variables.html - filtered_vars = set( - self.variable_manager._get_magic_variables( - play=self.play, host=self.host, task=None, include_hostvars=True - ).keys() - ) | { - "ansible_check_mode", - "ansible_collection_name", - "ansible_config_file", - "ansible_dependent_role_names", - "ansible_diff_mode", - "ansible_facts", - "ansible_forks", - "ansible_index_var", - "ansible_inventory_sources", - "ansible_limit", - "ansible_loop", - "ansible_loop_var", - "ansible_parent_role_names", - "ansible_parent_role_paths", - "ansible_play_batch", - "ansible_play_hosts", - "ansible_play_hosts_all", - "ansible_play_name", - "ansible_play_role_names", - "ansible_playbook_python", - "ansible_role_name", - "ansible_role_names", - "ansible_run_tags", - "ansible_search_path", - "ansible_skip_tags", - "ansible_verbosity", - "ansible_version", - "group_names", - "groups", - "hostvars", - "inventory_dir", - "inventory_hostname", - "inventory_hostname_short", - "inventory_file", - "omit", - "play_hosts", - "playbook_dir", - "role_name", - "role_names", - "role_path", - "vars", - } - - target_vars_names = set(all_vars.keys()) - filtered_vars - target_vars = {name: all_vars[name] for name in target_vars_names} - - if target_vars: - with open(host_vars_file_path, "w") as host_vars_file: - yaml.dump( - target_vars, - host_vars_file, - Dumper=AnsibleDumper, - default_flow_style=False, - ) - - def _add_play(self, play): - """Builds the playbook that will be executed on DispVM - - :param play: current Play object - - We need to build a YAML file containing only the current play that - will be passed to the disposable VM. - - `get_path` method from Ansible Play object is very useful for - our needs as this returns the path to the file containing the play - and the line at which it is declared. - - We just have to parse this YAML file starting from that line to get - the list of remaining plays and keep only the next one (i.e. first - of the list). - """ - play_yaml = self._get_first_play_yaml(*play.get_path().split(":")) - play_yaml["hosts"] = [str(self.host_name)] - play_yaml["strategy"] = "linear" - playbook_chunk_path = self.temp_dir / "playbook.yaml" - with playbook_chunk_path.open("w") as playbook_chunk_file: - yaml.dump( - [play_yaml], - playbook_chunk_file, - Dumper=AnsibleDumper, - default_flow_style=False, - ) - - def _add_inventory(self): - """Build pseudo inventory for DispVM - - This allows to use the correct group assignments from group_vars in the DispVM - """ - - inventory_data = "" - default_ansible_groups = ["all", "ungrouped"] - - for group in self.host.get_groups(): - if group.name in default_ansible_groups: - continue - - # create Inventory entry per group (other vars from inventory are not supported yet) - inventory_data += f"[{group.name}]\n{self.host}\n\n[{group.name}:vars]\nansible_connection=qubes\n\n" - - # if no group assignment, fallback to default appvms - if not inventory_data: - inventory_data = f"[appvms]\n{self.host}\n\n[appvms:vars]\nansible_connection=qubes\n\n" - - with open(self.temp_dir / "inventory", "w") as inventory_file: - inventory_file.write(inventory_data) - - def _add_roles(self, play): - """Adds play role - - :param play: current Play object - - We are using `get_roles` method from Ansible internal Play object - to check if there are associated roles to the current play. - If so, we can get the path to every roles directory using - `get_role_path` method. - - Then, we just have to copy every role in a destination "roles" - folder - """ - dest_roles_path = self.temp_dir / "roles" - dest_roles_path.mkdir() - - for role in play.get_roles(): - role_path = Path(role.get_role_path()) - shutil.copytree(role_path, dest_roles_path / role_path.name) - - def _add_rpc_policies(self): - src = self.dispvm_mgmt_name - dst = self.vm.name - - while True: - with RPC_INCLUDE_POL_FILE.open("a+") as pol_file: - fcntl.lockf(pol_file.fileno(), fcntl.LOCK_EX) - try: - if os.fstat(pol_file.fileno()) != os.stat( - RPC_INCLUDE_POL_FILE - ): - continue - except FileNotFoundError: - continue - - pol_file.write(f"{src} {dst} allow target=dom0\n") - try: - shutil.chown(RPC_INCLUDE_POL_FILE, group="qubes") - except PermissionError: - pass - break - - while True: - with RPC_ANSIBLE_POL_FILE.open("a+") as pol_file: - fcntl.lockf(pol_file.fileno(), fcntl.LOCK_EX) - try: - if os.fstat(pol_file.fileno()) != os.stat( - RPC_ANSIBLE_POL_FILE - ): - continue - except FileNotFoundError: - continue - - pol_file.write( - f"qubes.Filecopy * {src} {dst} allow\n" - f"qubes.WaitForSession * {src} {dst} allow\n" - f"qubes.VMShell * {src} {dst} allow\n" - f"qubes.VMRootShell * {src} {dst} allow\n" - f"admin.vm.List * {src} dom0 allow\n" - ) - try: - shutil.chown(RPC_ANSIBLE_POL_FILE, group="qubes") - except PermissionError: - pass - break - - @staticmethod - def _build_ansible_args(): - args = [] - current_args = context.CLIARGS - - verbosity = current_args.get("verbosity") - if verbosity: - args.append(f"-{'v'*display.verbosity}") - - tags = current_args.get("tags") - if tags: - for tag in tags: - args += ["-t", tag] - - skip_tags = current_args.get("skip_tags") - if skip_tags: - for tag in skip_tags: - args += ["--skip-tags", tag] - - for boolean_arg in ["check", "diff", "force_handlers", "flush_cache"]: - if current_args.get(boolean_arg): - args.append(f"--{boolean_arg.replace('_', '-')}") - - return args - - def _build_tar(self): - tar_file_path = self.temp_dir.parent / f"{self.temp_dir.name}.tar" - old_path = os.getcwd() - os.chdir(self.temp_dir) - with tarfile.open(tar_file_path, "w") as tar_file: - tar_file.add(".", recursive=True) - os.chdir(old_path) - return tar_file_path - - @staticmethod - def _get_first_play_yaml(path, start_line): - with open(path, "r") as playbook_file: - return yaml.safe_load( - "".join(playbook_file.readlines()[int(start_line) - 1 :]) - )[0] - - def _remove_rpc_policies(self): - src = self.dispvm_mgmt_name - dst = self.vm.name - - with RPC_INCLUDE_POL_FILE.open("a+") as pol_file: - fcntl.lockf(pol_file.fileno(), fcntl.LOCK_EX) - with suppress(FileNotFoundError): - os.stat(RPC_INCLUDE_POL_FILE) - pol_file.seek(0) - new_file_lines = [ - line - for line in pol_file.readlines() - if not re.match( - rf"^\s*{re.escape(src)}\s+{re.escape(dst)}\s+", - line, - ) - ] - pol_file.seek(0) - pol_file.truncate() - pol_file.write("".join(new_file_lines)) - pol_file.flush() - - with RPC_ANSIBLE_POL_FILE.open("a+") as pol_file: - fcntl.lockf(pol_file.fileno(), fcntl.LOCK_EX) - with suppress(FileNotFoundError): - pol_file.seek(0) - os.stat(RPC_ANSIBLE_POL_FILE) - new_file_lines = [ - line - for line in pol_file.readlines() - if not re.match( - rf"^\s*\S+\s+\S+\s+{re.escape(src)}\s+", - line, - ) - ] - pol_file.seek(0) - pol_file.truncate() - pol_file.write("".join(new_file_lines)) - pol_file.flush() - - def _start_mgmt_disp_vm(self): - self.vvv("Lookup for dispvm_mgmt") - dispvm = self.app.domains.get(self.dispvm_mgmt_name) - self.vvv(f"Found dispvm: {dispvm}") - if dispvm is None: - self.vvv(f"Creating dispvm {self.dispvm_mgmt_name}") - dispvm = self.app.add_new_vm( - "DispVM", - template=self.vm.management_dispvm, - label=self.vm.management_dispvm.label, - name=self.dispvm_mgmt_name, - ) - dispvm.features["internal"] = True - dispvm.features["gui"] = False - dispvm.netvm = None - dispvm.auto_cleanup = True - self.vvv(f"Dispvm <{dispvm.name}> was running: {dispvm.is_running()}") - self._dispvm_initially_running = dispvm.is_running() - if not dispvm.is_running(): - dispvm.start() - return dispvm - - def run(self): - """Runs the given play on the mgmt dispvm of the host""" - self.vvv(f"Running play {self.play}") - self.vm = self.app.domains.get(self.host_name) - if not self.vm: - raise KeyError(f"Host {self.host.name} not found") - self.vvv(f"Found VM {self.vm}") - - dispvm = self._start_mgmt_disp_vm() - - self._add_rpc_policies() - self.temp_dir.mkdir() - - try: - self._add_play(self.play) - self._add_roles(self.play) - self._add_host_vars() - self._add_inventory() - tar_file_path = self._build_tar() - ansible_args = self._build_ansible_args() - - self.vvv(f"Copying {tar_file_path} to {self.vm}") - dispvm.run_service( - "qubes.Filecopy", - localcmd="/usr/lib/qubes/qfile-dom0-agent {}".format( - tar_file_path - ), - ).wait() - - self.vvv(f"Running qubes.AnsibleVM on {self.vm}") - p = dispvm.run_service("qubes.AnsibleVM") - rpc_args = ( - tar_file_path.name - + "\n" - + self.host_name - + "\n" - + "\n".join(ansible_args) - + "\n" - ).encode() - self.vvvv(f"RPC args: {rpc_args}") - (untrusted_stdout, untrusted_stderr) = p.communicate(rpc_args) - self.vvvv(f"stdout: {untrusted_stdout}") - self.vvvv(f"stderr: {untrusted_stderr}") - self.vvvv(f"return code: {p.returncode}") - return ( - self.host, - p.returncode, - filter_control_chars(untrusted_stdout).decode("utf-8"), - filter_control_chars(untrusted_stderr).decode("utf-8"), - self.dispvm_mgmt_name, - self.play.name, - ) - - finally: - self._remove_rpc_policies() - shutil.rmtree(self.temp_dir) - if not self._dispvm_initially_running: - self.vvv(f"Stopping {dispvm.name}") - dispvm.shutdown() - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete( - asyncio.wait_for( - qubesadmin.events.utils.wait_for_domain_shutdown( - [dispvm] - ), - dispvm.shutdown_timeout, - ) - ) - - except asyncio.TimeoutError: - try: - display.warning(f" killing {dispvm.name}") - dispvm.kill() - except qubesadmin.exc.QubesVMNotStartedError: - pass - - def _verbose(self, msg: str, level: int): - getattr(display, "v" * level)(f"<{self.host_name}> {msg}") - - def v(self, msg): - self._verbose(msg, 1) - - def vv(self, msg): - self._verbose(msg, 2) - - def vvv(self, msg): - self._verbose(msg, 3) - - def vvvv(self, msg): - self._verbose(msg, 4) - - def vvvvv(self, msg): - self._verbose(msg, 5) - - def vvvvvv(self, msg): - self._verbose(msg, 6) - - -class StrategyModule(LinearStrategyModule): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._setup_rpc_policies() - - def _new_play_iterator_with_hosts(self, iterator, play_context, hosts): - new_play = iterator._play.copy() - new_play.hosts = hosts - return PlayIterator( - inventory=self._inventory, - play=new_play, - play_context=play_context, - variable_manager=self._variable_manager, - all_vars=self._variable_manager.get_vars(play=new_play), - start_at_done=self._tqm._start_at_done, - ) - - @staticmethod - def _setup_rpc_policies(): - Path(RPC_INCLUDE_POL_FILE).touch() - for policy_file in RPC_SYS_POLICY_FILES: - policy_lines = [ - line.strip() for line in policy_file.read_text().split("\n") - ] - if "!include include/qubes-ansible" not in policy_lines: - with policy_file.open("a") as policy_fd: - policy_fd.write("!include include/qubes-ansible\n") - - @staticmethod - def collect_error(error): - display.display(f"[ERROR]: {str(error)}", "red") - display.display( - "".join( - traceback.format_exception(None, error, error.__traceback__) - ), - "red", - ) - - def collect_result(self, result_tuple): - host, retcode, stdout, stderr, dispvm, play_name = result_tuple - display.banner(f"QUBESOS [{dispvm}: PLAY {play_name}]") - if stderr: - display.display(str(stderr), "red") - if stdout: - display.display(str(stdout), "bright blue") - self.qubes_results[host] = retcode - - def proxy_run(self, iterator, play_context): - play = iterator._play - display.vvv( - f" Running play {play} " f"with {self._tqm._forks} forks" - ) - pool = multiprocessing.Pool(self._tqm._forks) - - self.qubes_results = {} - for host in self._inventory.get_hosts(play.hosts): - self.qubes_results[host] = 255 - - new_iterator = self._new_play_iterator_with_hosts( - iterator, play_context, [host] - ) - pool.apply_async( - run_play_executor, - (new_iterator, play_context), - callback=self.collect_result, - error_callback=self.collect_error, - ) - pool.close() - pool.join() - - stats = self._tqm._stats - for host, result in self.qubes_results.items(): - if result == 0: - stats.increment("ok", host.name) - else: - stats.increment("failures", host.name) - - return max(self.qubes_results.values()) - - def run(self, iterator, play_context): - play = iterator._play - - target_hosts = self._inventory.get_hosts(play.hosts) - local_hosts = [ - host for host in target_hosts if host.name in ["localhost", "dom0"] - ] - retval_local_exec = self._tqm.RUN_OK - - remote_hosts = [ - host - for host in target_hosts - if host.name not in ["localhost", "dom0"] - ] - retval_remote_exec = self._tqm.RUN_OK - - if local_hosts: - retval_local_exec = super().run( - self._new_play_iterator_with_hosts( - iterator, play_context, local_hosts - ), - play_context, - ) - - # For other host, we need to start a disp mgmt and run the play inside - if remote_hosts: - retval_remote_exec = self.proxy_run( - self._new_play_iterator_with_hosts( - iterator, play_context, remote_hosts - ), - play_context, - ) - - return max(retval_local_exec, retval_remote_exec) diff --git a/plugins/strategy/qubes_proxy.py b/plugins/strategy/qubes_proxy.py new file mode 120000 index 0000000..4e640ed --- /dev/null +++ b/plugins/strategy/qubes_proxy.py @@ -0,0 +1 @@ +/usr/share/ansible/collections/ansible_collections/qubesos/security/plugins/strategy/qubes_proxy.py \ No newline at end of file diff --git a/rpm_spec/qubes-ansible.spec.in b/rpm_spec/qubes-ansible.spec.in index caee79a..dfa2442 100644 --- a/rpm_spec/qubes-ansible.spec.in +++ b/rpm_spec/qubes-ansible.spec.in @@ -1,3 +1,5 @@ +%define qubes_collection_dir %{_datadir}/ansible/collections/ansible_collections/qubesos + Name: qubes-ansible Version: @VERSION@ Release: @REL@%{?dist} @@ -66,11 +68,16 @@ make DESTDIR=$RPM_BUILD_ROOT install-all %doc README.md LICENSE EXAMPLES.md %{_datadir}/ansible/plugins/modules/qubesos.py %{_datadir}/ansible/plugins/connection/qubes.py +%{qubes_collection_dir}/core/plugins/connection/qubes.py +%{qubes_collection_dir}/core/plugins/module_utils/*.py +%{qubes_collection_dir}/core/plugins/modules/*.py %files dom0 %{_datadir}/ansible/plugins/callback/qubesos_strategy_guard.py %{_datadir}/ansible/plugins/strategy/qubes_proxy.py /usr/lib/qubes/update-ansible-default-strategy +%{qubes_collection_dir}/security/plugins/callback/qubesos_strategy_guard.py +%{qubes_collection_dir}/security/plugins/strategy/qubes_proxy.py %files tests %{_datadir}/ansible/tests/qubes/*.py diff --git a/tests/ansible_proxy_strategy.cfg b/tests/ansible_proxy_strategy.cfg index 0e92fba..699bfa5 100644 --- a/tests/ansible_proxy_strategy.cfg +++ b/tests/ansible_proxy_strategy.cfg @@ -1,3 +1,3 @@ [defaults] -strategy = qubes_proxy +strategy = qubesos.security.qubes_proxy stdout_callback = json diff --git a/tests/ansible_proxy_strategy_legacy.cfg b/tests/ansible_proxy_strategy_legacy.cfg new file mode 100644 index 0000000..0e92fba --- /dev/null +++ b/tests/ansible_proxy_strategy_legacy.cfg @@ -0,0 +1,3 @@ +[defaults] +strategy = qubes_proxy +stdout_callback = json diff --git a/tests/qubes/conftest.py b/tests/qubes/conftest.py index a64f9a0..6e77325 100644 --- a/tests/qubes/conftest.py +++ b/tests/qubes/conftest.py @@ -1,22 +1,35 @@ +import sys import uuid import pytest import qubesadmin from qubesadmin.utils import vm_dependencies -from plugins.modules.qubesos import core +sys.path.append("/usr/share/ansible/collections") + +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_qube import ( + QubeModule, +) + +DEBIAN_TEMPLATE = "debian-12-minimal" + + +class ModuleExitWithError(Exception): + pass # Helper to run the module core function class Module: def __init__(self, params): self.params = params + self.returned_data = None def fail_json(self, **kwargs): - pytest.fail(f"Module failed: {kwargs}") + self.returned_data = kwargs + raise ModuleExitWithError(kwargs) def exit_json(self, **kwargs): - return + self.returned_data = kwargs @pytest.fixture(scope="function") @@ -38,7 +51,7 @@ def vmname(): def vm(qubes, request): """Generate a VM with default configurations""" vmname = f"test-vm-{uuid.uuid4().hex[:8]}" - core(Module({"state": "present", "name": vmname})) + QubeModule(Module({"state": "present", "name": vmname})).run() request.node.mark_vm_created(vmname) qubes.domains.refresh_cache(force=True) @@ -48,8 +61,11 @@ def vm(qubes, request): @pytest.fixture(scope="function") def minimalvm(qubes, request): vmname = f"test-minimalvm-{uuid.uuid4().hex[:8]}" - props = {"template": "debian-12-minimal"} - core(Module({"state": "present", "name": vmname, "properties": props})) + QubeModule( + Module( + {"state": "present", "name": vmname, "template": DEBIAN_TEMPLATE} + ) + ).run() request.node.mark_vm_created(vmname) qubes.domains.refresh_cache(force=True) @@ -60,7 +76,9 @@ def minimalvm(qubes, request): def netvm(qubes, request): vmname = f"test-netvm-{uuid.uuid4().hex[:8]}" props = {"provides_network": True} - core(Module({"state": "present", "name": vmname, "properties": props})) + QubeModule( + Module({"state": "present", "name": vmname, "properties": props}) + ).run() request.node.mark_vm_created(vmname) qubes.domains.refresh_cache(force=True) @@ -70,7 +88,7 @@ def netvm(qubes, request): @pytest.fixture(scope="function") def audiovm(qubes, request): vmname = f"test-audiovm-{uuid.uuid4().hex[:8]}" - core(Module({"state": "present", "name": vmname})) + QubeModule(Module({"state": "present", "name": vmname})).run() request.node.mark_vm_created(vmname) qubes.domains.refresh_cache(force=True) @@ -80,7 +98,7 @@ def audiovm(qubes, request): @pytest.fixture(scope="function") def guivm(qubes, request): vmname = f"test-guivm-{uuid.uuid4().hex[:8]}" - core(Module({"state": "present", "name": vmname})) + QubeModule(Module({"state": "present", "name": vmname})).run() request.node.mark_vm_created(vmname) qubes.domains.refresh_cache(force=True) @@ -90,7 +108,15 @@ def guivm(qubes, request): @pytest.fixture(scope="function") def managementdvm(qubes, request): vmname = f"test-mdvm-{uuid.uuid4().hex[:8]}" - core(Module({"state": "present", "name": vmname})) + QubeModule( + Module( + { + "state": "present", + "name": vmname, + "properties": {"template_for_dispvms": True}, + } + ) + ).run() request.node.mark_vm_created(vmname) qubes.domains.refresh_cache(force=True) @@ -137,11 +163,7 @@ def mark(name): setattr(holder, prop_name, None) # now remove the VM itself - try: - core(Module({"command": "remove", "name": name})) - except Exception: - # if it still fails (e.g. already gone), ignore - pass + QubeModule(Module({"state": "absent", "name": name})).run() @pytest.fixture diff --git a/tests/qubes/test_cli.py b/tests/qubes/test_cli.py index 1543edf..b7f64fc 100644 --- a/tests/qubes/test_cli.py +++ b/tests/qubes/test_cli.py @@ -65,26 +65,26 @@ def test_create_and_destroy_vm(run_playbook, request, target_host): "tasks": [ { "name": "Create AppVM", - "qubesos": { + "qubesos.core.qube": { "name": name, - "command": "create", - "vmtype": "AppVM", + "state": "present", + "klass": "AppVM", }, }, { "name": "Start AppVM", - "qubesos": { + "qubesos.core.qube": { "name": name, - "command": "start", + "state": "running", }, }, { - "name": "Destroy AppVM", - "qubesos": {"name": name, "command": "destroy"}, + "name": "Stop AppVM", + "qubesos.core.qube": {"name": name, "state": "halted"}, }, { "name": "Remove AppVM", - "qubesos": {"name": name, "command": "remove"}, + "qubesos.core.qube": {"name": name, "state": "absent"}, }, ], } @@ -111,7 +111,7 @@ def test_properties_and_tags_playbook(run_playbook, request, target_host): "tasks": [ { "name": "Create VM with properties", - "qubesos": { + "qubesos.core.qube": { "name": name, "state": "present", "properties": {"autostart": True, "memory": 128}, @@ -120,11 +120,11 @@ def test_properties_and_tags_playbook(run_playbook, request, target_host): }, { "name": "Validate VM state", - "qubesos": {"name": name, "command": "status"}, + "qubesos.core.command": {"name": name, "command": "status"}, }, { "name": "Cleanup", - "qubesos": {"name": name, "state": "absent"}, + "qubesos.core.qube": {"name": name, "state": "absent"}, }, ], } @@ -138,9 +138,9 @@ def test_properties_and_tags_playbook(run_playbook, request, target_host): "changed" ], result.stdout assert {"tag1", "tag2"} == set( - run_output["plays"][0]["tasks"][1]["hosts"][target_host].get( - "Tags updated", [] - ) + run_output["plays"][0]["tasks"][1]["hosts"][target_host]["diff"][ + "after" + ]["tags"] ), result.stdout @@ -159,7 +159,7 @@ def test_inventory_playbook(run_playbook, tmp_path, qubes, target_host): "tasks": [ { "name": "Create inventory", - "qubesos": {"command": "createinventory"}, + "qubesos.core.command": {"command": "createinventory"}, } ], } @@ -188,7 +188,7 @@ def test_vm_connection(vm, run_playbook, ansible_config): play_attrs = { "hosts": vm.name, "gather_facts": False, - "connection": "qubes", + "connection": "qubesos.core.qubes", } default_user_playbook = [ @@ -277,13 +277,14 @@ def test_vm_connection(vm, run_playbook, ansible_config): ] invalid_user_result = run_playbook(invalid_user_playbook, vms=[vm.name]) + assert invalid_user_result.returncode == 2, invalid_user_result.stdout if ansible_config == "ansible_linear_strategy": invalid_user_output = json.loads(invalid_user_result.stdout) assert ( invalid_user_output["plays"][0]["tasks"][0]["hosts"][vm.name]["msg"] - == f'Invalid value "{invalid_user}" for configuration option "plugin_type: connection plugin: qubes setting: remote_user ", valid values are: user, root' + == f'Invalid value "{invalid_user}" for configuration option "plugin_type: connection plugin: ansible_collections.qubesos.core.plugins.connection.qubes setting: remote_user ", valid values are: user, root' ), invalid_user_result.stdout else: assert ( @@ -299,7 +300,7 @@ def test_minimalvm_connection(minimalvm, run_playbook, ansible_config): play_attrs = { "hosts": minimalvm.name, "gather_facts": False, - "connection": "qubes", + "connection": "qubesos.core.qubes", } default_user_playbook = [ @@ -410,21 +411,47 @@ def test_ansible_doc_qubesos_module(): @pytest.mark.parametrize( - "ansible_config", ["ansible_linear_strategy", "ansible_proxy_strategy"] + "ansible_config", + [ + "ansible_linear_strategy", + ], ) -def test_state_absent_when_vm_does_not_exist(run_playbook): - playbook = [ +def test_devices_assignment( + vm, run_playbook, latest_net_ports, ansible_config, qubes +): + port = latest_net_ports[0] + + default_user_playbook = [ { - "hosts": "localhost", + "hosts": vm.name, + "gather_facts": False, "tasks": [ { - "name": "Ensure VM doesn't exist", - "qubesos": {"state": "absent", "name": "not_existing_vm"}, - } + "name": "Assign PCI device", + "qubesos.core.qube": { + "name": vm.name, + "state": "present", + "devices": { + "strategy": "strict", + "items": [port], + }, + }, + }, ], - } + }, ] - result = run_playbook(playbook) - assert result.returncode == 0, result.stderr - output = json.loads(result.stdout) - assert not output["plays"][0]["tasks"][1]["hosts"]["localhost"]["changed"] + + res = run_playbook(default_user_playbook, vms=[vm.name]) + assert res.returncode == 0, res.stdout + + qubes.domains.refresh_cache(force=True) + assigned = qubes.domains[vm.name].devices["pci"].get_assigned_devices() + ports_assigned = [ + ( + f"pci:dom0:{d.virtual_device.port_id}" + if hasattr(d, "virtual_device") + else d.port_id + ) + for d in assigned + ] + assert ports_assigned == [port] diff --git a/tests/qubes/test_gather_pci_facts.py b/tests/qubes/test_gather_pci_facts.py new file mode 100644 index 0000000..f74418b --- /dev/null +++ b/tests/qubes/test_gather_pci_facts.py @@ -0,0 +1,42 @@ +import os +import pytest +import time + +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_host_devices_facts import ( + core, +) +from tests.qubes.conftest import qubes, vmname, Module, ModuleExitWithError + + +def test_devices_pci_facts_match_actual(qubes): + # Gather PCI facts from the module + fake_module = Module({"gather_device_facts": True}) + core(fake_module) + + facts = fake_module.returned_data["ansible_facts"] + assert "pci_net" in facts + assert "pci_usb" in facts + assert "pci_audio" in facts + + # Compute the lists directly from qubes.domains["dom0"] + net_actual = [ + f"pci:dom0:{dev.port_id}:{dev.device_id}" + for dev in qubes.domains["dom0"].devices["pci"] + if repr(dev.interfaces[0]).startswith("p02") + ] + usb_actual = [ + f"pci:dom0:{dev.port_id}:{dev.device_id}" + for dev in qubes.domains["dom0"].devices["pci"] + if repr(dev.interfaces[0]).startswith("p0c03") + ] + audio_actual = [ + f"pci:dom0:{dev.port_id}:{dev.device_id}" + for dev in qubes.domains["dom0"].devices["pci"] + if repr(dev.interfaces[0]).startswith("p0401") + or repr(dev.interfaces[0]).startswith("p0403") + ] + + # Compare sets + assert set(facts["pci_net"]) == set(net_actual) + assert set(facts["pci_usb"]) == set(usb_actual) + assert set(facts["pci_audio"]) == set(audio_actual) diff --git a/tests/qubes/test_legacy_cli.py b/tests/qubes/test_legacy_cli.py new file mode 100644 index 0000000..aaeb397 --- /dev/null +++ b/tests/qubes/test_legacy_cli.py @@ -0,0 +1,482 @@ +import subprocess +import uuid +import json +from typing import List + +import pytest +from pathlib import Path + +PLUGIN_PATH = Path(__file__).parent / "plugins" / "modules" + + +@pytest.fixture +def run_playbook(tmp_path, ansible_config): + """ + Helper to write a playbook and execute it with ansible-playbook. + """ + + ansible_config_path = Path(__file__).parent.parent / f"{ansible_config}.cfg" + assert ansible_config_path.is_file() + + def _run(playbook_content: List[dict], vms: List[str] = []): + # 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", + "-i", + f"localhost,dom0,{','.join(vms)}", + "-c", + "local", + "-M", + str(PLUGIN_PATH), + str(pb_file), + ] + result = subprocess.run( + cmd, + cwd=tmp_path, + capture_output=True, + text=True, + env={"ANSIBLE_CONFIG": str(ansible_config_path)}, + ) + return result + + return _run + + +@pytest.mark.parametrize( + "ansible_config", + ["ansible_linear_strategy", "ansible_proxy_strategy_legacy"], +) +@pytest.mark.parametrize( + "target_host", + ["localhost", "dom0"], +) +def test_create_and_destroy_vm(run_playbook, request, target_host): + name = f"test-vm-{uuid.uuid4().hex[:6]}" + request.node.mark_vm_created(name) + + playbook = [ + { + "hosts": target_host, + "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 + + +@pytest.mark.parametrize( + "ansible_config", + ["ansible_linear_strategy", "ansible_proxy_strategy_legacy"], +) +@pytest.mark.parametrize( + "target_host", + ["localhost", "dom0"], +) +def test_properties_and_tags_playbook(run_playbook, request, target_host): + name = f"test-vm-{uuid.uuid4().hex[:6]}" + request.node.mark_vm_created(name) + + playbook = [ + { + "hosts": target_host, + "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 + run_output = json.loads(result.stdout) + assert run_output["plays"][0]["tasks"][1]["hosts"][target_host][ + "changed" + ], result.stdout + assert {"tag1", "tag2"} == set( + run_output["plays"][0]["tasks"][1]["hosts"][target_host].get( + "Tags updated", [] + ) + ), result.stdout + + +@pytest.mark.parametrize( + "ansible_config", + ["ansible_linear_strategy", "ansible_proxy_strategy_legacy"], +) +@pytest.mark.parametrize( + "target_host", + ["localhost", "dom0"], +) +def test_inventory_playbook(run_playbook, tmp_path, qubes, target_host): + # Generate inventory via playbook + playbook = [ + { + "hosts": target_host, + "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 + + +@pytest.mark.parametrize( + "ansible_config", + ["ansible_linear_strategy", "ansible_proxy_strategy_legacy"], +) +def test_vm_connection(vm, run_playbook, ansible_config): + play_attrs = { + "hosts": vm.name, + "gather_facts": False, + "connection": "qubes", + } + + default_user_playbook = [ + { + **play_attrs, + "tasks": [ + { + "name": "Default VM user is 'user'", + "ansible.builtin.command": "whoami", + "register": "default_result", + "failed_when": "default_result.stdout != 'user'", + }, + ], + }, + ] + + default_user_result = run_playbook(default_user_playbook, vms=[vm.name]) + assert default_user_result.returncode == 0, default_user_result.stdout + + connect_user_playbook = [ + { + **play_attrs, + "remote_user": "user", + "tasks": [ + { + "name": "VM user with 'remote_user: user' is 'user'", + "ansible.builtin.command": "whoami", + "register": "user_result", + "failed_when": "user_result.stdout != 'user'", + }, + ], + }, + ] + + connect_user_result = run_playbook(connect_user_playbook, vms=[vm.name]) + assert connect_user_result.returncode == 0, connect_user_result.stdout + + connect_root_playbook = [ + { + **play_attrs, + "remote_user": "root", + "tasks": [ + { + "name": "VM user with 'remote_user: root' is 'root'", + "ansible.builtin.command": "whoami", + "register": "root_result", + "failed_when": "root_result.stdout != 'root'", + }, + ], + }, + ] + + connect_root_result = run_playbook(connect_root_playbook, vms=[vm.name]) + assert connect_root_result.returncode == 0, connect_root_result.stdout + + become_playbook = [ + { + **play_attrs, + "become": True, + "tasks": [ + { + "name": "VM user with 'become: true' is 'root'", + "ansible.builtin.command": "whoami", + "register": "become_result", + "failed_when": "become_result.stdout != 'root'", + }, + ], + }, + ] + + become_result = run_playbook(become_playbook, vms=[vm.name]) + assert become_result.returncode == 0, become_result.returncode + + invalid_user = "somebody" + invalid_user_playbook = [ + { + **play_attrs, + "remote_user": invalid_user, + "tasks": [ + { + "name": "No-op", + "ansible.builtin.command": "true", + }, + ], + }, + ] + + invalid_user_result = run_playbook(invalid_user_playbook, vms=[vm.name]) + assert invalid_user_result.returncode == 2, invalid_user_result.stdout + + if ansible_config == "ansible_linear_strategy": + invalid_user_output = json.loads(invalid_user_result.stdout) + assert ( + invalid_user_output["plays"][0]["tasks"][0]["hosts"][vm.name]["msg"] + == f'Invalid value "{invalid_user}" for configuration option "plugin_type: connection plugin: qubes setting: remote_user ", valid values are: user, root' + ), invalid_user_result.stdout + else: + assert ( + f'Invalid value \\"{invalid_user}\\" for configuration option \\"plugin_type: connection plugin: qubes setting: remote_user \\", valid values are: user, root' + in invalid_user_result.stdout + ) + + +@pytest.mark.parametrize( + "ansible_config", + ["ansible_linear_strategy", "ansible_proxy_strategy_legacy"], +) +def test_minimalvm_connection(minimalvm, run_playbook, ansible_config): + play_attrs = { + "hosts": minimalvm.name, + "gather_facts": False, + "connection": "qubes", + } + + default_user_playbook = [ + { + **play_attrs, + "tasks": [ + { + "name": "Default minimal VM user is 'user'", + "ansible.builtin.command": "whoami", + "register": "default_result", + "failed_when": "default_result.stdout != 'user'", + }, + ], + }, + ] + + default_user_result = run_playbook( + default_user_playbook, vms=[minimalvm.name] + ) + assert default_user_result.returncode == 0, default_user_result.stdout + + connect_user_playbook = [ + { + **play_attrs, + "remote_user": "user", + "tasks": [ + { + "name": "Minimal VM user with 'remote_user: user' is 'user'", + "ansible.builtin.command": "whoami", + "register": "user_result", + "failed_when": "user_result.stdout != 'user'", + }, + ], + }, + ] + + connect_user_result = run_playbook( + connect_user_playbook, vms=[minimalvm.name] + ) + assert connect_user_result.returncode == 0, connect_user_result.stdout + + connect_root_playbook = [ + { + **play_attrs, + "remote_user": "root", + "tasks": [ + { + "name": "Minimal VM user with 'remote_user: root' is 'root'", + "ansible.builtin.command": "whoami", + "register": "root_result", + "failed_when": "root_result.stdout != 'root'", + }, + ], + }, + ] + + connect_root_result = run_playbook( + connect_root_playbook, vms=[minimalvm.name] + ) + assert connect_root_result.returncode == 0, connect_root_result.stdout + + become_playbook = [ + { + **play_attrs, + "become": True, + "tasks": [ + { + "name": "No-op", + "ansible.builtin.command": "true", + }, + ], + }, + ] + + become_result = run_playbook(become_playbook, vms=[minimalvm.name]) + # Playbook should fail because "become" isn't possibile on unmodified minimal vms. + assert become_result.returncode == 2, become_result.stdout + + if ansible_config == "ansible_linear_strategy": + become_output = json.loads(become_result.stdout) + become_module_result = become_output["plays"][0]["tasks"][0]["hosts"][ + minimalvm.name + ] + assert become_module_result["failed"], become_result.stdout + assert become_module_result["rc"] == 1, become_result.stdout + assert ( + become_module_result["module_stderr"].rstrip() + == "sudo: a password is required" + ), become_result.stdout + else: + assert "sudo: a password is required" in become_result.stdout + + +def test_ansible_doc_qubesos_module(): + + cmd = ["ansible-doc", "-M", str(PLUGIN_PATH), "qubesos"] + + result = subprocess.run(cmd, capture_output=True, text=True) + + assert ( + result.returncode == 0 + ), f"ansible-doc failed with stderr: {result.stderr}" + + # Should contain expected module information + assert ( + "> QUBESOS" in result.stdout + ), "Documentation should mention the module name" + + +@pytest.mark.parametrize( + "ansible_config", + ["ansible_linear_strategy", "ansible_proxy_strategy_legacy"], +) +def test_state_absent_when_vm_does_not_exist(run_playbook): + playbook = [ + { + "hosts": "localhost", + "tasks": [ + { + "name": "Ensure VM doesn't exist", + "qubesos": {"state": "absent", "name": "not_existing_vm"}, + } + ], + } + ] + result = run_playbook(playbook) + assert result.returncode == 0, result.stderr + output = json.loads(result.stdout) + assert not output["plays"][0]["tasks"][1]["hosts"]["localhost"]["changed"] + + +@pytest.mark.parametrize( + "ansible_config", + [ + "ansible_linear_strategy", + ], +) +def test_devices_assignment( + vm, run_playbook, latest_net_ports, ansible_config, qubes +): + port = latest_net_ports[0] + + default_user_playbook = [ + { + "hosts": vm.name, + "gather_facts": False, + "tasks": [ + { + "name": "Assign PCI device", + "qubesos": { + "name": vm.name, + "state": "present", + "devices": { + "strategy": "strict", + "items": [port], + }, + }, + }, + ], + }, + ] + + res = run_playbook(default_user_playbook, vms=[vm.name]) + assert res.returncode == 0, res.stdout + + qubes.domains.refresh_cache(force=True) + assigned = qubes.domains[vm.name].devices["pci"].get_assigned_devices() + ports_assigned = [ + ( + f"pci:dom0:{d.virtual_device.port_id}" + if hasattr(d, "virtual_device") + else d.port_id + ) + for d in assigned + ] + assert ports_assigned == [port] diff --git a/tests/qubes/test_module.py b/tests/qubes/test_legacy_qubesos_module.py similarity index 84% rename from tests/qubes/test_module.py rename to tests/qubes/test_legacy_qubesos_module.py index 6ffa2c5..dd14eb4 100644 --- a/tests/qubes/test_module.py +++ b/tests/qubes/test_legacy_qubesos_module.py @@ -1,3 +1,4 @@ +import pytest import os import time @@ -166,6 +167,90 @@ def test_create_clone_vmtype_combinations(qubes, vmname, request): core(Module({"state": "absent", "name": vmname})) +def test_create_clone_vmtype_combinations_2(qubes, vmname, request): + request.node.mark_vm_created(vmname) + appvm_clone_name = f"{vmname}-appcln" + appvm_clone_name_2 = f"{vmname}-appcln2" + template_clone_name = f"{vmname}-tplcln" + standalone_clone_name_1 = f"{vmname}-std" + standalone_clone_name_2 = f"{vmname}-std-2" + request.node.mark_vm_created(appvm_clone_name) + request.node.mark_vm_created(appvm_clone_name_2) + request.node.mark_vm_created(template_clone_name) + request.node.mark_vm_created(standalone_clone_name_1) + request.node.mark_vm_created(standalone_clone_name_2) + + # 1- create an AppVM + rc, returned_data = core( + Module({"state": "present", "name": vmname, "vmtype": "AppVM"}) + ) + assert returned_data["created"] + + # 2- Clone this AppVM + rc, returned_data = core( + Module( + { + "state": "present", + "name": appvm_clone_name, + "vmtype": "AppVM", + "template": vmname, + } + ) + ) + qubes.domains.refresh_cache(force=True) + assert returned_data["created"] + assert appvm_clone_name in qubes.domains + assert qubes.domains[appvm_clone_name].klass == "AppVM" + + # 3- Clone a template + rc, returned_data = core( + Module( + { + "state": "present", + "name": template_clone_name, + "vmtype": "TemplateVM", + "template": qubes.default_template, + } + ) + ) + qubes.domains.refresh_cache(force=True) + assert returned_data["created"] + assert template_clone_name in qubes.domains + assert qubes.domains[template_clone_name].klass == "TemplateVM" + + # 4- Create a Standalone VM from that template + core( + Module( + { + "state": "present", + "name": standalone_clone_name_1, + "vmtype": "StandaloneVM", + "template": template_clone_name, + } + ) + ) + qubes.domains.refresh_cache(force=True) + assert returned_data["created"] + assert standalone_clone_name_1 in qubes.domains + assert qubes.domains[standalone_clone_name_1].klass == "StandaloneVM" + + # 5- Clone this StandaloneVM + core( + Module( + { + "state": "present", + "name": standalone_clone_name_2, + "vmtype": "StandaloneVM", + "template": standalone_clone_name_1, + } + ) + ) + qubes.domains.refresh_cache(force=True) + assert returned_data["created"] + assert standalone_clone_name_2 in qubes.domains + assert qubes.domains[standalone_clone_name_2].klass == "StandaloneVM" + + def test_volumes_list_for_standalonevm(qubes, vmname, request): request.node.mark_vm_created(vmname) @@ -524,7 +609,7 @@ def test_removetags_errors_if_no_tags_present(qubes, vmname, request): # Remove tags rc, res = core(Module({"command": "removetags", "name": vmname})) assert rc == VIRT_FAILED - assert "Missing tag" in res.get("Error", "") + assert res["msg"] == "Expected 'tags' parameter to be specified" def test_devices_pci_facts_match_actual(qubes): @@ -595,6 +680,40 @@ def test_devices_strict_single_pci_assignment( core(Module({"state": "absent", "name": vmname})) +def test_devices_explicit_strict_assignment( + qubes, vmname, request, latest_net_ports +): + request.node.mark_vm_created(vmname) + port = latest_net_ports[-1] + + # Create VM in strict mode with only one PCI device + rc, res = core( + Module( + { + "state": "present", + "name": vmname, + "devices": {"strategy": "strict", "items": [port]}, + } + ) + ) + assert rc == VIRT_SUCCESS + + qubes.domains.refresh_cache(force=True) + assigned = qubes.domains[vmname].devices["pci"].get_assigned_devices() + ports_assigned = [ + ( + f"pci:dom0:{d.virtual_device.port_id}" + if hasattr(d, "virtual_device") + else d.port_id + ) + for d in assigned + ] + assert ports_assigned == [port] + + # Clean up + core(Module({"state": "absent", "name": vmname})) + + def test_devices_strict_multiple_with_block( qubes, vmname, request, latest_net_ports, block_device ): @@ -931,6 +1050,7 @@ def test_services_and_explicit_features_combined(qubes, vmname, request): assert qube.features[key] == "1" +@pytest.mark.xfail(reason="dropped support for wait param") def test_lifecycle_shutdown_with_and_without_wait(qubes, vmname, request): request.node.mark_vm_created(vmname) @@ -1105,3 +1225,97 @@ def test_properties_invalid_type_for_new_properties(qubes, vmname, request): ) assert rc2 == VIRT_FAILED assert "Invalid property value type" in res2 + + +def test_set_property_to_empty_string(vm, qubes): + rc, _ = core( + Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": "sys-net"}, + } + ) + ) + assert rc == VIRT_SUCCESS + assert qubes.domains[vm.name].netvm == "sys-net" + + rc, _ = core( + Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": ""}, + } + ) + ) + assert rc == VIRT_SUCCESS + assert qubes.domains[vm.name].netvm == None + + +def test_set_property_to_none(vm, qubes): + rc, _ = core( + Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": "sys-net"}, + } + ) + ) + assert rc == VIRT_SUCCESS + assert qubes.domains[vm.name].netvm == "sys-net" + + rc, _ = core( + Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": None}, + } + ) + ) + assert rc == VIRT_SUCCESS + assert qubes.domains[vm.name].netvm == None + + +def test_set_property_to_none_str(vm, qubes): + rc, _ = core( + Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": "sys-net"}, + } + ) + ) + assert rc == VIRT_SUCCESS + assert qubes.domains[vm.name].netvm == "sys-net" + + rc, _ = core( + Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": "None"}, + } + ) + ) + assert rc == VIRT_SUCCESS + assert qubes.domains[vm.name].netvm == None + + +def test_create_vm_which_is_its_self_dispvm(vmname, request, qubes): + request.node.mark_vm_created(vmname) + rc, _ = core( + Module( + { + "state": "present", + "name": vmname, + "properties": {"default_dispvm": vmname}, + } + ) + ) + + assert rc == VIRT_SUCCESS + assert qubes.domains[vmname].default_dispvm == vmname diff --git a/tests/qubes/test_module_command.py b/tests/qubes/test_module_command.py new file mode 100644 index 0000000..e2ca8d7 --- /dev/null +++ b/tests/qubes/test_module_command.py @@ -0,0 +1,265 @@ +import os +import time + +import pytest +import qubesadmin + +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_command import ( + core, +) +from tests.qubes.conftest import qubes, vmname, Module, ModuleExitWithError + + +def test_unrecognized_command(): + # Create + fake_module = Module({"command": "foo"}) + try: + core(fake_module) + except ModuleExitWithError: + assert ( + fake_module.returned_data["msg"] == "Command 'foo' not recognized" + ) + else: + pytest.fail("Module should have raised an error") + + +def test_lifecycle_full_create_start_shutdown_remove(qubes, vmname, request): + + request.node.mark_vm_created(vmname) + + # Create + fake_module = Module( + {"command": "create", "name": vmname, "vmtype": "AppVM"} + ) + core(fake_module) + assert fake_module.returned_data["created"] == vmname + assert vmname in qubes.domains + + # Start + core(Module({"command": "start", "name": vmname})) + vm = qubes.domains[vmname] + assert vm.is_running() + + # Shutdown + core(Module({"command": "shutdown", "name": vmname})) + time.sleep(5) + assert vm.is_halted() + + # Remove + core(Module({"command": "remove", "name": vmname})) + qubes.domains.refresh_cache(force=True) + assert vmname not in qubes.domains + + +def test_lifecycle_create_and_absent(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + # Create + core(Module({"command": "create", "name": vmname, "vmtype": "AppVM"})) + assert vmname in qubes.domains + + # Absent + core(Module({"command": "remove", "name": vmname})) + qubes.domains.refresh_cache(force=True) + assert vmname not in qubes.domains + + +def test_lifecycle_pause_and_resume(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) + + core(Module({"command": "pause", "name": vmname})) + assert qubes.domains[vmname].is_paused() + + core(Module({"command": "unpause", "name": vmname})) + assert qubes.domains[vmname].is_running() + + # Clean up + core(Module({"command": "destroy", "name": vmname})) + core(Module({"command": "remove", "name": vmname})) + + +def test_lifecycle_status_reporting(qubes, vmname, request): + request.node.mark_vm_created(vmname) + fake_module = Module({"command": "status", "name": vmname}) + + core(Module({"command": "create", "name": vmname, "vmtype": "AppVM"})) + core(fake_module) + assert fake_module.returned_data["status"] == "shutdown" + + core(Module({"command": "start", "name": vmname})) + core(fake_module) + assert fake_module.returned_data["status"] == "running" + + core(Module({"command": "destroy", "name": vmname})) + core(fake_module) + assert fake_module.returned_data["status"] == "shutdown" + assert qubes.domains[vmname].get_power_state() == "Halted" + + core(Module({"command": "remove", "name": vmname})) + + +def test_create_clone_vmtype_combinations(qubes, vmname, request): + request.node.mark_vm_created(vmname) + request.node.mark_vm_created(f"{vmname}-clone-appvm") + # request.node.mark_vm_created(f"{vmname}-clone-templatevm") + # request.node.mark_vm_created(f"{vmname}-clone-standalonevm") + + # Test creating / cloning from AppVM + core(Module({"command": "create", "name": vmname, "vmtype": "AppVM"})) + core( + Module( + { + "command": "create", + "name": f"{vmname}-clone-appvm", + "template": vmname, + "vmtype": "AppVM", + } + ) + ) + + assert f"{vmname}-clone-appvm" in qubes.domains + + # rc, _ = core(Module({"command": "create", "name": f"{vmname}-clone-templatevm", "template": vmname, "vmtype": "TemplateVM"})) + # assert rc == VIRT_SUCCESS + # assert f"{vmname}-clone-templatevm" in qubes.domains + + # rc, _ = core(Module({"command": "create", "name": f"{vmname}-clone-standalonevm", "template": vmname, "vmtype": "StandaloneVM"})) + # assert rc == VIRT_SUCCESS + # assert f"{vmname}-clone-standalonevm" in qubes.domains + + # Test creating / cloning from TemplateVM + core(Module({"command": "create", "name": vmname, "vmtype": "TemplateVM"})) + core( + Module( + { + "command": "create", + "name": f"{vmname}-clone-appvm", + "template": vmname, + "vmtype": "AppVM", + } + ) + ) + + assert f"{vmname}-clone-appvm" in qubes.domains + # + # rc, _ = core(Module({"command": "create", "name": f"{vmname}-clone-templatevm", "template": vmname, "vmtype": "TemplateVM"})) + # + # assert rc == VIRT_SUCCESS + # assert f"{vmname}-clone-templatevm" in qubes.domains + # + # rc, _ = core(Module({"command": "create", "name": f"{vmname}-clone-standalonevm", "template": vmname, "vmtype": "StandaloneVM"})) + # + # assert rc == VIRT_SUCCESS + # assert f"{vmname}-clone-standalonevm" in qubes.domains + # + # # Test creating / cloning from StandaloneVM + # core(Module({"command": "create", "name": vmname, "vmtype": "StandaloneVM"})) + # rc, _ = core(Module({"command": "create", "name": f"{vmname}-clone-appvm", "template": vmname, "vmtype": "AppVM"})) + # assert rc == VIRT_SUCCESS + # assert f"{vmname}-clone-appvm" in qubes.domains + # + # rc, _ = core(Module({"command": "create", "name": f"{vmname}-clone-templatevm", "template": vmname, "vmtype": "TemplateVM"})) + # assert rc == VIRT_SUCCESS + # assert f"{vmname}-clone-templatevm" in qubes.domains + # + # rc, _ = core(Module({"command": "create", "name": f"{vmname}-clone-standalonevm", "template": vmname, "vmtype": "StandaloneVM"})) + # assert rc == VIRT_SUCCESS + # assert f"{vmname}-clone-standalonevm" in qubes.domains + # + # Cleanup + core(Module({"command": "remove", "name": f"{vmname}-clone-appvm"})) + # core(Module({"state": "absent", "name": f"{vmname}-clone-templatevm"})) + # core(Module({"state": "absent", "name": f"{vmname}-clone-standalonevm"})) + core(Module({"command": "remove", "name": vmname})) + + +def test_inventory_generation_and_grouping(tmp_path, qubes): + # Use a temporary directory for inventory + os.chdir(tmp_path) + + # Create a standalone VM (by default we don't have any) + core( + Module( + { + "command": "create", + "name": "teststandalone", + "vmtype": "StandaloneVM", + "template": "debian-13-xfce", + } + ) + ) + + # 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 + fake_module = Module({"command": "createinventory"}) + core(fake_module) + assert fake_module.returned_data["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_removetags_errors_if_no_tags_present(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + # Create + fake_module = Module( + {"command": "create", "name": vmname, "vmtype": "AppVM"} + ) + core(fake_module) + assert vmname in qubes.domains + + # Remove tags + fake_module = Module({"command": "removetags", "name": vmname}) + try: + core(fake_module) + + except ModuleExitWithError: + assert ( + fake_module.returned_data["msg"] + == "Expected 'tags' parameter to be specified" + ) + else: + pytest.fail("Module should have raised an error") + + +def test_list_vms_command(vm): + fake_module = Module({"command": "list_vms", "state": "shutdown"}) + core(fake_module) + assert vm.name in fake_module.returned_data["list_vms"] + + +def test_get_states_command(vm): + fake_module = Module({"command": "get_states"}) + core(fake_module) + assert f"{vm.name} shutdown" in fake_module.returned_data["states"] diff --git a/tests/qubes/test_module_qube.py b/tests/qubes/test_module_qube.py new file mode 100644 index 0000000..a3e1d7d --- /dev/null +++ b/tests/qubes/test_module_qube.py @@ -0,0 +1,1206 @@ +import os +import pytest +import time + +from ansible_collections.qubesos.core.plugins.module_utils.qubes_module_qube import ( + QubeModule, +) +from tests.qubes.conftest import qubes, vmname, Module, ModuleExitWithError + + +def test_lifecycle_full_create_start_shutdown_remove(qubes, vmname, request): + + request.node.mark_vm_created(vmname) + + # Create + fake_module = Module({"state": "present", "name": vmname, "klass": "AppVM"}) + QubeModule(fake_module).run() + + assert fake_module.returned_data.get("changed") + assert vmname in qubes.domains + assert qubes.domains[vmname].is_halted() + assert fake_module.returned_data["created"] + + fake_module = Module({"state": "running", "name": vmname}) + QubeModule(fake_module).run() + assert qubes.domains[vmname].is_running() + assert fake_module.returned_data.get("changed") + + # Shutdown + fake_module = Module({"state": "halted", "name": vmname}) + QubeModule(fake_module).run() + assert qubes.domains[vmname].is_halted() + assert fake_module.returned_data.get("changed") + + # Remove + fake_module = Module({"state": "absent", "name": vmname}) + QubeModule(fake_module).run() + qubes.domains.refresh_cache(force=True) + assert vmname not in qubes.domains + assert fake_module.returned_data.get("changed") + + +def test_lifecycle_create_and_absent(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + # Create + fake_module = Module({"state": "present", "name": vmname}) + QubeModule(fake_module).run() + assert fake_module.returned_data.get("changed") + assert vmname in qubes.domains + + # Absent + fake_module = Module({"state": "absent", "name": vmname}) + QubeModule(fake_module).run() + qubes.domains.refresh_cache(force=True) + assert vmname not in qubes.domains + assert fake_module.returned_data.get("changed") + + +def test_lifecycle_create_running_vm(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + # Create + fake_module = Module( + {"state": "running", "name": vmname, "tags": ["a", "b"]} + ) + QubeModule(fake_module).run() + assert fake_module.returned_data.get("changed") + assert vmname in qubes.domains + vm = qubes.domains[vmname] + assert vm.is_running() + assert "a" in vm.tags + assert "b" in vm.tags + + +def test_lifecycle_pause_and_resume(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + # create and start + fake_module = Module({"state": "running", "name": vmname}) + QubeModule(fake_module).run() + assert fake_module.returned_data.get("changed") + assert vmname in qubes.domains + vm = qubes.domains[vmname] + assert vm.get_power_state() == "Running" + + fake_module = Module({"state": "paused", "name": vmname}) + QubeModule(fake_module).run() + assert fake_module.returned_data.get("changed") + vm = qubes.domains[vmname] + assert vm.get_power_state() == "Paused" + + fake_module = Module({"state": "running", "name": vmname}) + QubeModule(fake_module).run() + assert fake_module.returned_data.get("changed") + vm = qubes.domains[vmname] + assert vm.get_power_state() == "Running" + + +def test_create_clone_vmtype_combinations(qubes, vmname, request): + request.node.mark_vm_created(vmname) + appvm_clone_name = f"{vmname}-appcln" + appvm_clone_name_2 = f"{vmname}-appcln2" + template_clone_name = f"{vmname}-tplcln" + standalone_clone_name_1 = f"{vmname}-std" + standalone_clone_name_2 = f"{vmname}-std-2" + request.node.mark_vm_created(appvm_clone_name) + request.node.mark_vm_created(appvm_clone_name_2) + request.node.mark_vm_created(template_clone_name) + request.node.mark_vm_created(standalone_clone_name_1) + request.node.mark_vm_created(standalone_clone_name_2) + + # 1- create an AppVM + fake_module = Module({"state": "present", "name": vmname, "klass": "AppVM"}) + QubeModule(fake_module).run() + assert fake_module.returned_data["created"] + + # 2- Clone this AppVM + fake_module = Module( + { + "state": "present", + "name": appvm_clone_name, + "klass": "AppVM", + "clone_src": vmname, + } + ) + QubeModule(fake_module).run() + qubes.domains.refresh_cache(force=True) + assert fake_module.returned_data["created"] + assert appvm_clone_name in qubes.domains + assert qubes.domains[appvm_clone_name].klass == "AppVM" + + # 3- Clone a template + fake_module = Module( + { + "state": "present", + "name": template_clone_name, + "klass": "TemplateVM", + "clone_src": qubes.default_template, + } + ) + QubeModule(fake_module).run() + qubes.domains.refresh_cache(force=True) + assert fake_module.returned_data["created"] + assert template_clone_name in qubes.domains + assert qubes.domains[template_clone_name].klass == "TemplateVM" + + # 4- Create a Standalone VM from that template + fake_module = Module( + { + "state": "present", + "name": standalone_clone_name_1, + "klass": "StandaloneVM", + "clone_src": template_clone_name, + } + ) + QubeModule(fake_module).run() + qubes.domains.refresh_cache(force=True) + assert fake_module.returned_data["created"] + assert standalone_clone_name_1 in qubes.domains + assert qubes.domains[standalone_clone_name_1].klass == "StandaloneVM" + + # 5- Clone this StandaloneVM + fake_module = Module( + { + "state": "present", + "name": standalone_clone_name_2, + "klass": "StandaloneVM", + "clone_src": standalone_clone_name_1, + } + ) + QubeModule(fake_module).run() + qubes.domains.refresh_cache(force=True) + assert fake_module.returned_data["created"] + assert standalone_clone_name_2 in qubes.domains + assert qubes.domains[standalone_clone_name_2].klass == "StandaloneVM" + + # 6- Last check, we want to clone an AppVM (to the data on the private + # volume for example, but we want to use another template) + fake_module = Module( + { + "state": "present", + "name": appvm_clone_name_2, + "klass": "AppVM", + "clone_src": appvm_clone_name, + "template": template_clone_name, + } + ) + QubeModule(fake_module).run() + qubes.domains.refresh_cache(force=True) + assert fake_module.returned_data["created"] + assert appvm_clone_name_2 in qubes.domains + vm = qubes.domains[appvm_clone_name_2] + assert vm.template == qubes.domains[template_clone_name] + assert vm.template != qubes.domains[appvm_clone_name].template + + +def test_volumes_list_for_standalonevm(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + fake_module = Module( + { + "state": "present", + "name": vmname, + "klass": "StandaloneVM", + "clone_src": qubes.default_template, + "volumes": { + "root": {"size": 32212254720}, + "private": {"size": 10737418240, "revisions_to_keep": 123}, + }, + } + ) + QubeModule(fake_module).run() + + vm = qubes.domains[vmname] + assert vm.klass == "StandaloneVM" + assert vm.volumes["root"].size == 32212254720 + assert vm.volumes["private"].size == 10737418240 + assert vm.volumes["private"].revisions_to_keep == 123 + + # Resize root + # Change revisions to keep + fake_module = Module( + { + "state": "present", + "name": vmname, + "volumes": { + "root": { + "size": 42949672960, + }, + "private": {"revisions_to_keep": 2}, + }, + } + ) + QubeModule(fake_module).run() + + assert fake_module.returned_data["diff"] == { + "before": { + "volumes": { + "root": {"size": 32212254720}, + "private": {"revisions_to_keep": 123}, + } + }, + "after": { + "volumes": { + "root": {"size": 42949672960}, + "private": {"revisions_to_keep": 2}, + } + }, + } + + assert vm.volumes["root"].size == 42949672960 + assert vm.volumes["private"].revisions_to_keep == 2 + + +def test_properties_and_features_set_and_tag_vm(qubes, vmname, request): + request.node.mark_vm_created(vmname) + props = {"autostart": True, "debug": True, "memory": 256} + feats = {"life": "Going on", "dummy_feature": None} + tags = ["tag1", "tag2"] + params = { + "state": "present", + "name": vmname, + "properties": props, + "features": feats, + "tags": tags, + "notes": "For your eyes only", + } + fake_module = Module(params) + QubeModule(fake_module).run() + + vm = qubes.domains[vmname] + props_new_values = fake_module.returned_data["diff"]["after"]["properties"] + feats_new_values = fake_module.returned_data["diff"]["after"]["features"] + tags_new_values = fake_module.returned_data["diff"]["after"]["tags"] + + assert vm.autostart is True + assert props_new_values["autostart"] is True + + assert vm.memory is 256 + assert props_new_values["memory"] == 256 + + assert vm.debug is True + assert props_new_values["debug"] is True + + for feat, value in feats.items(): + assert vm.features.get(feat, None) == value + assert feats_new_values["life"] == "Going on" + assert "dummy_feature" not in feats_new_values + + for t in tags: + assert t in vm.tags + assert t in tags_new_values + + assert vm.get_notes() == "For your eyes only" + assert ( + fake_module.returned_data["diff"]["after"]["notes"] + == "For your eyes only" + ) + + # test if updating tags work + tags = ["tag3", "tag4"] + params = { + "state": "present", + "name": vmname, + "tags": tags, + } + fake_module = Module(params) + QubeModule(fake_module).run() + for t in tags: + assert t in qubes.domains[vmname].tags + assert t not in fake_module.returned_data["diff"]["before"]["tags"] + assert t in fake_module.returned_data["diff"]["after"]["tags"] + + fake_module = Module( + { + "state": "present", + "name": vmname, + "features": { + "life": None, + "dummy_feature": "set", + }, + } + ) + QubeModule(fake_module).run() + assert vm.features.get("life") is None + assert vm.features.get("dummy_feature") == "set" + assert fake_module.returned_data["diff"]["before"]["features"] == { + "life": "Going on", + "dummy_feature": None, + } + assert fake_module.returned_data["diff"]["after"]["features"] == { + "life": None, + "dummy_feature": "set", + } + + +def test_features_vm(qubes, vmname, request): + request.node.mark_vm_created(vmname) + feats = {"life": "Going on", "dummy_feature": None} + fake_module = Module( + { + "state": "present", + "name": vmname, + "features": feats, + } + ) + QubeModule(fake_module).run() + feats_values = fake_module.returned_data["diff"]["after"]["features"] + assert "life" in feats_values + assert "dummy_feature" not in feats_values + for feat, value in feats.items(): + assert qubes.domains[vmname].features.get(feat, None) == value + + +def test_properties_invalid_key(qubes): + # Unknown property should fail + fake_module = Module( + { + "state": "present", + "name": "dom0", + "properties": {"titi": "toto"}, + } + ) + + try: + QubeModule(fake_module).run() + except ModuleExitWithError: + assert "Invalid property" in fake_module.returned_data["msg"] + else: + pytest.fail("Module should have raised an error") + + +def test_properties_invalid_type(qubes, vmname, request): + request.node.mark_vm_created(vmname) + # Wrong type for memory + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": {"memory": "toto"}, + } + ) + try: + QubeModule(fake_module).run() + except ModuleExitWithError: + assert ( + "Failed to parse property value as int" + in fake_module.returned_data["msg"] + ) + else: + pytest.fail("Module should have raised an error") + + +def test_properties_missing_netvm(qubes, vmname): + # netvm does not exist + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": {"netvm": "toto"}, + } + ) + try: + QubeModule(fake_module).run() + except ModuleExitWithError: + assert ( + "Cannot set value 'toto' to property 'netvm': the qube doesn't exist" + in fake_module.returned_data["msg"] + ) + + # Error should be raised before trying to create the qube + assert vmname not in qubes.domains + else: + pytest.fail("Module should have raised an error") + + +def test_properties_reset_to_default_netvm(qubes, vm, netvm): + """ + Able to reset back to default netvm without needing to mention it by name + """ + default_netvm = vm.netvm + + # Change to non-default netvm + fake_module = Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": netvm.name}, + } + ) + QubeModule(fake_module).run() + + assert ( + fake_module.returned_data["diff"]["before"]["properties"]["netvm"] + == default_netvm.name + ) + assert ( + fake_module.returned_data["diff"]["after"]["properties"]["netvm"] + == netvm.name + ) + + # Ability to reset back to default netvm, whichever it is + fake_module = Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": "*default*"}, + } + ) + QubeModule(fake_module).run() + + assert ( + fake_module.returned_data["diff"]["before"]["properties"]["netvm"] + == netvm.name + ) + assert ( + fake_module.returned_data["diff"]["after"]["properties"]["netvm"] + == "*default*" + ) + assert default_netvm != netvm + + qubes.domains.refresh_cache(force=True) + assert qubes.domains[vm.name].netvm == default_netvm + assert qubes.domains[vm.name].property_is_default("netvm") + + +def test_properties_reset_to_default_mac(qubes, vm, request): + """ + Able to reset back to default mac + """ + default_mac = vm.mac + + mac = "11:22:33:44:55:66" + + # Change to non-default mac + fake_module = Module( + { + "state": "present", + "name": vm.name, + "properties": {"mac": mac}, + } + ) + QubeModule(fake_module).run() + + assert ( + fake_module.returned_data["diff"]["before"]["properties"]["mac"] + == default_mac + ) + assert ( + fake_module.returned_data["diff"]["after"]["properties"]["mac"] == mac + ) + + # Ability to reset back to default mac, whatever it is + fake_module = Module( + { + "state": "present", + "name": vm.name, + "properties": {"mac": "*default*"}, + } + ) + QubeModule(fake_module).run() + + assert ( + fake_module.returned_data["diff"]["before"]["properties"]["mac"] == mac + ) + assert ( + fake_module.returned_data["diff"]["after"]["properties"]["mac"] + == "*default*" + ) + assert default_mac != mac + + qubes.domains.refresh_cache(force=True) + assert qubes.domains[vm.name].mac == default_mac + + +def test_properties_missing_default_dispvm(qubes): + # default_dispvm does not exist + fake_module = Module( + { + "state": "present", + "name": "dom0", + "properties": {"default_dispvm": "toto"}, + } + ) + try: + QubeModule(fake_module).run() + except ModuleExitWithError: + assert ( + "Cannot set value 'toto' to property 'default_dispvm': the qube doesn't exist" + in fake_module.returned_data["msg"] + ) + else: + pytest.fail("Module should have raised an error") + + +def test_properties_invalid_volume_name_for_appvm(qubes, vmname, request): + # volume name not allowed for AppVM + fake_module = Module( + { + "state": "present", + "name": vmname, + "volumes": {"root": {"size": 10}}, + } + ) + try: + QubeModule(fake_module).run() + except ModuleExitWithError: + assert ( + "Cannot change root volume config for 'AppVM" + in fake_module.returned_data["msg"] + ) + else: + pytest.fail("Module should have raised an error") + + +def test_notes(qubes, vmname, request): + fake_module = Module( + { + "state": "present", + "name": vmname, + "notes": "For your eyes only", + } + ) + QubeModule(fake_module).run() + assert qubes.domains[vmname].get_notes() == "For your eyes only" + assert ( + fake_module.returned_data["diff"]["after"]["notes"] + == "For your eyes only" + ) + + # The 2nd call should not change the notes + QubeModule(fake_module).run() + assert not fake_module.returned_data["changed"] + + +def test_services_aliased_to_features_only(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + services = ["clocksync", "minimal-netvm"] + fake_module = Module( + {"state": "present", "name": vmname, "services": services} + ) + QubeModule(fake_module).run() + + assert fake_module.returned_data["changed"] + + # And the VM should now have service. = 1 for each + qube = qubes.domains[vmname] + for svc in services: + key = f"service.{svc}" + assert key in qube.features + assert qube.features[key] == "1" + + +def test_devices_strict_single_pci_assignment( + qubes, vmname, request, latest_net_ports +): + request.node.mark_vm_created(vmname) + port = latest_net_ports[-1] + + # Create VM in strict mode with only one PCI device + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": [port], + } + ) + QubeModule(fake_module).run() + + qubes.domains.refresh_cache(force=True) + assigned = qubes.domains[vmname].devices["pci"].get_assigned_devices() + ports_assigned = [ + ( + f"pci:dom0:{d.virtual_device.port_id}" + if hasattr(d, "virtual_device") + else d.port_id + ) + for d in assigned + ] + assert ports_assigned == [port] + + +def test_devices_explicit_strict_assignment( + qubes, vmname, request, latest_net_ports +): + request.node.mark_vm_created(vmname) + port = latest_net_ports[-1] + + # Create VM in strict mode with only one PCI device + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": {"strategy": "strict", "items": [port]}, + } + ) + QubeModule(fake_module).run() + + qubes.domains.refresh_cache(force=True) + assigned = qubes.domains[vmname].devices["pci"].get_assigned_devices() + ports_assigned = [ + ( + f"pci:dom0:{d.virtual_device.port_id}" + if hasattr(d, "virtual_device") + else d.port_id + ) + for d in assigned + ] + assert ports_assigned == [port] + + +def test_devices_strict_multiple_with_block( + qubes, vmname, request, latest_net_ports, block_device +): + request.node.mark_vm_created(vmname) + # Use both PCI net devices plus the block device + devices = [latest_net_ports[-2], latest_net_ports[-1], block_device] + + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": devices, + } + ) + QubeModule(fake_module).run() + + qubes.domains.refresh_cache(force=True) + pci_assigned = qubes.domains[vmname].devices["pci"].get_assigned_devices() + blk_assigned = qubes.domains[vmname].devices["block"].get_assigned_devices() + + pci_ports = [ + ( + f"pci:dom0:{d.virtual_device.port_id}" + if hasattr(d, "virtual_device") + else d.port_id + ) + for d in pci_assigned + ] + blk_ports = [ + f"block:dom0:{d.device.port_id}" if hasattr(d, "device") else d.port_id + for d in blk_assigned + ] + assert set(pci_ports) == set(latest_net_ports[-2:]), "PCI ports mismatch" + assert blk_ports == [block_device], "Block device not assigned correctly" + + +def test_devices_append_strategy_adds_without_removal( + qubes, vmname, request, latest_net_ports, block_device +): + request.node.mark_vm_created(vmname) + first_port = latest_net_ports[-2] + second_port = latest_net_ports[-1] + + # Initial create with first PCI port + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": [first_port], + } + ) + QubeModule(fake_module).run() + + # Re-run with append strategy: add second PCI and block, keep first + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": { + "strategy": "append", + "items": [second_port, block_device], + }, + } + ) + QubeModule(fake_module).run() + + qubes.domains.refresh_cache(force=True) + pci_ports = [ + ( + f"pci:dom0:{d.virtual_device.port_id}" + if hasattr(d, "virtual_device") + else d.port_id + ) + for d in qubes.domains[vmname].devices["pci"].get_assigned_devices() + ] + blk_ports = [ + f"block:dom0:{d.device.port_id}" if hasattr(d, "device") else d.port_id + for d in qubes.domains[vmname].devices["block"].get_assigned_devices() + ] + + # All three must now be present + assert set(pci_ports) == {first_port, second_port} + assert blk_ports == [block_device] + + +def test_devices_per_device_mode_and_options( + qubes, vmname, request, latest_net_ports +): + request.node.mark_vm_created(vmname) + port = latest_net_ports[-1] + + # Use dict format to specify a custom mode and options + entry = { + "device": port, + "mode": "required", + "options": {"no-strict-reset": True}, + } + + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": [entry], + } + ) + QubeModule(fake_module).run() + + qubes.domains.refresh_cache(force=True) + assigned = list(qubes.domains[vmname].devices["pci"].get_assigned_devices()) + assert len(assigned) == 1 + + mode = assigned[0].mode.value + opts = sorted(assigned[0].options or []) + assert mode == "required" + assert "no-strict-reset" in opts + + +def test_devices_strict_idempotent_sync( + qubes, vmname, request, latest_net_ports +): + request.node.mark_vm_created(vmname) + port = latest_net_ports[-1] + + # Initial assignment of a single PCI port + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": [port], + } + ) + QubeModule(fake_module).run() + + assert fake_module.returned_data["changed"] + + # Re-run with the same device list (strict mode) — should be a no-op + QubeModule(fake_module).run() + + # No changes on the second sync + assert not fake_module.returned_data["changed"] + + # Verify still exactly that one port is assigned + qubes.domains.refresh_cache(force=True) + assigned = qubes.domains[vmname].devices["pci"].get_assigned_devices() + ports_assigned = [ + f"pci:dom0:{(d.virtual_device.port_id if hasattr(d, 'virtual_device') else d.port_id)}" + for d in assigned + ] + assert ports_assigned == [port] + + +def test_devices_strict_unassign_all(qubes, vmname, request, latest_net_ports): + request.node.mark_vm_created(vmname) + ports = latest_net_ports[-2:] + + # Assign two PCI ports initially + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": ports, + } + ) + QubeModule(fake_module).run() + + assert fake_module.returned_data["changed"] + + qubes.domains.refresh_cache(force=True) + + # Confirm both are there + initial = { + f"pci:dom0:{(d.virtual_device.port_id if hasattr(d, 'virtual_device') else d.port_id)}" + for d in qubes.domains[vmname].devices["pci"].get_assigned_devices() + } + assert initial == set(ports) + + # Now sync to an empty list (strict default) to remove all devices + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": [], # strict empty + } + ) + QubeModule(fake_module).run() + + # Should report that it changed by removing devices + assert fake_module.returned_data["changed"] + assert ( + len(fake_module.returned_data["diff"]["before"]["devices"]["pci"]) == 2 + ) + assert fake_module.returned_data["diff"]["after"]["devices"]["pci"] == {} + + # After removal, no PCI devices should remain assigned + qubes.domains.refresh_cache(force=True) + assert ( + list(qubes.domains[vmname].devices["pci"].get_assigned_devices()) == [] + ) + + # And a second empty-sync is a no-op + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": [], + } + ) + + QubeModule(fake_module).run() + + assert not fake_module.returned_data["changed"] + + +def test_devices_unchanged(qubes, vmname, request, latest_net_ports): + request.node.mark_vm_created(vmname) + port = latest_net_ports[-1] + + # Initial assignment of a single PCI port + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": [port], + } + ) + QubeModule(fake_module).run() + + assert fake_module.returned_data["changed"] + + # Re-run without devices, should not change anything + fake_module = Module( + { + "state": "present", + "name": vmname, + "devices": [port], + } + ) + QubeModule(fake_module).run() + # No changes on the second sync + assert not fake_module.returned_data["changed"] + + # Verify still exactly that one port is assigned + qubes.domains.refresh_cache(force=True) + assigned = qubes.domains[vmname].devices["pci"].get_assigned_devices() + ports_assigned = [ + f"pci:dom0:{(d.virtual_device.port_id if hasattr(d, 'virtual_device') else d.port_id)}" + for d in assigned + ] + assert ports_assigned == [port] + + +def test_services_and_explicit_features_combined(qubes, vmname, request): + request.node.mark_vm_created(vmname) + + # Predefine an arbitrary feature + features = {"foo": "bar"} + services = ["audio", "net"] + + fake_module = Module( + { + "state": "present", + "name": vmname, + "features": features, + "services": services, + } + ) + QubeModule(fake_module).run() + + # The module should report 'features' was updated + assert fake_module.returned_data["diff"]["after"]["features"] == { + "foo": "bar", + "service.audio": "1", + "service.net": "1", + } + + # VM should have both the explicit feature and the aliased ones + qube = qubes.domains[vmname] + # features stays intact + assert qube.features.get("foo") == "bar" + # services get aliased + for svc in services: + key = f"service.{svc}" + assert key in qube.features + assert qube.features[key] == "1" + + +def test_properties_set_kernelopts(qubes, vmname, request): + request.node.mark_vm_created(vmname) + props = {"kernelopts": "swiotlb=4096 foo=bar"} + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": props, + } + ) + QubeModule(fake_module).run() + assert ( + fake_module.returned_data["diff"]["after"]["properties"]["kernelopts"] + == "swiotlb=4096 foo=bar" + ) + assert qubes.domains[vmname].kernelopts == "swiotlb=4096 foo=bar" + + +def test_properties_set_timeouts(qubes, vmname, request): + request.node.mark_vm_created(vmname) + props = {"qrexec_timeout": 123, "shutdown_timeout": 456} + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": props, + } + ) + QubeModule(fake_module).run() + assert fake_module.returned_data["diff"]["after"]["properties"] == { + "qrexec_timeout": 123, + "shutdown_timeout": 456, + } + qubes.domains.refresh_cache(force=True) + vm = qubes.domains[vmname] + assert vm.qrexec_timeout == 123 + assert vm.shutdown_timeout == 456 + + +def test_properties_set_ip_ip6_and_mac(qubes, vmname, request): + request.node.mark_vm_created(vmname) + props = { + "ip": "10.1.2.3", + "ip6": "fe80::1", + "mac": "00:11:22:33:44:55", + } + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": props, + } + ) + QubeModule(fake_module).run() + + assert fake_module.returned_data["diff"]["after"]["properties"] == { + "ip": "10.1.2.3", + "ip6": "fe80::1", + "mac": "00:11:22:33:44:55", + } + qubes.domains.refresh_cache(force=True) + vm = qubes.domains[vmname] + assert vm.ip == "10.1.2.3" + assert vm.ip6 == "fe80::1" + assert vm.mac == "00:11:22:33:44:55" + + +def test_properties_set_management_dispvm_and_audiovm( + qubes, vmname, managementdvm, audiovm, request +): + request.node.mark_vm_created(vmname) + props = {"management_dispvm": managementdvm.name, "audiovm": audiovm.name} + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": props, + } + ) + QubeModule(fake_module).run() + + assert fake_module.returned_data["diff"]["after"]["properties"] == { + "management_dispvm": managementdvm.name, + "audiovm": audiovm.name, + } + qubes.domains.refresh_cache(force=True) + vm = qubes.domains[vmname] + assert vm.management_dispvm == managementdvm + assert vm.audiovm == audiovm + + +def test_properties_set_default_user_and_guivm(qubes, vmname, guivm, request): + request.node.mark_vm_created(vmname) + props = {"default_user": "alice", "guivm": guivm.name} + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": props, + } + ) + QubeModule(fake_module).run() + + assert fake_module.returned_data["diff"]["after"]["properties"] == { + "default_user": "alice", + "guivm": guivm.name, + } + + qubes.domains.refresh_cache(force=True) + vm = qubes.domains[vmname] + assert vm.default_user == "alice" + assert vm.guivm == guivm.name + + +def test_properties_invalid_type_for_new_properties(qubes, vmname, request): + request.node.mark_vm_created(vmname) + # ip must be str, not int + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": {"ip": 12345}, + } + ) + + try: + QubeModule(fake_module).run() + except ModuleExitWithError: + assert "Invalid property value type" in fake_module.returned_data["msg"] + else: + pytest.fail("Module should have raised an error") + + # qrexec_timeout must be int, not str + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": {"qrexec_timeout": "sixty"}, + } + ) + try: + QubeModule(fake_module).run() + except ModuleExitWithError: + assert "Invalid property value type" in fake_module.returned_data["msg"] + else: + pytest.fail("Module should have raised an error") + + +def test_change_properties_should_occur_only_when_necessary( + vm, vmname, request +): + request.node.mark_vm_created(vmname) + vm.template_for_dispvms = True + + properties = { + "audiovm": "sys-net", + "autostart": True, + "bootmode": "foo", + "debug": True, + "default_dispvm": vm.name, + "default_user": "root", + "kernelopts": "swiotlb=1024", + "keyboard_layout": "es+oss+", + "label": "blue", + "mac": "de:ad:be:ef:ca:fe", + "management_dispvm": vm.name, + "maxmem": 600, + "memory": 500, + "netvm": "sys-net", + "qrexec_timeout": 180, + "shutdown_timeout": 180, + "template_for_dispvms": True, + "updateable": False, + "vcpus": 3, + } + + fake_module = Module( + { + "state": "present", + "name": vmname, + } + ) + QubeModule(fake_module).run() + assert fake_module.returned_data["created"] + + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": properties, + } + ) + QubeModule(fake_module).run() + + assert fake_module.returned_data["changed"] + assert fake_module.returned_data["diff"]["before"]["properties"] + assert fake_module.returned_data["diff"]["after"]["properties"] + + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": properties, + } + ) + QubeModule(fake_module).run() + + assert not fake_module.returned_data["diff"]["before"] + assert not fake_module.returned_data["diff"]["after"] + + fake_module = Module( + { + "state": "absent", + "name": vmname, + } + ) + QubeModule(fake_module).run() + + +def test_set_property_to_empty_string(vm, qubes): + fake_module = Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": "sys-net"}, + } + ) + QubeModule(fake_module).run() + assert qubes.domains[vm.name].netvm == "sys-net" + + fake_module = Module( + {"state": "present", "name": vm.name, "properties": {"netvm": ""}} + ) + QubeModule(fake_module).run() + assert qubes.domains[vm.name].netvm == None + + +def test_set_property_to_none(vm, qubes): + fake_module = Module( + { + "state": "present", + "name": vm.name, + "properties": {"netvm": "sys-net"}, + } + ) + QubeModule(fake_module).run() + assert qubes.domains[vm.name].netvm == "sys-net" + + fake_module = Module( + {"state": "present", "name": vm.name, "properties": {"netvm": None}} + ) + QubeModule(fake_module).run() + assert qubes.domains[vm.name].netvm == None + + +def test_create_vm_which_is_its_self_dispvm(vmname, request, qubes): + request.node.mark_vm_created(vmname) + + fake_module = Module( + { + "state": "present", + "name": vmname, + "properties": {"default_dispvm": vmname}, + } + ) + QubeModule(fake_module).run() + assert qubes.domains[vmname].default_dispvm == vmname diff --git a/update-ansible-default-strategy b/update-ansible-default-strategy index f6d646f..635cdc6 100755 --- a/update-ansible-default-strategy +++ b/update-ansible-default-strategy @@ -18,7 +18,7 @@ except configparser.NoSectionError: except configparser.NoOptionError: pass -if current_val != "qubes_proxy": - c.set("defaults", "strategy", "qubes_proxy") +if current_val != "qubesos.core.qubes_proxy": + c.set("defaults", "strategy", "qubesos.security.qubes_proxy") shutil.copy(CONFIG_FILE, CONFIG_FILE.parent / (CONFIG_FILE.name + ".rpmsave")) c.write(CONFIG_FILE.open("w"))