亚马逊AWS官方博客

AWS Health Event通过SES发送事件日历

背景

虽然用户可以快速通过Event Bridge发送AWS Health通知,但是计划维护事件一般都在未来数天、数周,甚至数月才发生,容易被运维人员所遗忘。为了更好的提醒相关人员提前做好维护操作,我们可以通过Amazon EventBridge结合Amazon SES发送事件日历到邮箱,并且将事件自动添加到日历中。

步骤

第一步,验证您的 Amazon SES 身份(域或电子邮件地址)

  1. 要验证域,请参阅验证 Amazon SES 中的域
  2. 要验证电子邮件地址,请参阅验证 Amazon SES 中的电子邮件地址
  3. 获取 Amazon SES SMTP 凭证,请参阅获取 Amazon SES SMTP 凭证。

第二步,创建或更新包含通过 Amazon SES 发送电子邮件的逻辑的 Lambda 函数

  1. 创建一个 Lambda 函数

注意:您可以使用 Lambda 控制台构建并上传部署包来创建 Lambda 函数

  1. Lambda 控制台的左侧导航窗格中,选择函数
  2. 选择函数的名称。
  3. 在函数代码下,在编辑器窗格中,粘贴以下示例函数代码:

重要提示

  • smtp_user_namepassword替换为第一步中获取的Amazon SES SMTP 凭证。
  • attendees 替换为您的经过 Amazon SES 验证的发件人电子邮件地址,或来自经过 Amazon SES 验证的域的任何电子邮件地址。
  • organizer 替换为会议组织者邮箱地址。
  • alias 替换为会议组织者名字的别称。

源代码

import logging
import ssl
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
import email.encoders
import smtplib
import datetime
from email.utils import formatdate
import boto3
import json
from botocore.exceptions import ClientError

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')

smtp_user_name = 'AKIA3NMSGNFDFKX11111'
password = 'BOg9yyu+HxqjXOVlHtq0S7FYI0PKZEo97YX8eQ22222'
CRLF = "\r\n"
port = 587
attendees = ["att@example.com", ]
organizer = "devops@example.com"
alias = 'DevOps'
location = 'Online'
fro = f"{alias} <{organizer}>"
time_zone = 8
GMT_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'
error_msg = {
    'statusCode': 1,
    'body': json.dumps('格式错误')
}


def build_calendar_invitation_msg(msg_organizer,
                                  msg_attendees,
                                  msg_subject,
                                  msg_description,
                                  msg_summary,
                                  msg_body,
                                  ddt_start,
                                  duration):
    dt_end = ddt_start + duration
    dt_stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%SZ")
    dt_start = ddt_start.strftime("%Y%m%dT%H%M%SZ")
    dt_end = dt_end.strftime("%Y%m%dT%H%M%SZ")
    attendee = ""
    for att in msg_attendees:
        attendee += "ATTENDEE;CUTYPE=INDIVIDUAL;" \
                    "ROLE=REQ-PARTICIPANT;" \
                    "PARTSTAT=ACCEPTED;" \
                    "RSVP=TRUE;" \
                    f"CN={att}:" \
                    f"MAILTO:{att}" + CRLF
    ical = "BEGIN:VCALENDAR" + CRLF \
           + "PRODID:pyICSParser" + CRLF \
           + "VERSION:2.0" + CRLF \
           + "BEGIN:VTIMEZONE" + CRLF \
           + "TZID:China Standard Time" + CRLF \
           + "BEGIN:STANDARD" + CRLF \
           + "DTSTART:16010101T000000" + CRLF \
           + "TZOFFSETFROM:+0800" + CRLF \
           + "TZOFFSETTO:+0800" + CRLF \
           + "END:STANDARD" + CRLF \
           + "BEGIN:DAYLIGHT" + CRLF \
           + "DTSTART:16010101T000000" + CRLF \
           + "TZOFFSETFROM:+0800" + CRLF \
           + "TZOFFSETTO:+0800" + CRLF \
           + "END:DAYLIGHT" + CRLF \
           + "END:VTIMEZONE" + CRLF \
           + "CALSCALE:GREGORIAN" + CRLF \
           + "METHOD:REQUEST" + CRLF \
           + "BEGIN:VEVENT" + CRLF \
           + f"DTSTART;TZID=China Standard Time:{dt_start}{CRLF}" \
           + f"DTEND;TZID=China Standard Time:{dt_end}{CRLF}" \
           + f"DTSTAMP:{dt_stamp}{CRLF}" \
           + f'ORGANIZER;CN="{alias}":mailto:{msg_organizer}{CRLF}' \
           + f"UID:FIXMEUID{dt_stamp}{CRLF}" \
           + attendee + "CREATED:" + dt_stamp + CRLF \
           + "DESCRIPTION;LANGUAGE=zh-CN:" + msg_description + CRLF \
           + "LAST-MODIFIED:" + dt_stamp + CRLF \
           + "LOCATION;LANGUAGE=zh-CN:" + location + CRLF \
           + "SEQUENCE:0" + CRLF \
           + "STATUS:CONFIRMED" + CRLF \
           + "SUMMARY;LANGUAGE=zh-CN:" + msg_summary + CRLF \
           + "TRANSP:OPAQUE" + CRLF \
           + "BEGIN:VALARM" + CRLF \
           + "DESCRIPTION:REMINDER" + CRLF \
           + "TRIGGER;RELATED=START:-PT15M" + CRLF \
           + "ACTION:DISPLAY" + CRLF \
           + "END:VALARM" + CRLF \
           + "END:VEVENT" + CRLF \
           + "END:VCALENDAR" + CRLF

    msg = MIMEMultipart('mixed')
    msg['Reply-To'] = fro
    msg['Date'] = formatdate(localtime=True)
    msg['Subject'] = msg_subject
    msg['From'] = fro
    msg['To'] = ",".join(msg_attendees)

    part_email = MIMEText(msg_body, "html")
    part_cal = MIMEText(ical, 'calendar;method="REQUEST"')

    msg_alternative = MIMEMultipart('alternative')
    msg.attach(msg_alternative)

    ical_atch_file_name = "invite.ics"
    ical_atch = MIMEBase('application/ics', ' ;name="%s"' % ical_atch_file_name)
    ical_atch.set_payload(ical)
    email.encoders.encode_base64(ical_atch)
    ical_atch.add_header('Content-Disposition', 'attachment; filename="%s"' % ical_atch_file_name)

    msg_alternative.attach(part_email)
    msg_alternative.attach(part_cal)

    return msg


def send_email(msg):
    boto3_session = boto3.Session()
    region = boto3_session.region_name
    smtp_server = f'email-smtp.{region}.amazonaws.com'
    context = ssl.create_default_context()
    with smtplib.SMTP(host=smtp_server, port=port) as server:
        server.starttls(context=context)
        server.login(smtp_user_name, password)
        server.sendmail(fro, attendees, msg.as_string())

    logging.info("Mail sent. Check your inbox!")


def validate_dt_field(filed):
    if filed is None:
        return 'N/A'
    dt_filed = gmt_to_datetime(filed)
    dt_filed = dt_filed.replace(tzinfo=datetime.timezone(datetime.timedelta(hours=0)))
    dt_filed = dt_filed.astimezone(datetime.timezone(datetime.timedelta(hours=8)))
    return dt_filed.strftime("Beijing Time %Y-%m-%d %H:%M")


def gmt_to_datetime(gmt_dt):
    return datetime.datetime.strptime(gmt_dt, GMT_FORMAT)


def lambda_handler(event, context):
    if 'detail' not in event:
        return error_msg
    phd_event_detail = event['detail']
    if 'eventTypeCategory' not in phd_event_detail:
        return error_msg
    event_type_category = phd_event_detail['eventTypeCategory']
    if event_type_category != 'accountNotification' and event_type_category != 'scheduledChange':
        return error_msg

    # 获取AWS Health受影响的资源ARN,需要订阅Enterprise Support或者On-Ramp
    # 未订阅的情况下,会输出一行错误日志,但不影响逻辑执行
    health_client = boto3.client('health')
    entity_arn_list = []
    try:
        response = health_client.describe_affected_entities(
            filter={
                'eventArns': [
                    phd_event_detail['eventArn']
                ]
            }
        )
        if 'entities' in response:
            entity_arn_list = map(lambda entity: entity.get('entityArn'), response['entities'], )
    except ClientError as ex:
        logging.error(ex)

    subject = 'AWS Health事件通知 - ' + phd_event_detail['eventTypeCode']
    description = '来自于AWS Health事件通知,请关注维护事件,提前规划维护窗口'
    template = '''
        <table>
            <tr><td>Event Arn</td><td>{}</td></tr>
            <tr><td>Service</td><td>{}</td></tr>
            <tr><td>EventType Code</td><td>{}</td></tr>
            <tr><td>EventType Category</td><td>{}</td></tr>
            <tr><td>Start Time</td><td>{}</td></tr>
            <tr><td>End Time</td><td>{}</td></tr>
            <tr><td>Latest Description</td><td>{}</td></tr>
            <tr><td>Affected Entities</td><td>{}</td></tr>
        </table>
    '''

    affected_entities_temple = '<ul>'
    for entity_arn in entity_arn_list:
        affected_entities_temple += f'<li>{entity_arn}</li>'
    affected_entities_temple += '</ul>'

    eml_body = template.format(phd_event_detail['eventArn'], phd_event_detail['service'],
                               phd_event_detail['eventTypeCode'], phd_event_detail['eventTypeCategory'],
                               validate_dt_field(phd_event_detail.get('startTime')),
                               validate_dt_field(phd_event_detail.get('endTime')),
                               phd_event_detail['eventDescription'][0]['latestDescription'],
                               affected_entities_temple)

    start_ddt = gmt_to_datetime(phd_event_detail['startTime'])
    start_ddt = start_ddt - datetime.timedelta(days=2, hours=time_zone)  # 提前48小时提醒
    event_dur = datetime.timedelta(hours=1)
    summary = "AWS Health事件通知 - " + phd_event_detail['eventTypeCode'] + start_ddt.strftime("%Y-%m-%d %H:%M")
    phd_msg = build_calendar_invitation_msg(organizer, attendees, subject, description,
                                            summary, eml_body, start_ddt, event_dur)
    send_email(phd_msg)

    return {
        'statusCode': 200,
        'body': json.dumps('AWS Health邮件发送成功')
    }

PS:PARTSTAT=ACCEPTED;如果改成PARTSTAT=NEEDS-ACTION,可以将事件自动接受改成手动接受。

测试验证

  1. 在 Lambda 控制台中,配置测试事件为您的功能。
  2. 选择测试。 Lambda 使用 Amazon SES 将测试电子邮件发送给您的收件人。
{
  "version": "0",
  "id": "7bf73129-1428-4cd3-a780-95db273d1602",
  "detail-type": "AWS Health Event",
  "source": "aws.health",
  "account": "123456789012",
  "time": "2016-06-05T06:27:57Z",
  "region": "ap-southeast-2",
  "resources": [],
  "detail": {
    "eventArn": "arn:aws:health:ap-southeast-2::event/AWS_ELASTICLOADBALANCING_API_ISSUE_90353408594353980",
    "service": "ELASTICLOADBALANCING",
    "eventTypeCode": "AWS_ELASTICLOADBALANCING_API_ISSUE",
    "eventTypeCategory": "scheduledChange",
    "startTime": "Sat, 04 Jun 2016 05:01:10 GMT",
    "eventDescription": [
      {
        "language": "en_US",
        "latestDescription": "A description of the event will be provided here"
      }
    ]
  }
}

经过测试,邮件客户端Outlook和Foxmail都可以正常支持自动接受事件,同时测试未覆盖到客户端可能存在兼容性问题。

第三步,配置Event Bridge为触发器

您可以使用控制台或AWS CLI创建规则。使用AWS CLI,首先,您将向该规则授予调用 Lambda 函数的权限。然后,您可以创建规则并将该 Lambda 函数添加为目标。以下演示如何使用控制台来创建。

  1. 在Lambda的控制面板上,点击添加触发器按钮

  1. 选择触发源为EventBridge。创建新规则,填写规则名为“aws_health_event”。规则类型选择事件模式。最后点击添加,完成配置。

总结

通过以上配置后可以在AWS Health发送计划事件的时候,收到邮件日历通知。邀请将自动添加到日历中。可以在日历中查看AWS上的维护计划,以便提前规划好维护窗口。如果我们的AWS账户已经订阅了Enterprise Support Plan或者On-Ramp,我们还可以看到该事件受影响的资源ARN列表。

参考

health Boto3 API文档 

本篇作者

林业

AWS解决方案架构师,负责基于 AWS 的云计算方案的咨询与架构设计。拥有超过14年研发经验,曾打造千万级用户APP,多项Github开源项目贡献者。在游戏、IOT、智慧城市、汽车、电商等多个领域都拥有丰富的实践经验。