diff --git a/britney.py b/britney.py index a64a684..7de27fd 100755 --- a/britney.py +++ b/britney.py @@ -205,6 +205,7 @@ from britney2.policies.policy import AgePolicy, RCBugPolicy, PiupartsPolicy, Pol from britney2.policies.policy import LPBlockBugPolicy from britney2.policies.autopkgtest import AutopkgtestPolicy from britney2.policies.sourceppa import SourcePPAPolicy +from britney2.policies.sruadtregression import SRUADTRegressionPolicy from britney2.policies.email import EmailPolicy from britney2.utils import (old_libraries_format, undo_changes, compute_reverse_tree, possibly_compressed, @@ -543,6 +544,11 @@ class Britney(object): self.policies.append(EmailPolicy(self.options, self.suite_info, dry_run=add_email_policy == 'dry-run')) + add_sruregression_policy = getattr(self.options, 'sruregression_enable', 'no') + if add_sruregression_policy in ('yes', 'dry-run'): + self.policies.append(SRUADTRegressionPolicy(self.options, + self.suite_info, + dry_run=add_sruregression_policy == 'dry-run')) for policy in self.policies: policy.register_hints(self._hint_parser) diff --git a/britney2/policies/sruadtregression.py b/britney2/policies/sruadtregression.py new file mode 100644 index 0000000..9aaccc4 --- /dev/null +++ b/britney2/policies/sruadtregression.py @@ -0,0 +1,164 @@ +import os +import json +import smtplib + +from urllib.request import urlopen, URLError + +from britney2.policies.rest import Rest +from britney2.policies.policy import BasePolicy, PolicyVerdict + + +MESSAGE = """From: Ubuntu Stable Release Team +To: {bug_mail} +X-Proposed-Migration: notice +Subject: Autopkgtest regression report ({source_name}/{version}) + +All autopkgtests for the newly accepted {source_name} ({version}) for {series_name} have finished running. +There have been regressions in tests triggered by the package. Please visit the sru report page and investigate the failures. + +https://people.canonical.com/~ubuntu-archive/pending-sru.html#{series_name} +""" + + +class SRUADTRegressionPolicy(BasePolicy, Rest): + + def __init__(self, options, suite_info, dry_run=False): + super().__init__('sruadtregression', options, suite_info, {'unstable'}) + self.state_filename = os.path.join(options.unstable, 'sru_regress_inform_state') + self.state = {} + self.dry_run = dry_run + self.email_host = getattr(self.options, 'email_host', 'localhost') + + def initialise(self, britney): + super().initialise(britney) + if os.path.exists(self.state_filename): + with open(self.state_filename, encoding='utf-8') as data: + self.state = json.load(data) + self.log('Loaded state file %s' % self.state_filename) + tmp = self.state_filename + '.new' + if os.path.exists(tmp): + with open(tmp, encoding='utf-8') as data: + self.state.update(json.load(data)) + self.restore_state() + # Remove any old entries from the statefile + self.cleanup_state() + + def bugs_from_changes(self, change_url): + '''Return bug list from a .changes file URL''' + last_exception = None + # Querying LP can timeout a lot, retry 3 times + for i in range(3): + try: + changes = urlopen(change_url) + break + except URLError as e: + last_exception = e + pass + else: + raise last_exception + bugs = set() + for l in changes: + if l.startswith('Launchpad-Bugs-Fixed: '): + bugs = {int(b) for b in l.split()[1:]} + break + return bugs + + def apply_policy_impl(self, policy_info, suite, source_name, source_data_tdist, source_data_srcdist, excuse): + # If all the autopkgtests have finished running. + if (excuse.current_policy_verdict == PolicyVerdict.REJECTED_TEMPORARILY or + excuse.current_policy_verdict == PolicyVerdict.PASS_HINTED): + return PolicyVerdict.PASS + # We only care about autopkgtest regressions + if 'autopkgtest' not in excuse.reason or not excuse.reason['autopkgtest']: + return PolicyVerdict.PASS + version = source_data_srcdist.version + distro_name = self.options.distribution + series_name = self.options.series + try: + if self.state[source_name] == version: + # We already informed about the regression. + return PolicyVerdict.PASS + except KeyError: + # Expected when no failure has been reported so far for this + # source - we want to continue in that case. Easier than + # doing n number of if-checks. + pass + data = self.query_lp_rest_api('%s/+archive/primary' % distro_name, { + 'ws.op': 'getPublishedSources', + 'distro_series': '/%s/%s' % (distro_name, series_name), + 'exact_match': 'true', + 'order_by_date': 'true', + 'pocket': 'Proposed', + 'source_name': source_name, + 'version': version, + }) + try: + src = next(iter(data['entries'])) + # IndexError means no packages in -proposed matched this name/version, + # which is expected to happen when bileto runs britney. + except StopIteration: + self.log('No packages matching %s/%s the %s/%s main archive, not ' + 'informing of ADT regressions' % ( + source_name, version, distro_name, series_name)) + return PolicyVerdict.PASS + changes_url = self.query_lp_rest_api(src.self_link, { + 'ws.op': 'changesFileUrl', + }) + bugs = self.bugs_from_changes(changes_url) + # Now leave a comment informing about the ADT regressions on each bug + for bug in bugs: + if not self.dry_run: + bug_mail = '%s@bugs.launchpad.net' % bug + server = smtplib.SMTP(self.email_host) + server.sendmail( + 'noreply@canonical.com', + bug_mail, + MESSAGE.format(**locals())) + server.quit() + self.log('Sending ADT regression message to LP: #%s ' + 'regarding %s/%s in %s' % ( + bug, source_name, version, series_name)) + self.save_progress(source_name, version, distro_name, series_name) + return PolicyVerdict.PASS + + def save_progress(self, source, version, distro, series): + if self.dry_run: + return + if distro not in self.state: + self.state[distro] = {} + if series not in self.state[distro]: + self.state[distro][series] = {} + self.state[distro][series][source] = version + tmp = self.state_filename + '.new' + with open(tmp, 'w', encoding='utf-8') as data: + json.dump(self.state, data) + + def restore_state(self): + try: + os.rename(self.state_filename + '.new', self.state_filename) + # If we haven't written any state, don't clobber the old one + except FileNotFoundError: + pass + + self.log('Wrote SRU ADT regression state to %s' % self.state_filename) + + def cleanup_state(self): + '''Remove all no-longer-valid package entries from the statefile''' + for distro_name in self.state: + for series_name, pkgs in self.state[distro_name].items(): + for source_name, version in pkgs.copy().items(): + data = self.query_lp_rest_api( + '%s/+archive/primary' % distro_name, { + 'ws.op': 'getPublishedSources', + 'distro_series': '/%s/%s' % (distro_name, + series_name), + 'exact_match': 'true', + 'order_by_date': 'true', + 'pocket': 'Proposed', + 'status': 'Published', + 'source_name': source_name, + 'version': version, + } + ) + if not data['entries']: + del self.state[distro_name][series_name][source_name] diff --git a/tests/test_sruadtregression.py b/tests/test_sruadtregression.py new file mode 100755 index 0000000..ad7ef49 --- /dev/null +++ b/tests/test_sruadtregression.py @@ -0,0 +1,320 @@ +#!/usr/bin/python3 +# (C) 2018 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. + +import os +import sys +import json +import unittest +from contextlib import ExitStack +from tempfile import TemporaryDirectory +from unittest.mock import DEFAULT, Mock, patch, call +from urllib.request import URLError + +PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, PROJECT_DIR) + +from britney2.policies.policy import PolicyVerdict +from britney2.policies.sruadtregression import SRUADTRegressionPolicy +from tests.test_sourceppa import FakeBritney + + +FAKE_CHANGES = \ +"""Format: 1.8 +Date: Mon, 16 Jul 2018 17:05:18 -0500 +Source: test +Binary: test +Architecture: source +Version: 1.0 +Distribution: bionic +Urgency: medium +Maintainer: Ubuntu Developers +Changed-By: Foo Bar +Description: + test - A test package +Launchpad-Bugs-Fixed: 1 4 2 31337 31337 +Changes: + test (1.0) bionic; urgency=medium + . + * Test build +Checksums-Sha1: + fb11f859b85e09513d395a293dbb0808d61049a7 2454 test_1.0.dsc + 06500cc627bc04a02b912a11417fca5a1109ec97 69852 test_1.0.debian.tar.xz +Checksums-Sha256: + d91f369a4b7fc4cba63540a81bd6f1492ca86661cf2e3ccac6028fb8c98d5ff5 2454 test_1.0.dsc + ffb460269ea2acb3db24adb557ba7541fe57fde0b10f6b6d58e8958b9a05b0b9 69852 test_1.0.debian.tar.xz +Files: + 2749eba6cae4e49c00f25e870b228871 2454 libs extra test_1.0.dsc + 95bf4af1ba0766b6f83dd3c3a33b0272 69852 libs extra test_1.0.debian.tar.xz +""" + + +class FakeOptions: + distribution = 'testbuntu' + series = 'zazzy' + unstable = '/tmp' + verbose = False + email_host = 'localhost:1337' + + +class FakeSourceData: + version = '55.0' + + +class FakeExcuse: + is_valid = True + daysold = 0 + reason = {'autopkgtest': 1} + current_policy_verdict = PolicyVerdict.REJECTED_PERMANENTLY + + +class FakeExcuseRunning: + is_valid = True + daysold = 0 + reason = {'autopkgtest': 1} + current_policy_verdict = PolicyVerdict.REJECTED_TEMPORARILY + + +class FakeExcusePass: + is_valid = True + daysold = 0 + reason = {} + current_policy_verdict = PolicyVerdict.PASS + + +class FakeExcuseHinted: + is_valid = True + daysold = 0 + reason = {'skiptest': 1} + current_policy_verdict = PolicyVerdict.PASS_HINTED + + +class T(unittest.TestCase): + + def setUp(self): + super().setUp() + + def test_bugs_from_changes(self): + """Check extraction of bug numbers from .changes files""" + with ExitStack() as resources: + options = FakeOptions + options.unstable = resources.enter_context(TemporaryDirectory()) + urlopen_mock = resources.enter_context( + patch('britney2.policies.sruadtregression.urlopen')) + urlopen_mock.return_value = iter(FAKE_CHANGES.split('\n')) + pol = SRUADTRegressionPolicy(options, {}) + bugs = pol.bugs_from_changes('http://some.url') + self.assertEqual(len(bugs), 4) + self.assertSetEqual(bugs, set((1, 4, 2, 31337))) + + def test_bugs_from_changes_retry(self): + """Check .changes extraction retry mechanism""" + with ExitStack() as resources: + options = FakeOptions + options.unstable = resources.enter_context(TemporaryDirectory()) + urlopen_mock = resources.enter_context( + patch('britney2.policies.sruadtregression.urlopen')) + urlopen_mock.side_effect = URLError('timeout') + pol = SRUADTRegressionPolicy(options, {}) + self.assertRaises(URLError, pol.bugs_from_changes, 'http://some.url') + self.assertEqual(urlopen_mock.call_count, 3) + + def test_comment_on_regression_and_update_state(self): + """Verify bug commenting about ADT regressions and save the state""" + with ExitStack() as resources: + options = FakeOptions + options.unstable = resources.enter_context(TemporaryDirectory()) + pkg_mock = Mock() + pkg_mock.self_link = 'https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/9870565' + smtp_mock = Mock() + resources.enter_context( + patch('britney2.policies.sruadtregression.SRUADTRegressionPolicy.bugs_from_changes', + return_value=set([1, 2]))) + lp = resources.enter_context( + patch('britney2.policies.sruadtregression.SRUADTRegressionPolicy.query_lp_rest_api', + return_value={'entries': [pkg_mock]})) + smtp = resources.enter_context( + patch('britney2.policies.sruadtregression.smtplib.SMTP', return_value=smtp_mock)) + previous_state = { + 'testbuntu': { + 'zazzy': { + 'testpackage': '54.0', + 'ignored': '0.1', + } + }, + 'ghostdistro': { + 'spooky': { + 'ignored': '0.1', + } + } + } + pol = SRUADTRegressionPolicy(options, {}) + # Set a base state + pol.state = previous_state + status = pol.apply_policy_impl(None, None, 'testpackage', None, FakeSourceData, FakeExcuse) + self.assertEqual(status, PolicyVerdict.PASS) + # Assert that we were looking for the right package as per + # FakeSourceData contents + self.assertSequenceEqual(lp.mock_calls, [ + call('testbuntu/+archive/primary', { + 'distro_series': '/testbuntu/zazzy', + 'exact_match': 'true', + 'order_by_date': 'true', + 'pocket': 'Proposed', + 'source_name': 'testpackage', + 'version': '55.0', + 'ws.op': 'getPublishedSources', + }), + call('https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/9870565', { + 'ws.op': 'changesFileUrl', + }) + ]) + # The .changes file only lists 2 bugs, make sure only those are + # commented on + self.assertSequenceEqual(smtp.mock_calls, [ + call('localhost:1337'), + call('localhost:1337') + ]) + self.assertEqual(smtp_mock.sendmail.call_count, 2) + # Check if the state has been saved and not overwritten + expected_state = { + 'testbuntu': { + 'zazzy': { + 'testpackage': '55.0', + 'ignored': '0.1', + } + }, + 'ghostdistro': { + 'spooky': { + 'ignored': '0.1', + } + } + } + self.assertDictEqual(pol.state, expected_state) + + def test_no_comment_if_running(self): + """Don't comment if tests still running""" + with ExitStack() as resources: + options = FakeOptions + options.unstable = resources.enter_context(TemporaryDirectory()) + pkg_mock = Mock() + pkg_mock.self_link = 'https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/9870565' + smtp_mock = Mock() + bugs_from_changes = resources.enter_context( + patch('britney2.policies.sruadtregression.SRUADTRegressionPolicy.bugs_from_changes', + return_value=set([1, 2]))) + lp = resources.enter_context( + patch('britney2.policies.sruadtregression.SRUADTRegressionPolicy.query_lp_rest_api', + return_value={'entries': [pkg_mock]})) + smtp = resources.enter_context( + patch('britney2.policies.sruadtregression.smtplib.SMTP', return_value=smtp_mock)) + pol = SRUADTRegressionPolicy(options, {}) + status = pol.apply_policy_impl(None, None, 'testpackage', None, FakeSourceData, FakeExcuseRunning) + self.assertEqual(status, PolicyVerdict.PASS) + self.assertEqual(bugs_from_changes.call_count, 0) + self.assertEqual(lp.call_count, 0) + self.assertEqual(smtp_mock.sendmail.call_count, 0) + + def test_no_comment_if_passed(self): + """Don't comment if all tests passed""" + with ExitStack() as resources: + options = FakeOptions + options.unstable = resources.enter_context(TemporaryDirectory()) + pkg_mock = Mock() + pkg_mock.self_link = 'https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/9870565' + smtp_mock = Mock() + bugs_from_changes = resources.enter_context( + patch('britney2.policies.sruadtregression.SRUADTRegressionPolicy.bugs_from_changes', + return_value=set([1, 2]))) + lp = resources.enter_context( + patch('britney2.policies.sruadtregression.SRUADTRegressionPolicy.query_lp_rest_api', + return_value={'entries': [pkg_mock]})) + smtp = resources.enter_context( + patch('britney2.policies.sruadtregression.smtplib.SMTP', return_value=smtp_mock)) + pol = SRUADTRegressionPolicy(options, {}) + status = pol.apply_policy_impl(None, None, 'testpackage', None, FakeSourceData, FakeExcusePass) + self.assertEqual(status, PolicyVerdict.PASS) + self.assertEqual(bugs_from_changes.call_count, 0) + self.assertEqual(lp.call_count, 0) + self.assertEqual(smtp_mock.sendmail.call_count, 0) + + def test_no_comment_if_hinted(self): + """Don't comment if package has been hinted in""" + with ExitStack() as resources: + options = FakeOptions + options.unstable = resources.enter_context(TemporaryDirectory()) + pkg_mock = Mock() + pkg_mock.self_link = 'https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/9870565' + smtp_mock = Mock() + bugs_from_changes = resources.enter_context( + patch('britney2.policies.sruadtregression.SRUADTRegressionPolicy.bugs_from_changes', + return_value=set([1, 2]))) + lp = resources.enter_context( + patch('britney2.policies.sruadtregression.SRUADTRegressionPolicy.query_lp_rest_api', + return_value={'entries': [pkg_mock]})) + smtp = resources.enter_context( + patch('britney2.policies.sruadtregression.smtplib.SMTP', return_value=smtp_mock)) + pol = SRUADTRegressionPolicy(options, {}) + status = pol.apply_policy_impl(None, None, 'testpackage', None, FakeSourceData, FakeExcuseHinted) + self.assertEqual(status, PolicyVerdict.PASS) + self.assertEqual(bugs_from_changes.call_count, 0) + self.assertEqual(lp.call_count, 0) + self.assertEqual(smtp_mock.sendmail.call_count, 0) + + def test_initialize(self): + """Check state load, old package cleanup and LP login""" + with ExitStack() as resources: + options = FakeOptions + options.unstable = resources.enter_context(TemporaryDirectory()) + pkg_mock1 = Mock() + pkg_mock1.source_name = 'testpackage' + pkg_mock2 = Mock() + pkg_mock2.source_name = 'otherpackage' + # Since we want to be as accurate as possible, we return query + # results per what query has been performed. + def query_side_effect(link, query): + if query['source_name'] == 'testpackage': + return {'entries': [pkg_mock1]} + elif query['source_name'] == 'otherpackage': + return {'entries': [pkg_mock2]} + return {'entries': []} + lp = resources.enter_context( + patch('britney2.policies.sruadtregression.SRUADTRegressionPolicy.query_lp_rest_api', + side_effect=query_side_effect)) + state = { + 'testbuntu': { + 'zazzy': { + 'testpackage': '54.0', + 'toremove': '0.1', + 'otherpackage': '13ubuntu1', + } + } + } + # Prepare the state file + state_path = os.path.join( + options.unstable, 'sru_regress_inform_state') + with open(state_path, 'w') as f: + json.dump(state, f) + pol = SRUADTRegressionPolicy(options, {}) + pol.initialise(FakeBritney()) + # Check if the stale packages got purged and others not + expected_state = { + 'testbuntu': { + 'zazzy': { + 'testpackage': '54.0', + 'otherpackage': '13ubuntu1', + } + }, + } + # Make sure the state file has been loaded correctly + self.assertDictEqual(pol.state, expected_state) + # Check if we logged in with the right LP credentials + self.assertEqual(pol.email_host, 'localhost:1337') + + +if __name__ == '__main__': + unittest.main()