#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2021, quidame # Copyright (c) 2013, Alexander Bulimov # 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 from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = ''' --- author: - Alexander Bulimov (@abulimov) - quidame (@quidame) module: filesystem short_description: Makes a filesystem description: - This module creates a filesystem. extends_documentation_fragment: - community.general.attributes attributes: check_mode: support: full diff_mode: support: none options: state: description: - If O(state=present), the filesystem is created if it doesn't already exist, that is the default behaviour if O(state) is omitted. - If O(state=absent), filesystem signatures on O(dev) are wiped if it contains a filesystem (as known by C(blkid)). - When O(state=absent), all other options but O(dev) are ignored, and the module does not fail if the device O(dev) doesn't actually exist. type: str choices: [ present, absent ] default: present version_added: 1.3.0 fstype: choices: [ btrfs, ext2, ext3, ext4, ext4dev, f2fs, lvm, ocfs2, reiserfs, xfs, vfat, swap, ufs ] description: - Filesystem type to be created. This option is required with O(state=present) (or if O(state) is omitted). - ufs support has been added in community.general 3.4.0. type: str aliases: [type] dev: description: - Target path to block device (Linux) or character device (FreeBSD) or regular file (both). - When setting Linux-specific filesystem types on FreeBSD, this module only works when applying to regular files, aka disk images. - Currently V(lvm) (Linux-only) and V(ufs) (FreeBSD-only) do not support a regular file as their target O(dev). - Support for character devices on FreeBSD has been added in community.general 3.4.0. type: path required: true aliases: [device] force: description: - If V(true), allows to create new filesystem on devices that already has filesystem. type: bool default: false resizefs: description: - If V(true), if the block device and filesystem size differ, grow the filesystem into the space. - Supported for C(btrfs), C(ext2), C(ext3), C(ext4), C(ext4dev), C(f2fs), C(lvm), C(xfs), C(ufs) and C(vfat) filesystems. Attempts to resize other filesystem types will fail. - XFS Will only grow if mounted. Currently, the module is based on commands from C(util-linux) package to perform operations, so resizing of XFS is not supported on FreeBSD systems. - vFAT will likely fail if C(fatresize < 1.04). - Mutually exclusive with O(uuid). type: bool default: false opts: description: - List of options to be passed to C(mkfs) command. type: str uuid: description: - Set filesystem's UUID to the given value. - The UUID options specified in O(opts) take precedence over this value. - See xfs_admin(8) (C(xfs)), tune2fs(8) (C(ext2), C(ext3), C(ext4), C(ext4dev)) for possible values. - For O(fstype=lvm) the value is ignored, it resets the PV UUID if set. - Supported for O(fstype) being one of C(ext2), C(ext3), C(ext4), C(ext4dev), C(lvm), or C(xfs). - This is B(not idempotent). Specifying this option will always result in a change. - Mutually exclusive with O(resizefs). type: str version_added: 7.1.0 requirements: - Uses specific tools related to the O(fstype) for creating or resizing a filesystem (from packages e2fsprogs, xfsprogs, dosfstools, and so on). - Uses generic tools mostly related to the Operating System (Linux or FreeBSD) or available on both, as C(blkid). - On FreeBSD, either C(util-linux) or C(e2fsprogs) package is required. notes: - Potential filesystems on O(dev) are checked using C(blkid). In case C(blkid) is unable to detect a filesystem (and in case C(fstyp) on FreeBSD is also unable to detect a filesystem), this filesystem is overwritten even if O(force) is V(false). - On FreeBSD systems, both C(e2fsprogs) and C(util-linux) packages provide a C(blkid) command that is compatible with this module. However, these packages conflict with each other, and only the C(util-linux) package provides the command required to not fail when O(state=absent). seealso: - module: community.general.filesize - module: ansible.posix.mount - name: xfs_admin(8) manpage for Linux description: Manual page of the GNU/Linux's xfs_admin implementation link: https://man7.org/linux/man-pages/man8/xfs_admin.8.html - name: tune2fs(8) manpage for Linux description: Manual page of the GNU/Linux's tune2fs implementation link: https://man7.org/linux/man-pages/man8/tune2fs.8.html ''' EXAMPLES = ''' - name: Create a ext2 filesystem on /dev/sdb1 community.general.filesystem: fstype: ext2 dev: /dev/sdb1 - name: Create a ext4 filesystem on /dev/sdb1 and check disk blocks community.general.filesystem: fstype: ext4 dev: /dev/sdb1 opts: -cc - name: Blank filesystem signature on /dev/sdb1 community.general.filesystem: dev: /dev/sdb1 state: absent - name: Create a filesystem on top of a regular file community.general.filesystem: dev: /path/to/disk.img fstype: vfat - name: Reset an xfs filesystem UUID on /dev/sdb1 community.general.filesystem: fstype: xfs dev: /dev/sdb1 uuid: generate - name: Reset an ext4 filesystem UUID on /dev/sdb1 community.general.filesystem: fstype: ext4 dev: /dev/sdb1 uuid: random - name: Reset an LVM filesystem (PV) UUID on /dev/sdc community.general.filesystem: fstype: lvm dev: /dev/sdc uuid: random ''' import os import platform import re import stat from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_native from ansible_collections.community.general.plugins.module_utils.version import LooseVersion class Device(object): def __init__(self, module, path): self.module = module self.path = path def size(self): """ Return size in bytes of device. Returns int """ statinfo = os.stat(self.path) if stat.S_ISBLK(statinfo.st_mode): blockdev_cmd = self.module.get_bin_path("blockdev", required=True) dummy, out, dummy = self.module.run_command([blockdev_cmd, "--getsize64", self.path], check_rc=True) devsize_in_bytes = int(out) elif stat.S_ISCHR(statinfo.st_mode) and platform.system() == 'FreeBSD': diskinfo_cmd = self.module.get_bin_path("diskinfo", required=True) dummy, out, dummy = self.module.run_command([diskinfo_cmd, self.path], check_rc=True) devsize_in_bytes = int(out.split()[2]) elif os.path.isfile(self.path): devsize_in_bytes = os.path.getsize(self.path) else: self.module.fail_json(changed=False, msg="Target device not supported: %s" % self) return devsize_in_bytes def get_mountpoint(self): """Return (first) mountpoint of device. Returns None when not mounted.""" cmd_findmnt = self.module.get_bin_path("findmnt", required=True) # find mountpoint rc, mountpoint, dummy = self.module.run_command([cmd_findmnt, "--mtab", "--noheadings", "--output", "TARGET", "--source", self.path], check_rc=False) if rc != 0: mountpoint = None else: mountpoint = mountpoint.split('\n')[0] return mountpoint def __str__(self): return self.path class Filesystem(object): MKFS = None MKFS_FORCE_FLAGS = [] MKFS_SET_UUID_OPTIONS = None MKFS_SET_UUID_EXTRA_OPTIONS = [] INFO = None GROW = None GROW_MAX_SPACE_FLAGS = [] GROW_MOUNTPOINT_ONLY = False CHANGE_UUID = None CHANGE_UUID_OPTION = None CHANGE_UUID_OPTION_HAS_ARG = True LANG_ENV = {'LANG': 'C', 'LC_ALL': 'C', 'LC_MESSAGES': 'C'} def __init__(self, module): self.module = module @property def fstype(self): return type(self).__name__ def get_fs_size(self, dev): """Return size in bytes of filesystem on device (integer). Should query the info with a per-fstype command that can access the device whenever it is mounted or not, and parse the command output. Parser must ensure to return an integer, or raise a ValueError. """ raise NotImplementedError() def create(self, opts, dev, uuid=None): if self.module.check_mode: return if uuid and self.MKFS_SET_UUID_OPTIONS: if not (set(self.MKFS_SET_UUID_OPTIONS) & set(opts)): opts += [self.MKFS_SET_UUID_OPTIONS[0], uuid] + self.MKFS_SET_UUID_EXTRA_OPTIONS mkfs = self.module.get_bin_path(self.MKFS, required=True) cmd = [mkfs] + self.MKFS_FORCE_FLAGS + opts + [str(dev)] self.module.run_command(cmd, check_rc=True) if uuid and self.CHANGE_UUID and self.MKFS_SET_UUID_OPTIONS is None: self.change_uuid(new_uuid=uuid, dev=dev) def wipefs(self, dev): if self.module.check_mode: return # wipefs comes with util-linux package (as 'blockdev' & 'findmnt' above) # that is ported to FreeBSD. The use of dd as a portable fallback is # not doable here if it needs get_mountpoint() (to prevent corruption of # a mounted filesystem), since 'findmnt' is not available on FreeBSD, # even in util-linux port for this OS. wipefs = self.module.get_bin_path('wipefs', required=True) cmd = [wipefs, "--all", str(dev)] self.module.run_command(cmd, check_rc=True) def grow_cmd(self, target): """Build and return the resizefs commandline as list.""" cmdline = [self.module.get_bin_path(self.GROW, required=True)] cmdline += self.GROW_MAX_SPACE_FLAGS + [target] return cmdline def grow(self, dev): """Get dev and fs size and compare. Returns stdout of used command.""" devsize_in_bytes = dev.size() try: fssize_in_bytes = self.get_fs_size(dev) except NotImplementedError: self.module.fail_json(msg="module does not support resizing %s filesystem yet" % self.fstype) except ValueError as err: self.module.warn("unable to process %s output '%s'" % (self.INFO, to_native(err))) self.module.fail_json(msg="unable to process %s output for %s" % (self.INFO, dev)) if not fssize_in_bytes < devsize_in_bytes: self.module.exit_json(changed=False, msg="%s filesystem is using the whole device %s" % (self.fstype, dev)) elif self.module.check_mode: self.module.exit_json(changed=True, msg="resizing filesystem %s on device %s" % (self.fstype, dev)) if self.GROW_MOUNTPOINT_ONLY: mountpoint = dev.get_mountpoint() if not mountpoint: self.module.fail_json(msg="%s needs to be mounted for %s operations" % (dev, self.fstype)) grow_target = mountpoint else: grow_target = str(dev) dummy, out, dummy = self.module.run_command(self.grow_cmd(grow_target), check_rc=True) return out def change_uuid_cmd(self, new_uuid, target): """Build and return the UUID change command line as list.""" cmdline = [self.module.get_bin_path(self.CHANGE_UUID, required=True)] if self.CHANGE_UUID_OPTION_HAS_ARG: cmdline += [self.CHANGE_UUID_OPTION, new_uuid, target] else: cmdline += [self.CHANGE_UUID_OPTION, target] return cmdline def change_uuid(self, new_uuid, dev): """Change filesystem UUID. Returns stdout of used command""" if self.module.check_mode: self.module.exit_json(change=True, msg='Changing %s filesystem UUID on device %s' % (self.fstype, dev)) dummy, out, dummy = self.module.run_command(self.change_uuid_cmd(new_uuid=new_uuid, target=str(dev)), check_rc=True) return out class Ext(Filesystem): MKFS_FORCE_FLAGS = ['-F'] MKFS_SET_UUID_OPTIONS = ['-U'] INFO = 'tune2fs' GROW = 'resize2fs' CHANGE_UUID = 'tune2fs' CHANGE_UUID_OPTION = "-U" def get_fs_size(self, dev): """Get Block count and Block size and return their product.""" cmd = self.module.get_bin_path(self.INFO, required=True) dummy, out, dummy = self.module.run_command([cmd, '-l', str(dev)], check_rc=True, environ_update=self.LANG_ENV) block_count = block_size = None for line in out.splitlines(): if 'Block count:' in line: block_count = int(line.split(':')[1].strip()) elif 'Block size:' in line: block_size = int(line.split(':')[1].strip()) if None not in (block_size, block_count): break else: raise ValueError(repr(out)) return block_size * block_count class Ext2(Ext): MKFS = 'mkfs.ext2' class Ext3(Ext): MKFS = 'mkfs.ext3' class Ext4(Ext): MKFS = 'mkfs.ext4' class XFS(Filesystem): MKFS = 'mkfs.xfs' MKFS_FORCE_FLAGS = ['-f'] INFO = 'xfs_info' GROW = 'xfs_growfs' GROW_MOUNTPOINT_ONLY = True CHANGE_UUID = "xfs_admin" CHANGE_UUID_OPTION = "-U" def get_fs_size(self, dev): """Get bsize and blocks and return their product.""" cmdline = [self.module.get_bin_path(self.INFO, required=True)] # Depending on the versions, xfs_info is able to get info from the # device, whenever it is mounted or not, or only if unmounted, or # only if mounted, or not at all. For any version until now, it is # able to query info from the mountpoint. So try it first, and use # device as the last resort: it may or may not work. mountpoint = dev.get_mountpoint() if mountpoint: cmdline += [mountpoint] else: cmdline += [str(dev)] dummy, out, dummy = self.module.run_command(cmdline, check_rc=True, environ_update=self.LANG_ENV) block_size = block_count = None for line in out.splitlines(): col = line.split('=') if col[0].strip() == 'data': if col[1].strip() == 'bsize': block_size = int(col[2].split()[0]) if col[2].split()[1] == 'blocks': block_count = int(col[3].split(',')[0]) if None not in (block_size, block_count): break else: raise ValueError(repr(out)) return block_size * block_count class Reiserfs(Filesystem): MKFS = 'mkfs.reiserfs' MKFS_FORCE_FLAGS = ['-q'] class Btrfs(Filesystem): MKFS = 'mkfs.btrfs' INFO = 'btrfs' GROW = 'btrfs' GROW_MAX_SPACE_FLAGS = ['filesystem', 'resize', 'max'] GROW_MOUNTPOINT_ONLY = True def __init__(self, module): super(Btrfs, self).__init__(module) mkfs = self.module.get_bin_path(self.MKFS, required=True) dummy, stdout, stderr = self.module.run_command([mkfs, '--version'], check_rc=True) match = re.search(r" v([0-9.]+)", stdout) if not match: # v0.20-rc1 use stderr match = re.search(r" v([0-9.]+)", stderr) if match: # v0.20-rc1 doesn't have --force parameter added in following version v3.12 if LooseVersion(match.group(1)) >= LooseVersion('3.12'): self.MKFS_FORCE_FLAGS = ['-f'] else: # assume version is greater or equal to 3.12 self.MKFS_FORCE_FLAGS = ['-f'] self.module.warn('Unable to identify mkfs.btrfs version (%r, %r)' % (stdout, stderr)) def get_fs_size(self, dev): """Return size in bytes of filesystem on device (integer).""" mountpoint = dev.get_mountpoint() if not mountpoint: self.module.fail_json(msg="%s needs to be mounted for %s operations" % (dev, self.fstype)) dummy, stdout, dummy = self.module.run_command([self.module.get_bin_path(self.INFO), 'filesystem', 'usage', '-b', mountpoint], check_rc=True) for line in stdout.splitlines(): if "Device size" in line: return int(line.split()[-1]) raise ValueError(repr(stdout)) class Ocfs2(Filesystem): MKFS = 'mkfs.ocfs2' MKFS_FORCE_FLAGS = ['-Fx'] class F2fs(Filesystem): MKFS = 'mkfs.f2fs' INFO = 'dump.f2fs' GROW = 'resize.f2fs' def __init__(self, module): super(F2fs, self).__init__(module) mkfs = self.module.get_bin_path(self.MKFS, required=True) dummy, out, dummy = self.module.run_command([mkfs, os.devnull], check_rc=False, environ_update=self.LANG_ENV) # Looking for " F2FS-tools: mkfs.f2fs Ver: 1.10.0 (2018-01-30)" # mkfs.f2fs displays version since v1.2.0 match = re.search(r"F2FS-tools: mkfs.f2fs Ver: ([0-9.]+) \(", out) if match is not None: # Since 1.9.0, mkfs.f2fs check overwrite before make filesystem # before that version -f switch wasn't used if LooseVersion(match.group(1)) >= LooseVersion('1.9.0'): self.MKFS_FORCE_FLAGS = ['-f'] def get_fs_size(self, dev): """Get sector size and total FS sectors and return their product.""" cmd = self.module.get_bin_path(self.INFO, required=True) dummy, out, dummy = self.module.run_command([cmd, str(dev)], check_rc=True, environ_update=self.LANG_ENV) sector_size = sector_count = None for line in out.splitlines(): if 'Info: sector size = ' in line: # expected: 'Info: sector size = 512' sector_size = int(line.split()[4]) elif 'Info: total FS sectors = ' in line: # expected: 'Info: total FS sectors = 102400 (50 MB)' sector_count = int(line.split()[5]) if None not in (sector_size, sector_count): break else: raise ValueError(repr(out)) return sector_size * sector_count class VFAT(Filesystem): INFO = 'fatresize' GROW = 'fatresize' GROW_MAX_SPACE_FLAGS = ['-s', 'max'] def __init__(self, module): super(VFAT, self).__init__(module) if platform.system() == 'FreeBSD': self.MKFS = 'newfs_msdos' else: self.MKFS = 'mkfs.vfat' def get_fs_size(self, dev): """Get and return size of filesystem, in bytes.""" cmd = self.module.get_bin_path(self.INFO, required=True) dummy, out, dummy = self.module.run_command([cmd, '--info', str(dev)], check_rc=True, environ_update=self.LANG_ENV) fssize = None for line in out.splitlines()[1:]: parts = line.split(':', 1) if len(parts) < 2: continue param, value = parts if param.strip() in ('Size', 'Cur size'): fssize = int(value.strip()) break else: raise ValueError(repr(out)) return fssize class LVM(Filesystem): MKFS = 'pvcreate' MKFS_FORCE_FLAGS = ['-f'] MKFS_SET_UUID_OPTIONS = ['-u', '--uuid'] MKFS_SET_UUID_EXTRA_OPTIONS = ['--norestorefile'] INFO = 'pvs' GROW = 'pvresize' CHANGE_UUID = 'pvchange' CHANGE_UUID_OPTION = '-u' CHANGE_UUID_OPTION_HAS_ARG = False def get_fs_size(self, dev): """Get and return PV size, in bytes.""" cmd = self.module.get_bin_path(self.INFO, required=True) dummy, size, dummy = self.module.run_command([cmd, '--noheadings', '-o', 'pv_size', '--units', 'b', '--nosuffix', str(dev)], check_rc=True) pv_size = int(size) return pv_size class Swap(Filesystem): MKFS = 'mkswap' MKFS_FORCE_FLAGS = ['-f'] class UFS(Filesystem): MKFS = 'newfs' INFO = 'dumpfs' GROW = 'growfs' GROW_MAX_SPACE_FLAGS = ['-y'] def get_fs_size(self, dev): """Get providersize and fragment size and return their product.""" cmd = self.module.get_bin_path(self.INFO, required=True) dummy, out, dummy = self.module.run_command([cmd, str(dev)], check_rc=True, environ_update=self.LANG_ENV) fragmentsize = providersize = None for line in out.splitlines(): if line.startswith('fsize'): fragmentsize = int(line.split()[1]) elif 'providersize' in line: providersize = int(line.split()[-1]) if None not in (fragmentsize, providersize): break else: raise ValueError(repr(out)) return fragmentsize * providersize FILESYSTEMS = { 'ext2': Ext2, 'ext3': Ext3, 'ext4': Ext4, 'ext4dev': Ext4, 'f2fs': F2fs, 'reiserfs': Reiserfs, 'xfs': XFS, 'btrfs': Btrfs, 'vfat': VFAT, 'ocfs2': Ocfs2, 'LVM2_member': LVM, 'swap': Swap, 'ufs': UFS, } def main(): friendly_names = { 'lvm': 'LVM2_member', } fstypes = set(FILESYSTEMS.keys()) - set(friendly_names.values()) | set(friendly_names.keys()) # There is no "single command" to manipulate filesystems, so we map them all out and their options module = AnsibleModule( argument_spec=dict( state=dict(type='str', default='present', choices=['present', 'absent']), fstype=dict(type='str', aliases=['type'], choices=list(fstypes)), dev=dict(type='path', required=True, aliases=['device']), opts=dict(type='str'), force=dict(type='bool', default=False), resizefs=dict(type='bool', default=False), uuid=dict(type='str', required=False), ), required_if=[ ('state', 'present', ['fstype']) ], mutually_exclusive=[ ('resizefs', 'uuid'), ], supports_check_mode=True, ) state = module.params['state'] dev = module.params['dev'] fstype = module.params['fstype'] opts = module.params['opts'] force = module.params['force'] resizefs = module.params['resizefs'] uuid = module.params['uuid'] mkfs_opts = [] if opts is not None: mkfs_opts = opts.split() changed = False if not os.path.exists(dev): msg = "Device %s not found." % dev if state == "present": module.fail_json(msg=msg) else: module.exit_json(msg=msg) dev = Device(module, dev) # In case blkid/fstyp isn't able to identify an existing filesystem, device # is considered as empty, then this existing filesystem would be overwritten # even if force isn't enabled. cmd = module.get_bin_path('blkid', required=True) rc, raw_fs, err = module.run_command([cmd, '-c', os.devnull, '-o', 'value', '-s', 'TYPE', str(dev)]) fs = raw_fs.strip() if not fs and platform.system() == 'FreeBSD': cmd = module.get_bin_path('fstyp', required=True) rc, raw_fs, err = module.run_command([cmd, str(dev)]) fs = raw_fs.strip() if state == "present": if fstype in friendly_names: fstype = friendly_names[fstype] try: klass = FILESYSTEMS[fstype] except KeyError: module.fail_json(changed=False, msg="module does not support this filesystem (%s) yet." % fstype) filesystem = klass(module) if uuid and not (filesystem.CHANGE_UUID or filesystem.MKFS_SET_UUID_OPTIONS): module.fail_json(changed=False, msg="module does not support UUID option for this filesystem (%s) yet." % fstype) same_fs = fs and FILESYSTEMS.get(fs) == FILESYSTEMS[fstype] if same_fs and not resizefs and not uuid and not force: module.exit_json(changed=False) elif same_fs: if resizefs: if not filesystem.GROW: module.fail_json(changed=False, msg="module does not support resizing %s filesystem yet." % fstype) out = filesystem.grow(dev) module.exit_json(changed=True, msg=out) elif uuid: out = filesystem.change_uuid(new_uuid=uuid, dev=dev) module.exit_json(changed=True, msg=out) elif fs and not force: module.fail_json(msg="'%s' is already used as %s, use force=true to overwrite" % (dev, fs), rc=rc, err=err) # create fs filesystem.create(opts=mkfs_opts, dev=dev, uuid=uuid) changed = True elif fs: # wipe fs signatures filesystem = Filesystem(module) filesystem.wipefs(dev) changed = True module.exit_json(changed=changed) if __name__ == '__main__': main()