From 62d9c0b24b094cc410501f296791814895155d20 Mon Sep 17 00:00:00 2001 From: Charles Crossan Date: Wed, 11 Sep 2019 14:02:44 -0400 Subject: [PATCH] create replace action plugin; remove replace module. --- lib/ansible/modules/files/replace.py | 302 -------------------------- lib/ansible/plugins/action/replace.py | 191 ++++++++++++++++ 2 files changed, 191 insertions(+), 302 deletions(-) delete mode 100644 lib/ansible/modules/files/replace.py create mode 100644 lib/ansible/plugins/action/replace.py diff --git a/lib/ansible/modules/files/replace.py b/lib/ansible/modules/files/replace.py deleted file mode 100644 index 6c49a4d07da..00000000000 --- a/lib/ansible/modules/files/replace.py +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Copyright: (c) 2013, Evan Kaufman ). - type: str - after: - description: - - If specified, only content after this match will be replaced/removed. - - Can be used in combination with C(before). - - Uses Python regular expressions; see - U(http://docs.python.org/2/library/re.html). - - Uses DOTALL, which means the C(.) special character I(can match newlines). - type: str - version_added: "2.4" - before: - description: - - If specified, only content before this match will be replaced/removed. - - Can be used in combination with C(after). - - Uses Python regular expressions; see - U(http://docs.python.org/2/library/re.html). - - Uses DOTALL, which means the C(.) special character I(can match newlines). - type: str - version_added: "2.4" - 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 - others: - description: - - All arguments accepted by the M(file) module also work here. - type: str - encoding: - description: - - The character encoding for reading and writing the file. - type: str - default: utf-8 - version_added: "2.4" -notes: - - As of Ansible 2.3, the I(dest) option has been changed to I(path) as default, but I(dest) still works as well. - - As of Ansible 2.7.10, the combined use of I(before) and I(after) works properly. If you were relying on the - previous incorrect behavior, you may be need to adjust your tasks. - See U(https://github.com/ansible/ansible/issues/31354) for details. - - Option I(follow) has been removed in Ansible 2.5, because this module modifies the contents of the file so I(follow=no) doesn't make sense. -''' - -EXAMPLES = r''' -- name: Before Ansible 2.3, option 'dest', 'destfile' or 'name' was used instead of 'path' - replace: - path: /etc/hosts - regexp: '(\s+)old\.host\.name(\s+.*)?$' - replace: '\1new.host.name\2' - -- name: Replace after the expression till the end of the file (requires Ansible >= 2.4) - replace: - path: /etc/apache2/sites-available/default.conf - after: 'NameVirtualHost [*]' - regexp: '^(.+)$' - replace: '# \1' - -- name: Replace before the expression till the begin of the file (requires Ansible >= 2.4) - replace: - path: /etc/apache2/sites-available/default.conf - before: '# live site config' - regexp: '^(.+)$' - replace: '# \1' - -# Prior to Ansible 2.7.10, using before and after in combination did the opposite of what was intended. -# see https://github.com/ansible/ansible/issues/31354 for details. -- name: Replace between the expressions (requires Ansible >= 2.4) - replace: - path: /etc/hosts - after: '' - before: '' - regexp: '^(.+)$' - replace: '# \1' - -- name: Supports common file attributes - replace: - path: /home/jdoe/.ssh/known_hosts - regexp: '^old\.host\.name[^\n]*\n' - owner: jdoe - group: jdoe - mode: '0644' - -- name: Supports a validate command - replace: - path: /etc/apache/ports - regexp: '^(NameVirtualHost|Listen)\s+80\s*$' - replace: '\1 127.0.0.1:8080' - validate: '/usr/sbin/apache2ctl -f %s -t' - -- name: Short form task (in ansible 2+) necessitates backslash-escaped sequences - replace: path=/etc/hosts regexp='\\b(localhost)(\\d*)\\b' replace='\\1\\2.localdomain\\2 \\1\\2' - -- name: Long form task does not - replace: - path: /etc/hosts - regexp: '\b(localhost)(\d*)\b' - replace: '\1\2.localdomain\2 \1\2' - -- name: Explicitly specifying positional matched groups in replacement - replace: - path: /etc/ssh/sshd_config - regexp: '^(ListenAddress[ ]+)[^\n]+$' - replace: '\g<1>0.0.0.0' - -- name: Explicitly specifying named matched groups - replace: - path: /etc/ssh/sshd_config - regexp: '^(?PListenAddress[ ]+)(?P[^\n]+)$' - replace: '#\g\g\n\g0.0.0.0' -''' - -import os -import re -import tempfile - -from ansible.module_utils._text import to_text, to_bytes -from ansible.module_utils.basic import AnsibleModule - - -def write_changes(module, contents, path): - - tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir) - f = os.fdopen(tmpfd, 'wb') - f.write(contents) - f.close() - - validate = module.params.get('validate', None) - valid = not validate - if validate: - if "%s" not in validate: - module.fail_json(msg="validate must contain %%s: %s" % (validate)) - (rc, out, err) = module.run_command(validate % tmpfile) - valid = rc == 0 - if rc != 0: - module.fail_json(msg='failed to validate: ' - 'rc:%s error:%s' % (rc, err)) - if valid: - module.atomic_move(tmpfile, path, unsafe_writes=module.params['unsafe_writes']) - - -def check_file_attrs(module, changed, message): - - file_args = module.load_file_common_arguments(module.params) - if module.set_file_attributes_if_different(file_args, False): - - if changed: - message += " and " - changed = True - message += "ownership, perms or SE linux context changed" - - return message, changed - - -def main(): - module = AnsibleModule( - argument_spec=dict( - path=dict(type='path', required=True, aliases=['dest', 'destfile', 'name']), - regexp=dict(type='str', required=True), - replace=dict(type='str', default=''), - after=dict(type='str'), - before=dict(type='str'), - backup=dict(type='bool', default=False), - validate=dict(type='str'), - encoding=dict(type='str', default='utf-8'), - ), - add_file_common_args=True, - supports_check_mode=True, - ) - - params = module.params - path = params['path'] - encoding = params['encoding'] - res_args = dict() - - params['after'] = to_text(params['after'], errors='surrogate_or_strict', nonstring='passthru') - params['before'] = to_text(params['before'], errors='surrogate_or_strict', nonstring='passthru') - params['regexp'] = to_text(params['regexp'], errors='surrogate_or_strict', nonstring='passthru') - params['replace'] = to_text(params['replace'], errors='surrogate_or_strict', nonstring='passthru') - - if os.path.isdir(path): - module.fail_json(rc=256, msg='Path %s is a directory !' % path) - - if not os.path.exists(path): - module.fail_json(rc=257, msg='Path %s does not exist !' % path) - else: - f = open(path, 'rb') - contents = to_text(f.read(), errors='surrogate_or_strict', encoding=encoding) - f.close() - - pattern = u'' - if params['after'] and params['before']: - pattern = u'%s(?P.*?)%s' % (params['after'], params['before']) - elif params['after']: - pattern = u'%s(?P.*)' % params['after'] - elif params['before']: - pattern = u'(?P.*)%s' % params['before'] - - if pattern: - section_re = re.compile(pattern, re.DOTALL) - match = re.search(section_re, contents) - if match: - section = match.group('subsection') - indices = [match.start('subsection'), match.end('subsection')] - else: - res_args['msg'] = 'Pattern for before/after params did not match the given file: %s' % pattern - res_args['changed'] = False - module.exit_json(**res_args) - else: - section = contents - - mre = re.compile(params['regexp'], re.MULTILINE) - result = re.subn(mre, params['replace'], section, 0) - - if result[1] > 0 and section != result[0]: - if pattern: - result = (contents[:indices[0]] + result[0] + contents[indices[1]:], result[1]) - msg = '%s replacements made' % result[1] - changed = True - if module._diff: - res_args['diff'] = { - 'before_header': path, - 'before': contents, - 'after_header': path, - 'after': result[0], - } - else: - msg = '' - changed = False - - if changed and not module.check_mode: - if params['backup'] and os.path.exists(path): - res_args['backup_file'] = module.backup_local(path) - # We should always follow symlinks so that we change the real file - path = os.path.realpath(path) - write_changes(module, to_bytes(result[0], encoding=encoding), path) - - res_args['msg'], res_args['changed'] = check_file_attrs(module, changed, msg) - module.exit_json(**res_args) - - -if __name__ == '__main__': - main() diff --git a/lib/ansible/plugins/action/replace.py b/lib/ansible/plugins/action/replace.py new file mode 100644 index 00000000000..4ee3c8a8d2d --- /dev/null +++ b/lib/ansible/plugins/action/replace.py @@ -0,0 +1,191 @@ +# Copyright: (c) 2018, Ansible Project +# 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 + +import os +import shutil +import stat +import tempfile +import re + +from ansible import constants as C +from ansible.config.manager import ensure_type +from ansible.errors import AnsibleError, AnsibleFileNotFound, AnsibleAction, AnsibleActionFail +from ansible.module_utils._text import to_bytes, to_text, to_native +from ansible.module_utils.parsing.convert_bool import boolean +from ansible.module_utils.six import string_types +from ansible.plugins.action import ActionBase + + +class ActionModule(ActionBase): + + def copy_local_file_to_remote(self,local_path,remote_path,task_vars): + # create a copy of the current task so we can adjust it and re-use it to run the moudle + task_copy_new_file_contents = self._task.copy() + + task_copy_new_file_contents.args.clear() + + try: + task_copy_new_file_contents.args.update( + dict( + src=local_path, + dest=remote_path, + ), + ) + action_copy_new_file_contents = self._shared_loader_obj.action_loader.get('copy', + task=task_copy_new_file_contents, + connection=self._connection, + play_context=self._play_context, + loader=self._loader, + templar=self._templar, + shared_loader_obj=self._shared_loader_obj) + + except AnsibleAction as e: + action_plugin_result.update(e.action_plugin_result) + + return action_copy_new_file_contents.run(task_vars=task_vars) + + def copy_remote_file_to_local(self,remote_path,local_path,task_vars): + # create a copy of the current task so we can adjust it and re-use it to run the moudle + task_fetch_original_file_contents = self._task.copy() + + task_fetch_original_file_contents.args.clear() + + try: + task_fetch_original_file_contents.args.update( + dict( + src=remote_path, + dest=local_path, + flat=True + ), + ) + action_fetch_original_file_contents = self._shared_loader_obj.action_loader.get('fetch', + task=task_fetch_original_file_contents, + connection=self._connection, + play_context=self._play_context, + loader=self._loader, + templar=self._templar, + shared_loader_obj=self._shared_loader_obj) + + except AnsibleAction as e: + action_plugin_result.update(e.action_plugin_result) + + return action_fetch_original_file_contents.run(task_vars=task_vars) + + + def run(self, tmp=None, task_vars=None): + ''' handler for replace operations ''' + + if task_vars is None: + task_vars = dict() + + action_plugin_result = super(ActionModule, self).run(tmp, task_vars) + del tmp # tmp no longer has any effect + + # Options type validation + # stings + for s_type in ('path', 'regexp', 'replace', 'after', 'before', 'validate', + 'encoding'): + if s_type in self._task.args: + value = ensure_type(self._task.args[s_type], 'string') + if value is not None and not isinstance(value, string_types): + raise AnsibleActionFail("%s is expected to be a string, but got %s instead" % (s_type, type(value))) + self._task.args[s_type] = value + + # booleans + try: + backup = boolean(self._task.args.get('backup', False), strict=False) + except TypeError as e: + raise AnsibleActionFail(to_native(e)) + + # assign to local vars for ease of use + + path = self._task.args.get('path') + encoding = self._task.args.get('encoding') + res_args = dict() + + after = to_text(self._task.args.get('after'), errors='surrogate_or_strict', nonstring='passthru') + before = to_text(self._task.args.get('before'), errors='surrogate_or_strict', nonstring='passthru') + regexp = to_text(self._task.args.get('regexp'), errors='surrogate_or_strict', nonstring='passthru') + replace = to_text(self._task.args.get('replace'), errors='surrogate_or_strict', nonstring='passthru') + + + # Next we need to `stat` the remote path to ensure it's valid (porting https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/files/replace.py#L240) + + #make sure not a directory + # make sure file exists + + # Next we need to get the contents of the remote file (maybe this step would supersede the previous?) + local_tempdir = tempfile.mkdtemp(dir=C.DEFAULT_LOCAL_TMP) + local_copy_of_original_file = os.path.join(local_tempdir, os.path.basename(path)) + + copy_remote_to_acs_result = self.copy_remote_file_to_local(path,local_copy_of_original_file,task_vars) + action_plugin_result['original_checksum'] = copy_remote_to_acs_result['checksum'] + + + print (copy_remote_to_acs_result) + + with open(local_copy_of_original_file, 'rb') as f: + try: + original_file_contents = to_text(f.read(), errors='surrogate_or_strict') + except UnicodeError: + raise AnsibleActionFail("Template source files must be utf-8 encoded") + + # At this point, we've copied the file locally, and read it into original_file_contents + + # Next we need to run the "replace" operation + pattern = u'' + if after and before: + pattern = u'%s(?P.*?)%s' % (after, before) + elif after: + pattern = u'%s(?P.*)' % after + elif before: + pattern = u'(?P.*)%s' % before + + if pattern: + section_re = re.compile(pattern, re.DOTALL) + match = re.search(section_re, original_file_contents) + if match: + section = match.group('subsection') + indices = [match.start('subsection'), match.end('subsection')] + else: + res_args['msg'] = 'Pattern for before/after params did not match the given file: %s' % pattern + res_args['changed'] = False + module.exit_json(**res_args) + else: + section = original_file_contents + + mre = re.compile(regexp, re.MULTILINE) + replace_result = re.subn(mre, replace, section, 0) + if replace_result[1] > 0 and section != replace_result[0]: + if pattern: + replace_result = (original_file_contents[:indices[0]] + replace_result[0] + original_file_contents[indices[1]:], replace_result[1]) + msg = '%s replacements made' % replace_result[1] + changed = True + if self._play_context.diff: + res_args['diff'] = { + 'before_header': path, + 'before': original_file_contents, + 'after_header': path, + 'after': replace_result[0], + } + else: + msg = '' + changed = False + + print("New File contents: " + replace_result[0]) + + if changed and not self._play_context.check_mode: + with open(local_copy_of_original_file, 'wb') as f: + try: + f.write(to_bytes(replace_result[0], encoding="utf-8", errors='surrogate_or_strict')) + except UnicodeError: + raise AnsibleActionFail("Template source files must be utf-8 encoded") + copy_acs_to_remote_result = self.copy_local_file_to_remote(local_copy_of_original_file,path,task_vars) + action_plugin_result['new_checksum'] = copy_acs_to_remote_result['checksum'] + + action_plugin_result["changed"] = changed + + return action_plugin_result \ No newline at end of file