├── requirements.txt ├── README.md └── main.py /requirements.txt: -------------------------------------------------------------------------------- 1 | uWSGI 2 | Click 3 | Flask 4 | Jira 5 | prometheus_client 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jiralerts 2 | 3 | This is a basic JIRA integration for Alertmanager. It receives Alertmanager webhook messages 4 | and files labeled issues for it. If an alert stops firing or starts firing again, tickets 5 | are closed or reopened. 6 | 7 | Given how generic JIRA is, the integration attempts several different transitions 8 | that may be available for an issue. 9 | 10 | __Consider this an opinionated example snippet. It may not fit your use case without modification.__ 11 | 12 | ## Running it 13 | 14 | ``` 15 | JIRA_USERNAME= JIRA_PASSWORD= ./main.py 'https://' 16 | ``` 17 | 18 | In your Alertmanager receiver configurations: 19 | 20 | ```yaml 21 | receivers: 22 | - name: 'jira_issues' 23 | webhook_configs: 24 | - url: 'http:////' 25 | ``` 26 | 27 | A typical usage could be a single 'ALERTS' projects where the label in the URL 28 | refers to the affected system or the team that should handle the issue. 29 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | 6 | import click 7 | from flask import Flask, request, make_response 8 | from jira import JIRA 9 | from jinja2 import Template 10 | import prometheus_client as prometheus 11 | 12 | app = Flask(__name__) 13 | 14 | jira = None 15 | 16 | summary_tmpl = Template(r'{% if commonAnnotations.summary %}{{ commonAnnotations.summary }}{% else %}{% for k, v in groupLabels.items() %}{{ k }}="{{v}}" {% endfor %}{% endif %}') 17 | 18 | description_tmpl = Template(r''' 19 | h2. Common information 20 | 21 | {% for k, v in commonAnnotations.items() -%} 22 | * *{{ k }}*: {{ v }} 23 | {% endfor %} 24 | 25 | h2. Active alerts 26 | 27 | {% for a in alerts if a.status == 'firing' -%} 28 | _Annotations_: 29 | {% for k, v in a.annotations.items() -%} 30 | * {{ k }} = {{ v }} 31 | {% endfor %} 32 | _Labels_: 33 | {% for k, v in a.labels.items() -%} 34 | * {{ k }} = {{ v }} 35 | {% endfor %} 36 | [Source|{{ a.generatorURL }}] 37 | ---- 38 | {% endfor %} 39 | 40 | 41 | alert_group_key={{ groupKey }} 42 | ''') 43 | 44 | description_boundary = '_-- Alertmanager -- [only edit above]_' 45 | 46 | # Order for the search query is important for the query performance. It relies 47 | # on the 'alert_group_key' field in the description that must not be modified. 48 | search_query = 'project = %s and labels = "alert" and description ~ "alert_group_key=%s"' 49 | 50 | jira_request_time = prometheus.Histogram('jira_request_latency_seconds', 'Latency when querying the JIRA API', ['action']) 51 | request_time = prometheus.Histogram('request_latency_seconds', 'Latency of incoming requests') 52 | 53 | jira_request_time_transitions = jira_request_time.labels({'action': 'transitions'}) 54 | jira_request_time_close = jira_request_time.labels({'action': 'close'}) 55 | jira_request_time_reopen = jira_request_time.labels({'action': 'reopen'}) 56 | jira_request_time_update = jira_request_time.labels({'action': 'update'}) 57 | jira_request_time_create = jira_request_time.labels({'action': 'create'}) 58 | 59 | @jira_request_time_transitions.time() 60 | def transitions(issue): 61 | return jira.transitions(issue) 62 | 63 | @jira_request_time_close.time() 64 | def close(issue, tid): 65 | return jira.transition_issue(issue, tid) 66 | 67 | @jira_request_time_reopen.time() 68 | def reopen(issue, tid): 69 | return jira.transition_issue(issue, trans['reopen']) 70 | 71 | @jira_request_time_update.time() 72 | def update_issue(issue, summary, description): 73 | custom_desc = issue.fields.description.rsplit(description_boundary, 1)[0] 74 | return issue.update( 75 | summary=summary, 76 | description="%s\n\n%s\n%s" % (custom_desc.strip(), description_boundary, description)) 77 | 78 | @jira_request_time_create.time() 79 | def create_issue(project, team, summary, description): 80 | return jira.create_issue({ 81 | 'project': {'key': project}, 82 | 'summary': summary, 83 | 'description': "%s\n\n%s" % (description_boundary, description), 84 | 'issuetype': {'name': 'Task'}, 85 | 'labels': ['alert', team], 86 | }) 87 | 88 | @app.route('/-/health') 89 | def health(): 90 | return "OK", 200 91 | 92 | @request_time.time() 93 | @app.route('/issues//', methods=['POST']) 94 | def file_issue(project, team): 95 | """ 96 | This endpoint accepts a JSON encoded notification according to the version 3 97 | of the generic webhook of the Prometheus Alertmanager. 98 | """ 99 | data = request.get_json() 100 | if data['version'] != "3": 101 | return "unknown message version %s" % data['version'], 400 102 | 103 | resolved = data['status'] == "resolved" 104 | description = description_tmpl.render(data) 105 | summary = summary_tmpl.render(data) 106 | 107 | # If there's already a ticket for the incident, update it and reopen/close if necessary. 108 | result = jira.search_issues(search_query % (project, data['groupKey'])) 109 | if result: 110 | issue = result[0] 111 | 112 | # We have to check the available transitions for the issue. These differ 113 | # between boards depending on setup. 114 | trans = {} 115 | for t in transitions(issue): 116 | trans[t['name'].lower()] = t['id'] 117 | 118 | # Try different possible transitions for resolved incidents 119 | # in order of preference. Different ones may work for different boards. 120 | if resolved: 121 | for t in ["resolved", "closed", "done", "complete"]: 122 | if t in trans: 123 | close(issue, trans[t]) 124 | break 125 | # For issues that are closed by one of the status definitions below, reopen them. 126 | elif issue.fields.status.name.lower() in ["resolved", "closed", "done", "complete"]: 127 | for t in trans: 128 | if t in ['reopen', 'open']: 129 | reopen(issue, trans[t]) 130 | break 131 | 132 | # Update the base information regardless of the transition. 133 | update_issue(issue, summary, description) 134 | 135 | # Do not create an issue for resolved incidents that were never filed. 136 | elif not resolved: 137 | create_issue(project, team, summary, description) 138 | 139 | return "", 200 140 | 141 | 142 | @app.route('/metrics') 143 | def metrics(): 144 | resp = make_response(prometheus.generate_latest(prometheus.core.REGISTRY)) 145 | resp.headers['Content-Type'] = prometheus.CONTENT_TYPE_LATEST 146 | return resp, 200 147 | 148 | 149 | @click.command() 150 | @click.option('--host', help='Host listen address') 151 | @click.option('--port', '-p', default=9050, help='Listen port for the webhook') 152 | @click.option('--debug', '-d', default=False, is_flag=True, help='Enable debug mode') 153 | @click.argument('server') 154 | def main(host, port, server, debug): 155 | global jira 156 | 157 | username = os.environ.get('JIRA_USERNAME') 158 | password = os.environ.get('JIRA_PASSWORD') 159 | 160 | if not username or not password: 161 | print("JIRA_USERNAME or JIRA_PASSWORD not set") 162 | sys.exit(2) 163 | 164 | jira = JIRA(basic_auth=(username, password), server=server, logging=debug) 165 | app.run(host=host, port=port, debug=debug) 166 | 167 | if __name__ == "__main__": 168 | main() 169 | 170 | --------------------------------------------------------------------------------