diff --git a/README.md b/README.md index f764d072..cce00436 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ This repo contains a CLI tool to delete all AWS resources in an account. aws-nuk The currently supported functionality includes: +* Deleting all Auto scaling groups in an AWS account +* Deleting all Elastic Load Balancers (Classic and V2) in an AWS account +* Deleting all EBS Volumes in an AWS account * Deleting all unprotected EC2 instances in an AWS account ### WARNING: THIS TOOL IS HIGHLY DESTRUCTIVE, ALL SUPPORTED RESOURCES WILL BE DELETED. ITS EFFECTS ARE IRREVERSIBLE AND SHOULD NEVER BE USED IN A PRODUCTION ENVIRONMENT @@ -19,6 +22,14 @@ The currently supported functionality includes: Simply running `aws-nuke` will start the process of cleaning up your AWS account. You'll be shown a list of resources that'll be deleted as well as a prompt to confirm before any deletion actually takes place. +### Excluding Regions + +You can use the `--exclude-region` flag to exclude resources in certain regions from being deleted. For example the following command does not nuke resources in `ap-south-1` and `ap-south-2` regions: + +```shell +aws-nuke --exclude-region ap-south-1 --exclude-region ap-south-2 +``` + Happy Nuking!!! ## Credentials diff --git a/aws/asg.go b/aws/asg.go new file mode 100644 index 00000000..a7d6298b --- /dev/null +++ b/aws/asg.go @@ -0,0 +1,63 @@ +package aws + +import ( + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/autoscaling" + "github.com/gruntwork-io/aws-nuke/logging" + "github.com/gruntwork-io/gruntwork-cli/errors" +) + +// Returns a formatted string of ASG Names +func getAllAutoScalingGroups(session *session.Session, region string) ([]*string, error) { + svc := autoscaling.New(session) + result, err := svc.DescribeAutoScalingGroups(&autoscaling.DescribeAutoScalingGroupsInput{}) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + var groupNames []*string + for _, group := range result.AutoScalingGroups { + groupNames = append(groupNames, group.AutoScalingGroupName) + } + + return groupNames, nil +} + +// Deletes all Auto Scaling Groups +func nukeAllAutoScalingGroups(session *session.Session, groupNames []*string) error { + svc := autoscaling.New(session) + + if len(groupNames) == 0 { + logging.Logger.Infof("No Auto Scaling Groups to nuke in region %s", *session.Config.Region) + return nil + } + + logging.Logger.Infof("Deleting all Auto Scaling Groups in region %s", *session.Config.Region) + + for _, groupName := range groupNames { + params := &autoscaling.DeleteAutoScalingGroupInput{ + AutoScalingGroupName: groupName, + ForceDelete: awsgo.Bool(true), + } + + _, err := svc.DeleteAutoScalingGroup(params) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("Deleted Auto Scaling Group: %s", *groupName) + } + + err := svc.WaitUntilGroupNotExists(&autoscaling.DescribeAutoScalingGroupsInput{ + AutoScalingGroupNames: groupNames, + }) + + if err != nil { + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("[OK] %d Auto Scaling Group(s) deleted in %s", len(groupNames), *session.Config.Region) + return nil +} diff --git a/aws/asg_test.go b/aws/asg_test.go new file mode 100644 index 00000000..a56781f6 --- /dev/null +++ b/aws/asg_test.go @@ -0,0 +1,98 @@ +package aws + +import ( + "testing" + + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/autoscaling" + "github.com/gruntwork-io/aws-nuke/util" + "github.com/gruntwork-io/gruntwork-cli/errors" + "github.com/stretchr/testify/assert" +) + +func createTestAutoScalingGroup(t *testing.T, session *session.Session, name string) { + svc := autoscaling.New(session) + instance := createTestEC2Instance(t, session, name) + + param := &autoscaling.CreateAutoScalingGroupInput{ + AutoScalingGroupName: &name, + InstanceId: instance.InstanceId, + MinSize: awsgo.Int64(1), + MaxSize: awsgo.Int64(2), + } + + _, err := svc.CreateAutoScalingGroup(param) + if err != nil { + assert.Failf(t, "Could not create test ASG: %s", errors.WithStackTrace(err).Error()) + } + + err = svc.WaitUntilGroupExists(&autoscaling.DescribeAutoScalingGroupsInput{ + AutoScalingGroupNames: []*string{&name}, + }) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } +} + +func TestListAutoScalingGroups(t *testing.T) { + t.Parallel() + + region := getRandomRegion() + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + groupName := "aws-nuke-test-" + util.UniqueID() + createTestAutoScalingGroup(t, session, groupName) + // clean up after this test + defer nukeAllAutoScalingGroups(session, []*string{&groupName}) + + groupNames, err := getAllAutoScalingGroups(session, region) + if err != nil { + assert.Fail(t, "Unable to fetch list of Auto Scaling Groups") + } + + assert.Contains(t, awsgo.StringValueSlice(groupNames), groupName) +} + +func TestNukeAutoScalingGroups(t *testing.T) { + t.Parallel() + + region := getRandomRegion() + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + svc := autoscaling.New(session) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + groupName := "aws-nuke-test-" + util.UniqueID() + createTestAutoScalingGroup(t, session, groupName) + + _, err = svc.DescribeAutoScalingGroups(&autoscaling.DescribeAutoScalingGroupsInput{ + AutoScalingGroupNames: []*string{&groupName}, + }) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + if err := nukeAllAutoScalingGroups(session, []*string{&groupName}); err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + groupNames, err := getAllAutoScalingGroups(session, region) + if err != nil { + assert.Fail(t, "Unable to fetch list of Auto Scaling Groups") + } + + assert.NotContains(t, awsgo.StringValueSlice(groupNames), groupName) +} diff --git a/aws/asg_types.go b/aws/asg_types.go new file mode 100644 index 00000000..93416b93 --- /dev/null +++ b/aws/asg_types.go @@ -0,0 +1,31 @@ +package aws + +import ( + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/gruntwork-io/gruntwork-cli/errors" +) + +// ASGroups - represents all auto scaling groups +type ASGroups struct { + GroupNames []string +} + +// ResourceName - the simple name of the aws resource +func (group ASGroups) ResourceName() string { + return "asg" +} + +// ResourceIdentifiers - The group names of the auto scaling groups +func (group ASGroups) ResourceIdentifiers() []string { + return group.GroupNames +} + +// Nuke - nuke 'em all!!! +func (group ASGroups) Nuke(session *session.Session) error { + if err := nukeAllAutoScalingGroups(session, awsgo.StringSlice(group.GroupNames)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/aws/aws.go b/aws/aws.go index 5a4f4136..5a45a690 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -1,34 +1,59 @@ package aws import ( + "math/rand" + "time" + awsgo "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/endpoints" "github.com/aws/aws-sdk-go/aws/session" + "github.com/gruntwork-io/aws-nuke/logging" + "github.com/gruntwork-io/gruntwork-cli/collections" "github.com/gruntwork-io/gruntwork-cli/errors" ) -// Returns a list of all AWS regions -func getAllRegions() []string { +// GetAllRegions - Returns a list of all AWS regions +func GetAllRegions() []string { + // chinese and government regions are not accessible with regular accounts + reservedRegions := []string{ + "cn-north-1", "cn-northwest-1", "us-gov-west-1", + } + resolver := endpoints.DefaultResolver() partitions := resolver.(endpoints.EnumPartitions).Partitions() var regions []string for _, p := range partitions { for id := range p.Regions() { - regions = append(regions, id) + if !collections.ListContainsElement(reservedRegions, id) { + regions = append(regions, id) + } } } return regions } +func getRandomRegion() string { + allRegions := GetAllRegions() + rand.Seed(time.Now().UnixNano()) + randIndex := rand.Intn(len(allRegions)) + return allRegions[randIndex] +} + // GetAllResources - Lists all aws resources -func GetAllResources() (*AwsAccountResources, error) { +func GetAllResources(regions []string, excludedRegions []string) (*AwsAccountResources, error) { account := AwsAccountResources{ Resources: make(map[string]AwsRegionResource), } - for _, region := range getAllRegions() { + for _, region := range regions { + // Ignore all cli excluded regions + if collections.ListContainsElement(excludedRegions, region) { + logging.Logger.Infoln("Skipping region: " + region) + continue + } + session, err := session.NewSession(&awsgo.Config{ Region: awsgo.String(region)}, ) @@ -39,6 +64,49 @@ func GetAllResources() (*AwsAccountResources, error) { resourcesInRegion := AwsRegionResource{} + // The order in which resources are nuked is important + // because of dependencies between resources + + // ASG Names + groupNames, err := getAllAutoScalingGroups(session, region) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + asGroups := ASGroups{ + GroupNames: awsgo.StringValueSlice(groupNames), + } + + resourcesInRegion.Resources = append(resourcesInRegion.Resources, asGroups) + // End ASG Names + + // LoadBalancer Names + elbNames, err := getAllElbInstances(session, region) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + loadBalancers := LoadBalancers{ + Names: awsgo.StringValueSlice(elbNames), + } + + resourcesInRegion.Resources = append(resourcesInRegion.Resources, loadBalancers) + // End LoadBalancer Names + + // LoadBalancerV2 Arns + elbv2Arns, err := getAllElbv2Instances(session, region) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + loadBalancersV2 := LoadBalancersV2{ + Arns: awsgo.StringValueSlice(elbv2Arns), + } + + resourcesInRegion.Resources = append(resourcesInRegion.Resources, loadBalancersV2) + // End LoadBalancerV2 Arns + + // EC2 Instances instanceIds, err := getAllEc2Instances(session, region) if err != nil { return nil, errors.WithStackTrace(err) @@ -49,6 +117,21 @@ func GetAllResources() (*AwsAccountResources, error) { } resourcesInRegion.Resources = append(resourcesInRegion.Resources, ec2Instances) + // End EC2 Instances + + // EBS Volumes + volumeIds, err := getAllEbsVolumes(session, region) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + ebsVolumes := EBSVolumes{ + VolumeIds: awsgo.StringValueSlice(volumeIds), + } + + resourcesInRegion.Resources = append(resourcesInRegion.Resources, ebsVolumes) + // End EBS Volumes + account.Resources[region] = resourcesInRegion } @@ -56,8 +139,8 @@ func GetAllResources() (*AwsAccountResources, error) { } // NukeAllResources - Nukes all aws resources -func NukeAllResources(account *AwsAccountResources) error { - for _, region := range getAllRegions() { +func NukeAllResources(account *AwsAccountResources, regions []string) error { + for _, region := range regions { session, err := session.NewSession(&awsgo.Config{ Region: awsgo.String(region)}, ) diff --git a/aws/ebs.go b/aws/ebs.go new file mode 100644 index 00000000..4f842d67 --- /dev/null +++ b/aws/ebs.go @@ -0,0 +1,69 @@ +package aws + +import ( + "strings" + + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/gruntwork-io/aws-nuke/logging" + "github.com/gruntwork-io/gruntwork-cli/errors" +) + +// Returns a formatted string of EBS volume ids +func getAllEbsVolumes(session *session.Session, region string) ([]*string, error) { + svc := ec2.New(session) + + result, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{}) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + var volumeIds []*string + for _, volume := range result.Volumes { + volumeIds = append(volumeIds, volume.VolumeId) + } + + return volumeIds, nil +} + +// Deletes all EBS Volumes +func nukeAllEbsVolumes(session *session.Session, volumeIds []*string) error { + svc := ec2.New(session) + + if len(volumeIds) == 0 { + logging.Logger.Infof("No EBS volumes to nuke in region %s", *session.Config.Region) + return nil + } + + logging.Logger.Infof("Deleting all EBS volumes in region %s", *session.Config.Region) + + for _, volumeID := range volumeIds { + params := &ec2.DeleteVolumeInput{ + VolumeId: volumeID, + } + + _, err := svc.DeleteVolume(params) + if err != nil { + // Ignore not found errors, some volumes are deleted along with EC2 Instances + if !strings.Contains(err.Error(), "InvalidVolume.NotFound") { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("EBS volume %s has already been deleted", *volumeID) + } else { + logging.Logger.Infof("Deleted EBS Volume: %s", *volumeID) + } + } + + err := svc.WaitUntilVolumeDeleted(&ec2.DescribeVolumesInput{ + VolumeIds: volumeIds, + }) + + if err != nil { + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("[OK] %d EBS volumes(s) terminated in %s", len(volumeIds), *session.Config.Region) + return nil +} diff --git a/aws/ebs_test.go b/aws/ebs_test.go new file mode 100644 index 00000000..6e6a10ae --- /dev/null +++ b/aws/ebs_test.go @@ -0,0 +1,124 @@ +package aws + +import ( + "testing" + + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/gruntwork-io/aws-nuke/util" + "github.com/gruntwork-io/gruntwork-cli/errors" + "github.com/stretchr/testify/assert" +) + +func createTestEBSVolume(t *testing.T, session *session.Session, name string) ec2.Volume { + svc := ec2.New(session) + volume, err := svc.CreateVolume(&ec2.CreateVolumeInput{ + AvailabilityZone: awsgo.String(awsgo.StringValue(session.Config.Region) + "a"), + Size: awsgo.Int64(8), + }) + + if err != nil { + assert.Failf(t, "Could not create test EBS volume: %s", errors.WithStackTrace(err).Error()) + } + + err = svc.WaitUntilVolumeAvailable(&ec2.DescribeVolumesInput{ + VolumeIds: []*string{awsgo.String(*volume.VolumeId)}, + }) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + // Add test tag to the created instance + _, err = svc.CreateTags(&ec2.CreateTagsInput{ + Resources: []*string{volume.VolumeId}, + Tags: []*ec2.Tag{ + { + Key: awsgo.String("Name"), + Value: awsgo.String(name), + }, + }, + }) + + if err != nil { + assert.Failf(t, "Could not tag EBS volume: %s", errors.WithStackTrace(err).Error()) + } + + return *volume +} + +func findEBSVolumesByNameTag(output *ec2.DescribeVolumesOutput, name string) []*string { + var volumeIds []*string + for _, volume := range output.Volumes { + // Retrieve only IDs of instances with the unique test tag + for _, tag := range volume.Tags { + if awsgo.StringValue(tag.Key) == "Name" && awsgo.StringValue(tag.Value) == name { + volumeIds = append(volumeIds, volume.VolumeId) + } + } + } + + return volumeIds +} + +func TestListEBSVolumes(t *testing.T) { + t.Parallel() + + region := getRandomRegion() + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + uniqueTestID := "aws-nuke-test-" + util.UniqueID() + volume := createTestEBSVolume(t, session, uniqueTestID) + // clean up after this test + defer nukeAllEbsVolumes(session, []*string{volume.VolumeId}) + + volumeIds, err := getAllEbsVolumes(session, region) + if err != nil { + assert.Fail(t, "Unable to fetch list of EBS Volumes") + } + + assert.Contains(t, awsgo.StringValueSlice(volumeIds), awsgo.StringValue(volume.VolumeId)) +} + +func TestNukeEBSVolumes(t *testing.T) { + t.Parallel() + + region := getRandomRegion() + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + uniqueTestID := "aws-nuke-test-" + util.UniqueID() + createTestEC2Instance(t, session, uniqueTestID) + + output, err := ec2.New(session).DescribeVolumes(&ec2.DescribeVolumesInput{}) + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + volumeIds := findEBSVolumesByNameTag(output, uniqueTestID) + + if err := nukeAllEbsVolumes(session, volumeIds); err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + volumes, err := getAllEbsVolumes(session, region) + + if err != nil { + assert.Fail(t, "Unable to fetch list of EC2 Instances") + } + + for _, volumeID := range volumeIds { + assert.NotContains(t, volumes, *volumeID) + } +} diff --git a/aws/ebs_types.go b/aws/ebs_types.go new file mode 100644 index 00000000..7360d60b --- /dev/null +++ b/aws/ebs_types.go @@ -0,0 +1,31 @@ +package aws + +import ( + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/gruntwork-io/gruntwork-cli/errors" +) + +// EBSVolumes - represents all ebs volumes +type EBSVolumes struct { + VolumeIds []string +} + +// ResourceName - the simple name of the aws resource +func (volume EBSVolumes) ResourceName() string { + return "ebs" +} + +// ResourceIdentifiers - The instance ids of the ec2 instances +func (volume EBSVolumes) ResourceIdentifiers() []string { + return volume.VolumeIds +} + +// Nuke - nuke 'em all!!! +func (volume EBSVolumes) Nuke(session *session.Session) error { + if err := nukeAllEbsVolumes(session, awsgo.StringSlice(volume.VolumeIds)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/aws/ec2.go b/aws/ec2.go index 87eecee2..786a7415 100644 --- a/aws/ec2.go +++ b/aws/ec2.go @@ -85,6 +85,24 @@ func nukeAllEc2Instances(session *session.Session, instanceIds []*string) error return errors.WithStackTrace(err) } + for _, instanceID := range instanceIds { + logging.Logger.Infof("Terminated EC2 Instance: %s", *instanceID) + } + + err = svc.WaitUntilInstanceTerminated(&ec2.DescribeInstancesInput{ + Filters: []*ec2.Filter{ + &ec2.Filter{ + Name: awsgo.String("instance-id"), + Values: instanceIds, + }, + }, + }) + + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + logging.Logger.Infof("[OK] %d instance(s) terminated in %s", len(instanceIds), *session.Config.Region) return nil } diff --git a/aws/ec2_test.go b/aws/ec2_test.go index fd2cdb4d..aae48ccf 100644 --- a/aws/ec2_test.go +++ b/aws/ec2_test.go @@ -1,41 +1,41 @@ package aws import ( - "bytes" - "math/rand" "testing" - "time" awsgo "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/gruntwork-io/aws-nuke/util" "github.com/gruntwork-io/gruntwork-cli/errors" "github.com/stretchr/testify/assert" ) -// Returns a unique (ish) id we can attach to resources and tfstate files so they don't conflict with each other -// Uses base 62 to generate a 6 character string that's unlikely to collide with the handful of tests we run in -// parallel. Based on code here: http://stackoverflow.com/a/9543797/483528 -func uniqueID() string { - - const BASE_62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - const UNIQUE_ID_LENGTH = 6 // Should be good for 62^6 = 56+ billion combinations +func createTestEC2Instance(t *testing.T, session *session.Session, name string) ec2.Instance { + svc := ec2.New(session) - var out bytes.Buffer + imagesResult, err := svc.DescribeImages(&ec2.DescribeImagesInput{ + Owners: []*string{awsgo.String("self"), awsgo.String("amazon")}, + Filters: []*ec2.Filter{ + &ec2.Filter{ + Name: awsgo.String("root-device-type"), + Values: []*string{awsgo.String("ebs")}, + }, + &ec2.Filter{ + Name: awsgo.String("virtualization-type"), + Values: []*string{awsgo.String("hvm")}, + }, + }, + }) - rand := rand.New(rand.NewSource(time.Now().UnixNano())) - for i := 0; i < UNIQUE_ID_LENGTH; i++ { - out.WriteByte(BASE_62_CHARS[rand.Intn(len(BASE_62_CHARS))]) + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) } - return out.String() -} - -func createTestEC2Instance(t *testing.T, session *session.Session, name string) ec2.Instance { - svc := ec2.New(session) + imageID := *imagesResult.Images[0].ImageId params := &ec2.RunInstancesInput{ - ImageId: awsgo.String("ami-e7527ed7"), + ImageId: awsgo.String(imageID), InstanceType: awsgo.String("t1.micro"), MinCount: awsgo.Int64(1), MaxCount: awsgo.Int64(1), @@ -43,7 +43,11 @@ func createTestEC2Instance(t *testing.T, session *session.Session, name string) runResult, err := svc.RunInstances(params) if err != nil { - assert.Failf(t, "Could not create test EC2 instance: %s", errors.WithStackTrace(err).Error()) + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + if len(runResult.Instances) == 0 { + assert.Fail(t, "Could not create test EC2 instance") } err = svc.WaitUntilInstanceExists(&ec2.DescribeInstancesInput{ @@ -74,6 +78,20 @@ func createTestEC2Instance(t *testing.T, session *session.Session, name string) assert.Failf(t, "Could not tag EC2 instance: %s", errors.WithStackTrace(err).Error()) } + // EC2 Instance must be in a running before this function returns + err = svc.WaitUntilInstanceRunning(&ec2.DescribeInstancesInput{ + Filters: []*ec2.Filter{ + &ec2.Filter{ + Name: awsgo.String("instance-id"), + Values: []*string{runResult.Instances[0].InstanceId}, + }, + }, + }) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + return *runResult.Instances[0] } @@ -99,17 +117,23 @@ func findEC2InstancesByNameTag(output *ec2.DescribeInstancesOutput, name string) } func TestListInstances(t *testing.T) { + t.Parallel() + + region := getRandomRegion() session, err := session.NewSession(&awsgo.Config{ - Region: awsgo.String("us-west-2")}, + Region: awsgo.String(region)}, ) if err != nil { assert.Fail(t, errors.WithStackTrace(err).Error()) } - uniqueTestID := "aws-nuke-test-" + uniqueID() + uniqueTestID := "aws-nuke-test-" + util.UniqueID() instance := createTestEC2Instance(t, session, uniqueTestID) - instanceIds, err := getAllEc2Instances(session, "us-west-2") + // clean up after this test + defer nukeAllEc2Instances(session, []*string{instance.InstanceId}) + + instanceIds, err := getAllEc2Instances(session, region) if err != nil { assert.Fail(t, "Unable to fetch list of EC2 Instances") @@ -119,15 +143,18 @@ func TestListInstances(t *testing.T) { } func TestNukeInstances(t *testing.T) { + t.Parallel() + + region := getRandomRegion() session, err := session.NewSession(&awsgo.Config{ - Region: awsgo.String("us-west-2")}, + Region: awsgo.String(region)}, ) if err != nil { assert.Fail(t, errors.WithStackTrace(err).Error()) } - uniqueTestID := "aws-nuke-test-" + uniqueID() + uniqueTestID := "aws-nuke-test-" + util.UniqueID() createTestEC2Instance(t, session, uniqueTestID) output, err := ec2.New(session).DescribeInstances(&ec2.DescribeInstancesInput{}) @@ -140,7 +167,7 @@ func TestNukeInstances(t *testing.T) { if err := nukeAllEc2Instances(session, instanceIds); err != nil { assert.Fail(t, errors.WithStackTrace(err).Error()) } - instances, err := getAllEc2Instances(session, "us-west-2") + instances, err := getAllEc2Instances(session, region) if err != nil { assert.Fail(t, "Unable to fetch list of EC2 Instances") diff --git a/aws/ec2_types.go b/aws/ec2_types.go index cbf98742..16c202b7 100644 --- a/aws/ec2_types.go +++ b/aws/ec2_types.go @@ -6,7 +6,12 @@ import ( "github.com/gruntwork-io/gruntwork-cli/errors" ) -// Name - the simple name of the aws resource +// EC2Instances - represents all ec2 instances +type EC2Instances struct { + InstanceIds []string +} + +// ResourceName - the simple name of the aws resource func (instance EC2Instances) ResourceName() string { return "ec2" } diff --git a/aws/elb.go b/aws/elb.go new file mode 100644 index 00000000..e0db7950 --- /dev/null +++ b/aws/elb.go @@ -0,0 +1,82 @@ +package aws + +import ( + "time" + + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/elb" + "github.com/gruntwork-io/aws-nuke/logging" + "github.com/gruntwork-io/gruntwork-cli/errors" +) + +func waitUntilElbDeleted(svc *elb.ELB, input *elb.DescribeLoadBalancersInput) error { + for i := 0; i < 30; i++ { + _, err := svc.DescribeLoadBalancers(input) + if err != nil { + if awsErr, isAwsErr := err.(awserr.Error); isAwsErr && awsErr.Code() == "LoadBalancerNotFound" { + return nil + } + + return err + } + + time.Sleep(1 * time.Second) + logging.Logger.Debug("Waiting for ELB to be deleted") + } + + return ElbDeleteError{} +} + +// Returns a formatted string of ELB names +func getAllElbInstances(session *session.Session, region string) ([]*string, error) { + svc := elb.New(session) + result, err := svc.DescribeLoadBalancers(&elb.DescribeLoadBalancersInput{}) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + var names []*string + for _, balancer := range result.LoadBalancerDescriptions { + names = append(names, balancer.LoadBalancerName) + } + + return names, nil +} + +// Deletes all Elastic Load Balancers +func nukeAllElbInstances(session *session.Session, names []*string) error { + svc := elb.New(session) + + if len(names) == 0 { + logging.Logger.Infof("No Elastic Load Balancers to nuke in region %s", *session.Config.Region) + return nil + } + + logging.Logger.Infof("Deleting all Elastic Load Balancers in region %s", *session.Config.Region) + + for _, name := range names { + params := &elb.DeleteLoadBalancerInput{ + LoadBalancerName: name, + } + + _, err := svc.DeleteLoadBalancer(params) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("Deleted ELB: %s", *name) + } + + err := waitUntilElbDeleted(svc, &elb.DescribeLoadBalancersInput{ + LoadBalancerNames: names, + }) + + if err != nil { + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("[OK] %d Elastic Load Balancer(s) deleted in %s", len(names), *session.Config.Region) + return nil +} diff --git a/aws/elb_test.go b/aws/elb_test.go new file mode 100644 index 00000000..40e3af63 --- /dev/null +++ b/aws/elb_test.go @@ -0,0 +1,98 @@ +package aws + +import ( + "testing" + + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/elb" + "github.com/gruntwork-io/aws-nuke/util" + "github.com/gruntwork-io/gruntwork-cli/errors" + "github.com/stretchr/testify/assert" +) + +func createTestELB(t *testing.T, session *session.Session, name string) { + svc := elb.New(session) + + param := &elb.CreateLoadBalancerInput{ + AvailabilityZones: []*string{ + awsgo.String(awsgo.StringValue(session.Config.Region) + "a"), + }, + LoadBalancerName: awsgo.String(name), + Listeners: []*elb.Listener{ + &elb.Listener{ + InstancePort: awsgo.Int64(80), + LoadBalancerPort: awsgo.Int64(80), + Protocol: awsgo.String("HTTP"), + }, + }, + } + + _, err := svc.CreateLoadBalancer(param) + if err != nil { + assert.Failf(t, "Could not create test ELB: %s", errors.WithStackTrace(err).Error()) + } +} + +func TestListELBs(t *testing.T) { + t.Parallel() + + region := getRandomRegion() + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + elbName := "aws-nuke-test-" + util.UniqueID() + createTestELB(t, session, elbName) + // clean up after this test + defer nukeAllElbInstances(session, []*string{&elbName}) + + elbNames, err := getAllElbInstances(session, region) + if err != nil { + assert.Fail(t, "Unable to fetch list of Auto Scaling Groups") + } + + assert.Contains(t, awsgo.StringValueSlice(elbNames), elbName) +} + +func TestNukeELBs(t *testing.T) { + t.Parallel() + + region := getRandomRegion() + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + svc := elb.New(session) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + elbName := "aws-nuke-test-" + util.UniqueID() + createTestELB(t, session, elbName) + + _, err = svc.DescribeLoadBalancers(&elb.DescribeLoadBalancersInput{ + LoadBalancerNames: []*string{ + awsgo.String(elbName), + }, + }) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + if err := nukeAllElbInstances(session, []*string{&elbName}); err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + elbNames, err := getAllElbInstances(session, region) + if err != nil { + assert.Fail(t, "Unable to fetch list of ELBs") + } + + assert.NotContains(t, awsgo.StringValueSlice(elbNames), elbName) +} diff --git a/aws/elb_types.go b/aws/elb_types.go new file mode 100644 index 00000000..b1175937 --- /dev/null +++ b/aws/elb_types.go @@ -0,0 +1,37 @@ +package aws + +import ( + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/gruntwork-io/gruntwork-cli/errors" +) + +// LoadBalancers - represents all load balancers +type LoadBalancers struct { + Names []string +} + +// ResourceName - the simple name of the aws resource +func (balancer LoadBalancers) ResourceName() string { + return "elb" +} + +// ResourceIdentifiers - The names of the load balancers +func (balancer LoadBalancers) ResourceIdentifiers() []string { + return balancer.Names +} + +// Nuke - nuke 'em all!!! +func (balancer LoadBalancers) Nuke(session *session.Session) error { + if err := nukeAllElbInstances(session, awsgo.StringSlice(balancer.Names)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} + +type ElbDeleteError struct{} + +func (e ElbDeleteError) Error() string { + return "ELB was not deleted" +} diff --git a/aws/elbv2.go b/aws/elbv2.go new file mode 100644 index 00000000..78fd9cb0 --- /dev/null +++ b/aws/elbv2.go @@ -0,0 +1,61 @@ +package aws + +import ( + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/elbv2" + "github.com/gruntwork-io/aws-nuke/logging" + "github.com/gruntwork-io/gruntwork-cli/errors" +) + +// Returns a formatted string of ELBv2 Arns +func getAllElbv2Instances(session *session.Session, region string) ([]*string, error) { + svc := elbv2.New(session) + result, err := svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{}) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + var arns []*string + for _, balancer := range result.LoadBalancers { + arns = append(arns, balancer.LoadBalancerArn) + } + + return arns, nil +} + +// Deletes all Elastic Load Balancers +func nukeAllElbv2Instances(session *session.Session, arns []*string) error { + svc := elbv2.New(session) + + if len(arns) == 0 { + logging.Logger.Infof("No V2 Elastic Load Balancers to nuke in region %s", *session.Config.Region) + return nil + } + + logging.Logger.Infof("Deleting all V2 Elastic Load Balancers in region %s", *session.Config.Region) + + for _, arn := range arns { + params := &elbv2.DeleteLoadBalancerInput{ + LoadBalancerArn: arn, + } + + _, err := svc.DeleteLoadBalancer(params) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("Deleted ELBv2: %s", *arn) + } + + err := svc.WaitUntilLoadBalancersDeleted(&elbv2.DescribeLoadBalancersInput{ + LoadBalancerArns: arns, + }) + + if err != nil { + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("[OK] %d V2 Elastic Load Balancer(s) deleted in %s", len(arns), *session.Config.Region) + return nil +} diff --git a/aws/elbv2_test.go b/aws/elbv2_test.go new file mode 100644 index 00000000..a1055f3a --- /dev/null +++ b/aws/elbv2_test.go @@ -0,0 +1,130 @@ +package aws + +import ( + "testing" + + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/elbv2" + "github.com/gruntwork-io/aws-nuke/util" + "github.com/gruntwork-io/gruntwork-cli/errors" + "github.com/stretchr/testify/assert" +) + +func createTestELBv2(t *testing.T, session *session.Session, name string) elbv2.LoadBalancer { + svc := elbv2.New(session) + + subnetOutput, err := ec2.New(session).DescribeSubnets(&ec2.DescribeSubnetsInput{}) + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + if len(subnetOutput.Subnets) < 2 { + assert.Fail(t, "Needs at least 2 subnets to create ELBv2") + } + + subnet1 := *subnetOutput.Subnets[0] + subnet2 := *subnetOutput.Subnets[1] + + param := &elbv2.CreateLoadBalancerInput{ + Name: awsgo.String(name), + Subnets: []*string{ + subnet1.SubnetId, + subnet2.SubnetId, + }, + } + + result, err := svc.CreateLoadBalancer(param) + + if err != nil { + assert.Failf(t, "Could not create test ELBv2: %s", errors.WithStackTrace(err).Error()) + } + + if len(result.LoadBalancers) == 0 { + assert.Failf(t, "Could not create test ELBv2: %s", errors.WithStackTrace(err).Error()) + } + + balancer := *result.LoadBalancers[0] + + err = svc.WaitUntilLoadBalancerAvailable(&elbv2.DescribeLoadBalancersInput{ + LoadBalancerArns: []*string{balancer.LoadBalancerArn}, + }) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + return balancer +} + +func TestListELBv2(t *testing.T) { + t.Parallel() + + region := getRandomRegion() + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + elbName := "aws-nuke-test-" + util.UniqueID() + balancer := createTestELBv2(t, session, elbName) + // clean up after this test + defer nukeAllElbv2Instances(session, []*string{balancer.LoadBalancerArn}) + + arns, err := getAllElbv2Instances(session, region) + if err != nil { + assert.Fail(t, "Unable to fetch list of v2 ELBs") + } + + assert.Contains(t, awsgo.StringValueSlice(arns), awsgo.StringValue(balancer.LoadBalancerArn)) +} + +func TestNukeELBv2(t *testing.T) { + t.Parallel() + + region := getRandomRegion() + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + svc := elbv2.New(session) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + elbName := "aws-nuke-test-" + util.UniqueID() + balancer := createTestELBv2(t, session, elbName) + + _, err = svc.DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{ + LoadBalancerArns: []*string{ + balancer.LoadBalancerArn, + }, + }) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + if err := nukeAllElbv2Instances(session, []*string{balancer.LoadBalancerArn}); err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + err = svc.WaitUntilLoadBalancersDeleted(&elbv2.DescribeLoadBalancersInput{ + LoadBalancerArns: []*string{balancer.LoadBalancerArn}, + }) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + arns, err := getAllElbv2Instances(session, region) + if err != nil { + assert.Fail(t, "Unable to fetch list of v2 ELBs") + } + + assert.NotContains(t, awsgo.StringValueSlice(arns), awsgo.StringValue(balancer.LoadBalancerArn)) +} diff --git a/aws/elbv2_types.go b/aws/elbv2_types.go new file mode 100644 index 00000000..34c679d8 --- /dev/null +++ b/aws/elbv2_types.go @@ -0,0 +1,31 @@ +package aws + +import ( + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/gruntwork-io/gruntwork-cli/errors" +) + +// LoadBalancersV2 - represents all load balancers +type LoadBalancersV2 struct { + Arns []string +} + +// ResourceName - the simple name of the aws resource +func (balancer LoadBalancersV2) ResourceName() string { + return "elbv2" +} + +// ResourceIdentifiers - The arns of the load balancers +func (balancer LoadBalancersV2) ResourceIdentifiers() []string { + return balancer.Arns +} + +// Nuke - nuke 'em all!!! +func (balancer LoadBalancersV2) Nuke(session *session.Session) error { + if err := nukeAllElbv2Instances(session, awsgo.StringSlice(balancer.Arns)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/aws/types.go b/aws/types.go index a8986dd7..85215fea 100644 --- a/aws/types.go +++ b/aws/types.go @@ -17,7 +17,3 @@ type AwsResources interface { type AwsRegionResource struct { Resources []AwsResources } - -type EC2Instances struct { - InstanceIds []string -} diff --git a/commands/cli.go b/commands/cli.go index 617109b4..1f26b03b 100644 --- a/commands/cli.go +++ b/commands/cli.go @@ -3,6 +3,8 @@ package commands import ( "strings" + "github.com/gruntwork-io/gruntwork-cli/collections" + "github.com/fatih/color" "github.com/gruntwork-io/aws-nuke/aws" "github.com/gruntwork-io/aws-nuke/logging" @@ -19,7 +21,13 @@ func CreateCli(version string) *cli.App { app.HelpName = app.Name app.Author = "Gruntwork " app.Version = version - app.Usage = "A CLI tool to cleanup AWS resources (EC2). THIS TOOL WILL COMPLETELY REMOVE ALL RESOURCES AND ITS EFFECTS ARE IRREVERSIBLE!!!" + app.Usage = "A CLI tool to cleanup AWS resources (ASG, ELB, ELBv2, EBS, EC2). THIS TOOL WILL COMPLETELY REMOVE ALL RESOURCES AND ITS EFFECTS ARE IRREVERSIBLE!!!" + app.Flags = []cli.Flag{ + cli.StringSliceFlag{ + Name: "exclude-region", + Usage: "regions to exclude", + }, + } app.Action = errors.WithPanicHandling(awsNuke) return app @@ -27,9 +35,20 @@ func CreateCli(version string) *cli.App { // Nuke it all!!! func awsNuke(c *cli.Context) error { - logging.Logger.Infoln("Retrieving all active AWS resources") + regions := aws.GetAllRegions() + excludedRegions := c.StringSlice("exclude-region") - account, err := aws.GetAllResources() + for _, excludedRegion := range excludedRegions { + if !collections.ListContainsElement(regions, excludedRegion) { + return InvalidFlagError{ + Name: "exclude-regions", + Value: excludedRegion, + } + } + } + + logging.Logger.Infoln("Retrieving all active AWS resources") + account, err := aws.GetAllResources(regions, excludedRegions) if err != nil { return errors.WithStackTrace(err) @@ -40,10 +59,12 @@ func awsNuke(c *cli.Context) error { return nil } + logging.Logger.Infoln("The following AWS resources are going to be nuked: ") + for region, resourcesInRegion := range account.Resources { for _, resources := range resourcesInRegion.Resources { for _, identifier := range resources.ResourceIdentifiers() { - logging.Logger.Infof("%s-%s-%s", resources.ResourceName(), identifier, region) + logging.Logger.Infof("* %s-%s-%s\n", resources.ResourceName(), identifier, region) } } } @@ -60,7 +81,7 @@ func awsNuke(c *cli.Context) error { } if strings.ToLower(input) == "nuke" { - aws.NukeAllResources(account) + aws.NukeAllResources(account, regions) } return nil diff --git a/commands/errors.go b/commands/errors.go new file mode 100644 index 00000000..2eadde52 --- /dev/null +++ b/commands/errors.go @@ -0,0 +1,14 @@ +package commands + +import ( + "fmt" +) + +type InvalidFlagError struct { + Name string + Value string +} + +func (e InvalidFlagError) Error() string { + return fmt.Sprintf("Invalid value %s for flag %s", e.Value, e.Name) +} diff --git a/util/unique_id.go b/util/unique_id.go new file mode 100644 index 00000000..bb79863c --- /dev/null +++ b/util/unique_id.go @@ -0,0 +1,25 @@ +package util + +import ( + "bytes" + "math/rand" + "time" +) + +// Returns a unique (ish) id we can attach to resources and tfstate files so they don't conflict with each other +// Uses base 62 to generate a 6 character string that's unlikely to collide with the handful of tests we run in +// parallel. Based on code here: http://stackoverflow.com/a/9543797/483528 +func UniqueID() string { + + const BASE_62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + const UNIQUE_ID_LENGTH = 6 // Should be good for 62^6 = 56+ billion combinations + + var out bytes.Buffer + + rand := rand.New(rand.NewSource(time.Now().UnixNano())) + for i := 0; i < UNIQUE_ID_LENGTH; i++ { + out.WriteByte(BASE_62_CHARS[rand.Intn(len(BASE_62_CHARS))]) + } + + return out.String() +}