Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | # 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.""" |
| 7 | from __future__ import print_function |
| 8 | from __future__ import division |
| 9 | from __future__ import absolute_import |
| 10 | |
| 11 | import itertools |
| 12 | import logging |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 13 | import email.utils |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 14 | |
| 15 | import settings |
| 16 | from businesslogic import work_env |
| 17 | from features import commitlogcommands |
| 18 | from framework import framework_constants |
| 19 | from framework import monorailcontext |
| 20 | from framework import emailfmt |
| 21 | from tracker import tracker_constants |
| 22 | from tracker import tracker_helpers |
| 23 | |
| 24 | AlertEmailHeader = emailfmt.AlertEmailHeader |
| 25 | |
| 26 | |
| 27 | def 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 | |
| 32 | def 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 | |
| 38 | def 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 | |
| 71 | def 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 | |
| 119 | def 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 | |
| 197 | def _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 | |
| 206 | def _GetIncidentLabel(incident_id): |
| 207 | return 'Incident-Id-%s'.strip().lower() % incident_id if incident_id else '' |
| 208 | |
| 209 | |
| 210 | def _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 | |
| 225 | def _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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 230 | emails = [addr for _, addr in email.utils.getaddresses([owner_email])] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 231 | return user_svc.LookupExistingUserIDs( |
| 232 | cnxn, emails).get(owner_email) or framework_constants.NO_USER_SPECIFIED |
| 233 | |
| 234 | |
| 235 | def _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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 240 | emails = [addr for _, addr in email.utils.getaddresses([cc_emails])] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 241 | return [userID for _, userID |
| 242 | in user_svc.LookupExistingUserIDs(cnxn, emails).iteritems() |
| 243 | if userID is not None] |
| 244 | |
| 245 | |
| 246 | def _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 | |
| 257 | def _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 | |
| 277 | def _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 | |
| 294 | def _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 |