From d867698c0057bfe07c67c29fa71a75e03a2580fd Mon Sep 17 00:00:00 2001 From: Lukas Holecek Date: Nov 13 2018 11:38:31 +0000 Subject: Refactor getting matching policies and rules Adds Policy.matches() and Rule.matches() so as to simplify getting applicable policies for a changed test case result. Signed-off-by: Lukas Holecek --- diff --git a/greenwave/api_v1.py b/greenwave/api_v1.py index 19e5c7f..4aaae81 100644 --- a/greenwave/api_v1.py +++ b/greenwave/api_v1.py @@ -328,7 +328,12 @@ def make_decision(): for subject_type, subject_identifier in _decision_subjects_for_request(data): subject_policies = [ policy for policy in current_app.config['policies'] - if policy.applies_to(decision_context, product_version, subject_type)] + if policy.matches( + decision_context=decision_context, + product_version=product_version, + subject_type=subject_type) + ] + if not subject_policies: # Ignore non-existent policy for Bodhi updates. if subject_type == 'bodhi_update': diff --git a/greenwave/consumers/resultsdb.py b/greenwave/consumers/resultsdb.py index a70f78a..ef4388f 100644 --- a/greenwave/consumers/resultsdb.py +++ b/greenwave/consumers/resultsdb.py @@ -21,8 +21,7 @@ import greenwave.app_factory import greenwave.resources from greenwave.api_v1 import subject_type_identifier_to_list from greenwave.monitoring import publish_decision_exceptions_result_counter -from greenwave.policies import RemoteRule -from greenwave.safe_yaml import SafeYAMLError +from greenwave.policies import applicable_decision_context_product_version_pairs import xmlrpc.client @@ -211,94 +210,50 @@ class ResultsDBHandler(fedmsg.consumers.FedmsgConsumer): result_id (int): A result ID to ignore for comparison. testcase (munch.Munch): The name of a testcase to consider. """ - # Also need to apply policies for each build in the update. - if subject_type == 'bodhi_update': - subject_types = set([subject_type, 'koji_build']) - else: - subject_types = set([subject_type]) - - # Build a set of all policies which might apply to this new results - applicable_policies = set() - remote_policies = set() - for policy in current_app.config['policies']: - for rule in policy.rules: - if isinstance(rule, RemoteRule): - try: - for remote_policy in rule.get_policies(policy, subject_identifier): - remote_policy.product_versions = set( - remote_policy.product_versions).intersection(set( - policy.product_versions)) - if (remote_policy.product_versions and - remote_policy.decision_context == policy.decision_context): - remote_policies = remote_policies.union(set([remote_policy])) - except SafeYAMLError as e: - pass - - tmp_policies = remote_policies.union(current_app.config['policies']) - for policy in tmp_policies: - if policy.subject_type in subject_types: - testcases = ( - getattr(rule, 'test_case_name', None) - for rule in policy.rules) - - if testcase in testcases: - applicable_policies.add(policy) - - log.debug("messaging: found %i applicable policies of %i for testcase %r", - len(applicable_policies), len(current_app.config['policies']), testcase) - - # Given all of our applicable policies, build a map of all decision - # context we know about, and which product versions they relate to. - decision_contexts = collections.defaultdict(set) product_version = _subject_product_version(subject_identifier, subject_type) - for policy in applicable_policies: - if not product_version or policy.applies_to_product_version(product_version): - if product_version: - versions = set([product_version]) - else: - versions = set(policy.product_versions) - decision_contexts[policy.decision_context].update(versions) - log.debug("messaging: found %i decision contexts", len(decision_contexts)) - - # For every context X version combination, ask greenwave if this new - # result pushes any decisions over a threshold. - for decision_context in sorted(decision_contexts.keys()): - product_versions = decision_contexts[decision_context] - for product_version in sorted(product_versions): - greenwave_url = self.fedmsg_config['greenwave_api_url'] + '/decision' - - data = { - 'decision_context': decision_context, - 'product_version': product_version, + policies = self.flask_app.config['policies'] + contexts_product_versions = applicable_decision_context_product_version_pairs( + policies, + subject_type=subject_type, + subject_identifier=subject_identifier, + testcase=testcase, + product_version=product_version) + + for decision_context, product_version in sorted(contexts_product_versions): + greenwave_url = self.fedmsg_config['greenwave_api_url'] + '/decision' + + data = { + 'decision_context': decision_context, + 'product_version': product_version, + 'subject_type': subject_type, + 'subject_identifier': subject_identifier, + } + + try: + decision = greenwave.resources.retrieve_decision(greenwave_url, data) + + # get old decision + data.update({ + 'ignore_result': [result_id], + }) + old_decision = greenwave.resources.retrieve_decision(greenwave_url, data) + except requests.exceptions.HTTPError as e: + log.exception('Failed to retrieve decision for data=%s, error: %s', data, e) + continue + + if decision == old_decision: + log.debug('Skipped emitting fedmsg, decision did not change: %s', decision) + else: + decision.update({ 'subject_type': subject_type, 'subject_identifier': subject_identifier, - } - - try: - decision = greenwave.resources.retrieve_decision(greenwave_url, data) - - # get old decision - data.update({ - 'ignore_result': [result_id], - }) - old_decision = greenwave.resources.retrieve_decision(greenwave_url, data) - except requests.exceptions.HTTPError as e: - log.exception('Failed to retrieve decision for data=%s, error: %s', data, e) - continue - - if decision == old_decision: - log.debug('Skipped emitting fedmsg, decision did not change: %s', decision) - else: - decision.update({ - 'subject_type': subject_type, - 'subject_identifier': subject_identifier, - # subject is for backwards compatibility only: - 'subject': subject_type_identifier_to_list(subject_type, - subject_identifier), - 'decision_context': decision_context, - 'product_version': product_version, - 'previous': old_decision, - }) - log.debug('Emitted a fedmsg, %r, on the "%s" topic', decision, - 'greenwave.decision.update') - fedmsg.publish(topic='decision.update', msg=decision) + # subject is for backwards compatibility only: + 'subject': subject_type_identifier_to_list(subject_type, + subject_identifier), + 'decision_context': decision_context, + 'product_version': product_version, + 'previous': old_decision, + }) + log.debug('Emitted a fedmsg, %r, on the "%s" topic', decision, + 'greenwave.decision.update') + fedmsg.publish(topic='decision.update', msg=decision) diff --git a/greenwave/consumers/waiverdb.py b/greenwave/consumers/waiverdb.py index a7180d5..808cf64 100644 --- a/greenwave/consumers/waiverdb.py +++ b/greenwave/consumers/waiverdb.py @@ -18,6 +18,7 @@ import requests import greenwave.app_factory from greenwave.api_v1 import subject_type_identifier_to_list from greenwave.monitoring import publish_decision_exceptions_waiver_counter +from greenwave.policies import applicable_decision_context_product_version_pairs requests_session = requests.Session() @@ -78,54 +79,60 @@ class WaiverDBHandler(fedmsg.consumers.FedmsgConsumer): @publish_decision_exceptions_waiver_counter.count_exceptions() def _publish_decision_changes(self, subject_type, subject_identifier, waiver_id, product_version, testcase): - for policy in self.flask_app.config['policies']: - for rule in policy.rules: - if getattr(rule, 'test_case_name', None) == testcase: - data = { - 'decision_context': policy.decision_context, - 'product_version': product_version, - 'subject_type': subject_type, - 'subject_identifier': subject_identifier, - } - response = requests_session.post( - self.fedmsg_config['greenwave_api_url'] + '/decision', - headers={'Content-Type': 'application/json'}, - data=json.dumps(data)) - - if not response.ok: - log.error(response.text) - continue - - decision = response.json() - - # get old decision - data.update({ - 'ignore_waiver': [waiver_id], - }) - response = requests_session.post( - self.fedmsg_config['greenwave_api_url'] + '/decision', - headers={'Content-Type': 'application/json'}, - data=json.dumps(data)) - - if not response.ok: - log.error(response.text) - continue - - old_decision = response.json() - - if decision != old_decision: - msg = decision - decision.update({ - 'subject_type': subject_type, - 'subject_identifier': subject_identifier, - # subject is for backwards compatibility only: - 'subject': subject_type_identifier_to_list(subject_type, - subject_identifier), - 'testcase': testcase, - 'decision_context': policy.decision_context, - 'product_version': product_version, - 'previous': old_decision, - }) - log.debug('Emitted a fedmsg, %r, on the "%s" topic', msg, - 'greenwave.decision.update') - fedmsg.publish(topic='decision.update', msg=msg) + policies = self.flask_app.config['policies'] + contexts_product_versions = applicable_decision_context_product_version_pairs( + policies, + subject_type=subject_type, + subject_identifier=subject_identifier, + testcase=testcase, + product_version=product_version) + + for decision_context, product_version in sorted(contexts_product_versions): + data = { + 'decision_context': decision_context, + 'product_version': product_version, + 'subject_type': subject_type, + 'subject_identifier': subject_identifier, + } + response = requests_session.post( + self.fedmsg_config['greenwave_api_url'] + '/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + + if not response.ok: + log.error(response.text) + continue + + decision = response.json() + + # get old decision + data.update({ + 'ignore_waiver': [waiver_id], + }) + response = requests_session.post( + self.fedmsg_config['greenwave_api_url'] + '/decision', + headers={'Content-Type': 'application/json'}, + data=json.dumps(data)) + + if not response.ok: + log.error(response.text) + continue + + old_decision = response.json() + + if decision != old_decision: + msg = decision + decision.update({ + 'subject_type': subject_type, + 'subject_identifier': subject_identifier, + # subject is for backwards compatibility only: + 'subject': subject_type_identifier_to_list(subject_type, + subject_identifier), + 'testcase': testcase, + 'decision_context': decision_context, + 'product_version': product_version, + 'previous': old_decision, + }) + log.debug('Emitted a fedmsg, %r, on the "%s" topic', msg, + 'greenwave.decision.update') + fedmsg.publish(topic='decision.update', msg=msg) diff --git a/greenwave/policies.py b/greenwave/policies.py index c67db6f..cde1dec 100644 --- a/greenwave/policies.py +++ b/greenwave/policies.py @@ -284,6 +284,18 @@ class Rule(SafeYAMLObject): """ raise NotImplementedError() + def matches(self, policy, **attributes): + """ + Same as Policy.matches() for a rule attributes. + + Args: + policy (Policy): Parent policy of the rule + + Returns: + bool: True only if provided attributes matches the rule + """ + return True + def waives_invalid_gating_yaml(waiver, subject_type, subject_identifier): return (waiver['testcase'] == 'invalid-gating-yaml' and @@ -295,7 +307,7 @@ class RemoteRule(Rule): yaml_tag = '!RemoteRule' safe_yaml_attributes = {} - def get_policies(self, policy, subject_identifier): + def _get_sub_policies(self, policy, subject_identifier): if policy.subject_type != 'koji_build': return [] @@ -313,12 +325,15 @@ class RemoteRule(Rule): return [] policies = RemotePolicy.safe_load_all(response) - return policies + return [ + sub_policy for sub_policy in policies + if sub_policy.decision_context == policy.decision_context + ] def check(self, policy, product_version, subject_identifier, results_retriever, waivers): try: - policies = self.get_policies(policy, subject_identifier) + policies = self._get_sub_policies(policy, subject_identifier) except SafeYAMLError as e: if any(waives_invalid_gating_yaml(waiver, policy.subject_type, subject_identifier) for waiver in waivers): @@ -330,8 +345,7 @@ class RemoteRule(Rule): answers = [] for remote_policy in policies: - if remote_policy.applies_to( - policy.decision_context, product_version, policy.subject_type): + if remote_policy.matches_product_version(product_version): response = remote_policy.check( product_version, subject_identifier, results_retriever, waivers) @@ -342,6 +356,23 @@ class RemoteRule(Rule): return answers + def matches(self, policy, **attributes): + subject_identifier = attributes.get('subject_identifier') + if not subject_identifier: + return True + + sub_policies = [] + try: + sub_policies = self._get_sub_policies(policy, subject_identifier) + except SafeYAMLError: + logging.exception( + 'Failed to parse policies for %r', subject_identifier) + except Exception: + logging.exception( + 'Failed to retrieve policies for %r', subject_identifier) + + return any(sub_policy.matches(**attributes) for sub_policy in sub_policies) + def to_json(self): return { 'rule': self.__class__.__name__, @@ -409,6 +440,10 @@ class PassingTestCaseRule(Rule): return self._answer_for_result( latest_result, waivers, policy.subject_type, subject_identifier) + def matches(self, policy, **attributes): + testcase = attributes.get('testcase') + return not testcase or testcase == self.test_case_name + def to_json(self): return { 'rule': self.__class__.__name__, @@ -488,6 +523,10 @@ class PackageSpecificRule(Rule): rule.test_case_name = self.test_case_name return rule.check(policy, product_version, subject_identifier, results_retriever, waivers) + def matches(self, policy, **attributes): + testcase = attributes.get('testcase') + return not testcase or testcase == self.test_case_name + def to_json(self): return { 'rule': self.__class__.__name__, @@ -520,10 +559,29 @@ class Policy(SafeYAMLObject): 'relevance_value': SafeYAMLString(optional=True), } - def applies_to(self, decision_context, product_version, subject_type): - return (decision_context == self.decision_context and - self.applies_to_product_version(product_version) and - subject_type == self.subject_type) + def matches(self, **attributes): + """ + Returns True only if policy matches provided attributes. + + If an attribute to match is missing it's treated as irrelevant, i.e."match anything". + + Unknown attributes are ignored. + + There must be at least one matching rule or no rules in the policy. + """ + decision_context = attributes.get('decision_context') + if decision_context and decision_context != self.decision_context: + return False + + product_version = attributes.get('product_version') + if product_version and not self.matches_product_version(product_version): + return False + + subject_type = attributes.get('subject_type') + if subject_type and subject_type != self.subject_type: + return False + + return not self.rules or any(rule.matches(self, **attributes) for rule in self.rules) def check(self, product_version, subject_identifier, results_retriever, waivers): # If an item is about a package and it is in the blacklist, return RuleSatisfied() @@ -541,7 +599,7 @@ class Policy(SafeYAMLObject): answers.append(response) return answers - def applies_to_product_version(self, product_version): + def matches_product_version(self, product_version): return any(fnmatch(product_version, version) for version in self.product_versions) @property @@ -567,3 +625,31 @@ class RemotePolicy(Policy): if isinstance(rule, RemoteRule): raise SafeYAMLError('RemoteRule is not allowed in remote policies') super().validate() + + +def _applicable_decision_context_product_version_pairs(policies, **attributes): + applicable_policies = [ + policy for policy in policies if policy.matches(**attributes) + ] + + log.debug("found %i applicable policies of %i for: %r", + len(applicable_policies), len(policies), attributes) + + product_version = attributes.get('product_version') + if product_version: + for policy in applicable_policies: + yield policy.decision_context, product_version + else: + for policy in applicable_policies: + # FIXME: This can returns product version patterns like 'fedora-*'. + for product_version in policy.product_versions: + yield policy.decision_context, product_version + + +def applicable_decision_context_product_version_pairs(policies, **attributes): + contexts_product_versions = sorted(set( + _applicable_decision_context_product_version_pairs( + policies, **attributes))) + + log.debug("found %i decision contexts", len(contexts_product_versions)) + return contexts_product_versions diff --git a/greenwave/tests/test_policies.py b/greenwave/tests/test_policies.py index 794ac05..335406e 100644 --- a/greenwave/tests/test_policies.py +++ b/greenwave/tests/test_policies.py @@ -316,7 +316,12 @@ rules: load_policies(tmpdir.strpath) -def test_product_versions_pattern(tmpdir): +@pytest.mark.parametrize(('product_version', 'applies'), [ + ('fedora-27', True), + ('fedora-28', True), + ('epel-7', False), +]) +def test_product_versions_pattern(product_version, applies, tmpdir): p = tmpdir.join('fedora.yaml') p.write(""" --- !Policy @@ -325,14 +330,16 @@ product_versions: - fedora-* decision_context: dummy_context subject_type: bodhi_update -rules: [] +rules: + - !PassingTestCaseRule {test_case_name: test} """) policies = load_policies(tmpdir.strpath) policy = policies[0] - assert policy.applies_to('dummy_context', 'fedora-27', 'bodhi_update') - assert policy.applies_to('dummy_context', 'fedora-28', 'bodhi_update') - assert not policy.applies_to('dummy_context', 'epel-7', 'bodhi_update') + assert applies == policy.matches( + decision_context='dummy_context', + product_version=product_version, + subject_type='bodhi_update') def test_remote_rule_policy(tmpdir): diff --git a/greenwave/tests/test_rules.py b/greenwave/tests/test_rules.py new file mode 100644 index 0000000..afab240 --- /dev/null +++ b/greenwave/tests/test_rules.py @@ -0,0 +1,98 @@ +import mock + +from textwrap import dedent + +from greenwave.app_factory import create_app +from greenwave.policies import Policy +from greenwave.safe_yaml import SafeYAMLError + + +def test_match_passing_test_case_rule(): + 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} + """) + 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) + assert rule.matches(policy, testcase='some_test_case') + assert not rule.matches(policy, testcase='other_test_case') + + +def test_match_package_specific_rule(): + policy_yaml = dedent(""" + --- !Policy + id: "some_policy" + product_versions: [rhel-9000] + decision_context: bodhi_update_push_stable + subject_type: koji_build + rules: + - !PackageSpecificBuild {test_case_name: some_test_case, repos: [nethack]} + """) + 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) + assert rule.matches(policy, testcase='some_test_case') + assert not rule.matches(policy, testcase='other_test_case') + + +@mock.patch('greenwave.resources.retrieve_yaml_remote_rule') +@mock.patch('greenwave.resources.retrieve_scm_from_koji') +def test_match_remote_rule(mock_retrieve_scm_from_koji, mock_retrieve_yaml_remote_rule): + policy_yaml = dedent(""" + --- !Policy + id: "some_policy" + product_versions: [rhel-9000] + decision_context: bodhi_update_push_stable + subject_type: koji_build + rules: + - !RemoteRule {} + """) + mock_retrieve_yaml_remote_rule.return_value = dedent(""" + --- !Policy + product_versions: [rhel-*] + decision_context: bodhi_update_push_stable + rules: + - !PassingTestCaseRule {test_case_name: some_test_case} + """) + nvr = 'nethack-1.2.3-1.el9000' + mock_retrieve_scm_from_koji.return_value = ('rpms', nvr, '123') + + app = create_app('greenwave.config.TestingConfig') + with app.app_context(): + 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) + assert rule.matches(policy, subject_identifier=nvr) + assert rule.matches(policy, subject_identifier=nvr, testcase='some_test_case') + assert not rule.matches(policy, subject_identifier=nvr, testcase='other_test_case') + + # Simulate invalid gating.yaml file. + def raiseYamlError(*args): + raise SafeYAMLError() + mock_retrieve_yaml_remote_rule.side_effect = raiseYamlError + + assert rule.matches(policy) + assert not rule.matches(policy, subject_identifier=nvr) + assert not rule.matches(policy, subject_identifier=nvr, testcase='some_test_case') + assert not rule.matches(policy, subject_identifier=nvr, testcase='other_test_case')