diff --git a/changelogs/fragments/date-time-facts-fix-utctime.yml b/changelogs/fragments/date-time-facts-fix-utctime.yml new file mode 100644 index 00000000000..2a5bf8c408a --- /dev/null +++ b/changelogs/fragments/date-time-facts-fix-utctime.yml @@ -0,0 +1,2 @@ +bugfixes: + - facts - fix incorrect UTC timestamp in ``iso8601_micro`` and ``iso8601`` diff --git a/lib/ansible/module_utils/facts/system/date_time.py b/lib/ansible/module_utils/facts/system/date_time.py index f4e59705ef8..aa59d5bc1bb 100644 --- a/lib/ansible/module_utils/facts/system/date_time.py +++ b/lib/ansible/module_utils/facts/system/date_time.py @@ -32,7 +32,11 @@ class DateTimeFactCollector(BaseFactCollector): facts_dict = {} date_time_facts = {} - now = datetime.datetime.now() + # Store the timestamp once, then get local and UTC versions from that + epoch_ts = time.time() + now = datetime.datetime.fromtimestamp(epoch_ts) + utcnow = datetime.datetime.utcfromtimestamp(epoch_ts) + date_time_facts['year'] = now.strftime('%Y') date_time_facts['month'] = now.strftime('%m') date_time_facts['weekday'] = now.strftime('%A') @@ -44,12 +48,11 @@ class DateTimeFactCollector(BaseFactCollector): date_time_facts['second'] = now.strftime('%S') date_time_facts['epoch'] = now.strftime('%s') if date_time_facts['epoch'] == '' or date_time_facts['epoch'][0] == '%': - # NOTE: in this case, the epoch wont match the rest of the date_time facts? ie, it's a few milliseconds later..? -akl - date_time_facts['epoch'] = str(int(time.time())) + date_time_facts['epoch'] = str(int(epoch_ts)) date_time_facts['date'] = now.strftime('%Y-%m-%d') date_time_facts['time'] = now.strftime('%H:%M:%S') - date_time_facts['iso8601_micro'] = now.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ") - date_time_facts['iso8601'] = now.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + date_time_facts['iso8601_micro'] = utcnow.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + date_time_facts['iso8601'] = utcnow.strftime("%Y-%m-%dT%H:%M:%SZ") date_time_facts['iso8601_basic'] = now.strftime("%Y%m%dT%H%M%S%f") date_time_facts['iso8601_basic_short'] = now.strftime("%Y%m%dT%H%M%S") date_time_facts['tz'] = time.strftime("%Z") diff --git a/test/units/module_utils/facts/test_date_time.py b/test/units/module_utils/facts/test_date_time.py new file mode 100644 index 00000000000..7c92e521db4 --- /dev/null +++ b/test/units/module_utils/facts/test_date_time.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 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 + +import pytest +import datetime +import string +import time + +from ansible.module_utils.facts.system import date_time + +EPOCH_TS = 1594449296.123456 +DT = datetime.datetime(2020, 7, 11, 12, 34, 56, 124356) +DT_UTC = datetime.datetime(2020, 7, 11, 2, 34, 56, 124356) + + +@pytest.fixture +def fake_now(monkeypatch): + """ + Patch `datetime.datetime.fromtimestamp()`, `datetime.datetime.utcfromtimestamp()`, + and `time.time()` to return deterministic values. + """ + + class FakeNow: + @classmethod + def fromtimestamp(cls, timestamp): + return DT + + @classmethod + def utcfromtimestamp(cls, timestamp): + return DT_UTC + + def _time(): + return EPOCH_TS + + monkeypatch.setattr(date_time.datetime, 'datetime', FakeNow) + monkeypatch.setattr(time, 'time', _time) + + +@pytest.fixture +def fake_date_facts(fake_now): + """Return a predictable instance of collected date_time facts.""" + + collector = date_time.DateTimeFactCollector() + data = collector.collect() + + return data + + +@pytest.mark.parametrize( + ('fact_name', 'fact_value'), + ( + ('year', '2020'), + ('month', '07'), + ('weekday', 'Saturday'), + ('weekday_number', '6'), + ('weeknumber', '27'), + ('day', '11'), + ('hour', '12'), + ('minute', '34'), + ('second', '56'), + ('date', '2020-07-11'), + ('time', '12:34:56'), + ('iso8601_basic', '20200711T123456124356'), + ('iso8601_basic_short', '20200711T123456'), + ('iso8601_micro', '2020-07-11T02:34:56.124356Z'), + ('iso8601', '2020-07-11T02:34:56Z'), + ), +) +def test_date_time_facts(fake_date_facts, fact_name, fact_value): + assert fake_date_facts['date_time'][fact_name] == fact_value + + +def test_date_time_epoch(fake_date_facts): + """Test that format of returned epoch value is correct""" + + assert fake_date_facts['date_time']['epoch'].isdigit() + assert len(fake_date_facts['date_time']['epoch']) == 10 # This length will not change any time soon + + +def test_date_time_tz(fake_date_facts): + """ + Test that the returned value for timezone consists of only uppercase + letters and is the expected length. + """ + + assert fake_date_facts['date_time']['tz'].isupper() + assert 2 <= len(fake_date_facts['date_time']['tz']) <= 5 + assert not set(fake_date_facts['date_time']['tz']).difference(set(string.ascii_uppercase)) + + +def test_date_time_tz_offset(fake_date_facts): + """ + Test that the timezone offset begins with a `+` or `-` and ends with a + series of integers. + """ + + assert fake_date_facts['date_time']['tz_offset'][0] in ['-', '+'] + assert fake_date_facts['date_time']['tz_offset'][1:].isdigit() + assert len(fake_date_facts['date_time']['tz_offset']) == 5