#!/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 O(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 O(state=present) and O(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. - V(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. - V(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. - V(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 O(filesystem_device), O(filesystem_label) or O(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 O(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 = [mount, "-o", "noatime,subvolid=%d" % 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([umount, mountpoint]) if result[0] == 0: rmdir = self.module.get_bin_path("rmdir", required=True) self.module.run_command([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()