diff --git a/ansible_runner/utils/streaming.py b/ansible_runner/utils/streaming.py index 1fa6f687c..b8812c93d 100644 --- a/ansible_runner/utils/streaming.py +++ b/ansible_runner/utils/streaming.py @@ -1,3 +1,4 @@ +import time import tempfile import zipfile import os @@ -68,6 +69,7 @@ def unstream_dir(stream, length, target_directory): with zipfile.ZipFile(tmp.name, "r") as archive: # Fancy extraction in order to preserve permissions + # AWX relies on the execution bit, in particular, for inventory # https://www.burgundywall.com/post/preserving-file-perms-with-python-zipfile-module for info in archive.infolist(): out_path = os.path.join(target_directory, info.filename) @@ -85,6 +87,12 @@ def unstream_dir(stream, length, target_directory): archive.extract(info.filename, path=target_directory) + # Fancy logic to preserve modification times + # AWX uses modification times to determine if new facts were written for a host + # https://stackoverflow.com/questions/9813243/extract-files-from-zip-file-and-retain-mod-date + date_time = time.mktime(info.date_time + (0, 0, -1)) + os.utime(out_path, times=(date_time, date_time)) + if is_symlink: link = open(out_path).read() os.remove(out_path) diff --git a/test/unit/utils/test_utils.py b/test/unit/utils/test_utils.py index faaf7ea0f..860a099f7 100644 --- a/test/unit/utils/test_utils.py +++ b/test/unit/utils/test_utils.py @@ -1,7 +1,9 @@ +import datetime import io import json import os import signal +import time from pathlib import Path @@ -147,6 +149,51 @@ def test_transmit_permissions(tmp_path, fperm): assert oct(new_file_path.stat().st_mode) == oct(old_file_path.stat().st_mode) +def test_transmit_modtimes(tmp_path): + source_dir = tmp_path / 'source' + source_dir.mkdir() + + # python ZipFile uses an old standard that stores seconds in 2 second increments + # https://stackoverflow.com/questions/64048499/zipfile-lib-weird-behaviour-with-seconds-in-modified-time + (source_dir / 'b.txt').touch() + time.sleep(2.0) # flaky for anything less + (source_dir / 'a.txt').touch() + + very_old_file = source_dir / 'very_old.txt' + very_old_file.touch() + old_datetime = os.path.getmtime(source_dir / 'a.txt') - datetime.timedelta(days=1).total_seconds() + os.utime(very_old_file, (old_datetime, old_datetime)) + + # sanity, verify assertions pass for source dir + mod_delta = os.path.getmtime(source_dir / 'a.txt') - os.path.getmtime(source_dir / 'b.txt') + assert mod_delta >= 1.0 + + outgoing_buffer = io.BytesIO() + outgoing_buffer.name = 'not_stdout' + stream_dir(source_dir, outgoing_buffer) + + dest_dir = tmp_path / 'dest' + dest_dir.mkdir() + + outgoing_buffer.seek(0) + first_line = outgoing_buffer.readline() + size_data = json.loads(first_line.strip()) + unstream_dir(outgoing_buffer, size_data['zipfile'], dest_dir) + + # Assure modification times are internally consistent + mod_delta = os.path.getmtime(dest_dir / 'a.txt') - os.path.getmtime(dest_dir / 'b.txt') + assert mod_delta >= 1.0 + + # Assure modification times are same as original (to the rounded second) + for filename in ('a.txt', 'b.txt', 'very_old.txt'): + difference = abs(os.path.getmtime(dest_dir / filename) - os.path.getmtime(source_dir / filename)) + assert difference < 2.0 + + # Assure the very old timestamp is preserved + old_delta = os.path.getmtime(dest_dir / 'a.txt') - os.path.getmtime(source_dir / 'very_old.txt') + assert old_delta >= datetime.timedelta(days=1).total_seconds() - 2. + + def test_signal_handler(mocker): """Test the default handler is set to handle the correct signals"""