homeserver/collections/community/general/plugins/modules/btrfs_subvolume.py

683 lines
28 KiB
Python

#!/usr/bin/python
# Copyright (c) 2022, Gregory Furlong <gnfzdz@fzdz.io>
# 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 = "%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()