diff --git a/lib/ansible/module_utils/basic.py b/lib/ansible/module_utils/basic.py index 94aec93974..e4cbc6c8c3 100644 --- a/lib/ansible/module_utils/basic.py +++ b/lib/ansible/module_utils/basic.py @@ -2355,7 +2355,7 @@ class AnsibleModule(object): backupdest = '%s.%s.%s' % (fn, os.getpid(), ext) try: - shutil.copy2(fn, backupdest) + self.preserved_copy(fn, backupdest) except (shutil.Error, IOError): e = get_exception() self.fail_json(msg='Could not make backup of %s to %s: %s' % (fn, backupdest, e)) @@ -2370,6 +2370,42 @@ class AnsibleModule(object): e = get_exception() sys.stderr.write("could not cleanup %s: %s" % (tmpfile, e)) + def preserved_copy(self, src, dest): + """Copy a file with preserved ownership, permissions and context""" + + # shutil.copy2(src, dst) + # Similar to shutil.copy(), but metadata is copied as well - in fact, + # this is just shutil.copy() followed by copystat(). This is similar + # to the Unix command cp -p. + # + # shutil.copystat(src, dst) + # Copy the permission bits, last access time, last modification time, + # and flags from src to dst. The file contents, owner, and group are + # unaffected. src and dst are path names given as strings. + + shutil.copy2(src, dest) + + # Set the context + if self.selinux_enabled(): + context = self.selinux_context(src) + self.set_context_if_different(dest, context, False) + + # chown it + try: + dest_stat = os.stat(src) + tmp_stat = os.stat(dest) + if dest_stat and (tmp_stat.st_uid != dest_stat.st_uid or tmp_stat.st_gid != dest_stat.st_gid): + os.chown(dest, dest_stat.st_uid, dest_stat.st_gid) + except OSError as e: + if e.errno != errno.EPERM: + raise + + # Set the attributes + current_attribs = self.get_file_attributes(src) + current_attribs = current_attribs.get('attr_flags', []) + current_attribs = ''.join(current_attribs) + self.set_attributes_if_different(dest, current_attribs, True) + def atomic_move(self, src, dest, unsafe_writes=False): '''atomically move src to dest, copying attributes from dest, returns true on success it uses os.rename to ensure this as it is an atomic operation, rest of the function is diff --git a/test/integration/targets/template/tasks/backup_test.yml b/test/integration/targets/template/tasks/backup_test.yml new file mode 100644 index 0000000000..eb4eff1700 --- /dev/null +++ b/test/integration/targets/template/tasks/backup_test.yml @@ -0,0 +1,60 @@ +# https://github.com/ansible/ansible/issues/24408 + +- set_fact: + t_username: templateuser1 + t_groupname: templateuser1 + +- name: create the test group + group: + name: "{{ t_groupname }}" + +- name: create the test user + user: + name: "{{ t_username }}" + group: "{{ t_groupname }}" + createhome: no + +- name: set the dest file + set_fact: + t_dest: "{{ output_dir + '/tfile_dest.txt' }}" + +- name: create the old file + file: + path: "{{ t_dest }}" + state: touch + mode: 0777 + owner: "{{ t_username }}" + group: "{{ t_groupname }}" + +- name: failsafe attr change incase underlying system does not support it + shell: chattr =j "{{ t_dest }}" + ignore_errors: True + +- name: run the template + template: + src: foo.j2 + dest: "{{ t_dest }}" + backup: True + register: t_backup_res + +- name: check the data for the backup + stat: + path: "{{ t_backup_res.backup_file }}" + register: t_backup_stats + +- name: validate result of preserved backup + assert: + that: + - 't_backup_stats.stat.mode == "0777"' + - 't_backup_stats.stat.pw_name == t_username' + - 't_backup_stats.stat.gr_name == t_groupname' + +- name: cleanup the user + user: + name: "{{ t_username }}" + state: absent + +- name: cleanup the group + user: + name: "{{ t_groupname }}" + state: absent diff --git a/test/integration/targets/template/tasks/main.yml b/test/integration/targets/template/tasks/main.yml index 74847eae1f..2e3064306d 100644 --- a/test/integration/targets/template/tasks/main.yml +++ b/test/integration/targets/template/tasks/main.yml @@ -369,3 +369,6 @@ that: - 'diff_result.stdout == ""' - "diff_result.rc == 0" + +# aliases file requires root for template tests so this should be safe +- include: backup_test.yml