#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright: (c) 2012, Red Hat, inc
# Written by Seth Vidal
# based on the mount modules from salt and puppet
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type


DOCUMENTATION = r'''
---
module: mount
short_description: Control active and configured mount points
description:
  - This module controls active and configured mount points in C(/etc/fstab).
author:
  - Ansible Core Team
  - Seth Vidal (@skvidal)
version_added: "1.0.0"
options:
  path:
    description:
      - Path to the mount point (e.g. C(/mnt/files)).
      - Before Ansible 2.3 this option was only usable as I(dest), I(destfile) and I(name).
    type: path
    required: true
    aliases: [ name ]
  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).
    type: path
  fstype:
    description:
      - Filesystem type.
      - Required when I(state) is C(present) or C(mounted).
    type: str
  opts:
    description:
      - Mount options (see fstab(5), or vfstab(4) on Solaris).
    type: str
  dump:
    description:
      - Dump (see fstab(5)).
      - 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.
    type: str
    default: 0
  passno:
    description:
      - Passno (see fstab(5)).
      - 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.
    type: str
    default: 0
  state:
    description:
      - If C(mounted), the device will be actively mounted and appropriately
        configured in I(fstab). If the mount point is not present, the mount
        point will be created.
      - 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(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.
      - C(remounted) specifies that the device will be remounted for when you
        want to force a refresh on the mount itself (added in 2.9). This will
        always return changed=true. If I(opts) is set, the options will be
        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.
    type: str
    required: true
    choices: [ absent, mounted, present, unmounted, remounted ]
  fstab:
    description:
      - File to use instead of C(/etc/fstab).
      - You should not use this option unless you really know what you are doing.
      - This might be useful if you need to configure mountpoints in a chroot environment.
      - 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.
    type: str
  boot:
    description:
      - Determines if the filesystem should be mounted on boot.
      - Only applies to Solaris systems.
    type: bool
    default: yes
  backup:
    description:
      - Create a backup file including the timestamp information so you can get
        the original file back if you somehow clobbered it incorrectly.
    type: bool
    default: no
notes:
  - As of Ansible 2.3, the I(name) option has been changed to I(path) as
    default, but I(name) still works as well.
  - Using C(remounted) with I(opts) set may create unexpected results based on
    the existing options already defined on mount, so care should be taken to
    ensure that conflicting options are not present before hand.
'''

EXAMPLES = r'''
# Before 2.3, option 'name' was used instead of 'path'
- name: Mount DVD read-only
  ansible.posix.mount:
    path: /mnt/dvd
    src: /dev/sr0
    fstype: iso9660
    opts: ro,noauto
    state: present

- name: Mount up device by label
  ansible.posix.mount:
    path: /srv/disk
    src: LABEL=SOME_LABEL
    fstype: ext4
    state: present

- name: Mount up device by UUID
  ansible.posix.mount:
    path: /home
    src: UUID=b3e48f45-f933-4c8e-a700-22a159ec9077
    fstype: xfs
    opts: noatime
    state: present

- name: Unmount a mounted volume
  ansible.posix.mount:
    path: /tmp/mnt-pnt
    state: unmounted

- name: Remount a mounted volume
  ansible.posix.mount:
    path: /tmp/mnt-pnt
    state: remounted

# The following will not save changes to fstab, and only be temporary until
# a reboot, or until calling "state: unmounted" followed by "state: mounted"
# on the same "path"
- name: Remount a mounted volume and append exec to the existing options
  ansible.posix.mount:
    path: /tmp
    state: remounted
    opts: exec

- name: Mount and bind a volume
  ansible.posix.mount:
    path: /system/new_volume/boot
    src: /boot
    opts: bind
    state: mounted
    fstype: none

- name: Mount an NFS volume
  ansible.posix.mount:
    src: 192.168.1.100:/nfs/ssd/shared_data
    path: /mnt/shared_data
    opts: rw,sync,hard,intr
    state: mounted
    fstype: nfs
'''


import errno
import os
import platform

from ansible.module_utils.basic import AnsibleModule
from ansible_collections.ansible.posix.plugins.module_utils.mount import ismount
from ansible.module_utils.six import iteritems
from ansible.module_utils._text import to_bytes, to_native


def write_fstab(module, lines, path):
    if module.params['backup']:
        module.backup_local(path)

    fs_w = open(path, 'w')

    for l in lines:
        fs_w.write(l)

    fs_w.flush()
    fs_w.close()


def _escape_fstab(v):
    """Escape invalid characters in fstab fields.

    space (040)
    ampersand (046)
    backslash (134)
    """

    if isinstance(v, int):
        return v
    else:
        return(
            v.
            replace('\\', '\\134').
            replace(' ', '\\040').
            replace('&', '\\046'))


def set_mount(module, args):
    """Set/change a mount point location in fstab."""
    name, backup_lines, changed = _set_mount_save_old(module, args)
    return name, changed


def _set_mount_save_old(module, args):
    """Set/change a mount point location in fstab. Save the old fstab contents."""

    to_write = []
    old_lines = []
    exists = False
    changed = False
    escaped_args = dict([(k, _escape_fstab(v)) for k, v in iteritems(args)])
    new_line = '%(src)s %(name)s %(fstype)s %(opts)s %(dump)s %(passno)s\n'

    if platform.system() == 'SunOS':
        new_line = (
            '%(src)s - %(name)s %(fstype)s %(passno)s %(boot)s %(opts)s\n')

    for line in open(args['fstab'], 'r').readlines():
        old_lines.append(line)

        if not line.strip():
            to_write.append(line)

            continue

        if line.strip().startswith('#'):
            to_write.append(line)

            continue

        fields = line.split()

        # Check if we got a valid line for splitting
        # (on Linux the 5th and the 6th field is optional)
        if (
                platform.system() == 'SunOS' and len(fields) != 7 or
                platform.system() == 'Linux' and len(fields) not in [4, 5, 6] or
                platform.system() not in ['SunOS', 'Linux'] and len(fields) != 6):
            to_write.append(line)

            continue

        ld = {}

        if platform.system() == 'SunOS':
            (
                ld['src'],
                dash,
                ld['name'],
                ld['fstype'],
                ld['passno'],
                ld['boot'],
                ld['opts']
            ) = fields
        else:
            fields_labels = ['src', 'name', 'fstype', 'opts', 'dump', 'passno']

            # The last two fields are optional on Linux so we fill in default values
            ld['dump'] = 0
            ld['passno'] = 0

            # Fill in the rest of the available fields
            for i, field in enumerate(fields):
                ld[fields_labels[i]] = field

        # Check if we found the correct line
        if (
                ld['name'] != escaped_args['name'] or (
                    # In the case of swap, check the src instead
                    'src' in args and
                    ld['name'] == 'none' and
                    ld['fstype'] == 'swap' and
                    ld['src'] != args['src'])):
            to_write.append(line)

            continue

        # If we got here we found a match - let's check if there is any
        # difference
        exists = True
        args_to_check = ('src', 'fstype', 'opts', 'dump', 'passno')

        if platform.system() == 'SunOS':
            args_to_check = ('src', 'fstype', 'passno', 'boot', 'opts')

        for t in args_to_check:
            if ld[t] != escaped_args[t]:
                ld[t] = escaped_args[t]
                changed = True

        if changed:
            to_write.append(new_line % ld)
        else:
            to_write.append(line)

    if not exists:
        to_write.append(new_line % escaped_args)
        changed = True

    if changed and not module.check_mode:
        write_fstab(module, to_write, args['fstab'])

    return (args['name'], old_lines, changed)


def unset_mount(module, args):
    """Remove a mount point from fstab."""

    to_write = []
    changed = False
    escaped_name = _escape_fstab(args['name'])

    for line in open(args['fstab'], 'r').readlines():
        if not line.strip():
            to_write.append(line)

            continue

        if line.strip().startswith('#'):
            to_write.append(line)

            continue

        # Check if we got a valid line for splitting
        if (
                platform.system() == 'SunOS' and len(line.split()) != 7 or
                platform.system() != 'SunOS' and len(line.split()) != 6):
            to_write.append(line)

            continue

        ld = {}

        if platform.system() == 'SunOS':
            (
                ld['src'],
                dash,
                ld['name'],
                ld['fstype'],
                ld['passno'],
                ld['boot'],
                ld['opts']
            ) = line.split()
        else:
            (
                ld['src'],
                ld['name'],
                ld['fstype'],
                ld['opts'],
                ld['dump'],
                ld['passno']
            ) = line.split()

        if (
                ld['name'] != escaped_name or (
                    # In the case of swap, check the src instead
                    'src' in args and
                    ld['name'] == 'none' and
                    ld['fstype'] == 'swap' and
                    ld['src'] != args['src'])):
            to_write.append(line)

            continue

        # If we got here we found a match - continue and mark changed
        changed = True

    if changed and not module.check_mode:
        write_fstab(module, to_write, args['fstab'])

    return (args['name'], changed)


def _set_fstab_args(fstab_file):
    result = []

    if (
            fstab_file and
            fstab_file != '/etc/fstab' and
            platform.system().lower() != 'sunos'):
        if platform.system().lower().endswith('bsd'):
            result.append('-F')
        else:
            result.append('-T')

        result.append(fstab_file)

    return result


def mount(module, args):
    """Mount up a path or remount if needed."""

    mount_bin = module.get_bin_path('mount', required=True)
    name = args['name']
    cmd = [mount_bin]

    if platform.system().lower() == 'openbsd':
        # Use module.params['fstab'] here as args['fstab'] has been set to the
        # default value.
        if module.params['fstab'] is not None:
            module.fail_json(
                msg=(
                    'OpenBSD does not support alternate fstab files. Do not '
                    'specify the fstab parameter for OpenBSD hosts'))
    else:
        cmd += _set_fstab_args(args['fstab'])

    cmd += [name]

    rc, out, err = module.run_command(cmd)

    if rc == 0:
        return 0, ''
    else:
        return rc, out + err


def umount(module, path):
    """Unmount a path."""

    umount_bin = module.get_bin_path('umount', required=True)
    cmd = [umount_bin, path]

    rc, out, err = module.run_command(cmd)

    if rc == 0:
        return 0, ''
    else:
        return rc, out + err


def remount(module, args):
    """Try to use 'remount' first and fallback to (u)mount if unsupported."""
    mount_bin = module.get_bin_path('mount', required=True)
    cmd = [mount_bin]

    # Multiplatform remount opts
    if platform.system().lower().endswith('bsd'):
        if module.params['state'] == 'remounted' and args['opts'] != 'defaults':
            cmd += ['-u', '-o', args['opts']]
        else:
            cmd += ['-u']
    else:
        if module.params['state'] == 'remounted' and args['opts'] != 'defaults':
            cmd += ['-o', 'remount,' + args['opts']]
        else:
            cmd += ['-o', 'remount']

    if platform.system().lower() == 'openbsd':
        # Use module.params['fstab'] here as args['fstab'] has been set to the
        # default value.
        if module.params['fstab'] is not None:
            module.fail_json(
                msg=(
                    'OpenBSD does not support alternate fstab files. Do not '
                    'specify the fstab parameter for OpenBSD hosts'))
    else:
        cmd += _set_fstab_args(args['fstab'])

    cmd += [args['name']]
    out = err = ''

    try:
        if 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
            rc = 1
        else:
            rc, out, err = module.run_command(cmd)
    except Exception:
        rc = 1

    msg = ''

    if rc != 0:
        msg = out + err

        if module.params['state'] == 'remounted' and args['opts'] != 'defaults':
            module.fail_json(
                msg=(
                    'Options were specified with remounted, but the remount '
                    'command failed. Failing in order to prevent an '
                    'unexpected mount result. Try replacing this command with '
                    'a "state: unmounted" followed by a "state: mounted" '
                    'using the full desired mount options instead.'))

        rc, msg = umount(module, args['name'])

        if rc == 0:
            rc, msg = mount(module, args)

    return rc, msg


# Note if we wanted to put this into module_utils we'd have to get permission
# from @jupeter -- https://github.com/ansible/ansible-modules-core/pull/2923
# @jtyr -- https://github.com/ansible/ansible-modules-core/issues/4439
# and @abadger to relicense from GPLv3+
def is_bind_mounted(module, linux_mounts, dest, src=None, fstype=None):
    """Return whether the dest is bind mounted

    :arg module: The AnsibleModule (used for helper functions)
    :arg dest: The directory to be mounted under. This is the primary means
        of identifying whether the destination is mounted.
    :kwarg src: The source directory. If specified, this is used to help
        ensure that we are detecting that the correct source is mounted there.
    :kwarg fstype: The filesystem type. If specified this is also used to
        help ensure that we are detecting the right mount.
    :kwarg linux_mounts: Cached list of mounts for Linux.
    :returns: True if the dest is mounted with src otherwise False.
    """

    is_mounted = False

    if platform.system() == 'Linux' and linux_mounts is not None:
        if src is None:
            # That's for unmounted/absent
            if dest in linux_mounts:
                is_mounted = True
        else:
            if dest in linux_mounts:
                is_mounted = linux_mounts[dest]['src'] == src

    else:
        bin_path = module.get_bin_path('mount', required=True)
        cmd = '%s -l' % bin_path
        rc, out, err = module.run_command(cmd)
        mounts = []

        if len(out):
            mounts = to_native(out).strip().split('\n')

        for mnt in mounts:
            arguments = mnt.split()

            if (
                    (arguments[0] == src or src is None) and
                    arguments[2] == dest and
                    (arguments[4] == fstype or fstype is None)):
                is_mounted = True

            if is_mounted:
                break

    return is_mounted


def get_linux_mounts(module, mntinfo_file="/proc/self/mountinfo"):
    """Gather mount information"""

    try:
        f = open(mntinfo_file)
    except IOError:
        return

    lines = map(str.strip, f.readlines())

    try:
        f.close()
    except IOError:
        module.fail_json(msg="Cannot close file %s" % mntinfo_file)

    mntinfo = {}

    for line in lines:
        fields = line.split()

        record = {
            'id': int(fields[0]),
            'parent_id': int(fields[1]),
            'root': fields[3],
            'dst': fields[4],
            'opts': fields[5],
            'fs': fields[-3],
            'src': fields[-2]
        }

        mntinfo[record['id']] = record

    mounts = {}

    for mnt in mntinfo.values():
        if mnt['parent_id'] != 1 and mnt['parent_id'] in mntinfo:
            m = mntinfo[mnt['parent_id']]
            if (
                    len(m['root']) > 1 and
                    mnt['root'].startswith("%s/" % m['root'])):
                # Omit the parent's root in the child's root
                # == Example:
                # 140 136 253:2 /rootfs / rw - ext4 /dev/sdb2 rw
                # 141 140 253:2 /rootfs/tmp/aaa /tmp/bbb rw - ext4 /dev/sdb2 rw
                # == Expected result:
                # src=/tmp/aaa
                mnt['root'] = mnt['root'][len(m['root']):]

            # Prepend the parent's dst to the child's root
            # == Example:
            # 42 60 0:35 / /tmp rw - tmpfs tmpfs rw
            # 78 42 0:35 /aaa /tmp/bbb rw - tmpfs tmpfs rw
            # == Expected result:
            # src=/tmp/aaa
            if m['dst'] != '/':
                mnt['root'] = "%s%s" % (m['dst'], mnt['root'])
            src = mnt['root']
        else:
            src = mnt['src']

        record = {
            'dst': mnt['dst'],
            'src': src,
            'opts': mnt['opts'],
            'fs': mnt['fs']
        }

        mounts[mnt['dst']] = record

    return mounts


def main():
    module = AnsibleModule(
        argument_spec=dict(
            boot=dict(type='bool', default=True),
            dump=dict(type='str'),
            fstab=dict(type='str'),
            fstype=dict(type='str'),
            path=dict(type='path', required=True, aliases=['name']),
            opts=dict(type='str'),
            passno=dict(type='str'),
            src=dict(type='path'),
            backup=dict(type='bool', default=False),
            state=dict(type='str', required=True, choices=['absent', 'mounted', 'present', 'unmounted', 'remounted']),
        ),
        supports_check_mode=True,
        required_if=(
            ['state', 'mounted', ['src', 'fstype']],
            ['state', 'present', ['src', 'fstype']],
        ),
    )

    # solaris args:
    #   name, src, fstype, opts, boot, passno, state, fstab=/etc/vfstab
    # linux args:
    #   name, src, fstype, opts, dump, passno, state, fstab=/etc/fstab
    # Note: Do not modify module.params['fstab'] as we need to know if the user
    # explicitly specified it in mount() and remount()
    if platform.system().lower() == 'sunos':
        args = dict(
            name=module.params['path'],
            opts='-',
            passno='-',
            fstab=module.params['fstab'],
            boot='yes'
        )
        if args['fstab'] is None:
            args['fstab'] = '/etc/vfstab'
    else:
        args = dict(
            name=module.params['path'],
            opts='defaults',
            dump='0',
            passno='0',
            fstab=module.params['fstab']
        )
        if args['fstab'] is None:
            args['fstab'] = '/etc/fstab'

        # FreeBSD doesn't have any 'default' so set 'rw' instead
        if platform.system() == 'FreeBSD':
            args['opts'] = 'rw'

    linux_mounts = []

    # Cache all mounts here in order we have consistent results if we need to
    # call is_bind_mounted() multiple times
    if platform.system() == 'Linux':
        linux_mounts = get_linux_mounts(module)

        if linux_mounts is None:
            args['warnings'] = (
                'Cannot open file /proc/self/mountinfo. '
                'Bind mounts might be misinterpreted.')

    # Override defaults with user specified params
    for key in ('src', 'fstype', 'passno', 'opts', 'dump', 'fstab'):
        if module.params[key] is not None:
            args[key] = module.params[key]

    # 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)))

    # absent:
    #   Remove from fstab and unmounted.
    # unmounted:
    #   Do not change fstab state, but unmount.
    # present:
    #   Add to fstab, do not change mount state.
    # mounted:
    #   Add to fstab if not there and make sure it is mounted. If it has
    #   changed in fstab then remount it.

    state = module.params['state']
    name = module.params['path']
    changed = False

    if state == 'absent':
        name, changed = unset_mount(module, args)

        if changed and not module.check_mode:
            if ismount(name) or is_bind_mounted(module, linux_mounts, name):
                res, msg = umount(module, name)

                if res:
                    module.fail_json(
                        msg="Error unmounting %s: %s" % (name, msg))

            if os.path.exists(name):
                try:
                    os.rmdir(name)
                except (OSError, IOError) as e:
                    module.fail_json(msg="Error rmdir %s: %s" % (name, to_native(e)))
    elif state == 'unmounted':
        if ismount(name) or is_bind_mounted(module, linux_mounts, name):
            if not module.check_mode:
                res, msg = umount(module, name)

                if res:
                    module.fail_json(
                        msg="Error unmounting %s: %s" % (name, msg))

            changed = True
    elif state == 'mounted':
        dirs_created = []
        if not os.path.exists(name) and not module.check_mode:
            try:
                # Something like mkdir -p but with the possibility to undo.
                # Based on some copy-paste from the "file" module.
                curpath = ''
                for dirname in name.strip('/').split('/'):
                    curpath = '/'.join([curpath, dirname])
                    # Remove leading slash if we're creating a relative path
                    if not os.path.isabs(name):
                        curpath = curpath.lstrip('/')

                    b_curpath = to_bytes(curpath, errors='surrogate_or_strict')
                    if not os.path.exists(b_curpath):
                        try:
                            os.mkdir(b_curpath)
                            dirs_created.append(b_curpath)
                        except OSError as ex:
                            # Possibly something else created the dir since the os.path.exists
                            # check above. As long as it's a dir, we don't need to error out.
                            if not (ex.errno == errno.EEXIST and os.path.isdir(b_curpath)):
                                raise

            except (OSError, IOError) as e:
                module.fail_json(
                    msg="Error making dir %s: %s" % (name, to_native(e)))

        name, backup_lines, changed = _set_mount_save_old(module, args)
        res = 0

        if (
                ismount(name) or
                is_bind_mounted(
                    module, linux_mounts, name, args['src'], args['fstype'])):
            if changed and not module.check_mode:
                res, msg = remount(module, args)
                changed = True
        else:
            changed = True

            if not module.check_mode:
                res, msg = mount(module, args)

        if res:
            # Not restoring fstab after a failed mount was reported as a bug,
            # ansible/ansible#59183
            # 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'])
            except Exception:
                pass

            try:
                for dirname in dirs_created[::-1]:
                    os.rmdir(dirname)
            except Exception:
                pass

            module.fail_json(msg="Error mounting %s: %s" % (name, msg))
    elif state == 'present':
        name, changed = set_mount(module, args)
    elif state == 'remounted':
        if not module.check_mode:
            res, msg = remount(module, args)

            if res:
                module.fail_json(msg="Error remounting %s: %s" % (name, msg))

        changed = True
    else:
        module.fail_json(msg='Unexpected position reached')

    module.exit_json(changed=changed, **args)


if __name__ == '__main__':
    main()
