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" ]; diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index bae0186a5..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") } @@ -482,7 +482,23 @@ 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. + // handleTailAndMonitorErr skips the prompt in non-interactive mode. + 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..02cd6c7c4 --- /dev/null +++ b/src/pkg/cli/client/byoc/aws/cleanup.go @@ -0,0 +1,205 @@ +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 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 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) + lowerBase := strings.ToLower(base) // ALB/RDS names are lowercased + + // ALBs: any leftover load balancer is unblocked by disabling deletion protection (idempotent). + albPrefix := lowerBase + 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, lowerBase, 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 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) + for _, zoneName := range []string{b.projectZoneName(projectName), b.GetPrivateDomain(projectName)} { + zones, err := aws.GetHostedZonesByName(ctx, zoneName, r53Client) + if err != nil { + if !errors.Is(err, aws.ErrZoneNotFound) { + term.Warnf("cleanup: could not look up hosted zone %q: %v", zoneName, err) + } + continue + } + 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 (%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}) + } + } + } + + 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..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 } @@ -142,6 +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 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 { @@ -151,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 }