From e88ab431f06bb724353a08c263a102a547e68ab3 Mon Sep 17 00:00:00 2001 From: Evan Kaufman Date: Tue, 12 Nov 2013 13:30:18 -0600 Subject: [PATCH] Added replace module Heavily based on existing lineinfile module, but where it literally tests a regexp against *each individual line* of a file, this replace module is more analogous to common uses of a `sed` or `perl` match + replacement of all instances of a pattern anywhere in the file. Was debating adding `all` boolean or `count` numeric options to control how many replacements to make in the destfile (vs currently replacing all instances) Noted use of MULTILINE mode in docs, per suggestion from @jarv --- library/files/replace | 160 ++++++++++++++++++++++++++++++++++++++++++ test/TestRunner.py | 53 ++++++++++++++ test/known_hosts.txt | 4 ++ 3 files changed, 217 insertions(+) create mode 100644 library/files/replace create mode 100644 test/known_hosts.txt diff --git a/library/files/replace b/library/files/replace new file mode 100644 index 0000000000..b008d1b39d --- /dev/null +++ b/library/files/replace @@ -0,0 +1,160 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2013, Evan Kaufman . + +import re +import os +import tempfile + +DOCUMENTATION = """ +--- +module: replace +author: Evan Kaufman +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.4" +options: + dest: + required: true + aliases: [ name, destfile ] + description: + - The file to modify. + regexp: + required: true + description: + - The regular expression to look for in the contents of the file. + Uses Python regular expressions; see + U(http://docs.python.org/2/library/re.html). + Uses multiline mode, which means C(^) and C($) match the beginning + and end respectively of I(each line) of the file. + replace: + required: false + 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. + backup: + required: false + default: "no" + choices: [ "yes", "no" ] + description: + - Create a backup file including the timestamp information so you can + get the original file back if you somehow clobbered it incorrectly. + validate: + required: false + description: + - validation to run before copying into place + required: false + default: None + others: + description: + - All arguments accepted by the M(file) module also work here. + required: false +""" + +EXAMPLES = r""" +- replace: dest=/etc/hosts regexp='(\s+)old\.host\.name(\s+.*)?$' replace='\1new.host.name\2' backup=yes + +- replace: dest=/home/jdoe/.ssh/known_hosts regexp='^old\.host\.name[^\n]*\n' owner=jdoe group=jdoe mode=644 + +- replace: dest=/etc/apache/ports regexp='^(NameVirtualHost|Listen)\s+80\s*$' replace='\1 127.0.0.1:8080' validate='/usr/sbin/apache2ctl -f %s -t' +""" + +def write_changes(module,contents,dest): + + tmpfd, tmpfile = tempfile.mkstemp() + f = os.fdopen(tmpfd,'wb') + f.write(contents) + f.close() + + validate = module.params.get('validate', None) + valid = not validate + if 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, dest) + +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( + dest=dict(required=True, aliases=['name', 'destfile']), + regexp=dict(required=True), + replace=dict(default='', type='str'), + backup=dict(default=False, type='bool'), + validate=dict(default=None, type='str'), + ), + add_file_common_args=True, + supports_check_mode=True + ) + + params = module.params + dest = os.path.expanduser(params['dest']) + + if os.path.isdir(dest): + module.fail_json(rc=256, msg='Destination %s is a directory !' % dest) + + if not os.path.exists(dest): + module.fail_json(rc=257, msg='Destination %s does not exist !' % dest) + else: + f = open(dest, 'rb') + contents = f.read() + f.close() + + mre = re.compile(params['regexp'], re.MULTILINE) + result = re.subn(mre, params['replace'], contents, 0) + + if result[1] > 0: + msg = '%s replacements made' % result[1] + changed = True + else: + msg = '' + changed = False + + if changed and not module.check_mode: + if params['backup'] and os.path.exists(dest): + module.backup_local(dest) + write_changes(module, result[0], dest) + + msg, changed = check_file_attrs(module, changed, msg) + module.exit_json(changed=changed, msg=msg) + +# this is magic, see lib/ansible/module_common.py +#<> + +main() diff --git a/test/TestRunner.py b/test/TestRunner.py index f991d02bd3..9b25887f77 100644 --- a/test/TestRunner.py +++ b/test/TestRunner.py @@ -625,3 +625,56 @@ class TestRunner(unittest.TestCase): assert result['failed'] os.unlink(sample) + + def test_replace(self): + origin = self._get_test_file('known_hosts.txt') + scratch = self._get_stage_file('known_hosts.tmp') + shutil.copy(origin, scratch) + + # regexp should not match + testcase = ('replace', [ + "dest=%s" % scratch, + "regexp='^zeta.example.com(.+)$'" + r"replace='zulu.example.com\1'" + ]) + result = self._run(*testcase) + assert result['changed'] == False + assert result['msg'] == '' + + # regexp w one match, replace w backref + teststr = 'omega.example.com' + testip = '10.11.12.14' + testcase = ('replace', [ + "dest=%s" % scratch, + "regexp='^[^,]+(,%s\s+.+)$'" % testip, + r"replace='%s\1'" % teststr + ]) + result = self._run(*testcase) + assert result['changed'] + assert result['msg'] == '1 replacements made' + assert file(scratch).read().find(teststr) != -1 + assert file(scratch).read().find(testip) != -1 + + # regexp w multiple match, simple replace + teststr = '10.11.12.13' + testcase = ('replace', [ + "dest=%s" % scratch, + "regexp='%s'" % teststr, + "replace='11.12.13.14'" + ]) + result = self._run(*testcase) + assert result['changed'] + assert result['msg'] == '2 replacements made' + assert file(scratch).read().find(teststr) == -1 + + # no replace should remove all matches + testcase = ('replace', [ + "dest=%s" % scratch, + "regexp='^[^,]+,'" + ]) + result = self._run(*testcase) + assert result['changed'] + assert result['msg'] == '3 replacements made' + assert file(scratch).read().find('.example.com') == -1 + + os.unlink(scratch) diff --git a/test/known_hosts.txt b/test/known_hosts.txt new file mode 100644 index 0000000000..70fcf35363 --- /dev/null +++ b/test/known_hosts.txt @@ -0,0 +1,4 @@ +alpha.example.com,10.11.12.13 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== +bravo.example.com,10.11.12.14 ssh-rsa AAAAB3NzaC1yom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrvic2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9TjQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMcAW4OZPnTPI89ZPmVMLuayrD2cE86Z/iliLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== +charlie.example.com,10.11.12.15 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== +10.11.12.13 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q==