diff options
Diffstat (limited to 'lib/ansible/modules/replace.py')
-rw-r--r-- | lib/ansible/modules/replace.py | 316 |
1 files changed, 316 insertions, 0 deletions
diff --git a/lib/ansible/modules/replace.py b/lib/ansible/modules/replace.py new file mode 100644 index 0000000..4b8f74f --- /dev/null +++ b/lib/ansible/modules/replace.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2013, Evan Kaufman <evan@digitalflophouse.com +# Copyright: (c) 2017, 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 + + +DOCUMENTATION = r''' +--- +module: replace +author: Evan Kaufman (@EvanK) +extends_documentation_fragment: + - action_common_attributes + - action_common_attributes.files + - files + - validate +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + platforms: posix + safe_file_operations: + support: full + vault: + support: none +short_description: Replace all instances of a particular string in a + file using a back-referenced regular expression +description: + - This module will replace all instances of a pattern within a file. + - It is up to the user to maintain idempotence by ensuring that the + same pattern would never match any replacements made. +version_added: "1.6" +options: + path: + description: + - The file to modify. + - Before Ansible 2.3 this option was only usable as I(dest), I(destfile) and I(name). + type: path + required: true + aliases: [ dest, destfile, name ] + regexp: + description: + - The regular expression to look for in the contents of the file. + - Uses Python regular expressions; see + U(https://docs.python.org/3/library/re.html). + - Uses MULTILINE mode, which means C(^) and C($) match the beginning + and end of the file, as well as the beginning and end respectively + of I(each line) of the file. + - Does not use DOTALL, which means the C(.) special character matches + any character I(except newlines). A common mistake is to assume that + a negated character set like C([^#]) will also not match newlines. + - In order to exclude newlines, they must be added to the set like C([^#\n]). + - Note that, as of Ansible 2.0, short form tasks should have any escape + sequences backslash-escaped in order to prevent them being parsed + as string literal escapes. See the examples. + type: str + required: true + replace: + description: + - The string to replace regexp matches. + - May contain backreferences that will get expanded with the regexp capture groups if the regexp matches. + - If not set, matches are removed entirely. + - Backreferences can be used ambiguously like C(\1), or explicitly like C(\g<1>). + 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(https://docs.python.org/3/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(https://docs.python.org/3/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(ansible.builtin.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: Replace old hostname with new hostname (requires Ansible >= 2.4) + ansible.builtin.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) + ansible.builtin.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) + ansible.builtin.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) + ansible.builtin.replace: + path: /etc/hosts + after: '<VirtualHost [*]>' + before: '</VirtualHost>' + regexp: '^(.+)$' + replace: '# \1' + +- name: Supports common file attributes + ansible.builtin.replace: + path: /home/jdoe/.ssh/known_hosts + regexp: '^old\.host\.name[^\n]*\n' + owner: jdoe + group: jdoe + mode: '0644' + +- name: Supports a validate command + ansible.builtin.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 + ansible.builtin.replace: path=/etc/hosts regexp='\\b(localhost)(\\d*)\\b' replace='\\1\\2.localdomain\\2 \\1\\2' + +- name: Long form task does not + ansible.builtin.replace: + path: /etc/hosts + regexp: '\b(localhost)(\d*)\b' + replace: '\1\2.localdomain\2 \1\2' + +- name: Explicitly specifying positional matched groups in replacement + ansible.builtin.replace: + path: /etc/ssh/sshd_config + regexp: '^(ListenAddress[ ]+)[^\n]+$' + replace: '\g<1>0.0.0.0' + +- name: Explicitly specifying named matched groups + ansible.builtin.replace: + path: /etc/ssh/sshd_config + regexp: '^(?P<dctv>ListenAddress[ ]+)(?P<host>[^\n]+)$' + replace: '#\g<dctv>\g<host>\n\g<dctv>0.0.0.0' +''' + +RETURN = r'''#''' + +import os +import re +import tempfile +from traceback import format_exc + +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(rc=0) + + 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: + try: + with open(path, 'rb') as f: + contents = to_text(f.read(), errors='surrogate_or_strict', encoding=encoding) + except (OSError, IOError) as e: + module.fail_json(msg='Unable to read the contents of %s: %s' % (path, to_text(e)), + exception=format_exc()) + + pattern = u'' + if params['after'] and params['before']: + pattern = u'%s(?P<subsection>.*?)%s' % (params['after'], params['before']) + elif params['after']: + pattern = u'%s(?P<subsection>.*)' % params['after'] + elif params['before']: + pattern = u'(?P<subsection>.*)%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() |