Skip to content

Commit

Permalink
add Lambda that searches accounts for "forgotten" EC2 instances
Browse files Browse the repository at this point in the history
  • Loading branch information
kgregory-chariot committed Jun 4, 2020
1 parent 12e122e commit 0f75ee9
Show file tree
Hide file tree
Showing 2 changed files with 222 additions and 0 deletions.
48 changes: 48 additions & 0 deletions untagged_ec2_cleanup/README.md
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.
174 changes: 174 additions & 0 deletions untagged_ec2_cleanup/cloudformation.yml
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

0 comments on commit 0f75ee9

Please sign in to comment.