mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
New modules btrfs_subvolume / btrfs_info (#5832)
* Initial implementation for new modules btrfs_subvolume and btrfs_info * Improve/flesh out documentation. Add ability to target filesystem by uuid, label or device. Update tests to test targeting filesystem by each supported parameter and when only mountpoint. * Updates for btrfs modules. Add missing copyright notices. Switch options to contains in return documentation. Update btrfs_subvolume to always use closest parent mount. * Add maintainers for btrfs module(s) and remove unused class member cause lint failure. * Add changelog fragment. Attempt to only run against the VMs as part of CI. * Updates per code review. Remove changelog fragment. Switch use of map to list comprehension. Add trailing comma to last item in multi-line dicts. Clean up documentation with complete senstences for descriptions and correct/consistent use of macros. * Improved error handling in btrfs_subvolume module: add custom exception type, favor exceptions over immediate call to fail_json and add single top level return for failure scenarios. Normalize name and snapshot_source parameters early in module execution and remove unecessary duplicate normalization throughout processing. * Add azp/posix/3 to aliases per feedback * Clean up automatic mounting. Prevent automount when check_mode=True. Immediately fail if a mount is identified as required and automount=True. Identify the minimal subset of subvolumes that need to be mounted instead of just finding a single common root. * Skip btrfs_subvolume integration tests if btrfs-progs isn't successfully installed. * Bump version_added for btrfs modules to 6.6.0. Ensure consistent trailing punctuation for module descriptions and document check_mode behavior as attribute description rather than a module level note. * Remove unused imports from btrfs_subvolume module. * Fix import. * Docs improvements. --------- Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
parent
76dd465e08
commit
ae5090d90e
19 changed files with 1970 additions and 0 deletions
4
.github/BOTMETA.yml
vendored
4
.github/BOTMETA.yml
vendored
|
@ -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:
|
||||
|
|
464
plugins/module_utils/btrfs.py
Normal file
464
plugins/module_utils/btrfs.py
Normal file
|
@ -0,0 +1,464 @@
|
|||
# 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
|
||||
|
||||
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 <FS_TREE>, this value is removed.
|
||||
"""
|
||||
fstree_stripped = re.sub(r'^<FS_TREE>', '', 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<target>.*)"\s+SOURCE="(?P<source>.*)"\s+FSTYPE="(?P<fstype>.*)"\s+OPTIONS="(?P<options>.*)"\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)
|
109
plugins/modules/btrfs_info.py
Normal file
109
plugins/modules/btrfs_info.py
Normal file
|
@ -0,0 +1,109 @@
|
|||
#!/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_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()
|
682
plugins/modules/btrfs_subvolume.py
Normal file
682
plugins/modules/btrfs_subvolume.py
Normal file
|
@ -0,0 +1,682 @@
|
|||
#!/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 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()
|
12
tests/integration/targets/btrfs_subvolume/aliases
Normal file
12
tests/integration/targets/btrfs_subvolume/aliases
Normal file
|
@ -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
|
20
tests/integration/targets/btrfs_subvolume/defaults/main.yml
Normal file
20
tests/integration/targets/btrfs_subvolume/defaults/main.yml
Normal file
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
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"
|
29
tests/integration/targets/btrfs_subvolume/tasks/main.yml
Normal file
29
tests/integration/targets/btrfs_subvolume/tasks/main.yml
Normal file
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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'
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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
|
37
tests/integration/targets/btrfs_subvolume/tasks/setup.yml
Normal file
37
tests/integration/targets/btrfs_subvolume/tasks/setup.yml
Normal file
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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"
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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"
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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"
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
# 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
|
||||
|
||||
- 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
|
Loading…
Reference in a new issue