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