-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add Lambda that searches accounts for "forgotten" EC2 instances
- Loading branch information
1 parent
12e122e
commit 0f75ee9
Showing
2 changed files
with
222 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
# Untagged EC2 Cleanup | ||
|
||
This is an example Lambda function that will identify EC2 instances that don't meet all | ||
of the following requirements: | ||
|
||
* A non-empty `Name` tag | ||
* A non-empty `CreatedBy` tag | ||
* Either created within the past 7 days, or has a `DeleteAfter` tag that specifies a future | ||
date in the format `YYYY-MM-DD`, | ||
|
||
This Lambda is intended to be run against sandbox accounts, to ensure that developers don't | ||
start machines and forget about them. It is typically triggered via a scheduled CloudWatch Event. | ||
|
||
|
||
## Deployment | ||
|
||
This function is deployed using a CloudFormation template that creates the following resources: | ||
|
||
* The Lambda function itself. | ||
* An execution role. | ||
* A CloudWatch Events rule that triggers the function. | ||
|
||
To create the stack, you must provide the following parameters: | ||
|
||
* `FunctionName` | ||
The name of the Lambda function. This is also used as the base name for the function's | ||
execution role and trigger. | ||
* `Accounts` | ||
A comma-separated list of the accounts to be examined. | ||
* `Regions` | ||
A comma-separated list of the regions to examine for those accounts. This defaults to the | ||
US regsions. | ||
* `RoleName` | ||
The name of a role that is present in all accounts and has permissions to examine and terminate | ||
EC2 instances. This defaults to `OrganizationAccountAccessRole`, which is the default admin role | ||
created when adding an account to an organization. | ||
* `Schedule` | ||
The CloudWatch Events schedule rule that will trigger the Lambda. This defaults to a CRON | ||
expression for 4 AM UTC. | ||
|
||
**As created, the trigger is disabled and the `terminate()` call is commented-out in the Lambda.** | ||
|
||
Before enabling, you should run the Lambda and verify from its output that it will not delete any | ||
unexpected instances. You can either run manually, using the "Test" feature from the AWS Console | ||
(any test event is fine; it's not used), or enable the trigger and examine the CloudWatch logs for | ||
the function after it runs. | ||
|
||
When you are ready to run for real, uncomment the `instance.terminate()` call at line 45. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
AWSTemplateFormatVersion: "2010-09-09" | ||
Description: "Creates a Lambda function that will find forgotten EC2 instances" | ||
|
||
Parameters: | ||
|
||
FunctionName: | ||
Description: "Name for the Lambda function and associated resources" | ||
Type: "String" | ||
Default: "EC2InstanceCleanup" | ||
|
||
Accounts: | ||
Description: "A comma-delimited list of account numbers that will be examined" | ||
Type: "String" | ||
Default: "" | ||
|
||
Regions: | ||
Description: "A comma-delimited list of the regions that will be examined for each account" | ||
Type: "String" | ||
Default: "us-east-1,us-east-2,us-west-1,us-west-2" | ||
|
||
RoleName: | ||
Description: "A role that is present in each account and has the ability to inspect/terminate EC2 instances" | ||
Type: "String" | ||
Default: "OrganizationAccountAccessRole" | ||
|
||
Schedule: | ||
Description: "A schedule specification for the Lambda trigger" | ||
Type: "String" | ||
Default: "cron(0 4 * * ? *)" | ||
|
||
|
||
Resources: | ||
|
||
Trigger: | ||
Type: "AWS::Events::Rule" | ||
Properties: | ||
Name: !Sub "${FunctionName}-TriggerRole" | ||
Description: "Scheduled event to trigger the EC2 cleanup lambda" | ||
State: "DISABLED" | ||
ScheduleExpression: !Ref Schedule | ||
Targets: | ||
- | ||
Id: "EC2-Cleanup" | ||
Arn: !GetAtt LambdaFunction.Arn | ||
|
||
|
||
Permission: | ||
Type: "AWS::Lambda::Permission" | ||
Properties: | ||
FunctionName: !Ref LambdaFunction | ||
Action: "lambda:InvokeFunction" | ||
Principal: "events.amazonaws.com" | ||
SourceArn: !GetAtt Trigger.Arn | ||
|
||
|
||
LambdaRole: | ||
Type: "AWS::IAM::Role" | ||
Properties: | ||
Path: "/lambda/" | ||
RoleName: !Sub "${FunctionName}-ExecutionRole" | ||
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: "AssumeRole" | ||
PolicyDocument: | ||
Version: "2012-10-17" | ||
Statement: | ||
Effect: "Allow" | ||
Action: | ||
- "sts:AssumeRole" | ||
Resource: [ !Sub "arn:aws:iam::*:role/${RoleName}" ] | ||
|
||
|
||
LambdaFunction: | ||
Type: "AWS::Lambda::Function" | ||
Properties: | ||
FunctionName: !Ref FunctionName | ||
Description: "Examines managed accounts to find forgotten EC2 instances" | ||
Role: !GetAtt LambdaRole.Arn | ||
Runtime: "python3.7" | ||
Handler: "index.lambda_handler" | ||
MemorySize: 256 | ||
Timeout: 60 | ||
Environment: | ||
Variables: | ||
ACCOUNTS: !Ref Accounts | ||
REGIONS: !Ref Regions | ||
ROLE_NAME: !Ref RoleName | ||
Code: | ||
ZipFile: | | ||
import boto3 | ||
import os | ||
import uuid | ||
from datetime import datetime, timedelta | ||
DEFAULT_GRACE_PERIOD = timedelta(days=7) | ||
def lambda_handler(event, context): | ||
if not os.environ.get('ACCOUNTS'): | ||
print('must specify at least one account') | ||
return | ||
if not os.environ.get('ROLE_NAME'): | ||
print('must specify an assumed role') | ||
return | ||
accounts = os.environ.get('ACCOUNTS', '').split(',') | ||
regions = os.environ.get('REGIONS', '').split(',') | ||
roleName = os.environ.get('ROLE_NAME', '') | ||
for account in accounts: | ||
for region in regions: | ||
examine_ec2_instances(account.strip(), region.strip(), roleName.strip()) | ||
def examine_ec2_instances(account, region, roleName): | ||
print(f'examining instances in account {account}, region {region}') | ||
client = create_client_with_role('ec2', account, region, roleName) | ||
for instance in client.instances.all(): | ||
examine_ec2_instance(instance) | ||
def examine_ec2_instance(instance): | ||
instanceName = get_tag_value(instance, 'Name') | ||
createdBy = get_tag_value(instance, 'CreatedBy') | ||
deleteAfter = get_tag_value(instance, 'DeleteAfter') | ||
if not deleteAfter: | ||
deleteAfter = (instance.launch_time + DEFAULT_GRACE_PERIOD).isoformat() | ||
if instanceName and createdBy and (deleteAfter < datetime.now().date().isoformat()): | ||
return | ||
asg = get_tag_value(instance, 'aws:autoscaling:groupName') | ||
if asg: | ||
print(f' instance {instance.id} would be deleted, but is controlled by auto-scaling group "{asg}"') | ||
return | ||
print(f' deleting instance {instance.id}: instanceName = "{instanceName}", createdBy = "{createdBy}", deleteAfter = "{deleteAfter}"') | ||
# instance.terminate() | ||
def create_client_with_role(service, account, region, roleName, lowLevel=False): | ||
""" A function for creating boto3 clients that access resources via assumed roles. | ||
The account and rolename must both be specified, to avoid unfortunate accidents | ||
in the invoking account. This function | ||
A more generic version is here: https://github.com/kdgregory/aws-misc/blob/master/snippets/python.md | ||
""" | ||
roleArn = f'arn:aws:iam::{account}:role/{roleName}' | ||
sessionName = (os.environ.get('AWS_LAMBDA_FUNCTION_NAME', 'Interactive') | ||
+ "-" + str(int(datetime.now().timestamp()))) | ||
response = boto3.client('sts').assume_role(RoleArn=roleArn, RoleSessionName=sessionName) | ||
kwargs = {} | ||
kwargs['aws_access_key_id'] = response['Credentials']['AccessKeyId'] | ||
kwargs['aws_secret_access_key'] = response['Credentials']['SecretAccessKey'] | ||
kwargs['aws_session_token'] = response['Credentials']['SessionToken'] | ||
kwargs['region_name'] = region | ||
if lowLevel: | ||
return boto3.client(service, **kwargs) | ||
else: | ||
return boto3.resource(service, **kwargs) | ||
def get_tag_value(instance, tagKey): | ||
for tag in instance.tags: | ||
if tag['Key'] == tagKey: | ||
return tag['Value'] | ||
return None | ||