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