From 800c2ee67cee77a25773e79af762ad0c383815ef Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Mon, 29 Jun 2026 17:18:09 -0700 Subject: [PATCH 1/5] feat(aws): add cleanup tool to AI --- src/cmd/cli/command/compose.go | 17 +- src/go.mod | 15 +- src/go.sum | 30 ++-- src/pkg/agent/tools/cleanup.go | 105 +++++++++++ src/pkg/agent/tools/cleanup_test.go | 155 ++++++++++++++++ src/pkg/agent/tools/tools.go | 11 ++ src/pkg/cli/client/byoc/aws/byoc.go | 4 + src/pkg/cli/client/byoc/aws/cleanup.go | 196 +++++++++++++++++++++ src/pkg/cli/client/byoc/aws/domain_test.go | 5 + src/pkg/cli/client/cleanup.go | 24 +++ src/pkg/cli/stacks.go | 2 +- src/pkg/cli/stacks_test.go | 4 +- src/pkg/clouds/aws/ecr.go | 69 ++++++++ src/pkg/clouds/aws/ecr_test.go | 57 ++++++ src/pkg/clouds/aws/elbv2.go | 56 ++++++ src/pkg/clouds/aws/elbv2_test.go | 60 +++++++ src/pkg/clouds/aws/rds.go | 48 +++++ src/pkg/clouds/aws/rds_test.go | 48 +++++ src/pkg/clouds/aws/route53.go | 34 ++++ src/pkg/clouds/aws/route53_cleanup_test.go | 67 +++++++ src/pkg/debug/debug.go | 22 ++- src/pkg/utils.go | 10 ++ 22 files changed, 1015 insertions(+), 24 deletions(-) create mode 100644 src/pkg/agent/tools/cleanup.go create mode 100644 src/pkg/agent/tools/cleanup_test.go create mode 100644 src/pkg/cli/client/byoc/aws/cleanup.go create mode 100644 src/pkg/cli/client/cleanup.go create mode 100644 src/pkg/clouds/aws/ecr.go create mode 100644 src/pkg/clouds/aws/ecr_test.go create mode 100644 src/pkg/clouds/aws/elbv2.go create mode 100644 src/pkg/clouds/aws/elbv2_test.go create mode 100644 src/pkg/clouds/aws/rds.go create mode 100644 src/pkg/clouds/aws/rds_test.go create mode 100644 src/pkg/clouds/aws/route53_cleanup_test.go diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index bae0186a5..d700e8605 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -482,7 +482,22 @@ func makeComposeDownCmd() *cobra.Command { term.Warn("Unable to tail logs. Detaching.") return nil } - return err + // A failed destroy (e.g. CodeBuild exit status) is when resources get orphaned, so prompt + // the AI debugger just like `up` does; it can guide the user through cleanup. + deploymentErr := err + debugger, dbgErr := debug.NewDebugger(cmd.Context(), global.FabricAddr, session.Stack) + if dbgErr != nil { + term.Warn("Failed to initialize debugger:", dbgErr) + return deploymentErr + } + handleTailAndMonitorErr(cmd.Context(), deploymentErr, debugger, debug.DebugConfig{ + Deployment: deployment, + ProviderID: &session.Stack.Provider, + Stack: session.Stack.Name, + Since: since, + Until: time.Now(), + }) + return deploymentErr } term.Info("Done.") diff --git a/src/go.mod b/src/go.mod index 299fcb8ff..9348b9a7c 100644 --- a/src/go.mod +++ b/src/go.mod @@ -37,20 +37,23 @@ require ( github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 - github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2 v1.42.0 github.com/aws/aws-sdk-go-v2/config v1.32.7 github.com/aws/aws-sdk-go-v2/service/cloudformation v1.42.6 github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.65.0 github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 github.com/aws/aws-sdk-go-v2/service/ec2 v1.145.0 + github.com/aws/aws-sdk-go-v2/service/ecr v1.58.5 github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.7 + github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.55.5 + github.com/aws/aws-sdk-go-v2/service/rds v1.119.4 github.com/aws/aws-sdk-go-v2/service/route53 v1.37.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.5 github.com/aws/aws-sdk-go-v2/service/servicequotas v1.25.5 github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 - github.com/aws/smithy-go v1.24.2 + github.com/aws/smithy-go v1.27.1 github.com/awslabs/goformation/v7 v7.14.9 github.com/compose-spec/compose-go/v2 v2.10.1 github.com/digitalocean/godo v1.131.1 @@ -168,13 +171,13 @@ require ( github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.7 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // 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/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // 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/checksum v1.9.13 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect diff --git a/src/go.sum b/src/go.sum index 2fb735083..d5ef3e97b 100644 --- a/src/go.sum +++ b/src/go.sum @@ -100,8 +100,8 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= -github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +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.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= @@ -110,10 +110,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUT github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +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/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= @@ -126,16 +126,22 @@ github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 h1:lQTVEv/YAk8Rw1Yf4XZS/ github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12/go.mod h1:yoa0R6Xku788EmJYkFiARzJBxt4A3hgFjQPRmMAttr0= github.com/aws/aws-sdk-go-v2/service/ec2 v1.145.0 h1:SkSW6wtJmXqJJlBxSc+0mykDdv5nhl9xifMB7JuzNVo= github.com/aws/aws-sdk-go-v2/service/ec2 v1.145.0/go.mod h1:hIsHE0PaWAQakLCshKS7VKWMGXaqrAFp4m95s2W9E6c= +github.com/aws/aws-sdk-go-v2/service/ecr v1.58.5 h1:y6KxDUTvYd43ODh5o00oPSOTL6RP+aqWHfYDoElCy7Q= +github.com/aws/aws-sdk-go-v2/service/ecr v1.58.5/go.mod h1:7VJFM2lSPHz2I1rRb0a+lbphoOp7hXIgYjGhSTOLY7k= github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.7 h1:NHy1+Jq8gVp8fSLF6Z8SazA+R4Qzsbla/0SbHHReH4Y= github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.7/go.mod h1:KxsaVRXo+DeRMHVp65WqyM49XZiS6n74lEGQindkdgA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.55.5 h1:tEWkZW08+2cM3BPhymRzo2dEz2aWyHkQzT9av4eIMig= +github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.55.5/go.mod h1:sUBnPF4iTc3KaCTIbLTr8xXjsnw8J0kXwr0nPCaAK3I= +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/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +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/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/rds v1.119.4 h1:on9PiAL5ucRg9t17cecI/NagAUVH6zVv2sItyZyq/I0= +github.com/aws/aws-sdk-go-v2/service/rds v1.119.4/go.mod h1:zCRPUdp05FEZG3OO7LmJq9xkSDjMEhkiVrZV0oJs2a0= github.com/aws/aws-sdk-go-v2/service/route53 v1.37.1 h1:U7OksynDSIFScG+7sGqOuJh+fP1USMkNtjxzGFZYG34= github.com/aws/aws-sdk-go-v2/service/route53 v1.37.1/go.mod h1:8qqfpG4mug2JLlEyWPSFhEGvJiaZ9iPmMDDMYc5Xtas= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= @@ -154,8 +160,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLz github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/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/awslabs/goformation/v7 v7.14.9 h1:sZjjpTqXrcBDz4Fi07JWTT7zKM68XsQkW/7iLAJbA/M= github.com/awslabs/goformation/v7 v7.14.9/go.mod h1:7obldQ8NQ/AkMsgL5K3l4lRMDFB6kCGUloz5dURcXIs= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/src/pkg/agent/tools/cleanup.go b/src/pkg/agent/tools/cleanup.go new file mode 100644 index 000000000..b47321954 --- /dev/null +++ b/src/pkg/agent/tools/cleanup.go @@ -0,0 +1,105 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/DefangLabs/defang/src/pkg/agent/common" + "github.com/DefangLabs/defang/src/pkg/auth" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/DefangLabs/defang/src/pkg/stacks" + "github.com/DefangLabs/defang/src/pkg/term" +) + +type CleanupParams struct { + common.LoaderParams +} + +func HandleCleanupTool(ctx context.Context, loader client.Loader, params CleanupParams, cli CLIInterface, ec elicitations.Controller, sc StackConfig) (string, error) { + term.Debug("Function invoked: cli.Connect") + fabric, err := GetClientWithRetry(ctx, cli, sc.FabricAddr) + if err != nil { + var noBrowserErr auth.ErrNoBrowser + if errors.As(err, &noBrowserErr) { + return noBrowserErr.Error(), nil + } + return "", err + } + + workingDir, _ := loader.ProjectWorkingDir(ctx) + sm, err := stacks.NewManager(fabric, workingDir, params.ProjectName, ec) + if err != nil { + return "", fmt.Errorf("failed to create stack manager: %w", err) + } + pp := NewProviderPreparer(cli, ec, fabric, sm) + _, provider, err := pp.SetupProvider(ctx, sc.Stack) + if err != nil { + return "", fmt.Errorf("failed to setup provider: %w", err) + } + + projectName, err := cli.LoadProjectNameWithFallback(ctx, loader, provider) + if err != nil { + return "", fmt.Errorf("failed to load project name: %w", err) + } + + if err := cli.CanIUseProvider(ctx, fabric, provider, projectName, 0); err != nil { + return "", fmt.Errorf("failed to use provider: %w", err) + } + + cleaner, ok := provider.(client.OrphanCleaner) + if !ok { + return "Resource cleanup is currently only supported for AWS. The selected provider does not retain resources that need manual cleanup.", nil + } + + orphans, err := cleaner.DiscoverOrphans(ctx, projectName) + if err != nil { + return "", fmt.Errorf("failed to discover leftover resources: %w", err) + } + if len(orphans) == 0 { + return fmt.Sprintf("No leftover resources found for project %q that are blocking cleanup.", projectName), nil + } + + var report strings.Builder + fmt.Fprintf(&report, "Found %d leftover resource(s) for project %q blocking cleanup:\n", len(orphans), projectName) + + // Without interactive elicitation we cannot get confirmation for these destructive actions, + // so only report what was found and let the caller decide. + if !ec.IsSupported() { + for _, o := range orphans { + fmt.Fprintf(&report, "- [%s] %s — would %s\n", o.Category, o.Name, o.Action) + } + report.WriteString("\nRe-run this tool in an interactive session to apply these changes, then run `defang down` so Pulumi can finish removing the resources.") + return report.String(), nil + } + + var cleaned, skipped, failed int + for _, o := range orphans { + confirm, err := ec.RequestEnum(ctx, + fmt.Sprintf("Cleanup will %s for %s %q. Proceed?", o.Action, o.Category, o.Name), + "confirm", []string{"no", "yes"}) + if err != nil { + return "", fmt.Errorf("failed to confirm cleanup: %w", err) + } + if confirm != "yes" { + skipped++ + fmt.Fprintf(&report, "- [%s] %s — skipped\n", o.Category, o.Name) + continue + } + if err := cleaner.CleanupOrphan(ctx, o); err != nil { + failed++ + fmt.Fprintf(&report, "- [%s] %s — failed: %v\n", o.Category, o.Name, err) + continue + } + cleaned++ + fmt.Fprintf(&report, "- [%s] %s — done (%s)\n", o.Category, o.Name, o.Action) + } + + fmt.Fprintf(&report, "\n%d cleaned, %d skipped, %d failed.", cleaned, skipped, failed) + if cleaned > 0 { + report.WriteString(" Run `defang down` (or the destroy tool) so Pulumi can finish removing the now-unblocked resources.") + } + return report.String(), nil +} diff --git a/src/pkg/agent/tools/cleanup_test.go b/src/pkg/agent/tools/cleanup_test.go new file mode 100644 index 000000000..ae500f345 --- /dev/null +++ b/src/pkg/agent/tools/cleanup_test.go @@ -0,0 +1,155 @@ +package tools + +import ( + "context" + "errors" + "testing" + + "github.com/DefangLabs/defang/src/pkg/agent/common" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/DefangLabs/defang/src/pkg/stacks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockCleanerProvider embeds MockProvider and adds the optional OrphanCleaner capability, +// recording which resources were cleaned up. A pointer is used so recording persists. +type mockCleanerProvider struct { + client.MockProvider + orphans []client.OrphanResource + discoverErr error + cleanupErr error + cleaned []string +} + +func (m *mockCleanerProvider) DiscoverOrphans(ctx context.Context, projectName string) ([]client.OrphanResource, error) { + return m.orphans, m.discoverErr +} + +func (m *mockCleanerProvider) CleanupOrphan(ctx context.Context, r client.OrphanResource) error { + if m.cleanupErr != nil { + return m.cleanupErr + } + m.cleaned = append(m.cleaned, r.ID) + return nil +} + +// MockCleanupCLI implements CLIInterface, returning a configurable provider. +type MockCleanupCLI struct { + CLIInterface + provider client.Provider + projectName string +} + +func (m *MockCleanupCLI) Connect(ctx context.Context, fabricAddr string) (*client.GrpcClient, error) { + return &client.GrpcClient{}, nil +} + +func (m *MockCleanupCLI) InteractiveLoginMCP(ctx context.Context, fabricAddr string, mcpClient string) error { + return nil +} + +func (m *MockCleanupCLI) NewProvider(ctx context.Context, providerId client.ProviderID, grpcClient client.FabricClient, stack string) client.Provider { + if m.provider != nil { + return m.provider + } + return client.MockProvider{} +} + +func (m *MockCleanupCLI) LoadProjectNameWithFallback(ctx context.Context, loader client.Loader, provider client.Provider) (string, error) { + return m.projectName, nil +} + +func (m *MockCleanupCLI) CanIUseProvider(ctx context.Context, grpcClient *client.GrpcClient, provider client.Provider, projectName string, serviceCount int) error { + return nil +} + +func orphan(id, category string) client.OrphanResource { + return client.OrphanResource{ID: id, Category: category, Name: id, Action: "do the thing"} +} + +func TestHandleCleanupTool(t *testing.T) { + loader := &client.MockLoader{} + twoOrphans := []client.OrphanResource{orphan("alb:1", "alb"), orphan("ecr:repo", "ecr")} + + tests := []struct { + name string + provider client.Provider + confirm string + notInteractive bool + expectErr string + expectContains []string + expectCleaned []string + }{ + { + name: "non-AWS provider is unsupported", + provider: client.MockProvider{}, + expectContains: []string{"only supported for AWS"}, + }, + { + name: "no orphans found", + provider: &mockCleanerProvider{}, + expectContains: []string{"No leftover resources"}, + }, + { + name: "confirm yes cleans every orphan", + provider: &mockCleanerProvider{orphans: twoOrphans}, + confirm: "yes", + expectContains: []string{"2 cleaned, 0 skipped, 0 failed", "defang down"}, + expectCleaned: []string{"alb:1", "ecr:repo"}, + }, + { + name: "confirm no skips every orphan", + provider: &mockCleanerProvider{orphans: twoOrphans}, + confirm: "no", + expectContains: []string{"0 cleaned, 2 skipped, 0 failed"}, + expectCleaned: nil, + }, + { + name: "non-interactive reports only", + provider: &mockCleanerProvider{orphans: twoOrphans}, + notInteractive: true, + expectContains: []string{"would do the thing", "interactive session"}, + expectCleaned: nil, + }, + { + name: "discovery error is surfaced", + provider: &mockCleanerProvider{discoverErr: errors.New("boom")}, + expectErr: "failed to discover leftover resources: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Chdir("testdata") + mockCLI := &MockCleanupCLI{provider: tt.provider, projectName: "test-project"} + + ec := elicitations.NewController(&mockElicitationsClient{ + responses: map[string]string{"confirm": tt.confirm}, + }) + if tt.notInteractive { + ec.SetSupported(false) + } + + stack := stacks.Parameters{Name: "test-stack", Provider: client.ProviderAWS} + params := CleanupParams{LoaderParams: common.LoaderParams{WorkingDirectory: "."}} + result, err := HandleCleanupTool(t.Context(), loader, params, mockCLI, ec, StackConfig{ + FabricAddr: "test-cluster", + Stack: &stack, + }) + + if tt.expectErr != "" { + assert.EqualError(t, err, tt.expectErr) + return + } + require.NoError(t, err) + for _, want := range tt.expectContains { + assert.Contains(t, result, want) + } + if cp, ok := tt.provider.(*mockCleanerProvider); ok { + assert.Equal(t, tt.expectCleaned, cp.cleaned) + } + }) + } +} diff --git a/src/pkg/agent/tools/tools.go b/src/pkg/agent/tools/tools.go index 801cae288..20d31166a 100644 --- a/src/pkg/agent/tools/tools.go +++ b/src/pkg/agent/tools/tools.go @@ -42,6 +42,17 @@ func CollectDefangTools(ec elicitations.Controller, sc StackConfig) []ai.Tool { return HandleDestroyTool(ctx.Context, loader, params, cli, ec, sc) }, ), + ai.NewTool("cleanup_resources", + "Find and unblock AWS resources left behind after `defang down` that prevent Pulumi from finishing cleanup (load balancers and databases with deletion protection, leftover Route53 records, and non-empty ECR repositories). Performs the minimum action to unblock each (disable deletion protection, delete records, delete images) and confirms before each change. After running, run `defang down` again so Pulumi removes the resources.", + func(ctx *ai.ToolContext, params CleanupParams) (string, error) { + loader, err := common.ConfigureAgentLoader(params.LoaderParams) + if err != nil { + return "Failed to configure loader", err + } + cli := &DefaultToolCLI{} + return HandleCleanupTool(ctx.Context, loader, params, cli, ec, sc) + }, + ), ai.NewTool("logs", "Fetch logs for the application in the selected stack, in pages of up to 100 lines. You can use the 'since' and 'until' parameters to page through logs by time.", func(ctx *ai.ToolContext, params LogsParams) (string, error) { diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index e8ef67135..17a30be47 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -60,6 +60,10 @@ type ByocAws struct { cdBuildId awscodebuild.BuildID // for GetDeploymentStatus needDockerHubCreds bool + + // orphans maps an OrphanResource.ID to the cloud details needed to clean it up. It is + // (re)populated by DiscoverOrphans and read by CleanupOrphan. + orphans map[string]orphanDetail } var _ client.Provider = (*ByocAws)(nil) diff --git a/src/pkg/cli/client/byoc/aws/cleanup.go b/src/pkg/cli/client/byoc/aws/cleanup.go new file mode 100644 index 000000000..108b740af --- /dev/null +++ b/src/pkg/cli/client/byoc/aws/cleanup.go @@ -0,0 +1,196 @@ +package aws + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/clouds/aws" + "github.com/DefangLabs/defang/src/pkg/dns" + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/aws/aws-sdk-go-v2/service/ecr" + elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" + "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/aws/aws-sdk-go-v2/service/route53" + r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" +) + +var _ client.OrphanCleaner = (*ByocAws)(nil) + +// maxALBNameLen is the effective length AWS CD truncates load balancer names to (see alb_logs.go). +const maxALBNameLen = 31 + +// orphanDetail holds the cloud-specific data needed to clean up an OrphanResource. Only the +// fields relevant to the resource's category are populated. +type orphanDetail struct { + category string + lbArn string + dbID string + repoName string + zoneID string + record r53types.ResourceRecordSet +} + +// resourceBaseName returns the dash-joined {Prefix}-{project}-{stack} base that Defang/Pulumi use +// to name ALBs, RDS instances and similar resources (see alb_logs.go). +func (b *ByocAws) resourceBaseName(projectName string) string { + base := projectName + "-" + b.PulumiStack + if b.Prefix != "" { + base = b.Prefix + "-" + base + } + return strings.ToLower(base) +} + +// projectZoneName returns the Defang-managed hosted zone for the project's public services +// (..defang.app), matching ServicePublicDNS. +func (b *ByocAws) projectZoneName(projectName string) string { + tenantLabel := dns.SafeLabel(string(b.TenantLabel)) + return b.GetProjectLabel(projectName) + "." + tenantLabel + ".defang.app" +} + +// DiscoverOrphans finds AWS resources left behind by `defang down` that block Pulumi from +// finishing cleanup on a subsequent run. Failures in any single category are logged and skipped +// so the remaining categories can still be reported. +func (b *ByocAws) DiscoverOrphans(ctx context.Context, projectName string) ([]client.OrphanResource, error) { + cfg, err := b.driver.LoadConfig(ctx) + if err != nil { + return nil, AnnotateAwsError(err) + } + + b.orphans = map[string]orphanDetail{} + var resources []client.OrphanResource + add := func(id string, r client.OrphanResource, d orphanDetail) { + r.ID = id + b.orphans[id] = d + resources = append(resources, r) + } + + base := b.resourceBaseName(projectName) + + // ALBs: any leftover load balancer is unblocked by disabling deletion protection (idempotent). + albPrefix := base + if len(albPrefix) > maxALBNameLen { + albPrefix = albPrefix[:maxALBNameLen] + } + elbClient := elbv2.NewFromConfig(cfg) + if lbs, err := aws.FindLoadBalancersByPrefix(ctx, albPrefix, elbClient); err != nil { + term.Warnf("cleanup: could not list load balancers: %v", err) + } else { + for _, lb := range lbs { + add("alb:"+*lb.LoadBalancerArn, client.OrphanResource{ + Category: "alb", + Name: *lb.LoadBalancerName, + Action: "disable deletion protection so 'defang down' can delete the load balancer", + }, orphanDetail{category: "alb", lbArn: *lb.LoadBalancerArn}) + } + } + + // RDS: same as ALBs; disabling deletion protection is idempotent. + rdsClient := rds.NewFromConfig(cfg) + if insts, err := aws.FindDBInstancesByPrefix(ctx, base, rdsClient); err != nil { + term.Warnf("cleanup: could not list RDS instances: %v", err) + } else { + for _, inst := range insts { + add("rds:"+*inst.DBInstanceIdentifier, client.OrphanResource{ + Category: "rds", + Name: *inst.DBInstanceIdentifier, + Action: "disable deletion protection so 'defang down' can delete the database", + }, orphanDetail{category: "rds", dbID: *inst.DBInstanceIdentifier}) + } + } + + // ECR: a non-empty repository blocks deletion (RepositoryNotEmptyException); deleting its + // images lets Pulumi remove it. Empty repositories are not blockers, so they are skipped. + ecrClient := ecr.NewFromConfig(cfg) + repoPrefix := b.GetProjectLabel(projectName) + "/" + if repos, err := aws.FindRepositoriesByPrefix(ctx, repoPrefix, ecrClient); err != nil { + term.Warnf("cleanup: could not list ECR repositories: %v", err) + } else { + for _, repo := range repos { + ids, err := aws.ListImageIDs(ctx, *repo.RepositoryName, ecrClient) + if err != nil { + term.Warnf("cleanup: could not list images for %s: %v", *repo.RepositoryName, err) + continue + } + if len(ids) == 0 { + continue + } + add("ecr:"+*repo.RepositoryName, client.OrphanResource{ + Category: "ecr", + Name: *repo.RepositoryName, + Action: fmt.Sprintf("delete %d image(s) so 'defang down' can delete the repository", len(ids)), + }, orphanDetail{category: "ecr", repoName: *repo.RepositoryName}) + } + } + + // Route53: records left in the project's hosted zone block the zone from being deleted. + r53Client := route53.NewFromConfig(cfg) + zoneName := b.projectZoneName(projectName) + zones, err := aws.GetHostedZonesByName(ctx, zoneName, r53Client) + if err != nil && !errors.Is(err, aws.ErrZoneNotFound) { + term.Warnf("cleanup: could not look up hosted zone %q: %v", zoneName, err) + } + for _, zone := range zones { + records, err := aws.ListAllResourceRecordSets(ctx, *zone.Id, r53Client) + if err != nil { + term.Warnf("cleanup: could not list records in zone %s: %v", zoneName, err) + continue + } + for _, rec := range records { + if isApexManagedRecord(rec, zoneName) { + continue // NS/SOA at the apex are removed automatically when the zone is deleted + } + setID := "" + if rec.SetIdentifier != nil { + setID = *rec.SetIdentifier + } + add(fmt.Sprintf("dns:%s:%s:%s:%s", *zone.Id, *rec.Name, rec.Type, setID), client.OrphanResource{ + Category: "dns", + Name: fmt.Sprintf("%s %s", rec.Type, *rec.Name), + Action: "delete DNS record so 'defang down' can delete the hosted zone", + }, orphanDetail{category: "dns", zoneID: *zone.Id, record: rec}) + } + } + + return resources, nil +} + +// CleanupOrphan performs the minimum action needed to unblock Pulumi cleanup of the given +// resource. It must be passed a resource from the most recent DiscoverOrphans call. +func (b *ByocAws) CleanupOrphan(ctx context.Context, r client.OrphanResource) error { + detail, ok := b.orphans[r.ID] + if !ok { + return fmt.Errorf("unknown resource %q; run discovery again before cleaning up", r.ID) + } + cfg, err := b.driver.LoadConfig(ctx) + if err != nil { + return AnnotateAwsError(err) + } + + switch detail.category { + case "alb": + return AnnotateAwsError(aws.SetALBDeletionProtection(ctx, detail.lbArn, false, elbv2.NewFromConfig(cfg))) + case "rds": + return AnnotateAwsError(aws.SetDBInstanceDeletionProtection(ctx, detail.dbID, false, rds.NewFromConfig(cfg))) + case "ecr": + ecrClient := ecr.NewFromConfig(cfg) + ids, err := aws.ListImageIDs(ctx, detail.repoName, ecrClient) + if err != nil { + return AnnotateAwsError(err) + } + return AnnotateAwsError(aws.DeleteImages(ctx, detail.repoName, ids, ecrClient)) + case "dns": + return AnnotateAwsError(aws.DeleteResourceRecordSet(ctx, detail.zoneID, detail.record, route53.NewFromConfig(cfg))) + default: + return fmt.Errorf("unsupported orphan category %q", detail.category) + } +} + +func isApexManagedRecord(rec r53types.ResourceRecordSet, zoneName string) bool { + if rec.Type != r53types.RRTypeNs && rec.Type != r53types.RRTypeSoa { + return false + } + return rec.Name != nil && dns.Normalize(*rec.Name) == dns.Normalize(zoneName) +} diff --git a/src/pkg/cli/client/byoc/aws/domain_test.go b/src/pkg/cli/client/byoc/aws/domain_test.go index 3f404b07a..fa2e818e5 100644 --- a/src/pkg/cli/client/byoc/aws/domain_test.go +++ b/src/pkg/cli/client/byoc/aws/domain_test.go @@ -37,6 +37,11 @@ func (r r53Mock) DeleteReusableDelegationSet(ctx context.Context, params *route5 return nil, nil } +func (r r53Mock) ChangeResourceRecordSets(ctx context.Context, params *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { + // TODO: implement if needed + return nil, nil +} + func (r r53Mock) ListTagsForResource(ctx context.Context, params *route53.ListTagsForResourceInput, optFns ...func(*route53.Options)) (*route53.ListTagsForResourceOutput, error) { switch params.ResourceType { case types.TagResourceTypeHostedzone: diff --git a/src/pkg/cli/client/cleanup.go b/src/pkg/cli/client/cleanup.go new file mode 100644 index 000000000..7c5b66c6b --- /dev/null +++ b/src/pkg/cli/client/cleanup.go @@ -0,0 +1,24 @@ +package client + +import "context" + +// OrphanResource is a cloud resource left behind after `defang down` that blocks Pulumi from +// finishing cleanup on a subsequent run. The remediation (Action) is the minimum needed to +// unblock Pulumi, not a direct delete of the resource itself. +type OrphanResource struct { + ID string // opaque handle, stable within a single DiscoverOrphans call + Category string // e.g. "alb", "rds", "dns", "ecr" + Name string // human-readable resource name + Action string // what CleanupOrphan will do, e.g. "disable deletion protection" +} + +// OrphanCleaner is implemented by providers that can find and unblock resources retained after +// `defang down`. It is an optional capability (currently AWS only), so callers type-assert the +// provider to this interface rather than it being part of the core Provider interface. +// +// CleanupOrphan must be called with a resource returned by the most recent DiscoverOrphans call +// on the same provider instance. +type OrphanCleaner interface { + DiscoverOrphans(ctx context.Context, projectName string) ([]OrphanResource, error) + CleanupOrphan(ctx context.Context, r OrphanResource) error +} diff --git a/src/pkg/cli/stacks.go b/src/pkg/cli/stacks.go index ca9dbd924..00967c1c9 100644 --- a/src/pkg/cli/stacks.go +++ b/src/pkg/cli/stacks.go @@ -129,7 +129,7 @@ Are you sure you want to delete it?`, answer, err := ec.RequestEnum(ctx, prompt, "confirm", - []string{"yes", "no"}, + []string{"no", "yes"}, ) if err != nil { return false, err diff --git a/src/pkg/cli/stacks_test.go b/src/pkg/cli/stacks_test.go index a4afbe513..9e27af1b7 100644 --- a/src/pkg/cli/stacks_test.go +++ b/src/pkg/cli/stacks_test.go @@ -257,7 +257,7 @@ func TestRemoveStack(t *testing.T) { remover := &mockStacksRemover{} ec := &mockElicitationsController{} ec.On("IsSupported").Return(true) - ec.On("RequestEnum", ctx, mock.AnythingOfType("string"), "confirm", []string{"yes", "no"}).Return("no", nil) + ec.On("RequestEnum", ctx, mock.AnythingOfType("string"), "confirm", []string{"no", "yes"}).Return("no", nil) err := RemoveStack(ctx, remover, activeDeployment, ec, "my-project", "mystack", false) assert.ErrorContains(t, err, "cancelled") @@ -273,7 +273,7 @@ func TestRemoveStack(t *testing.T) { remover := &mockStacksRemover{} ec := &mockElicitationsController{} ec.On("IsSupported").Return(true) - ec.On("RequestEnum", ctx, mock.AnythingOfType("string"), "confirm", []string{"yes", "no"}).Return("yes", nil) + ec.On("RequestEnum", ctx, mock.AnythingOfType("string"), "confirm", []string{"no", "yes"}).Return("yes", nil) remover.On("DeleteStack", ctx, mock.AnythingOfType("*defangv1.DeleteStackRequest")).Return(nil) err = RemoveStack(ctx, remover, activeDeployment, ec, "my-project", "mystack", false) diff --git a/src/pkg/clouds/aws/ecr.go b/src/pkg/clouds/aws/ecr.go new file mode 100644 index 000000000..cfd83a3bb --- /dev/null +++ b/src/pkg/clouds/aws/ecr.go @@ -0,0 +1,69 @@ +package aws + +import ( + "context" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/ecr" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" +) + +type ECRAPI interface { + DescribeRepositories(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) + ListImages(ctx context.Context, params *ecr.ListImagesInput, optFns ...func(*ecr.Options)) (*ecr.ListImagesOutput, error) + BatchDeleteImage(ctx context.Context, params *ecr.BatchDeleteImageInput, optFns ...func(*ecr.Options)) (*ecr.BatchDeleteImageOutput, error) +} + +// FindRepositoriesByPrefix returns the ECR repositories whose name starts with prefix. +func FindRepositoriesByPrefix(ctx context.Context, prefix string, svc ECRAPI) ([]ecrtypes.Repository, error) { + var found []ecrtypes.Repository + var token *string + for { + out, err := svc.DescribeRepositories(ctx, &ecr.DescribeRepositoriesInput{NextToken: token}) + if err != nil { + return nil, err + } + for _, repo := range out.Repositories { + if repo.RepositoryName != nil && strings.HasPrefix(*repo.RepositoryName, prefix) { + found = append(found, repo) + } + } + if out.NextToken == nil { + return found, nil + } + token = out.NextToken + } +} + +// ListImageIDs returns the identifiers of every image in the repository. +func ListImageIDs(ctx context.Context, repoName string, svc ECRAPI) ([]ecrtypes.ImageIdentifier, error) { + var ids []ecrtypes.ImageIdentifier + var token *string + for { + out, err := svc.ListImages(ctx, &ecr.ListImagesInput{RepositoryName: &repoName, NextToken: token}) + if err != nil { + return nil, err + } + ids = append(ids, out.ImageIds...) + if out.NextToken == nil { + return ids, nil + } + token = out.NextToken + } +} + +// DeleteImages removes the given images from the repository, batching the calls (BatchDeleteImage +// accepts at most 100 image IDs). Emptying the repository lets a subsequent `defang down` (Pulumi) +// delete it, which otherwise fails with RepositoryNotEmptyException. +func DeleteImages(ctx context.Context, repoName string, ids []ecrtypes.ImageIdentifier, svc ECRAPI) error { + for start := 0; start < len(ids); start += 100 { + end := min(start+100, len(ids)) + if _, err := svc.BatchDeleteImage(ctx, &ecr.BatchDeleteImageInput{ + RepositoryName: &repoName, + ImageIds: ids[start:end], + }); err != nil { + return err + } + } + return nil +} diff --git a/src/pkg/clouds/aws/ecr_test.go b/src/pkg/clouds/aws/ecr_test.go new file mode 100644 index 000000000..31869159e --- /dev/null +++ b/src/pkg/clouds/aws/ecr_test.go @@ -0,0 +1,57 @@ +package aws + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/ecr" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/aws/smithy-go/ptr" +) + +type mockECR struct { + repos []ecrtypes.Repository + images []ecrtypes.ImageIdentifier + deletedBatch [][]ecrtypes.ImageIdentifier +} + +func (m *mockECR) DescribeRepositories(_ context.Context, _ *ecr.DescribeRepositoriesInput, _ ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { + return &ecr.DescribeRepositoriesOutput{Repositories: m.repos}, nil +} + +func (m *mockECR) ListImages(_ context.Context, _ *ecr.ListImagesInput, _ ...func(*ecr.Options)) (*ecr.ListImagesOutput, error) { + return &ecr.ListImagesOutput{ImageIds: m.images}, nil +} + +func (m *mockECR) BatchDeleteImage(_ context.Context, in *ecr.BatchDeleteImageInput, _ ...func(*ecr.Options)) (*ecr.BatchDeleteImageOutput, error) { + m.deletedBatch = append(m.deletedBatch, in.ImageIds) + return &ecr.BatchDeleteImageOutput{}, nil +} + +func TestFindRepositoriesByPrefix(t *testing.T) { + svc := &mockECR{repos: []ecrtypes.Repository{ + {RepositoryName: ptr.String("portal-production/kaniko-build")}, + {RepositoryName: ptr.String("other-project/build")}, + }} + found, err := FindRepositoriesByPrefix(t.Context(), "portal-production/", svc) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || *found[0].RepositoryName != "portal-production/kaniko-build" { + t.Fatalf("expected only the matching repo, got %+v", found) + } +} + +func TestDeleteImagesBatches(t *testing.T) { + ids := make([]ecrtypes.ImageIdentifier, 150) // exercise the 100-per-batch chunking + for i := range ids { + ids[i] = ecrtypes.ImageIdentifier{ImageDigest: ptr.String("sha256:")} + } + svc := &mockECR{} + if err := DeleteImages(t.Context(), "portal-production/kaniko-build", ids, svc); err != nil { + t.Fatal(err) + } + if len(svc.deletedBatch) != 2 || len(svc.deletedBatch[0]) != 100 || len(svc.deletedBatch[1]) != 50 { + t.Fatalf("expected batches of 100 and 50, got %v", svc.deletedBatch) + } +} diff --git a/src/pkg/clouds/aws/elbv2.go b/src/pkg/clouds/aws/elbv2.go new file mode 100644 index 000000000..51b09cd9b --- /dev/null +++ b/src/pkg/clouds/aws/elbv2.go @@ -0,0 +1,56 @@ +package aws + +import ( + "context" + "strings" + + elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" + elbv2types "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" + "github.com/aws/smithy-go/ptr" +) + +// albDeletionProtectionKey is the load balancer attribute that prevents deletion. +const albDeletionProtectionKey = "deletion_protection.enabled" + +type ELBv2API interface { + DescribeLoadBalancers(ctx context.Context, params *elbv2.DescribeLoadBalancersInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancersOutput, error) + ModifyLoadBalancerAttributes(ctx context.Context, params *elbv2.ModifyLoadBalancerAttributesInput, optFns ...func(*elbv2.Options)) (*elbv2.ModifyLoadBalancerAttributesOutput, error) +} + +// FindLoadBalancersByPrefix returns the load balancers whose name starts with prefix. +func FindLoadBalancersByPrefix(ctx context.Context, prefix string, svc ELBv2API) ([]elbv2types.LoadBalancer, error) { + var found []elbv2types.LoadBalancer + var marker *string + for { + out, err := svc.DescribeLoadBalancers(ctx, &elbv2.DescribeLoadBalancersInput{Marker: marker}) + if err != nil { + return nil, err + } + for _, lb := range out.LoadBalancers { + if lb.LoadBalancerName != nil && strings.HasPrefix(*lb.LoadBalancerName, prefix) { + found = append(found, lb) + } + } + if out.NextMarker == nil { + return found, nil + } + marker = out.NextMarker + } +} + +// SetALBDeletionProtection enables or disables deletion protection on the load balancer. Disabling +// it lets a subsequent `defang down` (Pulumi) delete the load balancer; it is idempotent, so +// callers need not check the current state first. +func SetALBDeletionProtection(ctx context.Context, lbArn string, enabled bool, svc ELBv2API) error { + value := "false" + if enabled { + value = "true" + } + _, err := svc.ModifyLoadBalancerAttributes(ctx, &elbv2.ModifyLoadBalancerAttributesInput{ + LoadBalancerArn: &lbArn, + Attributes: []elbv2types.LoadBalancerAttribute{ + {Key: ptr.String(albDeletionProtectionKey), Value: &value}, + }, + }) + return err +} diff --git a/src/pkg/clouds/aws/elbv2_test.go b/src/pkg/clouds/aws/elbv2_test.go new file mode 100644 index 000000000..166e68c26 --- /dev/null +++ b/src/pkg/clouds/aws/elbv2_test.go @@ -0,0 +1,60 @@ +package aws + +import ( + "context" + "testing" + + elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" + elbv2types "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" + "github.com/aws/smithy-go/ptr" +) + +type mockELBv2 struct { + pages [][]elbv2types.LoadBalancer + modifiedAttrs []elbv2types.LoadBalancerAttribute +} + +func (m *mockELBv2) DescribeLoadBalancers(_ context.Context, in *elbv2.DescribeLoadBalancersInput, _ ...func(*elbv2.Options)) (*elbv2.DescribeLoadBalancersOutput, error) { + idx := 0 + if in.Marker != nil { + idx = int((*in.Marker)[0] - '0') + } + out := &elbv2.DescribeLoadBalancersOutput{LoadBalancers: m.pages[idx]} + if idx+1 < len(m.pages) { + out.NextMarker = ptr.String(string(rune('0' + idx + 1))) + } + return out, nil +} + +func (m *mockELBv2) ModifyLoadBalancerAttributes(_ context.Context, in *elbv2.ModifyLoadBalancerAttributesInput, _ ...func(*elbv2.Options)) (*elbv2.ModifyLoadBalancerAttributesOutput, error) { + m.modifiedAttrs = in.Attributes + return &elbv2.ModifyLoadBalancerAttributesOutput{}, nil +} + +func lb(name string) elbv2types.LoadBalancer { + return elbv2types.LoadBalancer{LoadBalancerName: ptr.String(name), LoadBalancerArn: ptr.String("arn:" + name)} +} + +func TestFindLoadBalancersByPrefix(t *testing.T) { + svc := &mockELBv2{pages: [][]elbv2types.LoadBalancer{ + {lb("Defang-app-beta-7d0"), lb("other-lb")}, + {lb("Defang-app-beta-abc"), lb("Defang-different-beta")}, + }} + found, err := FindLoadBalancersByPrefix(t.Context(), "Defang-app-beta", svc) + if err != nil { + t.Fatal(err) + } + if len(found) != 2 { + t.Fatalf("expected 2 matches across pages, got %d", len(found)) + } +} + +func TestSetALBDeletionProtection(t *testing.T) { + svc := &mockELBv2{} + if err := SetALBDeletionProtection(t.Context(), "arn", false, svc); err != nil { + t.Fatal(err) + } + if len(svc.modifiedAttrs) != 1 || *svc.modifiedAttrs[0].Value != "false" { + t.Fatalf("expected deletion protection set to false, got %+v", svc.modifiedAttrs) + } +} diff --git a/src/pkg/clouds/aws/rds.go b/src/pkg/clouds/aws/rds.go new file mode 100644 index 000000000..de6e17b4e --- /dev/null +++ b/src/pkg/clouds/aws/rds.go @@ -0,0 +1,48 @@ +package aws + +import ( + "context" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/rds" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + "github.com/aws/smithy-go/ptr" +) + +type RDSAPI interface { + DescribeDBInstances(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) + ModifyDBInstance(ctx context.Context, params *rds.ModifyDBInstanceInput, optFns ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) +} + +// FindDBInstancesByPrefix returns the DB instances whose identifier starts with prefix. +// Note: this covers standalone RDS instances; Aurora clusters are not handled here. +func FindDBInstancesByPrefix(ctx context.Context, prefix string, svc RDSAPI) ([]rdstypes.DBInstance, error) { + var found []rdstypes.DBInstance + var marker *string + for { + out, err := svc.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{Marker: marker}) + if err != nil { + return nil, err + } + for _, inst := range out.DBInstances { + if inst.DBInstanceIdentifier != nil && strings.HasPrefix(*inst.DBInstanceIdentifier, prefix) { + found = append(found, inst) + } + } + if out.Marker == nil { + return found, nil + } + marker = out.Marker + } +} + +// SetDBInstanceDeletionProtection enables or disables deletion protection on the DB instance. +// Disabling it lets a subsequent `defang down` (Pulumi) delete the instance. +func SetDBInstanceDeletionProtection(ctx context.Context, id string, enabled bool, svc RDSAPI) error { + _, err := svc.ModifyDBInstance(ctx, &rds.ModifyDBInstanceInput{ + DBInstanceIdentifier: &id, + DeletionProtection: ptr.Bool(enabled), + ApplyImmediately: ptr.Bool(true), + }) + return err +} diff --git a/src/pkg/clouds/aws/rds_test.go b/src/pkg/clouds/aws/rds_test.go new file mode 100644 index 000000000..df01bdd05 --- /dev/null +++ b/src/pkg/clouds/aws/rds_test.go @@ -0,0 +1,48 @@ +package aws + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/rds" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + "github.com/aws/smithy-go/ptr" +) + +type mockRDS struct { + instances []rdstypes.DBInstance + modifyInput *rds.ModifyDBInstanceInput +} + +func (m *mockRDS) DescribeDBInstances(_ context.Context, _ *rds.DescribeDBInstancesInput, _ ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) { + return &rds.DescribeDBInstancesOutput{DBInstances: m.instances}, nil +} + +func (m *mockRDS) ModifyDBInstance(_ context.Context, in *rds.ModifyDBInstanceInput, _ ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) { + m.modifyInput = in + return &rds.ModifyDBInstanceOutput{}, nil +} + +func TestFindDBInstancesByPrefix(t *testing.T) { + svc := &mockRDS{instances: []rdstypes.DBInstance{ + {DBInstanceIdentifier: ptr.String("defang-app-beta-db")}, + {DBInstanceIdentifier: ptr.String("unrelated-db")}, + }} + found, err := FindDBInstancesByPrefix(t.Context(), "defang-app-beta", svc) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || *found[0].DBInstanceIdentifier != "defang-app-beta-db" { + t.Fatalf("expected only the matching instance, got %+v", found) + } +} + +func TestSetDBInstanceDeletionProtection(t *testing.T) { + svc := &mockRDS{} + if err := SetDBInstanceDeletionProtection(t.Context(), "db", false, svc); err != nil { + t.Fatal(err) + } + if svc.modifyInput == nil || *svc.modifyInput.DeletionProtection { + t.Fatalf("expected deletion protection disabled, got %+v", svc.modifyInput) + } +} diff --git a/src/pkg/clouds/aws/route53.go b/src/pkg/clouds/aws/route53.go index ae47ed471..0d04c4ad3 100644 --- a/src/pkg/clouds/aws/route53.go +++ b/src/pkg/clouds/aws/route53.go @@ -30,6 +30,7 @@ type Route53API interface { ListHostedZonesByName(ctx context.Context, params *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) ListResourceRecordSets(ctx context.Context, params *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) ListTagsForResource(ctx context.Context, params *route53.ListTagsForResourceInput, optFns ...func(*route53.Options)) (*route53.ListTagsForResourceOutput, error) + ChangeResourceRecordSets(ctx context.Context, params *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) } func CreateDelegationSet(ctx context.Context, zoneId *string, r53 Route53API) (*types.DelegationSet, error) { @@ -182,6 +183,39 @@ func ListResourceRecords(ctx context.Context, zoneId, recordName string, recordT return values, nil } +// ListAllResourceRecordSets returns every record set in the hosted zone, paginating as needed. +func ListAllResourceRecordSets(ctx context.Context, zoneId string, r53 Route53API) ([]types.ResourceRecordSet, error) { + var records []types.ResourceRecordSet + input := &route53.ListResourceRecordSetsInput{HostedZoneId: ptr.String(zoneId)} + for { + resp, err := r53.ListResourceRecordSets(ctx, input) + if err != nil { + return nil, err + } + records = append(records, resp.ResourceRecordSets...) + if !resp.IsTruncated { + return records, nil + } + input.StartRecordName = resp.NextRecordName + input.StartRecordType = resp.NextRecordType + input.StartRecordIdentifier = resp.NextRecordIdentifier + } +} + +// DeleteResourceRecordSet removes a single record set from the hosted zone. +func DeleteResourceRecordSet(ctx context.Context, zoneId string, record types.ResourceRecordSet, r53 Route53API) error { + _, err := r53.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ + HostedZoneId: ptr.String(strings.TrimPrefix(zoneId, "/hostedzone/")), + ChangeBatch: &types.ChangeBatch{ + Changes: []types.Change{{ + Action: types.ChangeActionDelete, + ResourceRecordSet: &record, + }}, + }, + }) + return err +} + func GetHostedZoneTags(ctx context.Context, zoneId string, r53 Route53API) (map[string]string, error) { zoneId = strings.TrimPrefix(zoneId, "/hostedzone/") listResp, err := r53.ListTagsForResource(ctx, &route53.ListTagsForResourceInput{ diff --git a/src/pkg/clouds/aws/route53_cleanup_test.go b/src/pkg/clouds/aws/route53_cleanup_test.go new file mode 100644 index 000000000..645d4fd89 --- /dev/null +++ b/src/pkg/clouds/aws/route53_cleanup_test.go @@ -0,0 +1,67 @@ +package aws + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/route53" + "github.com/aws/aws-sdk-go-v2/service/route53/types" + "github.com/aws/smithy-go/ptr" +) + +// fakeR53 embeds Route53API and implements only the methods exercised by cleanup. +type fakeR53 struct { + Route53API + pages [][]types.ResourceRecordSet + deleted []types.ResourceRecordSet +} + +func (f *fakeR53) ListResourceRecordSets(_ context.Context, in *route53.ListResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { + idx := 0 + if in.StartRecordName != nil { + idx = int((*in.StartRecordName)[0] - '0') + } + out := &route53.ListResourceRecordSetsOutput{ResourceRecordSets: f.pages[idx]} + if idx+1 < len(f.pages) { + out.IsTruncated = true + out.NextRecordName = ptr.String(string(rune('0' + idx + 1))) + out.NextRecordType = types.RRTypeA + } + return out, nil +} + +func (f *fakeR53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { + for _, c := range in.ChangeBatch.Changes { + f.deleted = append(f.deleted, *c.ResourceRecordSet) + } + return &route53.ChangeResourceRecordSetsOutput{}, nil +} + +func rrset(name string, t types.RRType) types.ResourceRecordSet { + return types.ResourceRecordSet{Name: ptr.String(name), Type: t} +} + +func TestListAllResourceRecordSetsPaginates(t *testing.T) { + f := &fakeR53{pages: [][]types.ResourceRecordSet{ + {rrset("a.example.com.", types.RRTypeA)}, + {rrset("b.example.com.", types.RRTypeCname)}, + }} + records, err := ListAllResourceRecordSets(t.Context(), "Z123", f) + if err != nil { + t.Fatal(err) + } + if len(records) != 2 { + t.Fatalf("expected 2 records across pages, got %d", len(records)) + } +} + +func TestDeleteResourceRecordSet(t *testing.T) { + f := &fakeR53{} + rec := rrset("app.example.com.", types.RRTypeA) + if err := DeleteResourceRecordSet(t.Context(), "/hostedzone/Z123", rec, f); err != nil { + t.Fatal(err) + } + if len(f.deleted) != 1 || *f.deleted[0].Name != "app.example.com." { + t.Fatalf("expected the record to be deleted, got %+v", f.deleted) + } +} diff --git a/src/pkg/debug/debug.go b/src/pkg/debug/debug.go index a1f835b86..81961ebf0 100644 --- a/src/pkg/debug/debug.go +++ b/src/pkg/debug/debug.go @@ -74,6 +74,9 @@ type DebugAgent interface { type Debugger struct { agent DebugAgent surveyor Surveyor + // defaultPermission is the default answer to the "debug with AI?" prompt. Paid accounts + // default to yes so they flow into the agent (and its cleanup tooling) seamlessly. + defaultPermission bool } func NewDebugger(ctx context.Context, fabricAddr string, stack *stacks.Parameters) (*Debugger, error) { @@ -82,11 +85,25 @@ func NewDebugger(ctx context.Context, fabricAddr string, stack *stacks.Parameter return nil, err } return &Debugger{ - agent: agent, - surveyor: &surveyor{}, + agent: agent, + surveyor: &surveyor{}, + defaultPermission: isPaidAccount(ctx, fabricAddr), }, nil } +// isPaidAccount reports whether the signed-in account is on a paid plan. Any error falls back to +// false so the prompt stays opt-in. +func isPaidAccount(ctx context.Context, fabricAddr string) bool { + host := client.NormalizeHost(fabricAddr) + fabricClient := client.NewGrpcClient(host, client.GetExistingToken(host), "") + resp, err := fabricClient.WhoAmI(ctx) + if err != nil { + term.Debug("Could not determine subscription tier for debug prompt default:", err) + return false + } + return pkg.IsPaidTier(resp.Tier) +} + func (d *Debugger) DebugDeployment(ctx context.Context, debugConfig DebugConfig) error { if debugConfig.Deployment == "" { return errors.New("no information to use for debugger") @@ -142,6 +159,7 @@ func (d *Debugger) promptForPermission() (bool, error) { var aiDebug bool err := d.surveyor.AskOne(&survey.Confirm{ Message: "Would you like to debug this with the Defang AI Agent?", + Default: d.defaultPermission, Help: "This will send logs and artifacts to our backend and attempt to diagnose the issue and provide a solution.", }, &aiDebug, survey.WithStdio(term.DefaultTerm.Stdio())) if err != nil { diff --git a/src/pkg/utils.go b/src/pkg/utils.go index 8c8926f75..d02a3c8cc 100644 --- a/src/pkg/utils.go +++ b/src/pkg/utils.go @@ -146,6 +146,16 @@ func SubscriptionTierToString(tier defangv1.SubscriptionTier) string { } } +// IsPaidTier reports whether the subscription tier is a paid plan (Personal, Pro, or Team). +func IsPaidTier(tier defangv1.SubscriptionTier) bool { + switch tier { + case defangv1.SubscriptionTier_PERSONAL, defangv1.SubscriptionTier_PRO, defangv1.SubscriptionTier_TEAM: + return true + default: + return false + } +} + func Ensure(cond bool, msg string) { if !cond { panic(msg) From 7624c7ef02586160961217dacf20cec4b380bebf Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Tue, 30 Jun 2026 12:52:07 -0700 Subject: [PATCH 2/5] fix: clean up records on internal zones --- src/pkg/cli/client/byoc/aws/cleanup.go | 58 +++++++++++++++----------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/src/pkg/cli/client/byoc/aws/cleanup.go b/src/pkg/cli/client/byoc/aws/cleanup.go index 108b740af..feb2df7f4 100644 --- a/src/pkg/cli/client/byoc/aws/cleanup.go +++ b/src/pkg/cli/client/byoc/aws/cleanup.go @@ -34,13 +34,15 @@ type orphanDetail struct { } // resourceBaseName returns the dash-joined {Prefix}-{project}-{stack} base that Defang/Pulumi use -// to name ALBs, RDS instances and similar resources (see alb_logs.go). +// to name resources (e.g. the ECS cluster "Defang---cluster" and task-definition +// families). Case is preserved; callers that match case-insensitive resources (ALB, RDS) lowercase +// it themselves. func (b *ByocAws) resourceBaseName(projectName string) string { base := projectName + "-" + b.PulumiStack if b.Prefix != "" { base = b.Prefix + "-" + base } - return strings.ToLower(base) + return base } // projectZoneName returns the Defang-managed hosted zone for the project's public services @@ -68,9 +70,10 @@ func (b *ByocAws) DiscoverOrphans(ctx context.Context, projectName string) ([]cl } base := b.resourceBaseName(projectName) + lowerBase := strings.ToLower(base) // ALB/RDS names are lowercased // ALBs: any leftover load balancer is unblocked by disabling deletion protection (idempotent). - albPrefix := base + albPrefix := lowerBase if len(albPrefix) > maxALBNameLen { albPrefix = albPrefix[:maxALBNameLen] } @@ -89,7 +92,7 @@ func (b *ByocAws) DiscoverOrphans(ctx context.Context, projectName string) ([]cl // RDS: same as ALBs; disabling deletion protection is idempotent. rdsClient := rds.NewFromConfig(cfg) - if insts, err := aws.FindDBInstancesByPrefix(ctx, base, rdsClient); err != nil { + if insts, err := aws.FindDBInstancesByPrefix(ctx, lowerBase, rdsClient); err != nil { term.Warnf("cleanup: could not list RDS instances: %v", err) } else { for _, inst := range insts { @@ -125,32 +128,39 @@ func (b *ByocAws) DiscoverOrphans(ctx context.Context, projectName string) ([]cl } } - // Route53: records left in the project's hosted zone block the zone from being deleted. + + // Route53: records left in the project's hosted zones block those zones from being deleted. + // Both the public delegated subdomain zone and the private ".internal" service- + // discovery zone are managed by Defang and need their leftover records removed. r53Client := route53.NewFromConfig(cfg) - zoneName := b.projectZoneName(projectName) - zones, err := aws.GetHostedZonesByName(ctx, zoneName, r53Client) - if err != nil && !errors.Is(err, aws.ErrZoneNotFound) { - term.Warnf("cleanup: could not look up hosted zone %q: %v", zoneName, err) - } - for _, zone := range zones { - records, err := aws.ListAllResourceRecordSets(ctx, *zone.Id, r53Client) + for _, zoneName := range []string{b.projectZoneName(projectName), b.GetPrivateDomain(projectName)} { + zones, err := aws.GetHostedZonesByName(ctx, zoneName, r53Client) if err != nil { - term.Warnf("cleanup: could not list records in zone %s: %v", zoneName, err) + if !errors.Is(err, aws.ErrZoneNotFound) { + term.Warnf("cleanup: could not look up hosted zone %q: %v", zoneName, err) + } continue } - for _, rec := range records { - if isApexManagedRecord(rec, zoneName) { - continue // NS/SOA at the apex are removed automatically when the zone is deleted + for _, zone := range zones { + records, err := aws.ListAllResourceRecordSets(ctx, *zone.Id, r53Client) + if err != nil { + term.Warnf("cleanup: could not list records in zone %s: %v", zoneName, err) + continue } - setID := "" - if rec.SetIdentifier != nil { - setID = *rec.SetIdentifier + for _, rec := range records { + if isApexManagedRecord(rec, zoneName) { + continue // NS/SOA at the apex are removed automatically when the zone is deleted + } + setID := "" + if rec.SetIdentifier != nil { + setID = *rec.SetIdentifier + } + add(fmt.Sprintf("dns:%s:%s:%s:%s", *zone.Id, *rec.Name, rec.Type, setID), client.OrphanResource{ + Category: "dns", + Name: fmt.Sprintf("%s %s (%s)", rec.Type, *rec.Name, zoneName), + Action: "delete DNS record so 'defang down' can delete the hosted zone", + }, orphanDetail{category: "dns", zoneID: *zone.Id, record: rec}) } - add(fmt.Sprintf("dns:%s:%s:%s:%s", *zone.Id, *rec.Name, rec.Type, setID), client.OrphanResource{ - Category: "dns", - Name: fmt.Sprintf("%s %s", rec.Type, *rec.Name), - Action: "delete DNS record so 'defang down' can delete the hosted zone", - }, orphanDetail{category: "dns", zoneID: *zone.Id, record: rec}) } } From 5df66072ca989942e02524c77347443bc16af397 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 30 Jun 2026 19:59:46 +0000 Subject: [PATCH 3/5] Update Nix vendorHash to sha256-eNm2ChvQ10xAYPJ6jN+Cm4CCnZfqk2e2MFGQ1Uj6T3w= --- pkgs/defang/cli.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 3f49b62ad..af9c853f6 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo125Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-qNsk3rEco0mzBIPodsp3GZpzgaIzthAPSIMmVCja50I="; + vendorHash = "sha256-eNm2ChvQ10xAYPJ6jN+Cm4CCnZfqk2e2MFGQ1Uj6T3w="; subPackages = [ "cmd/cli" ]; From e2ac8d6457886a621483a76416d493b3666de62d Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Tue, 30 Jun 2026 14:05:36 -0700 Subject: [PATCH 4/5] feat: switch AI debugger to default true --- src/cmd/cli/command/compose.go | 1 + src/pkg/cli/client/byoc/aws/cleanup.go | 1 - src/pkg/debug/debug.go | 25 +++++-------------------- src/pkg/utils.go | 10 ---------- 4 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index d700e8605..261df45a7 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -484,6 +484,7 @@ func makeComposeDownCmd() *cobra.Command { } // A failed destroy (e.g. CodeBuild exit status) is when resources get orphaned, so prompt // the AI debugger just like `up` does; it can guide the user through cleanup. + // handleTailAndMonitorErr skips the prompt in non-interactive mode. deploymentErr := err debugger, dbgErr := debug.NewDebugger(cmd.Context(), global.FabricAddr, session.Stack) if dbgErr != nil { diff --git a/src/pkg/cli/client/byoc/aws/cleanup.go b/src/pkg/cli/client/byoc/aws/cleanup.go index feb2df7f4..02cd6c7c4 100644 --- a/src/pkg/cli/client/byoc/aws/cleanup.go +++ b/src/pkg/cli/client/byoc/aws/cleanup.go @@ -128,7 +128,6 @@ func (b *ByocAws) DiscoverOrphans(ctx context.Context, projectName string) ([]cl } } - // Route53: records left in the project's hosted zones block those zones from being deleted. // Both the public delegated subdomain zone and the private ".internal" service- // discovery zone are managed by Defang and need their leftover records removed. diff --git a/src/pkg/debug/debug.go b/src/pkg/debug/debug.go index 81961ebf0..bf257adaf 100644 --- a/src/pkg/debug/debug.go +++ b/src/pkg/debug/debug.go @@ -74,9 +74,6 @@ type DebugAgent interface { type Debugger struct { agent DebugAgent surveyor Surveyor - // defaultPermission is the default answer to the "debug with AI?" prompt. Paid accounts - // default to yes so they flow into the agent (and its cleanup tooling) seamlessly. - defaultPermission bool } func NewDebugger(ctx context.Context, fabricAddr string, stack *stacks.Parameters) (*Debugger, error) { @@ -85,25 +82,11 @@ func NewDebugger(ctx context.Context, fabricAddr string, stack *stacks.Parameter return nil, err } return &Debugger{ - agent: agent, - surveyor: &surveyor{}, - defaultPermission: isPaidAccount(ctx, fabricAddr), + agent: agent, + surveyor: &surveyor{}, }, nil } -// isPaidAccount reports whether the signed-in account is on a paid plan. Any error falls back to -// false so the prompt stays opt-in. -func isPaidAccount(ctx context.Context, fabricAddr string) bool { - host := client.NormalizeHost(fabricAddr) - fabricClient := client.NewGrpcClient(host, client.GetExistingToken(host), "") - resp, err := fabricClient.WhoAmI(ctx) - if err != nil { - term.Debug("Could not determine subscription tier for debug prompt default:", err) - return false - } - return pkg.IsPaidTier(resp.Tier) -} - func (d *Debugger) DebugDeployment(ctx context.Context, debugConfig DebugConfig) error { if debugConfig.Deployment == "" { return errors.New("no information to use for debugger") @@ -159,7 +142,9 @@ func (d *Debugger) promptForPermission() (bool, error) { var aiDebug bool err := d.surveyor.AskOne(&survey.Confirm{ Message: "Would you like to debug this with the Defang AI Agent?", - Default: d.defaultPermission, + // Default to Yes for everyone; the server selects an appropriate model per account, so + // there is no need to gate the prompt client-side. + Default: true, Help: "This will send logs and artifacts to our backend and attempt to diagnose the issue and provide a solution.", }, &aiDebug, survey.WithStdio(term.DefaultTerm.Stdio())) if err != nil { diff --git a/src/pkg/utils.go b/src/pkg/utils.go index d02a3c8cc..8c8926f75 100644 --- a/src/pkg/utils.go +++ b/src/pkg/utils.go @@ -146,16 +146,6 @@ func SubscriptionTierToString(tier defangv1.SubscriptionTier) string { } } -// IsPaidTier reports whether the subscription tier is a paid plan (Personal, Pro, or Team). -func IsPaidTier(tier defangv1.SubscriptionTier) bool { - switch tier { - case defangv1.SubscriptionTier_PERSONAL, defangv1.SubscriptionTier_PRO, defangv1.SubscriptionTier_TEAM: - return true - default: - return false - } -} - func Ensure(cond bool, msg string) { if !cond { panic(msg) From b5c9e075ba694de9f5f8586d30275f691bfbd810 Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Tue, 30 Jun 2026 14:17:43 -0700 Subject: [PATCH 5/5] refactor(debug): update feedback prompt to capture detailed responses --- src/cmd/cli/command/compose.go | 2 +- src/pkg/debug/debug.go | 16 ++++++++-------- src/pkg/debug/debug_test.go | 6 ++---- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 261df45a7..d69e3d4c3 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -307,7 +307,7 @@ func promptToCreateStack(ctx context.Context, targetDirectory string, params sta func handleComposeUpErr(ctx context.Context, debugger *debug.Debugger, project *compose.Project, provider client.Provider, originalErr error) error { if errors.Is(originalErr, types.ErrComposeFileNotFound) { - // TODO: generate a compose file based on the current project + // TODO: suggest to generate a compose file based on the current project printDefangHint("To start a new project, do:", "new") } diff --git a/src/pkg/debug/debug.go b/src/pkg/debug/debug.go index bf257adaf..01a4c5bbb 100644 --- a/src/pkg/debug/debug.go +++ b/src/pkg/debug/debug.go @@ -129,12 +129,12 @@ func (d *Debugger) promptAndTrackDebugSession(fn func() error, eventName string, return err } - good, err := d.promptForFeedback() + feedback, err := d.promptForFeedback() if err != nil { track.Evt(eventName+" Feedback Prompt Failed", append([]track.Property{P("reason", err)}, eventProperty...)...) return err } - track.Evt(eventName+" Feedback Prompt Answered", append([]track.Property{P("feedback", good)}, eventProperty...)...) + track.Evt(eventName+" Feedback Prompt Answered", append([]track.Property{P("feedback", feedback)}, eventProperty...)...) return nil } @@ -154,17 +154,17 @@ func (d *Debugger) promptForPermission() (bool, error) { return aiDebug, err } -func (d *Debugger) promptForFeedback() (bool, error) { - var good bool - err := d.surveyor.AskOne(&survey.Confirm{ +func (d *Debugger) promptForFeedback() (string, error) { + var feedback string + err := d.surveyor.AskOne(&survey.Input{ Message: "Was the debugging helpful?", Help: "Please provide feedback to help us improve the debugging experience.", - }, &good, survey.WithStdio(term.DefaultTerm.Stdio())) + }, &feedback, survey.WithStdio(term.DefaultTerm.Stdio())) if err != nil { - return false, err + return "", err } - return good, err + return feedback, err } func buildDeploymentDebugPrompt(debugConfig DebugConfig) string { diff --git a/src/pkg/debug/debug_test.go b/src/pkg/debug/debug_test.go index 9abf216cf..4ab672141 100644 --- a/src/pkg/debug/debug_test.go +++ b/src/pkg/debug/debug_test.go @@ -29,11 +29,9 @@ type mockSurveyor struct { } func (s *mockSurveyor) AskOne(q survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - b, ok := response.(*bool) - if !ok { - panic("response must be a *bool for this mock") + if boolptr, ok := response.(*bool); ok { + *boolptr = s.response } - *b = s.response return nil }