├── ExcludeAccountIDs(sample).csv ├── readme-images ├── aha-logo.png ├── workflow.png ├── aha_banner.png ├── architecture.png ├── aha-arch-multi-region.png └── aha-arch-single-region.png ├── CODE_OF_CONDUCT.md ├── terraform ├── Terraform_MGMT_ROLE │ ├── terraform.tfvars │ └── Terraform_MGMT_ROLE.tf └── Terraform_DEPLOY_AHA │ ├── terraform.tfvars │ └── Terraform_DEPLOY_AHA.tf ├── LICENSE ├── .gitignore ├── CFN_MGMT_ROLE.yml ├── CONTRIBUTING.md ├── new_aha_event_schema.md ├── CFN_DEPLOY_AHA.yml ├── messagegenerator.py ├── README.md └── handler.py /ExcludeAccountIDs(sample).csv: -------------------------------------------------------------------------------- 1 | 000000000000 2 | 111111111111 3 | -------------------------------------------------------------------------------- /readme-images/aha-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-health-aware/HEAD/readme-images/aha-logo.png -------------------------------------------------------------------------------- /readme-images/workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-health-aware/HEAD/readme-images/workflow.png -------------------------------------------------------------------------------- /readme-images/aha_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-health-aware/HEAD/readme-images/aha_banner.png -------------------------------------------------------------------------------- /readme-images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-health-aware/HEAD/readme-images/architecture.png -------------------------------------------------------------------------------- /readme-images/aha-arch-multi-region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-health-aware/HEAD/readme-images/aha-arch-multi-region.png -------------------------------------------------------------------------------- /readme-images/aha-arch-single-region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-health-aware/HEAD/readme-images/aha-arch-single-region.png -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /terraform/Terraform_MGMT_ROLE/terraform.tfvars: -------------------------------------------------------------------------------- 1 | # Region for IAM role creation - it's global service, so we will be just using one region. 2 | aha_primary_region="us-east-1" 3 | 4 | # Tags applied to all resources - using module provider. Update them per your requirement. 5 | default_tags = { 6 | Application = "AHA-Solution" 7 | Environment = "DEV" 8 | auto-delete = "no" 9 | } 10 | 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # AHA Lambda Function package 3 | lambda_function.zip 4 | 5 | # ---> Terraform 6 | # Local .terraform directories 7 | **/.terraform/* 8 | 9 | # .tfstate files 10 | *.tfstate 11 | *.tfstate.* 12 | 13 | # tf lock file 14 | .terraform.lock.hcl 15 | 16 | # Crash log files 17 | crash.log 18 | 19 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 20 | # .tfvars files are managed as part of configuration and so should be included in 21 | # version control. 22 | # 23 | # example.tfvars 24 | 25 | # Ignore override files as they are usually used to override resources locally and so 26 | # are not checked in 27 | override.tf 28 | override.tf.json 29 | *_override.tf 30 | *_override.tf.json 31 | 32 | # Include override files you do wish to add to version control using negated pattern 33 | # 34 | # !example_override.tf 35 | 36 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 37 | # example: *tfplan* 38 | -------------------------------------------------------------------------------- /terraform/Terraform_DEPLOY_AHA/terraform.tfvars: -------------------------------------------------------------------------------- 1 | # Input variables for Terraform_DEPLOY_AHA.tf (AHA Solution deploy using terraform) 2 | # 3 | # Customize Alerts/Notifications 4 | aha_primary_region="us-east-1" 5 | aha_secondary_region="" 6 | AWSOrganizationsEnabled="No" 7 | AWSHealthEventType="issue | accountNotification | scheduledChange" 8 | 9 | # Communication Channels - Slack/Microsoft Teams/Amazon Chime And/or EventBridge 10 | SlackWebhookURL="" 11 | MicrosoftTeamsWebhookURL="" 12 | AmazonChimeWebhookURL="" 13 | EventBusName="" 14 | 15 | # Email Setup - For Alerting via Email 16 | FromEmail="none@domain.com" 17 | ToEmail="none@domain.com" 18 | Subject="AWS Health Alert" 19 | 20 | # More Configurations - Optional 21 | # By default, AHA reports events affecting all AWS regions. 22 | # If you want to report on certain regions you can enter up to 10 in a comma separated format. 23 | EventSearchBack="1" 24 | Regions="all regions" 25 | ManagementAccountRoleArn="" 26 | ExcludeAccountIDs="" 27 | 28 | # Tags applied to all resources - using module provider. Update them per your requirement. 29 | default_tags = { 30 | Application = "AHA-Solution" 31 | Environment = "PROD" 32 | auto-delete = "no" 33 | } 34 | 35 | # commands to apply changes 36 | # terraform init 37 | # terraform plan 38 | # terraform apply 39 | -------------------------------------------------------------------------------- /CFN_MGMT_ROLE.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Description: Deploy Cross-Account Role for PHD access 3 | Parameters: 4 | OrgMemberAccountId: 5 | Type: String 6 | AllowedPattern: '^\d{12}$' 7 | Description: AWS Account ID of the AWS Organizations Member Account that will run AWS Health Aware 8 | Resources: 9 | AWSHealthAwareRoleForPHDEvents: 10 | Type: "AWS::IAM::Role" 11 | Properties: 12 | Description: "Grants access to PHD events" 13 | Path: / 14 | AssumeRolePolicyDocument: 15 | Version: '2012-10-17' 16 | Statement: 17 | - Action: 18 | - sts:AssumeRole 19 | Effect: Allow 20 | Principal: 21 | AWS: !Sub 'arn:aws:iam::${OrgMemberAccountId}:root' 22 | Policies: 23 | - PolicyName: AllowHealthCalls 24 | PolicyDocument: 25 | Statement: 26 | - Effect: Allow 27 | Action: 28 | - health:DescribeAffectedAccountsForOrganization 29 | - health:DescribeAffectedEntitiesForOrganization 30 | - health:DescribeEventDetailsForOrganization 31 | - health:DescribeEventsForOrganization 32 | - health:DescribeEventDetails 33 | - health:DescribeEvents 34 | - health:DescribeEventTypes 35 | - health:DescribeAffectedEntities 36 | Resource: "*" 37 | - PolicyName: AllowsDescribeOrg 38 | PolicyDocument: 39 | Statement: 40 | - Effect: Allow 41 | Action: 42 | - organizations:ListAccounts 43 | - organizations:ListAWSServiceAccessForOrganization 44 | - organizations:DescribeAccount 45 | Resource: "*" 46 | Outputs: 47 | AWSHealthAwareRoleForPHDEventsArn: 48 | Value: !GetAtt AWSHealthAwareRoleForPHDEvents.Arn 49 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /terraform/Terraform_MGMT_ROLE/Terraform_MGMT_ROLE.tf: -------------------------------------------------------------------------------- 1 | # Deploy Cross-Account Role for PHD access 2 | 3 | # Parameters 4 | provider "aws" { 5 | region = var.aha_primary_region 6 | default_tags { 7 | tags = "${var.default_tags}" 8 | } 9 | } 10 | 11 | variable "aha_primary_region" { 12 | description = "Primary region where AHA solution will be deployed" 13 | type = string 14 | default = "us-east-1" 15 | } 16 | 17 | variable "default_tags" { 18 | description = "Tags used for the AWS resources created by this template" 19 | type = map 20 | default = { 21 | Application = "AHA-Solution" 22 | } 23 | } 24 | 25 | variable "OrgMemberAccountId" { 26 | type = string 27 | description = "AWS Account ID of the AWS Organizations Member Account that will run AWS Health Aware" 28 | 29 | validation { 30 | condition = length(var.OrgMemberAccountId) == 12 31 | error_message = "The OrgMemberAccountId must be a valid AWS Account ID." 32 | } 33 | } 34 | 35 | # Random id generator 36 | resource "random_string" "resource_code" { 37 | length = 8 38 | special = false 39 | upper = false 40 | } 41 | 42 | # aws_iam_role.AWSHealthAwareRoleForPHDEvents: 43 | resource "aws_iam_role" "AWSHealthAwareRoleForPHDEvents" { 44 | assume_role_policy = jsonencode( 45 | { 46 | Statement = [ 47 | { 48 | Action = "sts:AssumeRole" 49 | Effect = "Allow" 50 | Principal = { 51 | AWS = "arn:aws:iam::${var.OrgMemberAccountId}:root" 52 | } 53 | }, 54 | ] 55 | Version = "2012-10-17" 56 | } 57 | ) 58 | name = "AWSHealthAwareRoleForPHDEvents-${random_string.resource_code.result}" 59 | description = "Grants access to PHD event" 60 | path = "/" 61 | 62 | inline_policy { 63 | name = "AllowHealthCalls" 64 | policy = jsonencode( 65 | { 66 | Statement = [ 67 | { 68 | Action = [ 69 | "health:DescribeAffectedAccountsForOrganization", 70 | "health:DescribeAffectedEntitiesForOrganization", 71 | "health:DescribeEventDetailsForOrganization", 72 | "health:DescribeEventsForOrganization", 73 | "health:DescribeEventDetails", 74 | "health:DescribeEvents", 75 | "health:DescribeEventTypes", 76 | "health:DescribeAffectedEntities", 77 | ] 78 | Effect = "Allow" 79 | Resource = "*" 80 | }, 81 | ] 82 | } 83 | ) 84 | } 85 | inline_policy { 86 | name = "AllowsDescribeOrg" 87 | policy = jsonencode( 88 | { 89 | Statement = [ 90 | { 91 | Action = [ 92 | "organizations:ListAccounts", 93 | "organizations:ListAWSServiceAccessForOrganization", 94 | "organizations:DescribeAccount", 95 | ] 96 | Effect = "Allow" 97 | Resource = "*" 98 | }, 99 | ] 100 | } 101 | ) 102 | } 103 | } 104 | 105 | 106 | output "AWSHealthAwareRoleForPHDEventsArn" { 107 | value = aws_iam_role.AWSHealthAwareRoleForPHDEvents.arn 108 | } 109 | 110 | -------------------------------------------------------------------------------- /new_aha_event_schema.md: -------------------------------------------------------------------------------- 1 | # Readme for new AHA Event schema 2 | 3 | ## New AHA Event Schema 4 | 5 | With release X.Y.Z, AHA includes an updated format for events published to EventBridge. Building on the [existing event format](https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/EventTypes.html#health-event-types) published by AWS Health, AHA enriches it with additional data from the Health API and AWS Organizations to enable new options for filtering in EventBridge. 6 | 7 | 8 | >Note: If you used the previous { "title": "Title", "value": "Value" } schema in your rules, you must update your rules to reflect the new schema when deploying the new version of AHA 9 | 10 | ### Schema: 11 | 12 | ``` 13 | { 14 | "version": "0", 15 | "id": "7bf73129-1428-4cd3-a780-95db273d1602", 16 | "detail-type": "AHA Event", 17 | "source": "aha", 18 | "account": "123456789012", 19 | "time": "2022-07-14T03:56:10Z", 20 | "region": "region of the eventbus", 21 | "resources": [ 22 | "i-1234567890abcdef0" 23 | ], 24 | "detail": { 25 | "eventArn": "arn:aws:health:region::event/id", 26 | "service": "service", 27 | "eventTypeCode": "typecode", 28 | "eventTypeCategory": "category", 29 | "region": "region of the Health event", 30 | "startTime": "2022-07-02 12:33:26.951000+00:00", 31 | "endTime": "2022-07-02 12:33:26.951000+00:00", 32 | "lastUpdatedTime": "2022-07-02 12:36:18.576000+00:00", 33 | "statusCode": "status", 34 | "eventScopeCode": "scopecode", 35 | "eventDescription": { 36 | "latestDescription": "description" 37 | }, 38 | "affectedEntities": [{ 39 | "entityValue": "i-1234567890abcdef0", 40 | "awsAccountId": "account number", 41 | "awsAccountName": "account name" 42 | }] 43 | } 44 | } 45 | ``` 46 | 47 | ### AHA added properties 48 | 49 | **eventScopeCode:** Specifies if the Health event is a public AWS service event or an account-specific event. 50 | Values: *string -* `PUBLIC | ACCOUNT_SPECIFIC` 51 | 52 | **statusCode:** Reflects whether the event is ongoing, resolved or in the case of scheduled maintenance, upcoming. 53 | Values: *string -* `open | closed | upcoming` 54 | 55 | **affectedEntities:** For ACCOUNT_SPECIFIC events, AHA includes expanded detail on resources. **affectedEntities** includes the listed **resources**, each as an **entitityValue** with the resource ID (as it appears in events for single accounts). AHA adds the related **awsAccountId** and In AWS Organizations, **awsAccountName** of the resource. 56 | Values: *entity object(s). May be empty if no resources are listed* 57 | 58 | 59 | ## EventBridge pattern examples 60 | 61 | As a primer we recommended you review the [EventBridge EventPatterns](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html) documentation and examples on [Content filtering in Amazon EventBridge event patterns](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html) 62 | 63 | Use the following sample event published by AWS Health Aware to test matching in the provided examples: 64 | 65 | ``` 66 | { 67 | "version": "0", 68 | "id": "e47c4390-b295-ce6f-7e94-f13083d7bb90", 69 | "detail-type": "AHA Event", 70 | "source": "aha", 71 | "account": "`234567890123`", 72 | "time": "2022-07-20T18:26:17Z", 73 | "region": "us-east-1", 74 | "resources": [ 75 | "vpn-0d0e3eeefe6aabb0d" 76 | ], 77 | "detail": { 78 | "arn": "arn:aws:health:us-east-1::event/VPN/AWS_VPN_REDUNDANCY_LOSS/AWS_VPN_REDUNDANCY_LOSS-1656151378267-7672191-IAD", 79 | "service": "VPN", 80 | "eventTypeCode": "AWS_VPN_REDUNDANCY_LOSS", 81 | "eventTypeCategory": "accountNotification", 82 | "region": "us-east-1", 83 | "startTime": "2022-06-25 10:00:48.868000+00:00", 84 | "lastUpdatedTime": "2022-06-25 10:02:58.371000+00:00", 85 | "statusCode": "open", 86 | "eventScopeCode": "ACCOUNT_SPECIFIC", 87 | "eventDescription": { 88 | "latestDescription": "Your VPN Connection associated with this event in the us-east-1 Region had a momentary lapse of redundancy as one of two tunnel endpoints was replaced. Connectivity on the second tunnel was not affected during this time. Both tunnels are now operating normally.\n\nReplacements can occur for several reasons, including health, software upgrades, customer-initiated modifications, and when underlying hardware is retired. If you have configured your VPN Customer Gateway to use both tunnels, then your VPN Connection will have utilized the alternate tunnel during the replacement process. For more on tunnel endpoint replacements, please see our documentation [1].\n\nIf you have not configured your VPN Customer Gateway to use both tunnels, then your VPN Connection may have been interrupted during the replacement. We encourage you to configure your router to use both tunnels. You can obtain the VPN Connection configuration recommendations for several types of VPN devices from the AWS Management Console [2]. On the \"Amazon VPC\" tab, select \"VPN Connections\". Then highlight the VPN Connection and choose \"Download Configuration\".\n\n[1] https://docs.aws.amazon.com/vpn/latest/s2svpn/monitoring-vpn-health-events.html\n[2] https://console.aws.amazon.com" 89 | }, 90 | "affectedEntities": [{ 91 | "entityValue": "vpn-0d0e3eeefe6aabb0d", 92 | "awsAccountId": "987654321987", 93 | "awsAccountName": "Prod-Apps" 94 | }] 95 | } 96 | } 97 | ``` 98 | 99 | 100 | To write a rule that matches resources found in the event, reference the **resources** key of the JSON event, and provide an event pattern to the EventBridge rule. Example 1 matches on an exact resource - “*vpn-0d0e3eeefe6aabb0d*”. Example 2 matches any resource starting with "*vpn-*" 101 | **Example 1:** 102 | 103 | ``` 104 | { 105 | "resources": [ 106 | "vpn-0d0e3eeefe6aabb0d" 107 | ] 108 | } 109 | ``` 110 | 111 | 112 | **Example 2:** 113 | 114 | ``` 115 | { 116 | "resources": [ 117 | {"prefix": "vpn-"} 118 | ] 119 | } 120 | ``` 121 | 122 | 123 | To match based on a specific service, note that **service** is nested within the **detail** key in the JSON structure, so we reference both **detail** and **service**. Example 3 matches the VPN service, and Example 4 matches EC2 OR S3 (will not match the sample event). To get a list of all service names used by AWS Health you can use the cli command - `aws health describe-event-types` 124 | 125 | **Example 3:** 126 | 127 | ``` 128 | { 129 | "detail": { 130 | "service": ["VPN"] 131 | } 132 | } 133 | ``` 134 | 135 | 136 | **Example 4:** 137 | 138 | ``` 139 | { 140 | "detail": { 141 | "service": ["EC2", "S3"] 142 | } 143 | } 144 | ``` 145 | 146 | 147 | To match events based on an AWS account name or number, use the following patterns. Take note of the additional levels of nesting based on the sample event. Example 5 matches a specific account number as **awsAccountId**. Example 6 matches a specific account name, and Example 7 adds an additional field of **eventTypeCategory** along with the “prefix” filter pattern which will match any value in the **awsAccountName** field that starts with “*Prod*” similar to a wildcard match of “*Prod**” 148 | 149 | **Example 5:** 150 | 151 | ``` 152 | { 153 | "detail": { 154 | "affectedEntities": { 155 | "awsAccountId": ["987654321987"] 156 | } 157 | } 158 | } 159 | ``` 160 | 161 | 162 | **Example 6:** 163 | 164 | ``` 165 | { 166 | "detail": { 167 | "affectedEntities": { 168 | "awsAccountName": ["Prod-Apps"] 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | 175 | **Example 7:** 176 | 177 | ``` 178 | { 179 | "detail": { 180 | "eventTypeCategory": ["accountNotification"], 181 | "affectedEntities": { 182 | "awsAccountName": [{"prefix": "Prod"}] 183 | } 184 | } 185 | } 186 | ``` 187 | 188 | 189 | Combine any of the patterns listed to create more specific rules. Note that all patterns in the rule must match for the EventBridge rule to trigger. Example 8 will only match when all 3 conditions exist in an AHA event - **service** = *VPN*, **region** = *us-east-1* and **awsAccountId** = *987654321098* 190 | 191 | **Example 8:** 192 | 193 | ``` 194 | { 195 | "detail": { 196 | "service": ["VPN"], 197 | "region": ["us-east-1"], 198 | "affectedEntities": { 199 | "awsAccountId": ["987654321987"] 200 | } 201 | } 202 | } 203 | ``` 204 | 205 | 206 | As a best practice, also include `"source": ["aha"]` in your pattern if the event bus contains events generated by other sources. 207 | 208 | **Example 9:** 209 | 210 | ``` 211 | { 212 | "source": ["aha"], 213 | "detail": { 214 | "service": ["VPN"], 215 | "region": ["us-east-1"], 216 | "affectedEntities": { 217 | "awsAccountId": ["987654321987"] 218 | } 219 | } 220 | } 221 | ``` 222 | -------------------------------------------------------------------------------- /CFN_DEPLOY_AHA.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: CloudFormation Template for AWS Health Aware (AHA) 3 | Metadata: 4 | 'AWS::CloudFormation::Interface': 5 | ParameterGroups: 6 | - Label: 7 | default: Customize Alerts/Notifications 8 | Parameters: 9 | - AWSOrganizationsEnabled 10 | - AWSHealthEventType 11 | - Label: 12 | default: Package Information 13 | Parameters: 14 | - S3Bucket 15 | - S3Key 16 | - Label: 17 | default: >- 18 | Communication Channels - Slack/Microsoft Teams/Amazon Chime And/or 19 | EventBridge 20 | Parameters: 21 | - SlackWebhookURL 22 | - MicrosoftTeamsWebhookURL 23 | - AmazonChimeWebhookURL 24 | - EventBusName 25 | - Label: 26 | default: Email Setup - For Alerting via Email 27 | Parameters: 28 | - FromEmail 29 | - ToEmail 30 | - Subject 31 | - Label: 32 | default: More Configurations - Optional 33 | Parameters: 34 | - EventSearchBack 35 | - Regions 36 | - ManagementAccountRoleArn 37 | - SecondaryRegion 38 | - AccountIDs 39 | ParameterLabels: 40 | AWSOrganizationsEnabled: 41 | default: AWS Organizations Enabled? 42 | ManagementAccountRoleArn: 43 | default: ARN of the AWS Organizations Management Account assume role (if using) 44 | AWSHealthEventType: 45 | default: The types of events to get alerted on 46 | S3Bucket: 47 | default: Name of S3 Bucket 48 | S3Key: 49 | default: Name of .zip file in S3 Bucket 50 | SlackWebhookURL: 51 | default: Slack Webhook URL 52 | MicrosoftTeamsWebhookURL: 53 | default: Microsoft Teams Webhook URL 54 | AmazonChimeWebhookURL: 55 | default: Amazon Chime Webhook URL 56 | FromEmail: 57 | default: Email From 58 | ToEmail: 59 | default: Email To 60 | Subject: 61 | default: Subject of Email 62 | HealthAPIFrequency: 63 | default: Hours back to search for events 64 | Regions: 65 | default: Which regions to search for events in 66 | SecondaryRegion: 67 | default: Deploy in secondary region? 68 | AccountIDs: 69 | default: Exclude any account numbers? 70 | Conditions: 71 | UsingSlack: !Not [!Equals [!Ref SlackWebhookURL, None]] 72 | UsingTeams: !Not [!Equals [!Ref MicrosoftTeamsWebhookURL, None]] 73 | UsingChime: !Not [!Equals [!Ref AmazonChimeWebhookURL, None]] 74 | UsingEventBridge: !Not [!Equals [!Ref EventBusName, None]] 75 | UsingSecrets: !Or [!Condition UsingSlack, !Condition UsingTeams, !Condition UsingChime, !Condition UsingEventBridge, !Condition UsingCrossAccountRole] 76 | UsingCrossAccountRole: !Not [!Equals [!Ref ManagementAccountRoleArn, None]] 77 | NotUsingMultiRegion: !Equals [!Ref SecondaryRegion, 'No'] 78 | UsingMultiRegion: !Not [!Equals [!Ref SecondaryRegion, 'No']] 79 | TestCondition: !Equals ['true', 'false'] 80 | UsingMultiRegionTeams: !And [!Condition UsingTeams, !Condition UsingMultiRegion] 81 | UsingMultiRegionSlack: !And [!Condition UsingSlack, !Condition UsingMultiRegion] 82 | UsingMultiRegionEventBridge: !And [!Condition UsingEventBridge, !Condition UsingMultiRegion] 83 | UsingMultiRegionChime: !And [!Condition UsingChime, !Condition UsingMultiRegion] 84 | UsingMultiRegionCrossAccountRole: !And [!Condition UsingCrossAccountRole, !Condition UsingMultiRegion] 85 | UsingAccountIds: !Not [!Equals [!Ref AccountIDs, None]] 86 | Parameters: 87 | AWSOrganizationsEnabled: 88 | Description: >- 89 | You can receive both PHD and SHD alerts if you're using AWS Organizations. 90 | If you are, make sure to enable Organizational Health View: 91 | (https://docs.aws.amazon.com/health/latest/ug/aggregate-events.html) to 92 | aggregate all PHD events in your AWS Organization. If not, you can still 93 | get SHD alerts. 94 | Default: 'No' 95 | AllowedValues: 96 | - 'Yes' 97 | - 'No' 98 | Type: String 99 | SecondaryRegion: 100 | Description: You can deploy this in a secondary region for resiliency. As a result, 101 | the DynamoDB table will become a Global DynamoDB table. Regions that support 102 | Global DynamoDB tables are listed 103 | Default: 'No' 104 | AllowedValues: 105 | - 'No' 106 | - us-east-1 107 | - us-east-2 108 | - us-west-1 109 | - us-west-2 110 | - ap-south-1 111 | - ap-northeast-2 112 | - ap-southeast-1 113 | - ap-southeast-2 114 | - ap-northeast-1 115 | - ca-central-1 116 | - eu-central-1 117 | - eu-west-1 118 | - eu-west-2 119 | - eu-west-3 120 | - sa-east-1 121 | Type: String 122 | ManagementAccountRoleArn: 123 | Description: Arn of the IAM role in the top-level management account for collecting PHD Events. 'None' if deploying into the top-level management account. 124 | Type: String 125 | Default: None 126 | AWSHealthEventType: 127 | Description: >- 128 | Select the event type that you want AHA to report on. Refer to 129 | https://docs.aws.amazon.com/health/latest/APIReference/API_EventType.html for more information on EventType. 130 | Default: 'issue | accountNotification | scheduledChange' 131 | AllowedValues: 132 | - 'issue | accountNotification | scheduledChange' 133 | - 'issue' 134 | Type: String 135 | S3Bucket: 136 | Description: >- 137 | Name of your S3 Bucket where the AHA Package .zip resides. Just the name 138 | of the bucket (e.g. my-s3-bucket) 139 | Type: String 140 | S3Key: 141 | Description: >- 142 | Name of the .zip in your S3 Bucket. Just the name of the file (e.g. 143 | aha-v1.0.zip) 144 | Type: String 145 | EventBusName: 146 | Description: >- 147 | This is to ingest alerts into AWS EventBridge. Enter the event bus name if 148 | you wish to send the alerts to the AWS EventBridge. Note: By ingesting 149 | these alerts to AWS EventBridge, you can integrate with 35 SaaS vendors 150 | such as DataDog/NewRelic/PagerDuty. If you don't prefer to use EventBridge, leave the default (None). 151 | Type: String 152 | Default: None 153 | SlackWebhookURL: 154 | Description: >- 155 | Enter the Slack Webhook URL. If you don't prefer to use Slack, leave the default (None). 156 | Type: String 157 | Default: None 158 | MicrosoftTeamsWebhookURL: 159 | Description: >- 160 | Enter Microsoft Teams Webhook URL. If you don't prefer to use MS Teams, 161 | leave the default (None). 162 | Type: String 163 | Default: None 164 | AmazonChimeWebhookURL: 165 | Description: >- 166 | Enter the Chime Webhook URL, If you don't prefer to use Amazon Chime, 167 | leave the default (None). 168 | Type: String 169 | Default: None 170 | Regions: 171 | Description: >- 172 | By default, AHA reports events affecting all AWS regions. 173 | If you want to report on certain regions you can enter up to 10 in a comma separated format. 174 | Available Regions: us-east-1,us-east-2,us-west-1,us-west-2,af-south-1,ap-east-1,ap-south-1,ap-northeast-3, 175 | ap-northeast-2,ap-southeast-1,ap-southeast-2,ap-northeast-1,ca-central-1,eu-central-1,eu-west-1,eu-west-2, 176 | eu-south-1,eu-south-3,eu-north-1,me-south-1,sa-east-1,global 177 | Default: all regions 178 | AllowedPattern: ".+" 179 | ConstraintDescription: No regions were entered, please read the documentation about selecting all regions or filtering on some. 180 | Type: String 181 | AccountIDs: 182 | Description: >- 183 | If you would like to EXCLUDE any accounts from alerting, upload a .csv file of comma-seperated account numbers to the same S3 bucket 184 | where the AHA.zip package is located. Sample AccountIDs file name: aha_account_ids.csv. If not, leave the default of None. 185 | Default: None 186 | Type: String 187 | AllowedPattern: (None)|(.+(\.csv))$ 188 | EventSearchBack: 189 | Description: How far back to search for events in hours. Default is 1 hour 190 | Default: '1' 191 | Type: Number 192 | FromEmail: 193 | Description: Enter FROM Email Address 194 | Type: String 195 | Default: none@domain.com 196 | AllowedPattern: ^([\w+-.%]+@[\w-.]+\.[A-Za-z]+)(, ?[\w+-.%]+@[\w-.]+\.[A-Za-z]+)*$ 197 | ConstraintDescription: 'FromEmail is not a valid, please verify entry. If not sending to email, leave as the default, none@domain.com.' 198 | ToEmail: 199 | Description: >- 200 | Enter email addresses separated by commas (for ex: abc@amazon.com, 201 | bcd@amazon.com) 202 | Type: String 203 | Default: none@domain.com 204 | AllowedPattern: ^([\w+-.%]+@[\w-.]+\.[A-Za-z]+)(, ?[\w+-.%]+@[\w-.]+\.[A-Za-z]+)*$ 205 | ConstraintDescription: 'ToEmail is not a valid, please verify entry. If not sending to email, leave as the default, none@domain.com.' 206 | Subject: 207 | Description: Enter the subject of the email address 208 | Type: String 209 | Default: AWS Health Alert 210 | Resources: 211 | GlobalDDBTable: 212 | Type: AWS::DynamoDB::GlobalTable 213 | Condition: UsingMultiRegion 214 | Properties: 215 | AttributeDefinitions: 216 | - AttributeName: arn 217 | AttributeType: S 218 | KeySchema: 219 | - AttributeName: arn 220 | KeyType: HASH 221 | Replicas: 222 | - Region: !Ref SecondaryRegion 223 | ReadProvisionedThroughputSettings: 224 | ReadCapacityUnits: 5 225 | - Region: !Ref "AWS::Region" 226 | ReadProvisionedThroughputSettings: 227 | ReadCapacityUnits: 5 228 | StreamSpecification: 229 | StreamViewType: "NEW_AND_OLD_IMAGES" 230 | TimeToLiveSpecification: 231 | AttributeName: ttl 232 | Enabled: true 233 | WriteProvisionedThroughputSettings: 234 | WriteCapacityAutoScalingSettings: 235 | MaxCapacity: 10 236 | MinCapacity: 10 237 | TargetTrackingScalingPolicyConfiguration: 238 | DisableScaleIn: false 239 | ScaleInCooldown: 30 240 | ScaleOutCooldown: 30 241 | TargetValue: 10 242 | DynamoDBTable: 243 | Type: 'AWS::DynamoDB::Table' 244 | Condition: NotUsingMultiRegion 245 | Properties: 246 | AttributeDefinitions: 247 | - AttributeName: arn 248 | AttributeType: S 249 | KeySchema: 250 | - AttributeName: arn 251 | KeyType: HASH 252 | ProvisionedThroughput: 253 | ReadCapacityUnits: 5 254 | WriteCapacityUnits: 5 255 | TimeToLiveSpecification: 256 | AttributeName: ttl 257 | Enabled: TRUE 258 | AHASecondaryRegionStackSet: 259 | Condition: UsingMultiRegion 260 | DependsOn: GlobalDDBTable 261 | Type: AWS::CloudFormation::StackSet 262 | Properties: 263 | Description: Secondary Region CloudFormation Template for AWS Health Aware (AHA) 264 | PermissionModel: SELF_MANAGED 265 | Capabilities: [CAPABILITY_IAM] 266 | StackInstancesGroup: 267 | - Regions: 268 | - !Ref 'SecondaryRegion' 269 | DeploymentTargets: 270 | Accounts: 271 | - !Ref 'AWS::AccountId' 272 | StackSetName: 'aha-multi-region' 273 | TemplateBody: 274 | !Sub | 275 | Resources: 276 | AHA2ndRegionBucket: 277 | Type: AWS::S3::Bucket 278 | CopyAHA: 279 | Type: Custom::CopyAHA 280 | Properties: 281 | DestBucket: !Ref 'AHA2ndRegionBucket' 282 | ServiceToken: !GetAtt 'CopyAHAFunction.Arn' 283 | SourceBucket: ${S3Bucket} 284 | Object: 285 | - ${S3Key} 286 | CopyAHARole: 287 | Type: AWS::IAM::Role 288 | Properties: 289 | AssumeRolePolicyDocument: 290 | Version: '2012-10-17' 291 | Statement: 292 | - Effect: Allow 293 | Principal: 294 | Service: lambda.amazonaws.com 295 | Action: sts:AssumeRole 296 | ManagedPolicyArns: 297 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 298 | Path: / 299 | Policies: 300 | - PolicyName: aha-lambda-copier 301 | PolicyDocument: 302 | Version: '2012-10-17' 303 | Statement: 304 | - Effect: Allow 305 | Action: 306 | - s3:GetObject 307 | Resource: 308 | - 'arn:aws:s3:::${S3Bucket}*' 309 | - Effect: Allow 310 | Action: 311 | - s3:PutObject 312 | - s3:DeleteObject 313 | Resource: 314 | - !Join ['', [ 'arn:aws:s3:::', !Ref AHA2ndRegionBucket, '*']] 315 | CopyAHAFunction: 316 | Type: AWS::Lambda::Function 317 | DependsOn: AHA2ndRegionBucket 318 | Properties: 319 | Description: Copies AHA .zip from a source S3 bucket to a destination 320 | Handler: index.handler 321 | Runtime: python3.11 322 | Role: !GetAtt 'CopyAHARole.Arn' 323 | Timeout: 240 324 | Code: 325 | ZipFile: | 326 | import json 327 | import logging 328 | import threading 329 | import boto3 330 | import cfnresponse 331 | 332 | def copy_object(source_bucket, dest_bucket, object): 333 | s3 = boto3.client('s3') 334 | for o in object: 335 | key = o 336 | copy_source = { 337 | 'Bucket': source_bucket, 338 | 'Key': key 339 | } 340 | print('copy_source: %s' % copy_source) 341 | print('dest_bucket = %s'%dest_bucket) 342 | print('key = %s' %key) 343 | s3.copy_object(CopySource=copy_source, Bucket=dest_bucket, 344 | Key=key) 345 | 346 | def delete_object(bucket, object): 347 | s3 = boto3.client('s3') 348 | objects = {'Objects': [{'Key': o} for o in object]} 349 | s3.delete_objects(Bucket=bucket, Delete=objects) 350 | 351 | def timeout(event, context): 352 | logging.error('Execution is about to time out, sending failure response to CloudFormation') 353 | cfnresponse.send(event, context, cfnresponse.FAILED, {}, None) 354 | 355 | def handler(event, context): 356 | # make sure we send a failure to CloudFormation if the function 357 | # is going to timeout 358 | timer = threading.Timer((context.get_remaining_time_in_millis() 359 | / 1000.00) - 0.5, timeout, args=[event, context]) 360 | timer.start() 361 | 362 | print('Received event: %s' % json.dumps(event)) 363 | status = cfnresponse.SUCCESS 364 | try: 365 | source_bucket = event['ResourceProperties']['SourceBucket'] 366 | dest_bucket = event['ResourceProperties']['DestBucket'] 367 | object = event['ResourceProperties']['Object'] 368 | if event['RequestType'] == 'Delete': 369 | delete_object(dest_bucket, object) 370 | else: 371 | copy_object(source_bucket, dest_bucket, object) 372 | except Exception as e: 373 | logging.error('Exception: %s' % e, exc_info=True) 374 | status = cfnresponse.FAILED 375 | finally: 376 | timer.cancel() 377 | cfnresponse.send(event, context, status, {}, None) 378 | LambdaSchedule: 379 | Type: AWS::Events::Rule 380 | Properties: 381 | Description: Lambda trigger Event 382 | ScheduleExpression: rate(1 minute) 383 | State: ENABLED 384 | Targets: 385 | - Arn: !GetAtt 'LambdaFunction.Arn' 386 | Id: LambdaSchedule 387 | LambdaSchedulePermission: 388 | Type: AWS::Lambda::Permission 389 | Properties: 390 | Action: lambda:InvokeFunction 391 | FunctionName: !GetAtt 'LambdaFunction.Arn' 392 | Principal: events.amazonaws.com 393 | SourceArn: !GetAtt 'LambdaSchedule.Arn' 394 | LambdaFunction: 395 | Type: AWS::Lambda::Function 396 | DependsOn: CopyAHA 397 | Properties: 398 | Description: Lambda function that runs AHA 399 | Code: 400 | S3Bucket: 401 | Ref: AHA2ndRegionBucket 402 | S3Key: "${S3Key}" 403 | Handler: handler.main 404 | MemorySize: 128 405 | Timeout: 600 406 | Role: ${LambdaExecutionRole.Arn} 407 | Runtime: python3.11 408 | Environment: 409 | Variables: 410 | REGIONS: ${Regions} 411 | FROM_EMAIL: "${FromEmail}" 412 | TO_EMAIL: "${ToEmail}" 413 | EMAIL_SUBJECT: "${Subject}" 414 | DYNAMODB_TABLE: "${GlobalDDBTable}" 415 | EVENT_SEARCH_BACK: ${EventSearchBack} 416 | ORG_STATUS: ${AWSOrganizationsEnabled} 417 | HEALTH_EVENT_TYPE: "${AWSHealthEventType}" 418 | MANAGEMENT_ROLE_ARN: "${ManagementAccountRoleArn}" 419 | LambdaExecutionRole: 420 | Type: 'AWS::IAM::Role' 421 | Properties: 422 | AssumeRolePolicyDocument: 423 | Version: '2012-10-17' 424 | Statement: 425 | - Effect: Allow 426 | Principal: 427 | Service: 428 | - lambda.amazonaws.com 429 | Action: 430 | - 'sts:AssumeRole' 431 | Path: / 432 | Policies: 433 | - PolicyName: AHA-LambdaPolicy 434 | PolicyDocument: 435 | Version: '2012-10-17' 436 | Statement: 437 | - Effect: Allow 438 | Action: 439 | - logs:CreateLogGroup 440 | - logs:CreateLogStream 441 | - logs:PutLogEvents 442 | Resource: 443 | - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*' 444 | - !If [UsingMultiRegion, !Sub 'arn:aws:logs:${SecondaryRegion}:${AWS::AccountId}:*', !Ref AWS::NoValue] 445 | - !If 446 | - UsingSecrets 447 | - Effect: Allow 448 | Action: 449 | - 'secretsmanager:GetResourcePolicy' 450 | - 'secretsmanager:DescribeSecret' 451 | - 'secretsmanager:ListSecretVersionIds' 452 | - 'secretsmanager:GetSecretValue' 453 | Resource: 454 | - !If [UsingTeams, !Sub '${MicrosoftChannelSecret}', !Ref AWS::NoValue] 455 | - !If [UsingSlack, !Sub '${SlackChannelSecret}', !Ref AWS::NoValue] 456 | - !If [UsingEventBridge, !Sub '${EventBusNameSecret}', !Ref AWS::NoValue] 457 | - !If [UsingChime, !Sub '${ChimeChannelSecret}', !Ref AWS::NoValue] 458 | - !If [UsingCrossAccountRole, !Sub '${AssumeRoleSecret}', !Ref AWS::NoValue] 459 | - !If 460 | - UsingMultiRegionTeams 461 | - !Sub 462 | - 'arn:aws:secretsmanager:${SecondaryRegion}:${AWS::AccountId}:secret:${SecretNameWithSha}' 463 | - { SecretNameWithSha: !Select [1, !Split [':secret:', !Sub '${MicrosoftChannelSecret}' ]]} 464 | - !Ref AWS::NoValue 465 | - !If 466 | - UsingMultiRegionSlack 467 | - !Sub 468 | - 'arn:aws:secretsmanager:${SecondaryRegion}:${AWS::AccountId}:secret:${SecretNameWithSha}' 469 | - { SecretNameWithSha: !Select [1, !Split [':secret:', !Sub '${SlackChannelSecret}' ]]} 470 | - !Ref AWS::NoValue 471 | - !If 472 | - UsingMultiRegionEventBridge 473 | - !Sub 474 | - 'arn:aws:secretsmanager:${SecondaryRegion}:${AWS::AccountId}:secret:${SecretNameWithSha}' 475 | - { SecretNameWithSha: !Select [1, !Split [':secret:', !Sub '${EventBusNameSecret}' ]]} 476 | - !Ref AWS::NoValue 477 | - !If 478 | - UsingMultiRegionChime 479 | - !Sub 480 | - 'arn:aws:secretsmanager:${SecondaryRegion}:${AWS::AccountId}:secret:${SecretNameWithSha}' 481 | - { SecretNameWithSha: !Select [1, !Split [':secret:', !Sub '${ChimeChannelSecret}' ]]} 482 | - !Ref AWS::NoValue 483 | - !If 484 | - UsingMultiRegionCrossAccountRole 485 | - !Sub 486 | - 'arn:aws:secretsmanager:${SecondaryRegion}:${AWS::AccountId}:secret:${SecretNameWithSha}' 487 | - { SecretNameWithSha: !Select [1, !Split [':secret:', !Sub '${AssumeRoleSecret}' ]]} 488 | - !Ref AWS::NoValue 489 | - !Ref 'AWS::NoValue' 490 | - Effect: Allow 491 | Action: 492 | - health:DescribeAffectedAccountsForOrganization 493 | - health:DescribeAffectedEntitiesForOrganization 494 | - health:DescribeEventDetailsForOrganization 495 | - health:DescribeEventsForOrganization 496 | - health:DescribeEventDetails 497 | - health:DescribeEvents 498 | - health:DescribeEventTypes 499 | - health:DescribeAffectedEntities 500 | - organizations:ListAccounts 501 | - organizations:DescribeAccount 502 | Resource: "*" 503 | - Effect: Allow 504 | Action: 505 | - dynamodb:ListTables 506 | Resource: 507 | - !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:*' 508 | - !If [UsingMultiRegion, !Sub 'arn:aws:dynamodb:${SecondaryRegion}:${AWS::AccountId}:*', !Ref AWS::NoValue] 509 | - Effect: Allow 510 | Action: 511 | - ses:SendEmail 512 | Resource: 513 | - !Sub 'arn:aws:ses:${AWS::Region}:${AWS::AccountId}:*' 514 | - !If [UsingMultiRegion, !Sub 'arn:aws:ses:${SecondaryRegion}:${AWS::AccountId}:*', !Ref AWS::NoValue] 515 | - Effect: Allow 516 | Action: 517 | - dynamodb:UpdateTimeToLive 518 | - dynamodb:PutItem 519 | - dynamodb:DeleteItem 520 | - dynamodb:GetItem 521 | - dynamodb:Scan 522 | - dynamodb:Query 523 | - dynamodb:UpdateItem 524 | - dynamodb:UpdateTable 525 | - dynamodb:GetRecords 526 | Resource: !If [UsingMultiRegion, !GetAtt GlobalDDBTable.Arn, !GetAtt DynamoDBTable.Arn] 527 | - Effect: Allow 528 | Action: 529 | - events:PutEvents 530 | Resource: 531 | - !Sub 'arn:aws:events:${AWS::Region}:${AWS::AccountId}:event-bus/${EventBusName}' 532 | - !If [UsingMultiRegion, !Sub 'arn:aws:events:${SecondaryRegion}:${AWS::AccountId}:event-bus/${EventBusName}', !Ref AWS::NoValue] 533 | - !If 534 | - UsingAccountIds 535 | - Effect: Allow 536 | Action: 537 | - s3:GetObject 538 | Resource: !Sub 'arn:aws:s3:::${S3Bucket}/${AccountIDs}' 539 | - !Ref 'AWS::NoValue' 540 | - !If 541 | - UsingCrossAccountRole 542 | - Effect: Allow 543 | Action: 544 | - sts:AssumeRole 545 | Resource: !Ref ManagementAccountRoleArn 546 | - !Ref 'AWS::NoValue' 547 | LambdaSchedule: 548 | Type: 'AWS::Events::Rule' 549 | Properties: 550 | Description: Lambda trigger Event 551 | ScheduleExpression: rate(1 minute) 552 | State: ENABLED 553 | Targets: 554 | - Arn: !GetAtt LambdaFunction.Arn 555 | Id: LambdaSchedule 556 | LambdaSchedulePermission: 557 | Type: 'AWS::Lambda::Permission' 558 | Properties: 559 | Action: 'lambda:InvokeFunction' 560 | FunctionName: !GetAtt LambdaFunction.Arn 561 | Principal: events.amazonaws.com 562 | SourceArn: !GetAtt LambdaSchedule.Arn 563 | MicrosoftChannelSecret: 564 | Type: 'AWS::SecretsManager::Secret' 565 | Condition: UsingTeams 566 | Properties: 567 | Name: MicrosoftChannelID 568 | Description: Microsoft Channel ID Secret 569 | ReplicaRegions: 570 | !If 571 | - UsingMultiRegion 572 | - [{ Region: !Sub '${SecondaryRegion}' }] 573 | - !Ref "AWS::NoValue" 574 | SecretString: 575 | Ref: MicrosoftTeamsWebhookURL 576 | Tags: 577 | - Key: HealthCheckMicrosoft 578 | Value: ChannelID 579 | SlackChannelSecret: 580 | Type: 'AWS::SecretsManager::Secret' 581 | Condition: UsingSlack 582 | Properties: 583 | Name: SlackChannelID 584 | Description: Slack Channel ID Secret 585 | ReplicaRegions: 586 | !If 587 | - UsingMultiRegion 588 | - [{ Region: !Sub '${SecondaryRegion}' }] 589 | - !Ref "AWS::NoValue" 590 | SecretString: 591 | Ref: SlackWebhookURL 592 | Tags: 593 | - Key: HealthCheckSlack 594 | Value: ChannelID 595 | EventBusNameSecret: 596 | Type: 'AWS::SecretsManager::Secret' 597 | Condition: UsingEventBridge 598 | Properties: 599 | Name: EventBusName 600 | Description: EventBus Name Secret 601 | ReplicaRegions: 602 | !If 603 | - UsingMultiRegion 604 | - [{ Region: !Sub '${SecondaryRegion}' }] 605 | - !Ref "AWS::NoValue" 606 | SecretString: 607 | Ref: EventBusName 608 | Tags: 609 | - Key: EventBusName 610 | Value: ChannelID 611 | ChimeChannelSecret: 612 | Type: 'AWS::SecretsManager::Secret' 613 | Condition: UsingChime 614 | Properties: 615 | Name: ChimeChannelID 616 | Description: Chime Channel ID Secret 617 | ReplicaRegions: 618 | !If 619 | - UsingMultiRegion 620 | - [{ Region: !Sub '${SecondaryRegion}' }] 621 | - !Ref "AWS::NoValue" 622 | SecretString: 623 | Ref: AmazonChimeWebhookURL 624 | Tags: 625 | - Key: HealthCheckChime 626 | Value: ChannelID 627 | AssumeRoleSecret: 628 | Type: 'AWS::SecretsManager::Secret' 629 | Condition: UsingCrossAccountRole 630 | Properties: 631 | Name: AssumeRoleArn 632 | Description: Management account role for AHA to assume 633 | ReplicaRegions: 634 | !If 635 | - UsingMultiRegion 636 | - [{ Region: !Sub '${SecondaryRegion}' }] 637 | - !Ref "AWS::NoValue" 638 | SecretString: 639 | Ref: ManagementAccountRoleArn 640 | Tags: 641 | - Key: AssumeRoleArn 642 | Value: ChannelID 643 | LambdaFunction: 644 | Type: 'AWS::Lambda::Function' 645 | Properties: 646 | Description: Lambda function that runs AHA 647 | Code: 648 | S3Bucket: 649 | Ref: S3Bucket 650 | S3Key: 651 | Ref: S3Key 652 | Handler: handler.main 653 | MemorySize: 128 654 | Timeout: 600 655 | Role: 656 | 'Fn::Sub': '${LambdaExecutionRole.Arn}' 657 | Runtime: python3.11 658 | Environment: 659 | Variables: 660 | Slack: !If [UsingSlack, "True", !Ref 'AWS::NoValue'] 661 | Teams: !If [UsingTeams, "True", !Ref 'AWS::NoValue'] 662 | Chime: !If [UsingChime, "True", !Ref 'AWS::NoValue'] 663 | Eventbridge: !If [UsingEventBridge, "True", !Ref 'AWS::NoValue'] 664 | ACCOUNT_IDS: 665 | Ref: AccountIDs 666 | REGIONS: 667 | Ref: Regions 668 | S3_BUCKET: 669 | Ref: S3Bucket 670 | FROM_EMAIL: 671 | Ref: FromEmail 672 | TO_EMAIL: 673 | Ref: ToEmail 674 | EMAIL_SUBJECT: 675 | Ref: Subject 676 | DYNAMODB_TABLE: 677 | !If [UsingMultiRegion, !Ref GlobalDDBTable, !Ref DynamoDBTable] 678 | EVENT_SEARCH_BACK: 679 | Ref: EventSearchBack 680 | ORG_STATUS: 681 | Ref: AWSOrganizationsEnabled 682 | HEALTH_EVENT_TYPE: 683 | Ref: AWSHealthEventType 684 | MANAGEMENT_ROLE_ARN: 685 | Ref: ManagementAccountRoleArn 686 | 687 | -------------------------------------------------------------------------------- /terraform/Terraform_DEPLOY_AHA/Terraform_DEPLOY_AHA.tf: -------------------------------------------------------------------------------- 1 | # Terraform script to deploy AHA Solution 2 | # 1.0 - Initial version 3 | 4 | # Variables defined below, you can overwrite them using tfvars or imput variables 5 | 6 | data "aws_caller_identity" "current" {} 7 | 8 | provider "aws" { 9 | region = var.aha_primary_region 10 | default_tags { 11 | tags = "${var.default_tags}" 12 | } 13 | } 14 | 15 | # Secondary region - provider config 16 | locals { 17 | secondary_region = "${var.aha_secondary_region == "" ? var.aha_primary_region : var.aha_secondary_region}" 18 | } 19 | 20 | provider "aws" { 21 | alias = "secondary_region" 22 | region = local.secondary_region 23 | default_tags { 24 | tags = "${var.default_tags}" 25 | } 26 | } 27 | 28 | # Comment below - if needed to use s3_bucket, s3_key for consistency with cf 29 | locals { 30 | source_files = ["${path.module}/../../handler.py", "${path.module}/../../messagegenerator.py"] 31 | } 32 | data "archive_file" "lambda_zip" { 33 | type = "zip" 34 | output_path = "${path.module}/lambda_function.zip" 35 | source { 36 | filename = "${basename(local.source_files[0])}" 37 | content = file("${local.source_files[0]}") 38 | } 39 | source { 40 | filename = "${basename(local.source_files[1])}" 41 | content = file("${local.source_files[1]}") 42 | } 43 | } 44 | 45 | variable "aha_primary_region" { 46 | description = "Primary region where AHA solution will be deployed" 47 | type = string 48 | default = "us-east-1" 49 | } 50 | 51 | variable "aha_secondary_region" { 52 | description = "Secondary region where AHA solution will be deployed" 53 | type = string 54 | default = "" 55 | } 56 | 57 | variable "default_tags" { 58 | description = "Tags used for the AWS resources created by this template" 59 | type = map 60 | default = { 61 | Application = "AHA-Solution" 62 | } 63 | } 64 | 65 | variable "dynamodbtable" { 66 | type = string 67 | default = "AHA-DynamoDBTable" 68 | } 69 | 70 | variable "AWSOrganizationsEnabled" { 71 | type = string 72 | default = "No" 73 | description = "You can receive both PHD and SHD alerts if you're using AWS Organizations. \n If you are, make sure to enable Organizational Health View: \n (https://docs.aws.amazon.com/health/latest/ug/aggregate-events.html) to \n aggregate all PHD events in your AWS Organization. If not, you can still \n get SHD alerts." 74 | validation { 75 | condition = ( 76 | var.AWSOrganizationsEnabled == "Yes" || var.AWSOrganizationsEnabled == "No" 77 | ) 78 | error_message = "AWSOrganizationsEnabled variable can only accept Yes or No as values." 79 | } 80 | } 81 | 82 | variable "ManagementAccountRoleArn" { 83 | type = string 84 | default = "" 85 | description = "Arn of the IAM role in the top-level management account for collecting PHD Events. 'None' if deploying into the top-level management account." 86 | } 87 | 88 | variable "AWSHealthEventType" { 89 | type = string 90 | default = "issue | accountNotification | scheduledChange" 91 | description = "Select the event type that you want AHA to report on. Refer to \n https://docs.aws.amazon.com/health/latest/APIReference/API_EventType.html for more information on EventType." 92 | validation { 93 | condition = ( 94 | var.AWSHealthEventType == "issue | accountNotification | scheduledChange" || var.AWSHealthEventType == "issue" 95 | ) 96 | error_message = "AWSHealthEventType variable can only accept issue | accountNotification | scheduledChange or issue as values." 97 | } 98 | } 99 | 100 | #variable "S3Bucket" { 101 | # type = string 102 | # description = "Name of your S3 Bucket where the AHA Package .zip resides. Just the name of the bucket (e.g. my-s3-bucket)" 103 | # validation { 104 | # condition = length(var.S3Bucket) > 0 105 | # error_message = "The S3Bucket cannot be empty." 106 | # } 107 | #} 108 | # 109 | #variable "S3Key" { 110 | # type = string 111 | # description = "Name of the .zip in your S3 Bucket. Just the name of the file (e.g. aha-v1.0.zip)" 112 | # validation { 113 | # condition = length(var.S3Key) > 0 114 | # error_message = "The S3Key cannot be empty." 115 | # } 116 | #} 117 | 118 | variable "EventBusName" { 119 | type = string 120 | default = "" 121 | description = "This is to ingest alerts into AWS EventBridge. Enter the event bus name if you wish to send the alerts to the AWS EventBridge. Note: By ingesting you wish to send the alerts to the AWS EventBridge. Note: By ingesting these alerts to AWS EventBridge, you can integrate with 35 SaaS vendors such as DataDog/NewRelic/PagerDuty. If you don't prefer to use EventBridge, leave the default (None)." 122 | } 123 | 124 | variable "SlackWebhookURL" { 125 | type = string 126 | default = "" 127 | description = "Enter the Slack Webhook URL. If you don't prefer to use Slack, leave the default (empty)." 128 | } 129 | 130 | variable "MicrosoftTeamsWebhookURL" { 131 | type = string 132 | default = "" 133 | description = "Enter Microsoft Teams Webhook URL. If you don't prefer to use MS Teams, leave the default (empty)." 134 | } 135 | 136 | variable "AmazonChimeWebhookURL" { 137 | type = string 138 | default = "" 139 | description = "Enter the Chime Webhook URL, If you don't prefer to use Amazon Chime, leave the default (empty)." 140 | } 141 | 142 | variable "Regions" { 143 | type = string 144 | default = "all regions" 145 | description = "By default, AHA reports events affecting all AWS regions. \n If you want to report on certain regions you can enter up to 10 in a comma separated format. \n Available Regions: us-east-1,us-east-2,us-west-1,us-west-2,af-south-1,ap-east-1,ap-south-1,ap-northeast-3, \n ap-northeast-2,ap-southeast-1,ap-southeast-2,ap-northeast-1,ca-central-1,eu-central-1,eu-west-1,eu-west-2, \n eu-south-1,eu-south-3,eu-north-1,me-south-1,sa-east-1,global" 146 | } 147 | 148 | variable "EventSearchBack" { 149 | type = number 150 | default = "1" 151 | description = "How far back to search for events in hours. Default is 1 hour" 152 | } 153 | 154 | variable "FromEmail" { 155 | type = string 156 | default = "none@domain.com" 157 | description = "Enter FROM Email Address" 158 | } 159 | 160 | variable "ToEmail" { 161 | type = string 162 | default = "none@domain.com" 163 | description = "Enter email addresses separated by commas (for ex: abc@amazon.com, bcd@amazon.com)" 164 | } 165 | 166 | variable "Subject" { 167 | type = string 168 | default = "AWS Health Alert" 169 | description = "Enter the subject of the email address" 170 | } 171 | 172 | #variable "S3Bucket" { 173 | # type = string 174 | # description = "Name of your S3 Bucket where the AHA Package .zip resides. Just the name of the bucket (e.g. my-s3-bucket)" 175 | # default = "" 176 | #} 177 | 178 | variable "ExcludeAccountIDs" { 179 | type = string 180 | default = "" 181 | description = "If you would like to EXCLUDE any accounts from alerting, enter a .csv filename created with comma-seperated account numbers. Sample AccountIDs file name: aha_account_ids.csv. If not, leave the default empty." 182 | } 183 | 184 | ##### Resources for AHA Solution created below. 185 | 186 | # Random id generator 187 | resource "random_string" "resource_code" { 188 | length = 8 189 | special = false 190 | upper = false 191 | } 192 | 193 | # S3 buckets creation 194 | resource "aws_s3_bucket" "AHA-S3Bucket-PrimaryRegion" { 195 | count = "${var.ExcludeAccountIDs != "" ? 1 : 0}" 196 | bucket = "aha-bucket-${var.aha_primary_region}-${random_string.resource_code.result}" 197 | tags = { 198 | Name = "aha-bucket" 199 | } 200 | } 201 | 202 | resource "aws_s3_bucket_acl" "AHA-S3Bucket-PrimaryRegion" { 203 | count = var.ExcludeAccountIDs != "" ? 1 : 0 204 | bucket = aws_s3_bucket.AHA-S3Bucket-PrimaryRegion[0].id 205 | acl = "private" 206 | } 207 | 208 | resource "aws_s3_bucket" "AHA-S3Bucket-SecondaryRegion" { 209 | count = "${var.aha_secondary_region != "" && var.ExcludeAccountIDs != "" ? 1 : 0}" 210 | provider = aws.secondary_region 211 | bucket = "aha-bucket-${var.aha_secondary_region}-${random_string.resource_code.result}" 212 | tags = { 213 | Name = "aha-bucket" 214 | } 215 | } 216 | 217 | resource "aws_s3_bucket_acl" "AHA-S3Bucket-SecondaryRegion" { 218 | count = "${var.aha_secondary_region != "" && var.ExcludeAccountIDs != "" ? 1 : 0}" 219 | provider = aws.secondary_region 220 | bucket = aws_s3_bucket.AHA-S3Bucket-SecondaryRegion[0].id 221 | acl = "private" 222 | } 223 | 224 | resource "aws_s3_object" "AHA-S3Object-PrimaryRegion" { 225 | count = "${var.ExcludeAccountIDs != "" ? 1 : 0}" 226 | key = var.ExcludeAccountIDs 227 | bucket = aws_s3_bucket.AHA-S3Bucket-PrimaryRegion[0].bucket 228 | source = var.ExcludeAccountIDs 229 | tags = { 230 | Name = "${var.ExcludeAccountIDs}" 231 | } 232 | } 233 | 234 | resource "aws_s3_object" "AHA-S3Object-SecondaryRegion" { 235 | count = "${var.aha_secondary_region != "" && var.ExcludeAccountIDs != "" ? 1 : 0}" 236 | provider = aws.secondary_region 237 | key = var.ExcludeAccountIDs 238 | bucket = aws_s3_bucket.AHA-S3Bucket-SecondaryRegion[0].bucket 239 | source = var.ExcludeAccountIDs 240 | tags = { 241 | Name = "${var.ExcludeAccountIDs}" 242 | } 243 | } 244 | 245 | # DynamoDB table - Create if secondary region not set 246 | resource "aws_dynamodb_table" "AHA-DynamoDBTable" { 247 | count = "${var.aha_secondary_region == "" ? 1 : 0}" 248 | billing_mode = "PROVISIONED" 249 | hash_key = "arn" 250 | name = "${var.dynamodbtable}-${random_string.resource_code.result}" 251 | read_capacity = 5 252 | write_capacity = 5 253 | stream_enabled = false 254 | tags = { 255 | Name = "${var.dynamodbtable}" 256 | } 257 | 258 | attribute { 259 | name = "arn" 260 | type = "S" 261 | } 262 | 263 | point_in_time_recovery { 264 | enabled = false 265 | } 266 | 267 | timeouts {} 268 | 269 | ttl { 270 | attribute_name = "ttl" 271 | enabled = true 272 | } 273 | } 274 | 275 | # DynamoDB table - Multi region Global Table - Create if secondary region is set 276 | resource "aws_dynamodb_table" "AHA-GlobalDynamoDBTable" { 277 | count = "${var.aha_secondary_region == "" ? 0 : 1}" 278 | billing_mode = "PAY_PER_REQUEST" 279 | hash_key = "arn" 280 | name = "${var.dynamodbtable}-${random_string.resource_code.result}" 281 | stream_enabled = true 282 | stream_view_type = "NEW_AND_OLD_IMAGES" 283 | tags = { 284 | Name = "${var.dynamodbtable}" 285 | } 286 | 287 | attribute { 288 | name = "arn" 289 | type = "S" 290 | } 291 | 292 | point_in_time_recovery { 293 | enabled = false 294 | } 295 | 296 | replica { 297 | region_name = var.aha_secondary_region 298 | } 299 | 300 | timeouts {} 301 | 302 | ttl { 303 | attribute_name = "ttl" 304 | enabled = true 305 | } 306 | } 307 | # Tags for DynamoDB - secondary region 308 | resource "aws_dynamodb_tag" "AHA-GlobalDynamoDBTable" { 309 | count = "${var.aha_secondary_region == "" ? 0 : 1}" 310 | provider = aws.secondary_region 311 | resource_arn = replace(aws_dynamodb_table.AHA-GlobalDynamoDBTable[count.index].arn, var.aha_primary_region, var.aha_secondary_region) 312 | key = "Name" 313 | value = "${var.dynamodbtable}" 314 | } 315 | # Tags for DynamoDB - secondary region - default_tags 316 | resource "aws_dynamodb_tag" "AHA-GlobalDynamoDBTable-Additional-tags" { 317 | for_each = { for key, value in var.default_tags : key => value if var.aha_secondary_region != "" } 318 | provider = aws.secondary_region 319 | resource_arn = replace(aws_dynamodb_table.AHA-GlobalDynamoDBTable[0].arn, var.aha_primary_region, var.aha_secondary_region) 320 | key = each.key 321 | value = each.value 322 | } 323 | 324 | # Secrets - SlackChannelSecret 325 | resource "aws_secretsmanager_secret" "SlackChannelID" { 326 | count = "${var.SlackWebhookURL == "" ? 0 : 1}" 327 | name = "SlackChannelID" 328 | description = "Slack Channel ID Secret" 329 | recovery_window_in_days = 0 330 | tags = { 331 | "HealthCheckSlack" = "ChannelID" 332 | } 333 | dynamic "replica" { 334 | for_each = var.aha_secondary_region == "" ? [] : [1] 335 | content { 336 | region = var.aha_secondary_region 337 | } 338 | } 339 | } 340 | resource "aws_secretsmanager_secret_version" "SlackChannelID" { 341 | count = "${var.SlackWebhookURL == "" ? 0 : 1}" 342 | secret_id = "${aws_secretsmanager_secret.SlackChannelID.*.id[count.index]}" 343 | secret_string = "${var.SlackWebhookURL}" 344 | } 345 | 346 | # Secrets - MicrosoftChannelSecret 347 | resource "aws_secretsmanager_secret" "MicrosoftChannelID" { 348 | count = "${var.MicrosoftTeamsWebhookURL == "" ? 0 : 1}" 349 | name = "MicrosoftChannelID" 350 | description = "Microsoft Channel ID Secret" 351 | recovery_window_in_days = 0 352 | tags = { 353 | "HealthCheckMicrosoft" = "ChannelID" 354 | "Name" = "AHA-MicrosoftChannelID" 355 | } 356 | dynamic "replica" { 357 | for_each = var.aha_secondary_region == "" ? [] : [1] 358 | content { 359 | region = var.aha_secondary_region 360 | } 361 | } 362 | } 363 | resource "aws_secretsmanager_secret_version" "MicrosoftChannelID" { 364 | count = "${var.MicrosoftTeamsWebhookURL == "" ? 0 : 1}" 365 | secret_id = "${aws_secretsmanager_secret.MicrosoftChannelID.*.id[count.index]}" 366 | secret_string = "${var.MicrosoftTeamsWebhookURL}" 367 | } 368 | 369 | # Secrets - EventBusNameSecret 370 | resource "aws_secretsmanager_secret" "EventBusName" { 371 | count = "${var.EventBusName == "" ? 0 : 1}" 372 | name = "EventBusName" 373 | description = "EventBus Name Secret" 374 | recovery_window_in_days = 0 375 | tags = { 376 | "EventBusName" = "ChannelID" 377 | "Name" = "AHA-EventBusName" 378 | } 379 | dynamic "replica" { 380 | for_each = var.aha_secondary_region == "" ? [] : [1] 381 | content { 382 | region = var.aha_secondary_region 383 | } 384 | } 385 | } 386 | 387 | resource "aws_secretsmanager_secret_version" "EventBusName" { 388 | count = "${var.EventBusName == "" ? 0 : 1}" 389 | secret_id = "${aws_secretsmanager_secret.EventBusName.*.id[count.index]}" 390 | secret_string = "${var.EventBusName}" 391 | } 392 | 393 | # Secrets - ChimeChannelSecret 394 | resource "aws_secretsmanager_secret" "ChimeChannelID" { 395 | count = "${var.AmazonChimeWebhookURL == "" ? 0 : 1}" 396 | name = "ChimeChannelID" 397 | description = "Chime Channel ID Secret" 398 | recovery_window_in_days = 0 399 | tags = { 400 | "HealthCheckChime" = "ChannelID" 401 | "Name" = "AHA-ChimeChannelID-${random_string.resource_code.result}" 402 | } 403 | dynamic "replica" { 404 | for_each = var.aha_secondary_region == "" ? [] : [1] 405 | content { 406 | region = var.aha_secondary_region 407 | } 408 | } 409 | } 410 | resource "aws_secretsmanager_secret_version" "ChimeChannelID" { 411 | count = "${var.AmazonChimeWebhookURL == "" ? 0 : 1}" 412 | secret_id = "${aws_secretsmanager_secret.ChimeChannelID.*.id[count.index]}" 413 | secret_string = "${var.AmazonChimeWebhookURL}" 414 | } 415 | 416 | # Secrets - AssumeRoleSecret 417 | resource "aws_secretsmanager_secret" "AssumeRoleArn" { 418 | count = "${var.ManagementAccountRoleArn == "" ? 0 : 1}" 419 | name = "AssumeRoleArn" 420 | description = "Management account role for AHA to assume" 421 | recovery_window_in_days = 0 422 | tags = { 423 | "AssumeRoleArn" = "" 424 | "Name" = "AHA-AssumeRoleArn" 425 | } 426 | dynamic "replica" { 427 | for_each = var.aha_secondary_region == "" ? [] : [1] 428 | content { 429 | region = var.aha_secondary_region 430 | } 431 | } 432 | } 433 | resource "aws_secretsmanager_secret_version" "AssumeRoleArn" { 434 | count = "${var.ManagementAccountRoleArn == "" ? 0 : 1}" 435 | secret_id = "${aws_secretsmanager_secret.AssumeRoleArn.*.id[count.index]}" 436 | secret_string = "${var.ManagementAccountRoleArn}" 437 | } 438 | 439 | # IAM Role for Lambda function execution 440 | resource "aws_iam_role" "AHA-LambdaExecutionRole" { 441 | name = "AHA-LambdaExecutionRole-${random_string.resource_code.result}" 442 | path = "/" 443 | assume_role_policy = jsonencode( 444 | { 445 | Version = "2012-10-17" 446 | Statement = [ 447 | { 448 | Action = "sts:AssumeRole" 449 | Effect = "Allow" 450 | Principal = { 451 | Service = "lambda.amazonaws.com" 452 | } 453 | }, 454 | ] 455 | } 456 | ) 457 | inline_policy { 458 | name = "AHA-LambdaPolicy" 459 | policy = data.aws_iam_policy_document.AHA-LambdaPolicy-Document.json 460 | } 461 | tags = { 462 | "Name" = "AHA-LambdaExecutionRole" 463 | } 464 | } 465 | 466 | data "aws_iam_policy_document" "AHA-LambdaPolicy-Document" { 467 | version = "2012-10-17" 468 | statement { 469 | effect = "Allow" 470 | actions = [ 471 | "logs:CreateLogGroup", 472 | "logs:CreateLogStream", 473 | "logs:PutLogEvents", 474 | ] 475 | resources = [ 476 | "arn:aws:logs:${var.aha_primary_region}:${data.aws_caller_identity.current.account_id}:*", 477 | "arn:aws:logs:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:*" 478 | ] 479 | } 480 | statement { 481 | effect = "Allow" 482 | actions = [ 483 | "health:DescribeAffectedAccountsForOrganization", 484 | "health:DescribeAffectedEntitiesForOrganization", 485 | "health:DescribeEventDetailsForOrganization", 486 | "health:DescribeEventsForOrganization", 487 | "health:DescribeEventDetails", 488 | "health:DescribeEvents", 489 | "health:DescribeEventTypes", 490 | "health:DescribeAffectedEntities", 491 | "organizations:ListAccounts", 492 | "organizations:DescribeAccount", 493 | ] 494 | resources = [ "*" ] 495 | } 496 | statement { 497 | effect = "Allow" 498 | actions = [ 499 | "dynamodb:ListTables", 500 | ] 501 | resources = [ 502 | "arn:aws:dynamodb:${var.aha_primary_region}:${data.aws_caller_identity.current.account_id}:*", 503 | "arn:aws:dynamodb:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:*", 504 | ] 505 | } 506 | statement { 507 | effect = "Allow" 508 | actions = [ 509 | "ses:SendEmail", 510 | ] 511 | resources = [ 512 | "arn:aws:ses:${var.aha_primary_region}:${data.aws_caller_identity.current.account_id}:*", 513 | "arn:aws:ses:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:*", 514 | ] 515 | } 516 | statement { 517 | effect = "Allow" 518 | actions = [ 519 | "dynamodb:UpdateTimeToLive", 520 | "dynamodb:PutItem", 521 | "dynamodb:DeleteItem", 522 | "dynamodb:GetItem", 523 | "dynamodb:Scan", 524 | "dynamodb:Query", 525 | "dynamodb:UpdateItem", 526 | "dynamodb:UpdateTable", 527 | "dynamodb:GetRecords", 528 | ] 529 | resources = [ 530 | #aws_dynamodb_table.AHA-DynamoDBTable.arn 531 | "arn:aws:dynamodb:${var.aha_primary_region}:${data.aws_caller_identity.current.account_id}:table/${var.dynamodbtable}-${random_string.resource_code.result}", 532 | "arn:aws:dynamodb:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:table/${var.dynamodbtable}-${random_string.resource_code.result}", 533 | ] 534 | } 535 | dynamic "statement" { 536 | for_each = var.SlackWebhookURL == "" ? [] : [1] 537 | content { 538 | effect = "Allow" 539 | actions = [ 540 | "secretsmanager:GetResourcePolicy", 541 | "secretsmanager:DescribeSecret", 542 | "secretsmanager:ListSecretVersionIds", 543 | "secretsmanager:GetSecretValue", 544 | ] 545 | resources = [ 546 | aws_secretsmanager_secret.SlackChannelID[0].arn, 547 | "arn:aws:secretsmanager:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:secret:${element(split(":", aws_secretsmanager_secret.SlackChannelID[0].arn),6)}" 548 | # var.aha_secondary_region != "" ? "arn:aws:secretsmanager:${var.aha_secondary_region}:${data.aws_caller_identity.current.account_id}:secret:${element(split(":", aws_secretsmanager_secret.SlackChannelID[0].arn),6)}" : null 549 | ] 550 | } 551 | } 552 | dynamic "statement" { 553 | for_each = var.MicrosoftTeamsWebhookURL == "" ? [] : [1] 554 | content { 555 | effect = "Allow" 556 | actions = [ 557 | "secretsmanager:GetResourcePolicy", 558 | "secretsmanager:DescribeSecret", 559 | "secretsmanager:ListSecretVersionIds", 560 | "secretsmanager:GetSecretValue", 561 | ] 562 | resources = [ 563 | aws_secretsmanager_secret.MicrosoftChannelID[0].arn, 564 | "arn:aws:secretsmanager:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:secret:${element(split(":", aws_secretsmanager_secret.MicrosoftChannelID[0].arn),6)}" 565 | ] 566 | } 567 | } 568 | dynamic "statement" { 569 | for_each = var.AmazonChimeWebhookURL == "" ? [] : [1] 570 | content { 571 | effect = "Allow" 572 | actions = [ 573 | "secretsmanager:GetResourcePolicy", 574 | "secretsmanager:DescribeSecret", 575 | "secretsmanager:ListSecretVersionIds", 576 | "secretsmanager:GetSecretValue", 577 | ] 578 | resources = [ 579 | aws_secretsmanager_secret.ChimeChannelID[0].arn, 580 | "arn:aws:secretsmanager:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:secret:${element(split(":", aws_secretsmanager_secret.ChimeChannelID[0].arn),6)}" 581 | ] 582 | } 583 | } 584 | dynamic "statement" { 585 | for_each = var.EventBusName == "" ? [] : [1] 586 | content { 587 | effect = "Allow" 588 | actions = [ 589 | "secretsmanager:GetResourcePolicy", 590 | "secretsmanager:DescribeSecret", 591 | "secretsmanager:ListSecretVersionIds", 592 | "secretsmanager:GetSecretValue", 593 | ] 594 | resources = [ 595 | aws_secretsmanager_secret.EventBusName[0].arn, 596 | "arn:aws:secretsmanager:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:secret:${element(split(":", aws_secretsmanager_secret.EventBusName[0].arn),6)}" 597 | ] 598 | } 599 | } 600 | dynamic "statement" { 601 | for_each = var.ManagementAccountRoleArn == "" ? [] : [1] 602 | content { 603 | effect = "Allow" 604 | actions = [ 605 | "secretsmanager:GetResourcePolicy", 606 | "secretsmanager:DescribeSecret", 607 | "secretsmanager:ListSecretVersionIds", 608 | "secretsmanager:GetSecretValue", 609 | ] 610 | resources = [ 611 | aws_secretsmanager_secret.AssumeRoleArn[0].arn, 612 | "arn:aws:secretsmanager:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:secret:${element(split(":", aws_secretsmanager_secret.AssumeRoleArn[0].arn),6)}" 613 | ] 614 | } 615 | } 616 | dynamic "statement" { 617 | for_each = var.EventBusName == "" ? [] : [1] 618 | content { 619 | effect = "Allow" 620 | actions = [ 621 | "events:PutEvents", 622 | ] 623 | resources = [ 624 | "arn:aws:events:${var.aha_primary_region}:${data.aws_caller_identity.current.account_id}:event-bus/${var.EventBusName}", 625 | "arn:aws:events:${local.secondary_region}:${data.aws_caller_identity.current.account_id}:event-bus/${var.EventBusName}" 626 | ] 627 | } 628 | } 629 | dynamic "statement" { 630 | for_each = var.ManagementAccountRoleArn == "" ? [] : [1] 631 | content { 632 | effect = "Allow" 633 | actions = [ 634 | "sts:AssumeRole", 635 | ] 636 | resources = [ 637 | "${var.ManagementAccountRoleArn}", 638 | ] 639 | } 640 | } 641 | dynamic "statement" { 642 | for_each = var.ExcludeAccountIDs == "" ? [] : [1] 643 | content { 644 | effect = "Allow" 645 | actions = [ 646 | "s3:GetObject", 647 | ] 648 | resources = [ 649 | "arn:aws:s3:::aha-bucket-${var.aha_primary_region}-${random_string.resource_code.result}/${var.ExcludeAccountIDs}", 650 | "arn:aws:s3:::aha-bucket-${local.secondary_region}-${random_string.resource_code.result}/${var.ExcludeAccountIDs}", 651 | ] 652 | } 653 | } 654 | } 655 | # aws_lambda_function - AHA-LambdaFunction - Primary region 656 | resource "aws_lambda_function" "AHA-LambdaFunction-PrimaryRegion" { 657 | description = "Lambda function that runs AHA" 658 | function_name = "AHA-LambdaFunction-${random_string.resource_code.result}" 659 | handler = "handler.main" 660 | memory_size = 128 661 | timeout = 600 662 | filename = data.archive_file.lambda_zip.output_path 663 | source_code_hash = data.archive_file.lambda_zip.output_base64sha256 664 | # s3_bucket = var.S3Bucket 665 | # s3_key = var.S3Key 666 | reserved_concurrent_executions = -1 667 | role = aws_iam_role.AHA-LambdaExecutionRole.arn 668 | runtime = "python3.11" 669 | 670 | environment { 671 | variables = { 672 | "Slack" = var.SlackWebhookURL != "" ? "True" : null 673 | "Team" = var.MicrosoftTeamsWebhookURL != "" ? "True" : null 674 | "Chime" = var.AmazonChimeWebhookURL != "" ? "True" : null 675 | "Eventbridge" = var.EventBusName != "" ? "True" : null 676 | "DYNAMODB_TABLE" = "${var.dynamodbtable}-${random_string.resource_code.result}" 677 | "EMAIL_SUBJECT" = var.Subject 678 | "EVENT_SEARCH_BACK" = var.EventSearchBack 679 | "FROM_EMAIL" = var.FromEmail 680 | "HEALTH_EVENT_TYPE" = var.AWSHealthEventType 681 | "ORG_STATUS" = var.AWSOrganizationsEnabled 682 | "REGIONS" = var.Regions 683 | "TO_EMAIL" = var.ToEmail 684 | "MANAGEMENT_ROLE_ARN" = var.ManagementAccountRoleArn == "" ? "None" : var.ManagementAccountRoleArn 685 | "ACCOUNT_IDS" = var.ExcludeAccountIDs 686 | "S3_BUCKET" = join("",aws_s3_bucket.AHA-S3Bucket-PrimaryRegion[*].bucket) 687 | } 688 | } 689 | 690 | timeouts {} 691 | 692 | tracing_config { 693 | mode = "PassThrough" 694 | } 695 | tags = { 696 | "Name" = "AHA-LambdaFunction" 697 | } 698 | depends_on = [ 699 | aws_dynamodb_table.AHA-DynamoDBTable, 700 | aws_dynamodb_table.AHA-GlobalDynamoDBTable, 701 | ] 702 | } 703 | 704 | # aws_lambda_function - AHA-LambdaFunction - Secondary region 705 | resource "aws_lambda_function" "AHA-LambdaFunction-SecondaryRegion" { 706 | count = "${var.aha_secondary_region == "" ? 0 : 1}" 707 | provider = aws.secondary_region 708 | description = "Lambda function that runs AHA" 709 | function_name = "AHA-LambdaFunction-${random_string.resource_code.result}" 710 | handler = "handler.main" 711 | memory_size = 128 712 | timeout = 600 713 | filename = data.archive_file.lambda_zip.output_path 714 | source_code_hash = data.archive_file.lambda_zip.output_base64sha256 715 | # s3_bucket = var.S3Bucket 716 | # s3_key = var.S3Key 717 | reserved_concurrent_executions = -1 718 | role = aws_iam_role.AHA-LambdaExecutionRole.arn 719 | runtime = "python3.11" 720 | 721 | environment { 722 | variables = { 723 | "Slack" = var.SlackWebhookURL != "" ? "True" : null 724 | "Team" = var.MicrosoftTeamsWebhookURL != "" ? "True" : null 725 | "Chime" = var.AmazonChimeWebhookURL != "" ? "True" : null 726 | "Eventbridge" = var.EventBusName != "" ? "True" : null 727 | "DYNAMODB_TABLE" = "${var.dynamodbtable}-${random_string.resource_code.result}" 728 | "EMAIL_SUBJECT" = var.Subject 729 | "EVENT_SEARCH_BACK" = var.EventSearchBack 730 | "FROM_EMAIL" = var.FromEmail 731 | "HEALTH_EVENT_TYPE" = var.AWSHealthEventType 732 | "ORG_STATUS" = var.AWSOrganizationsEnabled 733 | "REGIONS" = var.Regions 734 | "TO_EMAIL" = var.ToEmail 735 | "MANAGEMENT_ROLE_ARN" = var.ManagementAccountRoleArn 736 | "ACCOUNT_IDS" = var.ExcludeAccountIDs 737 | "S3_BUCKET" = join("",aws_s3_bucket.AHA-S3Bucket-SecondaryRegion[*].bucket) 738 | } 739 | } 740 | 741 | timeouts {} 742 | 743 | tracing_config { 744 | mode = "PassThrough" 745 | } 746 | tags = { 747 | "Name" = "AHA-LambdaFunction" 748 | } 749 | depends_on = [ 750 | aws_dynamodb_table.AHA-DynamoDBTable, 751 | aws_dynamodb_table.AHA-GlobalDynamoDBTable, 752 | ] 753 | } 754 | 755 | # EventBridge - Schedule to run lambda 756 | resource "aws_cloudwatch_event_rule" "AHA-LambdaSchedule-PrimaryRegion" { 757 | description = "Lambda trigger Event" 758 | event_bus_name = "default" 759 | state = "ENABLED" 760 | name = "AHA-LambdaSchedule-${random_string.resource_code.result}" 761 | schedule_expression = "rate(1 minute)" 762 | tags = { 763 | "Name" = "AHA-LambdaSchedule" 764 | } 765 | } 766 | resource "aws_cloudwatch_event_rule" "AHA-LambdaSchedule-SecondaryRegion" { 767 | description = "Lambda trigger Event" 768 | count = "${var.aha_secondary_region == "" ? 0 : 1}" 769 | provider = aws.secondary_region 770 | event_bus_name = "default" 771 | state = "ENABLED" 772 | name = "AHA-LambdaSchedule-${random_string.resource_code.result}" 773 | schedule_expression = "rate(1 minute)" 774 | tags = { 775 | "Name" = "AHA-LambdaSchedule" 776 | } 777 | } 778 | 779 | resource "aws_cloudwatch_event_target" "AHA-LambdaFunction-PrimaryRegion" { 780 | arn = aws_lambda_function.AHA-LambdaFunction-PrimaryRegion.arn 781 | rule = aws_cloudwatch_event_rule.AHA-LambdaSchedule-PrimaryRegion.name 782 | } 783 | resource "aws_cloudwatch_event_target" "AHA-LambdaFunction-SecondaryRegion" { 784 | count = "${var.aha_secondary_region == "" ? 0 : 1}" 785 | provider = aws.secondary_region 786 | arn = aws_lambda_function.AHA-LambdaFunction-SecondaryRegion[0].arn 787 | rule = aws_cloudwatch_event_rule.AHA-LambdaSchedule-SecondaryRegion[0].name 788 | } 789 | 790 | resource "aws_lambda_permission" "AHA-LambdaSchedulePermission-PrimaryRegion" { 791 | action = "lambda:InvokeFunction" 792 | principal = "events.amazonaws.com" 793 | function_name = aws_lambda_function.AHA-LambdaFunction-PrimaryRegion.arn 794 | source_arn = aws_cloudwatch_event_rule.AHA-LambdaSchedule-PrimaryRegion.arn 795 | } 796 | resource "aws_lambda_permission" "AHA-LambdaSchedulePermission-SecondaryRegion" { 797 | count = "${var.aha_secondary_region == "" ? 0 : 1}" 798 | provider = aws.secondary_region 799 | action = "lambda:InvokeFunction" 800 | principal = "events.amazonaws.com" 801 | function_name = aws_lambda_function.AHA-LambdaFunction-SecondaryRegion[0].arn 802 | source_arn = aws_cloudwatch_event_rule.AHA-LambdaSchedule-SecondaryRegion[0].arn 803 | } 804 | 805 | -------------------------------------------------------------------------------- /messagegenerator.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | import sys 4 | import logging 5 | 6 | logger = logging.getLogger() 7 | 8 | 9 | def get_message_for_slack(event_details, event_type, affected_accounts, affected_entities, slack_webhook): 10 | message = "" 11 | summary = "" 12 | if slack_webhook == "services": #Handle "Incoming Webhook" webhooks 13 | if len(affected_entities) >= 1: 14 | affected_entities = "\n".join(affected_entities) 15 | if affected_entities == "UNKNOWN": 16 | affected_entities = "All resources\nin region" 17 | else: 18 | affected_entities = "All resources\nin region" 19 | if len(affected_accounts) >= 1: 20 | affected_accounts = "\n".join(affected_accounts) 21 | else: 22 | affected_accounts = "All accounts\nin region" 23 | if event_type == "create": 24 | summary += ( 25 | f":rotating_light:*[NEW] AWS Health reported an issue with the {event_details['successfulSet'][0]['event']['service'].upper()} service in " 26 | f"the {event_details['successfulSet'][0]['event']['region'].upper()} region.*" 27 | ) 28 | message = { 29 | "text": summary, 30 | "attachments": [ 31 | { 32 | "color": "danger", 33 | "fields": [ 34 | { "title": "Account(s)", "value": affected_accounts, "short": True }, 35 | { "title": "Resource(s)", "value": affected_entities, "short": True }, 36 | { "title": "Service", "value": event_details['successfulSet'][0]['event']['service'], "short": True }, 37 | { "title": "Region", "value": event_details['successfulSet'][0]['event']['region'], "short": True }, 38 | { "title": "Start Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event']['startTime']), "short": True }, 39 | { "title": "Status", "value": event_details['successfulSet'][0]['event']['statusCode'], "short": True }, 40 | { "title": "Event ARN", "value": event_details['successfulSet'][0]['event']['arn'], "short": False }, 41 | { "title": "Updates", "value": get_last_aws_update(event_details), "short": False } 42 | ], 43 | } 44 | ] 45 | } 46 | 47 | elif event_type == "resolve": 48 | summary += ( 49 | f":heavy_check_mark:*[RESOLVED] The AWS Health issue with the {event_details['successfulSet'][0]['event']['service'].upper()} service in " 50 | f"the {event_details['successfulSet'][0]['event']['region'].upper()} region is now resolved.*" 51 | ) 52 | message = { 53 | "text": summary, 54 | "attachments": [ 55 | { 56 | "color": "00ff00", 57 | "fields": [ 58 | { "title": "Account(s)", "value": affected_accounts, "short": True }, 59 | { "title": "Resource(s)", "value": affected_entities, "short": True }, 60 | { "title": "Service", "value": event_details['successfulSet'][0]['event']['service'], "short": True }, 61 | { "title": "Region", "value": event_details['successfulSet'][0]['event']['region'], "short": True }, 62 | { "title": "Start Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event']['startTime']), "short": True }, 63 | { "title": "End Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event'].get('endTime')), "short": True }, 64 | { "title": "Status", "value": event_details['successfulSet'][0]['event']['statusCode'], "short": True }, 65 | { "title": "Event ARN", "value": event_details['successfulSet'][0]['event']['arn'], "short": False }, 66 | { "title": "Updates", "value": get_last_aws_update(event_details), "short": False } 67 | ], 68 | } 69 | ] 70 | } 71 | else: #Handle 'workflows' or 'triggers' webhooks 72 | if len(affected_entities) >= 1: 73 | affected_entities = "\n".join(affected_entities) 74 | if affected_entities == "UNKNOWN": 75 | affected_entities = "All resources\nin region" 76 | else: 77 | affected_entities = "All resources in region" 78 | if len(affected_accounts) >= 1: 79 | affected_accounts = "\n".join(affected_accounts) 80 | else: 81 | affected_accounts = "All accounts in region" 82 | if event_type == "create": 83 | summary += ( 84 | f":rotating_light:*[NEW] AWS Health reported an issue with the {event_details['successfulSet'][0]['event']['service'].upper()} service in " 85 | f"the {event_details['successfulSet'][0]['event']['region'].upper()} region.*" 86 | ) 87 | message = { 88 | "text": summary, 89 | "accounts": affected_accounts, 90 | "resources": affected_entities, 91 | "service": event_details['successfulSet'][0]['event']['service'], 92 | "region": event_details['successfulSet'][0]['event']['region'], 93 | "start_time": cleanup_time(event_details['successfulSet'][0]['event']['startTime']), 94 | "status": event_details['successfulSet'][0]['event']['statusCode'], 95 | "event_arn": event_details['successfulSet'][0]['event']['arn'], 96 | "updates": get_last_aws_update(event_details) 97 | } 98 | 99 | elif event_type == "resolve": 100 | summary += ( 101 | f":heavy_check_mark:*[RESOLVED] The AWS Health issue with the {event_details['successfulSet'][0]['event']['service'].upper()} service in " 102 | f"the {event_details['successfulSet'][0]['event']['region'].upper()} region is now resolved.*" 103 | ) 104 | message = { 105 | "text": summary, 106 | "accounts": affected_accounts, 107 | "resources": affected_entities, 108 | "service": event_details['successfulSet'][0]['event']['service'], 109 | "region": event_details['successfulSet'][0]['event']['region'], 110 | "start_time": cleanup_time(event_details['successfulSet'][0]['event']['startTime']), 111 | "status": event_details['successfulSet'][0]['event']['statusCode'], 112 | "event_arn": event_details['successfulSet'][0]['event']['arn'], 113 | "updates": get_last_aws_update(event_details) 114 | } 115 | 116 | print("Message sent to Slack: ", message) 117 | return message 118 | 119 | # COMMON compose the event detail field for org and non-org 120 | def get_detail_for_eventbridge(event_details, affected_entities): 121 | 122 | message = {} 123 | 124 | #replace the key "arn" with eventArn to match event format from aws.health 125 | message["eventArn"] = "" 126 | message.update(event_details['successfulSet'][0]['event']) 127 | message["eventArn"] = message.pop("arn") 128 | #message = event_details['successfulSet'][0]['event'] 129 | 130 | message["eventDescription"] = event_details["successfulSet"][0]["eventDescription"] 131 | message["affectedEntities"] = affected_entities 132 | 133 | # Log length of json message for debugging if eventbridge may reject the message as messages 134 | # are limited in size to 256KB 135 | json_message = json.dumps(message) 136 | print("PHD/SHD Message generated for EventBridge with estimated size ", str(sys.getsizeof(json_message) / 1024), "KB: ", message) 137 | 138 | return message 139 | 140 | def get_org_message_for_slack(event_details, event_type, affected_org_accounts, affected_org_entities, slack_webhook): 141 | message = "" 142 | summary = "" 143 | if slack_webhook == "webhook": 144 | if len(affected_org_entities) >= 1: 145 | affected_org_entities = "\n".join(affected_org_entities) 146 | else: 147 | affected_org_entities = "All resources\nin region" 148 | if len(affected_org_accounts) >= 1: 149 | affected_org_accounts = "\n".join(affected_org_accounts) 150 | else: 151 | affected_org_accounts = "All accounts\nin region" 152 | if event_type == "create": 153 | summary += ( 154 | f":rotating_light:*[NEW] AWS Health reported an issue with the {event_details['successfulSet'][0]['event']['service'].upper()} service in " 155 | f"the {event_details['successfulSet'][0]['event']['region'].upper()} region.*" 156 | ) 157 | message = { 158 | "text": summary, 159 | "attachments": [ 160 | { 161 | "color": "danger", 162 | "fields": [ 163 | { "title": "Account(s)", "value": affected_org_accounts, "short": True }, 164 | { "title": "Resource(s)", "value": affected_org_entities, "short": True }, 165 | { "title": "Service", "value": event_details['successfulSet'][0]['event']['service'], "short": True }, 166 | { "title": "Region", "value": event_details['successfulSet'][0]['event']['region'], "short": True }, 167 | { "title": "Start Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event']['startTime']), "short": True }, 168 | { "title": "Status", "value": event_details['successfulSet'][0]['event']['statusCode'], "short": True }, 169 | { "title": "Event ARN", "value": event_details['successfulSet'][0]['event']['arn'], "short": False }, 170 | { "title": "Updates", "value": get_last_aws_update(event_details), "short": False } 171 | ], 172 | } 173 | ] 174 | } 175 | 176 | elif event_type == "resolve": 177 | summary += ( 178 | f":heavy_check_mark:*[RESOLVED] The AWS Health issue with the {event_details['successfulSet'][0]['event']['service'].upper()} service in " 179 | f"the {event_details['successfulSet'][0]['event']['region'].upper()} region is now resolved.*" 180 | ) 181 | message = { 182 | "text": summary, 183 | "attachments": [ 184 | { 185 | "color": "00ff00", 186 | "fields": [ 187 | { "title": "Account(s)", "value": affected_org_accounts, "short": True }, 188 | { "title": "Resource(s)", "value": affected_org_entities, "short": True }, 189 | { "title": "Service", "value": event_details['successfulSet'][0]['event']['service'], "short": True }, 190 | { "title": "Region", "value": event_details['successfulSet'][0]['event']['region'], "short": True }, 191 | { "title": "Start Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event']['startTime']), "short": True }, 192 | { "title": "End Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event'].get('endTime')), "short": True }, 193 | { "title": "Status", "value": event_details['successfulSet'][0]['event']['statusCode'], "short": True }, 194 | { "title": "Event ARN", "value": event_details['successfulSet'][0]['event']['arn'], "short": False }, 195 | { "title": "Updates", "value": get_last_aws_update(event_details), "short": False } 196 | ], 197 | } 198 | ] 199 | } 200 | else: 201 | if len(affected_org_entities) >= 1: 202 | affected_org_entities = "\n".join(affected_org_entities) 203 | else: 204 | affected_org_entities = "All resources in region" 205 | if len(affected_org_accounts) >= 1: 206 | affected_org_accounts = "\n".join(affected_org_accounts) 207 | else: 208 | affected_org_accounts = "All accounts in region" 209 | if event_type == "create": 210 | summary += ( 211 | f":rotating_light:*[NEW] AWS Health reported an issue with the {event_details['successfulSet'][0]['event']['service'].upper()} service in " 212 | f"the {event_details['successfulSet'][0]['event']['region'].upper()} region.*" 213 | ) 214 | message = { 215 | "text": summary, 216 | "accounts": affected_org_accounts, 217 | "resources": affected_org_entities, 218 | "service": event_details['successfulSet'][0]['event']['service'], 219 | "region": event_details['successfulSet'][0]['event']['region'], 220 | "start_time": cleanup_time(event_details['successfulSet'][0]['event']['startTime']), 221 | "status": event_details['successfulSet'][0]['event']['statusCode'], 222 | "event_arn": event_details['successfulSet'][0]['event']['arn'], 223 | "updates": get_last_aws_update(event_details) 224 | } 225 | 226 | elif event_type == "resolve": 227 | summary += ( 228 | f":heavy_check_mark:*[RESOLVED] The AWS Health issue with the {event_details['successfulSet'][0]['event']['service'].upper()} service in " 229 | f"the {event_details['successfulSet'][0]['event']['region'].upper()} region is now resolved.*" 230 | ) 231 | message = { 232 | "text": summary, 233 | "accounts": affected_org_accounts, 234 | "resources": affected_org_entities, 235 | "service": event_details['successfulSet'][0]['event']['service'], 236 | "region": event_details['successfulSet'][0]['event']['region'], 237 | "start_time": cleanup_time(event_details['successfulSet'][0]['event']['startTime']), 238 | "status": event_details['successfulSet'][0]['event']['statusCode'], 239 | "event_arn": event_details['successfulSet'][0]['event']['arn'], 240 | "updates": get_last_aws_update(event_details) 241 | } 242 | json.dumps(message) 243 | print("Message sent to Slack: ", message) 244 | return message 245 | 246 | 247 | def get_message_for_chime(event_details, event_type, affected_accounts, affected_entities): 248 | message = "" 249 | if len(affected_entities) >= 1: 250 | affected_entities = "\n".join(affected_entities) 251 | if affected_entities == "UNKNOWN": 252 | affected_entities = "All resources\nin region" 253 | else: 254 | affected_entities = "All resources\nin region" 255 | if len(affected_accounts) >= 1: 256 | affected_accounts = "\n".join(affected_accounts) 257 | else: 258 | affected_accounts = "All accounts\nin region" 259 | summary = "" 260 | if event_type == "create": 261 | 262 | message = str("/md" + "\n" + "**:rotating_light:\[NEW\] AWS Health reported an issue with the " + event_details['successfulSet'][0]['event']['service'].upper() + " service in " + event_details['successfulSet'][0]['event']['region'].upper() + " region.**" + "\n" 263 | "---" + "\n" 264 | "**Account(s)**: " + affected_accounts + "\n" 265 | "**Resource(s)**: " + affected_entities + "\n" 266 | "**Service**: " + event_details['successfulSet'][0]['event']['service'] + "\n" 267 | "**Region**: " + event_details['successfulSet'][0]['event']['region'] + "\n" 268 | "**Start Time (UTC)**: " + cleanup_time(event_details['successfulSet'][0]['event']['startTime']) + "\n" 269 | "**Status**: " + event_details['successfulSet'][0]['event']['statusCode'] + "\n" 270 | "**Event ARN**: " + event_details['successfulSet'][0]['event']['arn'] + "\n" 271 | "**Updates:**" + "\n" + get_last_aws_update(event_details) 272 | ) 273 | 274 | elif event_type == "resolve": 275 | 276 | message = str("/md" + "\n" + "**:heavy_check_mark:\[RESOLVED\] The AWS Health issue with the " + event_details['successfulSet'][0]['event']['service'].upper() + " service in " + event_details['successfulSet'][0]['event']['region'].upper() + " region is now resolved.**" + "\n" 277 | "---" + "\n" 278 | "**Account(s)**: " + affected_accounts + "\n" 279 | "**Resource(s)**: " + affected_entities + "\n" 280 | "**Service**: " + event_details['successfulSet'][0]['event']['service'] + "\n" 281 | "**Region**: " + event_details['successfulSet'][0]['event']['region'] + "\n" 282 | "**Start Time (UTC)**: " + cleanup_time(event_details['successfulSet'][0]['event']['startTime']) + "\n" 283 | "**End Time (UTC)**: " + cleanup_time(event_details['successfulSet'][0]['event'].get('endTime')) + "\n" 284 | "**Status**: " + event_details['successfulSet'][0]['event']['statusCode'] + "\n" 285 | "**Event ARN**: " + event_details['successfulSet'][0]['event']['arn'] + "\n" 286 | "**Updates:**" + "\n" + get_last_aws_update(event_details) 287 | ) 288 | message = truncate_message_if_needed(message, 4096) 289 | print("Message sent to Chime: ", message) 290 | return message 291 | 292 | 293 | def get_org_message_for_chime(event_details, event_type, affected_org_accounts, affected_org_entities): 294 | message = "" 295 | summary = "" 296 | if len(affected_org_entities) >= 1: 297 | affected_org_entities = "\n".join(affected_org_entities) 298 | else: 299 | affected_org_entities = "All resources in region" 300 | if len(affected_org_accounts) >= 1: 301 | affected_org_accounts = "\n".join(affected_org_accounts) 302 | else: 303 | affected_org_accounts = "All accounts in region" 304 | if event_type == "create": 305 | 306 | message = str("/md" + "\n" + "**:rotating_light:\[NEW\] AWS Health reported an issue with the " + event_details['successfulSet'][0]['event']['service'].upper()) + " service in " + str(event_details['successfulSet'][0]['event']['region'].upper() + " region**" + "\n" 307 | "---" + "\n" 308 | "**Account(s)**: " + affected_org_accounts + "\n" 309 | "**Resource(s)**: " + affected_org_entities + "\n" 310 | "**Service**: " + event_details['successfulSet'][0]['event']['service'] + "\n" 311 | "**Region**: " + event_details['successfulSet'][0]['event']['region'] + "\n" 312 | "**Start Time (UTC)**: " + cleanup_time(event_details['successfulSet'][0]['event']['startTime']) + "\n" 313 | "**Status**: " + event_details['successfulSet'][0]['event']['statusCode'] + "\n" 314 | "**Event ARN**: " + event_details['successfulSet'][0]['event']['arn'] + "\n" 315 | "**Updates:**" + "\n" + get_last_aws_update(event_details) 316 | ) 317 | 318 | elif event_type == "resolve": 319 | 320 | message = str("/md" + "\n" + "**:heavy_check_mark:\[RESOLVED\] The AWS Health issue with the " + event_details['successfulSet'][0]['event']['service'].upper()) + " service in " + str(event_details['successfulSet'][0]['event']['region'].upper() + " region is now resolved.**" + "\n" 321 | "---" + "\n" 322 | "**Account(s)**: " + affected_org_accounts + "\n" 323 | "**Resource(s)**: " + affected_org_entities + "\n" 324 | "**Service**: " + event_details['successfulSet'][0]['event']['service'] + "\n" 325 | "**Region**: " + event_details['successfulSet'][0]['event']['region'] + "\n" 326 | "**Start Time (UTC)**: " + cleanup_time(event_details['successfulSet'][0]['event']['startTime']) + "\n" 327 | "**End Time (UTC)**: " + cleanup_time(event_details['successfulSet'][0]['event'].get('endTime')) + "\n" 328 | "**Status**: " + event_details['successfulSet'][0]['event']['statusCode'] + "\n" 329 | "**Event ARN**: " + event_details['successfulSet'][0]['event']['arn'] + "\n" 330 | "**Updates:**" + "\n" + get_last_aws_update(event_details) 331 | ) 332 | message = truncate_message_if_needed(message, 4096) 333 | print("Message sent to Chime: ", message) 334 | return message 335 | 336 | 337 | 338 | def get_message_for_teams(event_details, event_type, affected_accounts, affected_entities): 339 | message = "" 340 | if len(affected_entities) >= 1: 341 | affected_entities = "\n".join(affected_entities) 342 | if affected_entities == "UNKNOWN": 343 | affected_entities = "All resources\nin region" 344 | else: 345 | affected_entities = "All resources\nin region" 346 | if len(affected_accounts) >= 1: 347 | affected_accounts = "\n".join(affected_accounts) 348 | else: 349 | affected_accounts = "All accounts\nin region" 350 | summary = "" 351 | if event_type == "create": 352 | title = "🚨 [NEW] AWS Health reported an issue with the " + event_details['successfulSet'][0]['event'][ 353 | 'service'].upper() + " service in the " + event_details['successfulSet'][0]['event'][ 354 | 'region'].upper() + " region." 355 | message = { 356 | "@type": "MessageCard", 357 | "@context": "http://schema.org/extensions", 358 | "themeColor": "FF0000", 359 | "summary": "AWS Health Aware Alert", 360 | "sections": [ 361 | { 362 | "activityTitle": str(title), 363 | "markdown": False, 364 | "facts": [ 365 | {"name": "Account(s)", "value": affected_accounts}, 366 | {"name": "Resource(s)", "value": affected_entities}, 367 | {"name": "Service", "value": event_details['successfulSet'][0]['event']['service']}, 368 | {"name": "Region", "value": event_details['successfulSet'][0]['event']['region']}, 369 | {"name": "Start Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event']['startTime'])}, 370 | {"name": "Status", "value": event_details['successfulSet'][0]['event']['statusCode']}, 371 | {"name": "Event ARN", "value": event_details['successfulSet'][0]['event']['arn']}, 372 | {"name": "Updates", "value": get_last_aws_update(event_details)} 373 | ], 374 | } 375 | ] 376 | } 377 | 378 | elif event_type == "resolve": 379 | title = "✅ [RESOLVED] The AWS Health issue with the " + event_details['successfulSet'][0]['event'][ 380 | 'service'].upper() + " service in the " + event_details['successfulSet'][0]['event'][ 381 | 'region'].upper() + " region is now resolved." 382 | message = { 383 | "@type": "MessageCard", 384 | "@context": "http://schema.org/extensions", 385 | "themeColor": "00ff00", 386 | "summary": "AWS Health Aware Alert", 387 | "sections": [ 388 | { 389 | "activityTitle": str(title), 390 | "markdown": False, 391 | "facts": [ 392 | {"name": "Account(s)", "value": affected_accounts}, 393 | {"name": "Resource(s)", "value": affected_entities}, 394 | {"name": "Service", "value": event_details['successfulSet'][0]['event']['service']}, 395 | {"name": "Region", "value": event_details['successfulSet'][0]['event']['region']}, 396 | {"name": "Start Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event']['startTime'])}, 397 | {"name": "End Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event'].get('endTime'))}, 398 | {"name": "Status", "value": event_details['successfulSet'][0]['event']['statusCode']}, 399 | {"name": "Event ARN", "value": event_details['successfulSet'][0]['event']['arn']}, 400 | {"name": "Updates", "value": get_last_aws_update(event_details)} 401 | ], 402 | } 403 | ] 404 | } 405 | print("Message sent to Teams: ", message) 406 | return message 407 | 408 | 409 | def get_org_message_for_teams(event_details, event_type, affected_org_accounts, affected_org_entities): 410 | message = "" 411 | summary = "" 412 | if len(affected_org_entities) >= 1: 413 | affected_org_entities = "\n".join(affected_org_entities) 414 | else: 415 | affected_org_entities = "All resources in region" 416 | if len(affected_org_accounts) >= 1: 417 | affected_org_accounts = "\n".join(affected_org_accounts) 418 | else: 419 | affected_org_accounts = "All accounts in region" 420 | if event_type == "create": 421 | title = "🚨 [NEW] AWS Health reported an issue with the " + event_details['successfulSet'][0]['event'][ 422 | 'service'].upper() + " service in the " + event_details['successfulSet'][0]['event'][ 423 | 'region'].upper() + " region." 424 | message = { 425 | "@type": "MessageCard", 426 | "@context": "http://schema.org/extensions", 427 | "themeColor": "FF0000", 428 | "summary": "AWS Health Aware Alert", 429 | "sections": [ 430 | { 431 | "activityTitle": title, 432 | "markdown": False, 433 | "facts": [ 434 | {"name": "Account(s)", "value": affected_org_accounts}, 435 | {"name": "Resource(s)", "value": affected_org_entities}, 436 | {"name": "Service", "value": event_details['successfulSet'][0]['event']['service']}, 437 | {"name": "Region", "value": event_details['successfulSet'][0]['event']['region']}, 438 | {"name": "Start Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event']['startTime'])}, 439 | {"name": "Status", "value": event_details['successfulSet'][0]['event']['statusCode']}, 440 | {"name": "Event ARN", "value": event_details['successfulSet'][0]['event']['arn']}, 441 | {"name": "Updates", "value": event_details['successfulSet'][0]['eventDescription']['latestDescription']} 442 | ], 443 | } 444 | ] 445 | } 446 | 447 | elif event_type == "resolve": 448 | title = "✅ [RESOLVED] The AWS Health issue with the " + event_details['successfulSet'][0]['event'][ 449 | 'service'].upper() + " service in the " + event_details['successfulSet'][0]['event'][ 450 | 'region'].upper() + " region is now resolved." 451 | message = { 452 | "@type": "MessageCard", 453 | "@context": "http://schema.org/extensions", 454 | "themeColor": "00ff00", 455 | "summary": "AWS Health Aware Alert", 456 | "sections": [ 457 | { 458 | "activityTitle": title, 459 | "markdown": False, 460 | "facts": [ 461 | {"name": "Account(s)", "value": affected_org_accounts}, 462 | {"name": "Resource(s)", "value": affected_org_entities}, 463 | {"name": "Service", "value": event_details['successfulSet'][0]['event']['service']}, 464 | {"name": "Region", "value": event_details['successfulSet'][0]['event']['region']}, 465 | {"name": "Start Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event']['startTime'])}, 466 | {"name": "End Time (UTC)", "value": cleanup_time(event_details['successfulSet'][0]['event'].get('endTime'))}, 467 | {"name": "Status", "value": event_details['successfulSet'][0]['event']['statusCode']}, 468 | {"name": "Event ARN", "value": event_details['successfulSet'][0]['event']['arn']}, 469 | {"name": "Updates", "value": event_details['successfulSet'][0]['eventDescription']['latestDescription']} 470 | ], 471 | } 472 | ] 473 | } 474 | return message 475 | print("Message sent to Teams: ", message) 476 | 477 | 478 | def get_message_for_email(event_details, event_type, affected_accounts, affected_entities): 479 | # Not srue why we have the new line in the affected entities code here 480 | if len(affected_entities) >= 1: 481 | affected_entities = "\n".join(affected_entities) 482 | if affected_entities == "UNKNOWN": 483 | affected_entities = "All resources\nin region" 484 | else: 485 | affected_entities = "All resources\nin region" 486 | if len(affected_accounts) >= 1: 487 | affected_accounts = "\n".join(affected_accounts) 488 | else: 489 | affected_accounts = "All accounts\nin region" 490 | if event_type == "create": 491 | BODY_HTML = f""" 492 | 493 | 494 | Greetings from AWS Health Aware,
495 |

There is an AWS incident that is in effect which may likely impact your resources. Here are the details:

496 | Account(s): {affected_accounts}
497 | Resource(s): {affected_entities}
498 | Service: {event_details['successfulSet'][0]['event']['service']}
499 | Region: {event_details['successfulSet'][0]['event']['region']}
500 | Start Time (UTC): {cleanup_time(event_details['successfulSet'][0]['event']['startTime'])}
501 | Status: {event_details['successfulSet'][0]['event']['statusCode']}
502 | Event ARN: {event_details['successfulSet'][0]['event']['arn']}
503 | Updates: {event_details['successfulSet'][0]['eventDescription']['latestDescription']}

504 | For updates, please visit the AWS Service Health Dashboard
505 | If you are experiencing issues related to this event, please open an AWS Support case within your account.

506 | Thanks,

AHA: AWS Health Aware 507 |

508 | 509 | 510 | """ 511 | else: 512 | BODY_HTML = f""" 513 | 514 | 515 | Greetings again from AWS Health Aware,
516 |

Good news! The AWS Health incident from earlier has now been marked as resolved.

517 | Account(s): {affected_accounts}
518 | Resource(s): {affected_entities}
519 | Service: {event_details['successfulSet'][0]['event']['service']}
520 | Region: {event_details['successfulSet'][0]['event']['region']}
521 | Start Time (UTC): {cleanup_time(event_details['successfulSet'][0]['event']['startTime'])}
522 | End Time (UTC): {cleanup_time(event_details['successfulSet'][0]['event'].get('endTime'))}
523 | Status: {event_details['successfulSet'][0]['event']['statusCode']}
524 | Event ARN: {event_details['successfulSet'][0]['event']['arn']}
525 | Updates: {event_details['successfulSet'][0]['eventDescription']['latestDescription']}

526 | If you are still experiencing issues related to this event, please open an AWS Support case within your account.

527 |

528 | Thanks,

AHA: AWS Health Aware 529 |

530 | 531 | 532 | """ 533 | print("Message sent to Email: ", BODY_HTML) 534 | return BODY_HTML 535 | 536 | 537 | def get_org_message_for_email(event_details, event_type, affected_org_accounts, affected_org_entities): 538 | if len(affected_org_entities) >= 1: 539 | affected_org_entities = "\n".join(affected_org_entities) 540 | else: 541 | affected_org_entities = "All services related resources in region" 542 | if len(affected_org_accounts) >= 1: 543 | affected_org_accounts = "\n".join(affected_org_accounts) 544 | else: 545 | affected_org_accounts = "All accounts in region" 546 | if event_type == "create": 547 | BODY_HTML = f""" 548 | 549 | 550 | Greetings from AWS Health Aware,
551 |

There is an AWS incident that is in effect which may likely impact your resources. Here are the details:

552 | Account(s): {affected_org_accounts}
553 | Resource(s): {affected_org_entities}
554 | Service: {event_details['successfulSet'][0]['event']['service']}
555 | Region: {event_details['successfulSet'][0]['event']['region']}
556 | Start Time (UTC): {cleanup_time(event_details['successfulSet'][0]['event']['startTime'])}
557 | Status: {event_details['successfulSet'][0]['event']['statusCode']}
558 | Event ARN: {event_details['successfulSet'][0]['event']['arn']}
559 | Updates: {event_details['successfulSet'][0]['eventDescription']['latestDescription']}

560 | For updates, please visit the AWS Service Health Dashboard
561 | If you are experiencing issues related to this event, please open an AWS Support case within your account.

562 | Thanks,

AHA: AWS Health Aware 563 |

564 | 565 | 566 | """ 567 | else: 568 | BODY_HTML = f""" 569 | 570 | 571 | Greetings again from AWS Health Aware,
572 |

Good news! The AWS Health incident from earlier has now been marked as resolved.

573 | Account(s): {affected_org_accounts}
574 | Resource(s): {affected_org_entities}
575 | Service: {event_details['successfulSet'][0]['event']['service']}
576 | Region: {event_details['successfulSet'][0]['event']['region']}
577 | Start Time (UTC): {cleanup_time(event_details['successfulSet'][0]['event']['startTime'])}
578 | End Time (UTC): {cleanup_time(event_details['successfulSet'][0]['event'].get('endTime'))}
579 | Status: {event_details['successfulSet'][0]['event']['statusCode']}
580 | Event ARN: {event_details['successfulSet'][0]['event']['arn']}
581 | Updates: {event_details['successfulSet'][0]['eventDescription']['latestDescription']}

582 | If you are still experiencing issues related to this event, please open an AWS Support case within your account.

583 | Thanks,

AHA: AWS Health Aware 584 |

585 | 586 | 587 | """ 588 | print("Message sent to Email: ", BODY_HTML) 589 | return BODY_HTML 590 | 591 | 592 | def cleanup_time(event_time): 593 | """ 594 | Takes as input a datetime string as received from The AWS Health event_detail call. It converts this string to a 595 | datetime object, changes the timezone to EST and then formats it into a readable string to display in Slack. 596 | 597 | :param event_time: datetime string 598 | :type event_time: str 599 | :return: A formatted string that includes the month, date, year and 12-hour time. 600 | :rtype: str 601 | """ 602 | if not event_time: 603 | return "Unknown" 604 | 605 | event_time = datetime.strptime(event_time[:16], '%Y-%m-%d %H:%M') 606 | return event_time.strftime("%Y-%m-%d %H:%M:%S") 607 | 608 | 609 | def get_last_aws_update(event_details): 610 | """ 611 | Takes as input the event_details and returns the last update from AWS (instead of the entire timeline) 612 | 613 | :param event_details: Detailed information about a specific AWS health event. 614 | :type event_details: dict 615 | :return: the last update message from AWS 616 | :rtype: str 617 | """ 618 | aws_message = event_details['successfulSet'][0]['eventDescription']['latestDescription'] 619 | return aws_message 620 | 621 | 622 | def format_date(event_time): 623 | """ 624 | Takes as input a datetime string as received from The AWS Health event_detail call. It converts this string to a 625 | datetime object, changes the timezone to EST and then formats it into a readable string to display in Slack. 626 | 627 | :param event_time: datetime string 628 | :type event_time: str 629 | :return: A formatted string that includes the month, date, year and 12-hour time. 630 | :rtype: str 631 | """ 632 | event_time = datetime.strptime(event_time[:16], '%Y-%m-%d %H:%M') 633 | return event_time.strftime('%B %d, %Y at %I:%M %p') 634 | 635 | 636 | def truncate_message_if_needed(message, max_length): 637 | """ 638 | Truncates the message if it exceeds the specified maximum length. 639 | 640 | :param message: Message you want to truncate. 641 | :type message: str 642 | :param max_length: Length at which to truncate the message. 643 | :type max_length: int 644 | :return: Possibly truncated message. 645 | :rtype: str 646 | """ 647 | message_length = len(message) 648 | if message_length > max_length: 649 | print(f"Message length of {message_length} is too long, truncating to {max_length}.") 650 | message = message[:(max_length - 3)] + "..." 651 | return message 652 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![](https://github.com/aws-samples/aws-health-aware/blob/main/readme-images/aha_banner.png?raw=1) 3 | 4 | 5 | **Table of Contents** 6 | 7 | - [Introduction](#introduction) 8 | - [What's New](#whats-new) 9 | - [Architecture](#architecture) 10 | - [Single Region](#single-region) 11 | - [Multi Region](#multi-region) 12 | - [Created AWS Resources](#created-aws-resources) 13 | - [Configuring an Endpoint](#configuring-an-endpoint) 14 | - [Creating a Amazon Chime Webhook URL](#creating-a-amazon-chime-webhook-url) 15 | - [Creating a Slack Webhook URL](#creating-a-slack-webhook-url) 16 | - [Creating a Microsoft Teams Webhook URL](#creating-a-microsoft-teams-webhook-url) 17 | - [Configuring an Email](#configuring-an-email) 18 | - [Creating a Amazon EventBridge Ingestion ARN](#creating-a-amazon-eventbridge-ingestion-arn) 19 | - [Deployment Options](#deployment-options) 20 | - [Using AWS Health Delegated Administrator with AHA](#using-aws-health-delegated-administrator-with-aha) 21 | - [CloudFormation](#cloudformation) 22 | - [AHA Without AWS Organizations using CloudFormation](#aha-without-aws-organizations-using-cloudformation) 23 | - [Prerequisites](#prerequisites) 24 | - [Deployment](#deployment) 25 | - [AHA With AWS Organizations on Management Account using CloudFormation](#aha-with-aws-organizations-on-management-or-delegated-administrator-account-using-cloudformation) 26 | - [Prerequisites](#prerequisites-1) 27 | - [Deployment](#deployment-1) 28 | - [AHA With AWS Organizations on Member Account using CloudFormation](#aha-with-aws-organizations-on-member-account-using-cloudformation) 29 | - [Prerequisites](#prerequisites-2) 30 | - [Deployment](#deployment-2) 31 | - [Terraform](#terraform) 32 | - [AHA Without AWS Organizations using Terraform](#aha-without-aws-organizations-using-terraform) 33 | - [Prerequisites](#prerequisites-3) 34 | - [Deployment - Terraform](#deployment---terraform) 35 | - [AHA WITH AWS Organizations on Management Account using Terraform](#aha-with-aws-organizations-on-management-or-delegated-administrator-account-using-terraform) 36 | - [Deployment - Terraform](#deployment---terraform-1) 37 | - [AHA WITH AWS Organizations on Member Account using Terraform](#aha-with-aws-organizations-on-member-account-using-terraform) 38 | - [Deployment - Terraform](#deployment---terraform-2) 39 | - [Updating using CloudFormation](#updating-using-cloudformation) 40 | - [Updating using Terraform](#updating-using-terraform) 41 | - [New Features](#new-features) 42 | - [Troubleshooting](#troubleshooting) 43 | 44 | # Introduction 45 | AWS Health Aware (AHA) is an automated notification tool for sending well-formatted AWS Health Alerts to Amazon Chime, Slack, Microsoft Teams, E-mail or an AWS Eventbridge compatible endpoint as long as you have Business or Enterprise Support. 46 | 47 | # What's New 48 | 49 | Release 2.3 introduces runtime performance improvements, terraform updates, allows use of Slack Workflow 2.0 webhooks (triggers), general fixes and documentation updates. 50 | 51 | # Architecture 52 | 53 | ## Single Region 54 | ![](https://github.com/aws-samples/aws-health-aware/blob/main/readme-images/aha-arch-single-region.png?raw=1) 55 | 56 | ## Multi Region 57 | ![](https://github.com/aws-samples/aws-health-aware/blob/main/readme-images/aha-arch-multi-region.png?raw=1) 58 | 59 | ## Created AWS Resources 60 | 61 | | Resource | Description | 62 | | ------------- | ------------------------------ | 63 | | `DynamoDBTable` | DynamoDB Table used to store Event ARNs, updates and TTL | 64 | | `ChimeChannelSecret` | Webhook URL for Amazon Chime stored in AWS Secrets Manager | 65 | | `EventBusNameSecret` | EventBus ARN for Amazon EventBridge stored in AWS Secrets Manager | 66 | | `LambdaExecutionRole` | IAM role used for LambdaFunction | 67 | | `LambdaFunction` | Main Lambda function that reads from AWS Health API, sends to endpoints and writes to DynamoDB | 68 | | `LambdaSchedule` | Amazon EventBridge rule that runs every min to invoke LambdaFunction | 69 | | `LambdaSchedulePermission` | IAM Role used for LambdaSchedule | 70 | | `MicrosoftChannelSecret` | Webhook URL for Microsoft Teams stored in AWS Secrets Manager | 71 | | `SlackChannelSecret` | Webhook URL for Slack stored in AWS Secrets Manager | 72 | 73 | # Configuring an Endpoint 74 | AHA can send to multiple endpoints (webhook URLs, Email or EventBridge). To use any of these you'll need to set it up before-hand as some of these are done on 3rd party websites. We'll go over some of the common ones here. 75 | 76 | ## Creating a Amazon Chime Webhook URL 77 | **You will need to have access to create a Amazon Chime room and manage webhooks.** 78 | 79 | 1. Create a new [chat room](https://docs.aws.amazon.com/chime/latest/ug/chime-chat-room.html) for events (i.e. aws_events). 80 | 2. In the chat room created in step 1, **click** on the gear icon and **click** *manage webhooks and bots*. 81 | 3. **Click** *Add webhook*. 82 | 4. **Type** a name for the bot (e.g. AWS Health Bot) and **click** *Create*. 83 | 5. **Click** *Copy URL*, we will need it for the deployment. 84 | 85 | ## Creating a Slack Webhook URL 86 | **You will need to have access to add a new channel and app to your Slack Workspace**. 87 | 88 | *Webhook* 89 | 1. Create a new [channel](https://slack.com/help/articles/201402297-Create-a-channel) for events (i.e. aws_events) 90 | 2. In your browser go to: workspace-name.slack.com/apps where workspace-name is the name of your Slack Workspace. 91 | 3. In the search bar, search for: *Incoming Webhooks* and **click** on it. 92 | 4. **Click** on *Add to Slack*. 93 | 5. From the dropdown **click** on the channel your created in step 1 and **click** *Add Incoming Webhooks integration*. 94 | 6. From this page you can change the name of the webhook (i.e. AWS Bot), the icon/emoji to use, etc. 95 | 7. For the deployment we will need the *Webhook URL*. 96 | 97 | *Workflow* 98 | 99 | 1. Create a new [channel](https://slack.com/help/articles/201402297-Create-a-channel) for events (i.e. aws_events) 100 | 2. Within Slack **click** on your workspace name drop down arrow in the upper left. **click on Tools > Workflow Builder** 101 | 3. **Click** Create in the upper right hand corner of the Workflow Builder and give your workflow a name **click** next. 102 | 4. **Click** on *select* next to **Webhook** and then **click** *add variable* add the following variables one at a time in the *Key* section. All *data type* will be *text*: 103 | -text 104 | -accounts 105 | -resources 106 | -service 107 | -region 108 | -status 109 | -start_time 110 | -event_arn 111 | -updates 112 | 5. When done you should have 9 variables, double check them as they are case sensitive and will be referenced. When checked **click** on *done* and *next*. 113 | 6. **Click** on *add step* and then on the add a workflow step **click** *add* next to *send a message*. 114 | 7. Under *send this message to:* select the channel you created in Step 1 in *message text* you can should recreate this following: 115 | ![](https://github.com/aws-samples/aws-health-aware/blob/main/readme-images//workflow.png?raw=1) 116 | 8. **Click** *save* and the **click** *publish* 117 | 9. For the deployment we will need the *Webhook URL*. 118 | 119 | ## Creating a Microsoft Teams Webhook URL 120 | **You will need to have access to add a new channel and app to your Microsoft Teams channel**. 121 | 122 | 1. Create a new [channel](https://docs.microsoft.com/en-us/microsoftteams/get-started-with-teams-create-your-first-teams-and-channels) for events (i.e. aws_events) 123 | 2. Within your Microsoft Team go to *Apps* 124 | 3. In the search bar, search for: *Incoming Webhook* and **click** on it. 125 | 4. **Click** on *Add to team*. 126 | 5. **Type** in the name of your on the channel your created in step 1 and **click** *Set up a connector*. 127 | 6. From this page you can change the name of the webhook (i.e. AWS Bot), the icon/emoji to use, etc. **Click** *Create* when done. 128 | 7. For the deployment we will need the webhook *URL* that is presented. 129 | 130 | ## Configuring an Email 131 | 132 | 1. You'll be able to send email alerts to one or many addresses. However, you must first [verify](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses-procedure.html) the email(s) in the Simple Email Service (SES) console. 133 | 2. AHA utilizes Amazon SES so all you need is to enter in a To: address and a From: address. 134 | 3. You *may* have to allow a rule in your environment so that the emails don't get labeled as SPAM. This will be something you have to congfigure on your own. 135 | 136 | ## Creating a Amazon EventBridge Ingestion ARN 137 | **Only required if you are going to be using EventBridge, you can create new with the instructions below or use an existing one**. 138 | 139 | 1. In the AWS Console, search for **Amazon EventBridge**. 140 | 2. On the left hand side, **click** *Event buses*. 141 | 3. Under *Custom event* bus **click** *Create event bus* 142 | 4. Give your Event bus a name and **click** *Create*. 143 | 5. For the deployment we will need the *Name* of the Event bus **(not the ARN, e.g. aha-eb01)**. 144 | 145 | # Deployment Options 146 | 147 | ## Using AWS Health Delegated Administrator with AHA 148 | 149 | >NOTE: For users with company restrictions of use/deployment of resources in the organization management account. 150 | > 151 | >On 2023-07-27, AWS Health released the [Delegated Administrator feature](https://docs.aws.amazon.com/health/latest/ug/delegated-administrator-organizational-view.html). By enabling an account as a delegated administrator, you can use AHA in Organization Mode without the need to create and assume the management account IAM role. 152 | 153 | To enable this feature: 154 | 1. Know the AWS Account ID of your AWS account you want to enable as a delegated administrator for AWS Health (e.g. 123456789012) 155 | 1. In the Org Management Account, run the command `aws organizations register-delegated-administrator --account-id ACCOUNT_ID --service-principal health.amazonaws.com` replacing ACCOUNT_ID with the ID of your Member Account 156 | 1. Deploy AHA in your deletegated administrator account using the steps for: 157 | 158 | 1. [AHA for users who ARE using AWS Organizations (CloudFormation)](#aha-with-aws-organizations-on-management-or-delegated-administrator-account-using-cloudformation) 159 | 1. [AHA for users who ARE using AWS Organizations (Terraform)](#aha-with-aws-organizations-on-management-or-delegated-administrator-account-using-terraform) 160 | 161 | ## CloudFormation 162 | There are 3 available ways to deploy AHA, all are done via the same CloudFormation template to make deployment as easy as possible. 163 | 164 | The 3 deployment methods for AHA are: 165 | 166 | 1. [**AHA for users WITHOUT AWS Organizations**](#aha-without-aws-organizations-using-cloudformation): Users NOT using AWS Organizations. 167 | 2. [**AHA for users WITH AWS Organizations (Management Account)**](#aha-with-aws-organizations-on-management-or-delegated-administrator-account-using-cloudformation): Users who ARE using AWS Organizations and deploying in the top-level management account. 168 | 3. [**AHA for users WITH AWS Organizations (Member Account)**](#aha-with-aws-organizations-on-member-account-using-cloudformation): Users who ARE using AWS Organizations and deploying in a member account in the organization to assume a role in the top-level management account. 169 | 170 | ## AHA Without AWS Organizations using CloudFormation 171 | 172 | ### Prerequisites 173 | 174 | 1. Have at least 1 [endpoint](#configuring-an-endpoint) configured (you can have multiple) 175 | 2. Have access to deploy Cloudformation Templates with the following resources: AWS IAM policies, Amazon DynamoDB Tables, AWS Lambda, Amazon EventBridge and AWS Secrets Manager. 176 | 3. If using Multi-Region, you must deploy the following 2 CloudFormation templates to allow the Stackset deployment to deploy resources **even if you have full administrator privileges, you still need to follow these steps**. 177 | - In CloudFormation Console create a stack with new resources from the following S3 URL: https://s3.amazonaws.com/cloudformation-stackset-sample-templates-us-east-1/AWSCloudFormationStackSetAdministrationRole.yml - this will allows CFT Stacksets to launch AHA in another region 178 | - Launch the stack. 179 | - In CloudFormation Console create a stack with new resources from the following S3 URL: https://s3.amazonaws.com/cloudformation-stackset-sample-templates-us-east-1/AWSCloudFormationStackSetExecutionRole.yml) - In *AdministratorAccountId* type in the 12 digit account number you're running the solution in (e.g. 000123456789) 180 | - Launch the stack. 181 | 182 | ### Deployment 183 | 184 | 1. Clone the AHA package that from this repository. If you're not familiar with the process, [here](https://git-scm.com/docs/git-clone) is some documentation. The URL to clone is in the upper right-hand corner labeled `Clone uri` 185 | 2. In the root of this package you'll have two files; `handler.py` and `messagegenerator.py`. Use your tool of choice to zip them both up and name them with a unique name (e.g. aha-v1.8.zip). **Note: Putting the version number in the name will make upgrading AHA seamless.** 186 | 3. Upload the .zip you created in Step 1 to an S3 in the same region you plan to deploy this in. 187 | 4. In your AWS console go to *CloudFormation*. 188 | 5. In the *CloudFormation* console **click** *Create stack > With new resources (standard)*. 189 | 6. Under *Template Source* **click** *Upload a template file* and **click** *Choose file* and select `CFN_DEPLOY_AHA.yml` **Click** *Next*. 190 | - In *Stack name* type a stack name (i.e. AHA-Deployment). 191 | - In *AWSOrganizationsEnabled* leave it set to default which is `No`. If you do have AWS Organizations enabled and you want to aggregate across all your accounts, you should be following the steps for [AHA for users who ARE using AWS Organizations (Management Account)](#aha-with-aws-organizations-on-management-or-delegated-administrator-account-using-cloudformation) or [AHA for users WITH AWS Organizations (Member Account)](#aha-with-aws-organizations-on-member-account-using-cloudformation) 192 | - In *AWSHealthEventType* select whether you want to receive *all* event types or *only* issues. 193 | - In *S3Bucket* type ***just*** the bucket name of the S3 bucket used in step 3 (e.g. my-aha-bucket). 194 | - In *S3Key* type ***just*** the name of the .zip file you created in Step 2 (e.g. aha-v1.8.zip). 195 | - In the *Communications Channels* section enter the URLs, Emails and/or ARN of the endpoints you configured previously. 196 | - In the *Email Setup* section enter the From and To Email addresses as well as the Email subject. If you aren't configuring email, just leave it as is. 197 | - In *EventSearchBack* enter in the amount of hours you want to search back for events. Default is 1 hour. 198 | - In *Regions* enter in the regions you want to search for events in. Default is all regions. You can filter for up to 10, comma separated (e.g. us-east-1, us-east-2). 199 | - In *ARN of the AWS Organizations Management Account assume role* leave it set to default None as this is only for customers using AWS Organizations. 200 | - In *Deploy in secondary region?* select another region to deploy AHA in. Otherwise leave to default No. 201 | 7. Scroll to the bottom and **click** *Next*. 202 | 8. Scroll to the bottom and **click** *Next* again. 203 | 9. Scroll to the bottom and **click** the *checkbox* and **click** *Create stack*. 204 | 10. Wait until *Status* changes to *CREATE_COMPLETE* (roughly 2-4 minutes or if deploying in a secondary region, it can take up to 30 minutes). 205 | 206 | ## AHA With AWS Organizations on Management or Delegated Administrator Account using CloudFormation 207 | 208 | ### Prerequisites 209 | 210 | 1. [Enable Health Organizational View](https://docs.aws.amazon.com/health/latest/ug/enable-organizational-view.html) from the console or CLI, so that you can aggregate Health events for all accounts in your AWS Organization. 211 | 2. Have at least 1 [endpoint](#configuring-an-endpoint) configured (you can have multiple) 212 | 3. Have access to deploy Cloudformation Templates with the following resources: AWS IAM policies, Amazon DynamoDB Tables, AWS Lambda, Amazon EventBridge and AWS Secrets Manager in the **AWS Organizations Master Account**. 213 | 4. If using Multi-Region, you must deploy the following 2 CloudFormation templates to allow the Stackset deployment to deploy resources **even if you have full administrator privileges, you still need to follow these steps**. 214 | - In CloudFormation Console create a stack with new resources from the following S3 URL: https://s3.amazonaws.com/cloudformation-stackset-sample-templates-us-east-1/AWSCloudFormationStackSetAdministrationRole.yml - this will allows CFT Stacksets to launch AHA in another region 215 | - Launch the stack. 216 | - In CloudFormation Console create a stack with new resources from the following S3 URL: https://s3.amazonaws.com/cloudformation-stackset-sample-templates-us-east-1/AWSCloudFormationStackSetExecutionRole.yml) - In *AdministratorAccountId* type in the 12 digit account number you're running the solution in (e.g. 000123456789) 217 | - Launch the stack. 218 | 219 | ### Deployment 220 | 221 | 1. Clone the AHA package that from this repository. If you're not familiar with the process, [here](https://git-scm.com/docs/git-clone) is some documentation. The URL to clone is in the upper right-hand corner labeled `Clone uri` 222 | 2. In the root of this package you'll have two files; `handler.py` and `messagegenerator.py`. Use your tool of choice to zip them both up and name them with a unique name (e.g. aha-v1.8.zip). **Note: Putting the version number in the name will make upgrading AHA seamless.** 223 | 3. Upload the .zip you created in Step 1 to an S3 in the same region you plan to deploy this in. 224 | 4. In your AWS console go to *CloudFormation*. 225 | 5. In the *CloudFormation* console **click** *Create stack > With new resources (standard)*. 226 | 6. Under *Template Source* **click** *Upload a template file* and **click** *Choose file* and select `CFN_DEPLOY_AHA.yml` **Click** *Next*. 227 | - In *Stack name* type a stack name (i.e. AHA-Deployment). 228 | - In *AWSOrganizationsEnabled* change the dropdown to `Yes`. If you do NOT have AWS Organizations enabled you should be following the steps for [AHA for users who are NOT using AWS Organizations](#aha-without-aws-organizations-using-cloudformation) 229 | - In *AWSHealthEventType* select whether you want to receive *all* event types or *only* issues. 230 | - In *S3Bucket* type ***just*** the bucket name of the S3 bucket used in step 3 (e.g. my-aha-bucket). 231 | - In *S3Key* type ***just*** the name of the .zip file you created in Step 2 (e.g. aha-v1.8.zip). 232 | - In the *Communications Channels* section enter the URLs, Emails and/or ARN of the endpoints you configured previously. 233 | - In the *Email Setup* section enter the From and To Email addresses as well as the Email subject. If you aren't configuring email, just leave it as is. 234 | - In *EventSearchBack* enter in the amount of hours you want to search back for events. Default is 1 hour. 235 | - In *Regions* enter in the regions you want to search for events in. Default is all regions. You can filter for up to 10, comma separated with (e.g. us-east-1, us-east-2). 236 | - In *ARN of the AWS Organizations Management Account assume role* leave it set to default None. 237 | - In *Deploy in secondary region?* select another region to deploy AHA in. Otherwise leave to default No. 238 | 7. Scroll to the bottom and **click** *Next*. 239 | 8. Scroll to the bottom and **click** *Next* again. 240 | 9. Scroll to the bottom and **click** the *checkbox* and **click** *Create stack*. 241 | 10. Wait until *Status* changes to *CREATE_COMPLETE* (roughly 2-4 minutes or if deploying in a secondary region, it can take up to 30 minutes). 242 | 243 | ## AHA With AWS Organizations on Member Account using CloudFormation 244 | 245 | > Note: On 2023-07-27, AWS Health released the Delegated Admin feature which enables AHA deployments in member accounts without the extra steps below. 246 | See: [Using AWS Health Delegated Administrator with AHA](#using-aws-health-delegated-administrator-with-aha) 247 | 248 | ### Prerequisites 249 | 250 | 1. [Enable Health Organizational View](https://docs.aws.amazon.com/health/latest/ug/enable-organizational-view.html) from the console or CLI, so that you can aggregate Health events for all accounts in your AWS Organization. 251 | 2. Have at least 1 [endpoint](#configuring-an-endpoint) configured (you can have multiple) 252 | 3. Have access to deploy Cloudformation Templates with the following resource: AWS IAM policies in the **AWS Organizations Master Account**. 253 | 4. If using Multi-Region, you must deploy the following 2 CloudFormation templates in the **Member Account** to allow the Stackset deployment to deploy resources **even if you have full administrator privileges, you still need to follow these steps**. 254 | - In CloudFormation Console create a stack with new resources from the following S3 URL: https://s3.amazonaws.com/cloudformation-stackset-sample-templates-us-east-1/AWSCloudFormationStackSetAdministrationRole.yml - this will allows CFT Stacksets to launch AHA in another region 255 | - Launch the stack. 256 | - In CloudFormation Console create a stack with new resources from the following S3 URL: https://s3.amazonaws.com/cloudformation-stackset-sample-templates-us-east-1/AWSCloudFormationStackSetExecutionRole.yml) - In *AdministratorAccountId* type in the 12 digit account number you're running the solution in (e.g. 000123456789) 257 | - Launch the stack. 258 | 259 | ### Deployment 260 | 261 | 1. Clone the AHA package that from this repository. If you're not familiar with the process, [here](https://git-scm.com/docs/git-clone) is some documentation. The URL to clone is in the upper right-hand corner labeled `Clone uri` 262 | 2. In your top-level management account AWS console go to *CloudFormation* 263 | 3. In the *CloudFormation* console **click** *Create stack > With new resources (standard)*. 264 | 4. Under *Template Source* **click** *Upload a template file* and **click** *Choose file* and select `CFN_MGMT_ROLE.yml` **Click** *Next*. 265 | - In *Stack name* type a stack name (i.e. aha-assume-role). 266 | - In *OrgMemberAccountId* put in the account id of the member account you plan to run AHA in (e.g. 000123456789). 267 | 5. Scroll to the bottom and **click** *Next*. 268 | 6. Scroll to the bottom and **click** *Next* again. 269 | 7. Scroll to the bottom and **click** the *checkbox* and **click** *Create stack*. 270 | 8. Wait until *Status* changes to *CREATE_COMPLETE* (roughly 1-2 minutes). This will create an IAM role with the necessary AWS Organizations and AWS Health API permissions for the member account to assume. 271 | 9. In the *Outputs* tab, there will be a value for *AWSHealthAwareRoleForPHDEventsArn* (e.g. arn:aws:iam::000123456789:role/aha-org-role-AWSHealthAwareRoleForPHDEvents-ABCSDE12201), copy that down as you will need it for step 14. 272 | 10. Back In the root of the package you downloaded/cloned you'll have two files; `handler.py` and `messagegenerator.py`. Use your tool of choice to zip them both up and name them with a unique name (e.g. aha-v1.8.zip). **Note: Putting the version number in the name will make upgrading AHA seamless.** 273 | 11. Upload the .zip you created in Step 11 to an S3 in the same region you plan to deploy this in. 274 | 12. Login to the member account you plan to deploy this in and in your AWS console go to *CloudFormation*. 275 | 13. In the *CloudFormation* console **click** *Create stack > With new resources (standard)*. 276 | 14. Under *Template Source* **click** *Upload a template file* and **click** *Choose file* and select `CFN_DEPLOY_AHA.yml` **Click** *Next*. 277 | - In *Stack name* type a stack name (i.e. AHA-Deployment). 278 | - In *AWSOrganizationsEnabled* change the dropdown to `Yes`. If you do NOT have AWS Organizations enabled you should be following the steps for [AHA for users who are NOT using AWS Organizations](#aha-without-aws-organizations-using-cloudformation) 279 | - In *AWSHealthEventType* select whether you want to receive *all* event types or *only* issues. 280 | - In *S3Bucket* type ***just*** the bucket name of the S3 bucket used in step 12 (e.g. my-aha-bucket). 281 | - In *S3Key* type ***just*** the name of the .zip file you created in Step 11 (e.g. aha-v1.8.zip). 282 | - In the *Communications Channels* section enter the URLs, Emails and/or ARN of the endpoints you configured previously. 283 | - In the *Email Setup* section enter the From and To Email addresses as well as the Email subject. If you aren't configuring email, just leave it as is. 284 | - In *EventSearchBack* enter in the amount of hours you want to search back for events. Default is 1 hour. 285 | - In *Regions* enter in the regions you want to search for events in. Default is all regions. You can filter for up to 10, comma separated with (e.g. us-east-1, us-east-2). 286 | - In *ManagementAccountRoleArn* enter in the full IAM arn from step 10 (e.g. arn:aws:iam::000123456789:role/aha-org-role-AWSHealthAwareRoleForPHDEvents-ABCSDE12201) 287 | - In *Deploy in secondary region?* select another region to deploy AHA in. Otherwise leave to default No. 288 | 15. Scroll to the bottom and **click** *Next*. 289 | 16. Scroll to the bottom and **click** *Next* again. 290 | 17. Scroll to the bottom and **click** the *checkbox* and **click** *Create stack*. 291 | 18. Wait until *Status* changes to *CREATE_COMPLETE* (roughly 2-4 minutes or if deploying in a secondary region, it can take up to 30 minutes). 292 | 293 | ## Terraform 294 | 295 | There are 3 available ways to deploy AHA, all are done via the same Terraform template to make deployment as easy as possible. 296 | 297 | **NOTE: ** AHA code is tested with Terraform version v1.0.9, please make sure to have minimum terraform verson of v1.0.9 installed. 298 | 299 | The 3 deployment methods for AHA are: 300 | 301 | 1. [**AHA for users NOT using AWS Organizations using Terraform**](#aha-without-aws-organizations-using-terraform): Users NOT using AWS Organizations. 302 | 2. [**AHA for users WITH AWS Organizations using Terraform (Management Account)**](#aha-with-aws-organizations-on-management-or-delegated-administrator-account-using-terraform): Users who ARE using AWS Organizations and deploying in the top-level management account. 303 | 3. [**AHA for users WITH AWS Organizations using Terraform (Member Account)**](#aha-with-aws-organizations-on-member-account-using-terraform): Users who ARE using AWS Organizations and deploying in a member account in the organization to assume a role in the top-level management account. 304 | 305 | ## AHA Without AWS Organizations using Terraform 306 | 307 | ### Prerequisites 308 | 309 | 1. Have at least 1 [endpoint](#configuring-an-endpoint) configured (you can have multiple) 310 | 2. Have access to deploy Terraform Templates with the following resources: AWS IAM policies, Amazon DynamoDB Tables, AWS Lambda, Amazon EventBridge and AWS Secrets Manager. 311 | 312 | **NOTE: ** For Multi region deployment, DynamoDB table will be created with PAY_PER_REQUEST billing mode insted of PROVISIONED due to limitation with terraform. 313 | 314 | ### Deployment - Terraform 315 | 316 | 1. Clone the AHA package that from this repository. If you're not familiar with the process, [here](https://git-scm.com/docs/git-clone) is some documentation. The URL to clone is in the upper right-hand corner labeled `Clone uri` 317 | ``` 318 | $ git clone https://github.com/aws-samples/aws-health-aware.git 319 | $ cd aws-health-aware/terraform/Terraform_DEPLOY_AHA 320 | ``` 321 | 2. Update parameters file **terraform.tfvars** as below 322 | - *aha_primary_region* - change to region where you want to deploy AHA solution 323 | - *aha_secondary_region* - Required if needed to deploy in AHA solution in multiple regions, change to another region (Secondary) where you want to deploy AHA solution, Otherwise leave to default empty value. 324 | - *AWSOrganizationsEnabled* - Leave it to default which is `No`. If you do have AWS Organizations enabled and you want to aggregate across all your accounts, you should be following the steps for [AHA for users who ARE using AWS Organizations (Management Account)](#aha-with-aws-organizations-on-management-or-delegated-administrator-account-using-terraform)] or [AHA for users WITH AWS Organizations (Member Account)](#aha-with-aws-organizations-on-member-account-using-terraform) 325 | - *AWSHealthEventType* - select whether you want to receive *all* event types or *only* issues. 326 | - *Communications Channels* section - enter the URLs, Emails and/or ARN of the endpoints you configured previously. 327 | - *Email Setup* section - enter the From and To Email addresses as well as the Email subject. If you aren't configuring email, just leave it as is. 328 | - *EventSearchBack* - enter in the amount of hours you want to search back for events. Default is 1 hour. 329 | - *Regions* - enter in the regions you want to search for events in. Default is all regions. You can filter for up to 10, comma separated (e.g. us-east-1, us-east-2). 330 | - *ManagementAccountRoleArn* - Leave it default empty value 331 | - *ExcludeAccountIDs* - type ***just*** the name of the .csv file you want to upload if needed to exclude accounts from monitoring, else leave it to empty. 332 | - *ManagementAccountRoleArn* - In ARN of the AWS Organizations Management Account assume role leave it set to default None as this is only for customers using AWS Organizations. 333 | 3. Deploy the solution using terraform commands below. 334 | ``` 335 | $ terraform init 336 | $ terraform plan 337 | $ terraform apply 338 | ``` 339 | 340 | ## AHA with AWS Organizations on Management or Delegated Administrator Account using Terraform 341 | 342 | 1. [Enable Health Organizational View](https://docs.aws.amazon.com/health/latest/ug/enable-organizational-view.html) from the console or CLI, so that you can aggregate Health events for all accounts in your AWS Organization. 343 | 2. Have at least 1 [endpoint](#configuring-an-endpoint) configured (you can have multiple) 344 | 345 | **NOTE: ** For Multi region deployment, DynamoDB table will be created with PAY_PER_REQUEST billing mode insted of PROVISIONED due to limitation with terraform. 346 | 347 | ### Deployment - Terraform 348 | 349 | 1. Clone the AHA package that from this repository. If you're not familiar with the process, [here](https://git-scm.com/docs/git-clone) is some documentation. The URL to clone is in the upper right-hand corner labeled `Clone uri` 350 | ``` 351 | $ git clone https://github.com/aws-samples/aws-health-aware.git 352 | $ cd aws-health-aware/terraform/Terraform_DEPLOY_AHA 353 | ``` 354 | 5. Update parameters file **terraform.tfvars** as below 355 | - *aha_primary_region* - change to region where you want to deploy AHA solution 356 | - *aha_secondary_region* - Required if needed to deploy in AHA solution in multiple regions, change to another region (Secondary) where you want to deploy AHA solution, Otherwise leave to default empty value. 357 | - *AWSOrganizationsEnabled* - change the value to `Yes`. If you do NOT have AWS Organizations enabled you should be following the steps for [AHA for users who are NOT using AWS Organizations](#aha-without-aws-organizations-using-terraform) 358 | - *AWSHealthEventType* - select whether you want to receive *all* event types or *only* issues. 359 | - *Communications Channels* section - enter the URLs, Emails and/or ARN of the endpoints you configured previously. 360 | - *Email Setup* section - enter the From and To Email addresses as well as the Email subject. If you aren't configuring email, just leave it as is. 361 | - *EventSearchBack* - enter in the amount of hours you want to search back for events. Default is 1 hour. 362 | - *Regions* enter in the regions you want to search for events in. Default is all regions. You can filter for up to 10, comma separated (e.g. us-east-1, us-east-2). 363 | - *ManagementAccountRoleArn* - Leave it default empty value 364 | - *S3Bucket* - type ***just*** the name of the S3 bucket where exclude file .csv you upload. leave it empty if exclude Account feature is not used. 365 | - *ExcludeAccountIDs* - type ***just*** the name of the .csv file you want to upload if needed to exclude accounts from monitoring, else leave it to empty. 366 | - *ManagementAccountRoleArn* - In ARN of the AWS Organizations Management Account assume role leave it set to default None, unless you are using a member account instead of the management account. Instructions for this configuration are in the next section. 367 | 3. Deploy the solution using terraform commands below. 368 | ``` 369 | $ terraform init 370 | $ terraform plan 371 | $ terraform apply 372 | ``` 373 | 374 | ## AHA WITH AWS Organizations on Member Account using Terraform 375 | 376 | > Note: On 2023-07-27, AWS Health released the Delegated Admin feature which enables AHA deployments in member accounts without the extra steps below. 377 | See: [Using AWS Health Delegated Administrator with AHA](#using-aws-health-delegated-administrator-with-aha) 378 | 379 | 1. [Enable Health Organizational View](https://docs.aws.amazon.com/health/latest/ug/enable-organizational-view.html) from the console or CLI, so that you can aggregate Health events for all accounts in your AWS Organization. 380 | 2. Have at least 1 [endpoint](#configuring-an-endpoint) configured (you can have multiple) 381 | 382 | **NOTE: ** For Multi region deployment, DynamoDB table will be created with PAY_PER_REQUEST billing mode insted of PROVISIONED due to limitation with terraform. 383 | 384 | ### Deployment - Terraform 385 | 386 | 1. Clone the AHA package that from this repository. If you're not familiar with the process, [here](https://git-scm.com/docs/git-clone) is some documentation. The URL to clone is in the upper right-hand corner labeled `Clone uri` 387 | ``` 388 | $ git clone https://github.com/aws-samples/aws-health-aware.git 389 | ``` 390 | 2. In your top-level management account deploy terraform module Terraform_MGMT_ROLE.tf to create Cross-Account Role for PHD access 391 | ``` 392 | $ cd aws-health-aware/terraform/Terraform_MGMT_ROLE 393 | $ terraform init 394 | $ terraform plan 395 | $ terraform apply 396 | Input *OrgMemberAccountId* Enter the account id of the member account you plan to run AHA in (e.g. 000123456789). 397 | ``` 398 | 3. Wait for deployment to complete. This will create an IAM role with the necessary AWS Organizations and AWS Health API permissions for the member account to assume. and note the **AWSHealthAwareRoleForPHDEventsArn** role name, this will be used during deploying solution in member account 399 | 4. In the *Outputs* section, there will be a value for *AWSHealthAwareRoleForPHDEventsArn* (e.g. arn:aws:iam::000123456789:role/aha-org-role-AWSHealthAwareRoleForPHDEvents-ABCSDE12201), copy that down as you will need to update params file (variable ManagementAccountRoleArn). 400 | 4. Change directory to **terraform/Terraform_DEPLOY_AHA** to deploy the solution 401 | 5. Update parameters file **terraform.tfvars** as below 402 | - *aha_primary_region* - change to region where you want to deploy AHA solution 403 | - *aha_secondary_region* - Required if needed to deploy in AHA solution in multiple regions, change to another region (Secondary) where you want to deploy AHA solution, Otherwise leave to default empty value. 404 | - *AWSOrganizationsEnabled* - change the value to `Yes`. If you do NOT have AWS Organizations enabled you should be following the steps for [AHA for users who are NOT using AWS Organizations](#aha-without-aws-organizations-using-terraform) 405 | - *AWSHealthEventType* - select whether you want to receive *all* event types or *only* issues. 406 | - *Communications Channels* section - enter the URLs, Emails and/or ARN of the endpoints you configured previously. 407 | - *Email Setup* section - enter the From and To Email addresses as well as the Email subject. If you aren't configuring email, just leave it as is. 408 | - *EventSearchBack* - enter in the amount of hours you want to search back for events. Default is 1 hour. 409 | - *Regions* enter in the regions you want to search for events in. Default is all regions. You can filter for up to 10, comma separated (e.g. us-east-1, us-east-2). 410 | - *ManagementAccountRoleArn* - Enter in the full IAM arn from step 10 (e.g. arn:aws:iam::000123456789:role/aha-org-role-AWSHealthAwareRoleForPHDEvents-ABCSDE12201) 411 | - *S3Bucket* - type ***just*** the name of the S3 bucket where exclude file .csv you upload. leave it empty if exclude Account feature is not used. 412 | - *ExcludeAccountIDs* - type ***just*** the name of the .csv file you want to upload if needed to exclude accounts from monitoring, else leave it to empty. 413 | 4. Deploy the solution using terraform commands below. 414 | ``` 415 | $ terraform init 416 | $ terraform plan 417 | $ terraform apply 418 | ``` 419 | 420 | # Updating using CloudFormation 421 | **Until this project is migrated to the AWS Serverless Application Model (SAM), updates will have to be done as described below:** 422 | 1. Download the updated CloudFormation Template .yml file and 2 `.py` files. 423 | 2. Zip up the 2 `.py` files and name the .zip with a different version number than before (e.g. if the .zip you originally uploaded is aha-v1.8.zip the new one should be aha-v1.9.zip) 424 | 3. In the AWS CloudFormation console **click** on the name of your stack, then **click** *Update*. 425 | 4. In the *Prepare template* section **click** *Replace current template*, **click** *Upload a template file*, **click** *Choose file*, select the newer `CFN_DEPLOY_AHA.yml` file you downloaded and finally **click** *Next*. 426 | 5. In the *S3Key* text box change the version number in the name of the .zip to match name of the .zip you uploaded in Step 2 (The name of the .zip has to be different for CloudFormation to recognize a change). **Click** *Next*. 427 | 6. At the next screen **click** *Next* and finally **click** *Update stack*. This will now upgrade your environment to the latest version you downloaded. 428 | 429 | **If for some reason, you still have issues after updating, you can easily just delete the stack and redeploy. The infrastructure can be destroyed and rebuilt within minutes through CloudFormation.** 430 | 431 | # Updating using Terraform 432 | **Until this project is migrated to the AWS Serverless Application Model (SAM), updates will have to be done as described below:** 433 | 1. Pull the latest code from git repository for AHA. 434 | 2. Update the parameters file terraform.tfvars per your requirement 435 | 3. Copy the terraform template files to directory where your previous state exists 436 | 4. Deploy the templates as below 437 | ``` 438 | $ cd aws-health-aware 439 | $ git pull https://github.com/aws-samples/aws-health-aware.git 440 | $ cd terraform/Terraform_DEPLOY_AHA 441 | $ terraform init 442 | $ terraform plan - This command should show any difference existing config and latest code. 443 | $ terraform apply 444 | ``` 445 | 446 | **If for some reason, you still have issues after updating, you can easily just delete the stack and redeploy. The infrastructure can be destroyed and rebuilt within minutes through Terraform.** 447 | 448 | # New Features 449 | *Release 2.2* 450 | 451 | We are happy to announce the launch of new enhancements to AHA. Please try them out and keep sending us your feedback! 452 | 1. A revised schema for AHA events sent to EventBridge which enables new filtering and routing options. See the [new AHA event schema readme](new_aha_event_schema.md) for more detail. 453 | 2. Multi-region deployment option 454 | 3. Updated file names for improved clarity 455 | 4. Ability to filter accounts (Refer to AccountIDs CFN parameter for more info on how to exclude accounts from AHA notifications) 456 | 4. Ability to view Account Names for a given Account ID in the PHD alerts 457 | 5. If you are running AHA with the Non-Org mode, AHA will send the Account #' and resource(s) impacts if applicable for a given alert 458 | 6. Ability to deploy AHA with the Org mode on a member account 459 | 7. Support for a new Health Event Type - "Investigation" 460 | 8. Terraform support to deploy the solution 461 | 462 | # Troubleshooting 463 | * If for whatever reason you need to update the Webhook URL; just update the CloudFormation or terraform Template with the new Webhook URL. 464 | * If you are expecting an event and it did not show up it may be an oddly formed event. Take a look at *CloudWatch > Log groups* and search for the name of your Lambda function. See what the error is and reach out to us [email](mailto:aha-builders@amazon.com) for help. 465 | * If for any errors related to duplicate secrets during deployment, try deleting manually and redeploy the solution. Example command to delete SlackChannelID secret in us-east-1 region. 466 | ``` 467 | $ aws secretsmanager delete-secret --secret-id SlackChannelID --force-delete-without-recovery --region us-east-1 468 | ``` 469 | * If you want to Exclude certain accounts from notifications, confirm your exlcusions file matches the format of the [sample ExcludeAccountIDs.csv file](ExcludeAccountIDs(sample).csv) with one account ID per line with no trailing commas (trailing commas indicate a null cell). 470 | * If your accounts listed in the CSV file are not excluded, check the CloudWatch log group for the AHA Lambda function for the message "Key filename is not a .csv file" as an indicator of any issues with your file. 471 | -------------------------------------------------------------------------------- /handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from functools import lru_cache 4 | 5 | import boto3 6 | import os 7 | import socket 8 | from dateutil import parser 9 | from datetime import datetime, timedelta 10 | from urllib.request import Request, urlopen, URLError, HTTPError 11 | from botocore.config import Config 12 | from botocore.exceptions import ClientError 13 | from messagegenerator import ( 14 | get_message_for_slack, 15 | get_org_message_for_slack, 16 | get_message_for_chime, 17 | get_org_message_for_chime, 18 | get_message_for_teams, 19 | get_org_message_for_teams, 20 | get_message_for_email, 21 | get_org_message_for_email, 22 | get_detail_for_eventbridge, 23 | ) 24 | 25 | logger = logging.getLogger() 26 | logger.setLevel(os.environ.get("LOG_LEVEL", "INFO").upper()) 27 | 28 | class CachedSecrets: 29 | def __init__(self, client): 30 | self.client = client 31 | 32 | @lru_cache 33 | def get_secret_value(self, *args, **kwargs): 34 | logger.debug(f"Getting secret {kwargs}") 35 | return self.client.get_secret_value(*args, **kwargs) 36 | 37 | 38 | class AWSApi: 39 | @lru_cache 40 | def client(self, *args, **kwargs): 41 | logger.debug(f"Returning new boto3 client for: {args}") 42 | return boto3.client(*args, **kwargs) 43 | 44 | @lru_cache 45 | def resource(self, resource_name): 46 | logger.debug(f"Returning new boto3 resource for: {resource_name}") 47 | return boto3.resource(resource_name) 48 | 49 | def cache_clear(self): 50 | self.client.cache_clear() 51 | self.resource.cache_clear() 52 | 53 | @lru_cache 54 | def secretsmanager(self, **kwargs): 55 | client = boto3.client("secretsmanager", **kwargs) 56 | return CachedSecrets(client) 57 | 58 | 59 | print("boto3 version: ", boto3.__version__) 60 | 61 | # query active health API endpoint 62 | health_dns = socket.gethostbyname_ex("global.health.amazonaws.com") 63 | (current_endpoint, global_endpoint, ip_endpoint) = health_dns 64 | health_active_list = current_endpoint.split(".") 65 | health_active_region = health_active_list[1] 66 | print("current health region: ", health_active_region) 67 | 68 | # create a boto3 health client w/ backoff/retry 69 | config = Config( 70 | region_name=health_active_region, 71 | retries=dict( 72 | max_attempts=10 # org view apis have a lower tps than the single 73 | # account apis so we need to use larger 74 | # backoff/retry values than than the boto defaults 75 | ), 76 | ) 77 | 78 | aws_api = AWSApi() 79 | 80 | 81 | # TODO decide if account_name should be blank on error 82 | # Get Account Name 83 | def get_account_name(account_id): 84 | org_client = get_sts_token("organizations") 85 | try: 86 | account_name = org_client.describe_account(AccountId=account_id)["Account"][ 87 | "Name" 88 | ] 89 | except Exception: 90 | account_name = account_id 91 | return account_name 92 | 93 | 94 | def send_alert(event_details, affected_accounts, affected_entities, event_type): 95 | slack_url = get_secrets()["slack"] 96 | teams_url = get_secrets()["teams"] 97 | chime_url = get_secrets()["chime"] 98 | SENDER = os.environ["FROM_EMAIL"] 99 | RECIPIENT = os.environ["TO_EMAIL"] 100 | event_bus_name = get_secrets()["eventbusname"] 101 | 102 | # get the list of resources from the array of affected entities 103 | resources = get_resources_from_entities(affected_entities) 104 | 105 | if "None" not in event_bus_name: 106 | try: 107 | print("Sending the alert to Event Bridge") 108 | send_to_eventbridge( 109 | get_detail_for_eventbridge(event_details, affected_entities), 110 | event_type, 111 | resources, 112 | event_bus_name, 113 | ) 114 | except HTTPError as e: 115 | print( 116 | "Got an error while sending message to EventBridge: ", e.code, e.reason 117 | ) 118 | except URLError as e: 119 | print("Server connection failed: ", e.reason) 120 | pass 121 | #Slack Notification Handling 122 | if slack_url != "None": 123 | for slack_webhook_type in ["services", "triggers", "workflows"]: 124 | if ("hooks.slack.com/" + slack_webhook_type) in slack_url: 125 | print("Sending the alert to Slack Webhook Channel") 126 | try: 127 | send_to_slack( 128 | get_message_for_slack( 129 | event_details, 130 | event_type, 131 | affected_accounts, 132 | resources, 133 | slack_webhook_type, 134 | ), 135 | slack_url, 136 | ) 137 | break 138 | except HTTPError as e: 139 | print("Got an error while sending message to Slack: ", e.code, e.reason) 140 | except URLError as e: 141 | print("Server connection failed: ", e.reason) 142 | pass 143 | else: 144 | print("Unsupported format in Slack Webhook") 145 | 146 | if "office.com/webhook" in teams_url: 147 | try: 148 | print("Sending the alert to Teams") 149 | send_to_teams( 150 | get_message_for_teams( 151 | event_details, event_type, affected_accounts, resources 152 | ), 153 | teams_url, 154 | ) 155 | except HTTPError as e: 156 | print("Got an error while sending message to Teams: ", e.code, e.reason) 157 | except URLError as e: 158 | print("Server connection failed: ", e.reason) 159 | pass 160 | # validate sender and recipient's email addresses 161 | if "none@domain.com" not in SENDER and RECIPIENT: 162 | try: 163 | print("Sending the alert to the emails") 164 | send_email(event_details, event_type, affected_accounts, resources) 165 | except HTTPError as e: 166 | print("Got an error while sending message to Email: ", e.code, e.reason) 167 | except URLError as e: 168 | print("Server connection failed: ", e.reason) 169 | pass 170 | if "hooks.chime.aws/incomingwebhooks" in chime_url: 171 | try: 172 | print("Sending the alert to Chime channel") 173 | send_to_chime( 174 | get_message_for_chime( 175 | event_details, event_type, affected_accounts, resources 176 | ), 177 | chime_url, 178 | ) 179 | except HTTPError as e: 180 | print("Got an error while sending message to Chime: ", e.code, e.reason) 181 | except URLError as e: 182 | print("Server connection failed: ", e.reason) 183 | pass 184 | 185 | 186 | def send_org_alert( 187 | event_details, affected_org_accounts, affected_org_entities, event_type 188 | ): 189 | slack_url = get_secrets()["slack"] 190 | teams_url = get_secrets()["teams"] 191 | chime_url = get_secrets()["chime"] 192 | SENDER = os.environ["FROM_EMAIL"] 193 | RECIPIENT = os.environ["TO_EMAIL"] 194 | event_bus_name = get_secrets()["eventbusname"] 195 | 196 | # get the list of resources from the array of affected entities 197 | resources = get_resources_from_entities(affected_org_entities) 198 | 199 | if "None" not in event_bus_name: 200 | try: 201 | print("Sending the org alert to Event Bridge") 202 | send_to_eventbridge( 203 | get_detail_for_eventbridge(event_details, affected_org_entities), 204 | event_type, 205 | resources, 206 | event_bus_name, 207 | ) 208 | except HTTPError as e: 209 | print( 210 | "Got an error while sending message to EventBridge: ", e.code, e.reason 211 | ) 212 | except URLError as e: 213 | print("Server connection failed: ", e.reason) 214 | pass 215 | #Slack Notification Handling 216 | if slack_url != "None": 217 | for slack_webhook_type in ["services", "triggers", "workflows"]: 218 | if ("hooks.slack.com/" + slack_webhook_type) in slack_url: 219 | print("Sending the alert to Slack Webhook Channel") 220 | try: 221 | send_to_slack( 222 | get_message_for_slack( 223 | event_details, 224 | event_type, 225 | affected_org_accounts, 226 | resources, 227 | slack_webhook_type, 228 | ), 229 | slack_url, 230 | ) 231 | break 232 | except HTTPError as e: 233 | print("Got an error while sending message to Slack: ", e.code, e.reason) 234 | except URLError as e: 235 | print("Server connection failed: ", e.reason) 236 | pass 237 | else: 238 | print("Unsupported format in Slack Webhook") 239 | 240 | if "office.com/webhook" in teams_url: 241 | try: 242 | print("Sending the alert to Teams") 243 | send_to_teams( 244 | get_org_message_for_teams( 245 | event_details, event_type, affected_org_accounts, resources 246 | ), 247 | teams_url, 248 | ) 249 | except HTTPError as e: 250 | print("Got an error while sending message to Teams: ", e.code, e.reason) 251 | except URLError as e: 252 | print("Server connection failed: ", e.reason) 253 | pass 254 | # validate sender and recipient's email addresses 255 | if "none@domain.com" not in SENDER and RECIPIENT: 256 | try: 257 | print("Sending the alert to the emails") 258 | send_org_email(event_details, event_type, affected_org_accounts, resources) 259 | except HTTPError as e: 260 | print("Got an error while sending message to Email: ", e.code, e.reason) 261 | except URLError as e: 262 | print("Server connection failed: ", e.reason) 263 | pass 264 | if "hooks.chime.aws/incomingwebhooks" in chime_url: 265 | try: 266 | print("Sending the alert to Chime channel") 267 | send_to_chime( 268 | get_org_message_for_chime( 269 | event_details, event_type, affected_org_accounts, resources 270 | ), 271 | chime_url, 272 | ) 273 | except HTTPError as e: 274 | print("Got an error while sending message to Chime: ", e.code, e.reason) 275 | except URLError as e: 276 | print("Server connection failed: ", e.reason) 277 | pass 278 | 279 | 280 | def send_to_slack(message, webhookurl): 281 | slack_message = message 282 | req = Request( 283 | webhookurl, 284 | data=json.dumps(slack_message).encode("utf-8"), 285 | headers={"content-type": "application/json"}, 286 | ) 287 | try: 288 | response = urlopen(req) 289 | response.read() 290 | except HTTPError as e: 291 | print("Request failed : ", e.code, e.reason) 292 | except URLError as e: 293 | print("Server connection failed: ", e.reason, e.reason) 294 | 295 | 296 | def send_to_chime(message, webhookurl): 297 | chime_message = {"Content": message} 298 | req = Request( 299 | webhookurl, 300 | data=json.dumps(chime_message).encode("utf-8"), 301 | headers={"content-Type": "application/json"}, 302 | ) 303 | try: 304 | response = urlopen(req) 305 | response.read() 306 | except HTTPError as e: 307 | print("Request failed : ", e.code, e.reason) 308 | except URLError as e: 309 | print("Server connection failed: ", e.reason, e.reason) 310 | 311 | 312 | def send_to_teams(message, webhookurl): 313 | teams_message = message 314 | req = Request( 315 | webhookurl, 316 | data=json.dumps(teams_message).encode("utf-8"), 317 | headers={"content-type": "application/json"}, 318 | ) 319 | try: 320 | response = urlopen(req) 321 | response.read() 322 | except HTTPError as e: 323 | print("Request failed : ", e.code, e.reason) 324 | except URLError as e: 325 | print("Server connection failed: ", e.reason, e.reason) 326 | 327 | 328 | def send_email(event_details, eventType, affected_accounts, affected_entities): 329 | SENDER = os.environ["FROM_EMAIL"] 330 | RECIPIENT = os.environ["TO_EMAIL"].split(",") 331 | # AWS_REGIONS = "us-east-1" 332 | AWS_REGION = os.environ["AWS_REGION"] 333 | SUBJECT = os.environ["EMAIL_SUBJECT"] 334 | BODY_HTML = get_message_for_email( 335 | event_details, eventType, affected_accounts, affected_entities 336 | ) 337 | client = aws_api.client("ses", AWS_REGION) 338 | response = client.send_email( 339 | Source=SENDER, 340 | Destination={"ToAddresses": RECIPIENT}, 341 | Message={ 342 | "Body": { 343 | "Html": {"Data": BODY_HTML}, 344 | }, 345 | "Subject": { 346 | "Charset": "UTF-8", 347 | "Data": SUBJECT, 348 | }, 349 | }, 350 | ) 351 | 352 | 353 | def send_org_email( 354 | event_details, eventType, affected_org_accounts, affected_org_entities 355 | ): 356 | SENDER = os.environ["FROM_EMAIL"] 357 | RECIPIENT = os.environ["TO_EMAIL"].split(",") 358 | # AWS_REGION = "us-east-1" 359 | AWS_REGION = os.environ["AWS_REGION"] 360 | SUBJECT = os.environ["EMAIL_SUBJECT"] 361 | BODY_HTML = get_org_message_for_email( 362 | event_details, eventType, affected_org_accounts, affected_org_entities 363 | ) 364 | client = aws_api.client("ses", AWS_REGION) 365 | response = client.send_email( 366 | Source=SENDER, 367 | Destination={"ToAddresses": RECIPIENT}, 368 | Message={ 369 | "Body": { 370 | "Html": {"Data": BODY_HTML}, 371 | }, 372 | "Subject": { 373 | "Charset": "UTF-8", 374 | "Data": SUBJECT, 375 | }, 376 | }, 377 | ) 378 | 379 | 380 | # non-organization view affected accounts 381 | def get_health_accounts(health_client, event, event_arn): 382 | affected_accounts = [] 383 | event_accounts_paginator = health_client.get_paginator("describe_affected_entities") 384 | event_accounts_page_iterator = event_accounts_paginator.paginate( 385 | filter={"eventArns": [event_arn]} 386 | ) 387 | for event_accounts_page in event_accounts_page_iterator: 388 | json_event_accounts = json.dumps(event_accounts_page, default=myconverter) 389 | parsed_event_accounts = json.loads(json_event_accounts) 390 | try: 391 | affected_accounts.append( 392 | parsed_event_accounts["entities"][0]["awsAccountId"] 393 | ) 394 | except Exception: 395 | affected_accounts = [] 396 | return affected_accounts 397 | 398 | 399 | # organization view affected accounts 400 | def get_health_org_accounts(health_client, event, event_arn): 401 | affected_org_accounts = [] 402 | event_accounts_paginator = health_client.get_paginator( 403 | "describe_affected_accounts_for_organization" 404 | ) 405 | event_accounts_page_iterator = event_accounts_paginator.paginate(eventArn=event_arn) 406 | for event_accounts_page in event_accounts_page_iterator: 407 | json_event_accounts = json.dumps(event_accounts_page, default=myconverter) 408 | parsed_event_accounts = json.loads(json_event_accounts) 409 | affected_org_accounts = affected_org_accounts + ( 410 | parsed_event_accounts["affectedAccounts"] 411 | ) 412 | return affected_org_accounts 413 | 414 | 415 | # get the array of affected entities for all affected accounts and return as an array of JSON objects 416 | def get_affected_entities(health_client, event_arn, affected_accounts, is_org_mode): 417 | affected_entity_array = [] 418 | 419 | for account in affected_accounts: 420 | account_name = "" 421 | if is_org_mode: 422 | event_entities_paginator = health_client.get_paginator( 423 | "describe_affected_entities_for_organization" 424 | ) 425 | event_entities_page_iterator = event_entities_paginator.paginate( 426 | organizationEntityFilters=[ 427 | {"awsAccountId": account, "eventArn": event_arn} 428 | ] 429 | ) 430 | account_name = get_account_name(account) 431 | else: 432 | event_entities_paginator = health_client.get_paginator( 433 | "describe_affected_entities" 434 | ) 435 | event_entities_page_iterator = event_entities_paginator.paginate( 436 | filter={"eventArns": [event_arn]} 437 | ) 438 | 439 | for event_entities_page in event_entities_page_iterator: 440 | json_event_entities = json.dumps(event_entities_page, default=myconverter) 441 | parsed_event_entities = json.loads(json_event_entities) 442 | for entity in parsed_event_entities["entities"]: 443 | entity.pop( 444 | "entityArn" 445 | ) # remove entityArn to avoid confusion with the arn of the entityValue (not present) 446 | entity.pop("eventArn") # remove eventArn duplicate of detail.arn 447 | entity.pop("lastUpdatedTime") # remove for brevity 448 | if is_org_mode: 449 | entity["awsAccountName"] = account_name 450 | affected_entity_array.append(entity) 451 | 452 | return affected_entity_array 453 | 454 | 455 | # COMMON 456 | # get the entityValues from the array and return as an array (of strings) for use with chat channels 457 | # don't list entities which are accounts (handled separately for chat applications) 458 | def get_resources_from_entities(affected_entity_array): 459 | resources = [] 460 | 461 | for entity in affected_entity_array: 462 | if entity["entityValue"] == "UNKNOWN": 463 | # UNKNOWN indicates a public/non-accountspecific event, no resources 464 | pass 465 | elif ( 466 | entity["entityValue"] != "AWS_ACCOUNT" 467 | and entity["entityValue"] != entity["awsAccountId"] 468 | ): 469 | resources.append(entity["entityValue"]) 470 | return resources 471 | 472 | 473 | # For Customers using AWS Organizations 474 | def update_org_ddb( 475 | event_arn, 476 | str_update, 477 | status_code, 478 | event_details, 479 | affected_org_accounts, 480 | affected_org_entities, 481 | ): 482 | # open dynamoDB 483 | dynamodb = aws_api.resource("dynamodb") 484 | ddb_table = os.environ["DYNAMODB_TABLE"] 485 | aha_ddb_table = dynamodb.Table(ddb_table) 486 | event_latestDescription = event_details["successfulSet"][0]["eventDescription"][ 487 | "latestDescription" 488 | ] 489 | # set time parameters 490 | delta_hours = os.environ["EVENT_SEARCH_BACK"] 491 | delta_hours = int(delta_hours) 492 | delta_hours_sec = delta_hours * 3600 493 | 494 | # formatting time in seconds 495 | srt_ddb_format_full = "%Y-%m-%d %H:%M:%S" 496 | str_ddb_format_sec = "%s" 497 | sec_now = datetime.strftime(datetime.now(), str_ddb_format_sec) 498 | 499 | # check if event arn already exists 500 | try: 501 | response = aha_ddb_table.get_item(Key={"arn": event_arn}) 502 | except ClientError as e: 503 | print(e.response["Error"]["Message"]) 504 | else: 505 | is_item_response = response.get("Item") 506 | if is_item_response == None: 507 | print(datetime.now().strftime(srt_ddb_format_full) + ": record not found") 508 | # write to dynamodb 509 | response = aha_ddb_table.put_item( 510 | Item={ 511 | "arn": event_arn, 512 | "lastUpdatedTime": str_update, 513 | "added": sec_now, 514 | "ttl": int(sec_now) + delta_hours_sec + 86400, 515 | "statusCode": status_code, 516 | "affectedAccountIDs": affected_org_accounts, 517 | "latestDescription": event_latestDescription 518 | # Cleanup: DynamoDB entry deleted 24 hours after last update 519 | } 520 | ) 521 | affected_org_accounts_details = [ 522 | f"{get_account_name(account_id)} ({account_id})" 523 | for account_id in affected_org_accounts 524 | ] 525 | # send to configured endpoints 526 | if status_code != "closed": 527 | send_org_alert( 528 | event_details, 529 | affected_org_accounts_details, 530 | affected_org_entities, 531 | event_type="create", 532 | ) 533 | else: 534 | send_org_alert( 535 | event_details, 536 | affected_org_accounts_details, 537 | affected_org_entities, 538 | event_type="resolve", 539 | ) 540 | 541 | else: 542 | item = response["Item"] 543 | if item["lastUpdatedTime"] != str_update and ( 544 | item["statusCode"] != status_code 545 | or item["latestDescription"] != event_latestDescription 546 | or item["affectedAccountIDs"] != affected_org_accounts 547 | ): 548 | print( 549 | datetime.now().strftime(srt_ddb_format_full) 550 | + ": last Update is different" 551 | ) 552 | # write to dynamodb 553 | response = aha_ddb_table.put_item( 554 | Item={ 555 | "arn": event_arn, 556 | "lastUpdatedTime": str_update, 557 | "added": sec_now, 558 | "ttl": int(sec_now) + delta_hours_sec + 86400, 559 | "statusCode": status_code, 560 | "affectedAccountIDs": affected_org_accounts, 561 | "latestDescription": event_latestDescription 562 | # Cleanup: DynamoDB entry deleted 24 hours after last update 563 | } 564 | ) 565 | affected_org_accounts_details = [ 566 | f"{get_account_name(account_id)} ({account_id})" 567 | for account_id in affected_org_accounts 568 | ] 569 | # send to configured endpoints 570 | if status_code != "closed": 571 | send_org_alert( 572 | event_details, 573 | affected_org_accounts_details, 574 | affected_org_entities, 575 | event_type="create", 576 | ) 577 | else: 578 | send_org_alert( 579 | event_details, 580 | affected_org_accounts_details, 581 | affected_org_entities, 582 | event_type="resolve", 583 | ) 584 | else: 585 | print("No new updates found, checking again in 1 minute.") 586 | 587 | 588 | # For Customers not using AWS Organizations 589 | def update_ddb( 590 | event_arn, 591 | str_update, 592 | status_code, 593 | event_details, 594 | affected_accounts, 595 | affected_entities, 596 | ): 597 | # open dynamoDB 598 | dynamodb = aws_api.resource("dynamodb") 599 | ddb_table = os.environ["DYNAMODB_TABLE"] 600 | aha_ddb_table = dynamodb.Table(ddb_table) 601 | event_latestDescription = event_details["successfulSet"][0]["eventDescription"][ 602 | "latestDescription" 603 | ] 604 | 605 | # set time parameters 606 | delta_hours = os.environ["EVENT_SEARCH_BACK"] 607 | delta_hours = int(delta_hours) 608 | delta_hours_sec = delta_hours * 3600 609 | 610 | # formatting time in seconds 611 | srt_ddb_format_full = "%Y-%m-%d %H:%M:%S" 612 | str_ddb_format_sec = "%s" 613 | sec_now = datetime.strftime(datetime.now(), str_ddb_format_sec) 614 | 615 | # check if event arn already exists 616 | try: 617 | response = aha_ddb_table.get_item(Key={"arn": event_arn}) 618 | except ClientError as e: 619 | print(e.response["Error"]["Message"]) 620 | else: 621 | is_item_response = response.get("Item") 622 | if is_item_response == None: 623 | print(datetime.now().strftime(srt_ddb_format_full) + ": record not found") 624 | # write to dynamodb 625 | response = aha_ddb_table.put_item( 626 | Item={ 627 | "arn": event_arn, 628 | "lastUpdatedTime": str_update, 629 | "added": sec_now, 630 | "ttl": int(sec_now) + delta_hours_sec + 86400, 631 | "statusCode": status_code, 632 | "affectedAccountIDs": affected_accounts, 633 | "latestDescription": event_latestDescription 634 | # Cleanup: DynamoDB entry deleted 24 hours after last update 635 | } 636 | ) 637 | 638 | affected_accounts_details = affected_accounts 639 | 640 | # send to configured endpoints 641 | if status_code != "closed": 642 | send_alert( 643 | event_details, 644 | affected_accounts_details, 645 | affected_entities, 646 | event_type="create", 647 | ) 648 | else: 649 | send_alert( 650 | event_details, 651 | affected_accounts_details, 652 | affected_entities, 653 | event_type="resolve", 654 | ) 655 | else: 656 | item = response["Item"] 657 | if item["lastUpdatedTime"] != str_update and ( 658 | item["statusCode"] != status_code 659 | or item["latestDescription"] != event_latestDescription 660 | or item["affectedAccountIDs"] != affected_accounts 661 | ): 662 | print( 663 | datetime.now().strftime(srt_ddb_format_full) 664 | + ": last Update is different" 665 | ) 666 | # write to dynamodb 667 | response = aha_ddb_table.put_item( 668 | Item={ 669 | "arn": event_arn, 670 | "lastUpdatedTime": str_update, 671 | "added": sec_now, 672 | "ttl": int(sec_now) + delta_hours_sec + 86400, 673 | "statusCode": status_code, 674 | "affectedAccountIDs": affected_accounts, 675 | "latestDescription": event_latestDescription 676 | # Cleanup: DynamoDB entry deleted 24 hours after last update 677 | } 678 | ) 679 | affected_accounts_details = [ 680 | f"{get_account_name(account_id)} ({account_id})" 681 | for account_id in affected_accounts 682 | ] 683 | # send to configured endpoints 684 | if status_code != "closed": 685 | send_alert( 686 | event_details, 687 | affected_accounts_details, 688 | affected_entities, 689 | event_type="create", 690 | ) 691 | else: 692 | send_alert( 693 | event_details, 694 | affected_accounts_details, 695 | affected_entities, 696 | event_type="resolve", 697 | ) 698 | else: 699 | print("No new updates found, checking again in 1 minute.") 700 | 701 | 702 | def get_secrets(): 703 | secret_teams_name = "MicrosoftChannelID" 704 | secret_slack_name = "SlackChannelID" 705 | secret_chime_name = "ChimeChannelID" 706 | region_name = os.environ["AWS_REGION"] 707 | event_bus_name = "EventBusName" 708 | secret_assumerole_name = "AssumeRoleArn" 709 | secrets = {} 710 | 711 | # create a Secrets Manager client 712 | client = aws_api.secretsmanager(region_name=region_name) 713 | # Iteration through the configured AWS Secrets 714 | secrets["teams"] = ( 715 | get_secret(secret_teams_name, client) if "Teams" in os.environ else "None" 716 | ) 717 | secrets["slack"] = ( 718 | get_secret(secret_slack_name, client) if "Slack" in os.environ else "None" 719 | ) 720 | secrets["chime"] = ( 721 | get_secret(secret_chime_name, client) if "Chime" in os.environ else "None" 722 | ) 723 | secrets["ahaassumerole"] = ( 724 | get_secret(secret_assumerole_name, client) 725 | if os.environ["MANAGEMENT_ROLE_ARN"] != "None" 726 | else "None" 727 | ) 728 | secrets["eventbusname"] = ( 729 | get_secret(event_bus_name, client) if "Eventbridge" in os.environ else "None" 730 | ) 731 | 732 | # uncomment below to verify secrets values 733 | # print("Secrets: ",secrets) 734 | return secrets 735 | 736 | 737 | def get_secret(secret_name, client): 738 | try: 739 | get_secret_value_response = client.get_secret_value(SecretId=secret_name) 740 | except ClientError as e: 741 | print(f"There was an error with the {secret_name} secret: ", e.response) 742 | return "None" 743 | finally: 744 | if "SecretString" not in get_secret_value_response: 745 | return "None" 746 | 747 | return get_secret_value_response["SecretString"] 748 | 749 | 750 | def describe_events(health_client): 751 | str_ddb_format_sec = "%s" 752 | # set hours to search back in time for events 753 | delta_hours = os.environ["EVENT_SEARCH_BACK"] 754 | health_event_type = os.environ["HEALTH_EVENT_TYPE"] 755 | delta_hours = int(delta_hours) 756 | time_delta = datetime.now() - timedelta(hours=delta_hours) 757 | print("Searching for events and updates made after: ", time_delta) 758 | dict_regions = os.environ["REGIONS"] 759 | 760 | str_filter = {"lastUpdatedTimes": [{"from": time_delta}]} 761 | 762 | if health_event_type == "issue": 763 | event_type_filter = {"eventTypeCategories": ["issue", "investigation"]} 764 | print( 765 | "AHA will be monitoring events with event type categories as 'issue' only!" 766 | ) 767 | str_filter.update(event_type_filter) 768 | 769 | if dict_regions != "all regions": 770 | dict_regions = [region.strip() for region in dict_regions.split(",")] 771 | print( 772 | "AHA will monitor for events only in the selected regions: ", dict_regions 773 | ) 774 | region_filter = {"regions": dict_regions} 775 | str_filter.update(region_filter) 776 | 777 | event_paginator = health_client.get_paginator("describe_events") 778 | event_page_iterator = event_paginator.paginate(filter=str_filter) 779 | for response in event_page_iterator: 780 | events = response.get("events", []) 781 | aws_events = json.dumps(events, default=myconverter) 782 | aws_events = json.loads(aws_events) 783 | print("Event(s) Received: ", json.dumps(aws_events)) 784 | if len(aws_events) > 0: # if there are new event(s) from AWS 785 | for event in aws_events: 786 | event_arn = event["arn"] 787 | status_code = event["statusCode"] 788 | str_update = parser.parse((event["lastUpdatedTime"])) 789 | str_update = str_update.strftime(str_ddb_format_sec) 790 | 791 | # get non-organizational view requirements 792 | affected_accounts = get_health_accounts(health_client, event, event_arn) 793 | affected_entities = get_affected_entities( 794 | health_client, event_arn, affected_accounts, is_org_mode=False 795 | ) 796 | 797 | # get event details 798 | event_details = json.dumps( 799 | describe_event_details(health_client, event_arn), 800 | default=myconverter, 801 | ) 802 | event_details = json.loads(event_details) 803 | print("Event Details: ", event_details) 804 | if event_details["successfulSet"] == []: 805 | print( 806 | "An error occured with account:", 807 | event_details["failedSet"][0]["awsAccountId"], 808 | "due to:", 809 | event_details["failedSet"][0]["errorName"], 810 | ":", 811 | event_details["failedSet"][0]["errorMessage"], 812 | ) 813 | continue 814 | else: 815 | # write to dynamoDB for persistence 816 | update_ddb( 817 | event_arn, 818 | str_update, 819 | status_code, 820 | event_details, 821 | affected_accounts, 822 | affected_entities, 823 | ) 824 | else: 825 | print("No events found in time frame, checking again in 1 minute.") 826 | 827 | 828 | def describe_org_events(health_client): 829 | str_ddb_format_sec = "%s" 830 | # set hours to search back in time for events 831 | delta_hours = os.environ["EVENT_SEARCH_BACK"] 832 | health_event_type = os.environ["HEALTH_EVENT_TYPE"] 833 | dict_regions = os.environ["REGIONS"] 834 | delta_hours = int(delta_hours) 835 | time_delta = datetime.now() - timedelta(hours=delta_hours) 836 | print("Searching for events and updates made after: ", time_delta) 837 | 838 | str_filter = {"lastUpdatedTime": {"from": time_delta}} 839 | 840 | if health_event_type == "issue": 841 | event_type_filter = {"eventTypeCategories": ["issue", "investigation"]} 842 | print( 843 | "AHA will be monitoring events with event type categories as 'issue' only!" 844 | ) 845 | str_filter.update(event_type_filter) 846 | 847 | if dict_regions != "all regions": 848 | dict_regions = [region.strip() for region in dict_regions.split(",")] 849 | print( 850 | "AHA will monitor for events only in the selected regions: ", dict_regions 851 | ) 852 | region_filter = {"regions": dict_regions} 853 | str_filter.update(region_filter) 854 | 855 | org_event_paginator = health_client.get_paginator( 856 | "describe_events_for_organization" 857 | ) 858 | org_event_page_iterator = org_event_paginator.paginate(filter=str_filter) 859 | for response in org_event_page_iterator: 860 | events = response.get("events", []) 861 | aws_events = json.dumps(events, default=myconverter) 862 | aws_events = json.loads(aws_events) 863 | print("Event(s) Received: ", json.dumps(aws_events)) 864 | if len(aws_events) > 0: 865 | for event in aws_events: 866 | event_arn = event["arn"] 867 | status_code = event["statusCode"] 868 | str_update = parser.parse((event["lastUpdatedTime"])) 869 | str_update = str_update.strftime(str_ddb_format_sec) 870 | 871 | # get organizational view requirements 872 | affected_org_accounts = get_health_org_accounts( 873 | health_client, event, event_arn 874 | ) 875 | if ( 876 | os.environ["ACCOUNT_IDS"] == "None" 877 | or os.environ["ACCOUNT_IDS"] == "" 878 | ): 879 | affected_org_accounts = affected_org_accounts 880 | update_org_ddb_flag = True 881 | else: 882 | account_ids_to_filter = getAccountIDs() 883 | if affected_org_accounts != []: 884 | focused_org_accounts = [ 885 | i 886 | for i in affected_org_accounts 887 | if i not in account_ids_to_filter 888 | ] 889 | print("Focused list is ", focused_org_accounts) 890 | if focused_org_accounts != []: 891 | update_org_ddb_flag = True 892 | affected_org_accounts = focused_org_accounts 893 | else: 894 | update_org_ddb_flag = False 895 | print("Focused Organization Account list is empty") 896 | else: 897 | update_org_ddb_flag = True 898 | 899 | affected_org_entities = get_affected_entities( 900 | health_client, event_arn, affected_org_accounts, is_org_mode=True 901 | ) 902 | # get event details 903 | event_details = json.dumps( 904 | describe_org_event_details( 905 | health_client, event_arn, affected_org_accounts 906 | ), 907 | default=myconverter, 908 | ) 909 | event_details = json.loads(event_details) 910 | print("Event Details: ", event_details) 911 | if event_details["successfulSet"] == []: 912 | print( 913 | "An error occured with account:", 914 | event_details["failedSet"][0]["awsAccountId"], 915 | "due to:", 916 | event_details["failedSet"][0]["errorName"], 917 | ":", 918 | event_details["failedSet"][0]["errorMessage"], 919 | ) 920 | continue 921 | else: 922 | # write to dynamoDB for persistence 923 | if update_org_ddb_flag: 924 | update_org_ddb( 925 | event_arn, 926 | str_update, 927 | status_code, 928 | event_details, 929 | affected_org_accounts, 930 | affected_org_entities, 931 | ) 932 | else: 933 | print("No events found in time frame, checking again in 1 minute.") 934 | 935 | 936 | def myconverter(json_object): 937 | if isinstance(json_object, datetime): 938 | return json_object.__str__() 939 | 940 | 941 | def describe_event_details(health_client, event_arn): 942 | response = health_client.describe_event_details( 943 | eventArns=[event_arn], 944 | ) 945 | return response 946 | 947 | 948 | def describe_org_event_details(health_client, event_arn, affected_org_accounts): 949 | if len(affected_org_accounts) >= 1: 950 | affected_account_ids = affected_org_accounts[0] 951 | response = health_client.describe_event_details_for_organization( 952 | organizationEventDetailFilters=[ 953 | {"awsAccountId": affected_account_ids, "eventArn": event_arn} 954 | ] 955 | ) 956 | else: 957 | response = describe_event_details(health_client, event_arn) 958 | 959 | return response 960 | 961 | 962 | def eventbridge_generate_entries(message, resources, event_bus): 963 | return [ 964 | { 965 | "Source": "aha", 966 | "DetailType": "AHA Event", 967 | "Resources": resources, 968 | "Detail": json.dumps(message), 969 | "EventBusName": event_bus, 970 | }, 971 | ] 972 | 973 | 974 | def send_to_eventbridge(message, event_type, resources, event_bus): 975 | print( 976 | "Sending response to Eventbridge - event_type, event_bus", event_type, event_bus 977 | ) 978 | client = aws_api.client("events") 979 | 980 | entries = eventbridge_generate_entries(message, resources, event_bus) 981 | 982 | print("Sending entries: ", entries) 983 | 984 | response = client.put_events(Entries=entries) 985 | print("Response from eventbridge is:", response) 986 | 987 | 988 | def getAccountIDs(): 989 | account_ids = "" 990 | key_file_name = os.environ["ACCOUNT_IDS"] 991 | print("Key filename is - ", key_file_name) 992 | if os.path.splitext(os.path.basename(key_file_name))[1] == ".csv": 993 | s3 = aws_api.client("s3") 994 | data = s3.get_object(Bucket=os.environ["S3_BUCKET"], Key=key_file_name) 995 | account_ids = [account.decode("utf-8") for account in data["Body"].iter_lines()] 996 | else: 997 | print("Key filename is not a .csv file") 998 | print(account_ids) 999 | return account_ids 1000 | 1001 | 1002 | def get_sts_token(service): 1003 | assumeRoleArn = get_secrets()["ahaassumerole"] 1004 | boto3_client = None 1005 | 1006 | if "arn:aws:iam::" in assumeRoleArn: 1007 | ACCESS_KEY = [] 1008 | SECRET_KEY = [] 1009 | SESSION_TOKEN = [] 1010 | 1011 | sts_connection = aws_api.client("sts") 1012 | 1013 | ct = datetime.now() 1014 | role_session_name = "cross_acct_aha_session" 1015 | 1016 | acct_b = sts_connection.assume_role( 1017 | RoleArn=assumeRoleArn, 1018 | RoleSessionName=role_session_name, 1019 | DurationSeconds=900, 1020 | ) 1021 | 1022 | ACCESS_KEY = acct_b["Credentials"]["AccessKeyId"] 1023 | SECRET_KEY = acct_b["Credentials"]["SecretAccessKey"] 1024 | SESSION_TOKEN = acct_b["Credentials"]["SessionToken"] 1025 | 1026 | # create service client using the assumed role credentials, e.g. S3 1027 | boto3_client = aws_api.client( 1028 | service, 1029 | config=config, 1030 | aws_access_key_id=ACCESS_KEY, 1031 | aws_secret_access_key=SECRET_KEY, 1032 | aws_session_token=SESSION_TOKEN, 1033 | ) 1034 | print("Running in member account deployment mode") 1035 | else: 1036 | boto3_client = aws_api.client(service, config=config) 1037 | print("Running in management account deployment mode") 1038 | 1039 | return boto3_client 1040 | 1041 | 1042 | def main(event, context): 1043 | aws_api.cache_clear() 1044 | print("THANK YOU FOR CHOOSING AWS HEALTH AWARE!") 1045 | health_client = get_sts_token("health") 1046 | org_status = os.environ["ORG_STATUS"] 1047 | # str_ddb_format_sec = '%s' 1048 | 1049 | # check for AWS Organizations Status 1050 | if org_status == "No": 1051 | # TODO update text below to reflect current functionality 1052 | print( 1053 | "AWS Organizations is not enabled. Only Service Health Dashboard messages will be alerted." 1054 | ) 1055 | describe_events(health_client) 1056 | else: 1057 | print( 1058 | "AWS Organizations is enabled. Personal Health Dashboard and Service Health Dashboard messages will be alerted." 1059 | ) 1060 | describe_org_events(health_client) 1061 | 1062 | 1063 | if __name__ == "__main__": 1064 | main("", "") 1065 | --------------------------------------------------------------------------------