blob: 6ecf9b0b4ac7ed95f4cc6de1243ff02c97abb5e7 [file] [log] [blame]
# 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