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