Repository: gruntwork-io/cloud-nuke (2026-03-23T08:44:58Z)

Issue:
Feature request: support tag filtering for backup-vault
Cloud-nuke should support filtering for tags in the AWS backup-vault resource.

<sup>Thomas Gunsch thomas.gunsch@mercedes-benz.com, Mercedes-Benz AG on behalf of Mercedes-Benz Tech Innovation GmbH, [Provider Information](https://github.com/mercedes-benz/foss/blob/main/PROVIDER_INFORMATION.md)</sup>


Discussion:

Patch:
diff --git a/aws/resources/backup_vault.go b/aws/resources/backup_vault.go
index 02410eca..c80b03dd 100644
--- a/aws/resources/backup_vault.go
+++ b/aws/resources/backup_vault.go
@@ -7,9 +7,11 @@ import (
 
 	"github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/service/backup"
+	"github.com/aws/aws-sdk-go-v2/service/backup/types"
 	"github.com/gruntwork-io/cloud-nuke/config"
 	"github.com/gruntwork-io/cloud-nuke/logging"
 	"github.com/gruntwork-io/cloud-nuke/resource"
+	"github.com/gruntwork-io/go-commons/errors"
 )
 
 // BackupVaultAPI defines the interface for Backup Vault operations.
@@ -18,6 +20,7 @@ type BackupVaultAPI interface {
 	DeleteRecoveryPoint(ctx context.Context, params *backup.DeleteRecoveryPointInput, optFns ...func(*backup.Options)) (*backup.DeleteRecoveryPointOutput, error)
 	ListBackupVaults(ctx context.Context, params *backup.ListBackupVaultsInput, optFns ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error)
 	ListRecoveryPointsByBackupVault(ctx context.Context, params *backup.ListRecoveryPointsByBackupVaultInput, optFns ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error)
+	ListTags(ctx context.Context, params *backup.ListTagsInput, optFns ...func(*backup.Options)) (*backup.ListTagsOutput, error)
 }
 
 // NewBackupVault creates a new BackupVault resource using the generic resource pattern.
@@ -48,9 +51,16 @@ func listBackupVaults(ctx context.Context, client BackupVaultAPI, scope resource
 		}
 
 		for _, backupVault := range page.BackupVaultList {
+			tags, err := getTags(ctx, client, cfg, backupVault)
+			if err != nil {
+				logging.Errorf("Unable to fetch tags for %s: %s", aws.ToString(backupVault.BackupVaultArn), err)
+				continue
+			}
+
 			if cfg.ShouldInclude(config.ResourceValue{
 				Name: backupVault.BackupVaultName,
 				Time: backupVault.CreationDate,
+				Tags: tags,
 			}) {
 				names = append(names, backupVault.BackupVaultName)
 			}
@@ -60,6 +70,27 @@ func listBackupVaults(ctx context.Context, client BackupVaultAPI, scope resource
 	return names, nil
 }
 
+// getTags retrieves the tags for a given backup vault if tag-based filters are specified in the config.
+func getTags(ctx context.Context, client BackupVaultAPI, cfg config.ResourceType, backupVault types.BackupVaultListMember) (map[string]string, error) {
+	tags := map[string]string{}
+	if len(cfg.IncludeRule.Tags) > 0 || len(cfg.ExcludeRule.Tags) > 0 {
+		tagsPaginator := backup.NewListTagsPaginator(client, &backup.ListTagsInput{
+			ResourceArn: backupVault.BackupVaultArn,
+		})
+		for tagsPaginator.HasMorePages() {
+			tagsPage, errListTags := tagsPaginator.NextPage(ctx)
+			if errListTags != nil {
+				return nil, errors.WithStackTrace(errListTags)
+			}
+
+			for tagKey, tagValue := range tagsPage.Tags {
+				tags[tagKey] = tagValue
+			}
+		}
+	}
+	return tags, nil
+}
+
 // nukeRecoveryPoints deletes all recovery points in a backup vault.
 func nukeRecoveryPoints(ctx context.Context, client BackupVaultAPI, name *string) error {
 	vaultName := aws.ToString(name)
diff --git a/docs/supported-resources.md b/docs/supported-resources.md
index 6b0cd5ec..4c9a4ec7 100644
--- a/docs/supported-resources.md
+++ b/docs/supported-resources.md
@@ -151,7 +151,7 @@ This table shows which filtering features are supported for each resource type i
 | api-gateway-v2 | APIGatewayV2 | ✓ | ✓ | ✓ | ✓ |
 | app-runner-service | AppRunnerService | ✓ | ✓ | | ✓ |
 | asg | AutoScalingGroup | ✓ | ✓ | ✓ | ✓ |
-| backup-vault | BackupVault | ✓ | ✓ | | ✓ |
+| backup-vault | BackupVault | ✓ | ✓ | ✓ | ✓ |
 | cloudformation-stack | CloudFormationStack | ✓ | ✓ | ✓ | ✓ |
 | cloudfront-distribution | CloudFrontDistribution | ✓ | | | ✓ |
 | cloudmap-namespace | CloudMapNamespace | ✓ | ✓ | ✓ | ✓ |


Test patch:
diff --git a/aws/resources/backup_vault_test.go b/aws/resources/backup_vault_test.go
index 0d59726e..fff6aca3 100644
--- a/aws/resources/backup_vault_test.go
+++ b/aws/resources/backup_vault_test.go
@@ -2,6 +2,7 @@ package resources
 
 import (
 	"context"
+	"errors"
 	"regexp"
 	"sync/atomic"
 	"testing"
@@ -15,6 +16,11 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+type mockListTagsPageOutput struct {
+	ListTagsPages []backup.ListTagsOutput
+	listTagsIndex int
+}
+
 type mockBackupVaultClient struct {
 	// ListBackupVaults pagination support
 	ListBackupVaultsPages []backup.ListBackupVaultsOutput
@@ -24,6 +30,10 @@ type mockBackupVaultClient struct {
 	ListRecoveryPointsPages  []backup.ListRecoveryPointsByBackupVaultOutput
 	listRecoveryPointsIndex  int
 	DeleteRecoveryPointCount atomic.Int32
+
+	// ListTags pagination support
+	ListTagsPages map[string]*mockListTagsPageOutput
+	ListTagsErr   error
 }
 
 func (m *mockBackupVaultClient) DeleteBackupVault(ctx context.Context, params *backup.DeleteBackupVaultInput, optFns ...func(*backup.Options)) (*backup.DeleteBackupVaultOutput, error) {
@@ -53,6 +63,26 @@ func (m *mockBackupVaultClient) ListRecoveryPointsByBackupVault(ctx context.Cont
 	return &output, nil
 }
 
+func (m *mockBackupVaultClient) ListTags(ctx context.Context, params *backup.ListTagsInput, optFns ...func(*backup.Options)) (*backup.ListTagsOutput, error) {
+	if m.ListTagsErr != nil {
+		return nil, m.ListTagsErr
+	}
+	pages := m.ListTagsPages[*params.ResourceArn]
+	if pages == nil || pages.listTagsIndex >= len(pages.ListTagsPages) {
+		return &backup.ListTagsOutput{}, nil
+	}
+
+	output := pages.ListTagsPages[pages.listTagsIndex]
+	pages.listTagsIndex++
+
+	// Set NextToken if there are more pages to indicate pagination should continue
+	if pages.listTagsIndex < len(pages.ListTagsPages) {
+		output.NextToken = aws.String("token")
+	}
+
+	return &output, nil
+}
+
 func TestListBackupVaults(t *testing.T) {
 	t.Parallel()
 
@@ -107,6 +137,99 @@ func TestListBackupVaults(t *testing.T) {
 	}
 }
 
+func TestListBackupVaults_FilterTags(t *testing.T) {
+	t.Parallel()
+
+	backupVaultWithoutTags := "backupVaultWithoutTags"
+	backVaultFooFaz := "backupVaultFooFaz"
+	backVaultFoo := "backupVaultFoo"
+
+	tests := map[string]struct {
+		configObj config.ResourceType
+		expected  []string
+	}{
+		"emptyFilter": {
+			configObj: config.ResourceType{},
+			expected:  []string{backupVaultWithoutTags, backVaultFooFaz, backVaultFoo},
+		},
+		"tagInclusionFilter": {
+			configObj: config.ResourceType{
+				IncludeRule: config.FilterRule{
+					Tags: map[string]config.Expression{"foo": {RE: *regexp.MustCompile("bar")}},
+				}},
+			expected: []string{backVaultFooFaz, backVaultFoo},
+		},
+		"tagExclusionFilter": {
+			configObj: config.ResourceType{
+				ExcludeRule: config.FilterRule{
+					Tags: map[string]config.Expression{"faz": {RE: *regexp.MustCompile("baz")}},
+				}},
+			expected: []string{backupVaultWithoutTags, backVaultFoo},
+		},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			// Create a fresh mock for each subtest to avoid state pollution
+			mock := &mockBackupVaultClient{
+				ListBackupVaultsPages: []backup.ListBackupVaultsOutput{
+					{
+						BackupVaultList: []types.BackupVaultListMember{
+							{BackupVaultName: aws.String(backupVaultWithoutTags), BackupVaultArn: aws.String("arn:aws:" + backupVaultWithoutTags)},
+							{BackupVaultName: aws.String(backVaultFooFaz), BackupVaultArn: aws.String("arn:aws:" + backVaultFooFaz)},
+							{BackupVaultName: aws.String(backVaultFoo), BackupVaultArn: aws.String("arn:aws:" + backVaultFoo)},
+						},
+					},
+				},
+				ListTagsPages: map[string]*mockListTagsPageOutput{
+					"arn:aws:" + backVaultFoo: {
+						ListTagsPages: []backup.ListTagsOutput{
+							{Tags: map[string]string{"foo": "bar"}},
+						},
+					},
+					"arn:aws:" + backVaultFooFaz: {
+						ListTagsPages: []backup.ListTagsOutput{
+							{Tags: map[string]string{"foo": "bar"}},
+							{Tags: map[string]string{"faz": "baz"}},
+						},
+					},
+				},
+			}
+
+			names, err := listBackupVaults(context.Background(), mock, resource.Scope{}, tc.configObj)
+
+			require.NoError(t, err)
+			require.Equal(t, tc.expected, aws.ToStringSlice(names))
+		})
+	}
+}
+
+func TestListBackupVaults_TagsError(t *testing.T) {
+	t.Parallel()
+
+	testArn := "arn:aws:backup:us-east-1:123456789012:backup/test-vault"
+	mock := &mockBackupVaultClient{
+		ListBackupVaultsPages: []backup.ListBackupVaultsOutput{
+			{
+				BackupVaultList: []types.BackupVaultListMember{
+					{BackupVaultName: aws.String("test-vault"), BackupVaultArn: aws.String(testArn)},
+				},
+			},
+		},
+		ListTagsErr: errors.New("BackupVaultException"),
+	}
+
+	cfg := config.ResourceType{
+		IncludeRule: config.FilterRule{
+			Tags: map[string]config.Expression{"foo": {RE: *regexp.MustCompile("bar")}},
+		},
+	}
+
+	names, err := listBackupVaults(context.Background(), mock, resource.Scope{}, cfg)
+	require.NoError(t, err)
+	require.Equal(t, []string{}, aws.ToStringSlice(names))
+}
+
 func TestNukeBackupVault(t *testing.T) {
 	t.Parallel()