Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
13 changes: 7 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ go 1.25.8

require (
github.com/aws/aws-lambda-go v1.54.0
github.com/aws/aws-sdk-go-v2 v1.41.11
github.com/aws/aws-sdk-go-v2 v1.42.0
github.com/aws/aws-sdk-go-v2/config v1.32.22
github.com/aws/aws-sdk-go-v2/credentials v1.19.21
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.71.15
github.com/aws/aws-sdk-go-v2/service/ec2 v1.308.0
github.com/aws/aws-sdk-go-v2/service/ecr v1.58.2
github.com/aws/aws-sdk-go-v2/service/iam v1.54.2
github.com/aws/aws-sdk-go-v2/service/lambda v1.92.1
github.com/aws/aws-sdk-go-v2/service/sts v1.43.1
github.com/aws/smithy-go v1.27.0
github.com/aws/smithy-go v1.27.1
github.com/onsi/ginkgo/v2 v2.29.0
github.com/onsi/gomega v1.41.0
github.com/spf13/cobra v1.10.2
Expand All @@ -22,11 +23,11 @@ require (
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.4 // indirect
Expand Down
26 changes: 14 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/aws/aws-lambda-go v1.54.0 h1:EGYpdyRGF88xszqlGcBewz811mJeRS+maNlLZXFheII=
github.com/aws/aws-lambda-go v1.54.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A=
github.com/aws/aws-sdk-go-v2 v1.41.11 h1:9PRf7jyTMEUM6fuNRAJa2mO/skJfrF50rENJwf2LXqw=
github.com/aws/aws-sdk-go-v2 v1.41.11/go.mod h1:iiUX27gOXRuYaoeUVXhUpPwjJHzISfPAjjcuhUbLSVs=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 h1:oRtsqWgxbpeXrOlxOoQStx2M9WNbIkPq4C4Xn1or6bc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12/go.mod h1:Zg0Oe9qT+9wcezlm1a64wGJp2qZdRElVxo/seJf7jYU=
github.com/aws/aws-sdk-go-v2/config v1.32.22 h1:Vfvp7+fYKsVCADcWOEllqEV47aIBXhNchvyDFu1B5fY=
Expand All @@ -12,22 +12,24 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.21 h1:0+HscFXtNa4+3buV4IlG6v5lnOd
github.com/aws/aws-sdk-go-v2/credentials v1.19.21/go.mod h1:UE8+9t5zudFwu5k5ShC1PKArVEdOkQQdCXIHQAVNUcU=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.27 h1:BEfN1sjtiKEdikRDxYkjZNE4tyvw/YbGWCbl3xDZgRw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.27/go.mod h1:ISGSFNbOHRS+JV/17yStzRTPBUHHqF92kCpRLLyH3Nk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 h1:8sPbKi1/KRHwl5oR3qN9mUXestCeHuaRutxylnr/eVY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27/go.mod h1:QV9IVIopJ1dpQUno0f9VYDUwOEjj8u0iEJ4JiZVre3Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 h1:9d8AoASQY9UwrOSmiJ7uSM0MGUPFhnenwSvpaFfat2c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27/go.mod h1:x0rldpsnUQaQIs4Rh+Vwm9Z/0vI6BxadGtsgJfZFb8s=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28 h1:eaS9vwQ5ym4Y9S6+G/K3d3lgZhxs9Sldcn/YS7cmdKY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28/go.mod h1:oTdbDr+BMs7gAYrNpD0LDTyqQfv6yOYgTDv46+xbwFY=
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.71.15 h1:us9kmxnViaD2sbiIKXzSOGZ4c14C5aLGV+EbnccThF4=
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.71.15/go.mod h1:H2CEB5hS3tOB3f6/1x/6iuMpktmS6Wei9ylWTBMpGe8=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.308.0 h1:xBP+yWpveXD/PxK7HRMcoG6yj1vdOjSahAg4qPomF+0=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.308.0/go.mod h1:8mrDF7OtbuL0QpwP4YCvLuoOE4/5lL7D33MXgp069/Y=
github.com/aws/aws-sdk-go-v2/service/ecr v1.58.2 h1:sSfN1WfNyYjV0CQC8moHP4uxgNXSUxDT0X2vXbcJSbc=
github.com/aws/aws-sdk-go-v2/service/ecr v1.58.2/go.mod h1:CJsIIgsVqF1YRtS436i4RKrNFlo2LtQ7gORkdhKaj3k=
github.com/aws/aws-sdk-go-v2/service/iam v1.54.2 h1:gJxdCZtnmAX9jF1RjBVJ6O2Bn4P2zLAQFXYmTBM+W5A=
github.com/aws/aws-sdk-go-v2/service/iam v1.54.2/go.mod h1:GO5Zea33HKIxG12xqmrzTFgEZcok4jdpc4aM2TcHTfk=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11 h1:rFSsqDfCMPAmG70JOsYqFZCHXkyatoGa1K4YEt/BggQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11/go.mod h1:XG68qW+YLLFH0vnSDCou43Cgj5TeAG83O5NRSJgt04Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27 h1:2/pUo42hhVmQcM21ttZoBOLHQymyUH8qEnZGTIuGBT8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27/go.mod h1:p7hwgbwompjCRNTdB3ytlldddNt1rDBgVVMqWEVG1II=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/lambda v1.92.1 h1:WXCHC8eGCieCTxSJ0CsZc4yRK8PiUKPZm3un8L4Eu6I=
github.com/aws/aws-sdk-go-v2/service/lambda v1.92.1/go.mod h1:RQmeW7HZrBCTGhwnmRTEw29G+Fhao0pkhrlwakALzbs=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.3 h1:t6U7sowMfOjTeZXtDOtgEJXsoJyX4MDag+sfWGwUM9M=
Expand All @@ -38,8 +40,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.4 h1:p9+Fizo2sUB6wI5Yb3K5lmyk
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.4/go.mod h1:0x10Wy0dVS4Gn552xhHY5th2QdYpfJf44EsfyYGV194=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.1 h1:r/vUkpLilfCA3sxbRnkHbJejaoVHEdj4FEhv+Zva4DU=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.1/go.mod h1:t01JURC8Fe5M+7R1K0vzIZ2NT04HqvZR+FjlHrHDT2A=
github.com/aws/smithy-go v1.27.0 h1:ZoFioDKJxkSIW2otF9T0aPtNlUwhdVCcuZh/rzH9Hus=
github.com/aws/smithy-go v1.27.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
146 changes: 146 additions & 0 deletions internal/aws/ec2/cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package ec2

import (
"context"
"fmt"
"log"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
)

// CleanVPCForDeletion removes orphaned ENIs and non-default security groups
// from a VPC so that its CloudFormation stack can be deleted cleanly.
// Resources left behind by hosted cluster teardown (load balancer controllers,
// CNI plugin) cause CloudFormation delete-stack to fail on the first attempt.
func CleanVPCForDeletion(ctx context.Context, cfg aws.Config, vpcID string) error {
client := ec2.NewFromConfig(cfg)

if err := deleteOrphanedENIs(ctx, client, vpcID); err != nil {
return fmt.Errorf("cleaning ENIs: %w", err)
}

if err := deleteNonDefaultSecurityGroups(ctx, client, vpcID); err != nil {
return fmt.Errorf("cleaning security groups: %w", err)
}

return nil
}

func deleteOrphanedENIs(ctx context.Context, client *ec2.Client, vpcID string) error {
out, err := client.DescribeNetworkInterfaces(ctx, &ec2.DescribeNetworkInterfacesInput{
Filters: []types.Filter{
{Name: aws.String("vpc-id"), Values: []string{vpcID}},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add additional filter for status as well to ensure that the ENI being filtered is "available" only and not "in-use". That might simplify some of the additional checks being added.

Reference - https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeNetworkInterfaces.html#API_DescribeNetworkInterfaces_RequestParameters

status - The status of the network interface. If the network interface is not attached to an instance, the status is available; if a network interface is attached to an instance the status is in-use.

},
})
Comment on lines +33 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect the relevant sections.
ast-grep outline internal/aws/ec2/cleanup.go --view expanded

printf '\n--- cleanup.go (selected lines) ---\n'
sed -n '1,220p' internal/aws/ec2/cleanup.go

printf '\n--- search for paginator usage in internal/aws/ec2 ---\n'
rg -n "New.*Paginator|Paginator\(" internal/aws/ec2 internal -g '*.go'

printf '\n--- inspect callers / surrounding cleanup flow ---\n'
rg -n "CleanVPCForDeletion|deleteOrphanedENIs|deleteNonDefaultSecurityGroups" internal -g '*.go'

Repository: openshift-online/rosa-hyperfleet-cli

Length of output: 6172


🌐 Web query:

AWS EC2 DescribeNetworkInterfaces DescribeSecurityGroups pagination NextToken paginator Go SDK v2

💡 Result:

In the AWS SDK for Go v2, you handle pagination for operations like DescribeNetworkInterfaces and DescribeSecurityGroups using the service-specific paginator helpers rather than manually managing the NextToken [1][2]. The SDK provides dedicated constructors for these paginators, which simplify the process by abstracting the request/response cycle [1][2]. To implement pagination, follow this pattern: 1. Use the appropriate constructor: ec2.NewDescribeNetworkInterfacesPaginator or ec2.NewDescribeSecurityGroupsPaginator [3][4]. 2. Create the paginator by passing your client and the input parameters [1][2]. You can optionally provide a functional argument to configure settings like the page limit [3][1]. 3. Iterate using the paginator's HasMorePages and NextPage methods [3][4][1]. Example for DescribeNetworkInterfaces: paginator:= ec2.NewDescribeNetworkInterfacesPaginator(ec2Client, &ec2.DescribeNetworkInterfacesInput{ MaxResults: aws.Int32(100), }) for paginator.HasMorePages { output, err:= paginator.NextPage(ctx) if err!= nil { log.Fatalf("failed to get page: %v", err) } for _, eni:= range output.NetworkInterfaces { fmt.Println(*eni.NetworkInterfaceId) } } The paginator automatically handles the NextToken internally, storing it and applying it to subsequent calls until no more pages remain [5][1]. This is the recommended approach over manual token handling, as it reduces error-prone boilerplate and ensures standard request lifecycle management [6][1][2].

Citations:


Use EC2 paginators for both listings. DescribeNetworkInterfaces and DescribeSecurityGroups are paginated, so a VPC with enough leaked ENIs or security groups can leave later pages untouched and still block DeleteVPC.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/aws/ec2/cleanup.go` around lines 33 - 37, The cleanup flow in
describeVPCResources/deleteVPC only fetches the first page of EC2 results, so
leaked ENIs or security groups on later pages can be missed. Update the
DescribeNetworkInterfaces and DescribeSecurityGroups paths to use the EC2
paginator APIs in the cleanup logic, and ensure the full result set is collected
before DeleteVPC proceeds.

if err != nil {
return fmt.Errorf("describing ENIs: %w", err)
}

for _, eni := range out.NetworkInterfaces {
eniID := aws.ToString(eni.NetworkInterfaceId)

if eni.Attachment != nil && eni.Attachment.AttachmentId != nil {
// Only attempt detach for ENIs we can force-detach.
// ELB/Lambda-managed attachments will be cleaned up by their
// owning service; skip them to avoid API errors.
if eni.Attachment.DeleteOnTermination != nil && *eni.Attachment.DeleteOnTermination {
log.Printf(" skipping ENI %s (managed attachment, will be cleaned by owner)", eniID)
continue
}
Comment on lines +46 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to understand this part better. So for ELB/Lamba ENI we will as such not have DeleteOnTermination since it's a EC2 property so we will not be able to filter out ELB and other ENI through this check right?

I came across requester-managed: true (link) which would cover the list of ENI/resources that will be automatically handled.

So in the previous comment , we could have the following filters instead?

  • "Name=status,Values=available"
  • "Name=requester-managed,Values=false"

This would filter out the ENI that can be cleaned up and are not eligible for automatic deletion. Thoughts?


log.Printf(" detaching ENI %s", eniID)
_, err := client.DetachNetworkInterface(ctx, &ec2.DetachNetworkInterfaceInput{
AttachmentId: eni.Attachment.AttachmentId,
Force: aws.Bool(true),
})
if err != nil {
log.Printf(" warning: failed to detach ENI %s: %v (will still try delete)", eniID, err)
} else {
waitForENIAvailable(ctx, client, eniID)
}
}

log.Printf(" deleting ENI %s", eniID)
_, err := client.DeleteNetworkInterface(ctx, &ec2.DeleteNetworkInterfaceInput{
NetworkInterfaceId: aws.String(eniID),
})
if err != nil {
log.Printf(" warning: failed to delete ENI %s: %v", eniID, err)
}
}

return nil
Comment on lines +32 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return partial cleanup failures to the caller.

These helpers only return an error when the initial Describe* call fails. If the one blocking ENI or security group fails to detach/delete, CleanVPCForDeletion still returns nil, so DeleteVPC proceeds as if pre-cleanup succeeded and the operator loses the real reason the first stack delete can still time out.

Possible fix
+import "errors"
+
 func deleteOrphanedENIs(ctx context.Context, client *ec2.Client, vpcID string) error {
+	var cleanupErr error
+
 	out, err := client.DescribeNetworkInterfaces(ctx, &ec2.DescribeNetworkInterfacesInput{
 		Filters: []types.Filter{
 			{Name: aws.String("vpc-id"), Values: []string{vpcID}},
@@
 			_, err := client.DetachNetworkInterface(ctx, &ec2.DetachNetworkInterfaceInput{
 				AttachmentId: eni.Attachment.AttachmentId,
 				Force:        aws.Bool(true),
 			})
 			if err != nil {
 				log.Printf("  warning: failed to detach ENI %s: %v (will still try delete)", eniID, err)
+				cleanupErr = errors.Join(cleanupErr, fmt.Errorf("detach ENI %s: %w", eniID, err))
 			} else {
 				waitForENIAvailable(ctx, client, eniID)
 			}
 		}
@@
 		_, err := client.DeleteNetworkInterface(ctx, &ec2.DeleteNetworkInterfaceInput{
 			NetworkInterfaceId: aws.String(eniID),
 		})
 		if err != nil {
 			log.Printf("  warning: failed to delete ENI %s: %v", eniID, err)
+			cleanupErr = errors.Join(cleanupErr, fmt.Errorf("delete ENI %s: %w", eniID, err))
 		}
 	}
 
-	return nil
+	return cleanupErr
 }

Apply the same pattern in deleteNonDefaultSecurityGroups.

Also applies to: 93-145

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/aws/ec2/cleanup.go` around lines 32 - 75, Surface partial cleanup
failures from deleteOrphanedENIs so CleanVPCForDeletion can fail when
detach/delete operations do not succeed, instead of swallowing the errors and
returning nil after only logging warnings. Track any detach or delete failure in
deleteOrphanedENIs and return a wrapped error at the end while preserving the
existing per-ENI logging and continue-on-error behavior; apply the same pattern
in deleteNonDefaultSecurityGroups so both cleanup helpers report blocking
failures back to DeleteVPC.

}

func waitForENIAvailable(ctx context.Context, client *ec2.Client, eniID string) {
for i := 0; i < 12; i++ {
time.Sleep(5 * time.Second)
out, err := client.DescribeNetworkInterfaces(ctx, &ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []string{eniID},
})
if err != nil {
return
}
if len(out.NetworkInterfaces) > 0 && out.NetworkInterfaces[0].Status == types.NetworkInterfaceStatusAvailable {
return
}
}
}

func deleteNonDefaultSecurityGroups(ctx context.Context, client *ec2.Client, vpcID string) error {
out, err := client.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{
Filters: []types.Filter{
{Name: aws.String("vpc-id"), Values: []string{vpcID}},
},
})
if err != nil {
return fmt.Errorf("describing security groups: %w", err)
}

// First pass: revoke all rules so cross-references don't block deletion.
for _, sg := range out.SecurityGroups {
if aws.ToString(sg.GroupName) == "default" {
continue
}
sgID := aws.ToString(sg.GroupId)

if len(sg.IpPermissions) > 0 {
_, err := client.RevokeSecurityGroupIngress(ctx, &ec2.RevokeSecurityGroupIngressInput{
GroupId: aws.String(sgID),
IpPermissions: sg.IpPermissions,
})
if err != nil {
log.Printf(" warning: failed to revoke ingress on SG %s: %v", sgID, err)
}
}
if len(sg.IpPermissionsEgress) > 0 {
_, err := client.RevokeSecurityGroupEgress(ctx, &ec2.RevokeSecurityGroupEgressInput{
GroupId: aws.String(sgID),
IpPermissions: sg.IpPermissionsEgress,
})
if err != nil {
log.Printf(" warning: failed to revoke egress on SG %s: %v", sgID, err)
}
}
}

// Second pass: delete the security groups.
for _, sg := range out.SecurityGroups {
if aws.ToString(sg.GroupName) == "default" {
continue
}
sgID := aws.ToString(sg.GroupId)
log.Printf(" deleting security group %s (%s)", sgID, aws.ToString(sg.GroupName))
_, err := client.DeleteSecurityGroup(ctx, &ec2.DeleteSecurityGroupInput{
GroupId: aws.String(sgID),
})
if err != nil {
log.Printf(" warning: failed to delete SG %s: %v", sgID, err)
}
}

return nil
}
29 changes: 24 additions & 5 deletions internal/services/clustervpc/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import (
"context"
"errors"
"fmt"
"log"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
"github.com/openshift-online/rosa-regional-platform-cli/internal/aws/cloudformation"
"github.com/openshift-online/rosa-regional-platform-cli/internal/aws/ec2"
"github.com/openshift-online/rosa-regional-platform-cli/internal/cloudformation/templates"
)

Expand Down Expand Up @@ -190,14 +192,31 @@ func updateVPC(ctx context.Context, cfnClient *cloudformation.Client, req *Creat
}, nil
}

// DeleteVPC deletes cluster VPC resources
// DeleteVPC deletes cluster VPC resources. It pre-cleans orphaned ENIs and
// security groups left behind by hosted cluster teardown so that the
// CloudFormation stack delete succeeds on the first attempt.
func DeleteVPC(ctx context.Context, req *DeleteVPCRequest) error {
// Create CloudFormation client
cfnClient := cloudformation.NewClient(req.AWSConfig)

// Delete stack
stackName := fmt.Sprintf("rosa-%s-vpc", req.ClusterName)
err := cfnClient.DeleteStack(ctx, stackName, 15*time.Minute)

// Get the VPC ID from stack outputs so we can clean up orphaned resources.
outputs, err := cfnClient.GetStackOutputs(ctx, stackName)
if err != nil {
var notFound *cloudformation.StackNotFoundError
if errors.As(err, &notFound) {
return nil
}
// Stack exists but we can't read outputs (e.g. rollback state).
// Fall through to DeleteStack without cleanup -- no worse than before.
log.Printf("warning: could not read stack outputs for %s: %v (skipping pre-cleanup)", stackName, err)
} else if vpcID := outputs.Outputs["VpcId"]; vpcID != "" {
log.Printf("pre-cleaning VPC %s before stack deletion", vpcID)
if cleanErr := ec2.CleanVPCForDeletion(ctx, req.AWSConfig, vpcID); cleanErr != nil {
log.Printf("warning: VPC pre-cleanup failed: %v (proceeding with stack delete)", cleanErr)
}
}

err = cfnClient.DeleteStack(ctx, stackName, 15*time.Minute)
if err != nil {
var notFound *cloudformation.StackNotFoundError
if errors.As(err, &notFound) {
Expand Down