This guide provides step-by-step instructions for adding a new AWS resource to aws-nuke. It covers resource structure, testing strategies, and best practices.
Resources in aws-nuke represent AWS resources that can be listed and deleted. Each resource must implement specific interfaces and follow established patterns for consistency and maintainability.
Before creating a new resource, ensure you have:
- Go 1.21 or later installed
- AWS SDK v2 knowledge (we use AWS SDK v2, not v1)
- Familiarity with the AWS service you're implementing
- golangci-lint installed for linting
- Read the CONTRIBUTING.md guide
Every resource consists of:
- Resource File -
resources/<service-name>-<resource-name>.go - Mock Tests -
resources/<service-name>-<resource-name>_mock_test.go(using gomock) - Integration Tests -
resources/<service-name>-<resource-name>_test.go(optional but preferred)
package resources
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/<servicename>"
"github.com/ekristen/libnuke/pkg/registry"
"github.com/ekristen/libnuke/pkg/resource"
"github.com/ekristen/libnuke/pkg/types"
"github.com/ekristen/aws-nuke/v3/pkg/nuke"
)
const MyResourceResource = "MyResource"
func init() {
registry.Register(®istry.Registration{
Name: MyResourceResource,
Scope: nuke.Account,
Resource: &MyResource{},
Lister: &MyResourceLister{},
})
}
type MyResourceLister struct{}
func (l *MyResourceLister) List(ctx context.Context, o interface{}) ([]resource.Resource, error) {
opts := o.(*nuke.ListerOpts)
svc := servicename.NewFromConfig(*opts.Config)
var resources []resource.Resource
// List resources using pagination
params := &servicename.ListMyResourcesInput{
MaxResults: aws.Int32(100),
}
paginator := servicename.NewListMyResourcesPaginator(svc, params)
for paginator.HasMorePages() {
resp, err := paginator.NextPage(ctx)
if err != nil {
return nil, err
}
for _, item := range resp.Items {
resources = append(resources, &MyResource{
svc: svc,
Name: item.Name,
})
}
}
return resources, nil
}
type MyResource struct {
svc *servicename.Client
Name *string
}
func (r *MyResource) Remove(ctx context.Context) error {
_, err := r.svc.DeleteMyResource(ctx, &servicename.DeleteMyResourceInput{
Name: r.Name,
})
return err
}
func (r *MyResource) Properties() types.Properties {
return types.NewPropertiesFromStruct(r)
}
func (r *MyResource) String() string {
return *r.Name
}Create a new file in the resources/ directory following the naming convention:
- Format:
<service>-<resource>.go - Example:
eks-clusters.go,inspector2.go
const EKSClusterResource = "EKSCluster"The constant should match the resource name used in configuration files.
The lister is responsible for discovering all resources of this type in an AWS account.
type EKSClusterLister struct{}
func (l *EKSClusterLister) List(ctx context.Context, o interface{}) ([]resource.Resource, error) {
opts := o.(*nuke.ListerOpts)
svc := eks.NewFromConfig(*opts.Config)
var resources []resource.Resource
params := &eks.ListClustersInput{
MaxResults: aws.Int32(100),
}
paginator := eks.NewListClustersPaginator(svc, params)
for paginator.HasMorePages() {
resp, err := paginator.NextPage(ctx)
if err != nil {
return nil, err
}
for _, cluster := range resp.Clusters {
resources = append(resources, &EKSCluster{
svc: svc,
Name: aws.String(cluster),
})
}
}
return resources, nil
}Key Points:
- Always use context for AWS SDK calls
- Use paginators when available to handle large result sets
- Convert AWS SDK types to your resource struct
- Handle errors appropriately
The resource struct holds the data for a single resource instance.
type EKSCluster struct {
svc *eks.Client
Name *string
CreatedAt *time.Time
Tags map[string]string
settings *libsettings.Setting
protection *bool
}Required Fields:
- Service client (to make deletion calls)
- Identifier fields (name, ID, ARN, etc.)
Optional Fields:
- Timestamps (CreatedAt, LastUpdatedTime)
- Tags
- Settings (if the resource supports settings)
- Status information
Every resource must implement these methods:
func (r *EKSCluster) Remove(ctx context.Context) error {
_, err := r.svc.DeleteCluster(ctx, &eks.DeleteClusterInput{
Name: r.Name,
})
return err
}func (r *EKSCluster) Properties() types.Properties {
return types.NewPropertiesFromStruct(r)
}The Properties method is used for filtering. NewPropertiesFromStruct automatically extracts all exported fields.
Special Property Tags:
property:"tagPrefix=resourceType"- Use a custom prefix for map fieldsproperty:"-"- Exclude a field from properties
func (r *EKSCluster) String() string {
return *r.Name
}Returns a human-readable identifier for the resource.
In the init() function, register your resource:
func init() {
registry.Register(®istry.Registration{
Name: EKSClusterResource,
Scope: nuke.Account,
Resource: &EKSCluster{},
Lister: &EKSClusterLister{},
})
}With Settings:
func init() {
registry.Register(®istry.Registration{
Name: EKSClusterResource,
Scope: nuke.Account,
Resource: &EKSCluster{},
Lister: &EKSClusterLister{},
Settings: []string{
"DisableDeletionProtection",
},
})
}If your resource supports settings:
func (r *EKSCluster) Settings(setting *libsettings.Setting) {
r.settings = setting
}Use settings in your Remove method:
func (r *EKSCluster) Remove(ctx context.Context) error {
if ptr.ToBool(r.protection) && r.settings.GetBool("DisableDeletionProtection") {
// Disable protection first
_, err := r.svc.UpdateClusterConfig(ctx, &eks.UpdateClusterConfigInput{
Name: r.Name,
DeletionProtection: aws.Bool(false),
})
if err != nil {
return err
}
}
_, err := r.svc.DeleteCluster(ctx, &eks.DeleteClusterInput{
Name: r.Name,
})
return err
}If some resources should be excluded from deletion:
func (r *MyResource) Filter() error {
// Example: Skip AWS-managed resources
if strings.HasPrefix(*r.Path, "/aws-service-role/") {
return fmt.Errorf("cannot delete service-linked roles")
}
return nil
}Testing is crucial for ensuring your resource works correctly. We use two types of tests:
Mock tests use gomock to simulate AWS API calls without making real requests.
File naming: <resource-name>_mock_test.go
package resources
import (
"context"
"testing"
"github.com/golang/mock/gomock"
"github.com/gotidy/ptr"
"github.com/stretchr/testify/assert"
"github.com/aws/aws-sdk-go-v2/service/myservice"
"github.com/ekristen/aws-nuke/v3/mocks/mock_myserviceiface"
)
func Test_Mock_MyResource_Remove(t *testing.T) {
a := assert.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockSvc := mock_myserviceiface.NewMockMyServiceAPI(ctrl)
resource := MyResource{
svc: mockSvc,
Name: ptr.String("test-resource"),
}
mockSvc.EXPECT().DeleteMyResource(gomock.Eq(&myservice.DeleteMyResourceInput{
Name: resource.Name,
})).Return(&myservice.DeleteMyResourceOutput{}, nil)
err := resource.Remove(context.TODO())
a.Nil(err)
}func Test_Mock_MyResource_Properties(t *testing.T) {
a := assert.New(t)
resource := MyResource{
Name: ptr.String("test-name"),
Tags: map[string]string{
"Environment": "test",
},
}
props := resource.Properties()
a.Equal("test-name", props.Get("Name"))
a.Equal("test", props.Get("tag:Environment"))
}func Test_Mock_MyResource_List(t *testing.T) {
a := assert.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockSvc := mock_myserviceiface.NewMockMyServiceAPI(ctrl)
mockSvc.EXPECT().ListMyResources(gomock.Any()).Return(&myservice.ListMyResourcesOutput{
Items: []myservice.Item{
{
Name: ptr.String("resource-1"),
},
{
Name: ptr.String("resource-2"),
},
},
}, nil)
lister := MyResourceLister{
mockSvc: mockSvc,
}
resources, err := lister.List(context.TODO(), testListerOpts)
a.Nil(err)
a.Len(resources, 2)
resource := resources[0].(*MyResource)
a.Equal("resource-1", *resource.Name)
}Integration tests make real AWS API calls and require actual AWS credentials.
File naming: <resource-name>_test.go
Build tag: //go:build integration
//go:build integration
package resources
import (
"context"
"fmt"
"testing"
"time"
"github.com/gotidy/ptr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/myservice"
)
type TestMyResourceSuite struct {
suite.Suite
svc *myservice.Client
resourceName *string
}
func (suite *TestMyResourceSuite) SetupSuite() {
var err error
suite.resourceName = ptr.String(fmt.Sprintf("aws-nuke-test-%d", time.Now().UnixNano()))
ctx := context.TODO()
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-west-2"))
if err != nil {
suite.T().Fatalf("failed to load config, %v", err)
}
suite.svc = myservice.NewFromConfig(cfg)
// Create test resource
_, err = suite.svc.CreateMyResource(ctx, &myservice.CreateMyResourceInput{
Name: suite.resourceName,
})
if err != nil {
suite.T().Fatalf("failed to create test resource, %v", err)
}
}
func (suite *TestMyResourceSuite) TearDownSuite() {
ctx := context.TODO()
// Clean up test resource
_, _ = suite.svc.DeleteMyResource(ctx, &myservice.DeleteMyResourceInput{
Name: suite.resourceName,
})
}
func (suite *TestMyResourceSuite) TestList() {
a := assert.New(suite.T())
lister := MyResourceLister{}
resources, err := lister.List(context.TODO(), &nuke.ListerOpts{
Config: &suite.svc.Options().Config,
})
a.Nil(err)
a.Greater(len(resources), 0)
}
func (suite *TestMyResourceSuite) TestRemove() {
a := assert.New(suite.T())
resource := MyResource{
svc: suite.svc,
Name: suite.resourceName,
}
err := resource.Remove(context.TODO())
a.Nil(err)
}
func TestMyResourceIntegration(t *testing.T) {
suite.Run(t, new(TestMyResourceSuite))
}Running Integration Tests:
go test -tags=integration ./resources/...Your tests should cover:
- Successful removal - Happy path deletion
- Properties - Verify all properties are correctly exposed
- Filtering - If implemented, test filter logic
- Error handling - Test error scenarios
- Edge cases - Empty lists, nil values, etc.
- Settings - If supported, test setting behavior
Always use AWS SDK v2 (github.com/aws/aws-sdk-go-v2), not v1.
// ✅ Correct
import "github.com/aws/aws-sdk-go-v2/service/eks"
// ❌ Wrong
import "github.com/aws/aws-sdk-go/service/eks"Always use paginators to handle large result sets:
paginator := eks.NewListClustersPaginator(svc, params)
for paginator.HasMorePages() {
resp, err := paginator.NextPage(ctx)
if err != nil {
return nil, err
}
// Process results
}Filter resources that cannot or should not be deleted:
func (r *MyResource) Filter() error {
// Skip AWS-managed resources
if r.IsAWSManaged {
return fmt.Errorf("cannot delete AWS-managed resource")
}
// Skip resources already being deleted
if r.Status == "DELETING" || r.Status == "DELETED" {
return fmt.Errorf("already deleting")
}
return nil
}Prefer the Properties() method over String() for filtering:
func (r *MyResource) Properties() types.Properties {
return types.NewPropertiesFromStruct(r)
}This automatically exposes all fields for filtering in config files.
For resources with tags:
type MyResource struct {
svc *myservice.Client
Name *string
Tags map[string]string
}Tags will automatically be available as tag:KeyName in filters.
Use settings for optional behavior:
Settings: []string{
"DisableDeletionProtection",
"ForceDelete",
}Users can enable these in their config:
MyResource:
- DisableDeletionProtection: trueSome resources must be deleted in a specific order. Use the DependsOn method:
func (r *MyResource) DependsOn() []string {
return []string{
"DependentResource1",
"DependentResource2",
}
}Return meaningful errors:
func (r *MyResource) Remove(ctx context.Context) error {
_, err := r.svc.DeleteMyResource(ctx, &myservice.DeleteMyResourceInput{
Name: r.Name,
})
if err != nil {
// Let the error propagate - aws-nuke will handle retries
return err
}
return nil
}Follow the import order specified in CONTRIBUTING.md:
import (
"context" // 1. Standard library
"github.com/gotidy/ptr" // 2. Third party
"github.com/aws/aws-sdk-go-v2/aws" // 3. AWS SDK
"github.com/aws/aws-sdk-go-v2/service/eks"
"github.com/ekristen/libnuke/pkg/registry" // 4. libnuke
"github.com/ekristen/libnuke/pkg/resource"
"github.com/ekristen/aws-nuke/v3/pkg/nuke" // 5. Local packages
)Used for resources with straightforward list and delete operations.
Example: Inspector2
func (l *Inspector2Lister) List(ctx context.Context, o interface{}) ([]resource.Resource, error) {
opts := o.(*nuke.ListerOpts)
svc := inspector2.NewFromConfig(*opts.Config)
resp, err := svc.BatchGetAccountStatus(ctx, &inspector2.BatchGetAccountStatusInput{})
if err != nil {
return nil, err
}
var resources []resource.Resource
for _, a := range resp.Accounts {
if a.State.Status != inspectortypes.StatusDisabled {
resources = append(resources, &Inspector2{
svc: svc,
AccountID: a.AccountId,
})
}
}
return resources, nil
}Used when the list operation doesn't return all needed information.
Example: EKS Clusters
for _, cluster := range resp.Clusters {
dcResp, err := svc.DescribeCluster(ctx, &eks.DescribeClusterInput{
Name: aws.String(cluster),
})
if err != nil {
return nil, err
}
resources = append(resources, &EKSCluster{
svc: svc,
Name: aws.String(cluster),
CreatedAt: dcResp.Cluster.CreatedAt,
Tags: dcResp.Cluster.Tags,
protection: dcResp.Cluster.DeletionProtection,
})
}Used for resources that have deletion protection that needs to be disabled first.
func (r *EKSCluster) Remove(ctx context.Context) error {
if ptr.ToBool(r.protection) && r.settings.GetBool("DisableDeletionProtection") {
_, err := r.svc.UpdateClusterConfig(ctx, &eks.UpdateClusterConfigInput{
Name: r.Name,
DeletionProtection: aws.Bool(false),
})
if err != nil {
return err
}
}
_, err := r.svc.DeleteCluster(ctx, &eks.DeleteClusterInput{
Name: r.Name,
})
return err
}Used when you need special property formatting or grouping.
type Inspector2 struct {
svc *inspector2.Client
AccountID *string
Status *inspectortypes.Status
ResourceState map[string]string `property:"tagPrefix=resourceType"`
}Used for resources with asynchronous deletion.
func (r *MyResource) Remove(ctx context.Context) error {
resp, err := r.svc.DeleteServiceLinkedRole(ctx, &iam.DeleteServiceLinkedRoleInput{
RoleName: r.Name,
})
if err != nil {
return err
}
r.deletionTaskID = resp.DeletionTaskId
return nil
}
func (r *MyResource) HandleWait(ctx context.Context) error {
if r.deletionTaskID == nil {
return nil
}
resp, err := r.svc.GetServiceLinkedRoleDeletionStatus(ctx, &iam.GetServiceLinkedRoleDeletionStatusInput{
DeletionTaskId: r.deletionTaskID,
})
if err != nil {
return err
}
switch *resp.Status {
case "SUCCEEDED":
return nil
case "IN_PROGRESS":
return liberrors.ErrWaitResource("deletion in progress")
case "FAILED":
return fmt.Errorf("deletion failed: %s", *resp.Reason.Reason)
}
return nil
}Here's a complete example incorporating all best practices:
package resources
import (
"context"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/example"
"github.com/gotidy/ptr"
"github.com/ekristen/libnuke/pkg/registry"
"github.com/ekristen/libnuke/pkg/resource"
libsettings "github.com/ekristen/libnuke/pkg/settings"
"github.com/ekristen/libnuke/pkg/types"
"github.com/ekristen/aws-nuke/v3/pkg/nuke"
)
const ExampleResourceResource = "ExampleResource"
func init() {
registry.Register(®istry.Registration{
Name: ExampleResourceResource,
Scope: nuke.Account,
Resource: &ExampleResource{},
Lister: &ExampleResourceLister{},
Settings: []string{
"DisableDeletionProtection",
},
})
}
type ExampleResourceLister struct{}
func (l *ExampleResourceLister) List(ctx context.Context, o interface{}) ([]resource.Resource, error) {
opts := o.(*nuke.ListerOpts)
svc := example.NewFromConfig(*opts.Config)
var resources []resource.Resource
params := &example.ListResourcesInput{
MaxResults: aws.Int32(100),
}
paginator := example.NewListResourcesPaginator(svc, params)
for paginator.HasMorePages() {
resp, err := paginator.NextPage(ctx)
if err != nil {
return nil, err
}
for _, item := range resp.Resources {
resources = append(resources, &ExampleResource{
svc: svc,
Name: item.Name,
CreatedAt: item.CreatedAt,
Tags: item.Tags,
protection: item.DeletionProtection,
})
}
}
return resources, nil
}
type ExampleResource struct {
svc *example.Client
Name *string
CreatedAt *time.Time
Tags map[string]string
settings *libsettings.Setting
protection *bool
}
func (r *ExampleResource) Remove(ctx context.Context) error {
if ptr.ToBool(r.protection) && r.settings.GetBool("DisableDeletionProtection") {
_, err := r.svc.UpdateResourceProtection(ctx, &example.UpdateResourceProtectionInput{
Name: r.Name,
DeletionProtection: aws.Bool(false),
})
if err != nil {
return err
}
}
_, err := r.svc.DeleteResource(ctx, &example.DeleteResourceInput{
Name: r.Name,
})
return err
}
func (r *ExampleResource) Properties() types.Properties {
return types.NewPropertiesFromStruct(r)
}
func (r *ExampleResource) String() string {
return *r.Name
}
func (r *ExampleResource) Settings(setting *libsettings.Setting) {
r.settings = setting
}Before submitting your resource:
- Resource file created in
resources/directory - Uses AWS SDK v2
- Implements required methods:
Remove(),Properties(),String() - Registered in
init()function - Handles pagination correctly
- Mock tests created and passing
- Properties test included
- Integration tests created (if possible)
- Follows import order from CONTRIBUTING.md
- Code passes
golangci-lint run - Code formatted with
go fmt - Filters undeletable resources appropriately
- Settings implemented if needed
- Signed commit with conventional commit message
- CONTRIBUTING.md - General contribution guidelines
- AWS SDK for Go v2 Documentation
- libnuke Documentation
- Resource Examples - Browse existing resources for patterns
If you have questions:
- Check existing resources in the
resources/directory for similar patterns - Review the GitHub Discussions
- Open an issue if you encounter problems
After creating your resource:
- Test thoroughly with both mock and integration tests
- Create a Pull Request following the guidelines in CONTRIBUTING.md
- Ensure all GitHub Actions checks pass
- Respond to any code review feedback
Happy coding!