| # Copyright 2019 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Handlers to process alert notification messages.""" |
| from __future__ import print_function |
| from __future__ import division |
| from __future__ import absolute_import |
| |
| import itertools |
| import logging |
| import email.utils |
| |
| import settings |
| from businesslogic import work_env |
| from features import commitlogcommands |
| from framework import framework_constants |
| from framework import monorailcontext |
| from framework import emailfmt |
| from tracker import tracker_constants |
| from tracker import tracker_helpers |
| |
| AlertEmailHeader = emailfmt.AlertEmailHeader |
| |
| |
| def IsAllowlisted(email_addr): |
| """Returns whether a given email is from one of the allowlisted domains.""" |
| return email_addr.endswith(settings.alert_allowlisted_suffixes) |
| |
| |
| def IsCommentSizeReasonable(comment): |
| # type: str -> bool |
| """Returns whether a given comment string is a reasonable size.""" |
| return len(comment) <= tracker_constants.MAX_COMMENT_CHARS |
| |
| |
| def FindAlertIssue(services, cnxn, project_id, incident_label): |
| """Find the existing issue with the incident_label.""" |
| if not incident_label: |
| return None |
| |
| label_id = services.config.LookupLabelID( |
| cnxn, project_id, incident_label) |
| if not label_id: |
| return None |
| |
| # If a new notification is sent with an existing incident ID, then it |
| # should be added as a new comment into the existing issue. |
| # |
| # If there are more than one issues with a given incident ID, then |
| # it's either |
| # - there is a bug in this module, |
| # - the issues were manually updated with the same incident ID, OR |
| # - an issue auto update program updated the issues with the same |
| # incident ID, which also sounds like a bug. |
| # |
| # In any cases, the latest issue should be used, whichever status it has. |
| # - The issue of an ongoing incident can be mistakenly closed by |
| # engineers. |
| # - A closed incident can be reopened, and, therefore, the issue also |
| # needs to be re-opened. |
| issue_ids = services.issue.GetIIDsByLabelIDs( |
| cnxn, [label_id], project_id, None) |
| issues = services.issue.GetIssues(cnxn, issue_ids) |
| if issues: |
| return max(issues, key=lambda issue: issue.modified_timestamp) |
| return None |
| |
| |
| def GetAlertProperties(services, cnxn, project_id, incident_id, trooper_queue, |
| msg): |
| """Create a dict of issue property values for the alert to be created with. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: the ID of the Monorail project, in which the alert should |
| be created in. |
| incident_id: string containing an optional unique incident used to |
| de-dupe alert issues. |
| trooper_queue: the label specifying the trooper queue to add an issue into. |
| msg: the email.Message object containing the alert notification. |
| |
| Returns: |
| A dict of issue property values to be used for issue creation. |
| """ |
| proj_config = services.config.GetProjectConfig(cnxn, project_id) |
| user_svc = services.user |
| known_labels = set(wkl.label.lower() for wkl in proj_config.well_known_labels) |
| |
| props = dict( |
| owner_id=_GetOwnerID(user_svc, cnxn, msg.get(AlertEmailHeader.OWNER)), |
| cc_ids=_GetCCIDs(user_svc, cnxn, msg.get(AlertEmailHeader.CC)), |
| component_ids=_GetComponentIDs( |
| proj_config, msg.get(AlertEmailHeader.COMPONENT)), |
| |
| # Props that are added as labels. |
| trooper_queue=(trooper_queue or 'Infra-Troopers-Alerts'), |
| incident_label=_GetIncidentLabel(incident_id), |
| priority=_GetPriority(known_labels, msg.get(AlertEmailHeader.PRIORITY)), |
| oses=_GetOSes(known_labels, msg.get(AlertEmailHeader.OS)), |
| issue_type=_GetIssueType(known_labels, msg.get(AlertEmailHeader.TYPE)), |
| |
| field_values=[], |
| ) |
| |
| # Props that depend on other props. |
| props.update( |
| status=_GetStatus(proj_config, props['owner_id'], |
| msg.get(AlertEmailHeader.STATUS)), |
| labels=_GetLabels(msg.get(AlertEmailHeader.LABEL), |
| props['trooper_queue'], props['incident_label'], |
| props['priority'], props['issue_type'], props['oses']), |
| ) |
| |
| return props |
| |
| |
| def ProcessEmailNotification( |
| services, cnxn, project, project_addr, from_addr, auth, subject, body, |
| incident_id, msg, trooper_queue=None): |
| # type: (...) -> None |
| """Process an alert notification email to create or update issues."" |
| |
| Args: |
| cnxn: connection to SQL database. |
| project: Project PB for the project containing the issue. |
| project_addr: string email address the alert email was sent to. |
| from_addr: string email address of the user who sent the alert email |
| to our server. |
| auth: AuthData object with user_id and email address of the user who |
| will file the alert issue. |
| subject: the subject of the email message |
| body: the body text of the email message |
| incident_id: string containing an optional unique incident used to |
| de-dupe alert issues. |
| msg: the email.Message object that the notification was delivered via. |
| trooper_queue: the label specifying the trooper queue that the alert |
| notification was sent to. If not given, the notification is sent to |
| Infra-Troopers-Alerts. |
| |
| Side-effect: |
| Creates an issue or issue comment, if no error was reported. |
| """ |
| # Make sure the email address is allowlisted. |
| if not IsAllowlisted(from_addr): |
| logging.info('Unauthorized %s tried to send alert to %s', |
| from_addr, project_addr) |
| return |
| |
| formatted_body = 'Filed by %s on behalf of %s\n\n%s' % ( |
| auth.email, from_addr, body) |
| if not IsCommentSizeReasonable(formatted_body): |
| logging.info( |
| '%s tried to send an alert comment that is too long in %s', from_addr, |
| project_addr) |
| return |
| |
| mc = monorailcontext.MonorailContext(services, auth=auth, cnxn=cnxn) |
| mc.LookupLoggedInUserPerms(project) |
| with work_env.WorkEnv(mc, services) as we: |
| alert_props = GetAlertProperties( |
| services, cnxn, project.project_id, incident_id, trooper_queue, msg) |
| alert_issue = FindAlertIssue( |
| services, cnxn, project.project_id, alert_props['incident_label']) |
| |
| if alert_issue: |
| # Add a reply to the existing issue for this incident. |
| services.issue.CreateIssueComment( |
| cnxn, alert_issue, auth.user_id, formatted_body) |
| else: |
| # Create a new issue for this incident. To preserve previous behavior do |
| # not raise filter rule errors. |
| alert_issue, _ = we.CreateIssue( |
| project.project_id, |
| subject, |
| alert_props['status'], |
| alert_props['owner_id'], |
| alert_props['cc_ids'], |
| alert_props['labels'], |
| alert_props['field_values'], |
| alert_props['component_ids'], |
| formatted_body, |
| raise_filter_errors=False) |
| |
| # Update issue using commands. |
| lines = body.strip().split('\n') |
| uia = commitlogcommands.UpdateIssueAction(alert_issue.local_id) |
| commands_found = uia.Parse( |
| cnxn, project.project_name, auth.user_id, lines, |
| services, strip_quoted_lines=True) |
| |
| if commands_found: |
| uia.Run(mc, services) |
| |
| |
| def _GetComponentIDs(proj_config, components): |
| comps = ['Infra'] |
| if components: |
| components = components.strip() |
| if components: |
| comps = [c.strip() for c in components.split(',')] |
| return tracker_helpers.LookupComponentIDs(comps, proj_config) |
| |
| |
| def _GetIncidentLabel(incident_id): |
| return 'Incident-Id-%s'.strip().lower() % incident_id if incident_id else '' |
| |
| |
| def _GetLabels(custom_labels, trooper_queue, incident_label, priority, |
| issue_type, oses): |
| labels = set(['Restrict-View-Google'.lower()]) |
| labels.update( |
| # Whitespaces in a label can cause UI rendering each of the words as |
| # a separate label. |
| ''.join(label.split()).lower() for label in itertools.chain( |
| custom_labels.split(',') if custom_labels else [], |
| [trooper_queue, incident_label, priority, issue_type], |
| oses) |
| if label |
| ) |
| return list(labels) |
| |
| |
| def _GetOwnerID(user_svc, cnxn, owner_email): |
| if owner_email: |
| owner_email = owner_email.strip() |
| if not owner_email: |
| return framework_constants.NO_USER_SPECIFIED |
| emails = [addr for _, addr in email.utils.getaddresses([owner_email])] |
| return user_svc.LookupExistingUserIDs( |
| cnxn, emails).get(owner_email) or framework_constants.NO_USER_SPECIFIED |
| |
| |
| def _GetCCIDs(user_svc, cnxn, cc_emails): |
| if cc_emails: |
| cc_emails = cc_emails.strip() |
| if not cc_emails: |
| return [] |
| emails = [addr for _, addr in email.utils.getaddresses([cc_emails])] |
| return [ |
| userID |
| for _, userID in user_svc.LookupExistingUserIDs(cnxn, emails).items() |
| if userID is not None |
| ] |
| |
| |
| def _GetPriority(known_labels, priority): |
| priority_label = ('Pri-%s' % priority).strip().lower() |
| if priority: |
| if priority_label in known_labels: |
| return priority_label |
| logging.info('invalid priority %s for alerts; default to pri-2', priority) |
| |
| # XXX: what if 'Pri-2' doesn't exist in known_labels? |
| return 'pri-2' |
| |
| |
| def _GetStatus(proj_config, owner_id, status): |
| # XXX: what if assigned and available are not in known_statuses? |
| if status: |
| status = status.strip().lower() |
| if owner_id: |
| # If there is an owner, the status must be 'Assigned'. |
| if status and status != 'assigned': |
| logging.info( |
| 'invalid status %s for an alert with an owner; default to assigned', |
| status) |
| return 'assigned' |
| |
| if status: |
| if tracker_helpers.MeansOpenInProject(status, proj_config): |
| return status |
| logging.info('invalid status %s for an alert; default to available', status) |
| |
| return 'available' |
| |
| |
| def _GetOSes(known_labels, oses): |
| if oses: |
| oses = oses.strip().lower() |
| if not oses: |
| return [] |
| |
| os_labels_to_lookup = { |
| ('os-%s' % os).strip() for os in oses.split(',') if os |
| } |
| os_labels_to_return = os_labels_to_lookup & known_labels |
| invalid_os_labels = os_labels_to_lookup - os_labels_to_return |
| if invalid_os_labels: |
| logging.info('invalid OSes %s', ','.join(invalid_os_labels)) |
| |
| return list(os_labels_to_return) |
| |
| |
| def _GetIssueType(known_labels, issue_type): |
| if issue_type: |
| issue_type = issue_type.strip().lower() |
| if issue_type is None: |
| return None |
| |
| issue_type_label = 'type-%s' % issue_type |
| if issue_type_label in known_labels: |
| return issue_type_label |
| |
| logging.info('invalid type %s for an alert; default to None', issue_type) |
| return None |