From 8d7493cb86c12896aba37ed53d36d8efc9915eef Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 17 Jul 2014 22:51:58 -0400 Subject: [PATCH] Convert over to notifications system. Note this is incomplete --- app.py | 4 +- config.py | 2 +- data/database.py | 10 +-- data/model/legacy.py | 25 +++++- endpoints/api/__init__.py | 1 - endpoints/api/repositorynotification.py | 31 ++++++- endpoints/api/webhook.py | 77 ---------------- endpoints/index.py | 43 ++++----- endpoints/notificationevent.py | 108 ++++++++++++++++++++++ endpoints/notificationmethod.py | 114 ++++++++++++++++++++++++ initdb.py | 23 +++-- license.pyc | Bin 915 -> 895 bytes static/js/app.js | 31 +++++-- test/data/test.db | Bin 929792 -> 917504 bytes test/test_api_security.py | 30 +++---- test/test_api_usage.py | 45 +++++----- workers/notificationworker.py | 54 +++++++++++ 17 files changed, 432 insertions(+), 166 deletions(-) delete mode 100644 endpoints/api/webhook.py create mode 100644 endpoints/notificationevent.py create mode 100644 endpoints/notificationmethod.py create mode 100644 workers/notificationworker.py diff --git a/app.py b/app.py index 8fd6a43ef..ac6f93a96 100644 --- a/app.py +++ b/app.py @@ -71,14 +71,14 @@ sentry = Sentry(app) build_logs = BuildLogs(app) queue_metrics = QueueMetrics(app) authentication = UserAuthentication(app) -expiration = Expiration(app) +#expiration = Expiration(app) userevents = UserEventsBuilderModule(app) tf = app.config['DB_TRANSACTION_FACTORY'] image_diff_queue = WorkQueue(app.config['DIFFS_QUEUE_NAME'], tf) dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf, reporter=queue_metrics.report) -webhook_queue = WorkQueue(app.config['WEBHOOK_QUEUE_NAME'], tf) +notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf) database.configure(app.config) model.config.app_config = app.config diff --git a/config.py b/config.py index 79866aaf2..df3216c0e 100644 --- a/config.py +++ b/config.py @@ -121,7 +121,7 @@ class DefaultConfig(object): with open(tag_path) as tag_svg: STATUS_TAGS[tag_name] = tag_svg.read() - WEBHOOK_QUEUE_NAME = 'webhook' + NOTIFICATION_QUEUE_NAME = 'notification' DIFFS_QUEUE_NAME = 'imagediff' DOCKERFILE_BUILD_QUEUE_NAME = 'dockerfilebuild' diff --git a/data/database.py b/data/database.py index e8fd7c8c3..a1f34fe52 100644 --- a/data/database.py +++ b/data/database.py @@ -382,18 +382,10 @@ class RepositoryNotification(BaseModel): config_json = TextField() -# TODO: remove after migration. -class Webhook(BaseModel): - public_id = CharField(default=random_string_generator(length=64), - unique=True, index=True) - repository = ForeignKeyField(Repository) - parameters = TextField() - - all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, Visibility, RepositoryTag, EmailConfirmation, FederatedLogin, LoginService, QueueItem, RepositoryBuild, Team, TeamMember, TeamRole, LogEntryKind, LogEntry, PermissionPrototype, ImageStorage, BuildTriggerService, RepositoryBuildTrigger, OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind, Notification, ImageStorageLocation, ImageStoragePlacement, - ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification, Webhook] + ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification] diff --git a/data/model/legacy.py b/data/model/legacy.py index 860b0af87..7a6df3cb1 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -840,6 +840,13 @@ def get_repository_for_resource(resource_key): return None +def lookup_repository(repo_id): + try: + return Repository.get(Repository.id == repo_id) + except Repository.DoesNotExist: + return None + + def get_repository(namespace_name, repository_name): try: return Repository.get(Repository.name == repository_name, @@ -1540,6 +1547,13 @@ def create_repo_notification(repo, event_name, method_name, config): config_json=json.dumps(config)) +def lookup_repo_notification(notification_id): + try: + return RepositoryNotification.get(RepositoryNotification.id == notification_id) + except RepositoryNotification.DoesNotExist: + return None + + def get_repo_notification(namespace_name, repository_name, uuid): joined = RepositoryNotification.select().join(Repository) found = list(joined.where(Repository.namespace == namespace_name, @@ -1558,11 +1572,16 @@ def delete_repo_notification(namespace_name, repository_name, uuid): return found -def list_repo_notifications(namespace_name, repository_name): +def list_repo_notifications(namespace_name, repository_name, event_name=None): joined = RepositoryNotification.select().join(Repository) - return joined.where(Repository.namespace == namespace_name, - Repository.name == repository_name) + where = joined.where(Repository.namespace == namespace_name, + Repository.name == repository_name) + if event_name: + event = ExternalNotificationEvent.get(ExternalNotificationEvent.name == event_name) + where = where.where(Repostiory.event == event) + + return where # TODO: remove webhook methods when no longer used. def create_webhook(repo, params_obj): diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 1b34ad38a..775e7328b 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -316,4 +316,3 @@ import endpoints.api.tag import endpoints.api.team import endpoints.api.trigger import endpoints.api.user -import endpoints.api.webhook diff --git a/endpoints/api/repositorynotification.py b/endpoints/api/repositorynotification.py index 5315d5282..175f45350 100644 --- a/endpoints/api/repositorynotification.py +++ b/endpoints/api/repositorynotification.py @@ -2,8 +2,10 @@ import json from flask import request +from app import notification_queue from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, log_action, validate_json_request, api, NotFound) +from endpoints.notificationevent import NotificationEvent from data import model @@ -23,7 +25,7 @@ def notification_view(notification): @resource('/v1/repository//notification/') -class NotificaitonList(RepositoryParamResource): +class RepositoryNotificationList(RepositoryParamResource): """ Resource for dealing with listing and creating notifications on a repository. """ schemas = { 'NotificationCreateRequest': { @@ -81,7 +83,7 @@ class NotificaitonList(RepositoryParamResource): @resource('/v1/repository//notification/') -class Notification(RepositoryParamResource): +class RepositoryNotification(RepositoryParamResource): """ Resource for dealing with specific notifications. """ @require_repo_admin @nickname('getRepoNotification') @@ -105,3 +107,28 @@ class Notification(RepositoryParamResource): repo=model.get_repository(namespace, repository)) return 'No Content', 204 + + +@resource('/v1/repository//notification//test') +class TestRepositoryNotification(RepositoryParamResource): + """ Resource for queuing a test of a notification. """ + @require_repo_admin + @nickname('testRepoNotification') + def post(self, namespace, repository, uuid): + """ Queues a test notification for this repository. """ + try: + notification = model.get_repo_notification(namespace, repository, uuid) + except model.InvalidNotificationException: + raise NotFound() + + event_info = NotificationEvent.get_event(notification.event.name) + sample_data = event_info.get_sample_data(repository=notification.repository) + notification_data = { + 'notification_id': notification.id, + 'repository_id': notification.repository.id, + 'event_data': sample_data + } + notification_queue.put([namespace, repository, notification.event.name], + json.dumps(notification_data)) + + return {} diff --git a/endpoints/api/webhook.py b/endpoints/api/webhook.py deleted file mode 100644 index b38d7ec43..000000000 --- a/endpoints/api/webhook.py +++ /dev/null @@ -1,77 +0,0 @@ -import json - -from flask import request - -from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, - log_action, validate_json_request, api, NotFound) -from data import model - - -def webhook_view(webhook): - return { - 'public_id': webhook.public_id, - 'parameters': json.loads(webhook.parameters), - } - - -@resource('/v1/repository//webhook/') -class WebhookList(RepositoryParamResource): - """ Resource for dealing with listing and creating webhooks. """ - schemas = { - 'WebhookCreateRequest': { - 'id': 'WebhookCreateRequest', - 'type': 'object', - 'description': 'Arbitrary json.', - }, - } - - @require_repo_admin - @nickname('createWebhook') - @validate_json_request('WebhookCreateRequest') - def post(self, namespace, repository): - """ Create a new webhook for the specified repository. """ - repo = model.get_repository(namespace, repository) - webhook = model.create_webhook(repo, request.get_json()) - resp = webhook_view(webhook) - repo_string = '%s/%s' % (namespace, repository) - headers = { - 'Location': api.url_for(Webhook, repository=repo_string, public_id=webhook.public_id), - } - log_action('add_repo_webhook', namespace, - {'repo': repository, 'webhook_id': webhook.public_id}, - repo=repo) - return resp, 201, headers - - @require_repo_admin - @nickname('listWebhooks') - def get(self, namespace, repository): - """ List the webhooks for the specified repository. """ - webhooks = model.list_webhooks(namespace, repository) - return { - 'webhooks': [webhook_view(webhook) for webhook in webhooks] - } - - -@resource('/v1/repository//webhook/') -class Webhook(RepositoryParamResource): - """ Resource for dealing with specific webhooks. """ - @require_repo_admin - @nickname('getWebhook') - def get(self, namespace, repository, public_id): - """ Get information for the specified webhook. """ - try: - webhook = model.get_webhook(namespace, repository, public_id) - except model.InvalidWebhookException: - raise NotFound() - - return webhook_view(webhook) - - @require_repo_admin - @nickname('deleteWebhook') - def delete(self, namespace, repository, public_id): - """ Delete the specified webhook. """ - model.delete_webhook(namespace, repository, public_id) - log_action('delete_repo_webhook', namespace, - {'repo': repository, 'webhook_id': public_id}, - repo=model.get_repository(namespace, repository)) - return 'No Content', 204 diff --git a/endpoints/index.py b/endpoints/index.py index 0e063882f..a410f9b38 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -8,7 +8,7 @@ from collections import OrderedDict from data import model from data.model import oauth -from app import analytics, app, webhook_queue, authentication, userevents, storage +from app import analytics, app, notification_queue, authentication, userevents, storage from auth.auth import process_auth from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token from util.names import parse_repository_name @@ -315,27 +315,30 @@ def update_images(namespace, repository): profile.debug('GCing repository') num_removed = model.garbage_collect_repository(namespace, repository) - # Generate a job for each webhook that has been added to this repo - profile.debug('Adding webhooks for repository') + # Generate a job for each notification that has been added to this repo + profile.debug('Adding notifications for repository') - webhooks = model.list_webhooks(namespace, repository) - for webhook in webhooks: - webhook_data = json.loads(webhook.parameters) - repo_string = '%s/%s' % (namespace, repository) - profile.debug('Creating webhook for repository \'%s\' for url \'%s\'', - repo_string, webhook_data['url']) - webhook_data['payload'] = { - 'repository': repo_string, - 'namespace': namespace, - 'name': repository, - 'docker_url': 'quay.io/%s' % repo_string, - 'homepage': 'https://quay.io/repository/%s' % repo_string, - 'visibility': repo.visibility.name, - 'updated_tags': updated_tags, - 'pushed_image_count': len(image_with_checksums), - 'pruned_image_count': num_removed, + repo_string = '%s/%s' % (namespace, repository) + event_data = { + 'repository': repo_string, + 'namespace': namespace, + 'name': repository, + 'docker_url': 'quay.io/%s' % repo_string, + 'homepage': 'https://quay.io/repository/%s' % repo_string, + 'visibility': repo.visibility.name, + 'updated_tags': updated_tags, + 'pushed_image_count': len(image_with_checksums), + 'pruned_image_count': num_removed + } + + notifications = model.list_repo_notifications(namespace, repository, event_name='repo_push') + for notification in notifications: + notification_data = { + 'notification_id': notification.id, + 'repository_id': repository.id, + 'event_data': event_data } - webhook_queue.put([namespace, repository], json.dumps(webhook_data)) + notification_queue.put([namespace, repository, 'repo_push'], json.dumps(notification_data)) return make_response('Updated', 204) diff --git a/endpoints/notificationevent.py b/endpoints/notificationevent.py new file mode 100644 index 000000000..c091a204c --- /dev/null +++ b/endpoints/notificationevent.py @@ -0,0 +1,108 @@ +import logging +import io +import os.path +import tarfile +import base64 + +logger = logging.getLogger(__name__) + +class InvalidNotificationEventException(Exception): + pass + +class NotificationEvent(object): + def __init__(self): + pass + + def get_summary(self, notification_data): + """ + Returns a human readable one-line summary for the given notification data. + """ + raise NotImplementedError + + def get_message(self, notification_data): + """ + Returns a human readable HTML message for the given notification data. + """ + raise NotImplementedError + + def get_sample_data(self, repository=None): + """ + Returns sample data for testing the raising of this notification, with an optional + repository. + """ + raise NotImplementedError + + @classmethod + def event_name(cls): + """ + Particular event implemented by subclasses. + """ + raise NotImplementedError + + @classmethod + def get_event(cls, eventname): + for subc in cls.__subclasses__(): + if subc.event_name() == eventname: + return subc() + + raise InvalidNotificationEventException('Unable to find event: %s' % eventname) + + +class RepoPushEvent(NotificationEvent): + @classmethod + def event_name(cls): + return 'repo_push' + + def get_summary(self, notification_data): + return 'Repository %s updated' % (event_data['repository']) + + def get_message(self, notification_data): + event_data = notification_data['event_data'] + if not event_data['tags']: + return '%s images pushed for repository %s (%s)' % (event_data['pushed_image_count'], + event_data['repository'], event_data['homepage']) + + return 'Tags %s updated for repository %s (%s)' % (event_data['updated_tags'], + event_data['repository'], event_data['homepage']) + + def get_sample_data(self, repository=None): + repo_string = '%s/%s' % (repository.namespace, repository.name) + event_data = { + 'repository': repo_string, + 'namespace': repository.namespace, + 'name': repository.name, + 'docker_url': 'quay.io/%s' % repo_string, + 'homepage': 'https://quay.io/repository/%s' % repo_string, + 'visibility': repository.visibility.name, + 'updated_tags': ['latest', 'foo', 'bar'], + 'pushed_image_count': 10, + 'pruned_image_count': 3 + } + return event_data + + +class BuildStartEvent(NotificationEvent): + @classmethod + def event_name(cls): + return 'build_start' + + def get_sample_data(repository=None): + pass + + +class BuildSuccessEvent(NotificationEvent): + @classmethod + def event_name(cls): + return 'build_success' + + def get_sample_data(repository=None): + pass + + +class BuildFailureEvent(NotificationEvent): + @classmethod + def event_name(cls): + return 'build_failure' + + def get_sample_data(repository=None): + pass diff --git a/endpoints/notificationmethod.py b/endpoints/notificationmethod.py new file mode 100644 index 000000000..011a6b010 --- /dev/null +++ b/endpoints/notificationmethod.py @@ -0,0 +1,114 @@ +import logging +import io +import os.path +import tarfile +import base64 +import json + +from flask.ext.mail import Message +from app import mail, app +from data import model + +logger = logging.getLogger(__name__) + +class InvalidNotificationMethodException(Exception): + pass + +class NotificationMethod(object): + def __init__(self): + pass + + @classmethod + def method_name(cls): + """ + Particular method implemented by subclasses. + """ + raise NotImplementedError + + def perform(self, notification, event_handler, notification_data): + """ + Performs the notification method. + + notification: The noticication record itself. + event_handler: The NotificationEvent handler. + notification_data: The dict of notification data placed in the queue. + """ + raise NotImplementedError + + @classmethod + def get_method(cls, methodname): + for subc in cls.__subclasses__(): + if subc.method_name() == methodname: + return subc() + + raise InvalidNotificationMethodException('Unable to find method: %s' % methodname) + + +class QuayNotificationMethod(NotificationMethod): + @classmethod + def method_name(cls): + return 'quay_notification' + + def perform(self, notification, event_handler, notification_data): + repository_id = notification_data['repository_id'] + repository = model.lookup_repository(repository_id) + if not repository: + # Probably deleted. + return True + + model.create_notification(event_handler.event_name(), + repository.namespace, metadata=notification_data['event_data']) + return True + + +class EmailMethod(NotificationMethod): + @classmethod + def method_name(cls): + return 'email' + + def perform(self, notification, event_handler, notification_data): + config_data = json.loads(notification.config_json) + email = config_data.get('email', '') + if not email: + return False + + msg = Message(event_handler.get_summary(notification_data), + sender='support@quay.io', + recipients=[email]) + msg.html = event_handler.get_message(notification_data) + + try: + mail.send(msg) + except Exception as ex: + logger.exception('Email was unable to be sent: %s' % ex.message) + return False + + return True + + +class WebhookMethod(NotificationMethod): + @classmethod + def method_name(cls): + return 'webhook' + + def perform(self, notification, event_handler, notification_data): + config_data = json.loads(notification.config_json) + url = config_data.get('url', '') + if not url: + return False + + payload = notification_data['event_data'] + headers = {'Content-type': 'application/json'} + + try: + resp = requests.post(url, data=json.dumps(payload), headers=headers) + if resp.status_code/100 != 2: + logger.error('%s response for webhook to url: %s' % (resp.status_code, + url)) + return False + + except requests.exceptions.RequestException as ex: + logger.exception('Webhook was unable to be sent: %s' % ex.message) + return False + + return True diff --git a/initdb.py b/initdb.py index 50c6f6197..20d442c8f 100644 --- a/initdb.py +++ b/initdb.py @@ -229,23 +229,18 @@ def initialize_database(): LogEntryKind.create(name='delete_application') LogEntryKind.create(name='reset_application_client_secret') - # TODO: remove these when webhooks are removed. + # Note: These are deprecated. LogEntryKind.create(name='add_repo_webhook') LogEntryKind.create(name='delete_repo_webhook') LogEntryKind.create(name='add_repo_notification') LogEntryKind.create(name='delete_repo_notification') - NotificationKind.create(name='password_required') - NotificationKind.create(name='over_private_usage') - NotificationKind.create(name='expiring_license') - NotificationKind.create(name='maintenance') - - NotificationKind.create(name='test_notification') - ImageStorageLocation.create(name='local_eu') ImageStorageLocation.create(name='local_us') + # NOTE: These MUST be copied over to NotificationKind, since every external + # notification can also generate a Quay.io notification. ExternalNotificationEvent.create(name='repo_push') ExternalNotificationEvent.create(name='build_start') ExternalNotificationEvent.create(name='build_success') @@ -255,6 +250,18 @@ def initialize_database(): ExternalNotificationMethod.create(name='email') ExternalNotificationMethod.create(name='webhook') + NotificationKind.create(name='repo_push') + NotificationKind.create(name='build_start') + NotificationKind.create(name='build_success') + NotificationKind.create(name='build_failure') + + NotificationKind.create(name='password_required') + NotificationKind.create(name='over_private_usage') + NotificationKind.create(name='expiring_license') + NotificationKind.create(name='maintenance') + + NotificationKind.create(name='test_notification') + def wipe_database(): logger.debug('Wiping all data from the DB.') diff --git a/license.pyc b/license.pyc index df6085268aa2ad376677ab36a11af0446fa01c9e..83687adfa9c32d46b214c3197998c215e4e9fcfa 100644 GIT binary patch delta 62 zcmbQt{-2GV`7Y)IF(rs0A*?u A&;S4c delta 82 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 BQ*HnN delta 16539 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 diff --git a/test/test_api_security.py b/test/test_api_security.py index 34fe7ee18..b8e8470c5 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -18,7 +18,7 @@ from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, TriggerBuildList, ActivateBuildTrigger, BuildTrigger, BuildTriggerList, BuildTriggerAnalyze) -from endpoints.api.webhook import Webhook, WebhookList +from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout, Signin, User, UserAuthorizationList, UserAuthorization) from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList @@ -1883,10 +1883,10 @@ class TestBuildTriggerD6tiBuynlargeOrgrepo(ApiTestCase): self._run_test('DELETE', 404, 'devtable', None) -class TestWebhookQfatPublicPublicrepo(ApiTestCase): +class TestRepositoryNotificationQfatPublicPublicrepo(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) - self._set_url(Webhook, public_id="QFAT", repository="public/publicrepo") + self._set_url(RepositoryNotification, uuid="QFAT", repository="public/publicrepo") def test_get_anonymous(self): self._run_test('GET', 401, None, None) @@ -1913,10 +1913,10 @@ class TestWebhookQfatPublicPublicrepo(ApiTestCase): self._run_test('DELETE', 403, 'devtable', None) -class TestWebhookQfatDevtableShared(ApiTestCase): +class TestRepositoryNotificationQfatDevtableShared(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) - self._set_url(Webhook, public_id="QFAT", repository="devtable/shared") + self._set_url(RepositoryNotification, uuid="QFAT", repository="devtable/shared") def test_get_anonymous(self): self._run_test('GET', 401, None, None) @@ -1943,10 +1943,10 @@ class TestWebhookQfatDevtableShared(ApiTestCase): self._run_test('DELETE', 400, 'devtable', None) -class TestWebhookQfatBuynlargeOrgrepo(ApiTestCase): +class TestRepositoryNotificationQfatBuynlargeOrgrepo(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) - self._set_url(Webhook, public_id="QFAT", repository="buynlarge/orgrepo") + self._set_url(RepositoryNotification, uuid="QFAT", repository="buynlarge/orgrepo") def test_get_anonymous(self): self._run_test('GET', 401, None, None) @@ -2529,10 +2529,10 @@ class TestBuildTriggerListBuynlargeOrgrepo(ApiTestCase): self._run_test('GET', 200, 'devtable', None) -class TestWebhookListPublicPublicrepo(ApiTestCase): +class TestRepositoryNotificationListPublicPublicrepo(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) - self._set_url(WebhookList, repository="public/publicrepo") + self._set_url(RepositoryNotificationList, repository="public/publicrepo") def test_get_anonymous(self): self._run_test('GET', 401, None, None) @@ -2559,10 +2559,10 @@ class TestWebhookListPublicPublicrepo(ApiTestCase): self._run_test('POST', 403, 'devtable', {}) -class TestWebhookListDevtableShared(ApiTestCase): +class TestRepositoryNotificationListDevtableShared(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) - self._set_url(WebhookList, repository="devtable/shared") + self._set_url(RepositoryNotificationList, repository="devtable/shared") def test_get_anonymous(self): self._run_test('GET', 401, None, None) @@ -2586,13 +2586,13 @@ class TestWebhookListDevtableShared(ApiTestCase): self._run_test('POST', 403, 'reader', {}) def test_post_devtable(self): - self._run_test('POST', 201, 'devtable', {}) + self._run_test('POST', 201, 'devtable', {'event': 'repo_push', 'method': 'email', 'config': {}}) -class TestWebhookListBuynlargeOrgrepo(ApiTestCase): +class TestRepositoryNotificationListBuynlargeOrgrepo(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) - self._set_url(WebhookList, repository="buynlarge/orgrepo") + self._set_url(RepositoryNotificationList, repository="buynlarge/orgrepo") def test_get_anonymous(self): self._run_test('GET', 401, None, None) @@ -2616,7 +2616,7 @@ class TestWebhookListBuynlargeOrgrepo(ApiTestCase): self._run_test('POST', 403, 'reader', {}) def test_post_devtable(self): - self._run_test('POST', 201, 'devtable', {}) + self._run_test('POST', 201, 'devtable', {'event': 'repo_push', 'method': 'email', 'config': {}}) class TestRepositoryTokenListPublicPublicrepo(ApiTestCase): diff --git a/test/test_api_usage.py b/test/test_api_usage.py index a2d062bea..e0c21f269 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -20,7 +20,7 @@ from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, TriggerBuildList, ActivateBuildTrigger, BuildTrigger, BuildTriggerList, BuildTriggerAnalyze) -from endpoints.api.webhook import Webhook, WebhookList +from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Signout, Signin, User, UserAuthorizationList, UserAuthorization) @@ -1073,41 +1073,44 @@ class TestRequestRepoBuild(ApiTestCase): -class TestWebhooks(ApiTestCase): +class TestRepositoryNotifications(ApiTestCase): def test_webhooks(self): self.login(ADMIN_ACCESS_USER) - # Add a webhook. - json = self.postJsonResponse(WebhookList, + # Add a notification. + json = self.postJsonResponse(RepositoryNotificationList, params=dict(repository=ADMIN_ACCESS_USER + '/simple'), - data=dict(url='http://example.com'), + data=dict(config={'url': 'http://example.com'}, event='repo_push', method='webhook'), expected_code=201) - self.assertEquals('http://example.com', json['parameters']['url']) - wid = json['public_id'] + self.assertEquals('repo_push', json['event']) + self.assertEquals('webhook', json['method']) + self.assertEquals('http://example.com', json['config']['url']) + wid = json['uuid'] - # Get the webhook. - json = self.getJsonResponse(Webhook, - params=dict(repository=ADMIN_ACCESS_USER + '/simple', public_id=wid)) + # Get the notification. + json = self.getJsonResponse(RepositoryNotification, + params=dict(repository=ADMIN_ACCESS_USER + '/simple', uuid=wid)) - self.assertEquals(wid, json['public_id']) - self.assertEquals('http://example.com', json['parameters']['url']) + self.assertEquals(wid, json['uuid']) + self.assertEquals('repo_push', json['event']) + self.assertEquals('webhook', json['method']) - # Verify the webhook is listed. - json = self.getJsonResponse(WebhookList, + # Verify the notification is listed. + json = self.getJsonResponse(RepositoryNotificationList, params=dict(repository=ADMIN_ACCESS_USER + '/simple')) - ids = [w['public_id'] for w in json['webhooks']] + ids = [w['uuid'] for w in json['notifications']] assert wid in ids - # Delete the webhook. - self.deleteResponse(Webhook, - params=dict(repository=ADMIN_ACCESS_USER + '/simple', public_id=wid), + # Delete the notification. + self.deleteResponse(RepositoryNotification, + params=dict(repository=ADMIN_ACCESS_USER + '/simple', uuid=wid), expected_code=204) - # Verify the webhook is gone. - self.getResponse(Webhook, - params=dict(repository=ADMIN_ACCESS_USER + '/simple', public_id=wid), + # Verify the notification is gone. + self.getResponse(RepositoryNotification, + params=dict(repository=ADMIN_ACCESS_USER + '/simple', uuid=wid), expected_code=404) diff --git a/workers/notificationworker.py b/workers/notificationworker.py new file mode 100644 index 000000000..4ccee7a2d --- /dev/null +++ b/workers/notificationworker.py @@ -0,0 +1,54 @@ +import logging +import argparse +import requests +import json + +from app import notification_queue +from workers.worker import Worker + +from endpoints.notificationmethod import NotificationMethod, InvalidNotificationMethodException +from endpoints.notificationevent import NotificationEvent, InvalidNotificationEventException + +from data import model + +root_logger = logging.getLogger('') +root_logger.setLevel(logging.DEBUG) + +FORMAT = '%(asctime)-15s - %(levelname)s - %(pathname)s - %(funcName)s - %(message)s' +formatter = logging.Formatter(FORMAT) + +logger = logging.getLogger(__name__) + + +class NotificationWorker(Worker): + def process_queue_item(self, job_details): + notification_id = job_details['notification_id']; + notification = model.lookup_repo_notification(notification_id) + + print job_details + print notification + + if not notification: + # Probably deleted. + return True + + event_name = notification.event.name + method_name = notification.method.name + + try: + event_handler = NotificationEvent.get_event(event_name) + method_handler = NotificationMethod.get_method(method_name) + except InvalidNotificationMethodException as ex: + logger.exception('Cannot find notification method: %s' % ex.message) + return False + except InvalidNotificationEventException as ex: + logger.exception('Cannot find notification method: %s' % ex.message) + return False + + return method_handler.perform(notification, event_handler, job_details) + +logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False) + +worker = NotificationWorker(notification_queue, poll_period_seconds=15, + reservation_seconds=3600) +worker.start()