blob: daf72cad320b389b3f4bf347a5a254b84c6be83f [file] [log] [blame]
# Copyright 2019 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file or at
"""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,
"""Create a dict of issue property values for the alert to be created with.
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.
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)),
proj_config, msg.get(AlertEmailHeader.COMPONENT)),
# Props that are added as labels.
trooper_queue=(trooper_queue or 'Infra-Troopers-Alerts'),
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)),
# Props that depend on other props.
status=_GetStatus(proj_config, props['owner_id'],
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.""
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
Creates an issue or issue comment, if no error was reported.
# Make sure the email address is allowlisted.
if not IsAllowlisted(from_addr):'Unauthorized %s tried to send alert to %s',
from_addr, project_addr)
formatted_body = 'Filed by %s on behalf of %s\n\n%s' % (, from_addr, body)
if not IsCommentSizeReasonable(formatted_body):
'%s tried to send an alert comment that is too long in %s', from_addr,
mc = monorailcontext.MonorailContext(services, auth=auth, cnxn=cnxn)
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.
cnxn, alert_issue, auth.user_id, formatted_body)
# Create a new issue for this incident. To preserve previous behavior do
# not raise filter rule errors.
alert_issue, _ = we.CreateIssue(
# 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()])
# 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],
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).iteritems()
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'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':
'invalid status %s for an alert with an owner; default to assigned',
return 'assigned'
if status:
if tracker_helpers.MeansOpenInProject(status, proj_config):
return status'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:'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'invalid type %s for an alert; default to None', issue_type)
return None