1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Copyright: (c) 2021, quidame <quidame@poivron.org>
5# Copyright: (c) 2013, Alexander Bulimov <lazywolf0@gmail.com>
6# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
7
8from __future__ import absolute_import, division, print_function
9__metaclass__ = type
10
11
12DOCUMENTATION = '''
13---
14author:
15  - Alexander Bulimov (@abulimov)
16  - quidame (@quidame)
17module: filesystem
18short_description: Makes a filesystem
19description:
20  - This module creates a filesystem.
21options:
22  state:
23    description:
24      - If C(state=present), the filesystem is created if it doesn't already
25        exist, that is the default behaviour if I(state) is omitted.
26      - If C(state=absent), filesystem signatures on I(dev) are wiped if it
27        contains a filesystem (as known by C(blkid)).
28      - When C(state=absent), all other options but I(dev) are ignored, and the
29        module doesn't fail if the device I(dev) doesn't actually exist.
30    type: str
31    choices: [ present, absent ]
32    default: present
33    version_added: 1.3.0
34  fstype:
35    choices: [ btrfs, ext2, ext3, ext4, ext4dev, f2fs, lvm, ocfs2, reiserfs, xfs, vfat, swap, ufs ]
36    description:
37      - Filesystem type to be created. This option is required with
38        C(state=present) (or if I(state) is omitted).
39      - ufs support has been added in community.general 3.4.0.
40    type: str
41    aliases: [type]
42  dev:
43    description:
44      - Target path to block device (Linux) or character device (FreeBSD) or
45        regular file (both).
46      - When setting Linux-specific filesystem types on FreeBSD, this module
47        only works when applying to regular files, aka disk images.
48      - Currently C(lvm) (Linux-only) and C(ufs) (FreeBSD-only) don't support
49        a regular file as their target I(dev).
50      - Support for character devices on FreeBSD has been added in community.general 3.4.0.
51    type: path
52    required: yes
53    aliases: [device]
54  force:
55    description:
56      - If C(yes), allows to create new filesystem on devices that already has filesystem.
57    type: bool
58    default: 'no'
59  resizefs:
60    description:
61      - If C(yes), if the block device and filesystem size differ, grow the filesystem into the space.
62      - Supported for C(ext2), C(ext3), C(ext4), C(ext4dev), C(f2fs), C(lvm), C(xfs), C(ufs) and C(vfat) filesystems.
63        Attempts to resize other filesystem types will fail.
64      - XFS Will only grow if mounted. Currently, the module is based on commands
65        from C(util-linux) package to perform operations, so resizing of XFS is
66        not supported on FreeBSD systems.
67      - vFAT will likely fail if fatresize < 1.04.
68    type: bool
69    default: 'no'
70  opts:
71    description:
72      - List of options to be passed to mkfs command.
73    type: str
74requirements:
75  - Uses specific tools related to the I(fstype) for creating or resizing a
76    filesystem (from packages e2fsprogs, xfsprogs, dosfstools, and so on).
77  - Uses generic tools mostly related to the Operating System (Linux or
78    FreeBSD) or available on both, as C(blkid).
79  - On FreeBSD, either C(util-linux) or C(e2fsprogs) package is required.
80notes:
81  - Potential filesystems on I(dev) are checked using C(blkid). In case C(blkid)
82    is unable to detect a filesystem (and in case C(fstyp) on FreeBSD is also
83    unable to detect a filesystem), this filesystem is overwritten even if
84    I(force) is C(no).
85  - On FreeBSD systems, both C(e2fsprogs) and C(util-linux) packages provide
86    a C(blkid) command that is compatible with this module. However, these
87    packages conflict with each other, and only the C(util-linux) package
88    provides the command required to not fail when I(state=absent).
89  - This module supports I(check_mode).
90seealso:
91  - module: community.general.filesize
92  - module: ansible.posix.mount
93'''
94
95EXAMPLES = '''
96- name: Create a ext2 filesystem on /dev/sdb1
97  community.general.filesystem:
98    fstype: ext2
99    dev: /dev/sdb1
100
101- name: Create a ext4 filesystem on /dev/sdb1 and check disk blocks
102  community.general.filesystem:
103    fstype: ext4
104    dev: /dev/sdb1
105    opts: -cc
106
107- name: Blank filesystem signature on /dev/sdb1
108  community.general.filesystem:
109    dev: /dev/sdb1
110    state: absent
111
112- name: Create a filesystem on top of a regular file
113  community.general.filesystem:
114    dev: /path/to/disk.img
115    fstype: vfat
116'''
117
118from distutils.version import LooseVersion
119import os
120import platform
121import re
122import stat
123
124from ansible.module_utils.basic import AnsibleModule
125from ansible.module_utils.common.text.converters import to_native
126
127
128class Device(object):
129    def __init__(self, module, path):
130        self.module = module
131        self.path = path
132
133    def size(self):
134        """ Return size in bytes of device. Returns int """
135        statinfo = os.stat(self.path)
136        if stat.S_ISBLK(statinfo.st_mode):
137            blockdev_cmd = self.module.get_bin_path("blockdev", required=True)
138            dummy, out, dummy = self.module.run_command([blockdev_cmd, "--getsize64", self.path], check_rc=True)
139            devsize_in_bytes = int(out)
140        elif stat.S_ISCHR(statinfo.st_mode) and platform.system() == 'FreeBSD':
141            diskinfo_cmd = self.module.get_bin_path("diskinfo", required=True)
142            dummy, out, dummy = self.module.run_command([diskinfo_cmd, self.path], check_rc=True)
143            devsize_in_bytes = int(out.split()[2])
144        elif os.path.isfile(self.path):
145            devsize_in_bytes = os.path.getsize(self.path)
146        else:
147            self.module.fail_json(changed=False, msg="Target device not supported: %s" % self)
148
149        return devsize_in_bytes
150
151    def get_mountpoint(self):
152        """Return (first) mountpoint of device. Returns None when not mounted."""
153        cmd_findmnt = self.module.get_bin_path("findmnt", required=True)
154
155        # find mountpoint
156        rc, mountpoint, dummy = self.module.run_command([cmd_findmnt, "--mtab", "--noheadings", "--output",
157                                                        "TARGET", "--source", self.path], check_rc=False)
158        if rc != 0:
159            mountpoint = None
160        else:
161            mountpoint = mountpoint.split('\n')[0]
162
163        return mountpoint
164
165    def __str__(self):
166        return self.path
167
168
169class Filesystem(object):
170
171    MKFS = None
172    MKFS_FORCE_FLAGS = []
173    INFO = None
174    GROW = None
175    GROW_MAX_SPACE_FLAGS = []
176    GROW_MOUNTPOINT_ONLY = False
177
178    LANG_ENV = {'LANG': 'C', 'LC_ALL': 'C', 'LC_MESSAGES': 'C'}
179
180    def __init__(self, module):
181        self.module = module
182
183    @property
184    def fstype(self):
185        return type(self).__name__
186
187    def get_fs_size(self, dev):
188        """Return size in bytes of filesystem on device (integer).
189           Should query the info with a per-fstype command that can access the
190           device whenever it is mounted or not, and parse the command output.
191           Parser must ensure to return an integer, or raise a ValueError.
192        """
193        raise NotImplementedError()
194
195    def create(self, opts, dev):
196        if self.module.check_mode:
197            return
198
199        mkfs = self.module.get_bin_path(self.MKFS, required=True)
200        cmd = [mkfs] + self.MKFS_FORCE_FLAGS + opts + [str(dev)]
201        self.module.run_command(cmd, check_rc=True)
202
203    def wipefs(self, dev):
204        if self.module.check_mode:
205            return
206
207        # wipefs comes with util-linux package (as 'blockdev' & 'findmnt' above)
208        # that is ported to FreeBSD. The use of dd as a portable fallback is
209        # not doable here if it needs get_mountpoint() (to prevent corruption of
210        # a mounted filesystem), since 'findmnt' is not available on FreeBSD,
211        # even in util-linux port for this OS.
212        wipefs = self.module.get_bin_path('wipefs', required=True)
213        cmd = [wipefs, "--all", str(dev)]
214        self.module.run_command(cmd, check_rc=True)
215
216    def grow_cmd(self, target):
217        """Build and return the resizefs commandline as list."""
218        cmdline = [self.module.get_bin_path(self.GROW, required=True)]
219        cmdline += self.GROW_MAX_SPACE_FLAGS + [target]
220        return cmdline
221
222    def grow(self, dev):
223        """Get dev and fs size and compare. Returns stdout of used command."""
224        devsize_in_bytes = dev.size()
225
226        try:
227            fssize_in_bytes = self.get_fs_size(dev)
228        except NotImplementedError:
229            self.module.fail_json(msg="module does not support resizing %s filesystem yet" % self.fstype)
230        except ValueError as err:
231            self.module.warn("unable to process %s output '%s'" % (self.INFO, to_native(err)))
232            self.module.fail_json(msg="unable to process %s output for %s" % (self.INFO, dev))
233
234        if not fssize_in_bytes < devsize_in_bytes:
235            self.module.exit_json(changed=False, msg="%s filesystem is using the whole device %s" % (self.fstype, dev))
236        elif self.module.check_mode:
237            self.module.exit_json(changed=True, msg="resizing filesystem %s on device %s" % (self.fstype, dev))
238
239        if self.GROW_MOUNTPOINT_ONLY:
240            mountpoint = dev.get_mountpoint()
241            if not mountpoint:
242                self.module.fail_json(msg="%s needs to be mounted for %s operations" % (dev, self.fstype))
243            grow_target = mountpoint
244        else:
245            grow_target = str(dev)
246
247        dummy, out, dummy = self.module.run_command(self.grow_cmd(grow_target), check_rc=True)
248        return out
249
250
251class Ext(Filesystem):
252    MKFS_FORCE_FLAGS = ['-F']
253    INFO = 'tune2fs'
254    GROW = 'resize2fs'
255
256    def get_fs_size(self, dev):
257        """Get Block count and Block size and return their product."""
258        cmd = self.module.get_bin_path(self.INFO, required=True)
259        dummy, out, dummy = self.module.run_command([cmd, '-l', str(dev)], check_rc=True, environ_update=self.LANG_ENV)
260
261        block_count = block_size = None
262        for line in out.splitlines():
263            if 'Block count:' in line:
264                block_count = int(line.split(':')[1].strip())
265            elif 'Block size:' in line:
266                block_size = int(line.split(':')[1].strip())
267            if None not in (block_size, block_count):
268                break
269        else:
270            raise ValueError(out)
271
272        return block_size * block_count
273
274
275class Ext2(Ext):
276    MKFS = 'mkfs.ext2'
277
278
279class Ext3(Ext):
280    MKFS = 'mkfs.ext3'
281
282
283class Ext4(Ext):
284    MKFS = 'mkfs.ext4'
285
286
287class XFS(Filesystem):
288    MKFS = 'mkfs.xfs'
289    MKFS_FORCE_FLAGS = ['-f']
290    INFO = 'xfs_info'
291    GROW = 'xfs_growfs'
292    GROW_MOUNTPOINT_ONLY = True
293
294    def get_fs_size(self, dev):
295        """Get bsize and blocks and return their product."""
296        cmdline = [self.module.get_bin_path(self.INFO, required=True)]
297
298        # Depending on the versions, xfs_info is able to get info from the
299        # device, whenever it is mounted or not, or only if unmounted, or
300        # only if mounted, or not at all. For any version until now, it is
301        # able to query info from the mountpoint. So try it first, and use
302        # device as the last resort: it may or may not work.
303        mountpoint = dev.get_mountpoint()
304        if mountpoint:
305            cmdline += [mountpoint]
306        else:
307            cmdline += [str(dev)]
308        dummy, out, dummy = self.module.run_command(cmdline, check_rc=True, environ_update=self.LANG_ENV)
309
310        block_size = block_count = None
311        for line in out.splitlines():
312            col = line.split('=')
313            if col[0].strip() == 'data':
314                if col[1].strip() == 'bsize':
315                    block_size = int(col[2].split()[0])
316                if col[2].split()[1] == 'blocks':
317                    block_count = int(col[3].split(',')[0])
318            if None not in (block_size, block_count):
319                break
320        else:
321            raise ValueError(out)
322
323        return block_size * block_count
324
325
326class Reiserfs(Filesystem):
327    MKFS = 'mkfs.reiserfs'
328    MKFS_FORCE_FLAGS = ['-q']
329
330
331class Btrfs(Filesystem):
332    MKFS = 'mkfs.btrfs'
333
334    def __init__(self, module):
335        super(Btrfs, self).__init__(module)
336        mkfs = self.module.get_bin_path(self.MKFS, required=True)
337        dummy, stdout, stderr = self.module.run_command([mkfs, '--version'], check_rc=True)
338        match = re.search(r" v([0-9.]+)", stdout)
339        if not match:
340            # v0.20-rc1 use stderr
341            match = re.search(r" v([0-9.]+)", stderr)
342        if match:
343            # v0.20-rc1 doesn't have --force parameter added in following version v3.12
344            if LooseVersion(match.group(1)) >= LooseVersion('3.12'):
345                self.MKFS_FORCE_FLAGS = ['-f']
346        else:
347            # assume version is greater or equal to 3.12
348            self.MKFS_FORCE_FLAGS = ['-f']
349            self.module.warn('Unable to identify mkfs.btrfs version (%r, %r)' % (stdout, stderr))
350
351
352class Ocfs2(Filesystem):
353    MKFS = 'mkfs.ocfs2'
354    MKFS_FORCE_FLAGS = ['-Fx']
355
356
357class F2fs(Filesystem):
358    MKFS = 'mkfs.f2fs'
359    INFO = 'dump.f2fs'
360    GROW = 'resize.f2fs'
361
362    def __init__(self, module):
363        super(F2fs, self).__init__(module)
364        mkfs = self.module.get_bin_path(self.MKFS, required=True)
365        dummy, out, dummy = self.module.run_command([mkfs, os.devnull], check_rc=False, environ_update=self.LANG_ENV)
366        # Looking for "	F2FS-tools: mkfs.f2fs Ver: 1.10.0 (2018-01-30)"
367        # mkfs.f2fs displays version since v1.2.0
368        match = re.search(r"F2FS-tools: mkfs.f2fs Ver: ([0-9.]+) \(", out)
369        if match is not None:
370            # Since 1.9.0, mkfs.f2fs check overwrite before make filesystem
371            # before that version -f switch wasn't used
372            if LooseVersion(match.group(1)) >= LooseVersion('1.9.0'):
373                self.MKFS_FORCE_FLAGS = ['-f']
374
375    def get_fs_size(self, dev):
376        """Get sector size and total FS sectors and return their product."""
377        cmd = self.module.get_bin_path(self.INFO, required=True)
378        dummy, out, dummy = self.module.run_command([cmd, str(dev)], check_rc=True, environ_update=self.LANG_ENV)
379        sector_size = sector_count = None
380        for line in out.splitlines():
381            if 'Info: sector size = ' in line:
382                # expected: 'Info: sector size = 512'
383                sector_size = int(line.split()[4])
384            elif 'Info: total FS sectors = ' in line:
385                # expected: 'Info: total FS sectors = 102400 (50 MB)'
386                sector_count = int(line.split()[5])
387            if None not in (sector_size, sector_count):
388                break
389        else:
390            raise ValueError(out)
391
392        return sector_size * sector_count
393
394
395class VFAT(Filesystem):
396    INFO = 'fatresize'
397    GROW = 'fatresize'
398    GROW_MAX_SPACE_FLAGS = ['-s', 'max']
399
400    def __init__(self, module):
401        super(VFAT, self).__init__(module)
402        if platform.system() == 'FreeBSD':
403            self.MKFS = 'newfs_msdos'
404        else:
405            self.MKFS = 'mkfs.vfat'
406
407    def get_fs_size(self, dev):
408        """Get and return size of filesystem, in bytes."""
409        cmd = self.module.get_bin_path(self.INFO, required=True)
410        dummy, out, dummy = self.module.run_command([cmd, '--info', str(dev)], check_rc=True, environ_update=self.LANG_ENV)
411        fssize = None
412        for line in out.splitlines()[1:]:
413            param, value = line.split(':', 1)
414            if param.strip() == 'Size':
415                fssize = int(value.strip())
416                break
417        else:
418            raise ValueError(out)
419
420        return fssize
421
422
423class LVM(Filesystem):
424    MKFS = 'pvcreate'
425    MKFS_FORCE_FLAGS = ['-f']
426    INFO = 'pvs'
427    GROW = 'pvresize'
428
429    def get_fs_size(self, dev):
430        """Get and return PV size, in bytes."""
431        cmd = self.module.get_bin_path(self.INFO, required=True)
432        dummy, size, dummy = self.module.run_command([cmd, '--noheadings', '-o', 'pv_size', '--units', 'b', '--nosuffix', str(dev)], check_rc=True)
433        pv_size = int(size)
434        return pv_size
435
436
437class Swap(Filesystem):
438    MKFS = 'mkswap'
439    MKFS_FORCE_FLAGS = ['-f']
440
441
442class UFS(Filesystem):
443    MKFS = 'newfs'
444    INFO = 'dumpfs'
445    GROW = 'growfs'
446    GROW_MAX_SPACE_FLAGS = ['-y']
447
448    def get_fs_size(self, dev):
449        """Get providersize and fragment size and return their product."""
450        cmd = self.module.get_bin_path(self.INFO, required=True)
451        dummy, out, dummy = self.module.run_command([cmd, str(dev)], check_rc=True, environ_update=self.LANG_ENV)
452
453        fragmentsize = providersize = None
454        for line in out.splitlines():
455            if line.startswith('fsize'):
456                fragmentsize = int(line.split()[1])
457            elif 'providersize' in line:
458                providersize = int(line.split()[-1])
459            if None not in (fragmentsize, providersize):
460                break
461        else:
462            raise ValueError(out)
463
464        return fragmentsize * providersize
465
466
467FILESYSTEMS = {
468    'ext2': Ext2,
469    'ext3': Ext3,
470    'ext4': Ext4,
471    'ext4dev': Ext4,
472    'f2fs': F2fs,
473    'reiserfs': Reiserfs,
474    'xfs': XFS,
475    'btrfs': Btrfs,
476    'vfat': VFAT,
477    'ocfs2': Ocfs2,
478    'LVM2_member': LVM,
479    'swap': Swap,
480    'ufs': UFS,
481}
482
483
484def main():
485    friendly_names = {
486        'lvm': 'LVM2_member',
487    }
488
489    fstypes = set(FILESYSTEMS.keys()) - set(friendly_names.values()) | set(friendly_names.keys())
490
491    # There is no "single command" to manipulate filesystems, so we map them all out and their options
492    module = AnsibleModule(
493        argument_spec=dict(
494            state=dict(type='str', default='present', choices=['present', 'absent']),
495            fstype=dict(type='str', aliases=['type'], choices=list(fstypes)),
496            dev=dict(type='path', required=True, aliases=['device']),
497            opts=dict(type='str'),
498            force=dict(type='bool', default=False),
499            resizefs=dict(type='bool', default=False),
500        ),
501        required_if=[
502            ('state', 'present', ['fstype'])
503        ],
504        supports_check_mode=True,
505    )
506
507    state = module.params['state']
508    dev = module.params['dev']
509    fstype = module.params['fstype']
510    opts = module.params['opts']
511    force = module.params['force']
512    resizefs = module.params['resizefs']
513
514    mkfs_opts = []
515    if opts is not None:
516        mkfs_opts = opts.split()
517
518    changed = False
519
520    if not os.path.exists(dev):
521        msg = "Device %s not found." % dev
522        if state == "present":
523            module.fail_json(msg=msg)
524        else:
525            module.exit_json(msg=msg)
526
527    dev = Device(module, dev)
528
529    # In case blkid/fstyp isn't able to identify an existing filesystem, device
530    # is considered as empty, then this existing filesystem would be overwritten
531    # even if force isn't enabled.
532    cmd = module.get_bin_path('blkid', required=True)
533    rc, raw_fs, err = module.run_command([cmd, '-c', os.devnull, '-o', 'value', '-s', 'TYPE', str(dev)])
534    fs = raw_fs.strip()
535    if not fs and platform.system() == 'FreeBSD':
536        cmd = module.get_bin_path('fstyp', required=True)
537        rc, raw_fs, err = module.run_command([cmd, str(dev)])
538        fs = raw_fs.strip()
539
540    if state == "present":
541        if fstype in friendly_names:
542            fstype = friendly_names[fstype]
543
544        try:
545            klass = FILESYSTEMS[fstype]
546        except KeyError:
547            module.fail_json(changed=False, msg="module does not support this filesystem (%s) yet." % fstype)
548
549        filesystem = klass(module)
550
551        same_fs = fs and FILESYSTEMS.get(fs) == FILESYSTEMS[fstype]
552        if same_fs and not resizefs and not force:
553            module.exit_json(changed=False)
554        elif same_fs and resizefs:
555            if not filesystem.GROW:
556                module.fail_json(changed=False, msg="module does not support resizing %s filesystem yet." % fstype)
557
558            out = filesystem.grow(dev)
559
560            module.exit_json(changed=True, msg=out)
561        elif fs and not force:
562            module.fail_json(msg="'%s' is already used as %s, use force=yes to overwrite" % (dev, fs), rc=rc, err=err)
563
564        # create fs
565        filesystem.create(mkfs_opts, dev)
566        changed = True
567
568    elif fs:
569        # wipe fs signatures
570        filesystem = Filesystem(module)
571        filesystem.wipefs(dev)
572        changed = True
573
574    module.exit_json(changed=changed)
575
576
577if __name__ == '__main__':
578    main()
579