diff --git a/azuredevops/README-ado.md b/azuredevops/README-ado.md new file mode 100644 index 0000000..c962a93 --- /dev/null +++ b/azuredevops/README-ado.md @@ -0,0 +1,566 @@ +# AWS HealthCompass - Azure DevOps (ADO) Integration + +**AWS HealthCompass Azure DevOps Integration** is a serverless solution that converts AWS Health planned lifecycle events into actionable work items in Azure DevOps. This solution reduces operational overhead by automating the creation and management of ADO work items for AWS Health planned lifecycle events, ensuring resource owners are notified about relevant changes through configurable routing capabilities. + +## Key Features + +1. **Automated Work Item Management**: Automatically creates Azure DevOps Feature and Child Task work items for AWS Health planned lifecycle events, eliminating manual monitoring and ticket creation while ensuring consistent documentation of infrastructure changes. + +2. **Event-Driven Architecture**: Leverages a serverless, event-driven design that processes events in near real-time with minimal operational overhead, automatically scaling to handle event volume fluctuations. + +3. **Flexible Routing Models**: Supports three deployment models: + - **Account-based routing**: Directs work items based on affected AWS accounts + - **Service-based routing**: Organizes work items by AWS service type (EC2, S3, etc.) + - **Tag-based routing**: Directs work items based on resource tags, enabling team-specific notifications + +4. **Intelligent Work Item Updates**: Updates existing work items when new resources are affected by the same AWS Health event, preventing duplicate items and providing consolidated tracking. Updates are posted as comments on the existing Feature work item. Optionally, when `EnableAutoActivate` is enabled, the Feature status is set to "Active" and reassigned to the current sprint iteration. + +5. **Two-Level Work Item Hierarchy**: Creates a parent Feature work item with full event details, and a linked Child Task for the operations team to track effort. Both Feature and Child Task share the same Iteration Path (when configured). The Child Task's Effort field is left empty for the task owner to populate. + +6. **Cross-Organization Visibility**: Aggregates health events across all accounts, providing comprehensive visibility through a single deployment. + +7. **Resilient Message Processing**: Implements dead letter queues with configurable retry policies to handle processing failures, ensuring no events are lost. + +8. **Secure Credential Management**: Uses AWS Secrets Manager to securely store and manage Azure DevOps Personal Access Token (PAT). + +## Architecture + + ![ADO-AWS Health Compass Architecture](./images/architecture-diagram-ado-integration.png) + +The solution consists of the following components: + +1. **AWS Health Events**: Supports single account or event aggregation across Organization using AWS Health's organizational view with delegated account feature. + +2. **AWS EventBridge**: Combines AWS default EventBridge and a custom EventBridge bus to efficiently aggregate and route AWS Health planned lifecycle events across an organization. + +3. **AWS Lambda Functions**: + - **HealthEventProcessorLambda**: Processes incoming AWS Health events, categorizes resources, and prepares messages for work item creation or updates + - **HealthEventADOIntegration**: Creates and updates Azure DevOps work items based on processed AWS Health events using the [ADO REST API](https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-items). For each event, it creates a parent Feature with full event details and a linked Child Task for operational tracking. + +4. **SQS Queues**: Provides buffering and resilience between Lambda components with Dead Letter Queue (DLQ) implementation for failed message processing. + +5. **AWS Secrets Manager**: Securely stores Azure DevOps PAT. + +6. **DynamoDB Tables**: Maintains the relationship between AWS Health events, affected resources, and their corresponding Azure DevOps work items. A routing table maps AWS identifiers to ADO projects and Area Paths based on the deployment model. + +7. **IAM Roles and Policies**: Provides appropriate permissions for Lambda execution and cross-account access (Tag model only). + +## Prerequisites + +1. **AWS Health Organizational View**: Enable [AWS Health organizational view](https://docs.aws.amazon.com/health/latest/ug/enable-organizational-view.html) and [AWS Health delegated account](https://docs.aws.amazon.com/health/latest/ug/delegated-administrator-organizational-view.html) to aggregate events across your organization. + +2. **Deployment Account**: Deploy the solution in the AWS Health delegated account (referenced as `deployment-account`). + +3. **Azure DevOps Instance**: + - Azure DevOps organization URL (e.g., `https://dev.azure.com/your-organization`) + - Personal Access Token (PAT) with **Work Items: Read & Write** scope (`vso.work_write`) + - Target ADO project name(s) — the project's process template must support **Feature** and **Task** work item types + + > **Note on Work Item Types**: The solution creates a Feature (parent) and Task (child) hierarchy. This requires a process template that supports both types: + > - **Agile**: ✅ Feature, Task + > - **Scrum**: ✅ Feature, Task + > - **CMMI**: ✅ Feature, Task + > - **Basic**: ❌ Does not support Feature + > + > Ensure your ADO project uses Agile, Scrum, or CMMI process template. + +4. **Deployment Region**: Identify preferred regions for solution deployment. You will configure event forwarding from all other AWS Regions to the deployment region for event aggregation. + +5. **S3 Bucket**: S3 bucket for Lambda deployment packages in the deployment account. + +6. **Cross-Account Role** (Tag deployment model only): + - IAM Role name to be used for cross-account access to linked accounts + - Tag key to monitor for routing events + +## Deployment Instructions + +### 1. Prepare Lambda Packages + +```bash +zip -r HealthEventProcessorLambda.zip HealthEventProcessorLambda.py +zip -r HealthEventADOIntegration.zip HealthEventADOIntegration.py +``` + +### 2. Upload to S3 + +1. Login to your `deployment-account` +2. Switch to your preferred deployment region +3. Upload the Lambda zip files to your S3 bucket +4. Ensure CloudFormation has access to this bucket + +### 3. Deploy CloudFormation Template + +1. Open AWS CloudFormation console in your deployment account +2. Select "Create Stack" → "With new resources (standard)" +3. Choose "Upload a template file" → Select `cloudformation.yaml` +4. Click "Next" +5. Provide the following parameters: + +#### Required Parameters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| **Stack name** | Name for your CloudFormation stack | `aws-health-ado-integration` | +| **DeployModel** | Deployment model (Account/Service/Tag) | `Account` | +| **ADOOrganizationUrl** | Azure DevOps organization URL | `https://dev.azure.com/your-organization` | +| **ADOPat** | Azure DevOps Personal Access Token | `your-pat-token` | +| **S3BucketName** | S3 bucket containing Lambda packages | `my-lambda-deployment-bucket` | +| **HealthEventProcessorLambdaKey** | S3 key for processor Lambda | `HealthEventProcessorLambda.zip` | +| **HealthEventADOIntegrationLambdaKey** | S3 key for ADO integration Lambda | `HealthEventADOIntegration.zip` | + +#### Optional Parameters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| **ADOIterationPathPrefix** | Iteration path prefix in ADO. The solution appends a bi-weekly sprint identifier automatically using the format `Sprint N Mon FY YY-YY` (e.g., `Sprint 1 Apr FY 26-27` for the first half of April in financial year 2026-27). If left empty, ADO defaults to the project root iteration. | `VF UK IT Cloud Infrastructure` | +| **ADOAreaPath** | Fixed Area Path for all work items. If set, this overrides DynamoDB-based routing for Area Path assignment. If left empty, Area Path is determined by the Account/Service/Tag routing model via DynamoDB mapping. | `VF UK IT Cloud Infrastructure\Operations and Support` | +| **EnableAutoActivate** | When set to `true`, subsequent Health notifications for already-tracked events will update the Feature status to "Active" and reassign it to the current sprint iteration. Default: `false`. | `true` | +| **ADOCustomFields** | JSON-encoded list of custom fields to include when creating Feature work items. Use this when your ADO project has required custom fields on the Feature work item type. Each entry needs `field` (the ADO field reference name) and `value`. | `[{"field":"Custom.ProjectContacts","value":"Cloud Team"},{"field":"Custom.DomainsorDepartments","value":"Infrastructure"}]` | + +#### Conditional Parameters (Tag Model Only) + +| Parameter | Description | Example | +|-----------|-------------|---------| +| **AssumeRoleName** | IAM role name for cross-account access | `HealthEventTagRole` | +| **TagKey** | Tag key to monitor for routing | `Environment` | + +6. Click "Next" → Configure stack options → Click "Next" +7. Review configuration and select "I acknowledge that AWS CloudFormation might create IAM resources" +8. Click "Create stack" + +### 4. Monitor Deployment + +Monitor stack creation progress in the CloudFormation console. Once complete, note the outputs for resource details. + +## Configuration + +### Configure Health Event Aggregation + +Create rules to send AWS Health events from default EventBridge to the custom EventBridge bus: + +1. **Locate Custom Event Bus**: In your deployment account and region, find the custom EventBridge bus from CloudFormation outputs. Note the custom Event bus ARN. + +2. **Create EventBridge Rules** (repeat for each region): + - Go to EventBridge console → Event buses → Select default Event bus → Create rule + - **Name**: `health-event-forwarding-rule` + - **Rule type**: Rule with an event pattern + - **Event source**: AWS events or EventBridge partner events + - **Event pattern**: Use pattern form + - **AWS Service**: Health + - **Event type**: All events + - **Target**: EventBridge event bus → select your custom event bus + - **Execution role**: Create a new role for this specific resource + +3. **Repeat for All Regions**: Create similar rules in all AWS regions to forward events to your deployment region. + +### Area Path Routing + +The solution supports two approaches for assigning the Area Path on work items: + +**Single-Route (Fixed Area Path)**: Set the `ADOAreaPath` CloudFormation parameter to a fixed value (e.g., `VF UK IT Cloud Infrastructure\Operations and Support`). All work items will be created under this Area Path regardless of the deployment model. The DynamoDB routing table is still used for project selection, but Area Path is overridden by this parameter. This is ideal for teams where all AWS Health events are handled by a single operations group. + +**Multi-Route (DynamoDB-based)**: Leave the `ADOAreaPath` parameter empty. The Area Path is then determined by the Account/Service/Tag routing model via the DynamoDB mapping table. This is ideal for organizations where different teams handle different accounts, services, or tagged resources. + +> **Note**: Both approaches can coexist with the three deployment models (Account/Service/Tag). The deployment model always controls which ADO project a work item is routed to. The `ADOAreaPath` parameter only controls whether the Area Path within that project is fixed or dynamically routed. + +### Configure DynamoDB Mapping + +Configure the DynamoDB routing table with mapping information based on your chosen deployment model. The project name is always required. The Area Path column is only used when the `ADOAreaPath` CloudFormation parameter is left empty (multi-route mode). + +1. Locate the DynamoDB table from CloudFormation stack outputs +2. Access the DynamoDB console and select your table +3. Use the PartiQL editor or item creation interface to add mapping entries +4. Always create a `DefaultProjectCode` entry to handle unmapped resources + +#### Account Model + +```sql +-- Default mapping (catches all for unmapped accounts) +INSERT into "AccountADOTable" value { + 'Account': 'DefaultProjectCode', + 'ACADOProjectName': 'MyADOProject', + 'ACADOAreaPath': 'MyADOProject\\Operations' +} + +-- Account-specific mapping +INSERT into "AccountADOTable" value { + 'Account': '123456789012', + 'ACADOProjectName': 'ProductionOps', + 'ACADOAreaPath': 'ProductionOps\\Cloud Infrastructure' +} +``` + +#### Service Model + +```sql +-- Default mapping (catches all for unmapped services) +INSERT into "ServiceADOTable" value { + 'Service': 'DefaultProjectCode', + 'SADOProjectName': 'MyADOProject', + 'SADOAreaPath': 'MyADOProject\\Operations' +} + +-- Service-specific mapping +INSERT into "ServiceADOTable" value { + 'Service': 'EC2', + 'SADOProjectName': 'InfraOps', + 'SADOAreaPath': 'InfraOps\\Compute' +} +``` + +#### Tag Model + +```sql +-- Default mapping (catches all for unmapped tag values) +INSERT into "TagADOTable" value { + 'HostTag': 'DefaultProjectCode', + 'HTADOProjectName': 'MyADOProject', + 'HTADOAreaPath': 'MyADOProject\\Operations' +} + +-- Tag-specific mapping +INSERT into "TagADOTable" value { + 'HostTag': 'production-web', + 'HTADOProjectName': 'WebOps', + 'HTADOAreaPath': 'WebOps\\Production' +} +``` + +> **Note**: Replace table names, project names, and Area Paths with your actual values. The Area Path column in the DynamoDB mapping is only used when the `ADOAreaPath` CloudFormation parameter is left empty (multi-route mode). If `ADOAreaPath` is set, it overrides the DynamoDB Area Path for all work items. + +### Azure DevOps API Details + +The solution interacts with Azure DevOps using the [Work Item Tracking REST API](https://learn.microsoft.com/en-us/rest/api/azure/devops/wit). + +#### Creating Work Items + +The solution creates a two-level work item hierarchy for each health event: a **Feature** (parent) and a **Child Task** linked to it. + +**Step 1: Create the Feature** + +The Feature is created using a `POST` request with a JSON Patch document (`application/json-patch+json`): + +``` +POST https://dev.azure.com/{organization}/{project}/_apis/wit/workitems/$Feature?api-version=7.1 +``` + +Note: The `$` before the type is literal and required by the ADO API. + +Example request body: +```json +[ + { + "op": "add", + "path": "/fields/System.Title", + "value": "Account: 123456789012 - AWS EC2 Planned Maintenance - AWS_EC2_PLANNED_LIFECYCLE_EVENT" + }, + { + "op": "add", + "path": "/fields/System.State", + "value": "New" + }, + { + "op": "add", + "path": "/fields/System.AreaPath", + "value": "MyProject\\Operations and Support" + }, + { + "op": "add", + "path": "/fields/System.IterationPath", + "value": "MyProject\\Sprint 1 Apr FY 26-27" + }, + { + "op": "add", + "path": "/fields/System.Description", + "value": "

Event Description: Scheduled maintenance for EC2 instances...

Affected Resources:

Resource: arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0
Status: OPEN
Last Updated: 2023-10-15T10:30:00Z

" + }, + { + "op": "add", + "path": "/fields/Custom.ProjectContacts", + "value": "Cloud Team" + } +] +``` + +> **Note**: The `System.Description` field accepts HTML content. If your ADO project has custom required fields on the Feature work item type (e.g., `Custom.ProjectContacts`), configure them via the `ADOCustomFields` CloudFormation parameter — they will be appended to the patch document automatically. The `System.IterationPath` is dynamically constructed from the `ADOIterationPathPrefix` CloudFormation parameter using a bi-weekly sprint naming convention (`Sprint N Mon FY YY-YY`). If the prefix is not configured, the iteration path field is omitted and ADO uses the project default. + +**Step 2: Create the Child Task** + +After the Feature is created, a Child Task is created and linked to the parent Feature using a parent-child relationship: + +``` +POST https://dev.azure.com/{organization}/{project}/_apis/wit/workitems/$Task?api-version=7.1 +``` + +Example request body: +```json +[ + { + "op": "add", + "path": "/fields/System.Title", + "value": "Task: Account: 123456789012 - AWS EC2 Planned Maintenance - AWS_EC2_PLANNED_LIFECYCLE_EVENT" + }, + { + "op": "add", + "path": "/fields/System.State", + "value": "New" + }, + { + "op": "add", + "path": "/fields/System.AreaPath", + "value": "MyProject\\Operations and Support" + }, + { + "op": "add", + "path": "/fields/System.IterationPath", + "value": "MyProject\\Sprint 1 Apr FY 26-27" + }, + { + "op": "add", + "path": "/relations/-", + "value": { + "rel": "System.LinkTypes.Hierarchy-Reverse", + "url": "https://dev.azure.com/{organization}/{project}/_apis/wit/workItems/{featureId}" + } + } +] +``` + +> **Note**: The Child Task's `Effort` field is intentionally left empty for the task owner to populate during sprint planning. The `System.LinkTypes.Hierarchy-Reverse` relation links the Task as a child of the Feature. When `ADOIterationPathPrefix` is configured, both Feature and Child Task receive the same dynamically constructed Iteration Path. + +#### Updating Work Items (Adding Comments) + +When new resources are affected by an existing tracked event, the solution adds a comment to the existing Feature work item using the [Comments API](https://learn.microsoft.com/en-us/rest/api/azure/devops/wit/comments/add-comment). The Feature is identified by its integer `id` returned during creation, which is stored in the DynamoDB tracking table as `adoWorkItemId`. + +If `EnableAutoActivate` is set to `true`, the solution also updates the Feature's state to "Active" and reassigns it to the current sprint iteration (based on the configured `ADOIterationPathPrefix`). This is done via a `PATCH` request to the Work Items API: + +``` +PATCH https://dev.azure.com/{organization}/{project}/_apis/wit/workitems/{workItemId}?api-version=7.1 +``` + +Example request body: +```json +[ + { + "op": "replace", + "path": "/fields/System.State", + "value": "Active" + }, + { + "op": "replace", + "path": "/fields/System.IterationPath", + "value": "MyProject\\Sprint 1 Apr FY 26-27" + } +] +``` + +The comment is added using: + +``` +POST https://dev.azure.com/{organization}/{project}/_apis/wit/workItems/{workItemId}/comments?api-version=7.1-preview.4 +``` + +Example request body: +```json +{ + "text": "Update for resources:

Resource: arn:aws:ec2:us-east-1:123456789012:instance/i-abcdef1234567890
Status: CLOSED
Last Updated: 2023-10-15T14:30:00Z" +} +``` + +> **Note**: The comment `text` field supports HTML formatting. + +#### Work Item Fields Used + +**Feature (Parent):** + +| Field | Path | Description | +|-------|------|-------------| +| **Title** | `/fields/System.Title` | Summary based on deployment model | +| **State** | `/fields/System.State` | Set to "New" on creation. Updated to "Active" on subsequent notifications if `EnableAutoActivate` is `true`. | +| **Area Path** | `/fields/System.AreaPath` | Fixed via `ADOAreaPath` parameter, or determined by DynamoDB routing model | +| **Iteration Path** | `/fields/System.IterationPath` | Dynamically set from `ADOIterationPathPrefix` + bi-weekly sprint identifier (`Sprint N Mon FY YY-YY`). Updated to current sprint on subsequent notifications if `EnableAutoActivate` is `true`. | +| **Description** | `/fields/System.Description` | AWS Health event description with affected resource details (HTML) | +| **Custom Fields** | As configured via `ADOCustomFields` | Any additional required fields defined by your ADO process template | + +**Child Task:** + +| Field | Path | Description | +|-------|------|-------------| +| **Title** | `/fields/System.Title` | Prefixed with "Task: " followed by the Feature title | +| **State** | `/fields/System.State` | Set to "New" on creation | +| **Area Path** | `/fields/System.AreaPath` | Same as parent Feature | +| **Iteration Path** | `/fields/System.IterationPath` | Same as parent Feature (dynamically set when `ADOIterationPathPrefix` is configured) | +| **Effort** | `/fields/Microsoft.VSTS.Scheduling.Effort` | Left empty — task owner populates during sprint planning | +| **Parent Link** | `/relations/-` | Linked to parent Feature via `System.LinkTypes.Hierarchy-Reverse` | + +#### Title Format by Deployment Model + +| Model | Title Format | +|-------|-------------| +| Account | `Account: 123456789012 - AWS EC2 Planned Maintenance - AWS_EC2_PLANNED_LIFECYCLE_EVENT` | +| Service | `Service: EC2 - AWS EC2 Planned Maintenance - AWS_EC2_PLANNED_LIFECYCLE_EVENT - Service Based` | +| Tag | `Tag: production-web - AWS EC2 Planned Maintenance - AWS_EC2_PLANNED_LIFECYCLE_EVENT - Tag Based` | + +### Create Cross-Account IAM Role (Tag Model Only) + +Create the following role in every linked account: + +**Role Permission Policy:** +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "tag:GetResources", + "tag:GetTagKeys", + "tag:GetTagValues" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "s3:GetBucketTagging", + "iam:ListRoleTags", + "iam:ListUserTags", + "route53:ListTagsForResource", + "autoscaling:DescribeTags" + ], + "Resource": "*" + } + ] +} +``` + +**Role Trust Policy:** +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::role/-HealthEventProcessorRole-" + }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +> **Note**: Replace `` and `` with your actual values. The processor role name can be found in CloudFormation outputs. + +## Testing the Solution + +### 1. Verify Deployment + +Check CloudFormation stack outputs for: +- DynamoDB table name +- Lambda function ARNs +- SQS queue URLs +- ADO secret name + +### 2. Test ADO Connectivity + +1. Use the ADO Integration Lambda function test console with a sample event +2. Check CloudWatch Logs for both Lambda functions +3. Verify ADO PAT in Secrets Manager is correct and not expired + +### 3. Simulate Health Events + +Test with sample events from the `test/` directory (if available), or wait for a real AWS scheduled maintenance event. + +### 4. Monitor Processing + +1. **SQS Queues**: Check for messages in processing queues +2. **DLQ**: Monitor dead letter queues for failed messages +3. **CloudWatch Logs**: Review Lambda execution logs +4. **Azure DevOps**: Verify Feature and Child Task work items are created in the correct projects, and that the parent-child link between them is intact + +## Troubleshooting + +### Common Issues + +#### 1. Lambda Function Errors +- Check CloudWatch Logs for error messages +- Verify IAM permissions are correctly configured +- Ensure ADO PAT is valid and has the `vso.work_write` scope +- Validate ADO organization URL is accessible from Lambda + +#### 2. ADO Authentication Failures (HTTP 401/403) +- Verify PAT in Secrets Manager is valid and not expired +- Ensure PAT has **Work Items: Read & Write** scope +- Check that the ADO organization URL is correct (format: `https://dev.azure.com/{organization}`) +- Test PAT manually: + ```bash + curl -u : \ + "https://dev.azure.com//_apis/projects?api-version=7.1" + ``` + +#### 3. Work Item Creation Failures (HTTP 400) +- Verify the project's process template supports Feature and Task work item types (Agile, Scrum, or CMMI) +- Ensure the ADO project name in DynamoDB mapping matches the actual project name exactly (case-sensitive) +- If `ADOIterationPathPrefix` is configured, verify the resulting iteration path (e.g., `MyProject\Sprint 1 Apr FY 26-27`) exists in the ADO project. Sprints must be created in ADO ahead of time. +- If using multi-route mode, check that the Area Path in the DynamoDB mapping exists in the ADO project +- If using single-route mode, check that the `ADOAreaPath` CloudFormation parameter value exists in the ADO project +- If the error mentions `RuleValidationErrors` with `Required, InvalidEmpty` for custom fields, your ADO process template has mandatory custom fields on the Feature work item type. Use the `ADOCustomFields` CloudFormation parameter (or Lambda environment variable `ADO_CUSTOM_FIELDS`) to provide values for these fields. See the [Optional Parameters](#optional-parameters) section for the JSON format. + +#### 4. Missing Work Items +- Check SQS queues for stuck messages +- Verify EventBridge rules are properly configured +- Review DynamoDB tracking table for event records + +#### 5. Feature Created Without Child Task +- If the Feature was created successfully but the Child Task creation failed, check CloudWatch Logs for errors on the second API call +- Common causes: Area Path or Iteration Path valid for Feature but not for Task in the project's process template +- The Feature will exist in ADO without a child — manually create the Task or reprocess the event after fixing the issue + +#### 6. Cross-Account Tag Discovery Issues (Tag Model) +- Verify IAM roles are correctly set up in all accounts +- Check trust relationships between accounts +- Ensure tag permissions are properly configured +- Test role assumption manually using AWS CLI + +### Debugging Steps + +1. **Check CloudWatch Logs**: + ```bash + aws logs describe-log-groups --log-group-name-prefix "/aws/lambda/your-stack-name" + ``` + +2. **Monitor SQS Queues**: + ```bash + aws sqs get-queue-attributes --queue-url --attribute-names All + ``` + +3. **Verify DynamoDB Records**: + ```bash + aws dynamodb scan --table-name + ``` + +4. **Test ADO API - List Projects**: + ```bash + curl -u : \ + "https://dev.azure.com//_apis/projects?api-version=7.1" + ``` + +5. **Test ADO API - Get Work Item**: + ```bash + curl -u : \ + "https://dev.azure.com///_apis/wit/workitems/?api-version=7.1" + ``` + +## Security Considerations + +- ADO PAT is stored securely in AWS Secrets Manager +- IAM roles follow least privilege principle +- ADO communication occurs over HTTPS +- Consider implementing VPC endpoints for enhanced security +- Regularly rotate ADO Personal Access Tokens (PATs have a maximum lifetime of 1 year) +- Use a dedicated service account for the PAT rather than a personal user account + +## License + +This library is licensed under the MIT-0 License. See the LICENSE file. diff --git a/azuredevops/TESTING.md b/azuredevops/TESTING.md new file mode 100644 index 0000000..d125966 --- /dev/null +++ b/azuredevops/TESTING.md @@ -0,0 +1,217 @@ +# Deployment & Testing Guide — Azure DevOps Integration + +This guide walks you through deploying and testing the AWS HealthCompass Azure DevOps integration in your environment. + +## Prerequisites Checklist + +Before you begin, confirm you have: + +- [ ] AWS account with [AWS Health organizational view](https://docs.aws.amazon.com/health/latest/ug/enable-organizational-view.html) and [delegated account](https://docs.aws.amazon.com/health/latest/ug/delegated-administrator-organizational-view.html) enabled +- [ ] Azure DevOps organization with a project using Agile, Scrum, or CMMI process template +- [ ] Azure DevOps PAT with **Work Items: Read & Write** scope (`vso.work_write`) +- [ ] S3 bucket in the deployment account for Lambda packages +- [ ] AWS CLI configured with appropriate permissions +- [ ] (Tag model only) IAM role name for cross-account tag discovery + +## Step 1: Prepare Lambda Deployment Packages + +```bash +cd azuredevops/code + +# Create zip packages +zip HealthEventProcessorLambda.zip HealthEventProcessorLambda.py +zip HealthEventADOIntegration.zip HealthEventADOIntegration.py +``` + +## Step 2: Upload to S3 + +```bash +aws s3 cp HealthEventProcessorLambda.zip s3:/// +aws s3 cp HealthEventADOIntegration.zip s3:/// +``` + +## Step 3: Deploy CloudFormation Stack + +```bash +aws cloudformation create-stack \ + --stack-name aws-health-ado-integration \ + --template-body file://azuredevops/cloudformation/cloudformation.yaml \ + --capabilities CAPABILITY_IAM \ + --parameters \ + ParameterKey=DeployModel,ParameterValue=Account \ + ParameterKey=ADOOrganizationUrl,ParameterValue=https://dev.azure.com/ \ + ParameterKey=ADOPat,ParameterValue= \ + ParameterKey=S3BucketName,ParameterValue= \ + ParameterKey=HealthEventProcessorLambdaKey,ParameterValue=HealthEventProcessorLambda.zip \ + ParameterKey=HealthEventADOIntegrationLambdaKey,ParameterValue=HealthEventADOIntegration.zip +``` + +**Optional parameters** — append as needed: +``` + ParameterKey=ADOAreaPath,ParameterValue= \ + ParameterKey=ADOIterationPathPrefix,ParameterValue= \ + ParameterKey=EnableAutoActivate,ParameterValue=true \ + ParameterKey=ADOCustomFields,ParameterValue='[{"field":"Custom.FieldName","value":"FieldValue"}]' +``` + +**Tag model** — append these: +``` + ParameterKey=AssumeRoleName,ParameterValue= \ + ParameterKey=TagKey,ParameterValue= +``` + +Monitor deployment: +```bash +aws cloudformation describe-stacks --stack-name aws-health-ado-integration --query 'Stacks[0].StackStatus' +``` + +## Step 4: Note Stack Outputs + +```bash +aws cloudformation describe-stacks \ + --stack-name aws-health-ado-integration \ + --query 'Stacks[0].Outputs' \ + --output table +``` + +Note down: +- `DynamoDBTrackTable` — tracking table name +- `DynamoDBMappingTable` — mapping table name (for DynamoDB configuration) +- `CustomEventBusArn` — needed for EventBridge forwarding rules +- `HealthEventProcessorRoleArn` — needed for Tag model cross-account setup + +## Step 5: Configure DynamoDB Mapping Table + +Using the `DynamoDBMappingTable` name from the stack outputs, add your routing entries. + +**Account model example:** +```bash +aws dynamodb put-item \ + --table-name \ + --item '{ + "Account": {"S": "DefaultProjectCode"}, + "ACADOProjectName": {"S": ""}, + "ACADOAreaPath": {"S": "\\"} + }' +``` + +> **Note**: Always create a `DefaultProjectCode` entry. See [README-ado.md](README-ado.md#configure-dynamodb-mapping) for Service and Tag model examples. + +## Step 6: Configure EventBridge Forwarding + +Follow the instructions in [README-ado.md — Configure Health Event Aggregation](README-ado.md#configure-health-event-aggregation) to create EventBridge rules that forward AWS Health events from each region to the custom event bus. + +## Testing + +### Test 1: Validate Stack Resources + +Verify all resources were created: +```bash +# Check Lambda functions +aws lambda get-function --function-name aws-health-ado-integration-health-event-processor +aws lambda get-function --function-name aws-health-ado-integration-health-event-ado-integration + +# Check SQS queues +aws sqs get-queue-url --queue-name aws-health-ado-integration-HealthEventIngestionQueue +aws sqs get-queue-url --queue-name aws-health-ado-integration-health-event-queue + +# Check DynamoDB tables +aws dynamodb describe-table --table-name +aws dynamodb describe-table --table-name + +# Check Secrets Manager +aws secretsmanager describe-secret --secret-id +``` + +### Test 2: Test ADO Connectivity + +Verify your PAT works against your ADO instance: +```bash +curl -s -o /dev/null -w "%{http_code}" \ + -u : \ + "https://dev.azure.com//_apis/projects?api-version=7.1" +``` + +Expected: `200` + +### Test 3: End-to-End Test with Sample Event + +Invoke the Processor Lambda with a sample health event. Create a file `test-event.json`: + +```json +{ + "Records": [ + { + "body": "{\"id\":\"test-event-001\",\"time\":\"2026-03-23T10:00:00Z\",\"region\":\"us-east-1\",\"detail\":{\"eventArn\":\"arn:aws:health:us-east-1::event/EC2/AWS_EC2_PLANNED_LIFECYCLE_EVENT/test001\",\"service\":\"EC2\",\"eventTypeCode\":\"AWS_EC2_PLANNED_LIFECYCLE_EVENT\",\"eventTypeCategory\":\"scheduledChange\",\"eventRegion\":\"us-east-1\",\"startTime\":\"2026-04-01T00:00:00Z\",\"endTime\":\"2026-04-02T00:00:00Z\",\"eventDescription\":[{\"latestDescription\":\"Test: Amazon EC2 has detected degradation of the underlying hardware hosting your EC2 instance.\"}],\"affectedAccount\":\"123456789012\",\"affectedEntities\":[{\"entityValue\":\"arn:aws:ec2:us-east-1:123456789012:instance/i-0123456789abcdef0\",\"status\":\"UPCOMING\",\"lastUpdatedTime\":\"2026-03-23T10:00:00Z\"}]}}" + } + ] +} +``` + +Invoke the Processor Lambda: +```bash +aws lambda invoke \ + --function-name aws-health-ado-integration-health-event-processor \ + --payload file://test-event.json \ + --cli-binary-format raw-in-base64-out \ + response.json + +cat response.json +``` + +Expected: `statusCode: 200` with `untrackedResourcesCount: 1` + +### Test 4: Verify ADO Work Items + +After the test event processes through both Lambdas: + +1. Check CloudWatch Logs for both Lambda functions: + ```bash + aws logs tail /aws/lambda/aws-health-ado-integration-health-event-processor --since 5m + aws logs tail /aws/lambda/aws-health-ado-integration-health-event-ado-integration --since 5m + ``` + +2. Verify in Azure DevOps: + - A **Feature** work item should be created with the health event details + - A **Child Task** should be linked to the Feature + - Both should have the correct Area Path and Iteration Path (if configured) + - The Child Task's Effort field should be empty + +3. Check DynamoDB tracking table: + ```bash + aws dynamodb scan --table-name + ``` + You should see an entry with `adoWorkItemId` matching the Feature ID in ADO. + +### Test 5: Test Update Flow + +Run the same test event again (Test 3). This time: +- No new Feature should be created +- A **comment** should be added to the existing Feature +- CloudWatch Logs should show "Found existing Feature" message +- If `EnableAutoActivate` is `true`, the Feature status should change to "Active" and the Iteration Path should update to the current sprint + +### Troubleshooting + +If tests fail, check in this order: + +1. **CloudWatch Logs** — Look for error messages in both Lambda log groups +2. **SQS DLQ** — Check if messages landed in the dead letter queues: + ```bash + aws sqs get-queue-attributes \ + --queue-url $(aws sqs get-queue-url --queue-name aws-health-ado-integration-health-event-dlq --query QueueUrl --output text) \ + --attribute-names ApproximateNumberOfMessages + ``` +3. **DynamoDB mapping** — Verify your mapping table has the correct entries for the identifier in your test event +4. **ADO PAT** — Verify the PAT hasn't expired and has the correct scope +5. **Area Path / Iteration Path** — Verify these exist in your ADO project +6. **Custom required fields** — If CloudWatch Logs show a 400 error with `RuleValidationErrors` and `Required, InvalidEmpty`, your ADO project has mandatory custom fields. Set the `ADO_CUSTOM_FIELDS` environment variable on the ADO Integration Lambda or use the `ADOCustomFields` CloudFormation parameter. + +## Cleanup + +To remove all resources: +```bash +aws cloudformation delete-stack --stack-name aws-health-ado-integration +``` + +> **Note**: The DynamoDB tables have deletion protection disabled by default. If you enabled it manually, you'll need to disable it before stack deletion. diff --git a/azuredevops/cloudformation/cloudformation.yaml b/azuredevops/cloudformation/cloudformation.yaml new file mode 100644 index 0000000..945e7e4 --- /dev/null +++ b/azuredevops/cloudformation/cloudformation.yaml @@ -0,0 +1,641 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: 'CloudFormation template for AWS Health Event to Azure DevOps Integration' + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: "Deployment Configuration" + Parameters: + - DeployModel + - AssumeRoleName + - TagKey + - Label: + default: "Azure DevOps Configuration" + Parameters: + - ADOOrganizationUrl + - ADOPat + - ADOAreaPath + - ADOIterationPathPrefix + - EnableAutoActivate + - EnableAutoResolve + - Label: + default: "Advanced Configuration" + Parameters: + - ADOCustomFields + - Label: + default: "Lambda Code Location" + Parameters: + - S3BucketName + - HealthEventProcessorLambdaKey + - HealthEventADOIntegrationLambdaKey + ParameterLabels: + DeployModel: + default: "Deployment Model" + AssumeRoleName: + default: "Assume Role Name" + TagKey: + default: "Tag Key to Monitor" + ADOOrganizationUrl: + default: "Azure DevOps Organization URL" + ADOPat: + default: "Azure DevOps Personal Access Token" + ADOAreaPath: + default: "Fixed Area Path (Optional)" + ADOIterationPathPrefix: + default: "Iteration Path Prefix (Optional)" + EnableAutoActivate: + default: "Auto-Activate Feature on Update (Optional)" + EnableAutoResolve: + default: "Auto-Resolve Feature when All Resources Resolved (Optional)" + S3BucketName: + default: "S3 Bucket Name" + ADOCustomFields: + default: "Custom Fields for Feature Work Items (JSON)" + HealthEventProcessorLambdaKey: + default: "Health Event Processor Lambda S3 Key" + HealthEventADOIntegrationLambdaKey: + default: "ADO Integration Lambda S3 Key" + +Parameters: + DeployModel: + Type: String + AllowedValues: + - Account + - Service + - Tag + Description: Select the deployment model for integration (Account, Service, Tag) + + AssumeRoleName: + Type: String + Description: | + (Required only if DeploymentModel is Tag) + Name of the IAM role to assume in Tag deployment model for cross account resource tag listing + MinLength: 0 + MaxLength: 64 + AllowedPattern: "^$|^[\\w+=,.@-]+$" + ConstraintDescription: Role name must be empty or contain only alphanumeric and [+=,.@-] characters + Default: "" + + TagKey: + Type: String + Description: | + (Required only if DeploymentModel is Tag) + The tag key to monitor for routing Health events (e.g., 'Environment', 'Application', 'Team') + MinLength: 0 + MaxLength: 128 + AllowedPattern: "^$|^[\\w+=,.@-]+$" + ConstraintDescription: Tag key must be empty or contain only alphanumeric and [+=,.@-] characters + Default: "" + + ADOOrganizationUrl: + Type: String + Description: Azure DevOps organization URL (e.g., https://dev.azure.com/your-organization) + + ADOPat: + Type: String + Description: Azure DevOps Personal Access Token with Work Items Read & Write scope + NoEcho: true + + ADOAreaPath: + Type: String + Description: | + (Optional) Fixed Area Path for all work items. If set, overrides DynamoDB-based Area Path routing. + Leave empty to use Account/Service/Tag routing model for Area Path assignment. + Default: "" + + ADOIterationPathPrefix: + Type: String + Description: | + (Optional) Iteration path prefix. If provided, the solution appends a bi-weekly sprint + identifier automatically (e.g., prefix 'VF UK IT Cloud Infrastructure' becomes + 'VF UK IT Cloud Infrastructure\Sprint 1 Apr FY 26-27'). + Leave empty to use ADO project default iteration. + Default: "" + + EnableAutoActivate: + Type: String + AllowedValues: + - "true" + - "false" + Default: "false" + Description: | + (Optional) When enabled, subsequent Health notifications for already-tracked events + will set the Feature status to Active and update the Iteration Path to the current sprint. + + EnableAutoResolve: + Type: String + AllowedValues: + - "true" + - "false" + Default: "false" + Description: | + (Optional) When enabled, automatically sets a Feature work item to Resolved + when all affected resources under it have transitioned to RESOLVED status in AWS Health. + + ADOCustomFields: + Type: String + Description: | + (Optional) JSON-encoded list of custom fields to include when creating Feature work items. + Each entry must have "field" (the ADO field reference name) and "value". + Example: [{"field":"Custom.ProjectContacts","value":"Cloud Team"},{"field":"Custom.DomainsorDepartments","value":"Infrastructure"}] + Default: "" + + S3BucketName: + Type: String + Description: S3 bucket containing Lambda code + + HealthEventProcessorLambdaKey: + Type: String + Description: S3 key for Health Event Processor Lambda code zip file + + HealthEventADOIntegrationLambdaKey: + Type: String + Description: S3 key for ADO Integration Lambda code zip file + +Rules: + ValidateAssumeRoleName: + RuleCondition: !Equals + - !Ref DeployModel + - 'Tag' + Assertions: + - Assert: !Not [!Equals [!Ref AssumeRoleName, ""]] + AssertDescription: AssumeRoleName is required when DeploymentModel is Tag + - Assert: !Not [!Equals [!Ref TagKey, ""]] + AssertDescription: TagKey is required when DeploymentModel is Tag + +Conditions: + IsAccountModel: !Equals [!Ref DeployModel, 'Account'] + IsServiceModel: !Equals [!Ref DeployModel, 'Service'] + IsTagModel: !Equals [!Ref DeployModel, 'Tag'] + HasAreaPath: !Not [!Equals [!Ref ADOAreaPath, ""]] + HasIterationPrefix: !Not [!Equals [!Ref ADOIterationPathPrefix, ""]] + IsAutoActivateEnabled: !Equals [!Ref EnableAutoActivate, "true"] + IsAutoResolveEnabled: !Equals [!Ref EnableAutoResolve, "true"] + HasCustomFields: !Not [!Equals [!Ref ADOCustomFields, ""]] + +Resources: + # DynamoDB Mapping Tables (one per deployment model) + AccountADOTable: + Condition: IsAccountModel + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: Account + AttributeType: S + KeySchema: + - AttributeName: Account + KeyType: HASH + BillingMode: PAY_PER_REQUEST + SSESpecification: + SSEEnabled: true + + ServiceADOTable: + Condition: IsServiceModel + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: Service + AttributeType: S + KeySchema: + - AttributeName: Service + KeyType: HASH + BillingMode: PAY_PER_REQUEST + SSESpecification: + SSEEnabled: true + + TagADOTable: + Condition: IsTagModel + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: HostTag + AttributeType: S + KeySchema: + - AttributeName: HostTag + KeyType: HASH + BillingMode: PAY_PER_REQUEST + SSESpecification: + SSEEnabled: true + + # Event tracking table + TrackEventTable: + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: eventArn + AttributeType: S + - AttributeName: resourceArn + AttributeType: S + KeySchema: + - AttributeName: resourceArn + KeyType: HASH + - AttributeName: eventArn + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: TETkeyIndex + KeySchema: + - AttributeName: eventArn + KeyType: HASH + Projection: + ProjectionType: ALL + BillingMode: PAY_PER_REQUEST + SSESpecification: + SSEEnabled: true + TimeToLiveSpecification: + AttributeName: expirationTime + Enabled: true + + # ADO PAT in Secrets Manager + ADOSecret: + Type: AWS::SecretsManager::Secret + Properties: + Description: Azure DevOps PAT for AWS Health Event integration + SecretString: !Sub | + { + "pat": "${ADOPat}" + } + + # SQS Queues + HealthEventIngestionDLQ: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub '${AWS::StackName}-HealthEventIngestion-DLQ' + MessageRetentionPeriod: 1209600 + + HealthEventIngestionQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub '${AWS::StackName}-HealthEventIngestionQueue' + VisibilityTimeout: 300 + MessageRetentionPeriod: 345600 + RedrivePolicy: + deadLetterTargetArn: !GetAtt HealthEventIngestionDLQ.Arn + maxReceiveCount: 3 + + HealthEventQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub '${AWS::StackName}-health-event-queue' + VisibilityTimeout: 300 + MessageRetentionPeriod: 345600 + RedrivePolicy: + deadLetterTargetArn: !GetAtt HealthEventDLQ.Arn + maxReceiveCount: 3 + + HealthEventDLQ: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub '${AWS::StackName}-health-event-dlq' + MessageRetentionPeriod: 1209600 + + # SQS Queue Policy for EventBridge + HealthEventIngestionQueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref HealthEventIngestionQueue + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: events.amazonaws.com + Action: sqs:SendMessage + Resource: !GetAtt HealthEventIngestionQueue.Arn + Condition: + ArnEquals: + aws:SourceArn: !GetAtt HealthEventRule.Arn + + # Health Event Processor Lambda + HealthEventProcessorFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Sub '${AWS::StackName}-health-event-processor' + Runtime: python3.12 + Handler: HealthEventProcessorLambda.lambda_handler + Code: + S3Bucket: !Ref S3BucketName + S3Key: !Ref HealthEventProcessorLambdaKey + Environment: + Variables: + LOG_LEVEL: INFO + SQS_QUEUE_URL: !Ref HealthEventQueue + DYNAMODB_TRACK_TABLE: !Ref TrackEventTable + ADO_DYNAMODB_TABLE: !If + - IsAccountModel + - !Ref AccountADOTable + - !If + - IsServiceModel + - !Ref ServiceADOTable + - !Ref TagADOTable + DEPLOY_MODEL: !Ref DeployModel + ASSUME_ROLE_NAME: !If + - IsTagModel + - !Ref AssumeRoleName + - !Ref "AWS::NoValue" + TAG_KEY: !If + - IsTagModel + - !Ref TagKey + - !Ref "AWS::NoValue" + MemorySize: 256 + Timeout: 240 + Role: !GetAtt HealthEventProcessorRole.Arn + + # Event Source Mapping for Health Event Processor + HealthEventProcessorEventSource: + Type: AWS::Lambda::EventSourceMapping + Properties: + BatchSize: 1 + Enabled: true + EventSourceArn: !GetAtt HealthEventIngestionQueue.Arn + FunctionName: !Ref HealthEventProcessorFunction + MaximumBatchingWindowInSeconds: 60 + + # ADO Integration Lambda + ADOIntegrationFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Sub '${AWS::StackName}-health-event-ado-integration' + Runtime: python3.12 + Handler: HealthEventADOIntegration.lambda_handler + Code: + S3Bucket: !Ref S3BucketName + S3Key: !Ref HealthEventADOIntegrationLambdaKey + Environment: + Variables: + LOG_LEVEL: INFO + DYNAMODB_TRACK_TABLE: !Ref TrackEventTable + ADO_DYNAMODB_TABLE: !If + - IsAccountModel + - !Ref AccountADOTable + - !If + - IsServiceModel + - !Ref ServiceADOTable + - !Ref TagADOTable + ADO_SECRET_NAME: !Ref ADOSecret + ADO_ORG_URL: !Ref ADOOrganizationUrl + DEPLOY_MODEL: !Ref DeployModel + ADO_AREA_PATH: !If + - HasAreaPath + - !Ref ADOAreaPath + - !Ref "AWS::NoValue" + ADO_ITERATION_PATH_PREFIX: !If + - HasIterationPrefix + - !Ref ADOIterationPathPrefix + - !Ref "AWS::NoValue" + ENABLE_AUTO_ACTIVATE: !If + - IsAutoActivateEnabled + - "true" + - !Ref "AWS::NoValue" + ENABLE_AUTO_RESOLVE: !If + - IsAutoResolveEnabled + - "true" + - !Ref "AWS::NoValue" + ADO_CUSTOM_FIELDS: !If + - HasCustomFields + - !Ref ADOCustomFields + - !Ref "AWS::NoValue" + MemorySize: 256 + Timeout: 240 + Role: !GetAtt ADOIntegrationRole.Arn + + # Event Source Mapping for ADO Integration Lambda + ADOIntegrationEventMapping: + Type: AWS::Lambda::EventSourceMapping + Properties: + BatchSize: 1 + Enabled: true + EventSourceArn: !GetAtt HealthEventQueue.Arn + FunctionName: !Ref ADOIntegrationFunction + MaximumBatchingWindowInSeconds: 60 + + # IAM Roles + HealthEventProcessorRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: SQSAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - sqs:ReceiveMessage + - sqs:DeleteMessage + - sqs:GetQueueAttributes + Resource: !GetAtt HealthEventIngestionQueue.Arn + - PolicyName: HealthEventProcessorPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: AWSHealthAccess + Effect: Allow + Action: + - health:DescribeEvents + - health:DescribeEventDetails + - health:DescribeAffectedEntities + Resource: !Sub "arn:aws:health:${AWS::Region}::*" + - !If + - IsTagModel + - Sid: CrossAccountAssumeRole + Effect: Allow + Action: + - sts:AssumeRole + Resource: !Sub 'arn:aws:iam::*:role/${AssumeRoleName}' + - !Ref AWS::NoValue + - Sid: TagAccess + Effect: Allow + Action: + - tag:GetResources + - tag:GetTagKeys + - tag:GetTagValues + Resource: '*' + - Sid: SQSAccess + Effect: Allow + Action: + - sqs:SendMessage + Resource: !GetAtt HealthEventQueue.Arn + - Sid: DynamoDBAccess + Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:PutItem + - dynamodb:UpdateItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + Resource: + - !GetAtt TrackEventTable.Arn + - !Sub '${TrackEventTable.Arn}/index/*' + - !If + - IsAccountModel + - !GetAtt AccountADOTable.Arn + - !If + - IsServiceModel + - !GetAtt ServiceADOTable.Arn + - !GetAtt TagADOTable.Arn + Tags: + - Key: Name + Value: !Sub '${AWS::StackName}-processor-role' + + ADOIntegrationRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: ADOIntegrationPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: SQSAccess + Effect: Allow + Action: + - sqs:ReceiveMessage + - sqs:DeleteMessage + - sqs:GetQueueAttributes + Resource: !GetAtt HealthEventQueue.Arn + - Sid: SecretsManagerAccess + Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: + - !Ref ADOSecret + - !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${ADOSecret}*' + - Sid: DynamoDBAccess + Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:PutItem + - dynamodb:UpdateItem + - dynamodb:DeleteItem + - dynamodb:DescribeTable + Resource: + - !GetAtt TrackEventTable.Arn + - !Sub '${TrackEventTable.Arn}/index/*' + - !If + - IsAccountModel + - !GetAtt AccountADOTable.Arn + - !If + - IsServiceModel + - !GetAtt ServiceADOTable.Arn + - !GetAtt TagADOTable.Arn + Tags: + - Key: Name + Value: !Sub '${AWS::StackName}-ado-integration-role' + + # Custom Event Bus + HealthEventBus: + Type: AWS::Events::EventBus + Properties: + Name: !Sub '${AWS::StackName}-custom-health-event-bus' + + # EventBridge Rule + HealthEventRule: + Type: AWS::Events::Rule + Properties: + Name: !Sub '${AWS::StackName}-health-event-rule' + Description: 'Capture AWS Health scheduled change events' + EventBusName: !Ref HealthEventBus + EventPattern: + source: + - aws.health + detail-type: + - AWS Health Event + detail: + eventTypeCategory: + - scheduledChange + eventTypeCode: + - { "suffix": { "equals-ignore-case": "_PLANNED_LIFECYCLE_EVENT" }} + State: ENABLED + Targets: + - Arn: !GetAtt HealthEventIngestionQueue.Arn + Id: HealthEventIngestionQueue + + # Lambda Permission for EventBridge + EventBridgePermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref HealthEventProcessorFunction + Principal: events.amazonaws.com + SourceArn: !GetAtt HealthEventRule.Arn + +Outputs: + DeployModel: + Description: Selected deployment model for ADO Integration + Value: !Ref DeployModel + + DynamoDBTrackTable: + Description: Name of the DynamoDB table used for event tracking + Value: !Ref TrackEventTable + + DynamoDBMappingTable: + Description: Name of the DynamoDB mapping table for routing + Value: !If + - IsAccountModel + - !Ref AccountADOTable + - !If + - IsServiceModel + - !Ref ServiceADOTable + - !Ref TagADOTable + + EventBridgeRuleName: + Description: Name of the EventBridge rule + Value: !Ref HealthEventRule + + ADOSecretName: + Description: Name of the ADO Secret in Secrets Manager + Value: !Ref ADOSecret + + HealthEventQueueURL: + Description: URL of the SQS queue for health events + Value: !Ref HealthEventQueue + + HealthEventIngestionQueueURL: + Description: URL of the SQS queue for health event ingestion + Value: !Ref HealthEventIngestionQueue + + HealthEventQueueARN: + Description: ARN of the SQS queue for health events + Value: !GetAtt HealthEventQueue.Arn + + HealthEventDLQURL: + Description: URL of the Dead Letter Queue + Value: !Ref HealthEventDLQ + + HealthEventProcessorFunctionArn: + Description: ARN of the Health Event Processor Lambda function + Value: !GetAtt HealthEventProcessorFunction.Arn + + HealthEventProcessorRoleArn: + Description: ARN of the Health Event Processor IAM Role (needed for Tag model cross-account setup) + Value: !GetAtt HealthEventProcessorRole.Arn + + ADOIntegrationFunctionArn: + Description: ARN of the ADO Integration Lambda function + Value: !GetAtt ADOIntegrationFunction.Arn + + CustomEventBusArn: + Description: ARN of the custom EventBridge event bus + Value: !GetAtt HealthEventBus.Arn diff --git a/azuredevops/code/HealthEventADOIntegration.py b/azuredevops/code/HealthEventADOIntegration.py new file mode 100644 index 0000000..27aefa4 --- /dev/null +++ b/azuredevops/code/HealthEventADOIntegration.py @@ -0,0 +1,564 @@ +""" +AWS Health Compass - Azure DevOps Integration Lambda +Creates and updates Azure DevOps work items (Feature + Child Task) based on +processed AWS Health events. +""" + +import json +import os +import logging +import sys +import base64 +import urllib3 +import boto3 +from datetime import datetime +from botocore.exceptions import ClientError + +# Configure logging +logger = logging.getLogger('ado_lambda') +logger.setLevel(logging.INFO) +console_handler = logging.StreamHandler(sys.stdout) +console_handler.setLevel(logging.INFO) +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +console_handler.setFormatter(formatter) +logger.addHandler(console_handler) + +http = urllib3.PoolManager() + +ado_org_url = os.environ.get('ADO_ORG_URL') +if not ado_org_url: + raise ValueError("ADO_ORG_URL environment variable is not set") +# Strip trailing slash +ado_org_url = ado_org_url.rstrip('/') + +ado_area_path = os.environ.get('ADO_AREA_PATH', '') +ado_iteration_prefix = os.environ.get('ADO_ITERATION_PATH_PREFIX', '') +enable_auto_activate = os.environ.get('ENABLE_AUTO_ACTIVATE', 'false').lower() == 'true' +enable_auto_resolve = os.environ.get('ENABLE_AUTO_RESOLVE', 'false').lower() == 'true' + +# Parse optional custom fields for Feature work items +ado_custom_fields = [] +_custom_fields_raw = os.environ.get('ADO_CUSTOM_FIELDS', '') +if _custom_fields_raw: + try: + ado_custom_fields = json.loads(_custom_fields_raw) + logger.info(f"Loaded {len(ado_custom_fields)} custom fields") + except json.JSONDecodeError as e: + logger.error(f"Failed to parse ADO_CUSTOM_FIELDS: {e}") + +# Setup boto3 session +session = boto3.session.Session() + +# Setup DynamoDB tracking table +track_table_name = os.environ.get('DYNAMODB_TRACK_TABLE') +if not track_table_name: + raise ValueError("DYNAMODB_TRACK_TABLE environment variable is not set") + +dynamodb = boto3.resource('dynamodb') +track_table = dynamodb.Table(track_table_name) +logger.info(f"DynamoDB tracking table status: {track_table.table_status}") + + +def get_secret(): + """Retrieve ADO PAT from Secrets Manager""" + secret_name = os.environ.get('ADO_SECRET_NAME') + if not secret_name: + raise ValueError("ADO_SECRET_NAME environment variable is not set") + + region_name = os.environ.get('AWS_REGION', 'us-east-1') + client = session.client(service_name='secretsmanager', region_name=region_name) + + try: + response = client.get_secret_value(SecretId=secret_name) + except ClientError as e: + raise e + + secret = json.loads(response['SecretString']) + return secret + + +def get_ado_headers(pat): + """Build HTTP headers for ADO REST API using PAT authentication""" + credentials = base64.b64encode(f":{pat}".encode()).decode() + return { + 'Content-Type': 'application/json-patch+json', + 'Authorization': f'Basic {credentials}' + } + + +def get_ado_headers_json(pat): + """Build HTTP headers for ADO REST API with standard JSON content type (for comments)""" + credentials = base64.b64encode(f":{pat}".encode()).decode() + return { + 'Content-Type': 'application/json', + 'Authorization': f'Basic {credentials}' + } + + +def get_iteration_path(): + """Build iteration path from prefix + bi-weekly sprint naming. + Format: \\Sprint N Mon FY YY-YY + where N=1 (day 1-15) or N=2 (day 16+), Mon=abbreviated month, + FY=financial year starting April (e.g., FY 26-27). + """ + if not ado_iteration_prefix: + return None + now = datetime.now() + sprint = 1 if now.day <= 15 else 2 + month_abbr = now.strftime('%b') + fy_start = now.year if now.month >= 4 else now.year - 1 + fy_end = fy_start + 1 + return f"{ado_iteration_prefix}\\Sprint {sprint} {month_abbr} FY {fy_start % 100}-{fy_end % 100}" + + +def check_tracking_table(event_arn, resource_arn): + """Check if a resource is already being tracked for a specific event""" + try: + response = track_table.get_item( + Key={ + 'resourceArn': resource_arn, + 'eventArn': event_arn + } + ) + if 'Item' in response: + logger.info(f"Found tracking for resource {resource_arn} in event {event_arn}") + return response['Item'] + logger.info(f"No tracking found for resource {resource_arn} in event {event_arn}") + return None + except ClientError as e: + logger.error(f"Error querying tracking table: {e.response['Error']['Message']}") + return None + + +def find_existing_workitem_for_event(event_arn, identifier): + """Find an existing ADO work item ID for an event and identifier""" + try: + response = track_table.query( + IndexName='TETkeyIndex', + KeyConditionExpression='eventArn = :event_arn', + FilterExpression='identifier = :identifier', + ExpressionAttributeValues={ + ':event_arn': event_arn, + ':identifier': identifier + } + ) + + for item in response.get('Items', []): + if 'adoWorkItemId' in item: + work_item_id = int(item['adoWorkItemId']) + logger.info(f"Found existing work item {work_item_id} for event {event_arn} and identifier {identifier}") + return work_item_id, item.get('adoProject', '') + + logger.info(f"No existing work item found for event {event_arn} and identifier {identifier}") + return None, None + + except ClientError as e: + logger.error(f"Error querying tracking table for existing work item: {e.response['Error']['Message']}") + return None, None + + +def store_event_tracking(event_arn, start_time, work_item_id, resource_arn, project=None, identifier=None): + """Store event tracking information in DynamoDB""" + from dateutil.relativedelta import relativedelta + + try: + if isinstance(start_time, str): + try: + start_time_format = datetime.strptime(start_time, "%a, %d %b %Y %H:%M:%S %Z") + except ValueError: + try: + start_time_format = datetime.strptime(start_time, "%Y-%m-%dT%H:%M:%S.%fZ") + except ValueError: + start_time_format = datetime.now() + else: + start_time_format = datetime.now() + + expiration_time = int((start_time_format + relativedelta(years=2)).timestamp()) + + item = { + 'eventArn': event_arn, + 'resourceArn': resource_arn, + 'adoWorkItemId': int(work_item_id), + 'expirationTime': expiration_time + } + + if project: + item['adoProject'] = project + + if identifier: + item['identifier'] = identifier + + track_table.put_item(Item=item) + logger.info(f"Successfully stored event tracking for resource: {resource_arn} with work item ID: {work_item_id}") + return True + + except Exception as e: + logger.error(f"Error storing event tracking: {str(e)}") + return False + + +def get_resource_arn(resource): + """Extract resource ARN from the resource object""" + if isinstance(resource.get('arn'), dict) and 'resource_arn' in resource['arn']: + return resource['arn']['resource_arn'] + return resource.get('arn') + + +def build_feature_payload(event_body, identifier, resources, area_path): + """Build JSON Patch document for creating a Feature work item""" + eventTypeCode = event_body['detail']['eventTypeCode'] + service = event_body['detail']['service'] + deployModel = event_body['deployModel'] + + # Build title based on deploy model + if deployModel == 'Account': + title = f"Account: {identifier} - AWS {service} Planned Maintenance - {eventTypeCode}" + elif deployModel == 'Tag': + title = f"Tag: {identifier} - AWS {service} Planned Maintenance - {eventTypeCode} - Tag Based" + elif deployModel == 'Service': + title = f"Service: {identifier} - AWS {service} Planned Maintenance - {eventTypeCode} - Service Based" + else: + title = f"AWS Planned Maintenance - {eventTypeCode}" + + # Build HTML description + event_description = event_body['detail']['eventDescription'] + description = f"

Event Description:

{event_description}

" + description += "

Affected Resources:

" + + for resource in resources: + resource_arn = get_resource_arn(resource) + if resource_arn: + status = resource.get('status', 'UNKNOWN') + last_updated = resource.get('last_updated_time', 'UNKNOWN') + description += ( + f"

Resource: {resource_arn}
" + f"Status: {status}
" + f"Last Updated: {last_updated}

" + ) + + # Build JSON Patch document + patch = [ + {"op": "add", "path": "/fields/System.Title", "value": title}, + {"op": "add", "path": "/fields/System.State", "value": "New"}, + {"op": "add", "path": "/fields/Microsoft.VSTS.Common.Priority", "value": 3}, + {"op": "add", "path": "/fields/System.Description", "value": description} + ] + + if area_path: + patch.append({"op": "add", "path": "/fields/System.AreaPath", "value": area_path}) + + iteration_path = get_iteration_path() + if iteration_path: + patch.append({"op": "add", "path": "/fields/System.IterationPath", "value": iteration_path}) + + # Append custom required fields + for cf in ado_custom_fields: + patch.append({"op": "add", "path": f"/fields/{cf['field']}", "value": cf['value']}) + + return patch, title + + +def build_child_task_payload(feature_title, feature_id, feature_url, area_path): + """Build JSON Patch document for creating a Child Task linked to a Feature""" + patch = [ + {"op": "add", "path": "/fields/System.Title", "value": f"Task: {feature_title}"}, + {"op": "add", "path": "/fields/System.State", "value": "New"}, + { + "op": "add", + "path": "/relations/-", + "value": { + "rel": "System.LinkTypes.Hierarchy-Reverse", + "url": feature_url + } + } + ] + + if area_path: + patch.append({"op": "add", "path": "/fields/System.AreaPath", "value": area_path}) + + iteration_path = get_iteration_path() + if iteration_path: + patch.append({"op": "add", "path": "/fields/System.IterationPath", "value": iteration_path}) + + return patch + + +def build_comment_payload(resources): + """Build comment payload for updating an existing Feature""" + text = "Update for resources:

" + for resource in resources: + resource_arn = get_resource_arn(resource) + if resource_arn: + status = resource.get('status', 'UNKNOWN') + last_updated = resource.get('last_updated_time', 'UNKNOWN') + text += ( + f"Resource: {resource_arn}
" + f"Status: {status}
" + f"Last Updated: {last_updated}

" + ) + return {"text": text} + + +def create_work_item(project, work_item_type, patch, headers): + """Create a work item in ADO""" + url = f"{ado_org_url}/{project}/_apis/wit/workitems/${work_item_type}?api-version=7.1" + logger.info(f"Creating {work_item_type} in project {project}") + + response = http.request('POST', url, headers=headers, body=json.dumps(patch)) + + if response.status in [200, 201]: + data = json.loads(response.data) + logger.info(f"Successfully created {work_item_type} with ID: {data['id']}") + return data + else: + logger.error(f"Failed to create {work_item_type}. Status: {response.status}, Response: {response.data.decode()}") + return None + + +def add_comment(project, work_item_id, payload, headers): + """Add a comment to an existing work item""" + url = f"{ado_org_url}/{project}/_apis/wit/workItems/{work_item_id}/comments?api-version=7.1-preview.4" + logger.info(f"Adding comment to work item {work_item_id} in project {project}") + + response = http.request('POST', url, headers=headers, body=json.dumps(payload)) + + if response.status == 200: + data = json.loads(response.data) + logger.info(f"Successfully added comment to work item {work_item_id}") + return data + else: + logger.error(f"Failed to add comment to work item {work_item_id}. Status: {response.status}, Response: {response.data.decode()}") + return None + + +def activate_work_item(project, work_item_id, headers): + """Update a work item's state to Active and set current iteration path""" + patch = [ + {"op": "replace", "path": "/fields/System.State", "value": "Active"} + ] + iteration_path = get_iteration_path() + if iteration_path: + patch.append({"op": "replace", "path": "/fields/System.IterationPath", "value": iteration_path}) + + url = f"{ado_org_url}/{project}/_apis/wit/workitems/{work_item_id}?api-version=7.1" + logger.info(f"Activating work item {work_item_id} in project {project}") + + response = http.request('PATCH', url, headers=headers, body=json.dumps(patch)) + + if response.status == 200: + logger.info(f"Successfully activated work item {work_item_id}") + return json.loads(response.data) + else: + logger.error(f"Failed to activate work item {work_item_id}. Status: {response.status}, Response: {response.data.decode()}") + return None + + +def resolve_work_item(project, work_item_id, headers): + """Update a work item's state to Resolved when all resources are resolved""" + patch = [ + {"op": "replace", "path": "/fields/System.State", "value": "Resolved"} + ] + + url = f"{ado_org_url}/{project}/_apis/wit/workitems/{work_item_id}?api-version=7.1" + logger.info(f"Resolving work item {work_item_id} in project {project}") + + response = http.request('PATCH', url, headers=headers, body=json.dumps(patch)) + + if response.status == 200: + logger.info(f"Successfully resolved work item {work_item_id}") + return json.loads(response.data) + else: + logger.warning(f"Failed to resolve work item {work_item_id}. Status: {response.status}, Response: {response.data.decode()}") + return None + + +def all_resources_resolved(resources): + """Check if all resources in a list have RESOLVED status""" + if not resources: + return False + return all(r.get('status') == 'RESOLVED' for r in resources) + + +def get_project_and_area_path(event_body, identifier): + """Look up ADO project name and area path from DynamoDB mapping table""" + deploy_model = event_body['deployModel'] + mapping_table_name = os.environ.get('ADO_DYNAMODB_TABLE') + if not mapping_table_name: + raise ValueError("ADO_DYNAMODB_TABLE environment variable is not set") + + mapping_table = dynamodb.Table(mapping_table_name) + + # Model-specific key/attribute names + config = { + 'Account': {'key': 'Account', 'project_attr': 'ACADOProjectName', 'area_attr': 'ACADOAreaPath'}, + 'Service': {'key': 'Service', 'project_attr': 'SADOProjectName', 'area_attr': 'SADOAreaPath'}, + 'Tag': {'key': 'HostTag', 'project_attr': 'HTADOProjectName', 'area_attr': 'HTADOAreaPath'} + } + + model_config = config.get(deploy_model) + if not model_config: + raise ValueError(f"Invalid deploy model: {deploy_model}") + + # Look up identifier, fall back to DefaultProjectCode + for lookup_key in [identifier, 'DefaultProjectCode']: + try: + response = mapping_table.get_item(Key={model_config['key']: lookup_key}) + if 'Item' in response: + item = response['Item'] + project = item.get(model_config['project_attr']) + # Use fixed ADOAreaPath if set, otherwise use DynamoDB value + area_path = ado_area_path if ado_area_path else item.get(model_config['area_attr'], '') + logger.info(f"Found mapping for {lookup_key}: project={project}, area_path={area_path}") + return project, area_path + except ClientError as e: + logger.error(f"Error looking up mapping for {lookup_key}: {e.response['Error']['Message']}") + + logger.error(f"No mapping found for identifier {identifier} or DefaultProjectCode") + return None, None + + +def lambda_handler(event, context): + """Main Lambda handler""" + + # Parse SQS message + event_body = json.loads(event['Records'][0]['body']) + + eventArn = event_body['detail']['eventArn'] + deployModel = event_body['deployModel'] + startTime = event_body['detail'].get('startTime', '') + + logger.info(f"Processing event: {eventArn} with deploy model: {deployModel}") + + # Get ADO PAT from Secrets Manager + ado_secret = get_secret() + pat = ado_secret.get('pat', ado_secret.get('PAT', '')) + if not pat: + raise ValueError("PAT not found in secret") + + headers_patch = get_ado_headers(pat) + headers_json = get_ado_headers_json(pat) + + # Collect all resources per Feature for final resolve/activate decision + # Key: (work_item_id, project), Value: list of resource dicts with status + feature_resources_map = {} + + # Process untracked resources (new work items) + untracked_resources = event_body.get('untrackedResources', {}) + if untracked_resources is None or untracked_resources == []: + untracked_resources = {} + elif not isinstance(untracked_resources, dict): + logger.warning(f"untrackedResources is not a dictionary: {type(untracked_resources)}. Converting to empty dict.") + untracked_resources = {} + + for identifier, resources in untracked_resources.items(): + logger.info(f"Processing resources for {deployModel} {identifier} with {len(resources)} resources") + + # Look up project and area path + project, area_path = get_project_and_area_path(event_body, identifier) + if not project: + logger.error(f"No project mapping found for identifier {identifier}, skipping") + continue + + # Check if we already have a Feature for this event + existing_work_item_id, existing_project = find_existing_workitem_for_event(eventArn, identifier) + + if existing_work_item_id: + logger.info(f"Found existing Feature {existing_work_item_id} for event {eventArn}") + + # Add comment to existing Feature + comment_payload = build_comment_payload(resources) + add_comment(existing_project or project, existing_work_item_id, comment_payload, headers_json) + + # Track all resources + for resource in resources: + resource_arn = get_resource_arn(resource) + if resource_arn: + store_event_tracking(eventArn, startTime, existing_work_item_id, resource_arn, project, identifier) + + # Collect resources for final resolve/activate decision + key = (existing_work_item_id, existing_project or project) + if key not in feature_resources_map: + feature_resources_map[key] = [] + feature_resources_map[key].extend(resources) + else: + logger.info(f"No existing Feature found for event {eventArn}, creating new Feature + Child Task") + + # Step 1: Create Feature + feature_patch, feature_title = build_feature_payload(event_body, identifier, resources, area_path) + feature_data = create_work_item(project, 'Feature', feature_patch, headers_patch) + + if feature_data: + feature_id = feature_data['id'] + feature_url = feature_data['url'] + + logger.info(f"Successfully created Feature {feature_id} in project {project}") + + # Step 2: Create Child Task + task_patch = build_child_task_payload(feature_title, feature_id, feature_url, area_path) + task_data = create_work_item(project, 'Task', task_patch, headers_patch) + + if task_data: + logger.info(f"Successfully created Child Task {task_data['id']} linked to Feature {feature_id}") + else: + logger.error(f"Failed to create Child Task for Feature {feature_id}") + + # Track all resources against the Feature + for resource in resources: + resource_arn = get_resource_arn(resource) + if resource_arn: + store_event_tracking(eventArn, startTime, feature_id, resource_arn, project, identifier) + else: + logger.warning(f"Could not extract resource ARN from resource: {resource}") + + # Collect resources for final resolve/activate decision + key = (feature_id, project) + if key not in feature_resources_map: + feature_resources_map[key] = [] + feature_resources_map[key].extend(resources) + + # Process tracked resources (updates to existing Features) + logger.info(f"Processing tracked resources for {deployModel} mode") + + tracked_resources = event_body.get('trackedResources', []) + if tracked_resources: + # Group resources by adoWorkItemId + work_item_groups = {} + for resource in tracked_resources: + resource_arn = resource.get('arn') + if not resource_arn: + logger.warning(f"Resource missing arn: {resource}") + continue + + tracking_info = check_tracking_table(eventArn, resource_arn) + if tracking_info and 'adoWorkItemId' in tracking_info: + wi_id = int(tracking_info['adoWorkItemId']) + wi_project = tracking_info.get('adoProject', '') + key = (wi_id, wi_project) + if key not in work_item_groups: + work_item_groups[key] = [] + work_item_groups[key].append(resource) + else: + logger.warning(f"No tracking info found for resource: {resource_arn}") + + # Add comments to each Feature + for (wi_id, wi_project), resources in work_item_groups.items(): + logger.info(f"Updating Feature {wi_id} with {len(resources)} resources") + comment_payload = build_comment_payload(resources) + add_comment(wi_project, wi_id, comment_payload, headers_json) + + # Collect resources for final resolve/activate decision + key = (wi_id, wi_project) + if key not in feature_resources_map: + feature_resources_map[key] = [] + feature_resources_map[key].extend(resources) + + # Final resolve/activate decision for each Feature touched in this execution + for (wi_id, wi_project), resources in feature_resources_map.items(): + if enable_auto_resolve and all_resources_resolved(resources): + logger.info(f"All resources resolved for Feature {wi_id}, resolving work item") + resolve_work_item(wi_project, wi_id, headers_patch) + elif enable_auto_activate: + activate_work_item(wi_project, wi_id, headers_patch) + + +logger.info('Lambda function initialized') diff --git a/azuredevops/code/HealthEventProcessorLambda.py b/azuredevops/code/HealthEventProcessorLambda.py new file mode 100644 index 0000000..4578e22 --- /dev/null +++ b/azuredevops/code/HealthEventProcessorLambda.py @@ -0,0 +1,531 @@ +""" +AWS Health Compass - Health Event Processor Lambda for Azure DevOps Integration +Processes incoming AWS Health events, categorizes resources, and prepares messages +for work item creation or updates in Azure DevOps. +""" + +import json +import logging +import os +from typing import Dict, Any, List, Tuple +from datetime import datetime +import boto3 + + +# Setup Logger +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +deploy_model = os.environ.get('DEPLOY_MODEL') +tag_key = os.environ.get('TAG_KEY') +if not deploy_model: + raise ValueError("DEPLOY_MODEL environment variable is not set") +if deploy_model == 'Tag' and not tag_key: + raise ValueError("TAG_KEY environment variable is not set") + +class ResourceProcessor: + def __init__(self): + """Initialize with configuration based on deployment model""" + self.dynamodb = boto3.client('dynamodb') + self.table_name = os.environ.get('ADO_DYNAMODB_TABLE') + self.deploy_model = os.environ.get('DEPLOY_MODEL') + self.sqs = boto3.client('sqs') + self.queue_url = os.environ.get('SQS_QUEUE_URL') + self.organizations = boto3.client('organizations') + + if not self.table_name: + raise ValueError("ADO_DYNAMODB_TABLE environment variable is not set") + if not self.deploy_model: + raise ValueError("DEPLOY_MODEL environment variable is not set") + if not self.queue_url: + raise ValueError("SQS_QUEUE_URL environment variable is not set") + + # Model-specific configurations + self.config = { + 'Account': { + 'primary_key': 'Account', + 'project_key': 'ACADOProjectName', + 'area_path_key': 'ACADOAreaPath', + 'index_name': 'ACkeyIndex' + }, + 'Service': { + 'primary_key': 'Service', + 'project_key': 'SADOProjectName', + 'area_path_key': 'SADOAreaPath', + 'index_name': 'SkeyIndex' + }, + 'Tag': { + 'primary_key': 'HostTag', + 'project_key': 'HTADOProjectName', + 'area_path_key': 'HTADOAreaPath', + 'index_name': 'HTkeyIndex' + } + } + + if self.deploy_model not in self.config: + raise ValueError(f"Invalid deploy model: {self.deploy_model}") + + self.model_config = self.config[self.deploy_model] + + def send_to_queue(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Send message to SQS queue""" + try: + response = self.sqs.send_message( + QueueUrl=self.queue_url, + MessageBody=json.dumps(payload) + ) + logger.info(f"Message sent to queue: {response['MessageId']}") + logger.info(f"{json.dumps(payload)}") + return response + except Exception as e: + logger.error(f"Error sending message to queue: {str(e)}") + raise + + def prepare_queue_message(self, event: Dict[str, Any], + tracked_resources: Dict[str, List[Dict]], + untracked_resources: Dict[str, List[Dict]]) -> Dict[str, Any]: + """Prepare a single message containing both tracked and untracked resources""" + event_details = event['detail'] + + return { + 'timestamp': datetime.utcnow().isoformat(), + 'detail': { + 'eventTypeCode': event_details['eventTypeCode'], + 'eventDescription': event_details['eventDescription'][0]['latestDescription'], + 'startTime': event_details.get('startTime'), + 'endTime': event_details.get('endTime'), + 'eventArn': event_details['eventArn'], + 'service': event_details['service'], + 'eventRegion': event_details['eventRegion'], + 'account': event_details['affectedAccount'], + 'eventTypeCategory': event_details['eventTypeCategory'], + 'id': event['id'], + 'time': event['time'], + 'region': event['region'] + }, + 'deployModel': self.deploy_model, + 'trackedResources': tracked_resources, + 'untrackedResources': untracked_resources + } + + +def get_tags_using_resource_groups(resource_arn: str, region: str, target_account: str) -> str: + """Get tags using Resource Groups Tagging API with cross-account support""" + try: + sts = boto3.client('sts') + current_account = sts.get_caller_identity()['Account'] + logger.info(f"get_tags_using_resource_groups {resource_arn} in account {target_account}") + + if current_account == target_account: + logger.info("get_tags_using_resource_groups same account processing") + resourcetagging = boto3.client('resourcegroupstaggingapi', region_name=region) + else: + assume_role_name = os.environ.get('ASSUME_ROLE_NAME') + if not assume_role_name: + raise ValueError("ASSUME_ROLE_NAME environment variable not set") + logger.info(f"get_tags_using_resource_groups cross-account processing {target_account}") + role_arn = f"arn:aws:iam::{target_account}:role/{assume_role_name}" + + assumed_role = sts.assume_role( + RoleArn=role_arn, + RoleSessionName="AssumeRoleSession" + ) + + resourcetagging = boto3.client( + 'resourcegroupstaggingapi', + region_name=region, + aws_access_key_id=assumed_role['Credentials']['AccessKeyId'], + aws_secret_access_key=assumed_role['Credentials']['SecretAccessKey'], + aws_session_token=assumed_role['Credentials']['SessionToken'] + ) + logger.info(f"get_tags_using_resource_groups Successfully assumed role {assume_role_name}") + + response = resourcetagging.get_resources( + ResourceARNList=[resource_arn] + ) + + if response['ResourceTagMappingList']: + tags = response['ResourceTagMappingList'][0].get('Tags', []) + for tag in tags: + if tag['Key'] == tag_key: + return tag['Value'] + + return 'HOST_TAG_NOT_AVAILABLE' + + except Exception as e: + logger.error(f"Error getting {tag_key} tag using Resource Groups: {str(e)}") + return 'HOST_TAG_NOT_AVAILABLE' + + +def needs_special_handling(service: str) -> bool: + """Check if service needs special handling for tag retrieval""" + special_handling_services = { + 's3': True, + 'iam': True, + 'route53': True, + 'cloudfront': True, + 'autoscaling': True + } + return special_handling_services.get(service, False) + + +def get_tags_special_handling(resource_arn: str, region: str, target_account: str, service_name: str) -> str: + """Handle tag retrieval for services with special requirements""" + try: + sts = boto3.client('sts') + current_account = sts.get_caller_identity()['Account'] + logger.info(f"Special handling for Service {service_name}: Processing resource {resource_arn} in account {target_account}") + + if current_account == target_account: + logger.info(f"Special handling {service_name}: Processing using same account") + tagapi = boto3.client(service_name) + + if service_name == 's3': + bucket_name = resource_arn.split(':')[-1].split('/')[-1] + logger.info(f"Special handling for Service {service_name}: Processing {bucket_name}") + if '/' in resource_arn.split(':')[-1]: + logger.warning("S3 object tagging not supported") + return 'HOST_TAG_NOT_AVAILABLE' + response = tagapi.get_bucket_tagging(Bucket=bucket_name) + elif service_name == 'autoscaling': + asg_name = resource_arn.split(':')[-1].split('/')[-1] + logger.info(f"Extracted ASG name: {asg_name}") + response = tagapi.describe_tags( + Filters=[{'Name': 'auto-scaling-group', 'Values': [asg_name]}] + ) + else: + logger.warning(f"Special handling for {service_name} not implemented") + return 'HOST_TAG_NOT_AVAILABLE' + else: + assume_role_name = os.environ.get('ASSUME_ROLE_NAME') + if not assume_role_name: + logger.error("ASSUME_ROLE_NAME environment variable not set") + return 'HOST_TAG_NOT_AVAILABLE' + logger.info(f"Special handling: Using cross-account access to target {target_account} from {current_account}") + role_arn = f"arn:aws:iam::{target_account}:role/{assume_role_name}" + + assumed_role = sts.assume_role( + RoleArn=role_arn, + RoleSessionName="AssumeRoleSession" + ) + session = boto3.client( + service_name, + region_name=region, + aws_access_key_id=assumed_role['Credentials']['AccessKeyId'], + aws_secret_access_key=assumed_role['Credentials']['SecretAccessKey'], + aws_session_token=assumed_role['Credentials']['SessionToken'] + ) + logger.info(f"Successfully assumed role {assume_role_name}") + if service_name == 's3': + bucket_name = resource_arn.split(':')[-1].split('/')[-1] + logger.info(f"Special handling for Service {service_name}: Processing {bucket_name}") + if '/' in resource_arn.split(':')[-1]: + logger.warning("S3 object tagging not supported") + return 'HOST_TAG_NOT_AVAILABLE' + response = session.get_bucket_tagging(Bucket=bucket_name) + elif service_name == 'autoscaling': + asg_name = resource_arn.split(':')[-1].split('/')[-1] + logger.info(f"Extracted ASG name: {asg_name}") + response = session.describe_tags( + Filters=[{'Name': 'auto-scaling-group', 'Values': [asg_name]}] + ) + else: + logger.warning(f"Special handling for {service_name} not implemented") + return 'HOST_TAG_NOT_AVAILABLE' + + if service_name in ['s3', 'cloudfront']: + for tag in response.get('TagSet', []): + if tag['Key'] == tag_key: + logger.info(f"Found {tag_key} tag with value: {tag['Value']}") + return tag['Value'] + else: + for tag in response.get('Tags', []): + logger.info(f"Special handling for {service_name}: Found tag: {tag}") + if tag['Key'] == tag_key: + logger.info(f"Found Host tag with value: {tag['Value']}") + return tag['Value'] + return 'HOST_TAG_NOT_AVAILABLE' + except Exception as e: + logger.error(f"Error in special handling for {service_name}: {str(e)}") + return 'HOST_TAG_NOT_AVAILABLE' + + +def get_entity_details(resource_arn: str, event: Dict[str, Any]) -> Dict[str, Any]: + """Get entity details from event for a specific resource ARN""" + affected_entities = event.get('detail', {}).get('affectedEntities', []) + logger.info("get_entity_details: Looking up affected entities") + for entity in affected_entities: + if entity.get('entityValue') == resource_arn: + return { + 'entity_value': entity.get('entityValue'), + 'status': entity.get('status'), + 'last_updated_time': entity.get('lastUpdatedTime') + } + logger.warning(f"No entity details found for resource ARN: {resource_arn}") + return {} + + +def get_host_tag_for_resource(resource_arn: str, affected_account: str, region: str) -> str: + """Get tag for a resource with fallback for unsupported services""" + try: + arn_parts = resource_arn.split(':') + service_name = arn_parts[2].lower() + arn_region = arn_parts[3] + region = arn_region if arn_region else region + arn_account = arn_parts[4] + target_account = arn_account if arn_account else affected_account + logger.info(f"get_host_tag_for_resource {resource_arn}, Service {service_name}, region {region} target account {target_account}") + + if needs_special_handling(service_name): + return get_tags_special_handling(resource_arn, region, target_account, service_name) + + return get_tags_using_resource_groups(resource_arn, region, target_account) + + except Exception as e: + logger.error(f"Error getting {tag_key} tag: {str(e)}") + return 'HOST_TAG_NOT_AVAILABLE' + + +def get_resources_by_identifier(untracked_resources: List[Dict[str, Any]], event: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: + """Groups resources by their identifier based on deployment model""" + resources_by_identifier = {} + + try: + service = event['detail']['service'] + region = event['detail']['eventRegion'] + affected_account = event['detail'].get('affectedAccount') + main_account = event.get('account') + + logger.info(f"get_resources_by_identifier: Deployment model {deploy_model} service {service} region {region} affected_account {affected_account} main_account {main_account}") + + for resource_item in untracked_resources: + try: + resource_arn = resource_item.get('arn') + if not resource_arn: + logger.warning(f"Resource item missing resource_arn field: {resource_item}") + continue + + if deploy_model == 'Account': + identifier = affected_account or main_account + elif deploy_model == 'Service': + identifier = event['detail']['service'] + elif deploy_model == 'Tag': + identifier = get_host_tag_for_resource(resource_arn, affected_account, region) + + if identifier not in resources_by_identifier: + resources_by_identifier[identifier] = [] + + resources_by_identifier[identifier].append({ + 'arn': resource_arn, + 'status': resource_item.get('status'), + 'last_updated_time': resource_item.get('last_updated_time') + }) + + except Exception as e: + logger.error(f"Error processing resource {resource_item}: {e}") + if 'HOST_TAG_NOT_AVAILABLE' not in resources_by_identifier: + resources_by_identifier['HOST_TAG_NOT_AVAILABLE'] = [] + resources_by_identifier['HOST_TAG_NOT_AVAILABLE'].append({ + 'arn': resource_item.get('resource_arn', 'unknown'), + 'status': resource_item.get('status', 'unknown'), + 'last_updated_time': resource_item.get('last_updated_time', 'unknown'), + 'host_tag': 'HOST_TAG_NOT_AVAILABLE' + }) + logger.info(f"resources_by_identifier: {resources_by_identifier}") + return resources_by_identifier + + except KeyError as e: + logger.error(f"Missing required field in event: {e}") + raise ValueError(f"Invalid event structure: missing {e}") from e + except Exception as e: + logger.error(f"Error processing event: {e}") + raise + + +def check_existing_event(event_arn: str, resource_arn: str) -> list: + """Check if event ARN exists in DynamoDB and return ticket details""" + try: + logger.info(f"check_existing_event: Checking tracking for event_arn: {event_arn} and resource_arn: {resource_arn}") + dynamodb = boto3.resource('dynamodb') + table_name = os.environ.get('DYNAMODB_TRACK_TABLE') + table = dynamodb.Table(table_name) + + # Direct lookup using primary key + try: + response = table.get_item( + Key={ + 'resourceArn': resource_arn, + 'eventArn': event_arn + } + ) + if 'Item' in response: + logger.info(f"check_existing_event: Found existing tracking via direct lookup: {response['Item']}") + return [response['Item']] + except Exception as e: + logger.warning(f"Direct lookup failed: {str(e)}") + + # Fallback to GSI + response = table.query( + IndexName='TETkeyIndex', + KeyConditionExpression='eventArn = :event_arn', + FilterExpression='resourceArn = :resource_arn', + ExpressionAttributeValues={ + ':event_arn': event_arn, + ':resource_arn': resource_arn + } + ) + + items = response.get('Items', []) + if items: + logger.info(f"check_existing_event: Found existing tracking via GSI: {items}") + else: + logger.info("check_existing_event: No existing tracking found via GSI, trying scan") + scan_response = table.scan( + FilterExpression='eventArn = :event_arn AND resourceArn = :resource_arn', + ExpressionAttributeValues={ + ':event_arn': event_arn, + ':resource_arn': resource_arn + } + ) + scan_items = scan_response.get('Items', []) + if scan_items: + logger.info(f"check_existing_event: Found existing tracking via scan: {scan_items}") + return scan_items + else: + logger.info("check_existing_event: No existing tracking found via any method") + + return items + + except Exception as e: + logger.error(f"Error checking existing event: {str(e)}") + return [] + + +def generate_resource_arn(service, region, resource_id, affected_account): + """Generate AWS resource ARN based on service type and resource ID""" + logger.info(f"generate_resource_arn: Generating ARN for service: {service}, resource_id: {resource_id}") + + if 'arn:' in resource_id: + return resource_id + + try: + if service == 'EC2': + return f"arn:aws:ec2:{region}:{affected_account}:instance/{resource_id}" + if service == 'S3': + return f"arn:aws:s3:::{resource_id}" + if service == 'EBS': + if ':' in resource_id: + resource_id = resource_id.split(':')[-1] + return f"arn:aws:ec2:{region}:{affected_account}:volume/{resource_id}" + + logger.warning(f"Unsupported service type: {service}") + return resource_id + + except Exception as e: + logger.error(f"Error generating ARN: {str(e)}") + raise + + +def group_resources_by_tracking_status(event: Dict[str, Any]) -> Tuple[List[Dict[str, str]], List[str]]: + """Groups resources into tracked and untracked based on DynamoDB records""" + try: + logger.info("group_resources_by_tracking_status: Checking for event tracking and creating list of existing vs new") + + detail = event.get('detail', {}) + event_arn = detail.get('eventArn') + service = detail.get('service') + region = detail.get('eventRegion') + affected_account = detail.get('affectedAccount') + + tracked_resources: List[Dict[str, str]] = [] + untracked_resources: List[str] = [] + + resource_list = [] + for entity in event.get('detail', {}).get('affectedEntities', []): + if entity.get('entityValue'): + resource_list.append(entity.get('entityValue')) + + logger.info(f"Processing {len(resource_list)} resources from event {event_arn}") + + for resource_id in resource_list: + resource_arn = generate_resource_arn( + service=service, + region=region, + resource_id=resource_id, + affected_account=affected_account + ) + + entity_info = get_entity_details(resource_arn, event) + + existing_tracking = check_existing_event( + event_arn=event_arn, + resource_arn=resource_arn + ) + + if existing_tracking: + logger.info(f"Resource {resource_arn} is already tracked with ticket info: {existing_tracking}") + resource_info = { + 'arn': entity_info.get('entity_value'), + 'status': entity_info.get('status'), + 'last_updated_time': entity_info.get('last_updated_time') + } + for tracking_item in existing_tracking: + if 'adoWorkItemId' in tracking_item: + resource_info['ticket_id'] = int(tracking_item['adoWorkItemId']) + tracked_resources.append(resource_info) + else: + logger.info(f"Resource {resource_arn} is not tracked yet") + untracked_resources.append({ + 'arn': entity_info.get('entity_value'), + 'status': entity_info.get('status'), + 'last_updated_time': entity_info.get('last_updated_time') + }) + + logger.info(f"Found {len(tracked_resources)} tracked and {len(untracked_resources)} untracked resources") + return tracked_resources, untracked_resources + + except Exception as e: + logger.error(f"Error grouping resources: {str(e)}") + raise + + +def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """Main Lambda handler""" + try: + logger.info(f"Received SQS event:{json.dumps(event)}") + event_body = json.loads(event['Records'][0]['body']) + + untracked_resources_by_identifier = {} + + tracked_resources, untracked_resources = group_resources_by_tracking_status(event_body) + + logger.info(f"main:TrackedResources:{tracked_resources}") + logger.info(f"main:UntrackedResources:{untracked_resources}") + + if untracked_resources: + logger.info(f"main:Processing {len(untracked_resources)} untracked resources") + untracked_resources_by_identifier = get_resources_by_identifier(untracked_resources, event_body) + + logger.info(f"main:UntrackedResourcesByIdentifier:{untracked_resources_by_identifier}") + processor = ResourceProcessor() + message = processor.prepare_queue_message( + event_body, + tracked_resources, + untracked_resources_by_identifier + ) + + response = processor.send_to_queue(message) + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Successfully processed event and sent to queue', + 'messageId': response['MessageId'], + 'trackedResourcesCount': len(tracked_resources), + 'untrackedResourcesCount': len(untracked_resources) + }) + } + + except Exception as e: + logger.error(f"Error processing event: {str(e)}") + raise diff --git a/azuredevops/images/architecture-diagram-ado-integration.png b/azuredevops/images/architecture-diagram-ado-integration.png new file mode 100644 index 0000000..9c9bc3a Binary files /dev/null and b/azuredevops/images/architecture-diagram-ado-integration.png differ