Ensure that a patch has been applied to the specified file or directory

    .. versionchanged:: 2019.2.0
        The ``hash`` and ``dry_run_first`` options are now ignored, as the
        logic which determines whether or not the patch has already been
        applied no longer requires them. Additionally, this state now supports
        patch files that modify more than one file. To use these sort of
        patches, specify a directory (and, if necessary, the ``strip`` option)
        instead of a file.

    .. note::
        A suitable ``patch`` executable must be available on the minion. Also,
        keep in mind that the pre-check this state does to determine whether or
        not changes need to be made will create a temp file and send all patch
        output to that file. This means that, in the event that the patch would
        not have applied cleanly, the comment included in the state results will
        reference a temp file that will no longer exist once the state finishes
        running.

    name
        The file or directory to which the patch should be applied

    source
        The patch file to apply

        .. versionchanged:: 2019.2.0
            The source can now be from any file source supported by Salt
            (``salt://``, ``http://``, ``https://``, ``ftp://``, etc.).
            Templating is also now supported.

    source_hash
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`.

        .. versionadded:: 2019.2.0

    source_hash_name
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    skip_verify
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    template
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    context
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    defaults
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    options
        Extra options to pass to patch. This should not be necessary in most
        cases.

        .. note::
            For best results, short opts should be separate from one another.
            The ``-N`` and ``-r``, and ``-o`` options are used internally by
            this state and cannot be used here. Additionally, instead of using
            ``-pN`` or ``--strip=N``, use the ``strip`` option documented
            below.

    reject_file
        If specified, any rejected hunks will be written to this file. If not
        specified, then they will be written to a temp file which will be
        deleted when the state finishes running.

        .. important::
            The parent directory must exist. Also, this will overwrite the file
            if it is already present.

        .. versionadded:: 2019.2.0

    strip
        Number of directories to strip from paths in the patch file. For
        example, using the below SLS would instruct Salt to use ``-p1`` when
        applying the patch:

        .. code-block:: yaml

            /etc/myfile.conf:
              file.patch:
                - source: salt://myfile.patch
                - strip: 1

        .. versionadded:: 2019.2.0
            In previous versions, ``-p1`` would need to be passed as part of
            the ``options`` value.

    saltenv
        Specify the environment from which to retrieve the patch file indicated
        by the ``source`` parameter. If not provided, this defaults to the
        environment from which the state is being executed.

        .. note::
            Ignored when the patch file is from a non-``salt://`` source.

    **Usage:**

    .. code-block:: yaml

        # Equivalent to ``patch --forward /opt/myfile.txt myfile.patch``
        /opt/myfile.txt:
          file.patch:
            - source: salt://myfile.patch

def patch(name,
          source=None,
          source_hash=None,
          source_hash_name=None,
          skip_verify=False,
          template=None,
          context=None,
          defaults=None,
          options='',
          reject_file=None,
          strip=None,
          saltenv=None,
          **kwargs):
    '''
    Ensure that a patch has been applied to the specified file or directory

    .. versionchanged:: 2019.2.0
        The ``hash`` and ``dry_run_first`` options are now ignored, as the
        logic which determines whether or not the patch has already been
        applied no longer requires them. Additionally, this state now supports
        patch files that modify more than one file. To use these sort of
        patches, specify a directory (and, if necessary, the ``strip`` option)
        instead of a file.

    .. note::
        A suitable ``patch`` executable must be available on the minion. Also,
        keep in mind that the pre-check this state does to determine whether or
        not changes need to be made will create a temp file and send all patch
        output to that file. This means that, in the event that the patch would
        not have applied cleanly, the comment included in the state results will
        reference a temp file that will no longer exist once the state finishes
        running.

    name
        The file or directory to which the patch should be applied

    source
        The patch file to apply

        .. versionchanged:: 2019.2.0
            The source can now be from any file source supported by Salt
            (``salt://``, ``http://``, ``https://``, ``ftp://``, etc.).
            Templating is also now supported.

    source_hash
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`.

        .. versionadded:: 2019.2.0

    source_hash_name
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    skip_verify
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    template
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    context
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    defaults
        Works the same way as in :py:func:`file.managed
        <salt.states.file.managed>`

        .. versionadded:: 2019.2.0

    options
        Extra options to pass to patch. This should not be necessary in most
        cases.

        .. note::
            For best results, short opts should be separate from one another.
            The ``-N`` and ``-r``, and ``-o`` options are used internally by
            this state and cannot be used here. Additionally, instead of using
            ``-pN`` or ``--strip=N``, use the ``strip`` option documented
            below.

    reject_file
        If specified, any rejected hunks will be written to this file. If not
        specified, then they will be written to a temp file which will be
        deleted when the state finishes running.

        .. important::
            The parent directory must exist. Also, this will overwrite the file
            if it is already present.

        .. versionadded:: 2019.2.0

    strip
        Number of directories to strip from paths in the patch file. For
        example, using the below SLS would instruct Salt to use ``-p1`` when
        applying the patch:

        .. code-block:: yaml

            /etc/myfile.conf:
              file.patch:
                - source: salt://myfile.patch
                - strip: 1

        .. versionadded:: 2019.2.0
            In previous versions, ``-p1`` would need to be passed as part of
            the ``options`` value.

    saltenv
        Specify the environment from which to retrieve the patch file indicated
        by the ``source`` parameter. If not provided, this defaults to the
        environment from which the state is being executed.

        .. note::
            Ignored when the patch file is from a non-``salt://`` source.

    **Usage:**

    .. code-block:: yaml

        # Equivalent to ``patch --forward /opt/myfile.txt myfile.patch``
        /opt/myfile.txt:
          file.patch:
            - source: salt://myfile.patch
    '''
    ret = {'name': name, 'changes': {}, 'result': False, 'comment': ''}

    if not salt.utils.path.which('patch'):
        ret['comment'] = 'patch executable not found on minion'
        return ret

    # is_dir should be defined if we proceed past the if/else block below, but
    # just in case, avoid a NameError.
    is_dir = False

    if not name:
        ret['comment'] = 'A file/directory to be patched is required'
        return ret
    else:
        try:
            name = os.path.expanduser(name)
        except Exception:
            ret['comment'] = 'Invalid path \'{0}\''.format(name)
            return ret
        else:
            if not os.path.isabs(name):
                ret['comment'] = '{0} is not an absolute path'.format(name)
                return ret
            elif not os.path.exists(name):
                ret['comment'] = '{0} does not exist'.format(name)
                return ret
            else:
                is_dir = os.path.isdir(name)

    for deprecated_arg in ('hash', 'dry_run_first'):
        if deprecated_arg in kwargs:
            ret.setdefault('warnings', []).append(
                'The \'{0}\' argument is no longer used and has been '
                'ignored.'.format(deprecated_arg)
            )

    if reject_file is not None:
        try:
            reject_file_parent = os.path.dirname(reject_file)
        except Exception:
            ret['comment'] = 'Invalid path \'{0}\' for reject_file'.format(
                reject_file
            )
            return ret
        else:
            if not os.path.isabs(reject_file_parent):
                ret['comment'] = '\'{0}\' is not an absolute path'.format(
                    reject_file
                )
                return ret
            elif not os.path.isdir(reject_file_parent):
                ret['comment'] = (
                    'Parent directory for reject_file \'{0}\' either does '
                    'not exist, or is not a directory'.format(reject_file)
                )
                return ret

    sanitized_options = []
    options = salt.utils.args.shlex_split(options)
    index = 0
    max_index = len(options) - 1
    # Not using enumerate here because we may need to consume more than one
    # option if --strip is used.
    blacklisted_options = []
    while index <= max_index:
        option = options[index]
        if not isinstance(option, six.string_types):
            option = six.text_type(option)

        for item in ('-N', '--forward', '-r', '--reject-file', '-o', '--output'):
            if option.startswith(item):
                blacklisted = option
                break
        else:
            blacklisted = None

        if blacklisted is not None:
            blacklisted_options.append(blacklisted)

        if option.startswith('-p'):
            try:
                strip = int(option[2:])
            except Exception:
                ret['comment'] = (
                    'Invalid format for \'-p\' CLI option. Consider using '
                    'the \'strip\' option for this state.'
                )
                return ret
        elif option.startswith('--strip'):
            if '=' in option:
                # Assume --strip=N
                try:
                    strip = int(option.rsplit('=', 1)[-1])
                except Exception:
                    ret['comment'] = (
                        'Invalid format for \'-strip\' CLI option. Consider '
                        'using the \'strip\' option for this state.'
                    )
                    return ret
            else:
                # Assume --strip N and grab the next option in the list
                try:
                    strip = int(options[index + 1])
                except Exception:
                    ret['comment'] = (
                        'Invalid format for \'-strip\' CLI option. Consider '
                        'using the \'strip\' option for this state.'
                    )
                    return ret
                else:
                    # We need to increment again because we grabbed the next
                    # option in the list.
                    index += 1
        else:
            sanitized_options.append(option)

        # Increment the index
        index += 1

    if blacklisted_options:
        ret['comment'] = (
            'The following CLI options are not allowed: {0}'.format(
                ', '.join(blacklisted_options)
            )
        )
        return ret

    options = sanitized_options

    try:
        source_match = __salt__['file.source_list'](source,
                                                    source_hash,
                                                    __env__)[0]
    except CommandExecutionError as exc:
        ret['result'] = False
        ret['comment'] = exc.strerror
        return ret
    else:
        # Passing the saltenv to file.managed to pull down the patch file is
        # not supported, because the saltenv is already being passed via the
        # state compiler and this would result in two values for that argument
        # (and a traceback). Therefore, we will add the saltenv to the source
        # URL to ensure we pull the file from the correct environment.
        if saltenv is not None:
            source_match_url, source_match_saltenv = \
                salt.utils.url.parse(source_match)
            if source_match_url.startswith('salt://'):
                if source_match_saltenv is not None \
                        and source_match_saltenv != saltenv:
                    ret.setdefault('warnings', []).append(
                        'Ignoring \'saltenv\' option in favor of saltenv '
                        'included in the source URL.'
                    )
                else:
                    source_match += '?saltenv={0}'.format(saltenv)

    cleanup = []

    try:
        patch_file = salt.utils.files.mkstemp()
        cleanup.append(patch_file)

        try:
            orig_test = __opts__['test']
            __opts__['test'] = False
            sys.modules[__salt__['test.ping'].__module__].__opts__['test'] = False
            result = managed(patch_file,
                             source=source_match,
                             source_hash=source_hash,
                             source_hash_name=source_hash_name,
                             skip_verify=skip_verify,
                             template=template,
                             context=context,
                             defaults=defaults)
        except Exception as exc:
            msg = 'Failed to cache patch file {0}: {1}'.format(
                salt.utils.url.redact_http_basic_auth(source_match),
                exc
            )
            log.exception(msg)
            ret['comment'] = msg
            return ret
        else:
            log.debug('file.managed: %s', result)
        finally:
            __opts__['test'] = orig_test
            sys.modules[__salt__['test.ping'].__module__].__opts__['test'] = orig_test

        if not result['result']:
            log.debug(
                'failed to download %s',
                salt.utils.url.redact_http_basic_auth(source_match)
            )
            return result

        def _patch(patch_file, options=None, dry_run=False):
            patch_opts = copy.copy(sanitized_options)
            if options is not None:
                patch_opts.extend(options)
            return __salt__['file.patch'](
                name,
                patch_file,
                options=patch_opts,
                dry_run=dry_run)

        if reject_file is not None:
            patch_rejects = reject_file
        else:
            # No rejects file specified, create a temp file
            patch_rejects = salt.utils.files.mkstemp()
            cleanup.append(patch_rejects)

        patch_output = salt.utils.files.mkstemp()
        cleanup.append(patch_output)

        # Older patch releases can only write patch output to regular files,
        # meaning that /dev/null can't be relied on. Also, if we ever want this
        # to work on Windows with patch.exe, /dev/null is a non-starter.
        # Therefore, redirect all patch output to a temp file, which we will
        # then remove.
        patch_opts = ['-N', '-r', patch_rejects, '-o', patch_output]
        if is_dir and strip is not None:
            patch_opts.append('-p{0}'.format(strip))

        pre_check = _patch(patch_file, patch_opts)
        if pre_check['retcode'] != 0:
            # Try to reverse-apply hunks from rejects file using a dry-run.
            # If this returns a retcode of 0, we know that the patch was
            # already applied. Rejects are written from the base of the
            # directory, so the strip option doesn't apply here.
            reverse_pass = _patch(patch_rejects, ['-R', '-f'], dry_run=True)
            already_applied = reverse_pass['retcode'] == 0

            if already_applied:
                ret['comment'] = 'Patch was already applied'
                ret['result'] = True
                return ret
            else:
                ret['comment'] = (
                    'Patch would not apply cleanly, no changes made. Results '
                    'of dry-run are below.'
                )
                if reject_file is None:
                    ret['comment'] += (
                        ' Run state again using the reject_file option to '
                        'save rejects to a persistent file.'
                    )
                opts = copy.copy(__opts__)
                opts['color'] = False
                ret['comment'] += '\n\n' + salt.output.out_format(
                    pre_check,
                    'nested',
                    opts,
                    nested_indent=14)
                return ret

        if __opts__['test']:
            ret['result'] = None
            ret['comment'] = 'The patch would be applied'
            ret['changes'] = pre_check
            return ret

        # If we've made it here, the patch should apply cleanly
        patch_opts = []
        if is_dir and strip is not None:
            patch_opts.append('-p{0}'.format(strip))
        ret['changes'] = _patch(patch_file, patch_opts)

        if ret['changes']['retcode'] == 0:
            ret['comment'] = 'Patch successfully applied'
            ret['result'] = True
        else:
            ret['comment'] = 'Failed to apply patch'

        return ret

    finally:
        # Clean up any temp files
        for path in cleanup:
            try:
                os.remove(path)
            except OSError as exc:
                if exc.errno != os.errno.ENOENT:
                    log.error(
                        'file.patch: Failed to remove temp file %s: %s',
                        path, exc
                    )