diff --git a/changelogs/fragments/replace-before-after.yaml b/changelogs/fragments/replace-before-after.yaml
new file mode 100644
index 00000000000..a50d238932f
--- /dev/null
+++ b/changelogs/fragments/replace-before-after.yaml
@@ -0,0 +1,2 @@
+bugfixes:
+ - replace - fix behavior when ``before`` and ``after`` are used together (https://github.com/ansible/ansible/issues/31354)
diff --git a/docs/docsite/rst/porting_guides/porting_guide_2.7.rst b/docs/docsite/rst/porting_guides/porting_guide_2.7.rst
index abd7a1b8ef8..ac412bb4fc9 100644
--- a/docs/docsite/rst/porting_guides/porting_guide_2.7.rst
+++ b/docs/docsite/rst/porting_guides/porting_guide_2.7.rst
@@ -224,6 +224,8 @@ Noteworthy module changes
* The ``pip`` module has added a dependency on ``setuptools`` to support version requirements, this requirement is for
the Python interpreter that executes the module and not the Python interpreter that the module is managing.
+* Prior to Ansible 2.7.10, the ``replace`` module did the opposite of what was intended when using the ``before`` and ``after`` options together. This now works properly but may require changes to tasks.
+
Plugins
=======
diff --git a/lib/ansible/modules/files/replace.py b/lib/ansible/modules/files/replace.py
index c816d64de13..6c49a4d07da 100644
--- a/lib/ansible/modules/files/replace.py
+++ b/lib/ansible/modules/files/replace.py
@@ -57,21 +57,24 @@ options:
- 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, the line after the replace/remove will start.
+ - 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, the line before the replace/remove will occur.
+ - 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:
@@ -92,63 +95,78 @@ options:
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'''
-# Before Ansible 2.3, option 'dest', 'destfile' or 'name' was used instead of 'path'
-- replace:
+- 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'
- backup: yes
- name: Replace after the expression till the end of the file (requires Ansible >= 2.4)
replace:
- path: /etc/hosts
- regexp: '(\s+)old\.host\.name(\s+.*)?$'
- replace: '\1new.host.name\2'
- after: Start after line.*
- backup: yes
+ 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/hosts
- regexp: '(\s+)old\.host\.name(\s+.*)?$'
- replace: '\1new.host.name\2'
- before: 'Start before line.*'
- backup: yes
+ 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
- regexp: '(\s+)old\.host\.name(\s+.*)?$'
- replace: '\1new.host.name\2'
- after: 'Start after line.*'
- before: 'Start before line.*'
- backup: yes
+ after: ''
+ before: ''
+ regexp: '^(.+)$'
+ replace: '# \1'
-- replace:
+- name: Supports common file attributes
+ replace:
path: /home/jdoe/.ssh/known_hosts
regexp: '^old\.host\.name[^\n]*\n'
owner: jdoe
group: jdoe
mode: '0644'
-- replace:
+- 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: dest=/etc/hosts regexp='\\b(localhost)(\\d*)\\b' replace='\\1\\2.localdomain\\2 \\1\\2'
+ replace: path=/etc/hosts regexp='\\b(localhost)(\\d*)\\b' replace='\\1\\2.localdomain\\2 \\1\\2'
- name: Long form task does not
replace:
- dest: /etc/hosts
+ 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
@@ -231,7 +249,7 @@ def main():
pattern = u''
if params['after'] and params['before']:
- pattern = u'%s(?P.*?)%s' % (params['before'], params['after'])
+ pattern = u'%s(?P.*?)%s' % (params['after'], params['before'])
elif params['after']:
pattern = u'%s(?P.*)' % params['after']
elif params['before']:
@@ -242,6 +260,7 @@ def main():
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
@@ -254,7 +273,7 @@ def main():
if result[1] > 0 and section != result[0]:
if pattern:
- result = (contents.replace(section, result[0]), result[1])
+ result = (contents[:indices[0]] + result[0] + contents[indices[1]:], result[1])
msg = '%s replacements made' % result[1]
changed = True
if module._diff:
diff --git a/test/integration/targets/replace/aliases b/test/integration/targets/replace/aliases
new file mode 100644
index 00000000000..a6dafcf8cd8
--- /dev/null
+++ b/test/integration/targets/replace/aliases
@@ -0,0 +1 @@
+shippable/posix/group1
diff --git a/test/integration/targets/replace/meta/main.yml b/test/integration/targets/replace/meta/main.yml
new file mode 100644
index 00000000000..07faa217762
--- /dev/null
+++ b/test/integration/targets/replace/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+ - prepare_tests
diff --git a/test/integration/targets/replace/tasks/main.yml b/test/integration/targets/replace/tasks/main.yml
new file mode 100644
index 00000000000..24146ff31b4
--- /dev/null
+++ b/test/integration/targets/replace/tasks/main.yml
@@ -0,0 +1,265 @@
+# setup
+- set_fact: output_dir_test={{output_dir}}/test_replace
+
+- name: make sure our testing sub-directory does not exist
+ file: path="{{ output_dir_test }}" state=absent
+
+- name: create our testing sub-directory
+ file: path="{{ output_dir_test }}" state=directory
+
+# tests
+- name: create test files
+ copy:
+ content: |-
+ The quick brown fox jumps over the lazy dog.
+ We promptly judged antique ivory buckles for the next prize.
+ Jinxed wizards pluck ivy from the big quilt.
+ Jaded zombies acted quaintly but kept driving their oxen forward.
+ dest: "{{ output_dir_test }}/pangrams.{{ item }}.txt"
+ with_sequence: start=0 end=6 format=%02x #increment as needed
+
+
+## test `before` option
+- name: remove all spaces before "quilt"
+ replace:
+ path: "{{ output_dir_test }}/pangrams.00.txt"
+ before: 'quilt'
+ regexp: ' '
+ register: replace_test0
+
+- command: "cat {{ output_dir_test }}/pangrams.00.txt"
+ register: replace_cat0
+
+- name: validate before assertions
+ assert:
+ that:
+ - replace_test0 is successful
+ - replace_test0 is changed
+ - replace_cat0.stdout_lines[0] == 'Thequickbrownfoxjumpsoverthelazydog.'
+ - replace_cat0.stdout_lines[-1] == 'Jaded zombies acted quaintly but kept driving their oxen forward.'
+
+
+## test `after` option
+- name: remove all spaces after "promptly"
+ replace:
+ path: "{{ output_dir_test }}/pangrams.01.txt"
+ after: 'promptly'
+ regexp: ' '
+ register: replace_test1
+
+- command: "cat {{ output_dir_test }}/pangrams.01.txt"
+ register: replace_cat1
+
+- name: validate after assertions
+ assert:
+ that:
+ - replace_test1 is successful
+ - replace_test1 is changed
+ - replace_cat1.stdout_lines[0] == 'The quick brown fox jumps over the lazy dog.'
+ - replace_cat1.stdout_lines[-1] == 'Jadedzombiesactedquaintlybutkeptdrivingtheiroxenforward.'
+
+
+## test combined `before` and `after` options
+- name: before "promptly" but after "quilt", replace every "e" with a "3"
+ replace:
+ path: "{{ output_dir_test }}/pangrams.02.txt"
+ before: 'promptly'
+ after: 'quilt'
+ regexp: 'e'
+ replace: '3'
+ register: replace_test2
+
+- name: validate after+before assertions
+ assert:
+ that:
+ - replace_test2 is successful
+ - not replace_test2 is changed
+ - replace_test2.msg.startswith("Pattern for before/after params did not match the given file")
+
+- name: before "quilt" but after "promptly", replace every "e" with a "3"
+ replace:
+ path: "{{ output_dir_test }}/pangrams.03.txt"
+ before: 'quilt'
+ after: 'promptly'
+ regexp: 'e'
+ replace: '3'
+ register: replace_test3
+
+- command: "cat {{ output_dir_test }}/pangrams.03.txt"
+ register: replace_cat3
+
+- name: validate before+after assertions
+ assert:
+ that:
+ - replace_test3 is successful
+ - replace_test3 is changed
+ - replace_cat3.stdout_lines[1] == 'We promptly judg3d antiqu3 ivory buckl3s for th3 n3xt priz3.'
+
+
+## test ^$ behavior in MULTILINE, and . behavior in absense of DOTALL
+- name: quote everything between bof and eof
+ replace:
+ path: "{{ output_dir_test }}/pangrams.04.txt"
+ regexp: ^([\S\s]+)$
+ replace: '"\1"'
+ register: replace_test4_0
+
+- command: "cat {{ output_dir_test }}/pangrams.04.txt"
+ register: replace_cat4_0
+
+- name: quote everything between bol and eol
+ replace:
+ path: "{{ output_dir_test }}/pangrams.04.txt"
+ regexp: ^(.+)$
+ replace: '"\1"'
+ register: replace_test4_1
+
+- command: "cat {{ output_dir_test }}/pangrams.04.txt"
+ register: replace_cat4_1
+
+- name: validate before+after assertions
+ assert:
+ that:
+ - replace_test4_0 is successful
+ - replace_test4_0 is changed
+ - replace_test4_1 is successful
+ - replace_test4_1 is changed
+ - replace_cat4_0.stdout_lines[0] == '"The quick brown fox jumps over the lazy dog.'
+ - replace_cat4_0.stdout_lines[-1] == 'Jaded zombies acted quaintly but kept driving their oxen forward."'
+ - replace_cat4_1.stdout_lines[0] == '""The quick brown fox jumps over the lazy dog."'
+ - replace_cat4_1.stdout_lines[-1] == '"Jaded zombies acted quaintly but kept driving their oxen forward.""'
+
+
+## test \b escaping in short and long form
+- name: short form with unescaped word boundaries
+ replace: path="{{ output_dir_test }}/pangrams.05.txt" regexp='\b(.+)\b' replace='"\1"'
+ register: replace_test5_0
+
+- name: short form with escaped word boundaries
+ replace: path="{{ output_dir_test }}/pangrams.05.txt" regexp='\\b(.+)\\b' replace='"\1"'
+ register: replace_test5_1
+
+- command: "cat {{ output_dir_test }}/pangrams.05.txt"
+ register: replace_cat5_1
+
+- name: long form with unescaped word boundaries
+ replace:
+ path: "{{ output_dir_test }}/pangrams.05.txt"
+ regexp: '\b(.+)\b'
+ replace: '"\1"'
+ register: replace_test5_2
+
+- command: "cat {{ output_dir_test }}/pangrams.05.txt"
+ register: replace_cat5_2
+
+- name: long form with escaped word boundaries
+ replace:
+ path: "{{ output_dir_test }}/pangrams.05.txt"
+ regexp: '\\b(.+)\\b'
+ replace: '"\1"'
+ register: replace_test5_3
+
+- name: validate before+after assertions
+ assert:
+ that:
+ - not replace_test5_0 is changed
+ - replace_test5_1 is changed
+ - replace_test5_2 is changed
+ - not replace_test5_3 is changed
+ - replace_cat5_1.stdout_lines[0] == '"The quick brown fox jumps over the lazy dog".'
+ - replace_cat5_1.stdout_lines[-1] == '"Jaded zombies acted quaintly but kept driving their oxen forward".'
+ - replace_cat5_2.stdout_lines[0] == '""The quick brown fox jumps over the lazy dog"".'
+ - replace_cat5_2.stdout_lines[-1] == '""Jaded zombies acted quaintly but kept driving their oxen forward"".'
+
+
+## test backup behaviors
+- name: replacement with backup
+ replace:
+ path: "{{ output_dir_test }}/pangrams.06.txt"
+ regexp: ^(.+)$
+ replace: '"\1"'
+ backup: true
+ register: replace_test6
+
+- command: "cat {{ output_dir_test }}/pangrams.06.txt"
+ register: replace_cat6_0
+
+- command: "cat {{ replace_test6.backup_file }}"
+ register: replace_cat6_1
+
+- name: validate backup
+ assert:
+ that:
+ - replace_test6 is successful
+ - replace_test6 is changed
+ - replace_test6.backup_file is search('/pangrams.06.txt.')
+ - replace_cat6_0.stdout != replace_cat6_1.stdout
+
+
+## test filesystem failures
+- name: fail on directory
+ replace:
+ path: "{{ output_dir_test }}"
+ regexp: ^(.+)$
+ register: replace_test7_1
+ ignore_errors: true
+
+- name: fail on missing file
+ replace:
+ path: "{{ output_dir_test }}/missing_file.txt"
+ regexp: ^(.+)$
+ register: replace_test7_2
+ ignore_errors: true
+
+- name: validate backup
+ assert:
+ that:
+ - replace_test7_1 is failure
+ - replace_test7_2 is failure
+ - replace_test7_1.msg.endswith(" is a directory !")
+ - replace_test7_2.msg.endswith(" does not exist !")
+
+
+## test subsection replacement when before/after potentially match more than once
+- name: test file for subsection replacement gone awry
+ copy:
+ content: |-
+ # start of group
+ 0.0.0.0
+ 127.0.0.1
+ 127.0.1.1
+ # end of group
+
+ # start of group
+ 0.0.0.0
+ 127.0.0.1
+ 127.0.1.1
+ # end of group
+
+ # start of group
+ 0.0.0.0
+ 127.0.0.1
+ 127.0.1.1
+ # end of group
+ dest: "{{ output_dir_test }}/addresses.txt"
+
+- name: subsection madness
+ replace:
+ path: "{{ output_dir_test }}/addresses.txt"
+ after: '# start of group'
+ before: '# end of group'
+ regexp: '0'
+ replace: '9'
+ register: replace_test8
+
+- command: "cat {{ output_dir_test }}/addresses.txt"
+ register: replace_cat8
+
+- name: validate before+after assertions
+ assert:
+ that:
+ - replace_test8 is successful
+ - replace_test8 is changed
+ - replace_cat8.stdout_lines[1] == "9.9.9.9"
+ - replace_cat8.stdout_lines[7] == "0.0.0.0"
+ - replace_cat8.stdout_lines[13] == "0.0.0.0"