Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support nuking EC2 Dedicated Hosts - CORE-286 #392

Merged
merged 3 commits into from
Jan 5, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,9 @@ The following resources support the Config file:
- EC2 Instances
- Resource type: `ec2`
- Config key: `EC2`
- EC2 Dedicated Hosts
- Resource type: `ec2-dedicated-hosts`
- Config key: `EC2DedicatedHosts`
- EC2 Key Pairs
- Resource type: `ec2-keypairs`
- Config key: `EC2KeyPairs`
Expand Down
21 changes: 21 additions & 0 deletions aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,26 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp
}
// End EC2 Instances

// EC2 Dedicated Hosts
ec2DedicatedHosts := EC2DedicatedHosts{}
if IsNukeable(ec2DedicatedHosts.ResourceName(), resourceTypes) {
hostIds, err := getAllEc2DedicatedHosts(cloudNukeSession, region, excludeAfter, configObj)
if err != nil {
ge := report.GeneralError{
Error: err,
Description: "Unable to retrieve EC2 dedicated hosts",
ResourceType: ec2DedicatedHosts.ResourceName(),
}
report.RecordError(ge)
}
if len(hostIds) > 0 {
ec2DedicatedHosts.HostIds = awsgo.StringValueSlice(hostIds)
resourcesInRegion.Resources = append(resourcesInRegion.Resources, ec2DedicatedHosts)
}
}

// End EC2 Dedicated Hosts

// EBS Volumes
ebsVolumes := EBSVolumes{}
if IsNukeable(ebsVolumes.ResourceName(), resourceTypes) {
Expand Down Expand Up @@ -1182,6 +1202,7 @@ func ListResourceTypes() []string {
TransitGatewaysRouteTables{}.ResourceName(),
TransitGateways{}.ResourceName(),
EC2Instances{}.ResourceName(),
EC2DedicatedHosts{}.ResourceName(),
EBSVolumes{}.ResourceName(),
EIPAddresses{}.ResourceName(),
AMIs{}.ResourceName(),
Expand Down
119 changes: 119 additions & 0 deletions aws/ec2_dedicated_host.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package aws

import (
"fmt"
"time"

"github.com/aws/aws-sdk-go/aws"
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/cloud-nuke/config"
"github.com/gruntwork-io/cloud-nuke/logging"
"github.com/gruntwork-io/cloud-nuke/report"
"github.com/gruntwork-io/go-commons/errors"
)

func getAllEc2DedicatedHosts(session *session.Session, region string, excludeAfter time.Time, configObj config.Config) ([]*string, error) {
arsci marked this conversation as resolved.
Show resolved Hide resolved
svc := ec2.New(session)
var hostIds []*string

describeHostsInput := &ec2.DescribeHostsInput{
Filter: []*ec2.Filter{
{
Name: awsgo.String("state"),
Values: []*string{
awsgo.String("available"),
awsgo.String("under-assessment"),
awsgo.String("permanent-failure"),
},
},
},
}

err := svc.DescribeHostsPages(
describeHostsInput,
func(page *ec2.DescribeHostsOutput, lastPage bool) bool {
for _, host := range page.Hosts {
if shouldIncludeHostId(host, excludeAfter, configObj) {
hostIds = append(hostIds, host.HostId)
}
}
return !lastPage
},
)

if err != nil {
return nil, errors.WithStackTrace(err)
}

return hostIds, nil
}

func shouldIncludeHostId(host *ec2.Host, excludeAfter time.Time, configObj config.Config) bool {
if host == nil {
return false
}

if excludeAfter.Before(*host.AllocationTime) {
return false
}

// If an instance is using the host allocation we cannot release it
if len(host.Instances) != 0 {
logging.Logger.Debugf("Host %s has instance(s) still associated, unable to nuke.", *host.HostId)
return false
}

// If Name is unset, GetEC2ResourceNameTagValue returns error and zero value string
// Ignore this error and pass empty string to config.ShouldInclude
hostNameTagValue, _ := GetEC2ResourceNameTagValue(host.Tags)

return config.ShouldInclude(
hostNameTagValue,
configObj.EC2DedicatedHosts.IncludeRule.NamesRegExp,
configObj.EC2DedicatedHosts.ExcludeRule.NamesRegExp,
)
}

func nukeAllEc2DedicatedHosts(session *session.Session, hostIds []*string) error {
svc := ec2.New(session)

if len(hostIds) == 0 {
logging.Logger.Debugf("No EC2 dedicated hosts to nuke in region %s", *session.Config.Region)
return nil
}

logging.Logger.Debugf("Releasing all EC2 dedicated host allocations in region %s", *session.Config.Region)

input := &ec2.ReleaseHostsInput{HostIds: hostIds}

releaseResult, err := svc.ReleaseHosts(input)

if err != nil {
logging.Logger.Debugf("[Failed] %s", err)
return errors.WithStackTrace(err)
}

// Report successes and failures from release host request
for _, hostSuccess := range releaseResult.Successful {
logging.Logger.Debugf("[OK] Dedicated host %s was released in %s", aws.StringValue(hostSuccess), *session.Config.Region)
e := report.Entry{
Identifier: aws.StringValue(hostSuccess),
ResourceType: "EC2 Dedicated Host",
}
report.Record(e)
}

for _, hostFailed := range releaseResult.Unsuccessful {
logging.Logger.Debugf("[ERROR] Unable to release dedicated host %s in %s: %s", aws.StringValue(hostFailed.ResourceId), *session.Config.Region, aws.StringValue(hostFailed.Error.Message))
e := report.Entry{
Identifier: aws.StringValue(hostFailed.ResourceId),
ResourceType: "EC2 Dedicated Host",
Error: fmt.Errorf(*hostFailed.Error.Message),
}
report.Record(e)
}

return nil
}
153 changes: 153 additions & 0 deletions aws/ec2_dedicated_host_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package aws

import (
"errors"
"testing"
"time"

"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/cloud-nuke/config"
"github.com/gruntwork-io/cloud-nuke/logging"
"github.com/gruntwork-io/cloud-nuke/util"
gruntworkerrors "github.com/gruntwork-io/go-commons/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const (
TagNamePrefix = "cloud-nuke-test-"
InstanceFamily = "c5"
InstanceType = "c5.large"
)

func TestListDedicatedHosts(t *testing.T) {
t.Parallel()

region, err := getRandomRegion()
require.NoError(t, err)

session, err := session.NewSession(&aws.Config{Region: aws.String(region)})
require.NoError(t, err)

svc := ec2.New(session)

createdHostIds, err := allocateDedicatedHosts(svc, 1)
require.NoError(t, err)

defer nukeAllEc2DedicatedHosts(session, createdHostIds)

// test if created allocation matches get response
hostIds, err := getAllEc2DedicatedHosts(session, region, time.Now(), config.Config{})

require.NoError(t, err)
assert.Equal(t, aws.StringValueSlice(hostIds), aws.StringValueSlice(createdHostIds))

//test time shift
olderThan := time.Now().Add(-1 * time.Hour)
hostIds, err = getAllEc2DedicatedHosts(session, region, olderThan, config.Config{})
require.NoError(t, err)
assert.NotEqual(t, aws.StringValueSlice(hostIds), aws.StringValueSlice(createdHostIds))
}

func TestNukeDedicatedHosts(t *testing.T) {
t.Parallel()

region, err := getRandomRegion()
require.NoError(t, err)

logging.Logger.Infof("region: %s", region)

session, err := session.NewSession(&aws.Config{Region: aws.String(region)})
require.NoError(t, err)

svc := ec2.New(session)

createdHostIds, err := allocateDedicatedHosts(svc, 1)
require.NoError(t, err)

err = nukeAllEc2DedicatedHosts(session, createdHostIds)
require.NoError(t, err)

hostIds, err := getAllEc2DedicatedHosts(session, region, time.Now(), config.Config{})
require.NoError(t, err)
assert.NotContains(t, aws.StringValueSlice(hostIds), createdHostIds)
}

func allocateDedicatedHosts(svc *ec2.EC2, hostQuantity int64) (hostIds []*string, err error) {

usableAzs, err := getAzsForInstanceType(svc)

if err != nil {
logging.Logger.Debugf("[Failed] %s", err)
return nil, gruntworkerrors.WithStackTrace(err)
}

if usableAzs == nil {
logging.Logger.Debugf("[Failed] No AZs with InstanceType found.")
}

hostTagName := TagNamePrefix + util.UniqueID()

input := &ec2.AllocateHostsInput{
AvailabilityZone: aws.String(usableAzs[0]),
InstanceFamily: aws.String(InstanceFamily),
Quantity: aws.Int64(hostQuantity),
TagSpecifications: []*ec2.TagSpecification{
{
ResourceType: aws.String("dedicated-host"),
Tags: []*ec2.Tag{
{
Key: aws.String("Name"),
Value: aws.String(hostTagName),
},
},
},
},
}

hostIdsOutput, err := svc.AllocateHosts(input)

if err != nil {
logging.Logger.Debugf("[Failed] %s", err)
return nil, gruntworkerrors.WithStackTrace(err)
}

for i := range hostIdsOutput.HostIds {
hostIds = append(hostIds, hostIdsOutput.HostIds[i])
}

return hostIds, nil
}

func getAzsForInstanceType(svc *ec2.EC2) ([]string, error) {
var instanceOfferings []string
input := &ec2.DescribeInstanceTypeOfferingsInput{
MaxResults: aws.Int64(5),
LocationType: aws.String("availability-zone"),
Filters: []*ec2.Filter{
{
Name: aws.String("instance-type"),
Values: []*string{aws.String(InstanceType)},
},
},
}

az, err := svc.DescribeInstanceTypeOfferings(input)

if err != nil {
return nil, err
}

if len(az.InstanceTypeOfferings) == 0 {
err := errors.New("No matching instance types found in region/az.")
return nil, gruntworkerrors.WithStackTrace(err)
}

for i := range az.InstanceTypeOfferings {
instanceOfferings = append(instanceOfferings, *az.InstanceTypeOfferings[i].Location)
}

return instanceOfferings, err
}
36 changes: 36 additions & 0 deletions aws/ec2_dedicated_host_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package aws

import (
awsgo "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/gruntwork-io/go-commons/errors"
)

// EC2DedicatedHosts - represents all host allocation IDs
type EC2DedicatedHosts struct {
HostIds []string
}

// ResourceName - the simple name of the aws resource
func (h EC2DedicatedHosts) ResourceName() string {
return "ec2-dedicated-hosts"
}

// ResourceIdentifiers - The instance ids of the ec2 instances
func (h EC2DedicatedHosts) ResourceIdentifiers() []string {
return h.HostIds
}

func (h EC2DedicatedHosts) MaxBatchSize() int {
// Tentative batch size to ensure AWS doesn't throttle
return 49
}

// Nuke - nuke 'em all!!!
func (h EC2DedicatedHosts) Nuke(session *session.Session, identifiers []string) error {
if err := nukeAllEc2DedicatedHosts(session, awsgo.StringSlice(identifiers)); err != nil {
return errors.WithStackTrace(err)
}

return nil
}
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Config struct {
ElasticIP ResourceType `yaml:"ElasticIP"`
EC2 ResourceType `yaml:"EC2"`
EC2KeyPairs ResourceType `yaml:"EC2KeyPairs"`
EC2DedicatedHosts ResourceType `yaml:"EC2DedicatedHosts"`
CloudWatchLogGroup ResourceType `yaml:"CloudWatchLogGroup"`
KMSCustomerKeys ResourceType `yaml:"KMSCustomerKeys"`
EKSCluster ResourceType `yaml:"EKSCluster"`
Expand Down
1 change: 1 addition & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func emptyConfig() *Config {
ResourceType{FilterRule{}, FilterRule{}},
ResourceType{FilterRule{}, FilterRule{}},
ResourceType{FilterRule{}, FilterRule{}},
ResourceType{FilterRule{}, FilterRule{}},
}
}

Expand Down