Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1 | # 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. |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 4 | |
| 5 | """Handlers to process alert notification messages.""" |
| 6 | from __future__ import print_function |
| 7 | from __future__ import division |
| 8 | from __future__ import absolute_import |
| 9 | |
| 10 | import itertools |
| 11 | import logging |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 12 | import email.utils |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 13 | |
| 14 | import settings |
| 15 | from businesslogic import work_env |
| 16 | from features import commitlogcommands |
| 17 | from framework import framework_constants |
| 18 | from framework import monorailcontext |
| 19 | from framework import emailfmt |
| 20 | from tracker import tracker_constants |
| 21 | from tracker import tracker_helpers |
| 22 | |
| 23 | AlertEmailHeader = emailfmt.AlertEmailHeader |
| 24 | |
| 25 | |
| 26 | def 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 | |
| 31 | def 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 | |
| 37 | def 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 | |
| 70 | def 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 | |
| 118 | def 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 | |
| 196 | def _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 | |
| 205 | def _GetIncidentLabel(incident_id): |
| 206 | return 'Incident-Id-%s'.strip().lower() % incident_id if incident_id else '' |
| 207 | |
| 208 | |
| 209 | def _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 | |
| 224 | def _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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 229 | emails = [addr for _, addr in email.utils.getaddresses([owner_email])] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 230 | return user_svc.LookupExistingUserIDs( |
| 231 | cnxn, emails).get(owner_email) or framework_constants.NO_USER_SPECIFIED |
| 232 | |
| 233 | |
| 234 | def _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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 239 | emails = [addr for _, addr in email.utils.getaddresses([cc_emails])] |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 240 | return [ |
| 241 | userID |
| 242 | for _, userID in user_svc.LookupExistingUserIDs(cnxn, emails).items() |
| 243 | if userID is not None |
| 244 | ] |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 245 | |
| 246 | |
| 247 | def _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 | |
| 258 | def _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 | |
| 278 | def _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 | |
| 295 | def _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 |