Merge lp:~jtv/launchpad/bug-876594 into lp:launchpad

Proposed by Jeroen T. Vermeulen on 2012-01-05
Status: Merged
Merged at revision: 14645
Proposed branch: lp:~jtv/launchpad/bug-876594
Merge into: lp:launchpad
Diff against target: 279 lines (+208/-16)
4 files modified
lib/lp/archiveuploader/nascentupload.py (+5/-5)
lib/lp/archiveuploader/tests/test_sync_notification.py (+178/-0)
lib/lp/soyuz/model/archive.py (+2/-6)
lib/lp/soyuz/model/queue.py (+23/-5)
To merge this branch: bzr merge lp:~jtv/launchpad/bug-876594
Reviewer Review Type Date Requested Status
j.c.sackett (community) 2012-01-05 Approve on 2012-01-05
Review via email: mp+87624@code.launchpad.net

Commit Message

Notify sync requester, not maintainer, of upload failures from synced packages for a distro.

Description of the Change

= Summary =

We're spamming Debian maintainers with failure notifications from builds of their packages as copied to Ubuntu (or derived distros).

== Proposed fix ==

Spam the person who requested the sync instead.

== Pre-implementation notes ==

Discussed with Colin, Julian, and William. There may be opportunities to simplify other notification logic in the future, in particular where it suppresses inappropriate notifications for PPAs.

Apparently a build upload has no signing key; we're not entirely sure right now whose signing key is on the upload. We kept the existing logic for non-copied uploads.

== Implementation details ==

See the bug for call trees.

I didn't want to mess with the notify() function underlying Upload.notify(); it's got weird special cases and is used in too many code paths and data flows. It seems to be picking the wrong default victim in cases where there is no signing key.

== Tests ==

I added a new one. Giving it a file of itself allowed me to introduce lots of helpers.
{{{
./bin/test -vvc lp.archiveuploader.tests.test_sync_notification
}}}

== Demo and Q/A ==

We'll need help from the Soyuz specialists for this.

= Launchpad lint =

There's one piece of pre-existing lint that I dared not touch. Something about a getter and setter with the same name confusing the linter, I think.

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/archiveuploader/nascentupload.py
  lib/lp/soyuz/model/queue.py
  lib/lp/archiveuploader/tests/test_sync_notification.py
  lib/lp/soyuz/model/archive.py

./lib/lp/soyuz/model/archive.py
     348: redefinition of function 'private' from line 344

To post a comment you must log in.
j.c.sackett (jcsackett) wrote :

Jeroen--

Looks good. That is some serious setup in the testcase, but I've never found a nice simple way to setup a soyuz based test.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/archiveuploader/nascentupload.py'
2--- lib/lp/archiveuploader/nascentupload.py 2011-12-24 13:09:35 +0000
3+++ lib/lp/archiveuploader/nascentupload.py 2012-01-06 06:18:28 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """The processing of nascent uploads.
10@@ -909,10 +909,10 @@
11 # state.
12 pass
13
14- changes_file_object = open(self.changes.filepath, "r")
15- self.queue_root.notify(summary_text=self.rejection_message,
16- changes_file_object=changes_file_object, logger=self.logger)
17- changes_file_object.close()
18+ with open(self.changes.filepath, "r") as changes_file_object:
19+ self.queue_root.notify(
20+ summary_text=self.rejection_message,
21+ changes_file_object=changes_file_object, logger=self.logger)
22
23 def _createQueueEntry(self):
24 """Return a PackageUpload object."""
25
26=== added file 'lib/lp/archiveuploader/tests/test_sync_notification.py'
27--- lib/lp/archiveuploader/tests/test_sync_notification.py 1970-01-01 00:00:00 +0000
28+++ lib/lp/archiveuploader/tests/test_sync_notification.py 2012-01-06 06:18:28 +0000
29@@ -0,0 +1,178 @@
30+# Copyright 2012 Canonical Ltd. This software is licensed under the
31+# GNU Affero General Public License version 3 (see the file LICENSE).
32+
33+"""Test notification behaviour for cross-distro package syncs."""
34+
35+__metaclass__ = type
36+
37+import os.path
38+
39+from zope.component import getUtility
40+
41+from lp.archiveuploader.nascentupload import (
42+ NascentUpload,
43+ UploadError,
44+ )
45+from lp.registry.interfaces.pocket import PackagePublishingPocket
46+from lp.services.log.logger import DevNullLogger
47+from lp.soyuz.enums import (
48+ ArchivePermissionType,
49+ SourcePackageFormat,
50+ )
51+from lp.soyuz.interfaces.sourcepackageformat import (
52+ ISourcePackageFormatSelectionSet,
53+ )
54+from lp.soyuz.model.archivepermission import ArchivePermission
55+from lp.soyuz.scripts.packagecopier import do_copy
56+from lp.testing import (
57+ login,
58+ TestCaseWithFactory,
59+ )
60+from lp.testing.fakemethod import FakeMethod
61+from lp.testing.layers import LaunchpadZopelessLayer
62+from lp.testing.mail_helpers import pop_notifications
63+
64+
65+class FakeUploadPolicy:
66+ def __init__(self, spph):
67+ self.distroseries = spph.distroseries
68+ self.archive = spph.distroseries.main_archive
69+ self.pocket = spph.pocket
70+
71+ setDistroSeriesAndPocket = FakeMethod()
72+ validateUploadType = FakeMethod()
73+ checkUpload = FakeMethod()
74+
75+
76+class FakeChangesFile:
77+ def __init__(self, spph, file_path):
78+ self.files = []
79+ self.filepath = file_path
80+ self.filename = os.path.basename(file_path)
81+ self.architectures = ['i386']
82+ self.suite_name = '-'.join([spph.distroseries.name, spph.pocket.name])
83+ self.raw_content = open(file_path).read()
84+ self.signingkey = None
85+
86+ checkFileName = FakeMethod([])
87+ processAddresses = FakeMethod([])
88+ processFiles = FakeMethod([])
89+ verify = FakeMethod([UploadError("Deliberately broken")])
90+
91+
92+class TestSyncNotification(TestCaseWithFactory):
93+
94+ layer = LaunchpadZopelessLayer
95+
96+ def makePersonWithEmail(self):
97+ """Create a person; return (person, email)."""
98+ address = "%s@example.com" % self.factory.getUniqueString()
99+ person = self.factory.makePerson(email=address)
100+ return person, address
101+
102+ def makeSPPH(self, distroseries, maintainer_address):
103+ """Create a `SourcePackagePublishingHistory`."""
104+ return self.factory.makeSourcePackagePublishingHistory(
105+ distroseries=distroseries, pocket=PackagePublishingPocket.RELEASE,
106+ dsc_maintainer_rfc822=maintainer_address)
107+
108+ def makeUploader(self, person, archive, component):
109+ """Grant a person upload privileges for archive/component."""
110+ ArchivePermission(
111+ person=person, archive=archive, component=component,
112+ permission=ArchivePermissionType.UPLOAD)
113+
114+ def syncSource(self, spph, target_distroseries, requester):
115+ """Sync `spph` into `target_distroseries`."""
116+ getUtility(ISourcePackageFormatSelectionSet).add(
117+ target_distroseries, SourcePackageFormat.FORMAT_1_0)
118+ target_archive = target_distroseries.main_archive
119+ self.makeUploader(requester, target_archive, spph.component)
120+ [synced_spph] = do_copy(
121+ [spph], target_archive, target_distroseries,
122+ pocket=spph.pocket, person=requester, allow_delayed_copies=False,
123+ close_bugs=False)
124+ return synced_spph
125+
126+ def makeChangesFile(self, spph, maintainer, maintainer_address,
127+ changer, changer_address):
128+ temp_dir = self.makeTemporaryDirectory()
129+ changes_file = os.path.join(
130+ temp_dir, "%s.changes" % spph.source_package_name)
131+ with open(changes_file, 'w') as changes:
132+ changes.write(
133+ "Maintainer: %s <%s>\n"
134+ "Changed-By: %s <%s>\n"
135+ % (
136+ maintainer.name,
137+ maintainer_address,
138+ changer.name,
139+ changer_address,
140+ ))
141+ return FakeChangesFile(spph, changes_file)
142+
143+ def makeNascentUpload(self, spph, maintainer, maintainer_address,
144+ changer, changer_address):
145+ """Create a `NascentUpload` for `spph`."""
146+ changes = self.makeChangesFile(
147+ spph, maintainer, maintainer_address, changer, changer_address)
148+ upload = NascentUpload(
149+ changes, FakeUploadPolicy(spph), DevNullLogger())
150+ upload.queue_root = upload._createQueueEntry()
151+ das = self.factory.makeDistroArchSeries(
152+ distroseries=spph.distroseries)
153+ bpb = self.factory.makeBinaryPackageBuild(
154+ source_package_release=spph.sourcepackagerelease,
155+ archive=spph.archive, distroarchseries=das, pocket=spph.pocket,
156+ sourcepackagename=spph.sourcepackagename)
157+ upload.queue_root.addBuild(bpb)
158+ return upload
159+
160+ def processAndRejectUpload(self, nascent_upload):
161+ nascent_upload.process()
162+ # Obtain the required privileges for do_reject.
163+ login('foo.bar@canonical.com')
164+ nascent_upload.do_reject(notify=True)
165+
166+ def getNotifiedAddresses(self):
167+ """Get email addresses that were notified."""
168+ return [message['to'] for message in pop_notifications()]
169+
170+ def test_failed_copy_builds_do_not_spam_upstream(self):
171+ """Failed builds do not spam people who are not responsible for them.
172+
173+ We import Debian source packages, then sync them into Ubuntu (and
174+ from there, into Ubuntu-derived distros). Those syncs then trigger
175+ builds that the original Debian maintainers and last-change authors
176+ are not responsible for.
177+
178+ In a situation like that, we should not bother those people with the
179+ failure. We notify the person who requested the sync instead.
180+
181+ (The logic in lp.soyuz.adapters.notification may still notify the
182+ author of the last change, if that person is also an uploader for the
183+ archive that the failure happened in. For this particular situation
184+ we consider that not so much an intended behaviour, as an emergent one
185+ that does not seem inappropriate. It'd be hard to change if we wanted
186+ to.)
187+
188+ This test guards against bug 876594.
189+ """
190+ maintainer, maintainer_address = self.makePersonWithEmail()
191+ changer, changer_address = self.makePersonWithEmail()
192+ dsp = self.factory.makeDistroSeriesParent()
193+ original_spph = self.makeSPPH(dsp.parent_series, maintainer_address)
194+ sync_requester, syncer_address = self.makePersonWithEmail()
195+ synced_spph = self.syncSource(
196+ original_spph, dsp.derived_series, sync_requester)
197+ nascent_upload = self.makeNascentUpload(
198+ synced_spph, maintainer, maintainer_address,
199+ changer, changer_address)
200+ pop_notifications()
201+ self.processAndRejectUpload(nascent_upload)
202+
203+ notified_addresses = '\n'.join(self.getNotifiedAddresses())
204+
205+ self.assertNotIn(maintainer_address, notified_addresses)
206+ self.assertNotIn(changer_address, notified_addresses)
207+ self.assertIn(syncer_address, notified_addresses)
208
209=== modified file 'lib/lp/soyuz/model/archive.py'
210--- lib/lp/soyuz/model/archive.py 2011-12-30 06:14:56 +0000
211+++ lib/lp/soyuz/model/archive.py 2012-01-06 06:18:28 +0000
212@@ -1288,18 +1288,14 @@
213 # - the given source package directly
214 # - a package set in the correct distro series that includes the
215 # given source package
216- source_allowed = self.checkArchivePermission(person,
217- sourcepackagename)
218+ source_allowed = self.checkArchivePermission(
219+ person, sourcepackagename)
220 set_allowed = self.isSourceUploadAllowed(
221 sourcepackagename, person, distroseries)
222 if source_allowed or set_allowed:
223 return None
224
225 if not self.getComponentsForUploader(person):
226- # XXX: JamesWestby 2010-08-01 bug=612351: We have to use
227- # is_empty() as we don't get an SQLObjectResultSet back, and
228- # so __nonzero__ isn't defined on it, and a straight bool
229- # check wouldn't do the right thing.
230 if self.getPackagesetsForUploader(person).is_empty():
231 return NoRightsForArchive()
232 else:
233
234=== modified file 'lib/lp/soyuz/model/queue.py'
235--- lib/lp/soyuz/model/queue.py 2011-12-30 06:14:56 +0000
236+++ lib/lp/soyuz/model/queue.py 2012-01-06 06:18:28 +0000
237@@ -845,6 +845,27 @@
238 # and uploading to any archive as the signer.
239 return changes, strip_pgp_signature(changes_content).splitlines(True)
240
241+ def findSourcePublication(self):
242+ """Find the `SourcePackagePublishingHistory` for this build."""
243+ first_build = self.builds[:1]
244+ if first_build:
245+ [first_build] = first_build
246+ return first_build.build._getLatestPublication()
247+ else:
248+ return None
249+
250+ def findPersonToNotify(self):
251+ """Find the right person to notify about this upload."""
252+ spph = self.findSourcePublication()
253+ if spph and self.sourcepackagerelease.upload_archive != self.archive:
254+ # This is a build triggered by the syncing of a source
255+ # package. Notify the person who requested the sync.
256+ return spph.creator
257+ elif self.signing_key:
258+ return self.signing_key.owner
259+ else:
260+ return None
261+
262 def notify(self, summary_text=None, changes_file_object=None,
263 logger=None, dry_run=False):
264 """See `IPackageUpload`."""
265@@ -860,12 +881,9 @@
266 changesfile_content = changes_file_object.read()
267 else:
268 changesfile_content = 'No changes file content available.'
269- if self.signing_key is not None:
270- signer = self.signing_key.owner
271- else:
272- signer = None
273+ blamee = self.findPersonToNotify()
274 notify(
275- signer, self.sourcepackagerelease, self.builds, self.customfiles,
276+ blamee, self.sourcepackagerelease, self.builds, self.customfiles,
277 self.archive, self.distroseries, self.pocket, summary_text,
278 changes, changesfile_content, changes_file_object,
279 status_action[self.status], dry_run=dry_run, logger=logger)