diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index d06dd4efea9..98df26fb8e5 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -267,6 +267,8 @@ files: maintainers: delineaKrehl tylerezimmerman $module_utils/: labels: module_utils + $module_utils/btrfs.py: + maintainers: gnfzdz $module_utils/deps.py: maintainers: russoz $module_utils/gconftool2.py: @@ -395,6 +397,8 @@ files: maintainers: catcombo $modules/bower.py: maintainers: mwarkentin + $modules/btrfs_: + maintainers: gnfzdz $modules/bundler.py: maintainers: thoiberg $modules/bzr.py: diff --git a/plugins/module_utils/btrfs.py b/plugins/module_utils/btrfs.py new file mode 100644 index 00000000000..d9f97958401 --- /dev/null +++ b/plugins/module_utils/btrfs.py @@ -0,0 +1,464 @@ +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible.module_utils.common.text.converters import to_bytes +import re +import os + + +def normalize_subvolume_path(path): + """ + Normalizes btrfs subvolume paths to ensure exactly one leading slash, no trailing slashes and no consecutive slashes. + In addition, if the path is prefixed with a leading , this value is removed. + """ + fstree_stripped = re.sub(r'^', '', path) + result = re.sub(r'/+$', '', re.sub(r'/+', '/', '/' + fstree_stripped)) + return result if len(result) > 0 else '/' + + +class BtrfsModuleException(Exception): + pass + + +class BtrfsCommands(object): + + """ + Provides access to a subset of the Btrfs command line + """ + + def __init__(self, module): + self.__module = module + self.__btrfs = self.__module.get_bin_path("btrfs", required=True) + + def filesystem_show(self): + command = "%s filesystem show -d" % (self.__btrfs) + result = self.__module.run_command(command, check_rc=True) + stdout = [x.strip() for x in result[1].splitlines()] + filesystems = [] + current = None + for line in stdout: + if line.startswith('Label'): + current = self.__parse_filesystem(line) + filesystems.append(current) + elif line.startswith('devid'): + current['devices'].append(self.__parse_filesystem_device(line)) + return filesystems + + def __parse_filesystem(self, line): + label = re.sub(r'\s*uuid:.*$', '', re.sub(r'^Label:\s*', '', line)) + id = re.sub(r'^.*uuid:\s*', '', line) + + filesystem = {} + filesystem['label'] = label.strip("'") if label != 'none' else None + filesystem['uuid'] = id + filesystem['devices'] = [] + filesystem['mountpoints'] = [] + filesystem['subvolumes'] = [] + filesystem['default_subvolid'] = None + return filesystem + + def __parse_filesystem_device(self, line): + return re.sub(r'^.*path\s', '', line) + + def subvolumes_list(self, filesystem_path): + command = "%s subvolume list -tap %s" % (self.__btrfs, filesystem_path) + result = self.__module.run_command(command, check_rc=True) + stdout = [x.split('\t') for x in result[1].splitlines()] + subvolumes = [{'id': 5, 'parent': None, 'path': '/'}] + if len(stdout) > 2: + subvolumes.extend([self.__parse_subvolume_list_record(x) for x in stdout[2:]]) + return subvolumes + + def __parse_subvolume_list_record(self, item): + return { + 'id': int(item[0]), + 'parent': int(item[2]), + 'path': normalize_subvolume_path(item[5]), + } + + def subvolume_get_default(self, filesystem_path): + command = [self.__btrfs, "subvolume", "get-default", to_bytes(filesystem_path)] + result = self.__module.run_command(command, check_rc=True) + # ID [n] ... + return int(result[1].strip().split()[1]) + + def subvolume_set_default(self, filesystem_path, subvolume_id): + command = [self.__btrfs, "subvolume", "set-default", str(subvolume_id), to_bytes(filesystem_path)] + result = self.__module.run_command(command, check_rc=True) + + def subvolume_create(self, subvolume_path): + command = [self.__btrfs, "subvolume", "create", to_bytes(subvolume_path)] + result = self.__module.run_command(command, check_rc=True) + + def subvolume_snapshot(self, snapshot_source, snapshot_destination): + command = [self.__btrfs, "subvolume", "snapshot", to_bytes(snapshot_source), to_bytes(snapshot_destination)] + result = self.__module.run_command(command, check_rc=True) + + def subvolume_delete(self, subvolume_path): + command = [self.__btrfs, "subvolume", "delete", to_bytes(subvolume_path)] + result = self.__module.run_command(command, check_rc=True) + + +class BtrfsInfoProvider(object): + + """ + Utility providing details of the currently available btrfs filesystems + """ + + def __init__(self, module): + self.__module = module + self.__btrfs_api = BtrfsCommands(module) + self.__findmnt_path = self.__module.get_bin_path("findmnt", required=True) + + def get_filesystems(self): + filesystems = self.__btrfs_api.filesystem_show() + mountpoints = self.__find_mountpoints() + for filesystem in filesystems: + device_mountpoints = self.__filter_mountpoints_for_devices(mountpoints, filesystem['devices']) + filesystem['mountpoints'] = device_mountpoints + + if len(device_mountpoints) > 0: + + # any path within the filesystem can be used to query metadata + mountpoint = device_mountpoints[0]['mountpoint'] + filesystem['subvolumes'] = self.get_subvolumes(mountpoint) + filesystem['default_subvolid'] = self.get_default_subvolume_id(mountpoint) + + return filesystems + + def get_mountpoints(self, filesystem_devices): + mountpoints = self.__find_mountpoints() + return self.__filter_mountpoints_for_devices(mountpoints, filesystem_devices) + + def get_subvolumes(self, filesystem_path): + return self.__btrfs_api.subvolumes_list(filesystem_path) + + def get_default_subvolume_id(self, filesystem_path): + return self.__btrfs_api.subvolume_get_default(filesystem_path) + + def __filter_mountpoints_for_devices(self, mountpoints, devices): + return [m for m in mountpoints if (m['device'] in devices)] + + def __find_mountpoints(self): + command = "%s -t btrfs -nvP" % self.__findmnt_path + result = self.__module.run_command(command) + mountpoints = [] + if result[0] == 0: + lines = result[1].splitlines() + for line in lines: + mountpoint = self.__parse_mountpoint_pairs(line) + mountpoints.append(mountpoint) + return mountpoints + + def __parse_mountpoint_pairs(self, line): + pattern = re.compile(r'^TARGET="(?P.*)"\s+SOURCE="(?P.*)"\s+FSTYPE="(?P.*)"\s+OPTIONS="(?P.*)"\s*$') + match = pattern.search(line) + if match is not None: + groups = match.groupdict() + + return { + 'mountpoint': groups['target'], + 'device': groups['source'], + 'subvolid': self.__extract_mount_subvolid(groups['options']), + } + else: + raise BtrfsModuleException("Failed to parse findmnt result for line: '%s'" % line) + + def __extract_mount_subvolid(self, mount_options): + for option in mount_options.split(','): + if option.startswith('subvolid='): + return int(option[len('subvolid='):]) + raise BtrfsModuleException("Failed to find subvolid for mountpoint in options '%s'" % mount_options) + + +class BtrfsSubvolume(object): + + """ + Wrapper class providing convenience methods for inspection of a btrfs subvolume + """ + + def __init__(self, filesystem, subvolume_id): + self.__filesystem = filesystem + self.__subvolume_id = subvolume_id + + def get_filesystem(self): + return self.__filesystem + + def is_mounted(self): + mountpoints = self.get_mountpoints() + return mountpoints is not None and len(mountpoints) > 0 + + def is_filesystem_root(self): + return 5 == self.__subvolume_id + + def is_filesystem_default(self): + return self.__filesystem.default_subvolid == self.__subvolume_id + + def get_mounted_path(self): + mountpoints = self.get_mountpoints() + if mountpoints is not None and len(mountpoints) > 0: + return mountpoints[0] + elif self.parent is not None: + parent = self.__filesystem.get_subvolume_by_id(self.parent) + parent_path = parent.get_mounted_path() + if parent_path is not None: + return parent_path + os.path.sep + self.name + else: + return None + + def get_mountpoints(self): + return self.__filesystem.get_mountpoints_by_subvolume_id(self.__subvolume_id) + + def get_child_relative_path(self, absolute_child_path): + """ + Get the relative path from this subvolume to the named child subvolume. + The provided parameter is expected to be normalized as by normalize_subvolume_path. + """ + path = self.path + if absolute_child_path.startswith(path): + relative = absolute_child_path[len(path):] + return re.sub(r'^/*', '', relative) + else: + raise BtrfsModuleException("Path '%s' doesn't start with '%s'" % (absolute_child_path, path)) + + def get_parent_subvolume(self): + parent_id = self.parent + return self.__filesystem.get_subvolume_by_id(parent_id) if parent_id is not None else None + + def get_child_subvolumes(self): + return self.__filesystem.get_subvolume_children(self.__subvolume_id) + + @property + def __info(self): + return self.__filesystem.get_subvolume_info_for_id(self.__subvolume_id) + + @property + def id(self): + return self.__subvolume_id + + @property + def name(self): + return self.path.split('/').pop() + + @property + def path(self): + return self.__info['path'] + + @property + def parent(self): + return self.__info['parent'] + + +class BtrfsFilesystem(object): + + """ + Wrapper class providing convenience methods for inspection of a btrfs filesystem + """ + + def __init__(self, info, provider, module): + self.__provider = provider + + # constant for module execution + self.__uuid = info['uuid'] + self.__label = info['label'] + self.__devices = info['devices'] + + # refreshable + self.__default_subvolid = info['default_subvolid'] if 'default_subvolid' in info else None + self.__update_mountpoints(info['mountpoints'] if 'mountpoints' in info else []) + self.__update_subvolumes(info['subvolumes'] if 'subvolumes' in info else []) + + @property + def uuid(self): + return self.__uuid + + @property + def label(self): + return self.__label + + @property + def default_subvolid(self): + return self.__default_subvolid + + @property + def devices(self): + return list(self.__devices) + + def refresh(self): + self.refresh_mountpoints() + self.refresh_subvolumes() + self.refresh_default_subvolume() + + def refresh_mountpoints(self): + mountpoints = self.__provider.get_mountpoints(list(self.__devices)) + self.__update_mountpoints(mountpoints) + + def __update_mountpoints(self, mountpoints): + self.__mountpoints = dict() + for i in mountpoints: + subvolid = i['subvolid'] + mountpoint = i['mountpoint'] + if subvolid not in self.__mountpoints: + self.__mountpoints[subvolid] = [] + self.__mountpoints[subvolid].append(mountpoint) + + def refresh_subvolumes(self): + filesystem_path = self.get_any_mountpoint() + if filesystem_path is not None: + subvolumes = self.__provider.get_subvolumes(filesystem_path) + self.__update_subvolumes(subvolumes) + + def __update_subvolumes(self, subvolumes): + # TODO strategy for retaining information on deleted subvolumes? + self.__subvolumes = dict() + for subvolume in subvolumes: + self.__subvolumes[subvolume['id']] = subvolume + + def refresh_default_subvolume(self): + filesystem_path = self.get_any_mountpoint() + if filesystem_path is not None: + self.__default_subvolid = self.__provider.get_default_subvolume_id(filesystem_path) + + def contains_device(self, device): + return device in self.__devices + + def contains_subvolume(self, subvolume): + return self.get_subvolume_by_name(subvolume) is not None + + def get_subvolume_by_id(self, subvolume_id): + return BtrfsSubvolume(self, subvolume_id) if subvolume_id in self.__subvolumes else None + + def get_subvolume_info_for_id(self, subvolume_id): + return self.__subvolumes[subvolume_id] if subvolume_id in self.__subvolumes else None + + def get_subvolume_by_name(self, subvolume): + for subvolume_info in self.__subvolumes.values(): + if subvolume_info['path'] == subvolume: + return BtrfsSubvolume(self, subvolume_info['id']) + return None + + def get_any_mountpoint(self): + for subvol_mountpoints in self.__mountpoints.values(): + if len(subvol_mountpoints) > 0: + return subvol_mountpoints[0] + # maybe error? + return None + + def get_any_mounted_subvolume(self): + for subvolid, subvol_mountpoints in self.__mountpoints.items(): + if len(subvol_mountpoints) > 0: + return self.get_subvolume_by_id(subvolid) + return None + + def get_mountpoints_by_subvolume_id(self, subvolume_id): + return self.__mountpoints[subvolume_id] if subvolume_id in self.__mountpoints else [] + + def get_nearest_subvolume(self, subvolume): + """Return the identified subvolume if existing, else the closest matching parent""" + subvolumes_by_path = self.__get_subvolumes_by_path() + while len(subvolume) > 1: + if subvolume in subvolumes_by_path: + return BtrfsSubvolume(self, subvolumes_by_path[subvolume]['id']) + else: + subvolume = re.sub(r'/[^/]+$', '', subvolume) + + return BtrfsSubvolume(self, 5) + + def get_mountpath_as_child(self, subvolume_name): + """Find a path to the target subvolume through a mounted ancestor""" + nearest = self.get_nearest_subvolume(subvolume_name) + if nearest.path == subvolume_name: + nearest = nearest.get_parent_subvolume() + if nearest is None or nearest.get_mounted_path() is None: + raise BtrfsModuleException("Failed to find a path '%s' through a mounted parent subvolume" % subvolume_name) + else: + return nearest.get_mounted_path() + os.path.sep + nearest.get_child_relative_path(subvolume_name) + + def get_subvolume_children(self, subvolume_id): + return [BtrfsSubvolume(self, x['id']) for x in self.__subvolumes.values() if x['parent'] == subvolume_id] + + def __get_subvolumes_by_path(self): + result = {} + for s in self.__subvolumes.values(): + path = s['path'] + result[path] = s + return result + + def is_mounted(self): + return self.__mountpoints is not None and len(self.__mountpoints) > 0 + + def get_summary(self): + subvolumes = [] + sources = self.__subvolumes.values() if self.__subvolumes is not None else [] + for subvolume in sources: + id = subvolume['id'] + subvolumes.append({ + 'id': id, + 'path': subvolume['path'], + 'parent': subvolume['parent'], + 'mountpoints': self.get_mountpoints_by_subvolume_id(id), + }) + + return { + 'default_subvolume': self.__default_subvolid, + 'devices': self.__devices, + 'label': self.__label, + 'uuid': self.__uuid, + 'subvolumes': subvolumes, + } + + +class BtrfsFilesystemsProvider(object): + + """ + Provides methods to query available btrfs filesystems + """ + + def __init__(self, module): + self.__module = module + self.__provider = BtrfsInfoProvider(module) + self.__filesystems = None + + def get_matching_filesystem(self, criteria): + if criteria['device'] is not None: + criteria['device'] = os.path.realpath(criteria['device']) + + self.__check_init() + matching = [f for f in self.__filesystems.values() if self.__filesystem_matches_criteria(f, criteria)] + if len(matching) == 1: + return matching[0] + else: + raise BtrfsModuleException("Found %d filesystems matching criteria uuid=%s label=%s device=%s" % ( + len(matching), + criteria['uuid'], + criteria['label'], + criteria['device'] + )) + + def __filesystem_matches_criteria(self, filesystem, criteria): + return ((criteria['uuid'] is None or filesystem.uuid == criteria['uuid']) and + (criteria['label'] is None or filesystem.label == criteria['label']) and + (criteria['device'] is None or filesystem.contains_device(criteria['device']))) + + def get_filesystem_for_device(self, device): + real_device = os.path.realpath(device) + self.__check_init() + for fs in self.__filesystems.values(): + if fs.contains_device(real_device): + return fs + return None + + def get_filesystems(self): + self.__check_init() + return list(self.__filesystems.values()) + + def __check_init(self): + if self.__filesystems is None: + self.__filesystems = dict() + for f in self.__provider.get_filesystems(): + uuid = f['uuid'] + self.__filesystems[uuid] = BtrfsFilesystem(f, self.__provider, self.__module) diff --git a/plugins/modules/btrfs_info.py b/plugins/modules/btrfs_info.py new file mode 100644 index 00000000000..c367b9ed108 --- /dev/null +++ b/plugins/modules/btrfs_info.py @@ -0,0 +1,109 @@ +#!/usr/bin/python + +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: btrfs_info +short_description: Query btrfs filesystem info +version_added: "6.6.0" +description: Query status of available btrfs filesystems, including uuid, label, subvolumes and mountpoints. + +author: + - Gregory Furlong (@gnfzdz) + +extends_documentation_fragment: + - community.general.attributes + - community.general.attributes.info_module +''' + +EXAMPLES = r''' + +- name: Query information about mounted btrfs filesystems + community.general.btrfs_info: + register: my_btrfs_info + +''' + +RETURN = r''' + +filesystems: + description: Summaries of the current state for all btrfs filesystems found on the target host. + type: list + elements: dict + returned: success + contains: + uuid: + description: A unique identifier assigned to the filesystem. + type: str + sample: 96c9c605-1454-49b8-a63a-15e2584c208e + label: + description: An optional label assigned to the filesystem. + type: str + sample: Tank + devices: + description: A list of devices assigned to the filesystem. + type: list + sample: + - /dev/sda1 + - /dev/sdb1 + default_subvolume: + description: The id of the filesystem's default subvolume. + type: int + sample: 5 + subvolumes: + description: A list of dicts containing metadata for all of the filesystem's subvolumes. + type: list + elements: dict + contains: + id: + description: An identifier assigned to the subvolume, unique within the containing filesystem. + type: int + sample: 256 + mountpoints: + description: Paths where the subvolume is mounted on the targeted host. + type: list + sample: ['/home'] + parent: + description: The identifier of this subvolume's parent. + type: int + sample: 5 + path: + description: The full path of the subvolume relative to the btrfs fileystem's root. + type: str + sample: /@home + +''' + + +from ansible_collections.community.general.plugins.module_utils.btrfs import BtrfsFilesystemsProvider +from ansible.module_utils.basic import AnsibleModule + + +def run_module(): + module_args = dict() + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True + ) + + provider = BtrfsFilesystemsProvider(module) + filesystems = [x.get_summary() for x in provider.get_filesystems()] + result = { + "filesystems": filesystems, + } + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/btrfs_subvolume.py b/plugins/modules/btrfs_subvolume.py new file mode 100644 index 00000000000..cd2ac6f9722 --- /dev/null +++ b/plugins/modules/btrfs_subvolume.py @@ -0,0 +1,682 @@ +#!/usr/bin/python + +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: btrfs_subvolume +short_description: Manage btrfs subvolumes +version_added: "6.6.0" + +description: Creates, updates and deletes btrfs subvolumes and snapshots. + +options: + automount: + description: + - Allow the module to temporarily mount the targeted btrfs filesystem in order to validate the current state and make any required changes. + type: bool + default: false + default: + description: + - Make the subvolume specified by I(name) the filesystem's default subvolume. + type: bool + default: false + filesystem_device: + description: + - A block device contained within the btrfs filesystem to be targeted. + - Useful when multiple btrfs filesystems are present to specify which filesystem should be targeted. + type: path + filesystem_label: + description: + - A descriptive label assigned to the btrfs filesystem to be targeted. + - Useful when multiple btrfs filesystems are present to specify which filesystem should be targeted. + type: str + filesystem_uuid: + description: + - A unique identifier assigned to the btrfs filesystem to be targeted. + - Useful when multiple btrfs filesystems are present to specify which filesystem should be targeted. + type: str + name: + description: + - Name of the subvolume/snapshot to be targeted. + required: true + type: str + recursive: + description: + - When true, indicates that parent/child subvolumes should be created/removedas necessary + to complete the operation (for I(state=present) and I(state=absent) respectively). + type: bool + default: false + snapshot_source: + description: + - Identifies the source subvolume for the created snapshot. + - Infers that the created subvolume is a snapshot. + type: str + snapshot_conflict: + description: + - Policy defining behavior when a subvolume already exists at the path of the requested snapshot. + - C(skip) - Create a snapshot only if a subvolume does not yet exist at the target location, otherwise indicate that no change is required. + Warning, this option does not yet verify that the target subvolume was generated from a snapshot of the requested source. + - C(clobber) - If a subvolume already exists at the requested location, delete it first. + This option is not idempotent and will result in a new snapshot being generated on every execution. + - C(error) - If a subvolume already exists at the requested location, return an error. + This option is not idempotent and will result in an error on replay of the module. + type: str + choices: [ skip, clobber, error ] + default: skip + state: + description: + - Indicates the current state of the targeted subvolume. + type: str + choices: [ absent, present ] + default: present + +notes: + - If any or all of the options I(filesystem_device), I(filesystem_label) or I(filesystem_uuid) parameters are provided, there is expected + to be a matching btrfs filesystem. If none are provided and only a single btrfs filesystem exists or only a single + btrfs filesystem is mounted, that filesystem will be used; otherwise, the module will take no action and return an error. + +extends_documentation_fragment: + - community.general.attributes + +attributes: + check_mode: + support: partial + details: + - In some scenarios it may erroneously report intermediate subvolumes being created. + After mounting, if a directory like file is found where the subvolume would have been created, the operation is skipped. + diff_mode: + support: none + +author: + - Gregory Furlong (@gnfzdz) +''' + +EXAMPLES = r''' + +- name: Create a @home subvolume under the root subvolume + community.general.btrfs_subvolume: + name: /@home + device: /dev/vda2 + +- name: Remove the @home subvolume if it exists + community.general.btrfs_subvolume: + name: /@home + state: absent + device: /dev/vda2 + +- name: Create a snapshot of the root subvolume named @ + community.general.btrfs_subvolume: + name: /@ + snapshot_source: / + device: /dev/vda2 + +- name: Create a snapshot of the root subvolume and make it the new default subvolume + community.general.btrfs_subvolume: + name: /@ + snapshot_source: / + default: Yes + device: /dev/vda2 + +- name: Create a snapshot of the /@ subvolume and recursively creating intermediate subvolumes as required + community.general.btrfs_subvolume: + name: /@snapshots/@2022_06_09 + snapshot_source: /@ + recursive: True + device: /dev/vda2 + +- name: Remove the /@ subvolume and recursively delete child subvolumes as required + community.general.btrfs_subvolume: + name: /@snapshots/@2022_06_09 + snapshot_source: /@ + recursive: True + device: /dev/vda2 + +''' + +RETURN = r''' + +filesystem: + description: + - A summary of the final state of the targeted btrfs filesystem. + type: dict + returned: success + contains: + uuid: + description: A unique identifier assigned to the filesystem. + returned: success + type: str + sample: 96c9c605-1454-49b8-a63a-15e2584c208e + label: + description: An optional label assigned to the filesystem. + returned: success + type: str + sample: Tank + devices: + description: A list of devices assigned to the filesystem. + returned: success + type: list + sample: + - /dev/sda1 + - /dev/sdb1 + default_subvolume: + description: The ID of the filesystem's default subvolume. + returned: success and if filesystem is mounted + type: int + sample: 5 + subvolumes: + description: A list of dicts containing metadata for all of the filesystem's subvolumes. + returned: success and if filesystem is mounted + type: list + elements: dict + contains: + id: + description: An identifier assigned to the subvolume, unique within the containing filesystem. + type: int + sample: 256 + mountpoints: + description: Paths where the subvolume is mounted on the targeted host. + type: list + sample: ['/home'] + parent: + description: The identifier of this subvolume's parent. + type: int + sample: 5 + path: + description: The full path of the subvolume relative to the btrfs fileystem's root. + type: str + sample: /@home + +modifications: + description: + - A list where each element describes a change made to the target btrfs filesystem. + type: list + returned: Success + elements: str + +target_subvolume_id: + description: + - The ID of the subvolume specified with the I(name) parameter, either pre-existing or created as part of module execution. + type: int + sample: 257 + returned: Success and subvolume exists after module execution +''' + +from ansible_collections.community.general.plugins.module_utils.btrfs import BtrfsFilesystemsProvider, BtrfsCommands, BtrfsModuleException +from ansible_collections.community.general.plugins.module_utils.btrfs import normalize_subvolume_path +from ansible.module_utils.basic import AnsibleModule +import os +import tempfile + + +class BtrfsSubvolumeModule(object): + + __BTRFS_ROOT_SUBVOLUME = '/' + __BTRFS_ROOT_SUBVOLUME_ID = 5 + __BTRFS_SUBVOLUME_INODE_NUMBER = 256 + + __CREATE_SUBVOLUME_OPERATION = 'create' + __CREATE_SNAPSHOT_OPERATION = 'snapshot' + __DELETE_SUBVOLUME_OPERATION = 'delete' + __SET_DEFAULT_SUBVOLUME_OPERATION = 'set-default' + + __UNKNOWN_SUBVOLUME_ID = '?' + + def __init__(self, module): + self.module = module + self.__btrfs_api = BtrfsCommands(module) + self.__provider = BtrfsFilesystemsProvider(module) + + # module parameters + name = self.module.params['name'] + self.__name = normalize_subvolume_path(name) if name is not None else None + self.__state = self.module.params['state'] + + self.__automount = self.module.params['automount'] + self.__default = self.module.params['default'] + self.__filesystem_device = self.module.params['filesystem_device'] + self.__filesystem_label = self.module.params['filesystem_label'] + self.__filesystem_uuid = self.module.params['filesystem_uuid'] + self.__recursive = self.module.params['recursive'] + self.__snapshot_conflict = self.module.params['snapshot_conflict'] + snapshot_source = self.module.params['snapshot_source'] + self.__snapshot_source = normalize_subvolume_path(snapshot_source) if snapshot_source is not None else None + + # execution state + self.__filesystem = None + self.__required_mounts = [] + self.__unit_of_work = [] + self.__completed_work = [] + self.__temporary_mounts = dict() + + def run(self): + error = None + try: + self.__load_filesystem() + self.__prepare_unit_of_work() + + if not self.module.check_mode: + # check required mounts & mount + if len(self.__unit_of_work) > 0: + self.__execute_unit_of_work() + self.__filesystem.refresh() + else: + # check required mounts + self.__completed_work.extend(self.__unit_of_work) + except Exception as e: + error = e + finally: + self.__cleanup_mounts() + if self.__filesystem is not None: + self.__filesystem.refresh_mountpoints() + + return (error, self.get_results()) + + # Identify the targeted filesystem and obtain the current state + def __load_filesystem(self): + if self.__has_filesystem_criteria(): + filesystem = self.__find_matching_filesytem() + else: + filesystem = self.__find_default_filesystem() + + # The filesystem must be mounted to obtain the current state (subvolumes, default, etc) + if not filesystem.is_mounted(): + if not self.__automount: + raise BtrfsModuleException( + "Target filesystem uuid=%s is not currently mounted and automount=False." + "Mount explicitly before module execution or pass automount=True" % filesystem.uuid) + elif self.module.check_mode: + # TODO is failing the module an appropriate outcome in this scenario? + raise BtrfsModuleException( + "Target filesystem uuid=%s is not currently mounted. Unable to validate the current" + "state while running with check_mode=True" % filesystem.uuid) + else: + self.__mount_subvolume_id_to_tempdir(filesystem, self.__BTRFS_ROOT_SUBVOLUME_ID) + filesystem.refresh() + self.__filesystem = filesystem + + def __has_filesystem_criteria(self): + return self.__filesystem_uuid is not None or self.__filesystem_label is not None or self.__filesystem_device is not None + + def __find_matching_filesytem(self): + criteria = { + 'uuid': self.__filesystem_uuid, + 'label': self.__filesystem_label, + 'device': self.__filesystem_device, + } + return self.__provider.get_matching_filesystem(criteria) + + def __find_default_filesystem(self): + filesystems = self.__provider.get_filesystems() + filesystem = None + + if len(filesystems) == 1: + filesystem = filesystems[0] + else: + mounted_filesystems = [x for x in filesystems if x.is_mounted()] + if len(mounted_filesystems) == 1: + filesystem = mounted_filesystems[0] + + if filesystem is not None: + return filesystem + else: + raise BtrfsModuleException( + "Failed to automatically identify targeted filesystem. " + "No explicit device indicated and found %d available filesystems." % len(filesystems) + ) + + # Prepare unit of work + def __prepare_unit_of_work(self): + if self.__state == "present": + if self.__snapshot_source is None: + self.__prepare_subvolume_present() + else: + self.__prepare_snapshot_present() + + if self.__default: + self.__prepare_set_default() + elif self.__state == "absent": + self.__prepare_subvolume_absent() + + def __prepare_subvolume_present(self): + subvolume = self.__filesystem.get_subvolume_by_name(self.__name) + if subvolume is None: + self.__prepare_before_create_subvolume(self.__name) + self.__stage_create_subvolume(self.__name) + + def __prepare_before_create_subvolume(self, subvolume_name): + closest_parent = self.__filesystem.get_nearest_subvolume(subvolume_name) + self.__stage_required_mount(closest_parent) + if self.__recursive: + self.__prepare_create_intermediates(closest_parent, subvolume_name) + + def __prepare_create_intermediates(self, closest_subvolume, subvolume_name): + relative_path = closest_subvolume.get_child_relative_path(self.__name) + missing_subvolumes = [x for x in relative_path.split(os.path.sep) if len(x) > 0] + if len(missing_subvolumes) > 1: + current = closest_subvolume.path + for s in missing_subvolumes[:-1]: + separator = os.path.sep if current[-1] != os.path.sep else "" + current = current + separator + s + self.__stage_create_subvolume(current, True) + + def __prepare_snapshot_present(self): + source_subvolume = self.__filesystem.get_subvolume_by_name(self.__snapshot_source) + subvolume = self.__filesystem.get_subvolume_by_name(self.__name) + subvolume_exists = subvolume is not None + + if subvolume_exists: + if self.__snapshot_conflict == "skip": + # No change required + return + elif self.__snapshot_conflict == "error": + raise BtrfsModuleException("Target subvolume=%s already exists and snapshot_conflict='error'" % self.__name) + + if source_subvolume is None: + raise BtrfsModuleException("Source subvolume %s does not exist" % self.__snapshot_source) + elif subvolume is not None and source_subvolume.id == subvolume.id: + raise BtrfsModuleException("Snapshot source and target are the same.") + else: + self.__stage_required_mount(source_subvolume) + + if subvolume_exists and self.__snapshot_conflict == "clobber": + self.__prepare_delete_subvolume_tree(subvolume) + elif not subvolume_exists: + self.__prepare_before_create_subvolume(self.__name) + + self.__stage_create_snapshot(source_subvolume, self.__name) + + def __prepare_subvolume_absent(self): + subvolume = self.__filesystem.get_subvolume_by_name(self.__name) + if subvolume is not None: + self.__prepare_delete_subvolume_tree(subvolume) + + def __prepare_delete_subvolume_tree(self, subvolume): + if subvolume.is_filesystem_root(): + raise BtrfsModuleException("Can not delete the filesystem's root subvolume") + if not self.__recursive and len(subvolume.get_child_subvolumes()) > 0: + raise BtrfsModuleException("Subvolume targeted for deletion %s has children and recursive=False." + "Either explicitly delete the child subvolumes first or pass " + "parameter recursive=True." % subvolume.path) + + self.__stage_required_mount(subvolume.get_parent_subvolume()) + queue = self.__prepare_recursive_delete_order(subvolume) if self.__recursive else [subvolume] + # prepare unit of work + for s in queue: + if s.is_mounted(): + # TODO potentially unmount the subvolume if automount=True ? + raise BtrfsModuleException("Can not delete mounted subvolume=%s" % s.path) + if s.is_filesystem_default(): + self.__stage_set_default_subvolume(self.__BTRFS_ROOT_SUBVOLUME, self.__BTRFS_ROOT_SUBVOLUME_ID) + self.__stage_delete_subvolume(s) + + def __prepare_recursive_delete_order(self, subvolume): + """Return the subvolume and all descendents as a list, ordered so that descendents always occur before their ancestors""" + pending = [subvolume] + ordered = [] + while len(pending) > 0: + next = pending.pop() + ordered.append(next) + pending.extend(next.get_child_subvolumes()) + ordered.reverse() # reverse to ensure children are deleted before their parent + return ordered + + def __prepare_set_default(self): + subvolume = self.__filesystem.get_subvolume_by_name(self.__name) + subvolume_id = subvolume.id if subvolume is not None else None + + if self.__filesystem.default_subvolid != subvolume_id: + self.__stage_set_default_subvolume(self.__name, subvolume_id) + + # Stage operations to the unit of work + def __stage_required_mount(self, subvolume): + if subvolume.get_mounted_path() is None: + if self.__automount: + self.__required_mounts.append(subvolume) + else: + raise BtrfsModuleException("The requested changes will require the subvolume '%s' to be mounted, but automount=False" % subvolume.path) + + def __stage_create_subvolume(self, subvolume_path, intermediate=False): + """ + Add required creation of an intermediate subvolume to the unit of work + If intermediate is true, the action will be skipped if a directory like file is found at target + after mounting a parent subvolume + """ + self.__unit_of_work.append({ + 'action': self.__CREATE_SUBVOLUME_OPERATION, + 'target': subvolume_path, + 'intermediate': intermediate, + }) + + def __stage_create_snapshot(self, source_subvolume, target_subvolume_path): + """Add creation of a snapshot from source to target to the unit of work""" + self.__unit_of_work.append({ + 'action': self.__CREATE_SNAPSHOT_OPERATION, + 'source': source_subvolume.path, + 'source_id': source_subvolume.id, + 'target': target_subvolume_path, + }) + + def __stage_delete_subvolume(self, subvolume): + """Add deletion of the target subvolume to the unit of work""" + self.__unit_of_work.append({ + 'action': self.__DELETE_SUBVOLUME_OPERATION, + 'target': subvolume.path, + 'target_id': subvolume.id, + }) + + def __stage_set_default_subvolume(self, subvolume_path, subvolume_id=None): + """Add update of the filesystem's default subvolume to the unit of work""" + self.__unit_of_work.append({ + 'action': self.__SET_DEFAULT_SUBVOLUME_OPERATION, + 'target': subvolume_path, + 'target_id': subvolume_id, + }) + + # Execute the unit of work + def __execute_unit_of_work(self): + self.__check_required_mounts() + for op in self.__unit_of_work: + if op['action'] == self.__CREATE_SUBVOLUME_OPERATION: + self.__execute_create_subvolume(op) + elif op['action'] == self.__CREATE_SNAPSHOT_OPERATION: + self.__execute_create_snapshot(op) + elif op['action'] == self.__DELETE_SUBVOLUME_OPERATION: + self.__execute_delete_subvolume(op) + elif op['action'] == self.__SET_DEFAULT_SUBVOLUME_OPERATION: + self.__execute_set_default_subvolume(op) + else: + raise ValueError("Unknown operation type '%s'" % op['action']) + + def __execute_create_subvolume(self, operation): + target_mounted_path = self.__filesystem.get_mountpath_as_child(operation['target']) + if not self.__is_existing_directory_like(target_mounted_path): + self.__btrfs_api.subvolume_create(target_mounted_path) + self.__completed_work.append(operation) + + def __execute_create_snapshot(self, operation): + source_subvolume = self.__filesystem.get_subvolume_by_name(operation['source']) + source_mounted_path = source_subvolume.get_mounted_path() + target_mounted_path = self.__filesystem.get_mountpath_as_child(operation['target']) + + self.__btrfs_api.subvolume_snapshot(source_mounted_path, target_mounted_path) + self.__completed_work.append(operation) + + def __execute_delete_subvolume(self, operation): + target_mounted_path = self.__filesystem.get_mountpath_as_child(operation['target']) + self.__btrfs_api.subvolume_delete(target_mounted_path) + self.__completed_work.append(operation) + + def __execute_set_default_subvolume(self, operation): + target = operation['target'] + target_id = operation['target_id'] + + if target_id is None: + target_subvolume = self.__filesystem.get_subvolume_by_name(target) + + if target_subvolume is None: + self.__filesystem.refresh() # the target may have been created earlier in module execution + target_subvolume = self.__filesystem.get_subvolume_by_name(target) + + if target_subvolume is None: + raise BtrfsModuleException("Failed to find existing subvolume '%s'" % target) + else: + target_id = target_subvolume.id + + self.__btrfs_api.subvolume_set_default(self.__filesystem.get_any_mountpoint(), target_id) + self.__completed_work.append(operation) + + def __is_existing_directory_like(self, path): + return os.path.exists(path) and ( + os.path.isdir(path) or + os.stat(path).st_ino == self.__BTRFS_SUBVOLUME_INODE_NUMBER + ) + + def __check_required_mounts(self): + filtered = self.__filter_child_subvolumes(self.__required_mounts) + if len(filtered) > 0: + for subvolume in filtered: + self.__mount_subvolume_id_to_tempdir(self.__filesystem, subvolume.id) + self.__filesystem.refresh_mountpoints() + + def __filter_child_subvolumes(self, subvolumes): + """Filter the provided list of subvolumes to remove any that are a child of another item in the list""" + filtered = [] + last = None + ordered = sorted(subvolumes, key=lambda x: x.path) + for next in ordered: + if last is None or not next.path[0:len(last)] == last: + filtered.append(next) + last = next.path + return filtered + + # Create/cleanup temporary mountpoints + def __mount_subvolume_id_to_tempdir(self, filesystem, subvolid): + # this check should be redundant + if self.module.check_mode or not self.__automount: + raise BtrfsModuleException("Unable to temporarily mount required subvolumes" + "with automount=%s and check_mode=%s" % (self.__automount, self.module.check_mode)) + + cache_key = "%s:%d" % (filesystem.uuid, subvolid) + # The subvolume was already mounted, so return the current path + if cache_key in self.__temporary_mounts: + return self.__temporary_mounts[cache_key] + + device = filesystem.devices[0] + mountpoint = tempfile.mkdtemp(dir="/tmp") + self.__temporary_mounts[cache_key] = mountpoint + + mount = self.module.get_bin_path("mount", required=True) + command = "%s -o noatime,subvolid=%d %s %s " % (mount, + subvolid, + device, + mountpoint) + result = self.module.run_command(command, check_rc=True) + + return mountpoint + + def __cleanup_mounts(self): + for key in self.__temporary_mounts.keys(): + self.__cleanup_mount(self.__temporary_mounts[key]) + + def __cleanup_mount(self, mountpoint): + umount = self.module.get_bin_path("umount", required=True) + result = self.module.run_command("%s %s" % (umount, mountpoint)) + if result[0] == 0: + rmdir = self.module.get_bin_path("rmdir", required=True) + self.module.run_command("%s %s" % (rmdir, mountpoint)) + + # Format and return results + def get_results(self): + target = self.__filesystem.get_subvolume_by_name(self.__name) + return dict( + changed=len(self.__completed_work) > 0, + filesystem=self.__filesystem.get_summary(), + modifications=self.__get_formatted_modifications(), + target_subvolume_id=(target.id if target is not None else None) + ) + + def __get_formatted_modifications(self): + return [self.__format_operation_result(op) for op in self.__completed_work] + + def __format_operation_result(self, operation): + action_type = operation['action'] + if action_type == self.__CREATE_SUBVOLUME_OPERATION: + return self.__format_create_subvolume_result(operation) + elif action_type == self.__CREATE_SNAPSHOT_OPERATION: + return self.__format_create_snapshot_result(operation) + elif action_type == self.__DELETE_SUBVOLUME_OPERATION: + return self.__format_delete_subvolume_result(operation) + elif action_type == self.__SET_DEFAULT_SUBVOLUME_OPERATION: + return self.__format_set_default_subvolume_result(operation) + else: + raise ValueError("Unknown operation type '%s'" % operation['action']) + + def __format_create_subvolume_result(self, operation): + target = operation['target'] + target_subvolume = self.__filesystem.get_subvolume_by_name(target) + target_id = target_subvolume.id if target_subvolume is not None else self.__UNKNOWN_SUBVOLUME_ID + return "Created subvolume '%s' (%s)" % (target, target_id) + + def __format_create_snapshot_result(self, operation): + source = operation['source'] + source_id = operation['source_id'] + + target = operation['target'] + target_subvolume = self.__filesystem.get_subvolume_by_name(target) + target_id = target_subvolume.id if target_subvolume is not None else self.__UNKNOWN_SUBVOLUME_ID + return "Created snapshot '%s' (%s) from '%s' (%s)" % (target, target_id, source, source_id) + + def __format_delete_subvolume_result(self, operation): + target = operation['target'] + target_id = operation['target_id'] + return "Deleted subvolume '%s' (%s)" % (target, target_id) + + def __format_set_default_subvolume_result(self, operation): + target = operation['target'] + if 'target_id' in operation: + target_id = operation['target_id'] + else: + target_subvolume = self.__filesystem.get_subvolume_by_name(target) + target_id = target_subvolume.id if target_subvolume is not None else self.__UNKNOWN_SUBVOLUME_ID + return "Updated default subvolume to '%s' (%s)" % (target, target_id) + + +def run_module(): + module_args = dict( + automount=dict(type='bool', required=False, default=False), + default=dict(type='bool', required=False, default=False), + filesystem_device=dict(type='path', required=False), + filesystem_label=dict(type='str', required=False), + filesystem_uuid=dict(type='str', required=False), + name=dict(type='str', required=True), + recursive=dict(type='bool', default=False), + state=dict(type='str', required=False, default='present', choices=['present', 'absent']), + snapshot_source=dict(type='str', required=False), + snapshot_conflict=dict(type='str', required=False, default='skip', choices=['skip', 'clobber', 'error']) + ) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True + ) + + subvolume = BtrfsSubvolumeModule(module) + error, result = subvolume.run() + if error is not None: + module.fail_json(str(error), **result) + else: + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/btrfs_subvolume/aliases b/tests/integration/targets/btrfs_subvolume/aliases new file mode 100644 index 00000000000..914c36ad3d9 --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/aliases @@ -0,0 +1,12 @@ +# Copyright (c) Ansible Projec +# 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 + +azp/posix/3 +azp/posix/vm +destructive +needs/privileged +skip/aix +skip/freebsd +skip/osx +skip/macos diff --git a/tests/integration/targets/btrfs_subvolume/defaults/main.yml b/tests/integration/targets/btrfs_subvolume/defaults/main.yml new file mode 100644 index 00000000000..52c88d5de14 --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/defaults/main.yml @@ -0,0 +1,20 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +btrfs_subvolume_single_configs: +- file: "/tmp/disks0.img" + loop: "/dev/loop95" +btrfs_subvolume_multiple_configs: +- file: "/tmp/diskm0.img" + loop: "/dev/loop97" +- file: "/tmp/diskm1.img" + loop: "/dev/loop98" +- file: "/tmp/diskm2.img" + loop: "/dev/loop99" +btrfs_subvolume_configs: "{{ btrfs_subvolume_single_configs + btrfs_subvolume_multiple_configs }}" +btrfs_subvolume_single_devices: "{{ btrfs_subvolume_single_configs | map(attribute='loop') }}" +btrfs_subvolume_single_label: "single" +btrfs_subvolume_multiple_devices: "{{ btrfs_subvolume_multiple_configs | map(attribute='loop') }}" +btrfs_subvolume_multiple_label: "multiple" diff --git a/tests/integration/targets/btrfs_subvolume/tasks/main.yml b/tests/integration/targets/btrfs_subvolume/tasks/main.yml new file mode 100644 index 00000000000..d472704401a --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/main.yml @@ -0,0 +1,29 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Install required packages + ansible.builtin.package: + name: + - btrfs-progs # btrfs userspace + - util-linux # losetup + ignore_errors: True + register: btrfs_installed + +- name: Execute integration tests tests + block: + - ansible.builtin.include_tasks: 'setup.yml' + + - name: "Execute test scenario for single device filesystem" + ansible.builtin.include_tasks: 'run_filesystem_tests.yml' + vars: + btrfs_subvolume_target_device: "{{ btrfs_subvolume_single_devices | first }}" + btrfs_subvolume_target_label: "{{ btrfs_subvolume_single_label }}" + + - name: "Execute test scenario for multiple device configuration" + ansible.builtin.include_tasks: 'run_filesystem_tests.yml' + vars: + btrfs_subvolume_target_device: "{{ btrfs_subvolume_multiple_devices | first }}" + btrfs_subvolume_target_label: "{{ btrfs_subvolume_multiple_label }}" + when: btrfs_installed is success diff --git a/tests/integration/targets/btrfs_subvolume/tasks/run_common_tests.yml b/tests/integration/targets/btrfs_subvolume/tasks/run_common_tests.yml new file mode 100644 index 00000000000..013ec50bf0d --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/run_common_tests.yml @@ -0,0 +1,15 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- ansible.builtin.include_tasks: 'test_subvolume_simple.yml' +- ansible.builtin.include_tasks: 'test_subvolume_nested.yml' +- ansible.builtin.include_tasks: 'test_subvolume_recursive.yml' +- ansible.builtin.include_tasks: 'test_subvolume_default.yml' + +- ansible.builtin.include_tasks: 'test_snapshot_skip.yml' +- ansible.builtin.include_tasks: 'test_snapshot_clobber.yml' +- ansible.builtin.include_tasks: 'test_snapshot_error.yml' + +- ansible.builtin.include_tasks: 'test_subvolume_whitespace.yml' diff --git a/tests/integration/targets/btrfs_subvolume/tasks/run_filesystem_tests.yml b/tests/integration/targets/btrfs_subvolume/tasks/run_filesystem_tests.yml new file mode 100644 index 00000000000..0ea3fa6660b --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/run_filesystem_tests.yml @@ -0,0 +1,32 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- ansible.builtin.include_tasks: 'test_filesystem_matching.yml' + +- name: "Execute all test scenario for unmounted filesystem" + ansible.builtin.include_tasks: 'run_common_tests.yml' + +- name: "Execute test scenarios where non-root subvolume is mounted" + block: + - name: Create subvolume '/nonroot' + community.general.btrfs_subvolume: + automount: Yes + name: "/nonroot" + filesystem_label: "{{ btrfs_subvolume_target_label }}" + state: "present" + register: nonroot + - name: "Mount subvolume '/nonroot'" + ansible.posix.mount: + src: "{{ nonroot.filesystem.devices | first }}" + path: /mnt + opts: "subvolid={{ nonroot.target_subvolume_id }}" + fstype: btrfs + state: mounted + - name: "Run tests for explicit, mounted single device configuration" + ansible.builtin.include_tasks: 'run_common_tests.yml' + - name: "Unmount subvolume /nonroot" + ansible.posix.mount: + path: /mnt + state: absent diff --git a/tests/integration/targets/btrfs_subvolume/tasks/setup.yml b/tests/integration/targets/btrfs_subvolume/tasks/setup.yml new file mode 100644 index 00000000000..f5bbdf9c549 --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/setup.yml @@ -0,0 +1,37 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: "Create file {{ item.file }} to back loop device {{ item.loop }}" + ansible.builtin.command: + cmd: "dd if=/dev/zero of={{ item.file }} bs=1M count=200" ## minimum count 109 + creates: "{{ item.file }}" + with_items: "{{ btrfs_subvolume_configs }}" + +- name: "Setup loop device {{ item.loop }}" + ansible.builtin.command: + cmd: "losetup {{ item.loop }} {{ item.file }}" + creates: "{{ item.loop }}" + with_items: "{{ btrfs_subvolume_configs }}" + +- name: Create single device btrfs filesystem + ansible.builtin.command: + cmd: "mkfs.btrfs --label {{ btrfs_subvolume_single_label }} -f {{ btrfs_subvolume_single_devices | first }}" + changed_when: True + +- name: Create multiple device btrfs filesystem + ansible.builtin.command: + cmd: "mkfs.btrfs --label {{ btrfs_subvolume_multiple_label }} -f -d raid0 {{ btrfs_subvolume_multiple_devices | join(' ') }}" + changed_when: True + +# Typically created by udev, but apparently missing on Alpine +- name: Create btrfs control device node + ansible.builtin.command: + cmd: "mknod /dev/btrfs-control c 10 234" + creates: "/dev/btrfs-control" + +- name: Force rescan to ensure all device are detected + ansible.builtin.command: + cmd: "btrfs device scan" + changed_when: True diff --git a/tests/integration/targets/btrfs_subvolume/tasks/test_filesystem_matching.yml b/tests/integration/targets/btrfs_subvolume/tasks/test_filesystem_matching.yml new file mode 100644 index 00000000000..2455eeacf10 --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/test_filesystem_matching.yml @@ -0,0 +1,80 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: "Match targeted filesystem by label" + block: + - name: Match '{{ btrfs_subvolume_target_label }}' filesystem by label + community.general.btrfs_subvolume: + automount: Yes + name: "/match_label" + filesystem_label: "{{ btrfs_subvolume_target_label }}" + state: "present" + register: result + + - name: Validate the '{{ btrfs_subvolume_target_label }}' filesystem was chosen + ansible.builtin.assert: + that: + - result.filesystem.label == btrfs_subvolume_target_label + +- name: "Match targeted filesystem by uuid" + block: + - name: Match '{{ btrfs_subvolume_target_label }}' filesystem by uuid + community.general.btrfs_subvolume: + automount: Yes + name: "/match_uuid" + filesystem_uuid: "{{ result.filesystem.uuid }}" + state: "present" + register: result + + - name: Validate the '{{ btrfs_subvolume_target_label }}' filesystem was chosen + ansible.builtin.assert: + that: + - result.filesystem.label == btrfs_subvolume_target_label + +- name: "Match targeted filesystem by devices" + block: + - name: Match '{{ btrfs_subvolume_target_label }}' filesystem by device + community.general.btrfs_subvolume: + automount: Yes + name: "/match_device" + filesystem_device: "{{ result.filesystem.devices | first }}" + state: "present" + register: result + + - name: Validate the '{{ btrfs_subvolume_target_label }}' filesystem was chosen + ansible.builtin.assert: + that: + - result.filesystem.label == btrfs_subvolume_target_label + +- name: "Match only mounted filesystem" + block: + - name: "Mount filesystem '{{ btrfs_subvolume_target_label }}'" + ansible.posix.mount: + src: "{{ result.filesystem.devices | first }}" + path: /mnt + opts: "subvolid={{ 5 }}" + fstype: btrfs + state: mounted + + - name: Print current status + community.general.btrfs_info: + + - name: Match '{{ btrfs_subvolume_target_label }}' filesystem when only mount + community.general.btrfs_subvolume: + automount: Yes + name: "/match_only_mounted" + state: "present" + register: result + + - name: "Unmount filesystem '{{ btrfs_subvolume_target_label }}'" + ansible.posix.mount: + path: /mnt + state: absent + + - name: Validate the '{{ btrfs_subvolume_target_label }}' filesystem was chosen + ansible.builtin.assert: + that: + - result.filesystem.label == btrfs_subvolume_target_label + when: False # TODO don't attempt this if the host already has a pre-existing btrfs filesystem diff --git a/tests/integration/targets/btrfs_subvolume/tasks/test_snapshot_clobber.yml b/tests/integration/targets/btrfs_subvolume/tasks/test_snapshot_clobber.yml new file mode 100644 index 00000000000..ce25a999baf --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/test_snapshot_clobber.yml @@ -0,0 +1,41 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create a snapshot, overwriting if one already exists at path + block: + - name: Create a snapshot named 'snapshot_clobber' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/snapshot_clobber" + snapshot_source: "/" + snapshot_conflict: "clobber" + state: "present" + register: result + - name: Snapshot 'snapshot_clobber' created + ansible.builtin.assert: + that: + - result is changed + + - name: Create a snapshot named 'snapshot_clobber' (no idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/snapshot_clobber" + snapshot_source: "/" + snapshot_conflict: "clobber" + state: "present" + register: result + - name: Snapshot 'snapshot_clobber' created (no idempotency) + ansible.builtin.assert: + that: + - result is changed + +- name: Cleanup created snapshot + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/snapshot_clobber" + state: "absent" diff --git a/tests/integration/targets/btrfs_subvolume/tasks/test_snapshot_error.yml b/tests/integration/targets/btrfs_subvolume/tasks/test_snapshot_error.yml new file mode 100644 index 00000000000..49d928b74cc --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/test_snapshot_error.yml @@ -0,0 +1,42 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create a snapshot, erroring if one already exists at path + block: + - name: Create a snapshot named 'snapshot_error' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/snapshot_error" + snapshot_source: "/" + snapshot_conflict: "error" + state: "present" + register: result + - name: Snapshot 'snapshot_error' created + ansible.builtin.assert: + that: + - result is changed + + - name: Create a snapshot named 'snapshot_error' (no idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/snapshot_error" + snapshot_source: "/" + snapshot_conflict: "error" + state: "present" + register: result + ignore_errors: true + - name: Snapshot 'snapshot_error' created (no idempotency) + ansible.builtin.assert: + that: + - result is not changed + +- name: Cleanup created snapshot + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/snapshot_error" + state: "absent" diff --git a/tests/integration/targets/btrfs_subvolume/tasks/test_snapshot_skip.yml b/tests/integration/targets/btrfs_subvolume/tasks/test_snapshot_skip.yml new file mode 100644 index 00000000000..07e65b133ca --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/test_snapshot_skip.yml @@ -0,0 +1,41 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create a snapshot if one does not already exist at path + block: + - name: Create a snapshot named 'snapshot_skip' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/snapshot_skip" + snapshot_source: "/" + snapshot_conflict: "skip" + state: "present" + register: result + - name: Snapshot 'snapshot_skip' created + ansible.builtin.assert: + that: + - result is changed + + - name: Create a snapshot named 'snapshot_skip' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/snapshot_skip" + snapshot_source: "/" + snapshot_conflict: "skip" + state: "present" + register: result + - name: Snapshot 'snapshot_skip' created (idempotency) + ansible.builtin.assert: + that: + - result is not changed + +- name: Cleanup created snapshot + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/snapshot_skip" + state: "absent" diff --git a/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_default.yml b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_default.yml new file mode 100644 index 00000000000..f6eed938786 --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_default.yml @@ -0,0 +1,99 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Change the default subvolume + block: + - name: Update filesystem default subvolume to '@' + community.general.btrfs_subvolume: + automount: Yes + default: True + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/@" + state: "present" + register: result + - name: Subvolume '@' set to default + ansible.builtin.assert: + that: + - result is changed + - name: Update filesystem default subvolume to '@' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + default: True + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/@" + state: "present" + register: result + - name: Subvolume '@' set to default (idempotency) + ansible.builtin.assert: + that: + - result is not changed + +- name: Revert the default subvolume + block: + - name: Revert filesystem default subvolume to '/' + community.general.btrfs_subvolume: + automount: Yes + default: True + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/" + state: "present" + register: result + - name: Subvolume '/' set to default + ansible.builtin.assert: + that: + - result is changed + - name: Revert filesystem default subvolume to '/' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + default: True + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/" + state: "present" + register: result + - name: Subvolume '/' set to default (idempotency) + ansible.builtin.assert: + that: + - result is not changed + + +- name: Change the default subvolume again + block: + - name: Update filesystem default subvolume to '@' + community.general.btrfs_subvolume: + automount: Yes + default: True + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/@" + state: "present" + register: result + - name: Subvolume '@' set to default + ansible.builtin.assert: + that: + - result is changed + +- name: Revert custom default subvolume to fs_tree root when deleted + block: + - name: Delete custom default subvolume '@' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/@" + state: "absent" + register: result + - name: Subvolume '@' deleted + ansible.builtin.assert: + that: + - result is changed + - name: Delete custom default subvolume '@' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/@" + state: "absent" + register: result + - name: Subvolume '@' deleted (idempotency) + ansible.builtin.assert: + that: + - result is not changed diff --git a/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_nested.yml b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_nested.yml new file mode 100644 index 00000000000..b706bf72a8a --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_nested.yml @@ -0,0 +1,61 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create parent subvolume 'container' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container" + state: "present" + +- name: Create a nested subvolume + block: + - name: Create a subvolume named 'nested' inside 'container' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container/nested" + state: "present" + register: result + - name: Subvolume 'container/nested' created + ansible.builtin.assert: + that: + - result is changed + - name: Create a subvolume named 'nested' inside 'container' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container/nested" + state: "present" + register: result + - name: Subvolume 'container/nested' created (idempotency) + ansible.builtin.assert: + that: + - result is not changed + +- name: Remove a nested subvolume + block: + - name: Remove a subvolume named 'nested' inside 'container' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container/nested" + state: "absent" + register: result + - name: Subvolume 'container/nested' removed + ansible.builtin.assert: + that: + - result is changed + - name: Remove a subvolume named 'nested' inside 'container' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container/nested" + state: "absent" + register: result + - name: Subvolume 'container/nested' removed (idempotency) + ansible.builtin.assert: + that: + - result is not changed diff --git a/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_recursive.yml b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_recursive.yml new file mode 100644 index 00000000000..7e9f9900708 --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_recursive.yml @@ -0,0 +1,86 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Recursively create subvolumes + block: + - name: Create a subvolume named '/recursive/son/grandson' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/recursive/son/grandson" + recursive: Yes + state: "present" + register: result + - name: Subvolume named '/recursive/son/grandson' created + ansible.builtin.assert: + that: + - result is changed + + - name: Create a subvolume named '/recursive/son/grandson' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/recursive/son/grandson" + recursive: Yes + state: "present" + register: result + - name: Subvolume named '/recursive/son/grandson' created (idempotency) + ansible.builtin.assert: + that: + - result is not changed + + - name: Create a subvolume named '/recursive/daughter/granddaughter' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/recursive/daughter/granddaughter" + recursive: Yes + state: "present" + register: result + - name: Subvolume named '/recursive/son/grandson' created + ansible.builtin.assert: + that: + - result is changed + + - name: Create a subvolume named '/recursive/daughter/granddaughter' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/recursive/daughter/granddaughter" + recursive: Yes + state: "present" + register: result + - name: Subvolume named '/recursive/son/grandson' created (idempotency) + ansible.builtin.assert: + that: + - result is not changed + +- name: Recursively remove subvolumes + block: + - name: Remove subvolume '/recursive' and all descendents + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/recursive" + recursive: Yes + state: "absent" + register: result + - name: Subvolume '/recursive' removed + ansible.builtin.assert: + that: + - result is changed + + - name: Remove subvolume '/recursive' and all descendents (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/recursive" + recursive: Yes + state: "absent" + register: result + - name: Subvolume '/recursive' removed (idempotency) + ansible.builtin.assert: + that: + - result is not changed diff --git a/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_simple.yml b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_simple.yml new file mode 100644 index 00000000000..6cd214e7477 --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_simple.yml @@ -0,0 +1,54 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create a simple subvolume + block: + - name: Create a subvolume named 'simple' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/simple" + state: "present" + register: result + - name: Subvolume named 'simple' created + ansible.builtin.assert: + that: + - result is changed + - name: Create a subvolume named 'simple' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/simple" + state: "present" + register: result + - name: Subvolume named 'simple' created (idempotency) + ansible.builtin.assert: + that: + - result is not changed + +- name: Remove a simple subvolume + block: + - name: Remove a subvolume named 'simple' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/simple" + state: "absent" + register: result + - name: Subvolume named 'simple' removed + ansible.builtin.assert: + that: + - result is changed + - name: Remove a subvolume named 'simple' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/simple" + state: "absent" + register: result + - name: Subvolume named 'simple' removed (idempotency) + ansible.builtin.assert: + that: + - result is not changed diff --git a/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_whitespace.yml b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_whitespace.yml new file mode 100644 index 00000000000..6a0147af6cc --- /dev/null +++ b/tests/integration/targets/btrfs_subvolume/tasks/test_subvolume_whitespace.yml @@ -0,0 +1,62 @@ +--- +# Copyright (c) 2022, Gregory Furlong +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create a subvolume named 'container' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container" + state: "present" + +- name: Create a subvolume with whitespace in the name + block: + - name: Create a subvolume named 'container/my data' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container/my data" + state: "present" + register: result + - name: Subvolume named 'container/my data' created + ansible.builtin.assert: + that: + - result is changed + - name: Create a subvolume named 'container/my data' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container/my data" + state: "present" + register: result + - name: Subvolume named 'container/my data' created (idempotency) + ansible.builtin.assert: + that: + - result is not changed + +- name: Remove a subvolume with whitespace in the name + block: + - name: Remove a subvolume named 'container/my data' + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container/my data" + state: "absent" + register: result + - name: Subvolume named 'container/my data' removed + ansible.builtin.assert: + that: + - result is changed + + - name: Remove a subvolume named 'container/my data' (idempotency) + community.general.btrfs_subvolume: + automount: Yes + filesystem_label: "{{ btrfs_subvolume_target_label }}" + name: "/container/my data" + state: "absent" + register: result + - name: Subvolume named 'container/my data' removed (idempotency) + ansible.builtin.assert: + that: + - result is not changed