diff --git a/.github/workflows/docs-push.yml b/.github/workflows/docs-push.yml index 493e4d0..53662ca 100644 --- a/.github/workflows/docs-push.yml +++ b/.github/workflows/docs-push.yml @@ -37,6 +37,7 @@ init-html-short-title: Theko2fi.Multipass Collection Docs init-extra-html-theme-options: | documentation_home_url=https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/branch/main/ + init-append-conf-py: "html_js_files=[('https://eu.umami.is/script.js', {'data-website-id': '0086c656-f41e-4131-ac3f-59bf72b1c4d8','defer': 'defer'})]" publish-docs-gh-pages: # for now we won't run this on forks diff --git a/.gitignore b/.gitignore index c87ed7c..b63de4d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ github_action_key* .vscode/ dfdf.json test-playbook.yml -changelogs/.plugin-cache.yaml \ No newline at end of file +changelogs/.plugin-cache.yaml +plugins/modules/__pycache__/ +ansible.cfg \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fe7f908..c310f1d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,31 @@ Theko2Fi.Multipass Release Notes .. contents:: Topics +v0.3.0 +====== + +Release Summary +--------------- + +Release Date: 2024-02-29 + +The collection now contains module and option to manage directory mapping between host and Multipass virtual machines. + + +It also contains a Multipass driver for Molecule which allow to use Multipass instances for provisioning test resources. + + +Minor Changes +------------- + +- molecule_multipass - a Multipass driver for Molecule. +- multipass_vm - add ``mount`` option which allows to mount host directories inside multipass instances. + +New Modules +----------- + +- theko2fi.multipass.multipass_mount - Module to manage directory mapping between host and Multipass virtual machine + v0.2.3 ====== diff --git a/README.md b/README.md index ba3e015..3590f56 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ Please note that this collection is **not** developed by [Canonical](https://can - [multipass_vm_purge](https://theko2fi.github.io/ansible-multipass-collection/branch/main/multipass_vm_purge_module.html) - Module to purge all deleted Multipass virtual machines permanently - [multipass_vm_transfer_into](https://theko2fi.github.io/ansible-multipass-collection/branch/main/multipass_vm_transfer_into_module.html) - Module to copy a file into a Multipass virtual machine - [multipass_config_get](https://theko2fi.github.io/ansible-multipass-collection/branch/main/multipass_config_get_module.html) - Module to get Multipass configuration setting + - [multipass_mount](https://theko2fi.github.io/ansible-multipass-collection/branch/main/multipass_mount_module.html) - Module to manage directory mapping between host and Multipass virtual machines +* Roles: + - molecule_multipass - Molecule Multipass driver ## Installation diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 2ce5dbe..cc1b73a 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -70,3 +70,29 @@ releases: fragments: - v0.2.3.yaml release_date: '2024-01-05' + 0.3.0: + changes: + minor_changes: + - molecule_multipass - a Multipass driver for Molecule. + - multipass_vm - add ``mount`` option which allows to mount host directories + inside multipass instances. + release_summary: 'Release Date: 2024-02-29 + + + The collection now contains module and option to manage directory mapping + between host and Multipass virtual machines. + + + + It also contains a Multipass driver for Molecule which allow to use Multipass + instances for provisioning test resources. + + ' + fragments: + - v0.3.0.yaml + modules: + - description: Module to manage directory mapping between host and Multipass virtual + machine + name: multipass_mount + namespace: '' + release_date: '2024-03-01' diff --git a/changelogs/fragments/v0.3.0.yaml b/changelogs/fragments/v0.3.0.yaml new file mode 100644 index 0000000..95f5c7d --- /dev/null +++ b/changelogs/fragments/v0.3.0.yaml @@ -0,0 +1,10 @@ +release_summary: | + Release Date: 2024-02-29 + + The collection now contains module and option to manage directory mapping between host and Multipass virtual machines. + + + It also contains a Multipass driver for Molecule which allow to use Multipass instances for provisioning test resources. +minor_changes: + - multipass_vm - add ``mount`` option which allows to mount host directories inside multipass instances. + - molecule_multipass - a Multipass driver for Molecule. \ No newline at end of file diff --git a/galaxy.yml b/galaxy.yml index 8870de6..d7fea7d 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -8,7 +8,7 @@ namespace: theko2fi name: multipass # The version of the collection. Must be compatible with semantic versioning -version: 0.2.3 +version: 0.3.0 # The path to the Markdown (.md) readme file. This path is relative to the root of the collection readme: README.md diff --git a/plugins/module_utils/errors.py b/plugins/module_utils/errors.py index 6a7edf6..3afdee0 100644 --- a/plugins/module_utils/errors.py +++ b/plugins/module_utils/errors.py @@ -1,9 +1,15 @@ #!/usr/bin/python # -# Copyright (c) 2022, Kenneth KOFFI +# Copyright (c) 2024, Kenneth KOFFI (https://www.linkedin.com/in/kenneth-koffi-6b1218178/) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later +class MountExistsError(Exception): + pass + +class MountNonExistentError(Exception): + pass + class MultipassFileTransferError(Exception): pass @@ -11,4 +17,4 @@ class MultipassContentTransferError(Exception): pass class SocketError(Exception): - pass \ No newline at end of file + pass diff --git a/plugins/module_utils/multipass.py b/plugins/module_utils/multipass.py index 961202e..3c6adf4 100644 --- a/plugins/module_utils/multipass.py +++ b/plugins/module_utils/multipass.py @@ -5,8 +5,11 @@ import json import time from shlex import split as shlexsplit -from .errors import SocketError +from .errors import SocketError, MountNonExistentError, MountExistsError +def get_existing_mounts(vm_name): + vm = MultipassClient().get_vm(vm_name) + return vm.info().get('info').get(vm_name).get("mounts") # Added decorator to automatically retry on unpredictable module failures def retry_on_failure(ExceptionsToCheck, max_retries=5, delay=5, backoff=2): @@ -29,6 +32,7 @@ class MultipassVM: def __init__(self, vm_name, multipass_cmd): self.cmd = multipass_cmd self.vm_name = vm_name + # Will retry to execute info() if SocketError occurs @retry_on_failure(ExceptionsToCheck=SocketError) def info(self): @@ -48,6 +52,7 @@ def info(self): else: raise Exception("Multipass info command failed: {0}".format(stderr.decode(encoding="utf-8"))) return json.loads(stdout) + def delete(self, purge=False): cmd = [self.cmd, "delete", self.vm_name] if purge: @@ -67,8 +72,10 @@ def delete(self, purge=False): self.vm_name, stderr.decode(encoding="utf-8") ) ) + def shell(self): raise Exception("The shell command is not supported in the Multipass SDK. Consider using exec.") + def exec(self, cmd_to_execute, working_directory=""): cmd = [self.cmd, "exec", self.vm_name] if working_directory: @@ -84,18 +91,21 @@ def exec(self, cmd_to_execute, working_directory=""): if(exitcode != 0): raise Exception("Multipass exec command failed: {0}".format(stderr.decode(encoding="utf-8"))) return stdout, stderr + def stop(self): cmd = [self.cmd, "stop", self.vm_name] try: subprocess.check_output(cmd) except: raise Exception("Error stopping Multipass VM {0}".format(self.vm_name)) + def start(self): cmd = [self.cmd, "start", self.vm_name] try: subprocess.check_output(cmd) except: raise Exception("Error starting Multipass VM {0}".format(self.vm_name)) + def restart(self): cmd = [self.cmd, "restart", self.vm_name] try: @@ -109,6 +119,7 @@ class MultipassClient: """ def __init__(self, multipass_cmd="multipass"): self.cmd = multipass_cmd + def launch(self, vm_name=None, cpu=1, disk="5G", mem="1G", image=None, cloud_init=None): if(not vm_name): # similar to Multipass's VM name generator @@ -124,20 +135,24 @@ def launch(self, vm_name=None, cpu=1, disk="5G", mem="1G", image=None, cloud_ini except: raise Exception("Error launching Multipass VM {0}".format(vm_name)) return MultipassVM(vm_name, self.cmd) + def transfer(self, src, dest): cmd = [self.cmd, "transfer", src, dest] try: subprocess.check_output(cmd) except: raise Exception("Multipass transfer command failed.") + def get_vm(self, vm_name): return MultipassVM(vm_name, self.cmd) + def purge(self): cmd = [self.cmd, "purge"] try: subprocess.check_output(cmd) except: raise Exception("Purge command failed.") + def list(self): cmd = [self.cmd, "list", "--format", "json"] out = subprocess.Popen(cmd, @@ -148,6 +163,7 @@ def list(self): if(not exitcode == 0): raise Exception("Multipass list command failed: {0}".format(stderr)) return json.loads(stdout) + def find(self): cmd = [self.cmd, "find", "--format", "json"] out = subprocess.Popen(cmd, @@ -158,30 +174,52 @@ def find(self): if(not exitcode == 0): raise Exception("Multipass find command failed: {0}".format(stderr)) return json.loads(stdout) - def mount(self, src, target): - cmd = [self.cmd, "mount", src, target] - try: - subprocess.check_output(cmd) - except: - raise Exception("Multipass mount command failed.") - def unmount(self, mount): - cmd = [self.cmd, "unmount", mount] - try: - subprocess.check_output(cmd) - except: - raise Exception("Multipass unmount command failed.") + + def mount(self, src, target, mount_type='classic', uid_maps=[], gid_maps=[]): + mount_options = ["--type", mount_type] + for uid_map in uid_maps: + mount_options.extend(["--uid-map", uid_map]) + for gid_map in gid_maps: + mount_options.extend(["--gid-map", gid_map]) + cmd = [self.cmd, "mount"] + mount_options + [src, target] + out = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + _,stderr = out.communicate() + exitcode = out.wait() + stderr_cleaned = stderr.decode(encoding="utf-8").splitlines() + if(not exitcode == 0): + for error_msg in stderr_cleaned: + if 'is already mounted' in error_msg: + raise MountExistsError + raise Exception("Multipass mount command failed: {0}".format(stderr.decode(encoding="utf-8").rstrip())) + + def umount(self, mount): + cmd = [self.cmd, "umount", mount] + out = subprocess.Popen(cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + _,stderr = out.communicate() + exitcode = out.wait() + stderr_cleaned = stderr.decode(encoding="utf-8").splitlines() + if(not exitcode == 0): + for error_msg in stderr_cleaned: + if 'is not mounted' in error_msg: + raise MountNonExistentError + raise Exception("{}".format(stderr.decode(encoding="utf-8").rstrip())) + def recover(self, vm_name): cmd = [self.cmd, "recover", vm_name] try: subprocess.check_output(cmd) except: raise Exception("Multipass recover command failed.") + def suspend(self): cmd = [self.cmd, "suspend"] try: subprocess.check_output(cmd) except: raise Exception("Multipass suspend command failed.") + def get(self, key): cmd = [self.cmd, "get", key] out = subprocess.Popen(cmd, diff --git a/plugins/modules/multipass_mount.py b/plugins/modules/multipass_mount.py new file mode 100644 index 0000000..4b19b07 --- /dev/null +++ b/plugins/modules/multipass_mount.py @@ -0,0 +1,185 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# +# Copyright 2023 Kenneth KOFFI (@theko2fi) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ansible.module_utils.basic import AnsibleModule +from ansible.errors import AnsibleError +from ansible_collections.theko2fi.multipass.plugins.module_utils.multipass import MultipassClient, get_existing_mounts +from ansible_collections.theko2fi.multipass.plugins.module_utils.errors import MountExistsError, MountNonExistentError + + + +multipassclient = MultipassClient() + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name = dict(required=True, type='str'), + source = dict(type='str'), + target = dict(required=False, type='str'), + state = dict(required=False, type=str, default='present', choices=['present','absent']), + type = dict(type='str', required=False, default='classic', choices=['classic','native']), + gid_map = dict(type='list', elements='str', required=False, default=[]), + uid_map = dict(type='list', elements='str', required=False, default=[]) + ), + required_if = [('state', 'present', ['source'])] + ) + + vm_name = module.params.get('name') + src = module.params.get('source') + dest = module.params.get('target') + state = module.params.get('state') + gid_map = module.params.get('gid_map') + uid_map = module.params.get('uid_map') + mount_type = module.params.get('type') + + if state in ('present'): + dest = dest or src + target = f"{vm_name}:{dest}" + try: + multipassclient.mount(src=src, target=target, uid_maps=uid_map, gid_maps=gid_map, mount_type=mount_type ) + module.exit_json(changed=True, result=get_existing_mounts(vm_name).get(dest)) + except MountExistsError: + module.exit_json(changed=False, result=get_existing_mounts(vm_name).get(dest)) + except Exception as e: + module.fail_json(msg=str(e)) + else: + target = f"{vm_name}:{dest}" if dest else vm_name + try: + changed = False if not get_existing_mounts(vm_name=vm_name) else True + multipassclient.umount(mount=target) + module.exit_json(changed=changed) + except MountNonExistentError: + module.exit_json(changed=False) + except Exception as e: + module.fail_json(msg=str(e)) + + + +if __name__ == "__main__": + main() + + + +DOCUMENTATION = ''' +module: multipass_mount +short_description: Module to manage directory mapping between host and Multipass virtual machine +description: + - Mount a local directory in a Multipass virtual machine. + - Unmount a directory from a Multipass virtual machine. +version_added: 0.3.0 +options: + name: + type: str + description: + - Name of the virtual machine to operate on. + required: true + source: + type: str + description: + - Path of the local directory to mount + - Use with O(state=present) to mount the local directory inside the VM. + required: false + target: + type: str + description: + - target mount point (path inside the VM). + - If omitted when O(state=present), the mount point will be the same as the source's absolute path. + - If omitted when O(state=absent), all mounts will be removed from the named VM. + required: false + type: + description: + - Specify the type of mount to use. + - V(classic) mounts use technology built into Multipass. + - V(native) mounts use hypervisor and/or platform specific mounts. + type: str + default: classic + choices: + - classic + - native + gid_map: + description: + - A list of group IDs mapping for use in the mount. + - Use the Multipass CLI syntax C(:). + - File and folder ownership will be mapped from to inside the VM. + type: list + elements: str + default: [] + uid_map: + description: + - A list of user IDs mapping for use in the mount. + - Use the Multipass CLI syntax C(:). + - File and folder ownership will be mapped from to inside the VM. + type: list + elements: str + default: [] + state: + description: + - V(absent) Unmount the O(target) mount point from the VM. + - V(present) Mount the O(source) directory inside the VM. If the VM is not currently running, the directory will be mounted automatically on next boot. + type: str + default: present + choices: + - absent + - present +author: + - Kenneth KOFFI (@theko2fi) +''' + +EXAMPLES = ''' +- name: Mount '/root/data' directory from the host to '/root/data' inside the VM named 'healthy-cankerworm' + theko2fi.multipass.multipass_mount: + name: healthy-cankerworm + source: /root/data + +- name: Mount '/root/data' to '/tmp' inside the VM + theko2fi.multipass.multipass_mount: + name: healthy-cankerworm + source: /root/data + target: /tmp + state: present + +- name: Unmount '/tmp' directory from the VM + theko2fi.multipass.multipass_mount: + name: healthy-cankerworm + target: /tmp + state: absent + +- name: Mount directory, set file and folder ownership + theko2fi.multipass.multipass_mount: + name: healthy-cankerworm + source: /root/data + target: /tmp + state: present + type: classic + uid_map: + - "50:50" + - "1000:1000" + gid_map: + - "50:50" + +- name: Unmount all mount points from the 'healthy-cankerworm' VM + theko2fi.multipass.multipass_mount: + name: healthy-cankerworm + state: absent +''' + +RETURN = ''' +result: + description: + - Empty if O(state=absent). + returned: when O(state=present) + type: dict + sample: { + "gid_mappings": [ + "0:default" + ], + "source_path": "/root/tmp", + "uid_mappings": [ + "0:default" + ] + } +''' \ No newline at end of file diff --git a/plugins/modules/multipass_vm.py b/plugins/modules/multipass_vm.py index 3ac2aef..f1e9e29 100644 --- a/plugins/modules/multipass_vm.py +++ b/plugins/modules/multipass_vm.py @@ -1,127 +1,240 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- # -# Copyright 2023 Kenneth KOFFI <@theko2fi> +# Copyright 2023 Kenneth KOFFI (@theko2fi) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) from ansible.module_utils.basic import AnsibleModule -from ansible_collections.theko2fi.multipass.plugins.module_utils.multipass import MultipassClient +from ansible_collections.theko2fi.multipass.plugins.module_utils.multipass import MultipassClient, get_existing_mounts +import os, sys multipassclient = MultipassClient() -def is_vm_exists(vm_name): - vm_local = multipassclient.get_vm(vm_name=vm_name) - try: - vm_local.info() - return True - except NameError: - return False - -def get_vm_state(vm_name: str): - if is_vm_exists(vm_name=vm_name): - vm = multipassclient.get_vm(vm_name=vm_name) - vm_info = vm.info() - vm_state = vm_info.get("info").get(vm_name).get("state") - return vm_state - -def is_vm_deleted(vm_name: str): - vm_state = get_vm_state(vm_name=vm_name) - return vm_state == 'Deleted' - -def is_vm_running(vm_name: str): - vm_state = get_vm_state(vm_name=vm_name) - return vm_state == 'Running' - -def is_vm_stopped(vm_name: str): - vm_state = get_vm_state(vm_name=vm_name) - return vm_state == 'Stopped' +class AnsibleMultipassVM: + def __init__(self, name): + self.name = name + self.vm = multipassclient.get_vm(vm_name=name) -def main(): - module = AnsibleModule( - argument_spec=dict( - name = dict(required=True, type='str'), - image = dict(required=False, type=str, default='ubuntu-lts'), - cpus = dict(required=False, type=int, default=1), - memory = dict(required=False, type=str, default='1G'), - disk = dict(required=False, type=str, default='5G'), - cloud_init = dict(required=False, type=str, default=None), - state = dict(required=False, type=str, default='present'), - recreate = dict(required=False, type=bool, default=False), - purge = dict(required=False, type=bool, default=False) - ) - ) + @property + def is_vm_exists(self): + try: + self.vm.info() + return True + except NameError: + return False + + def get_vm_state(self): + vm_state = self.vm.info().get("info").get(self.name).get("state") + return vm_state + + @property + def is_vm_deleted(self) -> bool: + return self.get_vm_state() == 'Deleted' + + @property + def is_vm_running(self) -> bool: + return self.get_vm_state() == 'Running' + + @property + def is_vm_stopped(self) -> bool: + vm_state = self.get_vm_state() + return vm_state == 'Stopped' + +def compare_dictionaries(dict1, dict2): + # Check for keys present in dict1 but not in dict2 + keys_only_in_dict1 = set(dict1.keys()) - set(dict2.keys()) - vm_name = module.params.get('name') - image = module.params.get('image') - cpus = module.params.get('cpus') - state = module.params.get('state') - memory = module.params.get('memory') - disk = module.params.get('disk') - cloud_init = module.params.get('cloud_init') - purge = module.params.get('purge') - - if state in ('present', 'started'): - try: - if not is_vm_exists(vm_name): - vm = multipassclient.launch(vm_name=vm_name, image=image, cpu=cpus, mem=memory, disk=disk, cloud_init=cloud_init) - module.exit_json(changed=True, result=vm.info()) - else: - vm = multipassclient.get_vm(vm_name=vm_name) - if module.params.get('recreate'): - vm.delete(purge=True) - vm = multipassclient.launch(vm_name=vm_name, image=image, cpu=cpus, mem=memory, disk=disk, cloud_init=cloud_init) - module.exit_json(changed=True, result=vm.info()) - - if state == 'started': - # we do nothing if the VM is already running - if is_vm_running(vm_name): - module.exit_json(changed=False, result=vm.info()) - else: - # we recover the VM if it was deleted - if is_vm_deleted(vm_name): - multipassclient.recover(vm_name=vm_name) - # We start the VM if it isn't running - vm.start() - module.exit_json(changed=True, result=vm.info()) - - # we do nothing if the VM is already present - module.exit_json(changed=False, result=vm.info()) - except Exception as e: - module.fail_json(msg=str(e)) - - if state in ('absent', 'stopped'): - try: - if not is_vm_exists(vm_name=vm_name): - module.exit_json(changed=False) - else: - vm = multipassclient.get_vm(vm_name=vm_name) - - if state == 'stopped': - # we do nothing if the VM is already stopped - if is_vm_stopped(vm_name=vm_name): - module.exit_json(changed=False) - elif is_vm_deleted(vm_name=vm_name): - module.exit_json(changed=False) - else: - # stop the VM if it's running - vm.stop() - module.exit_json(changed=True) - - try: - vm.delete(purge=purge) - module.exit_json(changed=True) - except NameError: - # we do nothing if the VM doesn't exist - module.exit_json(changed=False) - except Exception as e: - module.fail_json(msg=str(e)) - + # Check for keys present in dict2 but not in dict1 + keys_only_in_dict2 = set(dict2.keys()) - set(dict1.keys()) + + # Check for common keys and compare values + keys_with_different_values = {key for key in set(dict1.keys()) & set(dict2.keys()) if dict1[key] != dict2[key]} + + if not keys_only_in_dict1 and not keys_only_in_dict2 and not keys_with_different_values: + is_different = False + else: + is_different = True + + return is_different, keys_only_in_dict1, keys_only_in_dict2, keys_with_different_values + +def build_expected_mounts_dictionnary(mounts: list): + + expected_mounts = dict() + + for mount in mounts: + source = mount.get('source') + target = mount.get('target') or source + # By default, Multipass use the current process gid and uid for mappings + # On Windows, there is no direct equivalent to the Unix-like concept of user ID (UID) + # So Multipass seems to use "-2" as default values on Windows (need to be verified) + gid_mappings = mount.get('gid_map') or [f"{os.getgid()}:default"] if sys.platform not in ("win32","win64") else ["-2:default"] + uid_mappings = mount.get('uid_map') or [f"{os.getuid()}:default"] if sys.platform not in ("win32","win64") else ["-2:default"] + expected_mounts[target] = { + "gid_mappings": gid_mappings, + "source_path": source, + "uid_mappings": uid_mappings + } + return expected_mounts + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name = dict(required=True, type='str'), + image = dict(required=False, type=str, default='ubuntu-lts'), + cpus = dict(required=False, type=int, default=1), + memory = dict(required=False, type=str, default='1G'), + disk = dict(required=False, type=str, default='5G'), + cloud_init = dict(required=False, type=str, default=None), + state = dict(required=False, type=str, default='present'), + recreate = dict(required=False, type=bool, default=False), + purge = dict(required=False, type=bool, default=False), + mounts = dict(type='list', elements='dict', suboptions=dict( + target=dict(type='str'), + source=dict(type='str', required=True), + type=dict(type='str', choices=['classic', 'native'], default='classic'), + gid_map=dict(type='list', elements='str'), + uid_map=dict(type='list', elements='str') + ) + ) + ) + ) + + vm_name = module.params.get('name') + image = module.params.get('image') + cpus = module.params.get('cpus') + state = module.params.get('state') + memory = module.params.get('memory') + disk = module.params.get('disk') + cloud_init = module.params.get('cloud_init') + purge = module.params.get('purge') + mounts = module.params.get('mounts') + + ansible_multipass = AnsibleMultipassVM(vm_name) + + if state in ('present', 'started'): + try: + # we create a new VM + if not ansible_multipass.is_vm_exists: + vm = multipassclient.launch( + vm_name=vm_name, + image=image, + cpu=cpus, + mem=memory, + disk=disk, + cloud_init=cloud_init + ) + changed = True + #module.exit_json(changed=True, result=vm.info()) + else: + vm = ansible_multipass.vm + changed = False + + if state == 'started': + # # we do nothing if the VM is already running + # if is_vm_running(vm_name): + # changed=False + # #module.exit_json(changed=False, result=vm.info()) + if not ansible_multipass.is_vm_running: + # we recover the VM if it was deleted + if ansible_multipass.is_vm_deleted: + multipassclient.recover(vm_name=vm_name) + # We start the VM if it isn't running + vm.start() + changed = True + #module.exit_json(changed=True, result=vm.info()) + + if module.params.get('recreate'): + vm.delete(purge=True) + vm = multipassclient.launch( + vm_name=vm_name, + image=image, + cpu=cpus, + mem=memory, + disk=disk, + cloud_init=cloud_init + ) + changed = True + #module.exit_json(changed=True, result=vm.info()) + + if mounts: + existing_mounts = get_existing_mounts(vm_name=vm_name) + expected_mounts = build_expected_mounts_dictionnary(mounts) + # Compare existing and expected mounts + is_different, target_paths_only_in_expected_mounts, target_paths_only_in_existing_mounts, different_mounts = compare_dictionaries(expected_mounts, existing_mounts) + if is_different: + changed = True + for target_path in target_paths_only_in_existing_mounts: + MultipassClient().umount(mount=f"{vm_name}:{target_path}") + + for target_path in target_paths_only_in_expected_mounts: + source_path = expected_mounts.get(target_path).get("source_path") + uid_mappings = expected_mounts.get(target_path).get("uid_mappings") + gid_mappings = expected_mounts.get(target_path).get("gid_mappings") + + uid_mappings_cleaned = [uid_mapping for uid_mapping in uid_mappings if not "default" in uid_mapping] + gid_mappings_cleaned = [gid_mapping for gid_mapping in gid_mappings if not "default" in gid_mapping] + + MultipassClient().mount( + src=source_path, + target=f"{vm_name}:{target_path}", + uid_maps=uid_mappings_cleaned, + gid_maps=gid_mappings_cleaned + ) + + for target_path in different_mounts: + MultipassClient().umount(mount=f"{vm_name}:{target_path}") + + source_path = expected_mounts.get(target_path).get("source_path") + uid_mappings = expected_mounts.get(target_path).get("uid_mappings") + gid_mappings = expected_mounts.get(target_path).get("gid_mappings") + + uid_mappings_cleaned = [uid_mapping for uid_mapping in uid_mappings if not "default" in uid_mapping] + gid_mappings_cleaned = [gid_mapping for gid_mapping in gid_mappings if not "default" in gid_mapping] + + MultipassClient().mount( + src=source_path, + target=f"{vm_name}:{target_path}", + uid_maps=uid_mappings_cleaned, + gid_maps=gid_mappings_cleaned + ) + + + module.exit_json(changed=changed, result=vm.info()) + except Exception as e: + module.fail_json(msg=str(e)) + + if state in ('absent', 'stopped'): + try: + if not ansible_multipass.is_vm_exists: + module.exit_json(changed=False) + else: + if state == 'stopped': + # we do nothing if the VM is already stopped + if ansible_multipass.is_vm_stopped: + module.exit_json(changed=False) + elif ansible_multipass.is_vm_deleted: + module.exit_json(changed=False) + else: + # stop the VM if it's running + ansible_multipass.vm.stop() + module.exit_json(changed=True) + + try: + ansible_multipass.vm.delete(purge=purge) + module.exit_json(changed=True) + except NameError: + # we do nothing if the VM doesn't exist + module.exit_json(changed=False) + except Exception as e: + module.fail_json(msg=str(e)) + if __name__ == "__main__": - main() + main() DOCUMENTATION = ''' @@ -131,14 +244,14 @@ def main(): short_description: Module to manage Multipass VM description: - Manage the life cycle of Multipass virtual machines (create, start, stop, - delete). + delete). options: name: description: - Name for the VM. - If it is C('primary') (the configured primary instance name), the user's - home directory is mounted inside the newly launched instance, in - C('Home'). + home directory is mounted inside the newly launched instance, in + C('Home'). required: yes type: str image: @@ -155,11 +268,57 @@ def main(): required: false type: str default: 1G + mounts: + type: list + elements: dict + required: false + description: + - Specification for mounts to be added to the VM. + - Omitting a mount that is currently applied to a VM will remove it. + version_added: 0.3.0 + suboptions: + source: + type: str + description: + - Path of the local directory to mount. + required: true + target: + type: str + description: + - target mount point (path inside the VM). + - If omitted, the mount point will be the same as the source's absolute path. + required: false + type: + type: str + description: + - Specify the type of mount to use. + - V(classic) mounts use technology built into Multipass. + - V(native) mounts use hypervisor and/or platform specific mounts. + default: classic + choices: + - classic + - native + uid_map: + description: + - A list of user IDs mapping for use in the mount. + - Use the Multipass CLI syntax C(:). + File and folder ownership will be mapped from to inside the VM. + - Omitting an uid_map that is currently applied to a mount, will remove it. + type: list + elements: str + gid_map: + description: + - A list of group IDs mapping for use in the mount. + - Use the Multipass CLI syntax C(:). + File and folder ownership will be mapped from to inside the VM. + - Omitting an gid_map that is currently applied to a mount, will remove it. + type: list + elements: str disk: description: - Disk space to allocate to the VM in format C([]). - Positive integers, in bytes, or with V(K) (kibibyte, 1024B), V(M) - (mebibyte), V(G) (gibibyte) suffix. + (mebibyte), V(G) (gibibyte) suffix. - Omitting the unit defaults to bytes. required: false type: str @@ -172,17 +331,17 @@ def main(): state: description: - C(absent) - An instance matching the specified name will be stopped and - deleted. + deleted. - C(present) - Asserts the existence of an instance matching the name and - any provided configuration parameters. If no instance matches the name, - a virtual machine will be created. + any provided configuration parameters. If no instance matches the name, + a virtual machine will be created. - 'V(started) - Asserts that the VM is first V(present), and then if the VM - is not running moves it to a running state. If the VM was deleted, it will - be recovered and started.' + is not running moves it to a running state. If the VM was deleted, it will + be recovered and started.' - 'V(stopped) - If an instance matching the specified name is running, moves - it to a stopped state.' + it to a stopped state.' - Use the O(recreate) option to always force re-creation of a matching virtual - machine, even if it is running. + machine, even if it is running. required: false type: str default: present @@ -197,7 +356,7 @@ def main(): default: false recreate: description: Use with O(state=present) or O(state=started) to force the re-creation - of an existing virtual machine. + of an existing virtual machine. type: bool default: false ''' @@ -236,7 +395,7 @@ def main(): theko2fi.multipass.multipass_vm: name: foo state: absent - + - name: Delete and purge a VM theko2fi.multipass.multipass_vm: name: foo @@ -247,47 +406,56 @@ def main(): RETURN = ''' --- result: - description: return the VM info + description: return the VM info ''' RETURN = ''' result: - description: + description: - Facts representing the current state of the virtual machine. Matches the multipass info output. - Empty if O(state=absent) or O(state=stopped). - Will be V(none) if virtual machine does not exist. - returned: success; or when O(state=started) or O(state=present), and when waiting for the VM result did not fail - type: dict - sample: '{ - "errors": [], - "info": { - "foo": { - "cpu_count": "1", - "disks": { - "sda1": { - "total": "5120710656", - "used": "2200540672" - } - }, - "image_hash": "fe102bfb3d3d917d31068dd9a4bd8fcaeb1f529edda86783f8524fdc1477ee29", - "image_release": "22.04 LTS", - "ipv4": [ - "172.23.240.92" - ], - "load": [ - 0.01, - 0.01, - 0 - ], - "memory": { - "total": 935444480, - "used": 199258112 - }, - "mounts": { - }, - "release": "Ubuntu 22.04.2 LTS", - "state": "Running" + returned: success; or when O(state=started) or O(state=present), and when waiting for the VM result did not fail + type: dict + sample: '{ + "errors": [], + "info": { + "foo": { + "cpu_count": "1", + "disks": { + "sda1": { + "total": "5120710656", + "used": "2200540672" + } + }, + "image_hash": "fe102bfb3d3d917d31068dd9a4bd8fcaeb1f529edda86783f8524fdc1477ee29", + "image_release": "22.04 LTS", + "ipv4": [ + "172.23.240.92" + ], + "load": [ + 0.01, + 0.01, + 0 + ], + "memory": { + "total": 935444480, + "used": 199258112 + }, + "mounts": { + "/home/ubuntu/data": { + "gid_mappings": [ + "0:default" + ], + "source_path": "/tmp", + "uid_mappings": [ + "0:default" + ] + } + }, + "release": "Ubuntu 22.04.2 LTS", + "state": "Running" + } } - } }' ''' \ No newline at end of file diff --git a/roles/molecule_multipass/README.md b/roles/molecule_multipass/README.md new file mode 100755 index 0000000..736b41e --- /dev/null +++ b/roles/molecule_multipass/README.md @@ -0,0 +1,30 @@ +Molecule Multipass driver +========================= + +Multipass driver for Molecule. + +Requirements +------------ + +This role requires the following tools to be installed: +- [multipass](https://multipass.run/) +- [molecule](https://ansible.readthedocs.io/projects/molecule/) + +How to use? +----------- + +This section is being written. Coming soon... + +License +------- + +GNU General Public License v3.0 or later + +Author Information +------------------ + +Created by Kenneth KOFFI, you can reach him on the following platforms: +- medium: https://theko2fi.medium.com +- github: https://github.com/theko2fi +- linkedin: https://www.linkedin.com/in/kenneth-koffi-6b1218178 + diff --git a/roles/molecule_multipass/defaults/main.yml b/roles/molecule_multipass/defaults/main.yml new file mode 100755 index 0000000..8fccc82 --- /dev/null +++ b/roles/molecule_multipass/defaults/main.yml @@ -0,0 +1,3 @@ +--- +# defaults file for theko2fi.multipass.molecule +dest: "{{ ('molecule/' + (scenario_name | default('default'))) | realpath }}" \ No newline at end of file diff --git a/roles/molecule_multipass/handlers/main.yml b/roles/molecule_multipass/handlers/main.yml new file mode 100755 index 0000000..8b738d7 --- /dev/null +++ b/roles/molecule_multipass/handlers/main.yml @@ -0,0 +1,2 @@ +--- +# handlers file for theko2fi.multipass.molecule diff --git a/roles/molecule_multipass/meta/main.yml b/roles/molecule_multipass/meta/main.yml new file mode 100755 index 0000000..c572acc --- /dev/null +++ b/roles/molecule_multipass/meta/main.yml @@ -0,0 +1,52 @@ +galaxy_info: + author: your name + description: your role description + company: your company (optional) + + # If the issue tracker for your role is not on github, uncomment the + # next line and provide a value + # issue_tracker_url: http://example.com/issue/tracker + + # Choose a valid license ID from https://spdx.org - some suggested licenses: + # - BSD-3-Clause (default) + # - MIT + # - GPL-2.0-or-later + # - GPL-3.0-only + # - Apache-2.0 + # - CC-BY-4.0 + license: license (GPL-2.0-or-later, MIT, etc) + + min_ansible_version: 2.1 + + # If this a Container Enabled role, provide the minimum Ansible Container version. + # min_ansible_container_version: + + # + # Provide a list of supported platforms, and for each platform a list of versions. + # If you don't wish to enumerate all versions for a particular platform, use 'all'. + # To view available platforms and versions (or releases), visit: + # https://galaxy.ansible.com/api/v1/platforms/ + # + # platforms: + # - name: Fedora + # versions: + # - all + # - 25 + # - name: SomePlatform + # versions: + # - all + # - 1.0 + # - 7 + # - 99.99 + + galaxy_tags: [] + # List tags for your role here, one per line. A tag is a keyword that describes + # and categorizes the role. Users find roles by searching for tags. Be sure to + # remove the '[]' above, if you add tags to this list. + # + # NOTE: A tag is limited to a single word comprised of alphanumeric characters. + # Maximum 20 tags per role. + +dependencies: [] + # List your role dependencies here, one per line. Be sure to remove the '[]' above, + # if you add dependencies to this list. diff --git a/roles/molecule_multipass/tasks/main.yml b/roles/molecule_multipass/tasks/main.yml new file mode 100755 index 0000000..7939f4e --- /dev/null +++ b/roles/molecule_multipass/tasks/main.yml @@ -0,0 +1,27 @@ +--- +# tasks file for theko2fi.multipass.molecule + +- name: Check if destination folder exists + ansible.builtin.file: + path: "{{ dest }}" + state: directory + mode: "0700" +- name: Check if destination folder is empty + ansible.builtin.find: + paths: "{{ dest }}" + register: dest_content +- name: Fail if destination folder is not empty + when: dest_content.matched > 0 + ansible.builtin.fail: + msg: Refused to expand templates as destination folder '{{ dest }}' as it already has content in it. +- name: Expand templates + vars: + dest_file: "{{ dest }}/{{ item | basename | regex_replace('\\.j2$', '') }}" + ansible.builtin.template: + src: "{{ item }}" + dest: "{{ dest_file }}" + mode: "0644" + with_fileglob: + - templates/scenario/*.j2 + loop_control: + label: "{{ dest_file | relpath }}" \ No newline at end of file diff --git a/roles/molecule_multipass/templates/scenario/converge.yml.j2 b/roles/molecule_multipass/templates/scenario/converge.yml.j2 new file mode 100755 index 0000000..a118a3b --- /dev/null +++ b/roles/molecule_multipass/templates/scenario/converge.yml.j2 @@ -0,0 +1,8 @@ +--- +- name: Converge + hosts: all + gather_facts: true + tasks: + - name: Replace this task with one that validates your content + ansible.builtin.debug: + msg: "This is the effective test" diff --git a/roles/molecule_multipass/templates/scenario/create.yml.j2 b/roles/molecule_multipass/templates/scenario/create.yml.j2 new file mode 100755 index 0000000..7a8d98d --- /dev/null +++ b/roles/molecule_multipass/templates/scenario/create.yml.j2 @@ -0,0 +1,44 @@ +--- +{% raw -%} +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + molecule_labels: + owner: molecule + tags: + - always + tasks: + - name: Set async_dir for HOME env + ansible.builtin.set_fact: + ansible_async_dir: "{{ lookup('env', 'HOME') }}/.ansible_async/" + when: (lookup('env', 'HOME')) + + - name: Create molecule instance(s) + theko2fi.multipass.multipass_vm: + name: "{{ item.name }}" + cloud_init: "{{ item.cloud_init | default(omit) }}" + cpus: "{{ item.cpu | default(omit) }}" + disk: "{{ item.disk | default(omit) }}" + image: "{{ item.image | default(omit) }}" + memory: "{{ item.memory | default(omit) }}" + recreate: "{{ item.recreate | default(omit) }}" + state: 'started' + register: server + with_items: "{{ molecule_yml.platforms }}" + loop_control: + label: "{{ item.name }}" + no_log: false + async: 7200 + poll: 0 + + - name: Wait for instance(s) creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: multipass_jobs + until: multipass_jobs.finished + retries: 300 + with_items: "{{ server.results }}" +{%- endraw %} \ No newline at end of file diff --git a/roles/molecule_multipass/templates/scenario/destroy.yml.j2 b/roles/molecule_multipass/templates/scenario/destroy.yml.j2 new file mode 100755 index 0000000..f640f1c --- /dev/null +++ b/roles/molecule_multipass/templates/scenario/destroy.yml.j2 @@ -0,0 +1,39 @@ +--- +{% raw -%} +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + tags: + - always + tasks: + - name: Set async_dir for HOME env + ansible.builtin.set_fact: + ansible_async_dir: "{{ lookup('env', 'HOME') }}/.ansible_async/" + when: (lookup('env', 'HOME')) + + - name: Destroy molecule instance(s) + theko2fi.multipass.multipass_vm: + name: "{{ item.name }}" + state: absent + purge: true + register: server + loop: "{{ molecule_yml.platforms }}" + loop_control: + label: "{{ item.name }}" + no_log: false + async: 7200 + poll: 0 + + - name: Wait for instance(s) deletion to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: multipass_jobs + until: multipass_jobs.finished + retries: 300 + loop: "{{ server.results }}" + loop_control: + label: "{{ item.item.name }}" + +{%- endraw %} \ No newline at end of file diff --git a/roles/molecule_multipass/templates/scenario/molecule.yml.j2 b/roles/molecule_multipass/templates/scenario/molecule.yml.j2 new file mode 100755 index 0000000..3b81f12 --- /dev/null +++ b/roles/molecule_multipass/templates/scenario/molecule.yml.j2 @@ -0,0 +1,15 @@ +--- +dependency: + name: galaxy +driver: + options: + managed: False + login_cmd_template: 'multipass exec {instance} -- bash' + ansible_connection_options: + ansible_connection: theko2fi.multipass.multipass +platforms: + - name: instance-molecule +provisioner: + name: ansible +verifier: + name: ansible diff --git a/roles/molecule_multipass/templates/scenario/requirements.yml.j2 b/roles/molecule_multipass/templates/scenario/requirements.yml.j2 new file mode 100755 index 0000000..00418f9 --- /dev/null +++ b/roles/molecule_multipass/templates/scenario/requirements.yml.j2 @@ -0,0 +1,2 @@ +collections: + - theko2fi.multipass diff --git a/roles/molecule_multipass/tests/inventory b/roles/molecule_multipass/tests/inventory new file mode 100755 index 0000000..878877b --- /dev/null +++ b/roles/molecule_multipass/tests/inventory @@ -0,0 +1,2 @@ +localhost + diff --git a/roles/molecule_multipass/tests/test.yml b/roles/molecule_multipass/tests/test.yml new file mode 100755 index 0000000..1d0e10a --- /dev/null +++ b/roles/molecule_multipass/tests/test.yml @@ -0,0 +1,8 @@ +--- +- name: Create a new molecule scenario + hosts: localhost + connection: local + remote_user: root + gather_facts: false + roles: + - theko2fi.multipass.molecule_multipass diff --git a/roles/molecule_multipass/vars/main.yml b/roles/molecule_multipass/vars/main.yml new file mode 100755 index 0000000..6c0f643 --- /dev/null +++ b/roles/molecule_multipass/vars/main.yml @@ -0,0 +1,2 @@ +--- +# vars file for theko2fi.multipass.molecule diff --git a/tests/integration/targets/multipass_mount/tasks/main.yml b/tests/integration/targets/multipass_mount/tasks/main.yml new file mode 100644 index 0000000..bcc51e5 --- /dev/null +++ b/tests/integration/targets/multipass_mount/tasks/main.yml @@ -0,0 +1,195 @@ +--- + +- name: Set facts + set_fact: + vm_name: "{{ 'ansible-multipass-mount-test-%0x' % ((2**32) | random) }}" + mount_source_for_test: "{{ansible_env.HOME}}/foo_bar" + +- name: Delete any existing VM + theko2fi.multipass.multipass_vm: + name: "{{vm_name}}" + state: absent + purge: true + +- name: Ensure that the VM exist for the test + theko2fi.multipass.multipass_vm: + name: "{{vm_name}}" + state: present + +- name: Create '{{ mount_source_for_test }}' directory if it does not exist + ansible.builtin.file: + path: "{{ mount_source_for_test }}" + state: directory + +- name: Mount '{{ mount_source_for_test }}' directory from the host to '{{ mount_source_for_test }}' inside the VM named '{{vm_name}}' + theko2fi.multipass.multipass_mount: + name: "{{vm_name}}" + source: "{{ mount_source_for_test }}" + register: mount_output + +- name: Assert that {{ mount_source_for_test }} has been mounted to the VM + ansible.builtin.assert: + that: + - mount_output.changed + - mount_output.result is defined + - mount_output.result.source_path == mount_source_for_test + +- name: Mount '{{ mount_source_for_test }}' to '{{ mount_source_for_test }}' (check idempotency) + theko2fi.multipass.multipass_mount: + name: "{{vm_name}}" + source: "{{ mount_source_for_test }}" + register: mount_idempotency_output + +- name: Check idempotency + ansible.builtin.assert: + that: + - mount_idempotency_output.changed == False + +- name: Mount '{{ mount_source_for_test }}' to '/data' inside the VM, set ownership + theko2fi.multipass.multipass_mount: + name: "{{vm_name}}" + source: "{{ mount_source_for_test }}" + target: /data + state: present + uid_map: + - "50:50" + - "1000:1000" + gid_map: + - "50:50" + register: mount_to_data + +- name: Assert that {{ mount_source_for_test }} has been mounted to /data + ansible.builtin.assert: + that: + - mount_to_data.changed + - mount_to_data.result is defined + - mount_to_data.result.source_path == mount_source_for_test + - mount_to_data.result.uid_mappings == ["50:50", "1000:1000"] + - mount_to_data.result.gid_mappings == ["50:50"] + +- name: Unmount '{{ mount_source_for_test }}' directory from the VM + theko2fi.multipass.multipass_mount: + name: "{{vm_name}}" + target: "{{ mount_source_for_test }}" + state: absent + register: unmount + +- name: Get infos on virtual machine + theko2fi.multipass.multipass_vm_info: + name: "{{vm_name}}" + register: unmount_info + +- name: Assert that {{ mount_source_for_test }} has been unmounted + ansible.builtin.assert: + that: + - unmount.changed + - unmount_info.result.info[vm_name].mounts[mount_source_for_test] is not defined + - unmount_info.result.info[vm_name].mounts['/data'] is defined + +- name: Unmount all mount points from the '{{vm_name}}' VM + theko2fi.multipass.multipass_mount: + name: "{{vm_name}}" + state: absent + register: unmount_all + ignore_errors: true + +# TO DO: This need to be uncommented later After multipass the underlying bug: +# 'Failed to terminate SSHFS mount process' + +# - name: Get infos on virtual machine +# theko2fi.multipass.multipass_vm_info: +# name: "{{vm_name}}" +# register: unmount_all_info + +# - name: Assert that all mount points have been removed from the VM +# ansible.builtin.assert: +# that: +# - unmount_all.changed +# - unmount_all_info.result.info[vm_name].mounts is not defined + +# - name: Unmount all directories (idempotency check) +# theko2fi.multipass.multipass_mount: +# name: "{{vm_name}}" +# state: absent +# register: unmount_all_idempotency + +# - name: Assert Unmount all directories (idempotency check) +# ansible.builtin.assert: +# that: +# - unmount_all_idempotency.changed == False + +- name: Delete the VM + theko2fi.multipass.multipass_vm: + name: "{{vm_name}}" + state: absent + purge: true + +###### For windows devices ############# + +# - name: Mount a nonexistent source directory +# theko2fi.multipass.multipass_mount: +# name: "healthy-cankerworm" +# source: "C:/Users/kenneth/Downloads/165.232.139.40xx" +# target: "/tmp" +# state: present +# register: eettt +# ignore_errors: true + +# - name: Debug +# ansible.builtin.debug: +# var: eettt + +# - name: Mount to a nonexistent instance +# theko2fi.multipass.multipass_mount: +# name: "heaaalthy-cankerworm" +# source: "C:/Users/kenneth/Downloads/165.232.139.40" +# target: "/tmp" +# state: present +# register: ee +# ignore_errors: true + +# - name: Debug +# ansible.builtin.debug: +# var: ee + +# - name: Mount a directory to an instance +# theko2fi.multipass.multipass_mount: +# name: "healthy-cankerworm" +# source: "C:/Users/kenneth/Downloads/165.232.139.40" +# target: "/tmp" +# state: present +# register: mounted + +# - name: Debug +# ansible.builtin.debug: +# var: mounted + +# - name: Mount a directory to an instance (idempotency check) +# theko2fi.multipass.multipass_mount: +# name: "healthy-cankerworm" +# source: "C:/Users/kenneth/Downloads/165.232.139.40" +# target: "/tmp" +# state: present + +# - name: Unmount specific directory +# theko2fi.multipass.multipass_mount: +# name: "healthy-cankerworm" +# target: "/tmp" +# state: absent + +# - name: Unmount nonexistent mount point +# theko2fi.multipass.multipass_mount: +# name: "healthy-cankerworm" +# target: "/tmpxx" +# state: absent + +# - name: Unmount all directories +# theko2fi.multipass.multipass_mount: +# name: "healthy-cankerworm" +# state: absent + +# - name: Unmount all directories (idempotency check) +# theko2fi.multipass.multipass_mount: +# name: "healthy-cankerworm" +# state: absent + diff --git a/tests/integration/targets/multipass_vm/tasks/main.yml b/tests/integration/targets/multipass_vm/tasks/main.yml index 82a54d7..7ad38ba 100644 --- a/tests/integration/targets/multipass_vm/tasks/main.yml +++ b/tests/integration/targets/multipass_vm/tasks/main.yml @@ -1,4 +1,9 @@ --- +- name: Ensure multipass VM named zazilapus doesn't exist + theko2fi.multipass.multipass_vm: + name: "zazilapus" + state: absent + purge: true - name: Create a multipass VM theko2fi.multipass.multipass_vm: @@ -129,4 +134,7 @@ - name: Verify that VM purge is idempotent ansible.builtin.assert: that: - - repurge_vm.changed == False \ No newline at end of file + - repurge_vm.changed == False + +- name: Include mount tests + ansible.builtin.import_tasks: mount.yml \ No newline at end of file diff --git a/tests/integration/targets/multipass_vm/tasks/mount.yml b/tests/integration/targets/multipass_vm/tasks/mount.yml new file mode 100644 index 0000000..d747798 --- /dev/null +++ b/tests/integration/targets/multipass_vm/tasks/mount.yml @@ -0,0 +1,126 @@ +--- + +- name: Ensure that VM doesn't exist + theko2fi.multipass.multipass_vm: + name: "zobosky" + state: absent + purge: true + +- name: Ensure that mount source folder exists + ansible.builtin.file: + path: "/root/tmp/testmount2" + state: directory + +- name: Mount directories to multipass VM + theko2fi.multipass.multipass_vm: + name: "zobosky" + state: present + mounts: + - source: "/root/tmp/testmount2" + target: "/tmpyy" + gid_map: ["50:50","500:25"] + uid_map: ["1000:1000"] + - source: "/tmp" + target: "/tmpxx" + register: create_vm_with_mounts + +- name: Verify that VM has been created with mounts + ansible.builtin.assert: + that: + - create_vm_with_mounts.changed + - "'/tmpxx' in create_vm_with_mounts.result.info.zobosky.mounts" + - "'/tmpyy' in create_vm_with_mounts.result.info.zobosky.mounts" + - create_vm_with_mounts.result.info.zobosky.mounts['/tmpyy'].gid_mappings == ["50:50","500:25"] + - create_vm_with_mounts.result.info.zobosky.mounts['/tmpyy'].uid_mappings == ["1000:1000"] + +- name: Reduce gid_map + theko2fi.multipass.multipass_vm: + name: "zobosky" + state: present + mounts: + - source: "/root/tmp/testmount2" + target: "/tmpyy" + gid_map: ["50:50"] + uid_map: ["1000:1000"] + - source: "/tmp" + target: "/tmpxx" + register: reduce_gid_map + +- name: Verify that gid_map reduced + ansible.builtin.assert: + that: + - reduce_gid_map.changed + - reduce_gid_map.result.info.zobosky.mounts['/tmpyy'].gid_mappings == ["50:50"] + +- name: Remove uid_map + theko2fi.multipass.multipass_vm: + name: "zobosky" + state: present + mounts: + - source: "/root/tmp/testmount2" + target: "/tmpyy" + gid_map: ["50:50"] + - source: "/tmp" + target: "/tmpxx" + register: remove_uid_map + +- name: Verify that uid_map reverted to default + ansible.builtin.assert: + that: + - remove_uid_map.changed + - remove_uid_map.result.info.zobosky.mounts['/tmpyy'].uid_mappings == ["0:default"] + +- name: change '/tmpxx' mount point to '/tmpzz' + theko2fi.multipass.multipass_vm: + name: "zobosky" + state: present + mounts: + - source: "/root/tmp/testmount2" + target: "/tmpyy" + gid_map: ["50:50"] + - source: "/tmp" + target: "/tmpzz" + register: change_mount_point + +- name: Verify that '/tmpxx' changed to '/tmpzz' + ansible.builtin.assert: + that: + - change_mount_point.changed + - "'/tmpxx' not in change_mount_point.result.info.zobosky.mounts" + - change_mount_point.result.info.zobosky.mounts['/tmpzz'].source_path == "/tmp" + +- name: Remove one mount point + theko2fi.multipass.multipass_vm: + name: "zobosky" + state: present + mounts: + - source: "/tmp" + target: "/tmpzz" + register: remove_one_mount_point + +- name: Verify that '/tmpyy' mount point has been removed + ansible.builtin.assert: + that: + - remove_one_mount_point.changed + - "'/tmpyy' not in remove_one_mount_point.result.info.zobosky.mounts" + +- name: Remove one mount point (check idempotency) + theko2fi.multipass.multipass_vm: + name: "zobosky" + state: present + mounts: + - source: "/tmp" + target: "/tmpzz" + register: remove_one_mount_point_idempotency + + +- name: Verify that '/tmpyy' mount point has been removed (idempotency) + ansible.builtin.assert: + that: + - remove_one_mount_point_idempotency.changed == False + +- name: Delete and purge VM + theko2fi.multipass.multipass_vm: + name: "zobosky" + state: absent + purge: true \ No newline at end of file