Merge lp:~wgrant/launchpad/webhook-deletion into lp:launchpad

Proposed by William Grant
Status: Merged
Merged at revision: 17643
Proposed branch: lp:~wgrant/launchpad/webhook-deletion
Merge into: lp:launchpad
Diff against target: 547 lines (+196/-28)
11 files modified
database/schema/security.cfg (+1/-0)
lib/lp/code/model/gitrepository.py (+2/-0)
lib/lp/code/model/tests/test_gitrepository.py (+15/-9)
lib/lp/scripts/garbo.py (+35/-10)
lib/lp/scripts/tests/test_garbo.py (+20/-1)
lib/lp/services/webhooks/configure.zcml (+5/-0)
lib/lp/services/webhooks/interfaces.py (+16/-0)
lib/lp/services/webhooks/model.py (+25/-0)
lib/lp/services/webhooks/tests/test_webhook.py (+25/-7)
lib/lp/services/webhooks/tests/test_webhookjob.py (+42/-1)
lib/lp/services/webhooks/tests/test_webservice.py (+10/-0)
To merge this branch: bzr merge lp:~wgrant/launchpad/webhook-deletion
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+265360@code.launchpad.net

Commit message

Allow Webhooks to be deleted through the API, and prune WebhookJobs older than a month.

Description of the change

Allow Webhooks to be deleted through the API, and prune WebhookJobs older than a month.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2015-07-21 10:51:36 +0000
3+++ database/schema/security.cfg 2015-07-22 07:10:43 +0000
4@@ -2301,6 +2301,7 @@
5 public.teamparticipation = SELECT, DELETE
6 public.translationmessage = SELECT, DELETE
7 public.translationtemplateitem = SELECT, DELETE
8+public.webhookjob = SELECT, DELETE
9 type=user
10
11 [garbo_daily]
12
13=== modified file 'lib/lp/code/model/gitrepository.py'
14--- lib/lp/code/model/gitrepository.py 2015-07-15 06:25:36 +0000
15+++ lib/lp/code/model/gitrepository.py 2015-07-22 07:10:43 +0000
16@@ -146,6 +146,7 @@
17 get_property_cache,
18 )
19 from lp.services.webapp.authorization import available_with_permission
20+from lp.services.webhooks.interfaces import IWebhookSource
21 from lp.services.webhooks.model import WebhookTargetMixin
22
23
24@@ -1075,6 +1076,7 @@
25 self._deleteRepositoryAccessGrants()
26 self._deleteRepositorySubscriptions()
27 self._deleteJobs()
28+ getUtility(IWebhookSource).delete(self.webhooks)
29
30 # Now destroy the repository.
31 repository_name = self.unique_name
32
33=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
34--- lib/lp/code/model/tests/test_gitrepository.py 2015-07-10 22:24:49 +0000
35+++ lib/lp/code/model/tests/test_gitrepository.py 2015-07-22 07:10:43 +0000
36@@ -16,6 +16,7 @@
37 from lazr.lifecycle.snapshot import Snapshot
38 import pytz
39 from sqlobject import SQLObjectNotFound
40+from storm.exceptions import LostObjectError
41 from storm.store import Store
42 from testtools.matchers import (
43 EndsWith,
44@@ -321,7 +322,7 @@
45 class TestGitRepositoryDeletion(TestCaseWithFactory):
46 """Test the different cases that make a repository deletable or not."""
47
48- layer = LaunchpadZopelessLayer
49+ layer = LaunchpadFunctionalLayer
50
51 def setUp(self):
52 super(TestGitRepositoryDeletion, self).setUp()
53@@ -414,10 +415,9 @@
54
55 def test_related_GitJobs_deleted(self):
56 # A repository with an associated job will delete those jobs.
57- repository = self.factory.makeGitRepository()
58- GitAPI(None, None).notify(repository.getInternalPath())
59- store = Store.of(repository)
60- repository.destroySelf()
61+ GitAPI(None, None).notify(self.repository.getInternalPath())
62+ store = Store.of(self.repository)
63+ self.repository.destroySelf()
64 # Need to commit the transaction to fire off the constraint checks.
65 transaction.commit()
66 jobs = store.find(GitJob, GitJob.job_type == GitJobType.REF_SCAN)
67@@ -426,10 +426,9 @@
68 def test_creates_job_to_reclaim_space(self):
69 # When a repository is deleted from the database, a job is created
70 # to remove the repository from disk as well.
71- repository = self.factory.makeGitRepository()
72- repository_path = repository.getInternalPath()
73- store = Store.of(repository)
74- repository.destroySelf()
75+ repository_path = self.repository.getInternalPath()
76+ store = Store.of(self.repository)
77+ self.repository.destroySelf()
78 jobs = store.find(
79 GitJob,
80 GitJob.job_type == GitJobType.RECLAIM_REPOSITORY_SPACE)
81@@ -468,6 +467,13 @@
82 )
83 self.repository.destroySelf(break_references=True)
84
85+ def test_related_webhooks_deleted(self):
86+ webhook = self.factory.makeWebhook(target=self.repository)
87+ webhook.ping()
88+ self.repository.destroySelf()
89+ transaction.commit()
90+ self.assertRaises(LostObjectError, getattr, webhook, 'target')
91+
92
93 class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
94 """Test determination and application of repository deletion
95
96=== modified file 'lib/lp/scripts/garbo.py'
97--- lib/lp/scripts/garbo.py 2015-07-21 08:42:30 +0000
98+++ lib/lp/scripts/garbo.py 2015-07-22 07:10:43 +0000
99@@ -119,6 +119,8 @@
100 )
101 from lp.services.session.model import SessionData
102 from lp.services.verification.model.logintoken import LoginToken
103+from lp.services.webhooks.interfaces import IWebhookJobSource
104+from lp.services.webhooks.model import WebhookJob
105 from lp.soyuz.model.archive import Archive
106 from lp.soyuz.model.livefsbuild import LiveFSFile
107 from lp.soyuz.model.publishing import SourcePackagePublishingHistory
108@@ -1112,6 +1114,28 @@
109 """
110
111
112+class WebhookJobPruner(TunableLoop):
113+ """Prune `WebhookJobs` that finished more than a month ago."""
114+
115+ maximum_chunk_size = 5000
116+
117+ @property
118+ def old_jobs(self):
119+ return IMasterStore(WebhookJob).using(WebhookJob, Job).find(
120+ (WebhookJob.job_id,),
121+ Job.id == WebhookJob.job_id,
122+ Job.date_finished < SQL(
123+ "CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - '30 days'::interval"))
124+
125+ def __call__(self, chunksize):
126+ getUtility(IWebhookJobSource).deleteByIDs(
127+ list(self.old_jobs[:int(chunksize)].values(WebhookJob.job_id)))
128+ transaction.commit()
129+
130+ def isDone(self):
131+ return self.old_jobs.is_empty()
132+
133+
134 class BugHeatUpdater(TunableLoop):
135 """A `TunableLoop` for bug heat calculations."""
136
137@@ -1663,12 +1687,12 @@
138 """
139 script_name = 'garbo-frequently'
140 tunable_loops = [
141+ AntiqueSessionPruner,
142 BugSummaryJournalRollup,
143+ OpenIDConsumerAssociationPruner,
144 OpenIDConsumerNoncePruner,
145- OpenIDConsumerAssociationPruner,
146- AntiqueSessionPruner,
147+ PopulateLatestPersonSourcePackageReleaseCache,
148 VoucherRedeemer,
149- PopulateLatestPersonSourcePackageReleaseCache,
150 ]
151 experimental_tunable_loops = []
152
153@@ -1685,11 +1709,11 @@
154 """
155 script_name = 'garbo-hourly'
156 tunable_loops = [
157+ BugHeatUpdater,
158+ BugWatchScheduler,
159+ DuplicateSessionPruner,
160 RevisionCachePruner,
161- BugWatchScheduler,
162 UnusedSessionPruner,
163- DuplicateSessionPruner,
164- BugHeatUpdater,
165 ]
166 experimental_tunable_loops = []
167
168@@ -1714,6 +1738,7 @@
169 BugWatchActivityPruner,
170 CodeImportEventPruner,
171 CodeImportResultPruner,
172+ DiffPruner,
173 GitJobPruner,
174 HWSubmissionEmailLinker,
175 LiveFSFilePruner,
176@@ -1721,17 +1746,17 @@
177 ObsoleteBugAttachmentPruner,
178 OldTimeLimitedTokenDeleter,
179 PersonSettingsENFPopulator,
180+ POTranslationPruner,
181+ PreviewDiffPruner,
182+ ProductVCSPopulator,
183 RevisionAuthorEmailLinker,
184 ScrubPOFileTranslator,
185 SuggestiveTemplatesCacheUpdater,
186 TeamMembershipPruner,
187- POTranslationPruner,
188 UnlinkedAccountPruner,
189 UnusedAccessPolicyPruner,
190 UnusedPOTMsgSetPruner,
191- ProductVCSPopulator,
192- PreviewDiffPruner,
193- DiffPruner,
194+ WebhookJobPruner,
195 ]
196 experimental_tunable_loops = [
197 PersonPruner,
198
199=== modified file 'lib/lp/scripts/tests/test_garbo.py'
200--- lib/lp/scripts/tests/test_garbo.py 2015-07-21 08:42:30 +0000
201+++ lib/lp/scripts/tests/test_garbo.py 2015-07-22 07:10:43 +0000
202@@ -16,7 +16,10 @@
203 import time
204
205 from pytz import UTC
206-from storm.exceptions import NoneError
207+from storm.exceptions import (
208+ LostObjectError,
209+ NoneError,
210+ )
211 from storm.expr import (
212 In,
213 Like,
214@@ -979,6 +982,22 @@
215 switch_dbuser('testadmin')
216 self.assertEqual(1, store.find(GitJob).count())
217
218+ def test_WebhookJobPruner(self):
219+ # Garbo should remove jobs completed over 30 days ago.
220+ switch_dbuser('testadmin')
221+
222+ webhook = self.factory.makeWebhook()
223+ job1 = webhook.ping()
224+ removeSecurityProxy(job1).job.date_finished = THIRTY_DAYS_AGO
225+ job2 = webhook.ping()
226+ removeSecurityProxy(job2).job.date_finished = SEVEN_DAYS_AGO
227+
228+ self.runDaily()
229+
230+ switch_dbuser('testadmin')
231+ self.assertEqual(webhook, job2.webhook)
232+ self.assertRaises(LostObjectError, getattr, job1, 'webhook')
233+
234 def test_ObsoleteBugAttachmentPruner(self):
235 # Bug attachments without a LibraryFileContent record are removed.
236
237
238=== modified file 'lib/lp/services/webhooks/configure.zcml'
239--- lib/lp/services/webhooks/configure.zcml 2015-07-13 09:13:57 +0000
240+++ lib/lp/services/webhooks/configure.zcml 2015-07-22 07:10:43 +0000
241@@ -23,6 +23,11 @@
242 </securedutility>
243
244 <securedutility
245+ component="lp.services.webhooks.model.WebhookJob"
246+ provides="lp.services.webhooks.interfaces.IWebhookJobSource">
247+ <allow interface="lp.services.webhooks.interfaces.IWebhookJobSource"/>
248+ </securedutility>
249+ <securedutility
250 component="lp.services.webhooks.model.WebhookDeliveryJob"
251 provides="lp.services.webhooks.interfaces.IWebhookDeliveryJobSource">
252 <allow interface="lp.services.webhooks.interfaces.IWebhookDeliveryJobSource"/>
253
254=== modified file 'lib/lp/services/webhooks/interfaces.py'
255--- lib/lp/services/webhooks/interfaces.py 2015-07-07 07:37:54 +0000
256+++ lib/lp/services/webhooks/interfaces.py 2015-07-22 07:10:43 +0000
257@@ -11,6 +11,7 @@
258 'IWebhookDeliveryJob',
259 'IWebhookDeliveryJobSource',
260 'IWebhookJob',
261+ 'IWebhookJobSource',
262 'IWebhookSource',
263 'IWebhookTarget',
264 'WebhookFeatureDisabled',
265@@ -23,6 +24,7 @@
266 call_with,
267 error_status,
268 export_as_webservice_entry,
269+ export_destructor_operation,
270 export_factory_operation,
271 exported,
272 operation_for_version,
273@@ -112,6 +114,11 @@
274 def ping():
275 """Send a test event."""
276
277+ @export_destructor_operation()
278+ @operation_for_version('devel')
279+ def destroySelf():
280+ """Delete this webhook."""
281+
282
283 class IWebhookSource(Interface):
284
285@@ -159,6 +166,15 @@
286 json_data = Attribute(_("A dict of data about the job."))
287
288
289+class IWebhookJobSource(IJobSource):
290+
291+ def deleteByIDs(webhookjob_ids):
292+ """Delete `IWebhookJob`s by their primary key (`Job.id`)."""
293+
294+ def deleteByWebhooks(webhooks):
295+ """Delete all `IWebhookJob`s for the given `IWebhook`."""
296+
297+
298 class IWebhookDeliveryJob(IRunnableJob):
299 """A Job that delivers an event to a webhook consumer."""
300
301
302=== modified file 'lib/lp/services/webhooks/model.py'
303--- lib/lp/services/webhooks/model.py 2015-07-13 09:13:57 +0000
304+++ lib/lp/services/webhooks/model.py 2015-07-22 07:10:43 +0000
305@@ -58,6 +58,7 @@
306 IWebhookDeliveryJob,
307 IWebhookDeliveryJobSource,
308 IWebhookJob,
309+ IWebhookJobSource,
310 IWebhookSource,
311 WebhookFeatureDisabled,
312 )
313@@ -122,6 +123,9 @@
314 def ping(self):
315 return WebhookDeliveryJob.create(self, {'ping': True})
316
317+ def destroySelf(self):
318+ getUtility(IWebhookSource).delete([self])
319+
320 @property
321 def event_types(self):
322 return (self.json_data or {}).get('event_types', [])
323@@ -157,6 +161,8 @@
324 return hook
325
326 def delete(self, hooks):
327+ hooks = list(hooks)
328+ getUtility(IWebhookJobSource).deleteByWebhooks(hooks)
329 IStore(Webhook).find(
330 Webhook, Webhook.id.is_in(set(hook.id for hook in hooks))).remove()
331
332@@ -200,6 +206,7 @@
333 """)
334
335
336+@provider(IWebhookJobSource)
337 @implementer(IWebhookJob)
338 class WebhookJob(StormBase):
339 """See `IWebhookJob`."""
340@@ -236,6 +243,24 @@
341 def makeDerived(self):
342 return WebhookJobDerived.makeSubclass(self)
343
344+ @staticmethod
345+ def deleteByIDs(webhookjob_ids):
346+ """See `IWebhookJobSource`."""
347+ # Assumes that Webhook's PK is its FK to Job.id.
348+ webookjob_ids = list(webhookjob_ids)
349+ IStore(WebhookJob).find(
350+ WebhookJob, WebhookJob.job_id.is_in(webhookjob_ids)).remove()
351+ IStore(Job).find(Job, Job.id.is_in(webhookjob_ids)).remove()
352+
353+ @classmethod
354+ def deleteByWebhooks(cls, webhooks):
355+ """See `IWebhookJobSource`."""
356+ result = IStore(WebhookJob).find(
357+ WebhookJob,
358+ WebhookJob.webhook_id.is_in(hook.id for hook in webhooks))
359+ job_ids = list(result.values(WebhookJob.job_id))
360+ cls.deleteByIDs(job_ids)
361+
362
363 @delegate_to(IWebhookJob)
364 class WebhookJobDerived(BaseRunnableJob):
365
366=== modified file 'lib/lp/services/webhooks/tests/test_webhook.py'
367--- lib/lp/services/webhooks/tests/test_webhook.py 2015-07-12 23:51:16 +0000
368+++ lib/lp/services/webhooks/tests/test_webhook.py 2015-07-22 07:10:43 +0000
369@@ -3,25 +3,32 @@
370
371 from lazr.lifecycle.event import ObjectModifiedEvent
372 from storm.store import Store
373-from testtools.matchers import GreaterThan
374+from testtools.matchers import (
375+ Equals,
376+ GreaterThan,
377+ )
378 import transaction
379 from zope.component import getUtility
380 from zope.event import notify
381 from zope.security.checker import getChecker
382
383+from lp.services.database.interfaces import IStore
384 from lp.services.webapp.authorization import check_permission
385 from lp.services.webhooks.interfaces import (
386 IWebhook,
387 IWebhookSource,
388 )
389+from lp.services.webhooks.model import WebhookJob
390 from lp.testing import (
391 admin_logged_in,
392 anonymous_logged_in,
393 login_person,
394 person_logged_in,
395+ StormStatementRecorder,
396 TestCaseWithFactory,
397 )
398 from lp.testing.layers import DatabaseFunctionalLayer
399+from lp.testing.matchers import HasQueryCount
400
401
402 class TestWebhook(TestCaseWithFactory):
403@@ -67,8 +74,9 @@
404 expected_get_permissions = {
405 'launchpad.View': set((
406 'active', 'date_created', 'date_last_modified', 'deliveries',
407- 'delivery_url', 'event_types', 'getDelivery', 'id', 'ping',
408- 'registrant', 'registrant_id', 'secret', 'target')),
409+ 'delivery_url', 'destroySelf', 'event_types', 'getDelivery',
410+ 'id', 'ping', 'registrant', 'registrant_id', 'secret',
411+ 'target')),
412 }
413 webhook = self.factory.makeWebhook()
414 checker = getChecker(webhook)
415@@ -141,15 +149,25 @@
416 def test_delete(self):
417 target = self.factory.makeGitRepository()
418 login_person(target.owner)
419- hooks = [
420- self.factory.makeWebhook(target, u'http://path/to/%d' % i)
421- for i in range(3)]
422+ hooks = []
423+ for i in range(3):
424+ hook = self.factory.makeWebhook(target, u'http://path/to/%d' % i)
425+ hook.ping()
426+ hooks.append(hook)
427+ self.assertEqual(3, IStore(WebhookJob).find(WebhookJob).count())
428 self.assertContentEqual(
429 [u'http://path/to/0', u'http://path/to/1', u'http://path/to/2'],
430 [hook.delivery_url for hook in
431 getUtility(IWebhookSource).findByTarget(target)])
432- getUtility(IWebhookSource).delete(hooks[:2])
433+
434+ transaction.commit()
435+ with StormStatementRecorder() as recorder:
436+ getUtility(IWebhookSource).delete(hooks[:2])
437+ self.assertThat(recorder, HasQueryCount(Equals(4)))
438+
439 self.assertContentEqual(
440 [u'http://path/to/2'],
441 [hook.delivery_url for hook in
442 getUtility(IWebhookSource).findByTarget(target)])
443+ self.assertEqual(1, IStore(WebhookJob).find(WebhookJob).count())
444+ self.assertEqual(1, hooks[2].deliveries.count())
445
446=== modified file 'lib/lp/services/webhooks/tests/test_webhookjob.py'
447--- lib/lp/services/webhooks/tests/test_webhookjob.py 2015-07-13 11:20:08 +0000
448+++ lib/lp/services/webhooks/tests/test_webhookjob.py 2015-07-22 07:10:43 +0000
449@@ -10,6 +10,7 @@
450 urlmatch,
451 )
452 import requests
453+from storm.store import Store
454 from testtools import TestCase
455 from testtools.matchers import (
456 Contains,
457@@ -22,6 +23,7 @@
458 Not,
459 )
460 import transaction
461+from zope.component import getUtility
462
463 from lp.services.features.testing import FeatureFixture
464 from lp.services.job.interfaces.job import JobStatus
465@@ -33,6 +35,7 @@
466 IWebhookClient,
467 IWebhookDeliveryJob,
468 IWebhookJob,
469+ IWebhookJobSource,
470 )
471 from lp.services.webhooks.model import (
472 WebhookDeliveryJob,
473@@ -40,7 +43,10 @@
474 WebhookJobDerived,
475 WebhookJobType,
476 )
477-from lp.testing import TestCaseWithFactory
478+from lp.testing import (
479+ login_person,
480+ TestCaseWithFactory,
481+ )
482 from lp.testing.dbuser import dbuser
483 from lp.testing.fixture import (
484 CaptureOops,
485@@ -65,6 +71,41 @@
486 WebhookJob(hook, WebhookJobType.DELIVERY, {}), IWebhookJob)
487
488
489+class TestWebhookJobSource(TestCaseWithFactory):
490+
491+ layer = DatabaseFunctionalLayer
492+
493+ def test_deleteByIDs(self):
494+ target = self.factory.makeGitRepository()
495+ login_person(target.owner)
496+ hook = self.factory.makeWebhook(target=target)
497+ job1 = hook.ping()
498+ job2 = hook.ping()
499+ job3 = hook.ping()
500+ self.assertContentEqual([job3, job2, job1], hook.deliveries)
501+ getUtility(IWebhookJobSource).deleteByIDs([job1.job_id, job3.job_id])
502+ self.assertContentEqual([job2], hook.deliveries)
503+
504+ def test_deleteByWebhooks(self):
505+ target = self.factory.makeGitRepository()
506+ login_person(target.owner)
507+ hook1 = self.factory.makeWebhook(target=target)
508+ job1 = hook1.ping()
509+ job2 = hook1.ping()
510+ hook2 = self.factory.makeWebhook(target=target)
511+ job3 = hook2.ping()
512+ hook3 = self.factory.makeWebhook(target=target)
513+ job4 = hook3.ping()
514+ store = Store.of(hook1)
515+ self.assertEqual(4, store.find(WebhookJob).count())
516+ self.assertContentEqual([job2, job1], hook1.deliveries)
517+ self.assertContentEqual([job3], hook2.deliveries)
518+ self.assertContentEqual([job4], hook3.deliveries)
519+ getUtility(IWebhookJobSource).deleteByWebhooks([hook1, hook2])
520+ self.assertEqual(1, store.find(WebhookJob).count())
521+ self.assertContentEqual([job4], hook3.deliveries)
522+
523+
524 class TestWebhookJobDerived(TestCaseWithFactory):
525 """Tests for `WebhookJobDerived`."""
526
527
528=== modified file 'lib/lp/services/webhooks/tests/test_webservice.py'
529--- lib/lp/services/webhooks/tests/test_webservice.py 2015-07-07 07:37:54 +0000
530+++ lib/lp/services/webhooks/tests/test_webservice.py 2015-07-22 07:10:43 +0000
531@@ -126,6 +126,16 @@
532 get_deliveries, create_delivery, 2)
533 self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
534
535+ def test_delete(self):
536+ with person_logged_in(self.owner):
537+ self.webhook.ping()
538+ delete_response = self.webservice.delete(
539+ self.webhook_url, api_version='devel')
540+ self.assertEqual(200, delete_response.status)
541+ get_response = self.webservice.get(
542+ self.webhook_url, api_version='devel')
543+ self.assertEqual(404, get_response.status)
544+
545
546 class TestWebhookDelivery(TestCaseWithFactory):
547 layer = DatabaseFunctionalLayer