diff --git a/endpoints/notificationevent.py b/endpoints/notificationevent.py index f1bf0a09d..dc0491e27 100644 --- a/endpoints/notificationevent.py +++ b/endpoints/notificationevent.py @@ -1,6 +1,7 @@ import logging import time import json +import re from datetime import datetime from notificationhelper import build_event_data @@ -138,7 +139,28 @@ class VulnerabilityFoundEvent(NotificationEvent): ', '.join(event_data['tags'])) -class BuildQueueEvent(NotificationEvent): +class BaseBuildEvent(NotificationEvent): + def should_perform(self, event_data, notification_data): + event_config = json.loads(notification_data.event_config_json) + ref_regex = event_config.get('ref-regex') or None + if ref_regex is None: + return True + + # Lookup the ref. If none, this is a non-git build and we should not fire the event. + ref = event_data.get('trigger_metadata', {}).get('ref', None) + if ref is None: + return False + + # Try parsing the regex string as a regular expression. If we fail, we fail to fire + # the event. + try: + return bool(re.compile(str(ref_regex)).match(ref)) + except Exception: + logger.warning('Regular expression error for build event filter: %s', ref_regex) + return False + + +class BuildQueueEvent(BaseBuildEvent): @classmethod def event_name(cls): return 'build_queued' @@ -177,7 +199,7 @@ class BuildQueueEvent(NotificationEvent): return 'Build queued ' + _build_summary(event_data) -class BuildStartEvent(NotificationEvent): +class BuildStartEvent(BaseBuildEvent): @classmethod def event_name(cls): return 'build_start' @@ -205,7 +227,7 @@ class BuildStartEvent(NotificationEvent): return 'Build started ' + _build_summary(event_data) -class BuildSuccessEvent(NotificationEvent): +class BuildSuccessEvent(BaseBuildEvent): @classmethod def event_name(cls): return 'build_success' @@ -234,7 +256,7 @@ class BuildSuccessEvent(NotificationEvent): return 'Build succeeded ' + _build_summary(event_data) -class BuildFailureEvent(NotificationEvent): +class BuildFailureEvent(BaseBuildEvent): @classmethod def event_name(cls): return 'build_failure' diff --git a/static/css/directives/ui/create-external-notification-dialog.css b/static/css/directives/ui/create-external-notification-dialog.css deleted file mode 100644 index 12394955c..000000000 --- a/static/css/directives/ui/create-external-notification-dialog.css +++ /dev/null @@ -1,20 +0,0 @@ -#createNotificationModal .dropdown-select { - margin: 0px; -} - -#createNotificationModal .options-table { - width: 100%; - margin-bottom: 10px; -} - -#createNotificationModal .options-table td { - padding-bottom: 6px; -} - -#createNotificationModal .options-table td.name { - width: 160px; -} - -#createNotificationModal .options-table-wrapper { - padding: 10px; -} \ No newline at end of file diff --git a/static/css/directives/ui/create-external-notification.css b/static/css/directives/ui/create-external-notification.css new file mode 100644 index 000000000..133cced84 --- /dev/null +++ b/static/css/directives/ui/create-external-notification.css @@ -0,0 +1,69 @@ +.create-external-notification-element { + padding: 10px; +} + +.create-external-notification-element .dropdown-select { + margin: 0px; +} + +.create-external-notification-element .button-bar { + margin-top: 20px; + padding: 20px; + border-top: 1px solid #eee; + padding-bottom: 8px; + padding-left: 6px; +} + +.create-external-notification-element .options-table .section-header { + font-size: 18px; + padding: 6px; + padding-bottom: 2px; +} + +.create-external-notification-element .options-table { + width: 100%; +} + +.create-external-notification-element .options-table td { + padding: 10px; +} + +.create-external-notification-element .options-table td.name { + padding-left: 20px; + width: 1px; + white-space: nowrap; + vertical-align: top; + padding-top: 16px; +} + +.create-external-notification-element .options-table td.value { + padding-left: 20px; +} + +.create-external-notification-element .help-table { + width: 100%; +} + +.create-external-notification-element .help-table > tbody > tr > td { + vertical-align: top; + max-width: 600px; +} + +.create-external-notification-element .help-table td.help-col { + vertical-align: top; + padding: 20px; + padding-top: 40px; + width: 500px; + font-size: 15px; + color: #aaa; +} + +.create-external-notification-element .config-section { + margin-top: 20px; +} + + +.create-external-notification-element .help-text { + margin-top: 10px; + color: #aaa; +} \ No newline at end of file diff --git a/static/directives/create-external-notification-dialog.html b/static/directives/create-external-notification-dialog.html deleted file mode 100644 index f45e7353e..000000000 --- a/static/directives/create-external-notification-dialog.html +++ /dev/null @@ -1,174 +0,0 @@ - - diff --git a/static/directives/create-external-notification.html b/static/directives/create-external-notification.html new file mode 100644 index 000000000..7bc684365 --- /dev/null +++ b/static/directives/create-external-notification.html @@ -0,0 +1,233 @@ +
+
+ +
+ + + + + + + +
+ + + + + + + + + + +
When this event occurs
+ +
With {{ field.title }} (optional): +
+ + + + +
+
+
+ + +
+ {{ field.values[currentEventConfig[field.name]].description }} +
+ + +
+ {{ field.help_text }} +
+
+
+
+ +
+ + + + + + +
+ + + + + + + + + + +
Then issue a notification
+ +
{{ field.title }}: +
+ + + + + + + + + + + + +
+ + +
+ +
+
+ + + + + + +
+ JSON metadata representing the event will be POSTed to the URL. All requests made to TLS-enabled URLs will be signed with the key. +

+ The contents for each event can be found in the user guide: + + http://docs.quay.io/guides/notifications.html + +
+
+ + +
+ {{ field.help_text }} +
+
+
+
+ +
+ + + + + +
+ + + + + + +
With extra configuration
Notification title: + +
+
+
+ + +
+ +
+
+ + + +
\ No newline at end of file diff --git a/static/directives/regex-editor.html b/static/directives/regex-editor.html new file mode 100644 index 000000000..96201d7d0 --- /dev/null +++ b/static/directives/regex-editor.html @@ -0,0 +1,4 @@ +
+ +
\ No newline at end of file diff --git a/static/directives/repo-view/repo-panel-info.html b/static/directives/repo-view/repo-panel-info.html index 6605fecf7..2a4edf047 100644 --- a/static/directives/repo-view/repo-panel-info.html +++ b/static/directives/repo-view/repo-panel-info.html @@ -75,14 +75,6 @@ - - Automated Security Scanning -
Continually scanning this repository for 17K+ known vulnerabilities. Read more about this feature.
-
- Configure Vulnerability Alerts -
-
diff --git a/static/directives/repository-events-table.html b/static/directives/repository-events-table.html index 7fae2b4ff..67257080b 100644 --- a/static/directives/repository-events-table.html +++ b/static/directives/repository-events-table.html @@ -4,9 +4,9 @@ Events and Notifications
@@ -19,7 +19,7 @@ Click the "Create Notification" button above to add a new notification for a repository event.
- Click here to add a new notification for a repository event. + Click here to add a new notification for a repository event.
@@ -95,11 +95,4 @@ - - -
diff --git a/static/js/app.js b/static/js/app.js index 19e0390d9..a08e89d70 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -120,6 +120,9 @@ quayApp.config(['$routeProvider', '$locationProvider', 'pages', function($routeP // Repo Build View .route('/repository/:namespace/:name/build/:buildid', 'build-view') + // Create repository notification + .route('/repository/:namespace/:name/create-notification', 'create-repository-notification') + // Repo List .route('/repository/', 'repo-list') diff --git a/static/js/directives/ui/create-external-notification-dialog.js b/static/js/directives/ui/create-external-notification.js similarity index 79% rename from static/js/directives/ui/create-external-notification-dialog.js rename to static/js/directives/ui/create-external-notification.js index 874769216..f6f00774f 100644 --- a/static/js/directives/ui/create-external-notification-dialog.js +++ b/static/js/directives/ui/create-external-notification.js @@ -1,16 +1,15 @@ /** - * An element which displays a dialog to register a new external notification on a repository. + * An element which displays a form to register a new external notification on a repository. */ -angular.module('quay').directive('createExternalNotificationDialog', function () { +angular.module('quay').directive('createExternalNotification', function () { var directiveDefinitionObject = { priority: 0, - templateUrl: '/static/directives/create-external-notification-dialog.html', + templateUrl: '/static/directives/create-external-notification.html', replace: false, transclude: false, restrict: 'C', scope: { 'repository': '=repository', - 'counter': '=counter', 'notificationCreated': '¬ificationCreated', 'defaultData': '=defaultData' }, @@ -27,7 +26,12 @@ angular.module('quay').directive('createExternalNotificationDialog', function () $scope.methods = ExternalNotificationData.getSupportedMethods(); $scope.getPattern = function(field) { - return new RegExp(field.regex); + if (field._cached_regex) { + return field._cached_regex; + } + + field._cached_regex = new RegExp(field.pattern); + return field._cached_regex; }; $scope.setEvent = function(event) { @@ -99,14 +103,6 @@ angular.module('quay').directive('createExternalNotificationDialog', function () ApiService.createRepoNotification(data, params).then(function(resp) { $scope.status = ''; $scope.notificationCreated({'notification': resp}); - - // Used by repository-events-summary. - if (!$scope.repository._notificationCounter) { - $scope.repository._notificationCounter = 0; - } - - $scope.repository._notificationCounter++; - $('#createNotificationModal').modal('hide'); }); }; @@ -123,6 +119,7 @@ angular.module('quay').directive('createExternalNotificationDialog', function () } $scope.unauthorizedEmail = true; + $('#authorizeEmailModal').modal({}); }; $scope.sendAuthEmail = function() { @@ -146,6 +143,11 @@ angular.module('quay').directive('createExternalNotificationDialog', function () }, 1000); }; + $scope.cancelEmailAuth = function() { + $scope.status = ''; + $('#authorizeEmailModal').modal('hide'); + }; + $scope.getHelpUrl = function(field, config) { var helpUrl = field['help_url']; if (!helpUrl) { @@ -155,21 +157,9 @@ angular.module('quay').directive('createExternalNotificationDialog', function () return StringBuilderService.buildUrl(helpUrl, config); }; - $scope.$watch('counter', function(counter) { - if (counter) { - $scope.clearCounter++; - $scope.status = ''; - $scope.currentEvent = null; - $scope.currentMethod = null; - $scope.unauthorizedEmail = false; - - $timeout(function() { - if ($scope.defaultData && $scope.defaultData['currentEvent']) { - $scope.setEvent($scope.defaultData['currentEvent']); - } - }, 100); - - $('#createNotificationModal').modal({}); + $scope.$watch('defaultData', function(counter) { + if ($scope.defaultData && $scope.defaultData['currentEvent']) { + $scope.setEvent($scope.defaultData['currentEvent']); } }); } diff --git a/static/js/directives/ui/regex-editor.js b/static/js/directives/ui/regex-editor.js new file mode 100644 index 000000000..4b3358c49 --- /dev/null +++ b/static/js/directives/ui/regex-editor.js @@ -0,0 +1,39 @@ +/** + * An element which displays an edit box for regular expressions. + */ +angular.module('quay').directive('regexEditor', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/regex-editor.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'placeholder': '@placeholder', + 'optional': '=optional', + 'binding': '=binding' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); + +angular.module('quay').directive('requireValidRegex', function() { + return { + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + function validator(value) { + try { + new RegExp(value) + ctrl.$setValidity('regex', true); + } catch (e) { + ctrl.$setValidity('regex', false); + } + return value; + } + + ctrl.$parsers.push(validator); + } + }; +}); \ No newline at end of file diff --git a/static/js/directives/ui/repository-events-table.js b/static/js/directives/ui/repository-events-table.js index 8aca804b6..02c94ba73 100644 --- a/static/js/directives/ui/repository-events-table.js +++ b/static/js/directives/ui/repository-events-table.js @@ -53,14 +53,6 @@ angular.module('quay').directive('repositoryEventsTable', function () { loadNotifications(); - $scope.handleNotificationCreated = function(notification) { - $scope.notifications.push(notification); - }; - - $scope.askCreateNotification = function() { - $scope.showNewNotificationCounter++; - }; - $scope.findEnumValue = function(values, index) { var found = null; Object.keys(values).forEach(function(key) { diff --git a/static/js/pages/create-repository-notification.js b/static/js/pages/create-repository-notification.js new file mode 100644 index 000000000..7a02ca4d6 --- /dev/null +++ b/static/js/pages/create-repository-notification.js @@ -0,0 +1,33 @@ +(function() { + /** + * Create repository notification page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('create-repository-notification', 'create-repository-notification.html', CreateRepoNotificationCtrl, { + 'newLayout': true, + 'title': 'Create Repo Notification: {{ namespace }}/{{ name }}', + 'description': 'Create repository notification for repository {{ namespace }}/{{ name }}' + }) + }]); + + function CreateRepoNotificationCtrl($scope, $routeParams, $location, ApiService) { + $scope.namespace = $routeParams.namespace; + $scope.name = $routeParams.name; + + var loadRepository = function() { + var params = { + 'repository': $scope.namespace + '/' + $scope.name + }; + + $scope.repositoryResource = ApiService.getRepoAsResource(params).get(function(repo) { + $scope.repository = repo; + }); + }; + + loadRepository(); + + $scope.notificationCreated = function() { + $location.url('repository/' + $scope.namespace + '/' + $scope.name + '?tab=settings'); + }; + } +})(); \ No newline at end of file diff --git a/static/js/services/external-notification-data.js b/static/js/services/external-notification-data.js index 229667e8c..623f1445d 100644 --- a/static/js/services/external-notification-data.js +++ b/static/js/services/external-notification-data.js @@ -20,22 +20,66 @@ function(Config, Features, VulnerabilityService) { { 'id': 'build_queued', 'title': 'Dockerfile Build Queued', - 'icon': 'fa-tasks' + 'icon': 'fa-tasks', + 'fields': [ + { + 'name': 'ref-regex', + 'type': 'regex', + 'title': 'matching ref(s)', + 'help_text': 'An optional regular expression for matching the git branch or tag ' + + 'git ref. If left blank, the notification will fire for all builds.', + 'optional': true, + 'placeholder': '(refs/heads/somebranch)|(refs/tags/sometag)' + } + ] }, { 'id': 'build_start', 'title': 'Dockerfile Build Started', - 'icon': 'fa-circle-o-notch' + 'icon': 'fa-circle-o-notch', + 'fields': [ + { + 'name': 'ref-regex', + 'type': 'regex', + 'title': 'matching ref(s)', + 'help_text': 'An optional regular expression for matching the git branch or tag ' + + 'git ref. If left blank, the notification will fire for all builds.', + 'optional': true, + 'placeholder': '(refs/heads/somebranch)|(refs/tags/sometag)' + } + ] }, { 'id': 'build_success', 'title': 'Dockerfile Build Successfully Completed', - 'icon': 'fa-check-circle-o' + 'icon': 'fa-check-circle-o', + 'fields': [ + { + 'name': 'ref-regex', + 'type': 'regex', + 'title': 'matching ref(s)', + 'help_text': 'An optional regular expression for matching the git branch or tag ' + + 'git ref. If left blank, the notification will fire for all builds.', + 'optional': true, + 'placeholder': '(refs/heads/somebranch)|(refs/tags/sometag)' + } + ] }, { 'id': 'build_failure', 'title': 'Dockerfile Build Failed', - 'icon': 'fa-times-circle-o' + 'icon': 'fa-times-circle-o', + 'fields': [ + { + 'name': 'ref-regex', + 'type': 'regex', + 'title': 'matching ref(s)', + 'help_text': 'An optional regular expression for matching the git branch or tag ' + + 'git ref. If left blank, the notification will fire for all builds.', + 'optional': true, + 'placeholder': '(refs/heads/somebranch)|(refs/tags/sometag)' + } + ] }]; for (var i = 0; i < buildEvents.length; ++i) { @@ -52,8 +96,12 @@ function(Config, Features, VulnerabilityService) { { 'name': 'level', 'type': 'enum', - 'title': 'Minimum Priority Level', + 'title': 'minimum severity level', 'values': VulnerabilityService.LEVELS, + 'help_text': 'A vulnerability must have a severity of the chosen level (or higher) ' + + 'for this notification to fire. Defcon 1 is a special severity level ' + + 'manually tagged by the ' + Config.REGISTRY_TITLE_SHORT + ' team for ' + + 'above-critical issues', } ] }); @@ -68,7 +116,8 @@ function(Config, Features, VulnerabilityService) { { 'name': 'target', 'type': 'entity', - 'title': 'Recipient' + 'title': 'Recipient', + 'help_text': 'The ' + Config.REGISTRY_TITLE_SHORT + ' user to notify' } ] }, @@ -117,11 +166,11 @@ function(Config, Features, VulnerabilityService) { 'fields': [ { 'name': 'room_id', - 'type': 'regex', + 'type': 'pattern', 'title': 'Room ID #', - 'regex': '^[0-9]+$', + 'pattern': '^[0-9]+$', 'help_url': 'https://hipchat.com/admin/rooms', - 'regex_fail_message': 'We require the HipChat room number, not name.' + 'pattern_fail_message': 'We require the HipChat room number, not name.' }, { 'name': 'notification_token', @@ -138,9 +187,9 @@ function(Config, Features, VulnerabilityService) { 'fields': [ { 'name': 'url', - 'type': 'regex', + 'type': 'pattern', 'title': 'Webhook URL', - 'regex': '^https://hooks\\.slack\\.com/services/[A-Z0-9]+/[A-Z0-9]+/[a-zA-Z0-9]+$', + 'pattern': '^https://hooks\\.slack\\.com/services/[A-Z0-9]+/[A-Z0-9]+/[a-zA-Z0-9]+$', 'help_url': 'https://slack.com/services/new/incoming-webhook', 'placeholder': 'https://hooks.slack.com/service/{some}/{token}/{here}' } diff --git a/static/partials/create-repository-notification.html b/static/partials/create-repository-notification.html new file mode 100644 index 000000000..70ab559cc --- /dev/null +++ b/static/partials/create-repository-notification.html @@ -0,0 +1,21 @@ +
+
+
+ + + {{ namespace }} / {{ name }} + + + + + Create repository notification + +
+ +
+
+
+
+
diff --git a/test/test_notifications.py b/test/test_notifications.py new file mode 100644 index 000000000..3da728912 --- /dev/null +++ b/test/test_notifications.py @@ -0,0 +1,135 @@ +import unittest + +from endpoints.notificationevent import BuildSuccessEvent +from util.morecollections import AttrDict + +class TestShouldPerform(unittest.TestCase): + def test_build_nofilter(self): + notification_data = AttrDict({ + 'event_config_json': '{}', + }) + + # No build data at all. + self.assertTrue(BuildSuccessEvent().should_perform({}, notification_data)) + + # With trigger metadata but no ref. + self.assertTrue(BuildSuccessEvent().should_perform({ + 'trigger_metadata': {}, + }, notification_data)) + + # With trigger metadata and a ref. + self.assertTrue(BuildSuccessEvent().should_perform({ + 'trigger_metadata': { + 'ref': 'refs/heads/somebranch', + }, + }, notification_data)) + + + def test_build_emptyfilter(self): + notification_data = AttrDict({ + 'event_config_json': '{"ref-regex": ""}', + }) + + # No build data at all. + self.assertTrue(BuildSuccessEvent().should_perform({}, notification_data)) + + # With trigger metadata but no ref. + self.assertTrue(BuildSuccessEvent().should_perform({ + 'trigger_metadata': {}, + }, notification_data)) + + # With trigger metadata and a ref. + self.assertTrue(BuildSuccessEvent().should_perform({ + 'trigger_metadata': { + 'ref': 'refs/heads/somebranch', + }, + }, notification_data)) + + + def test_build_invalidfilter(self): + notification_data = AttrDict({ + 'event_config_json': '{"ref-regex": "]["}', + }) + + # No build data at all. + self.assertFalse(BuildSuccessEvent().should_perform({}, notification_data)) + + # With trigger metadata but no ref. + self.assertFalse(BuildSuccessEvent().should_perform({ + 'trigger_metadata': {}, + }, notification_data)) + + # With trigger metadata and a ref. + self.assertFalse(BuildSuccessEvent().should_perform({ + 'trigger_metadata': { + 'ref': 'refs/heads/somebranch', + }, + }, notification_data)) + + + def test_build_withfilter(self): + notification_data = AttrDict({ + 'event_config_json': '{"ref-regex": "refs/heads/master"}', + }) + + # No build data at all. + self.assertFalse(BuildSuccessEvent().should_perform({}, notification_data)) + + # With trigger metadata but no ref. + self.assertFalse(BuildSuccessEvent().should_perform({ + 'trigger_metadata': {}, + }, notification_data)) + + # With trigger metadata and a not-matching ref. + self.assertFalse(BuildSuccessEvent().should_perform({ + 'trigger_metadata': { + 'ref': 'refs/heads/somebranch', + }, + }, notification_data)) + + # With trigger metadata and a matching ref. + self.assertTrue(BuildSuccessEvent().should_perform({ + 'trigger_metadata': { + 'ref': 'refs/heads/master', + }, + }, notification_data)) + + + def test_build_withwildcardfilter(self): + notification_data = AttrDict({ + 'event_config_json': '{"ref-regex": "refs/heads/.+"}', + }) + + # No build data at all. + self.assertFalse(BuildSuccessEvent().should_perform({}, notification_data)) + + # With trigger metadata but no ref. + self.assertFalse(BuildSuccessEvent().should_perform({ + 'trigger_metadata': {}, + }, notification_data)) + + # With trigger metadata and a not-matching ref. + self.assertFalse(BuildSuccessEvent().should_perform({ + 'trigger_metadata': { + 'ref': 'refs/tags/sometag', + }, + }, notification_data)) + + # With trigger metadata and a matching ref. + self.assertTrue(BuildSuccessEvent().should_perform({ + 'trigger_metadata': { + 'ref': 'refs/heads/master', + }, + }, notification_data)) + + # With trigger metadata and another matching ref. + self.assertTrue(BuildSuccessEvent().should_perform({ + 'trigger_metadata': { + 'ref': 'refs/heads/somebranch', + }, + }, notification_data)) + + +if __name__ == '__main__': + unittest.main() +