From 59879ca974e7e9dc6611481185e125e475cfddca Mon Sep 17 00:00:00 2001
From: Lukas Holecek <hluk@email.cz>
Date: Oct 14 2021 06:24:38 +0000
Subject: Add support for valid_since and valid_until to PassingTestCaseRule


JIRA: RHELWF-398

---

diff --git a/Dockerfile b/Dockerfile
index c8ddd0c..c1b1a9a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,6 +8,7 @@ LABEL \
 WORKDIR /src
 RUN dnf -y install \
     git-core \
+    python3-dateutil \
     python3-dogpile-cache \
     python3-fedmsg \
     python3-flask \
diff --git a/Vagrantfile b/Vagrantfile
index 044524a..bbbb1da 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -8,6 +8,7 @@ $script = <<-'SCRIPT'
         git-core \
         postgresql-server \
         postgresql-contrib \
+        python3-dateutil \
         python3-gunicorn \
         python3-psycopg2 \
         python3-pylint \
diff --git a/docs/policies.rst b/docs/policies.rst
index 65656a9..517baf7 100644
--- a/docs/policies.rst
+++ b/docs/policies.rst
@@ -186,6 +186,48 @@ PassingTestCaseRule
    Optional ``scenario`` property can be specified to consider only results
    with a given scenario name.
 
+   Optional ``valid_since`` and ``valid_until`` properties declare a date/time
+   range for which the rule is applicable. The range is compared to subject's
+   build time from Koji if available or the current date/time. The default
+   value is ``null`` for both, indicating that the rule is always valid. The
+   comparison logic is following::
+
+      if valid_since != null and subject_time < valid_since then
+         rule is not applicable
+      else if valid_until != null and subject_time >= valid_until then
+         rule is not applicable
+      else
+         rule is applicable
+
+   Removing the rule is equivalent to setting ``valid_until`` to the current
+   date/time. This is preferable since it won't affect previous decisions.
+   Similarly, adding new rule with ``valid_since`` set to the current or a
+   future date/time does not affect previous decisions.
+
+   In the following example, on ``2021-10-02`` (if not specified, the time
+   defaults to 00:00 UTC), compose test results for test case
+   ``compose.autocloud`` start requiring scenario ``x86_64.uefi`` instead of
+   ``x86_64.64bit``.
+
+   .. code-block:: yaml
+      :linenos:
+
+      --- !Policy
+      id: "compose_required_tests"
+      product_versions:
+        - fedora-rawhide
+      decision_context: compose_required_tests
+      subject_type: compose
+      rules:
+        - !PassingTestCaseRule
+          valid_until: 2021-10-02
+          test_case_name: compose.autocloud
+          scenario: x86_64.64bit
+        - !PassingTestCaseRule
+          valid_since: 2021-10-02
+          test_case_name: compose.autocloud
+          scenario: x86_64.uefi
+
 .. _remote-rule:
 
 RemoteRule
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 0389ef5..8d48a76 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,4 +1,5 @@
 flask
+python-dateutil
 sphinx < 4
 sphinxcontrib-httpdomain
 prometheus_client
diff --git a/greenwave/policies.py b/greenwave/policies.py
index 5ace576..1aff7ec 100644
--- a/greenwave/policies.py
+++ b/greenwave/policies.py
@@ -11,6 +11,7 @@ from werkzeug.exceptions import BadRequest, NotFound
 from flask import current_app
 from greenwave.safe_yaml import (
     SafeYAMLBool,
+    SafeYAMLDateTime,
     SafeYAMLList,
     SafeYAMLObject,
     SafeYAMLString,
@@ -640,9 +641,20 @@ class PassingTestCaseRule(Rule):
     safe_yaml_attributes = {
         'test_case_name': SafeYAMLString(),
         'scenario': SafeYAMLString(optional=True),
+        'valid_since': SafeYAMLDateTime(optional=True),
+        'valid_until': SafeYAMLDateTime(optional=True),
     }
 
     def check(self, policy, rule_context):
+        if self.valid_since or self.valid_until:
+            koji_url = current_app.config["KOJI_BASE_URL"]
+            subject_creation_time = greenwave.resources.retrieve_koji_build_creation_time(
+                rule_context.subject, koji_url)
+            if self.valid_since and subject_creation_time < self.valid_since:
+                return []
+            if self.valid_until and self.valid_until <= subject_creation_time:
+                return []
+
         matching_results = rule_context.get_results(self.test_case_name)
 
         if self.scenario is not None:
diff --git a/greenwave/product_versions.py b/greenwave/product_versions.py
index 5294793..7949044 100644
--- a/greenwave/product_versions.py
+++ b/greenwave/product_versions.py
@@ -11,8 +11,8 @@ import xmlrpc.client
 from werkzeug.exceptions import NotFound
 
 from greenwave.resources import (
-    retrieve_koji_task_id_and_source,
     retrieve_koji_build_target,
+    retrieve_koji_build_task_id,
 )
 
 log = logging.getLogger(__name__)
@@ -55,7 +55,7 @@ def _guess_koji_build_product_version(
         if not koji_task_id:
             log.debug('Getting Koji task ID for build %r', subject_identifier)
             try:
-                koji_task_id, _ = retrieve_koji_task_id_and_source(
+                koji_task_id = retrieve_koji_build_task_id(
                     subject_identifier, koji_base_url
                 )
             except NotFound:
diff --git a/greenwave/resources.py b/greenwave/resources.py
index 803d5e9..d93c0c9 100644
--- a/greenwave/resources.py
+++ b/greenwave/resources.py
@@ -6,10 +6,13 @@ waiverdb, etc..).
 
 """
 
+import datetime
 import logging
 import re
 import socket
 
+from dateutil import tz
+from dateutil.parser import parse
 from urllib.parse import urlparse
 import xmlrpc.client
 from flask import current_app
@@ -158,7 +161,7 @@ def retrieve_koji_build_target(nvr, koji_url):
 
 
 @cached
-def retrieve_koji_task_id_and_source(nvr, koji_url):
+def _retrieve_koji_build_attributes(nvr, koji_url):
     log.debug('Getting Koji build %r', nvr)
     proxy = get_server_proxy(koji_url, _requests_timeout())
     build = proxy.getBuild(nvr)
@@ -174,14 +177,40 @@ def retrieve_koji_task_id_and_source(nvr, koji_url):
     except (TypeError, KeyError, AttributeError):
         source = build.get("source")
 
-    return (task_id, source)
+    creation_time = build.get('creation_time')
+
+    return (task_id, source, creation_time)
+
+
+def retrieve_koji_build_task_id(nvr, koji_url):
+    return _retrieve_koji_build_attributes(nvr, koji_url)[0]
+
+
+def retrieve_koji_build_source(nvr, koji_url):
+    return _retrieve_koji_build_attributes(nvr, koji_url)[1]
+
+
+def retrieve_koji_build_creation_time(nvr, koji_url):
+    creation_time = _retrieve_koji_build_attributes(nvr, koji_url)[2]
+    try:
+        time = parse(str(creation_time))
+        if time.tzinfo is None:
+            time = time.replace(tzinfo=tz.tzutc())
+        return time
+    except ValueError:
+        log.warning(
+            'Could not parse Koji build creation_time %r for nvr %r',
+            creation_time, nvr
+        )
+
+    return datetime.datetime.now(tz.tzutc())
 
 
 def retrieve_scm_from_koji(nvr):
     """Retrieve cached rev and namespace from koji using the nvr"""
     koji_url = current_app.config["KOJI_BASE_URL"]
     try:
-        _, source = retrieve_koji_task_id_and_source(nvr, koji_url)
+        source = retrieve_koji_build_source(nvr, koji_url)
     except (xmlrpc.client.ProtocolError, socket.error) as err:
         raise ConnectionError("Could not reach Koji: {}".format(err))
     return retrieve_scm_from_koji_build(nvr, source, koji_url)
diff --git a/greenwave/safe_yaml.py b/greenwave/safe_yaml.py
index 6a65bfb..7f9ac03 100644
--- a/greenwave/safe_yaml.py
+++ b/greenwave/safe_yaml.py
@@ -2,6 +2,8 @@
 """
 Provides a way of defining type-safe YAML parsing.
 """
+from dateutil import tz
+from dateutil.parser import parse
 import yaml
 
 safe_yaml_tag_to_class = {}
@@ -110,6 +112,33 @@ class SafeYAMLString(SafeYAMLAttribute):
         return self.default
 
 
+class SafeYAMLDateTime(SafeYAMLAttribute):
+    """
+    YAML object attribute representing a date/time value.
+    """
+    def from_yaml(self, loader, node):
+        value = loader.construct_scalar(node)
+        return self.from_value(value)
+
+    def from_value(self, value):
+        try:
+            time = parse(str(value))
+        except ValueError:
+            raise SafeYAMLError(
+                'Could not parse string as date/time, got: {}'.format(value))
+
+        if time.tzinfo is None:
+            time = time.replace(tzinfo=tz.tzutc())
+        return time
+
+    def to_json(self, value):
+        raise value
+
+    @property
+    def default_value(self):
+        return None
+
+
 class SafeYAMLList(SafeYAMLAttribute):
     """
     YAML object attribute represeting a list of values.
diff --git a/greenwave/tests/test_rules.py b/greenwave/tests/test_rules.py
index 95aaa68..bd7b6d6 100644
--- a/greenwave/tests/test_rules.py
+++ b/greenwave/tests/test_rules.py
@@ -229,3 +229,112 @@ def test_remote_rule_requiered_flag_bad(required_flag):
     error = 'Expected a boolean value, got: {}'.format(required_flag)
     with pytest.raises(SafeYAMLError, match=error):
         Policy.safe_load_all(policy_yaml)
+
+
+@pytest.mark.parametrize(('prop', 'value'), (
+    ('valid_since', False),
+    ('valid_since', ''),
+    ('valid_since', 'x'),
+))
+def test_remote_rule_valid_date_bad(prop, value):
+    policy_yaml = dedent("""
+        --- !Policy
+        id: test
+        product_versions: [fedora-rawhide]
+        decision_context: test
+        subject_type: koji_build
+        rules:
+          - !PassingTestCaseRule {test_case_name: some_test_case, %s: %s}
+    """) % (prop, value)
+    error = 'Could not parse string as date/time, got: {}'.format(value)
+    with pytest.raises(SafeYAMLError, match=error):
+        Policy.safe_load_all(policy_yaml)
+
+
+@pytest.mark.parametrize(('properties', 'is_valid'), (
+    ('valid_since: 2021-10-06', True),
+    ('valid_since: 2021-10-07', False),
+    ('valid_until: 2021-10-07', True),
+    ('valid_until: 2021-10-06', False),
+    ('valid_since: 2021-10-06, valid_until: 2021-10-07', True),
+    ('valid_since: 2021-10-07, valid_until: 2021-10-08', False),
+    ('valid_since: 2021-10-05, valid_until: 2021-10-06', False),
+))
+def test_passing_test_case_rule_valid_times(koji_proxy, properties, is_valid):
+    policy_yaml = dedent("""
+        --- !Policy
+        id: "some_policy"
+        product_versions: [rhel-9000]
+        decision_context: bodhi_update_push_stable
+        subject_type: koji_build
+        rules:
+          - !PassingTestCaseRule {test_case_name: some_test_case, %s}
+    """) % properties
+
+    nvr = 'nethack-1.2.3-1.el9000'
+
+    app = create_app('greenwave.config.TestingConfig')
+    with app.app_context():
+        subject = create_subject('koji_build', nvr)
+        policies = Policy.safe_load_all(policy_yaml)
+        assert len(policies) == 1
+
+        policy = policies[0]
+        assert len(policy.rules) == 1
+
+        rule = policy.rules[0]
+
+        assert rule.matches(policy, subject=subject)
+        assert rule.matches(policy, subject=subject, testcase='some_test_case')
+
+        koji_proxy.getBuild.return_value = {
+            'creation_time': '2021-10-06 06:00:00.000000+00:00'}
+        decision = Decision('bodhi_update_push_stable', 'rhel-9000')
+
+        results_retriever = mock.MagicMock()
+        results_retriever.retrieve.return_value = []
+        decision.check(subject, policies, results_retriever=results_retriever)
+        answers = ['test-result-missing'] if is_valid else []
+        assert [x.to_json()['type'] for x in decision.answers] == answers
+
+
+@pytest.mark.parametrize(('creation_time', 'test_case_name'), (
+    ('2021-10-06 05:59:59.999999+00:00', 'old_test_case'),
+    ('2021-10-06 06:00:00.000000+00:00', 'new_test_case'),
+))
+def test_passing_test_case_rule_replace_using_valid_times(
+        koji_proxy, creation_time, test_case_name):
+    policy_yaml = dedent("""
+        --- !Policy
+        id: "some_policy"
+        product_versions: [rhel-9000]
+        decision_context: bodhi_update_push_stable
+        subject_type: koji_build
+        rules:
+          - !PassingTestCaseRule
+            valid_until: 2021-10-06 06:00:00.000000+00:00
+            test_case_name: old_test_case
+          - !PassingTestCaseRule
+            valid_since: 2021-10-06 06:00:00.000000+00:00
+            test_case_name: new_test_case
+    """)
+
+    nvr = 'nethack-1.2.3-1.el9000'
+
+    app = create_app('greenwave.config.TestingConfig')
+    with app.app_context():
+        subject = create_subject('koji_build', nvr)
+        policies = Policy.safe_load_all(policy_yaml)
+        assert len(policies) == 1
+
+        policy = policies[0]
+        assert len(policy.rules) == 2
+
+        koji_proxy.getBuild.return_value = {'creation_time': creation_time}
+        decision = Decision('bodhi_update_push_stable', 'rhel-9000')
+
+        results_retriever = mock.MagicMock()
+        results_retriever.retrieve.return_value = []
+        decision.check(subject, policies, results_retriever=results_retriever)
+        assert [x.to_json()['type'] for x in decision.answers] == ['test-result-missing']
+        assert decision.answers[0].test_case_name == test_case_name
diff --git a/requirements.txt b/requirements.txt
index 3387cae..04b994e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
 flask
+python-dateutil
 requests
 PyYAML
 dogpile.cache

