← Back to team overview

launchpad-reviewers team mailing list archive

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

 

William Grant has proposed merging lp:~wgrant/launchpad/webhook-deletion into lp:launchpad.

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

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wgrant/launchpad/webhook-deletion/+merge/265360

Allow Webhooks to be deleted through the API, and prune WebhookJobs older than a month.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/webhook-deletion into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2015-07-15 06:20:47 +0000
+++ database/schema/security.cfg	2015-07-21 07:05:40 +0000
@@ -2297,6 +2297,7 @@
 public.teamparticipation                = SELECT, DELETE
 public.translationmessage               = SELECT, DELETE
 public.translationtemplateitem          = SELECT, DELETE
+public.webhookjob                       = SELECT, DELETE
 type=user
 
 [garbo_daily]

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2015-07-15 06:25:36 +0000
+++ lib/lp/code/model/gitrepository.py	2015-07-21 07:05:40 +0000
@@ -146,6 +146,7 @@
     get_property_cache,
     )
 from lp.services.webapp.authorization import available_with_permission
+from lp.services.webhooks.interfaces import IWebhookSource
 from lp.services.webhooks.model import WebhookTargetMixin
 
 
@@ -1075,6 +1076,7 @@
         self._deleteRepositoryAccessGrants()
         self._deleteRepositorySubscriptions()
         self._deleteJobs()
+        getUtility(IWebhookSource).delete(self.webhooks)
 
         # Now destroy the repository.
         repository_name = self.unique_name

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2015-07-10 22:24:49 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2015-07-21 07:05:40 +0000
@@ -16,6 +16,7 @@
 from lazr.lifecycle.snapshot import Snapshot
 import pytz
 from sqlobject import SQLObjectNotFound
+from storm.exceptions import LostObjectError
 from storm.store import Store
 from testtools.matchers import (
     EndsWith,
@@ -321,7 +322,7 @@
 class TestGitRepositoryDeletion(TestCaseWithFactory):
     """Test the different cases that make a repository deletable or not."""
 
-    layer = LaunchpadZopelessLayer
+    layer = LaunchpadFunctionalLayer
 
     def setUp(self):
         super(TestGitRepositoryDeletion, self).setUp()
@@ -414,10 +415,9 @@
 
     def test_related_GitJobs_deleted(self):
         # A repository with an associated job will delete those jobs.
-        repository = self.factory.makeGitRepository()
-        GitAPI(None, None).notify(repository.getInternalPath())
-        store = Store.of(repository)
-        repository.destroySelf()
+        GitAPI(None, None).notify(self.repository.getInternalPath())
+        store = Store.of(self.repository)
+        self.repository.destroySelf()
         # Need to commit the transaction to fire off the constraint checks.
         transaction.commit()
         jobs = store.find(GitJob, GitJob.job_type == GitJobType.REF_SCAN)
@@ -426,10 +426,9 @@
     def test_creates_job_to_reclaim_space(self):
         # When a repository is deleted from the database, a job is created
         # to remove the repository from disk as well.
-        repository = self.factory.makeGitRepository()
-        repository_path = repository.getInternalPath()
-        store = Store.of(repository)
-        repository.destroySelf()
+        repository_path = self.repository.getInternalPath()
+        store = Store.of(self.repository)
+        self.repository.destroySelf()
         jobs = store.find(
             GitJob,
             GitJob.job_type == GitJobType.RECLAIM_REPOSITORY_SPACE)
@@ -468,6 +467,13 @@
         )
         self.repository.destroySelf(break_references=True)
 
+    def test_related_webhooks_deleted(self):
+        webhook = self.factory.makeWebhook(target=self.repository)
+        webhook.ping()
+        self.repository.destroySelf()
+        transaction.commit()
+        self.assertRaises(LostObjectError, getattr, webhook, 'target')
+
 
 class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
     """Test determination and application of repository deletion

=== modified file 'lib/lp/scripts/garbo.py'
--- lib/lp/scripts/garbo.py	2015-07-13 00:52:36 +0000
+++ lib/lp/scripts/garbo.py	2015-07-21 07:05:40 +0000
@@ -116,6 +116,8 @@
     )
 from lp.services.session.model import SessionData
 from lp.services.verification.model.logintoken import LoginToken
+from lp.services.webhooks.interfaces import IWebhookJobSource
+from lp.services.webhooks.model import WebhookJob
 from lp.soyuz.model.archive import Archive
 from lp.soyuz.model.livefsbuild import LiveFSFile
 from lp.soyuz.model.publishing import SourcePackagePublishingHistory
@@ -1109,6 +1111,28 @@
         """
 
 
+class WebhookJobPruner(TunableLoop):
+    """Prune `WebhookJobs` that finished more than a month ago."""
+
+    maximum_chunk_size = 5000
+
+    @property
+    def old_jobs(self):
+        return IMasterStore(WebhookJob).using(WebhookJob, Job).find(
+            (WebhookJob.job_id,),
+            Job.id == WebhookJob.job_id,
+            Job.date_finished < SQL(
+                "CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - '30 days'::interval"))
+
+    def __call__(self, chunksize):
+        getUtility(IWebhookJobSource).deleteByIDs(
+            list(self.old_jobs[:int(chunksize)].values(WebhookJob.job_id)))
+        transaction.commit()
+
+    def isDone(self):
+        return self.old_jobs.is_empty()
+
+
 class BugHeatUpdater(TunableLoop):
     """A `TunableLoop` for bug heat calculations."""
 
@@ -1689,6 +1713,7 @@
         CodeImportEventPruner,
         CodeImportResultPruner,
         GitJobPruner,
+        WebhookJobPruner,
         HWSubmissionEmailLinker,
         LiveFSFilePruner,
         LoginTokenPruner,

=== modified file 'lib/lp/scripts/tests/test_garbo.py'
--- lib/lp/scripts/tests/test_garbo.py	2015-07-13 00:55:56 +0000
+++ lib/lp/scripts/tests/test_garbo.py	2015-07-21 07:05:40 +0000
@@ -16,6 +16,7 @@
 import time
 
 from pytz import UTC
+from storm.exceptions import LostObjectError
 from storm.expr import (
     In,
     Like,
@@ -977,6 +978,22 @@
         switch_dbuser('testadmin')
         self.assertEqual(1, store.find(GitJob).count())
 
+    def test_WebhookJobPruner(self):
+        # Garbo should remove jobs completed over 30 days ago.
+        switch_dbuser('testadmin')
+
+        webhook = self.factory.makeWebhook()
+        job1 = webhook.ping()
+        removeSecurityProxy(job1).job.date_finished = THIRTY_DAYS_AGO
+        job2 = webhook.ping()
+        removeSecurityProxy(job2).job.date_finished = SEVEN_DAYS_AGO
+
+        self.runDaily()
+
+        switch_dbuser('testadmin')
+        self.assertEqual(webhook, job2.webhook)
+        self.assertRaises(LostObjectError, getattr, job1, 'webhook')
+
     def test_ObsoleteBugAttachmentPruner(self):
         # Bug attachments without a LibraryFileContent record are removed.
 

=== modified file 'lib/lp/services/webhooks/configure.zcml'
--- lib/lp/services/webhooks/configure.zcml	2015-07-13 09:13:57 +0000
+++ lib/lp/services/webhooks/configure.zcml	2015-07-21 07:05:40 +0000
@@ -23,6 +23,11 @@
     </securedutility>
 
     <securedutility
+        component="lp.services.webhooks.model.WebhookJob"
+        provides="lp.services.webhooks.interfaces.IWebhookJobSource">
+        <allow interface="lp.services.webhooks.interfaces.IWebhookJobSource"/>
+    </securedutility>
+    <securedutility
         component="lp.services.webhooks.model.WebhookDeliveryJob"
         provides="lp.services.webhooks.interfaces.IWebhookDeliveryJobSource">
         <allow interface="lp.services.webhooks.interfaces.IWebhookDeliveryJobSource"/>

=== modified file 'lib/lp/services/webhooks/interfaces.py'
--- lib/lp/services/webhooks/interfaces.py	2015-07-07 07:37:54 +0000
+++ lib/lp/services/webhooks/interfaces.py	2015-07-21 07:05:40 +0000
@@ -11,6 +11,7 @@
     'IWebhookDeliveryJob',
     'IWebhookDeliveryJobSource',
     'IWebhookJob',
+    'IWebhookJobSource',
     'IWebhookSource',
     'IWebhookTarget',
     'WebhookFeatureDisabled',
@@ -23,6 +24,7 @@
     call_with,
     error_status,
     export_as_webservice_entry,
+    export_destructor_operation,
     export_factory_operation,
     exported,
     operation_for_version,
@@ -112,6 +114,11 @@
     def ping():
         """Send a test event."""
 
+    @export_destructor_operation()
+    @operation_for_version('devel')
+    def destroySelf():
+        """Delete this webhook."""
+
 
 class IWebhookSource(Interface):
 
@@ -159,6 +166,15 @@
     json_data = Attribute(_("A dict of data about the job."))
 
 
+class IWebhookJobSource(IJobSource):
+
+    def deleteByIDs(webhookjob_ids):
+        """Delete `IWebhookJob`s by their primary key (`Job.id`)."""
+
+    def deleteByWebhooks(webhooks):
+        """Delete all `IWebhookJob`s for the given `IWebhook`."""
+
+
 class IWebhookDeliveryJob(IRunnableJob):
     """A Job that delivers an event to a webhook consumer."""
 

=== modified file 'lib/lp/services/webhooks/model.py'
--- lib/lp/services/webhooks/model.py	2015-07-13 09:13:57 +0000
+++ lib/lp/services/webhooks/model.py	2015-07-21 07:05:40 +0000
@@ -58,6 +58,7 @@
     IWebhookDeliveryJob,
     IWebhookDeliveryJobSource,
     IWebhookJob,
+    IWebhookJobSource,
     IWebhookSource,
     WebhookFeatureDisabled,
     )
@@ -122,6 +123,9 @@
     def ping(self):
         return WebhookDeliveryJob.create(self, {'ping': True})
 
+    def destroySelf(self):
+        getUtility(IWebhookSource).delete([self])
+
     @property
     def event_types(self):
         return (self.json_data or {}).get('event_types', [])
@@ -157,6 +161,8 @@
         return hook
 
     def delete(self, hooks):
+        hooks = list(hooks)
+        getUtility(IWebhookJobSource).deleteByWebhooks(hooks)
         IStore(Webhook).find(
             Webhook, Webhook.id.is_in(set(hook.id for hook in hooks))).remove()
 
@@ -200,6 +206,7 @@
         """)
 
 
+@provider(IWebhookJobSource)
 @implementer(IWebhookJob)
 class WebhookJob(StormBase):
     """See `IWebhookJob`."""
@@ -236,6 +243,24 @@
     def makeDerived(self):
         return WebhookJobDerived.makeSubclass(self)
 
+    @staticmethod
+    def deleteByIDs(webhookjob_ids):
+        """See `IWebhookJobSource`."""
+        # Assumes that Webhook's PK is its FK to Job.id.
+        webookjob_ids = list(webhookjob_ids)
+        IStore(WebhookJob).find(
+            WebhookJob, WebhookJob.job_id.is_in(webhookjob_ids)).remove()
+        IStore(Job).find(Job, Job.id.is_in(webhookjob_ids)).remove()
+
+    @classmethod
+    def deleteByWebhooks(cls, webhooks):
+        """See `IWebhookJobSource`."""
+        result = IStore(WebhookJob).find(
+            WebhookJob,
+            WebhookJob.webhook_id.is_in(hook.id for hook in webhooks))
+        job_ids = list(result.values(WebhookJob.job_id))
+        cls.deleteByIDs(job_ids)
+
 
 @delegate_to(IWebhookJob)
 class WebhookJobDerived(BaseRunnableJob):

=== modified file 'lib/lp/services/webhooks/tests/test_webhook.py'
--- lib/lp/services/webhooks/tests/test_webhook.py	2015-07-12 23:51:16 +0000
+++ lib/lp/services/webhooks/tests/test_webhook.py	2015-07-21 07:05:40 +0000
@@ -3,25 +3,32 @@
 
 from lazr.lifecycle.event import ObjectModifiedEvent
 from storm.store import Store
-from testtools.matchers import GreaterThan
+from testtools.matchers import (
+    Equals,
+    GreaterThan,
+    )
 import transaction
 from zope.component import getUtility
 from zope.event import notify
 from zope.security.checker import getChecker
 
+from lp.services.database.interfaces import IStore
 from lp.services.webapp.authorization import check_permission
 from lp.services.webhooks.interfaces import (
     IWebhook,
     IWebhookSource,
     )
+from lp.services.webhooks.model import WebhookJob
 from lp.testing import (
     admin_logged_in,
     anonymous_logged_in,
     login_person,
     person_logged_in,
+    StormStatementRecorder,
     TestCaseWithFactory,
     )
 from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.matchers import HasQueryCount
 
 
 class TestWebhook(TestCaseWithFactory):
@@ -67,8 +74,9 @@
         expected_get_permissions = {
             'launchpad.View': set((
                 'active', 'date_created', 'date_last_modified', 'deliveries',
-                'delivery_url', 'event_types', 'getDelivery', 'id', 'ping',
-                'registrant', 'registrant_id', 'secret', 'target')),
+                'delivery_url', 'destroySelf', 'event_types', 'getDelivery',
+                'id', 'ping', 'registrant', 'registrant_id', 'secret',
+                'target')),
             }
         webhook = self.factory.makeWebhook()
         checker = getChecker(webhook)
@@ -141,15 +149,25 @@
     def test_delete(self):
         target = self.factory.makeGitRepository()
         login_person(target.owner)
-        hooks = [
-            self.factory.makeWebhook(target, u'http://path/to/%d' % i)
-            for i in range(3)]
+        hooks = []
+        for i in range(3):
+            hook = self.factory.makeWebhook(target, u'http://path/to/%d' % i)
+            hook.ping()
+            hooks.append(hook)
+        self.assertEqual(3, IStore(WebhookJob).find(WebhookJob).count())
         self.assertContentEqual(
             [u'http://path/to/0', u'http://path/to/1', u'http://path/to/2'],
             [hook.delivery_url for hook in
              getUtility(IWebhookSource).findByTarget(target)])
-        getUtility(IWebhookSource).delete(hooks[:2])
+
+        transaction.commit()
+        with StormStatementRecorder() as recorder:
+            getUtility(IWebhookSource).delete(hooks[:2])
+        self.assertThat(recorder, HasQueryCount(Equals(4)))
+
         self.assertContentEqual(
             [u'http://path/to/2'],
             [hook.delivery_url for hook in
              getUtility(IWebhookSource).findByTarget(target)])
+        self.assertEqual(1, IStore(WebhookJob).find(WebhookJob).count())
+        self.assertEqual(1, hooks[2].deliveries.count())

=== modified file 'lib/lp/services/webhooks/tests/test_webhookjob.py'
--- lib/lp/services/webhooks/tests/test_webhookjob.py	2015-07-13 11:20:08 +0000
+++ lib/lp/services/webhooks/tests/test_webhookjob.py	2015-07-21 07:05:40 +0000
@@ -10,6 +10,7 @@
     urlmatch,
     )
 import requests
+from storm.store import Store
 from testtools import TestCase
 from testtools.matchers import (
     Contains,
@@ -22,6 +23,7 @@
     Not,
     )
 import transaction
+from zope.component import getUtility
 
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
@@ -33,6 +35,7 @@
     IWebhookClient,
     IWebhookDeliveryJob,
     IWebhookJob,
+    IWebhookJobSource,
     )
 from lp.services.webhooks.model import (
     WebhookDeliveryJob,
@@ -40,7 +43,10 @@
     WebhookJobDerived,
     WebhookJobType,
     )
-from lp.testing import TestCaseWithFactory
+from lp.testing import (
+    login_person,
+    TestCaseWithFactory,
+    )
 from lp.testing.dbuser import dbuser
 from lp.testing.fixture import (
     CaptureOops,
@@ -65,6 +71,41 @@
             WebhookJob(hook, WebhookJobType.DELIVERY, {}), IWebhookJob)
 
 
+class TestWebhookJobSource(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_deleteByIDs(self):
+        target = self.factory.makeGitRepository()
+        login_person(target.owner)
+        hook = self.factory.makeWebhook(target=target)
+        job1 = hook.ping()
+        job2 = hook.ping()
+        job3 = hook.ping()
+        self.assertContentEqual([job3, job2, job1], hook.deliveries)
+        getUtility(IWebhookJobSource).deleteByIDs([job1.job_id, job3.job_id])
+        self.assertContentEqual([job2], hook.deliveries)
+
+    def test_deleteByWebhooks(self):
+        target = self.factory.makeGitRepository()
+        login_person(target.owner)
+        hook1 = self.factory.makeWebhook(target=target)
+        job1 = hook1.ping()
+        job2 = hook1.ping()
+        hook2 = self.factory.makeWebhook(target=target)
+        job3 = hook2.ping()
+        hook3 = self.factory.makeWebhook(target=target)
+        job4 = hook3.ping()
+        store = Store.of(hook1)
+        self.assertEqual(4, store.find(WebhookJob).count())
+        self.assertContentEqual([job2, job1], hook1.deliveries)
+        self.assertContentEqual([job3], hook2.deliveries)
+        self.assertContentEqual([job4], hook3.deliveries)
+        getUtility(IWebhookJobSource).deleteByWebhooks([hook1, hook2])
+        self.assertEqual(1, store.find(WebhookJob).count())
+        self.assertContentEqual([job4], hook3.deliveries)
+
+
 class TestWebhookJobDerived(TestCaseWithFactory):
     """Tests for `WebhookJobDerived`."""
 

=== modified file 'lib/lp/services/webhooks/tests/test_webservice.py'
--- lib/lp/services/webhooks/tests/test_webservice.py	2015-07-07 07:37:54 +0000
+++ lib/lp/services/webhooks/tests/test_webservice.py	2015-07-21 07:05:40 +0000
@@ -126,6 +126,16 @@
             get_deliveries, create_delivery, 2)
         self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
 
+    def test_delete(self):
+        with person_logged_in(self.owner):
+            self.webhook.ping()
+        delete_response = self.webservice.delete(
+            self.webhook_url, api_version='devel')
+        self.assertEqual(200, delete_response.status)
+        get_response = self.webservice.get(
+            self.webhook_url, api_version='devel')
+        self.assertEqual(404, get_response.status)
+
 
 class TestWebhookDelivery(TestCaseWithFactory):
     layer = DatabaseFunctionalLayer


Follow ups