blob: daf72cad320b389b3f4bf347a5a254b84c6be83f [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2019 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6"""Handlers to process alert notification messages."""
7from __future__ import print_function
8from __future__ import division
9from __future__ import absolute_import
10
11import itertools
12import logging
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020013import email.utils
Copybara854996b2021-09-07 19:36:02 +000014
15import settings
16from businesslogic import work_env
17from features import commitlogcommands
18from framework import framework_constants
19from framework import monorailcontext
20from framework import emailfmt
21from tracker import tracker_constants
22from tracker import tracker_helpers
23
24AlertEmailHeader = emailfmt.AlertEmailHeader
25
26
27def IsAllowlisted(email_addr):
28 """Returns whether a given email is from one of the allowlisted domains."""
29 return email_addr.endswith(settings.alert_allowlisted_suffixes)
30
31
32def IsCommentSizeReasonable(comment):
33 # type: str -> bool
34 """Returns whether a given comment string is a reasonable size."""
35 return len(comment) <= tracker_constants.MAX_COMMENT_CHARS
36
37
38def FindAlertIssue(services, cnxn, project_id, incident_label):
39 """Find the existing issue with the incident_label."""
40 if not incident_label:
41 return None
42
43 label_id = services.config.LookupLabelID(
44 cnxn, project_id, incident_label)
45 if not label_id:
46 return None
47
48 # If a new notification is sent with an existing incident ID, then it
49 # should be added as a new comment into the existing issue.
50 #
51 # If there are more than one issues with a given incident ID, then
52 # it's either
53 # - there is a bug in this module,
54 # - the issues were manually updated with the same incident ID, OR
55 # - an issue auto update program updated the issues with the same
56 # incident ID, which also sounds like a bug.
57 #
58 # In any cases, the latest issue should be used, whichever status it has.
59 # - The issue of an ongoing incident can be mistakenly closed by
60 # engineers.
61 # - A closed incident can be reopened, and, therefore, the issue also
62 # needs to be re-opened.
63 issue_ids = services.issue.GetIIDsByLabelIDs(
64 cnxn, [label_id], project_id, None)
65 issues = services.issue.GetIssues(cnxn, issue_ids)
66 if issues:
67 return max(issues, key=lambda issue: issue.modified_timestamp)
68 return None
69
70
71def GetAlertProperties(services, cnxn, project_id, incident_id, trooper_queue,
72 msg):
73 """Create a dict of issue property values for the alert to be created with.
74
75 Args:
76 cnxn: connection to SQL database.
77 project_id: the ID of the Monorail project, in which the alert should
78 be created in.
79 incident_id: string containing an optional unique incident used to
80 de-dupe alert issues.
81 trooper_queue: the label specifying the trooper queue to add an issue into.
82 msg: the email.Message object containing the alert notification.
83
84 Returns:
85 A dict of issue property values to be used for issue creation.
86 """
87 proj_config = services.config.GetProjectConfig(cnxn, project_id)
88 user_svc = services.user
89 known_labels = set(wkl.label.lower() for wkl in proj_config.well_known_labels)
90
91 props = dict(
92 owner_id=_GetOwnerID(user_svc, cnxn, msg.get(AlertEmailHeader.OWNER)),
93 cc_ids=_GetCCIDs(user_svc, cnxn, msg.get(AlertEmailHeader.CC)),
94 component_ids=_GetComponentIDs(
95 proj_config, msg.get(AlertEmailHeader.COMPONENT)),
96
97 # Props that are added as labels.
98 trooper_queue=(trooper_queue or 'Infra-Troopers-Alerts'),
99 incident_label=_GetIncidentLabel(incident_id),
100 priority=_GetPriority(known_labels, msg.get(AlertEmailHeader.PRIORITY)),
101 oses=_GetOSes(known_labels, msg.get(AlertEmailHeader.OS)),
102 issue_type=_GetIssueType(known_labels, msg.get(AlertEmailHeader.TYPE)),
103
104 field_values=[],
105 )
106
107 # Props that depend on other props.
108 props.update(
109 status=_GetStatus(proj_config, props['owner_id'],
110 msg.get(AlertEmailHeader.STATUS)),
111 labels=_GetLabels(msg.get(AlertEmailHeader.LABEL),
112 props['trooper_queue'], props['incident_label'],
113 props['priority'], props['issue_type'], props['oses']),
114 )
115
116 return props
117
118
119def ProcessEmailNotification(
120 services, cnxn, project, project_addr, from_addr, auth, subject, body,
121 incident_id, msg, trooper_queue=None):
122 # type: (...) -> None
123 """Process an alert notification email to create or update issues.""
124
125 Args:
126 cnxn: connection to SQL database.
127 project: Project PB for the project containing the issue.
128 project_addr: string email address the alert email was sent to.
129 from_addr: string email address of the user who sent the alert email
130 to our server.
131 auth: AuthData object with user_id and email address of the user who
132 will file the alert issue.
133 subject: the subject of the email message
134 body: the body text of the email message
135 incident_id: string containing an optional unique incident used to
136 de-dupe alert issues.
137 msg: the email.Message object that the notification was delivered via.
138 trooper_queue: the label specifying the trooper queue that the alert
139 notification was sent to. If not given, the notification is sent to
140 Infra-Troopers-Alerts.
141
142 Side-effect:
143 Creates an issue or issue comment, if no error was reported.
144 """
145 # Make sure the email address is allowlisted.
146 if not IsAllowlisted(from_addr):
147 logging.info('Unauthorized %s tried to send alert to %s',
148 from_addr, project_addr)
149 return
150
151 formatted_body = 'Filed by %s on behalf of %s\n\n%s' % (
152 auth.email, from_addr, body)
153 if not IsCommentSizeReasonable(formatted_body):
154 logging.info(
155 '%s tried to send an alert comment that is too long in %s', from_addr,
156 project_addr)
157 return
158
159 mc = monorailcontext.MonorailContext(services, auth=auth, cnxn=cnxn)
160 mc.LookupLoggedInUserPerms(project)
161 with work_env.WorkEnv(mc, services) as we:
162 alert_props = GetAlertProperties(
163 services, cnxn, project.project_id, incident_id, trooper_queue, msg)
164 alert_issue = FindAlertIssue(
165 services, cnxn, project.project_id, alert_props['incident_label'])
166
167 if alert_issue:
168 # Add a reply to the existing issue for this incident.
169 services.issue.CreateIssueComment(
170 cnxn, alert_issue, auth.user_id, formatted_body)
171 else:
172 # Create a new issue for this incident. To preserve previous behavior do
173 # not raise filter rule errors.
174 alert_issue, _ = we.CreateIssue(
175 project.project_id,
176 subject,
177 alert_props['status'],
178 alert_props['owner_id'],
179 alert_props['cc_ids'],
180 alert_props['labels'],
181 alert_props['field_values'],
182 alert_props['component_ids'],
183 formatted_body,
184 raise_filter_errors=False)
185
186 # Update issue using commands.
187 lines = body.strip().split('\n')
188 uia = commitlogcommands.UpdateIssueAction(alert_issue.local_id)
189 commands_found = uia.Parse(
190 cnxn, project.project_name, auth.user_id, lines,
191 services, strip_quoted_lines=True)
192
193 if commands_found:
194 uia.Run(mc, services)
195
196
197def _GetComponentIDs(proj_config, components):
198 comps = ['Infra']
199 if components:
200 components = components.strip()
201 if components:
202 comps = [c.strip() for c in components.split(',')]
203 return tracker_helpers.LookupComponentIDs(comps, proj_config)
204
205
206def _GetIncidentLabel(incident_id):
207 return 'Incident-Id-%s'.strip().lower() % incident_id if incident_id else ''
208
209
210def _GetLabels(custom_labels, trooper_queue, incident_label, priority,
211 issue_type, oses):
212 labels = set(['Restrict-View-Google'.lower()])
213 labels.update(
214 # Whitespaces in a label can cause UI rendering each of the words as
215 # a separate label.
216 ''.join(label.split()).lower() for label in itertools.chain(
217 custom_labels.split(',') if custom_labels else [],
218 [trooper_queue, incident_label, priority, issue_type],
219 oses)
220 if label
221 )
222 return list(labels)
223
224
225def _GetOwnerID(user_svc, cnxn, owner_email):
226 if owner_email:
227 owner_email = owner_email.strip()
228 if not owner_email:
229 return framework_constants.NO_USER_SPECIFIED
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200230 emails = [addr for _, addr in email.utils.getaddresses([owner_email])]
Copybara854996b2021-09-07 19:36:02 +0000231 return user_svc.LookupExistingUserIDs(
232 cnxn, emails).get(owner_email) or framework_constants.NO_USER_SPECIFIED
233
234
235def _GetCCIDs(user_svc, cnxn, cc_emails):
236 if cc_emails:
237 cc_emails = cc_emails.strip()
238 if not cc_emails:
239 return []
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200240 emails = [addr for _, addr in email.utils.getaddresses([cc_emails])]
Copybara854996b2021-09-07 19:36:02 +0000241 return [userID for _, userID
242 in user_svc.LookupExistingUserIDs(cnxn, emails).iteritems()
243 if userID is not None]
244
245
246def _GetPriority(known_labels, priority):
247 priority_label = ('Pri-%s' % priority).strip().lower()
248 if priority:
249 if priority_label in known_labels:
250 return priority_label
251 logging.info('invalid priority %s for alerts; default to pri-2', priority)
252
253 # XXX: what if 'Pri-2' doesn't exist in known_labels?
254 return 'pri-2'
255
256
257def _GetStatus(proj_config, owner_id, status):
258 # XXX: what if assigned and available are not in known_statuses?
259 if status:
260 status = status.strip().lower()
261 if owner_id:
262 # If there is an owner, the status must be 'Assigned'.
263 if status and status != 'assigned':
264 logging.info(
265 'invalid status %s for an alert with an owner; default to assigned',
266 status)
267 return 'assigned'
268
269 if status:
270 if tracker_helpers.MeansOpenInProject(status, proj_config):
271 return status
272 logging.info('invalid status %s for an alert; default to available', status)
273
274 return 'available'
275
276
277def _GetOSes(known_labels, oses):
278 if oses:
279 oses = oses.strip().lower()
280 if not oses:
281 return []
282
283 os_labels_to_lookup = {
284 ('os-%s' % os).strip() for os in oses.split(',') if os
285 }
286 os_labels_to_return = os_labels_to_lookup & known_labels
287 invalid_os_labels = os_labels_to_lookup - os_labels_to_return
288 if invalid_os_labels:
289 logging.info('invalid OSes %s', ','.join(invalid_os_labels))
290
291 return list(os_labels_to_return)
292
293
294def _GetIssueType(known_labels, issue_type):
295 if issue_type:
296 issue_type = issue_type.strip().lower()
297 if issue_type is None:
298 return None
299
300 issue_type_label = 'type-%s' % issue_type
301 if issue_type_label in known_labels:
302 return issue_type_label
303
304 logging.info('invalid type %s for an alert; default to None', issue_type)
305 return None