From 217df077a79001641b0365ff8fcb642a0f08ec58 Mon Sep 17 00:00:00 2001 From: NdFeB Date: Tue, 14 Sep 2021 18:57:39 +0200 Subject: [PATCH] Add ephemeral state to mount fs without altering fstab --- changelogs/fragments/267_mount_ephemeral.yml | 4 + plugins/modules/mount.py | 161 +++++++++-- .../integration/targets/mount/tasks/main.yml | 273 ++++++++++++++++++ 3 files changed, 416 insertions(+), 22 deletions(-) create mode 100644 changelogs/fragments/267_mount_ephemeral.yml diff --git a/changelogs/fragments/267_mount_ephemeral.yml b/changelogs/fragments/267_mount_ephemeral.yml new file mode 100644 index 00000000000..56719167878 --- /dev/null +++ b/changelogs/fragments/267_mount_ephemeral.yml @@ -0,0 +1,4 @@ +--- +minor_changes: +- mount - Add ``ephemeral`` value for the ``state`` parameter, that allows to mount a filesystem + without altering the ``fstab`` file (https://github.com/ansible-collections/ansible.posix/pull/267). diff --git a/plugins/modules/mount.py b/plugins/modules/mount.py index 8b28f129ba0..7fdc77e5f45 100644 --- a/plugins/modules/mount.py +++ b/plugins/modules/mount.py @@ -31,12 +31,12 @@ src: description: - Device (or NFS volume, or something else) to be mounted on I(path). - - Required when I(state) set to C(present) or C(mounted). + - Required when I(state) set to C(present), C(mounted) or C(ephemeral). type: path fstype: description: - Filesystem type. - - Required when I(state) is C(present) or C(mounted). + - Required when I(state) is C(present), C(mounted) or C(ephemeral). type: str opts: description: @@ -48,7 +48,7 @@ - Note that if set to C(null) and I(state) set to C(present), it will cease to work and duplicate entries will be made with subsequent runs. - - Has no effect on Solaris systems. + - Has no effect on Solaris systems or when used with C(ephemeral). type: str default: 0 passno: @@ -57,7 +57,7 @@ - Note that if set to C(null) and I(state) set to C(present), it will cease to work and duplicate entries will be made with subsequent runs. - - Deprecated on Solaris systems. + - Deprecated on Solaris systems. Has no effect when used with C(ephemeral). type: str default: 0 state: @@ -68,6 +68,13 @@ - If C(unmounted), the device will be unmounted without changing I(fstab). - C(present) only specifies that the device is to be configured in I(fstab) and does not trigger or require a mount. + - C(ephemeral) only specifies that the device is to be mounted, without changing + I(fstab). If it is already mounted, a remount will be triggered. + This will always return changed=True. If the mount point I(path) + has already a device mounted on, and its source is different than I(src), + the module will fail to avoid unexpected unmount or mount point override. + If the mount point is not present, the mount point will be created. + The I(fstab) is completely ignored. - C(absent) specifies that the device mount's entry will be removed from I(fstab) and will also unmount the device and remove the mount point. @@ -77,10 +84,12 @@ applied to the remount, but will not change I(fstab). Additionally, if I(opts) is set, and the remount command fails, the module will error to prevent unexpected mount changes. Try using C(mounted) - instead to work around this issue. + instead to work around this issue. C(remounted) expects the mount point + to be present in the I(fstab). To remount a mount point not registered + in I(fstab), use C(ephemeral) instead, especially with BSD nodes. type: str required: true - choices: [ absent, mounted, present, unmounted, remounted ] + choices: [ absent, mounted, present, unmounted, remounted, ephemeral ] fstab: description: - File to use instead of C(/etc/fstab). @@ -89,6 +98,7 @@ - OpenBSD does not allow specifying alternate fstab files with mount so do not use this on OpenBSD with any state that operates on the live filesystem. - This parameter defaults to /etc/fstab or /etc/vfstab on Solaris. + - This parameter is ignored when I(state) is set to C(ephemeral). type: str boot: description: @@ -100,6 +110,7 @@ to mount options in I(/etc/fstab). - To avoid mount option conflicts, if C(noauto) specified in C(opts), mount module will ignore C(boot). + - This parameter is ignored when I(state) is set to C(ephemeral). type: bool default: yes backup: @@ -184,6 +195,14 @@ boot: no state: mounted fstype: nfs + +- name: Mount ephemeral SMB volume + ansible.posix.mount: + src: //192.168.1.200/share + path: /mnt/smb_share + opts: "rw,vers=3,file_mode=0600,dir_mode=0700,dom={{ ad_domain }},username={{ ad_username }},password={{ ad_password }}" + fstype: cifs + state: ephemeral ''' import errno @@ -430,6 +449,24 @@ def _set_fstab_args(fstab_file): return result +def _set_ephemeral_args(args): + result = [] + # Set fstype switch according to platform. SunOS/Solaris use -F + if platform.system().lower() == 'sunos': + result.append('-F') + else: + result.append('-t') + result.append(args['fstype']) + + # Even if '-o remount' is already set, specifying multiple -o is valid + if args['opts'] != 'defaults': + result += ['-o', args['opts']] + + result.append(args['src']) + + return result + + def mount(module, args): """Mount up a path or remount if needed.""" @@ -446,7 +483,11 @@ def mount(module, args): 'OpenBSD does not support alternate fstab files. Do not ' 'specify the fstab parameter for OpenBSD hosts')) else: - cmd += _set_fstab_args(args['fstab']) + if module.params['state'] != 'ephemeral': + cmd += _set_fstab_args(args['fstab']) + + if module.params['state'] == 'ephemeral': + cmd += _set_ephemeral_args(args) cmd += [name] @@ -498,18 +539,24 @@ def remount(module, args): 'OpenBSD does not support alternate fstab files. Do not ' 'specify the fstab parameter for OpenBSD hosts')) else: - cmd += _set_fstab_args(args['fstab']) + if module.params['state'] != 'ephemeral': + cmd += _set_fstab_args(args['fstab']) + + if module.params['state'] == 'ephemeral': + cmd += _set_ephemeral_args(args) cmd += [args['name']] out = err = '' try: - if platform.system().lower().endswith('bsd'): + if module.params['state'] != 'ephemeral' and platform.system().lower().endswith('bsd'): # Note: Forcing BSDs to do umount/mount due to BSD remount not # working as expected (suspect bug in the BSD mount command) # Interested contributor could rework this to use mount options on # the CLI instead of relying on fstab # https://github.com/ansible/ansible-modules-core/issues/5591 + # Note: this does not affect ephemeral state as all options + # are set on the CLI and fstab is expected to be ignored. rc = 1 else: rc, out, err = module.run_command(cmd) @@ -663,6 +710,47 @@ def get_linux_mounts(module, mntinfo_file="/proc/self/mountinfo"): return mounts +def _is_same_mount_src(module, src, mountpoint, linux_mounts): + """Return True if the mounted fs on mountpoint is the same source than src. Return False if mountpoint is not a mountpoint""" + # If the provided mountpoint is not a mountpoint, don't waste time + if ( + not ismount(mountpoint) and + not is_bind_mounted(module, linux_mounts, mountpoint)): + return False + + # Treat Linux bind mounts + if platform.system() == 'Linux' and linux_mounts is not None: + # For Linux bind mounts only: the mount command does not return + # the actual source for bind mounts, but the device of the source. + # is_bind_mounted() called with the 'src' parameter will return True if + # the mountpoint is a bind mount AND the source FS is the same than 'src'. + # is_bind_mounted() is not reliable on Solaris, NetBSD and OpenBSD. + # But we can rely on 'mount -v' on all other platforms, and Linux non-bind mounts. + if (is_bind_mounted(module, linux_mounts, mountpoint, src)): + return True + + # mount with parameter -v has a close behavior on Linux, *BSD, SunOS + # Requires -v with SunOS. Without -v, source and destination are reversed + # Output format differs from a system to another, but field[0:3] are consistent: [src, 'on', dest] + cmd = '%s -v' % module.get_bin_path('mount', required=True) + rc, out, err = module.run_command(cmd) + mounts = [] + + if len(out): + mounts = to_native(out).strip().split('\n') + else: + module.fail_json(msg="Unable to retrieve mount info with command '%s'" % cmd) + + for mnt in mounts: + fields = mnt.split() + mp_src = fields[0] + mp_dst = fields[2] + if mp_src == src and mp_dst == mountpoint: + return True + + return False + + def main(): module = AnsibleModule( argument_spec=dict( @@ -675,12 +763,13 @@ def main(): passno=dict(type='str', no_log=False), src=dict(type='path'), backup=dict(type='bool', default=False), - state=dict(type='str', required=True, choices=['absent', 'mounted', 'present', 'unmounted', 'remounted']), + state=dict(type='str', required=True, choices=['absent', 'mounted', 'present', 'unmounted', 'remounted', 'ephemeral']), ), supports_check_mode=True, required_if=( ['state', 'mounted', ['src', 'fstype']], ['state', 'present', ['src', 'fstype']], + ['state', 'ephemeral', ['src', 'fstype']] ), ) @@ -751,15 +840,17 @@ def main(): # If fstab file does not exist, we first need to create it. This mainly # happens when fstab option is passed to the module. - if not os.path.exists(args['fstab']): - if not os.path.exists(os.path.dirname(args['fstab'])): - os.makedirs(os.path.dirname(args['fstab'])) - try: - open(args['fstab'], 'a').close() - except PermissionError as e: - module.fail_json(msg="Failed to open %s due to permission issue" % args['fstab']) - except Exception as e: - module.fail_json(msg="Failed to open %s due to %s" % (args['fstab'], to_native(e))) + # If state is 'ephemeral', we do not need fstab file + if module.params['state'] != 'ephemeral': + if not os.path.exists(args['fstab']): + if not os.path.exists(os.path.dirname(args['fstab'])): + os.makedirs(os.path.dirname(args['fstab'])) + try: + open(args['fstab'], 'a').close() + except PermissionError as e: + module.fail_json(msg="Failed to open %s due to permission issue" % args['fstab']) + except Exception as e: + module.fail_json(msg="Failed to open %s due to %s" % (args['fstab'], to_native(e))) # absent: # Remove from fstab and unmounted. @@ -770,6 +861,8 @@ def main(): # mounted: # Add to fstab if not there and make sure it is mounted. If it has # changed in fstab then remount it. + # ephemeral: + # Do not change fstab state, but mount. state = module.params['state'] name = module.params['path'] @@ -801,7 +894,7 @@ def main(): msg="Error unmounting %s: %s" % (name, msg)) changed = True - elif state == 'mounted': + elif state == 'mounted' or state == 'ephemeral': dirs_created = [] if not os.path.exists(name) and not module.check_mode: try: @@ -829,7 +922,11 @@ def main(): module.fail_json( msg="Error making dir %s: %s" % (name, to_native(e))) - name, backup_lines, changed = _set_mount_save_old(module, args) + # ephemeral: completely ignore fstab + if state != 'ephemeral': + name, backup_lines, changed = _set_mount_save_old(module, args) + else: + name, backup_lines, changed = args['name'], [], False res = 0 if ( @@ -839,7 +936,26 @@ def main(): if changed and not module.check_mode: res, msg = remount(module, args) changed = True + + # When 'state' == 'ephemeral', we don't know what is in fstab, and 'changed' is always False + if state == 'ephemeral': + # If state == 'ephemeral', check if the mountpoint src == module.params['src'] + # If it doesn't, fail to prevent unwanted unmount or unwanted mountpoint override + if _is_same_mount_src(module, args['src'], args['name'], linux_mounts): + changed = True + if not module.check_mode: + res, msg = remount(module, args) + else: + module.fail_json( + msg=( + 'Ephemeral mount point is already mounted with a different ' + 'source than the specified one. Failing in order to prevent an ' + 'unwanted unmount or override operation. Try replacing this command with ' + 'a "state: unmounted" followed by a "state: ephemeral", or use ' + 'a different destination path.')) + else: + # If not already mounted, mount it changed = True if not module.check_mode: @@ -851,7 +967,8 @@ def main(): # A non-working fstab entry may break the system at the reboot, # so undo all the changes if possible. try: - write_fstab(module, backup_lines, args['fstab']) + if state != 'ephemeral': + write_fstab(module, backup_lines, args['fstab']) except Exception: pass diff --git a/tests/integration/targets/mount/tasks/main.yml b/tests/integration/targets/mount/tasks/main.yml index be1850fd5d8..7b0d141c7b7 100644 --- a/tests/integration/targets/mount/tasks/main.yml +++ b/tests/integration/targets/mount/tasks/main.yml @@ -1,3 +1,9 @@ +- name: Install dependencies + ansible.builtin.package: + name: e2fsprogs + state: present + when: ansible_system == 'Linux' + - name: Create the mount point file: state: directory @@ -406,3 +412,270 @@ - /tmp/myfs1 - /tmp/test_fstab when: ansible_system in ('Linux') + +- name: Block to test ephemeral option + environment: + PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + block: + - name: Create empty file A + community.general.filesize: + path: /tmp/myfs_A.img + size: 20M + + - name: Create empty file B + community.general.filesize: + path: /tmp/myfs_B.img + size: 20M + + - name: Register facts on Linux + ansible.builtin.set_fact: + ephemeral_device_A: /tmp/myfs_A.img + ephemeral_device_B: /tmp/myfs_B.img + ephemeral_fstype: ext3 + ephemeral_fstab: /etc/fstab + when: ansible_system == 'Linux' + + - name: Register facts on Solaris/SunOS + ansible.builtin.set_fact: + ephemeral_device_A: /dev/lofi/1 + ephemeral_device_B: /dev/lofi/2 + ephemeral_create_loop_dev_cmd: > + lofiadm -a /tmp/myfs_A.img /dev/lofi/1 && + lofiadm -a /tmp/myfs_B.img /dev/lofi/2 + ephemeral_remove_loop_dev_cmd: > + lofiadm -d /dev/lofi/1 && + lofiadm -d /dev/lofi/2 || true + ephemeral_fstype: ufs + ephemeral_fstab: /etc/vfstab + when: ansible_system == 'SunOS' + + - name: Register facts on FreeBSD + ansible.builtin.set_fact: + ephemeral_device_A: /dev/md1 + ephemeral_device_B: /dev/md2 + ephemeral_create_loop_dev_cmd: > + mdconfig -a -t vnode -f /tmp/myfs_A.img -u /dev/md1 && + mdconfig -a -t vnode -f /tmp/myfs_B.img -u /dev/md2 + ephemeral_remove_loop_dev_cmd: > + mdconfig -d -u /dev/md1 && + mdconfig -d -u /dev/md2 + ephemeral_fstype: ufs + ephemeral_fstab: /etc/fstab + when: ansible_system == 'FreeBSD' + + - name: Register facts on NetBSD + ansible.builtin.set_fact: + ephemeral_device_A: /dev/vnd1 + ephemeral_device_B: /dev/vnd2 + ephemeral_create_loop_dev_cmd: > + vnconfig /dev/vnd1 /tmp/myfs_A.img && + vnconfig /dev/vnd2 /tmp/myfs_B.img + ephemeral_remove_loop_dev_cmd: > + vnconfig -u /dev/vnd1 && + vnconfig -u /dev/vnd2 + ephemeral_fstype: ufs + ephemeral_fstab: /etc/fstab + when: ansible_system == 'NetBSD' + + - name: Register format fs command on Non-Linux and Non-OpenBSD + ansible.builtin.set_fact: + ephemeral_format_fs_cmd: > + yes | newfs {{ ephemeral_device_A }} && + yes | newfs {{ ephemeral_device_B }} + when: ansible_system in ('SunOS', 'FreeBSD', 'NetBSD') + + - name: Register facts on OpenBSD + ansible.builtin.set_fact: + ephemeral_device_A: /dev/vnd1c + ephemeral_device_B: /dev/vnd2c + ephemeral_create_loop_dev_cmd: > + vnconfig vnd1 /tmp/myfs_A.img && + vnconfig vnd2 /tmp/myfs_B.img + ephemeral_remove_loop_dev_cmd: > + vnconfig -u vnd1 && + vnconfig -u vnd2 + ephemeral_format_fs_cmd: > + yes | newfs /dev/rvnd1c && + yes | newfs /dev/rvnd2c + ephemeral_fstype: ffs + ephemeral_fstab: /etc/fstab + when: ansible_system == 'OpenBSD' + +##### FORMAT FS ON LINUX + + - name: Block to format FS on Linux + block: + - name: Format FS A on Linux + community.general.filesystem: + fstype: ext3 + dev: /tmp/myfs_A.img + + - name: Format FS B on Linux + community.general.filesystem: + fstype: ext3 + dev: /tmp/myfs_B.img + when: ansible_system == 'Linux' + +##### FORMAT FS ON SOLARIS AND BSD + + - name: Create loop devices on Solaris and BSD + ansible.builtin.shell: "{{ ephemeral_create_loop_dev_cmd }}" + when: ephemeral_create_loop_dev_cmd is defined + + - name: Format FS A and B on Solaris and BSD + ansible.builtin.shell: "{{ ephemeral_format_fs_cmd }}" + when: ephemeral_format_fs_cmd is defined + +##### TESTS + + - name: Create fstab if it does not exist + ansible.builtin.file: + path: "{{ ephemeral_fstab }}" + state: touch + + - name: Get checksum of /etc/fstab before mounting anything + stat: + path: '{{ ephemeral_fstab }}' + register: fstab_stat_before_mount + + - name: Mount the FS A with ephemeral state + mount: + path: /tmp/myfs + src: '{{ ephemeral_device_A }}' + fstype: '{{ ephemeral_fstype }}' + opts: rw + state: ephemeral + register: ephemeral_mount_info + + - name: Put something in the directory so we can do additional checks later on + copy: + content: 'Testing' + dest: /tmp/myfs/test_file + + - name: Get checksum of /etc/fstab after an ephemeral mount + stat: + path: '{{ ephemeral_fstab }}' + register: fstab_stat_after_mount + + - name: Get mountinfo + shell: mount -v | awk '{print $3}' | grep '^/tmp/myfs$' | wc -l + register: check_mountinfo + changed_when: no + + - name: Assert the mount occured and the fstab is unchanged + assert: + that: + - check_mountinfo.stdout|int == 1 + - ephemeral_mount_info['changed'] + - fstab_stat_before_mount['stat']['checksum'] == fstab_stat_after_mount['stat']['checksum'] + + - name: Get first mount record + shell: mount -v | grep '/tmp/myfs' + register: ephemeral_mount_record_1 + changed_when: no + + - name: Try to mount FS A where FS A is already mounted (should trigger remount and changed) + mount: + path: /tmp/myfs + src: '{{ ephemeral_device_A }}' + fstype: '{{ ephemeral_fstype }}' + opts: ro + state: ephemeral + register: ephemeral_mount_info + + - name: Get second mount record (should be different than the first) + shell: mount -v | grep '/tmp/myfs' + register: ephemeral_mount_record_2 + changed_when: no + + - name: Get mountinfo + shell: mount -v | awk '{print $3}' | grep '^/tmp/myfs$' | wc -l + register: check_mountinfo + changed_when: no + + - name: Assert the FS A is still mounted, the options changed and the fstab unchanged + assert: + that: + - check_mountinfo.stdout|int == 1 + - ephemeral_mount_record_1.stdout != ephemeral_mount_record_2.stdout + - ephemeral_mount_info['changed'] + - fstab_stat_before_mount['stat']['checksum'] == fstab_stat_after_mount['stat']['checksum'] + + - name: Try to mount file B on file A mountpoint (should fail) + mount: + path: /tmp/myfs + src: '{{ ephemeral_device_B }}' + fstype: '{{ ephemeral_fstype }}' + state: ephemeral + register: ephemeral_mount_b_info + ignore_errors: true + + - name: Get third mount record (should be the same than the second) + shell: mount -v | grep '/tmp/myfs' + register: ephemeral_mount_record_3 + changed_when: no + + - name: Get mountinfo + shell: mount -v | awk '{print $3}' | grep '^/tmp/myfs$' | wc -l + register: check_mountinfo + changed_when: no + + - name: Try to stat our test file + stat: + path: /tmp/myfs/test_file + register: test_file_stat + + - name: Assert that mounting FS B over FS A failed + assert: + that: + - check_mountinfo.stdout|int == 1 + - ephemeral_mount_record_2.stdout == ephemeral_mount_record_3.stdout + - test_file_stat['stat']['exists'] + - ephemeral_mount_b_info is failed + + - name: Unmount FS with state = unmounted + mount: + path: /tmp/myfs + state: unmounted + + - name: Get fstab checksum after unmounting an ephemeral mount with state = unmounted + stat: + path: '{{ ephemeral_fstab }}' + register: fstab_stat_after_unmount + + - name: Get mountinfo + shell: mount -v | awk '{print $3}' | grep '^/tmp/myfs$' | wc -l + register: check_mountinfo + changed_when: no + + - name: Try to stat our test file + stat: + path: /tmp/myfs/test_file + register: test_file_stat + + - name: Assert that fstab is unchanged after unmounting an ephemeral mount with state = unmounted + assert: + that: + - check_mountinfo.stdout|int == 0 + - not test_file_stat['stat']['exists'] + - fstab_stat_before_mount['stat']['checksum'] == fstab_stat_after_unmount['stat']['checksum'] + + always: + - name: Unmount potential failure relicas + mount: + path: /tmp/myfs + state: unmounted + + - name: Remove loop devices on Solaris and BSD + ansible.builtin.shell: "{{ ephemeral_remove_loop_dev_cmd }}" + when: ephemeral_remove_loop_dev_cmd is defined + + - name: Remove the test FS + file: + path: '{{ item }}' + state: absent + loop: + - /tmp/myfs_A.img + - /tmp/myfs_B.img + - /tmp/myfs + when: ansible_system in ('Linux', 'SunOS', 'FreeBSD', 'NetBSD', 'OpenBSD')