← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wgrant/launchpad/webhook-api-and-active into lp:launchpad

 

William Grant has proposed merging lp:~wgrant/launchpad/webhook-api-and-active into lp:launchpad.

Commit message:
Webhook enhancements: secret is settable, error_message is exposed, and the active flag is respected.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wgrant/launchpad/webhook-api-and-active/+merge/266835

Webhook enhancements: secret is settable, error_message is exposed, and the active flag is respected.

Also relax a couple of celery retry tests slightly, as buildbot can be astoundingly slow.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/webhook-api-and-active into lp:launchpad.
=== modified file 'lib/lp/services/job/tests/test_celery.py'
--- lib/lp/services/job/tests/test_celery.py	2015-07-24 09:17:39 +0000
+++ lib/lp/services/job/tests/test_celery.py	2015-08-04 06:04:54 +0000
@@ -155,8 +155,6 @@
         self.assertEqual(JobStatus.WAITING, job_forever.status)
         self.assertThat(
             job_future.date_started, GreaterThan(job_past.date_started))
-        self.assertThat(
-            job_future.date_started, GreaterThan(job_whenever.date_started))
 
     def test_jobs_with_retry_exceptions_are_queued_again(self):
         # A job that raises a retry error is automatically queued
@@ -200,7 +198,7 @@
                     LessThan(dates_started[0] + timedelta(seconds=8))),
                 MatchesAll(
                     GreaterThan(dates_started[1] + timedelta(seconds=8)),
-                    LessThan(dates_started[1] + timedelta(seconds=12))),
+                    LessThan(dates_started[1] + timedelta(seconds=13))),
                 ]))
         self.assertEqual(3, job.attempt_count)
         self.assertEqual(JobStatus.COMPLETED, job.status)

=== modified file 'lib/lp/services/webhooks/interfaces.py'
--- lib/lp/services/webhooks/interfaces.py	2015-08-03 08:27:04 +0000
+++ lib/lp/services/webhooks/interfaces.py	2015-08-04 06:04:54 +0000
@@ -31,12 +31,14 @@
     export_write_operation,
     exported,
     operation_for_version,
+    operation_parameters,
     REQUEST_USER,
     )
 from lazr.restful.fields import (
     CollectionField,
     Reference,
     )
+from lazr.restful.interface import copy_field
 from zope.interface import (
     Attribute,
     Interface,
@@ -111,8 +113,13 @@
         title=_("URL"), required=True, readonly=False))
     active = exported(Bool(
         title=_("Active"), required=True, readonly=False))
+
+    # Do not export this.
     secret = TextLine(
-        title=_("Unique name"), required=False, readonly=True)
+        title=_("Secret"), required=False,
+        description=_(
+            "An optional string used to sign delivery bodies with HMAC-SHA1 "
+            "in the X-Hub-Signature header."))
 
     deliveries = exported(doNotSnapshot(CollectionField(
         title=_("Recent deliveries for this webhook."),
@@ -132,6 +139,12 @@
     def destroySelf():
         """Delete this webhook."""
 
+    @export_write_operation()
+    @operation_parameters(secret=copy_field(secret))
+    @operation_for_version('devel')
+    def setSecret(secret):
+        """Set the secret used to sign deliveries."""
+
 
 class IWebhookSource(Interface):
 
@@ -159,9 +172,10 @@
 
     @call_with(registrant=REQUEST_USER)
     @export_factory_operation(
-        IWebhook, ['delivery_url', 'active', 'event_types'])
+        IWebhook, ['delivery_url', 'active', 'event_types', 'secret'])
     @operation_for_version("devel")
-    def newWebhook(registrant, delivery_url, event_types, active=True):
+    def newWebhook(registrant, delivery_url, event_types, active=True,
+                   secret=None):
         """Create a new webhook."""
 
 
@@ -210,6 +224,13 @@
             "no attempts have been made yet."),
         required=False, readonly=True))
 
+    error_message = exported(TextLine(
+        title=_("Error message"),
+        description=_(
+            "Details of the error encountered by the most recent delivery "
+            "attempt."),
+        required=False, readonly=True))
+
     date_created = exported(Datetime(
         title=_("Date created"), required=True, readonly=True))
 
@@ -228,8 +249,13 @@
         key_type=TextLine(), required=True, readonly=True))
 
     @export_write_operation()
+    @operation_parameters(reset=Bool(
+        title=_("Reset automatic retries"),
+        description=_(
+            "Restart the 24 hour automatic retry window as well as trying "
+            "again now.")))
     @operation_for_version("devel")
-    def retry():
+    def retry(reset=False):
         """Attempt to deliver the event again.
 
         Launchpad will automatically retry regularly for 24 hours, but

=== modified file 'lib/lp/services/webhooks/model.py'
--- lib/lp/services/webhooks/model.py	2015-08-03 10:49:46 +0000
+++ lib/lp/services/webhooks/model.py	2015-08-04 06:04:54 +0000
@@ -31,6 +31,7 @@
     )
 from storm.references import Reference
 from storm.store import Store
+import transaction
 from zope.component import getUtility
 from zope.interface import (
     implementer,
@@ -144,6 +145,10 @@
         updated_data['event_types'] = event_types
         self.json_data = updated_data
 
+    def setSecret(self, secret):
+        """See `IWebhook`."""
+        self.secret = secret
+
 
 @implementer(IWebhookSource)
 class WebhookSource:
@@ -195,11 +200,12 @@
             getUtility(IWebhookSource).findByTarget(self),
             pre_iter_hook=preload_registrants)
 
-    def newWebhook(self, registrant, delivery_url, event_types, active=True):
+    def newWebhook(self, registrant, delivery_url, event_types, active=True,
+                   secret=None):
         if not getFeatureFlag('webhooks.new.enabled'):
             raise WebhookFeatureDisabled()
         return getUtility(IWebhookSource).new(
-            self, registrant, delivery_url, event_types, active, None)
+            self, registrant, delivery_url, event_types, active, secret)
 
 
 class WebhookJobType(DBEnumeratedType):
@@ -326,12 +332,14 @@
     def successful(self):
         if 'result' not in self.json_data:
             return None
-        return self.failure_detail is None
+        return self.error_message is None
 
     @property
-    def failure_detail(self):
+    def error_message(self):
         if 'result' not in self.json_data:
             return None
+        if self.json_data['result'].get('webhook_deactivated'):
+            return 'Webhook deactivated'
         connection_error = self.json_data['result'].get('connection_error')
         if connection_error is not None:
             return 'Connection error: %s' % connection_error
@@ -360,10 +368,14 @@
     def _time_since_first_attempt(self):
         return datetime.now(utc) - (self.date_first_sent or self.date_created)
 
-    def retry(self):
+    def retry(self, reset=False):
         """See `IWebhookDeliveryJob`."""
         # Unset any retry delay and reset attempt_count to prevent
         # queue() from delaying it again.
+        if reset:
+            updated_data = self.json_data
+            del updated_data['date_first_sent']
+            self.json_data = updated_data
         self.scheduled_start = None
         self.attempt_count = 0
         self.queue()
@@ -382,6 +394,13 @@
             return timedelta(hours=1)
 
     def run(self):
+        if not self.webhook.active:
+            updated_data = self.json_data
+            updated_data['result'] = {'webhook_deactivated': True}
+            self.json_data = updated_data
+            # Job.fail will abort the transaction.
+            transaction.commit()
+            raise WebhookDeliveryFailure(self.error_message)
         user_agent = '%s-Webhooks/r%s' % (
             config.vhost.mainsite.hostname, lp.app.versioninfo.revno)
         secret = self.webhook.secret
@@ -402,9 +421,10 @@
         if 'date_first_sent' not in updated_data:
             updated_data['date_first_sent'] = updated_data['date_sent']
         self.json_data = updated_data
+        transaction.commit()
 
         if not self.successful:
             if self.retry_automatically:
                 raise WebhookDeliveryRetry()
             else:
-                raise WebhookDeliveryFailure(self.failure_detail)
+                raise WebhookDeliveryFailure(self.error_message)

=== renamed file 'lib/lp/services/webhooks/tests/test_webhookjob.py' => 'lib/lp/services/webhooks/tests/test_job.py'
--- lib/lp/services/webhooks/tests/test_webhookjob.py	2015-08-04 00:11:46 +0000
+++ lib/lp/services/webhooks/tests/test_job.py	2015-08-04 06:04:54 +0000
@@ -241,9 +241,10 @@
     layer = ZopelessDatabaseLayer
 
     def makeAndRunJob(self, response_status=200, raises=None, mock=True,
-                      secret=None):
+                      secret=None, active=True):
         hook = self.factory.makeWebhook(
-            delivery_url=u'http://example.com/ep', secret=secret)
+            delivery_url=u'http://example.com/ep', secret=secret,
+            active=active)
         job = WebhookDeliveryJob.create(hook, payload={'foo': 'bar'})
 
         client = MockWebhookClient(
@@ -286,6 +287,7 @@
                 pending=Equals(False),
                 successful=Equals(True),
                 date_sent=Not(Is(None)),
+                error_message=Is(None),
                 json_data=ContainsDict(
                     {'result': MatchesAll(
                         KeysEqual('request', 'response'),
@@ -327,6 +329,7 @@
                 pending=Equals(True),
                 successful=Equals(False),
                 date_sent=Not(Is(None)),
+                error_message=Equals('Bad HTTP response: 404'),
                 json_data=ContainsDict(
                     {'result': MatchesAll(
                         KeysEqual('request', 'response'),
@@ -349,6 +352,7 @@
                 pending=Equals(True),
                 successful=Equals(False),
                 date_sent=Not(Is(None)),
+                error_message=Equals('Connection error: Connection refused'),
                 json_data=ContainsDict(
                     {'result': MatchesAll(
                         KeysEqual('request', 'connection_error'),
@@ -373,12 +377,34 @@
                 pending=Equals(False),
                 successful=Is(None),
                 date_sent=Is(None),
+                error_message=Is(None),
                 json_data=Not(Contains('result'))))
         self.assertEqual([], reqs)
         self.assertEqual(1, len(oopses.oopses))
         self.assertEqual(
             'No webhook proxy configured.', oopses.oopses[0]['value'])
 
+    def test_run_inactive(self):
+        # A delivery for a webhook that has been deactivated immediately
+        # fails.
+        with CaptureOops() as oopses:
+            job, reqs = self.makeAndRunJob(
+                raises=requests.ConnectionError('Connection refused'),
+                active=False)
+        self.assertThat(
+            job,
+            MatchesStructure(
+                status=Equals(JobStatus.FAILED),
+                pending=Equals(False),
+                successful=Equals(False),
+                date_sent=Is(None),
+                error_message=Equals('Webhook deactivated'),
+                json_data=ContainsDict(
+                    {'result': MatchesDict(
+                        {'webhook_deactivated': Equals(True)})})))
+        self.assertEqual([], reqs)
+        self.assertEqual([], oopses.oopses)
+
     def test_date_first_sent(self):
         job, reqs = self.makeAndRunJob(response_status=404)
         self.assertEqual(job.date_first_sent, job.date_sent)
@@ -513,6 +539,31 @@
         self.assertEqual(True, self.runJob(job))
         self.assertEqual(JobStatus.COMPLETED, job.status)
 
+    def test_manual_retry_with_reset(self):
+        # retry(reset=True) unsets date_first_sent so the automatic
+        # retries can be resumed. This can be useful for recovering from
+        # systemic errors the erroneously failed many deliveries.
+        hook = self.factory.makeWebhook()
+        job = WebhookDeliveryJob.create(hook, payload={'foo': 'bar'})
+        client = MockWebhookClient(response_status=404)
+        self.useFixture(ZopeUtilityFixture(client, IWebhookClient))
+
+        # Simulate a first attempt failure.
+        self.assertEqual(False, self.runJob(job))
+        self.assertEqual(JobStatus.WAITING, job.status)
+        self.assertIsNot(None, job.date_first_sent)
+
+        # A manual retry brings the scheduled start forward.
+        job.retry()
+        self.assertEqual(JobStatus.WAITING, job.status)
+        self.assertIsNot(None, job.date_first_sent)
+
+        # When reset=True, date_first_sent is unset to restart the 24
+        # hour auto-retry window.
+        job.retry(reset=True)
+        self.assertEqual(JobStatus.WAITING, job.status)
+        self.assertIs(None, job.date_first_sent)
+
 
 class TestViaCronscript(TestCaseWithFactory):
 

=== renamed file 'lib/lp/services/webhooks/tests/test_webhook.py' => 'lib/lp/services/webhooks/tests/test_model.py'
--- lib/lp/services/webhooks/tests/test_webhook.py	2015-07-20 07:01:40 +0000
+++ lib/lp/services/webhooks/tests/test_model.py	2015-08-04 06:04:54 +0000
@@ -76,7 +76,7 @@
                 'active', 'date_created', 'date_last_modified', 'deliveries',
                 'delivery_url', 'destroySelf', 'event_types', 'getDelivery',
                 'id', 'ping', 'registrant', 'registrant_id', 'secret',
-                'target')),
+                'setSecret', 'target')),
             }
         webhook = self.factory.makeWebhook()
         checker = getChecker(webhook)
@@ -86,7 +86,8 @@
     def test_set_permissions(self):
         expected_set_permissions = {
             'launchpad.View': set((
-                'active', 'delivery_url', 'event_types', 'registrant_id')),
+                'active', 'delivery_url', 'event_types', 'registrant_id',
+                'secret')),
             }
         webhook = self.factory.makeWebhook()
         checker = getChecker(webhook)

=== modified file 'lib/lp/services/webhooks/tests/test_webservice.py'
--- lib/lp/services/webhooks/tests/test_webservice.py	2015-07-29 09:06:07 +0000
+++ lib/lp/services/webhooks/tests/test_webservice.py	2015-08-04 06:04:54 +0000
@@ -5,8 +5,10 @@
 
 __metaclass__ = type
 
+from datetime import datetime
 import json
 
+from pytz import utc
 from testtools.matchers import (
     ContainsDict,
     Equals,
@@ -16,6 +18,7 @@
     MatchesAll,
     Not,
     )
+from zope.security.proxy import removeSecurityProxy
 
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp.interfaces import OAuthPermission
@@ -136,6 +139,31 @@
             self.webhook_url, api_version='devel')
         self.assertEqual(404, get_response.status)
 
+    def test_setSecret(self):
+        with person_logged_in(self.owner):
+            self.assertIs(None, self.webhook.secret)
+        self.assertEqual(
+            200,
+            self.webservice.named_post(
+                self.webhook_url, 'setSecret', secret='sekrit',
+                api_version='devel').status)
+        with person_logged_in(self.owner):
+            self.assertEqual(u'sekrit', self.webhook.secret)
+        self.assertEqual(
+            200,
+            self.webservice.named_post(
+                self.webhook_url, 'setSecret', secret='shhh',
+                api_version='devel').status)
+        with person_logged_in(self.owner):
+            self.assertEqual(u'shhh', self.webhook.secret)
+        self.assertEqual(
+            200,
+            self.webservice.named_post(
+                self.webhook_url, 'setSecret', secret=None,
+                api_version='devel').status)
+        with person_logged_in(self.owner):
+            self.assertIs(None, self.webhook.secret)
+
 
 class TestWebhookDelivery(TestCaseWithFactory):
     layer = DatabaseFunctionalLayer
@@ -161,28 +189,46 @@
             MatchesAll(
                 KeysEqual(
                     'date_created', 'date_first_sent', 'date_sent',
-                    'http_etag', 'payload', 'pending', 'resource_type_link',
-                    'self_link', 'successful', 'web_link', 'webhook_link'),
-                ContainsDict(
-                    {'payload': Equals({'ping': True}),
+                    'error_message', 'http_etag', 'payload', 'pending',
+                    'resource_type_link', 'self_link', 'successful',
+                    'web_link', 'webhook_link'),
+                ContainsDict({
+                    'payload': Equals({'ping': True}),
                     'pending': Equals(True),
                     'successful': Is(None),
                     'date_created': Not(Is(None)),
-                    'date_sent': Is(None)})))
+                    'date_sent': Is(None),
+                    'error_message': Is(None),
+                    })))
 
     def test_retry(self):
         with person_logged_in(self.owner):
             self.delivery.start()
+            removeSecurityProxy(self.delivery).json_data['date_first_sent'] = (
+                datetime.now(utc).isoformat())
             self.delivery.fail()
         representation = self.webservice.get(
             self.delivery_url, api_version='devel').jsonBody()
         self.assertFalse(representation['pending'])
+
+        # A normal retry just makes the job pending again.
         response = self.webservice.named_post(
             self.delivery_url, 'retry', api_version='devel')
         self.assertEqual(200, response.status)
         representation = self.webservice.get(
             self.delivery_url, api_version='devel').jsonBody()
         self.assertTrue(representation['pending'])
+        self.assertIsNot(None, representation['date_first_sent'])
+
+        # retry(reset=True) unsets date_first_sent as well, restarting
+        # the automatic retry window.
+        response = self.webservice.named_post(
+            self.delivery_url, 'retry', reset=True, api_version='devel')
+        self.assertEqual(200, response.status)
+        representation = self.webservice.get(
+            self.delivery_url, api_version='devel').jsonBody()
+        self.assertTrue(representation['pending'])
+        self.assertIs(None, representation['date_first_sent'])
 
 
 class TestWebhookTarget(TestCaseWithFactory):
@@ -244,6 +290,21 @@
             [(entry['delivery_url'], entry['event_types'], entry['active'])
              for entry in representation['entries']])
 
+    def test_newWebhook_secret(self):
+        self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'}))
+        response = self.webservice.named_post(
+            self.target_url, 'newWebhook',
+            delivery_url='http://example.com/ep', event_types=['foo', 'bar'],
+            secret='sekrit', api_version='devel')
+        self.assertEqual(201, response.status)
+
+        # The secret is set, but cannot be read back through the API.
+        with person_logged_in(self.owner):
+            self.assertEqual(u'sekrit', self.target.webhooks.one().secret)
+        representation = self.webservice.get(
+            self.target_url + '/webhooks', api_version='devel').jsonBody()
+        self.assertNotIn(u'secret', representation['entries'][0])
+
     def test_newWebhook_permissions(self):
         self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'}))
         webservice = LaunchpadWebServiceCaller()

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2015-08-04 00:19:36 +0000
+++ lib/lp/testing/factory.py	2015-08-04 06:04:54 +0000
@@ -4525,13 +4525,14 @@
         return ProxyFactory(
             LiveFSFile(livefsbuild=livefsbuild, libraryfile=libraryfile))
 
-    def makeWebhook(self, target=None, delivery_url=None, secret=None):
+    def makeWebhook(self, target=None, delivery_url=None, secret=None,
+                    active=True):
         if target is None:
             target = self.makeGitRepository()
         if delivery_url is None:
             delivery_url = self.getUniqueURL().decode('utf-8')
         return getUtility(IWebhookSource).new(
-            target, self.makePerson(), delivery_url, [], True, secret)
+            target, self.makePerson(), delivery_url, [], active, secret)
 
     def makeSnap(self, registrant=None, owner=None, distroseries=None,
                  name=None, branch=None, git_ref=None,


Follow ups