From 59879ca974e7e9dc6611481185e125e475cfddca Mon Sep 17 00:00:00 2001 From: Lukas Holecek 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