Initial webhook receiver

Change-Id: I4786b133f103a65a381e2b281224b0a9bc8d4182
Related-bug: PROD-24880 (PROD:24880)
diff --git a/sf_notifier/__init__.py b/sf_notifier/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/sf_notifier/__init__.py
diff --git a/sf_notifier/helpers.py b/sf_notifier/helpers.py
new file mode 100644
index 0000000..8d9d7d5
--- /dev/null
+++ b/sf_notifier/helpers.py
@@ -0,0 +1,35 @@
+# Copyright 2018: Mirantis Inc.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+
+RESOLVED_STATUSES = ('UP', 'OK', 'resolved')
+
+
+def alert_fields_and_action(alert):
+    fields = []
+    action = 'create_case'
+
+    if alert['status'] in RESOLVED_STATUSES:
+        fields.append(alert['labels'])
+        action = 'close_case'
+    else:
+        # Order matters
+        fields.append('[sf-notifier] {}'.format(
+            alert['annotations']['summary'])
+        )
+        fields.append(alert['annotations']['description'])
+        fields.append(alert['status'])
+        fields.append(alert['labels'])
+    return fields, action
diff --git a/sf_notifier/salesforce/__init__.py b/sf_notifier/salesforce/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/sf_notifier/salesforce/__init__.py
diff --git a/sf_notifier/salesforce/client.py b/sf_notifier/salesforce/client.py
new file mode 100644
index 0000000..84fc0b4
--- /dev/null
+++ b/sf_notifier/salesforce/client.py
@@ -0,0 +1,208 @@
+# Copyright 2018: Mirantis Inc.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import hashlib
+import logging
+import os
+import uuid
+
+import requests
+
+from simple_salesforce import Salesforce
+from simple_salesforce import exceptions as sf_exceptions
+
+
+STATE_MAP = {
+    'OK': '060 Informational',
+    'UP': '060 Informational',
+    'UNKNOWN': '070 Unknown',
+    'WARNING': '080 Warning',
+    'MINOR': '080 Warning',
+    'MAJOR': '090 Critical',
+    'CRITICAL': '090 Critical',
+    'DOWN': '090 Critical',
+    'UNREACHABLE': '090 Critical',
+}
+
+CONFIG_FIELD_MAP = {
+    'auth_url': 'instance',
+    'username': 'username',
+    'password': 'password',
+    'organization_id': 'organizationId',
+    'environment_id': 'environment_id',
+    'sandbox_enabled': 'domain',
+}
+
+logger = logging.getLogger(__name__)
+
+
+def sf_auth_retry(method):
+    def wrapper(self, *args, **kwargs):
+        try:
+            return method(self, *args, **kwargs)
+        except sf_exceptions.SalesforceExpiredSession:
+            logger.warning('Salesforce session expired.')
+            self._auth()
+        return method(self, *args, **kwargs)
+    return wrapper
+
+
+class SfNotifierError(Exception):
+    def __init__(self, message, errors):
+        super(SfNotifierError, self).__init__(message)
+        self.errors = errors
+
+
+class SalesforceClient(object):
+
+    def __init__(self, config):
+        self.session = requests.Session()
+        self.config = self._validate_config(config)
+        self.environment = self.config.pop('environment_id')
+        self._auth()
+        self._registered_alerts = {}
+
+    def _validate_config(self, config):
+        kwargs = {'session': self.session}
+
+        for param, field in CONFIG_FIELD_MAP.iteritems():
+            setting_var = param.upper()
+            env_var = 'SFDC_{}'.format(setting_var)
+            kwargs[field] = os.environ.get(
+                env_var, getattr(config, setting_var, None))
+
+            if field == 'domain':
+                kwargs[field] = kwargs[field] and 'test'
+                continue
+
+            if kwargs[field] is None:
+                msg = ('Invalid config: missing "{}" field or "{}" environment'
+                       ' variable.').format(param, env_var)
+                logger.error(msg)
+                raise SfNotifierError(msg)
+        return kwargs
+
+    def _auth(self):
+        self.sf = Salesforce(**self.config)
+        logger.info('Salesforce authentication successful.')
+
+    def _get_alert_id(self, labels):
+        alert_id_data = ''
+        for key in sorted(labels):
+            alert_id_data += labels[key].replace(".", "\\.")
+        return hashlib.sha256(alert_id_data).hexdigest()
+
+    @sf_auth_retry
+    def _create_case(self, subject, body, labels, alert_id):
+
+        if alert_id in self._registered_alerts:
+            logger.info('Duplicate case for alert: {}.'.format(alert_id))
+            return 1, self._registered_alerts[alert_id]['Id']
+
+        severity = labels.get('severity', 'unknown').upper()
+        payload = {
+            'Subject': subject,
+            'Description': body,
+            'IsMosAlert__c': 'true',
+            'Alert_Priority__c': STATE_MAP.get(severity, '070 Unknown'),
+            'Alert_Host__c': labels.get('host') or labels.get(
+                'instance', 'UNKNOWN'
+            ),
+            'Alert_Service__c': labels.get('service', 'UNKNOWN'),
+            'Environment2__c': self.environment,
+            'Alert_ID__c': alert_id,
+        }
+        logger.info('Try to create case: {}'.format(payload))
+        try:
+            case = self.sf.Case.create(payload)
+        except sf_exceptions.SalesforceMalformedRequest as ex:
+            msg = ex.content[0]['message']
+            err_code = ex.content[0]['errorCode']
+
+            if err_code == 'DUPLICATE_VALUE':
+                logger.info('Duplicate case: {}.'.format(msg))
+                case_id = msg.split()[-1]
+                self._registered_alerts[alert_id] = {'Id': case_id}
+                return 1, case_id
+            else:
+                raise
+
+        self._registered_alerts[alert_id] = {'Id': case['id']}
+        return 0, case['id']
+
+    @sf_auth_retry
+    def _get_case(self, case_id):
+        return self.sf.Case.get(case_id)
+
+    @sf_auth_retry
+    def _update_case(self, case_id, data):
+        return self.sf.Case.update(case_id, data)
+
+    @sf_auth_retry
+    def _close_case(self, case_id):
+        logger.info('Try to close case: {}.'.format(case_id))
+        update = self.sf.Case.update(
+            case_id,
+            {'Status': 'Auto-solved', 'Alert_ID__c': uuid.uuid4().hex}
+        )
+        logger.info('Closed case: {}.'.format(case_id))
+        return update
+
+    @sf_auth_retry
+    def _create_feed_item(self, subject, body, case_id):
+        feed_item = {'Title': subject, 'ParentId': case_id, 'Body': body}
+        return self.sf.FeedItem.create(feed_item)
+
+    @sf_auth_retry
+    def _get_case_by_alert_id(self, alert_id):
+        logger.info('Try to get case by alert ID: {}.'.format(alert_id))
+
+        if alert_id in self._registered_alerts:
+            return self._registered_alerts[alert_id]
+        try:
+            return self.sf.Case.get_by_custom_id('Alert_ID__c', alert_id)
+        except sf_exceptions.SalesforceResourceNotFound:
+            logger.warning('Alert ID: {} was already solved.'.format(alert_id))
+
+    def create_case(self, subject, body, status, labels):
+        alert_id = self._get_alert_id(labels)
+
+        error_code, case_id = self._create_case(subject, body,
+                                                labels, alert_id)
+
+        response = {'case_id': case_id, 'alert_id': alert_id,
+                    'status': 'created'}
+
+        if error_code != 2:
+            self._create_feed_item(subject, body, case_id)
+        if error_code == 1:
+            response['status'] = 'duplicate'
+        return response
+
+    def close_case(self, labels):
+        alert_id = self._get_alert_id(labels)
+        case = self._get_case_by_alert_id(alert_id)
+
+        response = {'alert_id': alert_id, 'status': 'resolved'}
+
+        if case is None:
+            return response
+
+        if self._registered_alerts.get(alert_id):
+            del self._registered_alerts[alert_id]
+
+        response['case_id'] = case['Id']
+        response['closed'] = self._close_case(case['Id'])
+        return response
diff --git a/sf_notifier/server.py b/sf_notifier/server.py
new file mode 100644
index 0000000..182f55d
--- /dev/null
+++ b/sf_notifier/server.py
@@ -0,0 +1,78 @@
+# Copyright 2018: Mirantis Inc.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import json
+from logging.config import dictConfig
+
+from flask import Flask, Response, jsonify, request
+
+from sf_notifier.helpers import alert_fields_and_action
+from sf_notifier.salesforce.client import SalesforceClient
+
+from simple_salesforce.exceptions import SalesforceMalformedRequest
+
+from simple_settings import settings
+
+
+dictConfig(settings.LOGGING)
+
+app = Flask('__name__')
+sf_cli = SalesforceClient(settings.SF_CONFIG)
+
+
+@app.route('/health', methods=['GET'])
+def health():
+    app.logger.info('Health: OK!')
+    return 'OK!'
+
+
+@app.route('/hook', methods=['POST'])
+def webhook_receiver():
+
+    try:
+        data = json.loads(request.data)
+    except ValueError:
+        return Response(json.dumps({'error': 'Invalid request data.'}),
+                        status=400,
+                        mimetype='application/json')
+
+    app.logger.info('Received requests: {}'.format(data))
+
+    cases = []
+    for alert in data['alerts']:
+        try:
+            fields, action = alert_fields_and_action(alert)
+        except KeyError as key:
+            msg = 'Alert misses {} key.'.format(key)
+            app.logger.error(msg)
+            return Response(json.dumps({'error': msg}),
+                            status=400,
+                            mimetype='application/json')
+
+        if fields:
+            try:
+                cases.append(getattr(sf_cli, action)(*fields))
+            except SalesforceMalformedRequest:
+                return Response(json.dumps({'error': 'Request failure.'}),
+                                status=500,
+                                mimetype='application/json')
+
+    if len(cases) == 1:
+        return jsonify(cases[0])
+    return jsonify(cases)
+
+
+if __name__ == '__main__':
+    app.run()
diff --git a/sf_notifier/settings/__init__.py b/sf_notifier/settings/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/sf_notifier/settings/__init__.py
diff --git a/sf_notifier/settings/development.py b/sf_notifier/settings/development.py
new file mode 100644
index 0000000..39e7006
--- /dev/null
+++ b/sf_notifier/settings/development.py
@@ -0,0 +1,28 @@
+LOGGING = {
+    'version': 1,
+    'formatters': {'default': {
+        'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
+    }},
+    'handlers': {'wsgi': {
+        'class': 'logging.StreamHandler',
+        'stream': 'ext://flask.logging.wsgi_errors_stream',
+        'formatter': 'default'
+    }},
+    'loggers': {
+        'sf_notifier.server': {
+            'level': 'INFO',
+            'handlers': ['wsgi']
+        },
+        'sf_notifier.salesforce.client': {
+            'level': 'INFO',
+            'handlers': ['wsgi']
+        }
+    }
+}
+
+SIMPLE_SETTINGS = {
+    'OVERRIDE_BY_ENV': True,
+    'CONFIGURE_LOGGING': True,
+}
+
+SF_CONFIG = {}
diff --git a/sf_notifier/vars/development b/sf_notifier/vars/development
new file mode 100644
index 0000000..0792fb3
--- /dev/null
+++ b/sf_notifier/vars/development
@@ -0,0 +1,4 @@
+export FLASK_APP=sf_notifier/server.py
+export FLASK_ENV=development
+
+export SIMPLE_SETTINGS=sf_notifier.settings.development