From 5331e5d195f4fe5b7334e34f9ff54d9586ed578c Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 9 Feb 2026 13:12:34 -0800 Subject: [PATCH 01/11] improve cd teardown error handling * only print suggestion to use `--force` if there are existing projects * separate hint from error * print the names of the existing projects and stacks --- src/cmd/cli/command/cd.go | 6 +++++- src/pkg/cli/teardown_cd.go | 23 +++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/cmd/cli/command/cd.go b/src/cmd/cli/command/cd.go index b9b1f942f..fc5b69a72 100644 --- a/src/cmd/cli/command/cd.go +++ b/src/cmd/cli/command/cd.go @@ -108,7 +108,11 @@ var cdTearDownCmd = &cobra.Command{ return err } - return cli.TearDownCD(ctx, session.Provider, force) + err = cli.TearDownCD(ctx, session.Provider, force) + if errors.Is(err, cli.ErrExistingProjects) { + printDefangHint("Use `defang cd destroy --force` to force teardown the CD cluster, leaving existing projects orphaned") + } + return err }, } diff --git a/src/pkg/cli/teardown_cd.go b/src/pkg/cli/teardown_cd.go index c02cae8cf..e69fb5dee 100644 --- a/src/pkg/cli/teardown_cd.go +++ b/src/pkg/cli/teardown_cd.go @@ -10,19 +10,30 @@ import ( "github.com/DefangLabs/defang/src/pkg/term" ) +var ErrExistingProjects = errors.New("there are still deployed projects") + func TearDownCD(ctx context.Context, provider client.Provider, force bool) error { if dryrun.DoDryRun { return errors.New("dry run") } if !force { - if list, err := provider.CdList(ctx, false); err != nil { - return fmt.Errorf("could not get list of services; use --force to tear down anyway: %w", err) - } else { - for range list { - return errors.New("there are still deployed services; use --force to tear down anyway") + list, err := provider.CdList(ctx, false) + if err != nil { + return fmt.Errorf("could not get list of projects: %w", err) + } + + found := false + for project := range list { + if !found { + term.Info("There are still deployed projects:") } + fmt.Println(project) + found = true + } + if found { + return ErrExistingProjects } } - term.Warn("Deleting the CD cluster; this does not delete services or configs!") + term.Warn("Deleting the CD cluster; this does not delete projects or configs!") return provider.TearDownCD(ctx) } From c4aaa7bdc631b8ac1265fde939654c5d99bca8de Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 9 Feb 2026 14:01:38 -0800 Subject: [PATCH 02/11] factor out byoc/state package --- src/pkg/cli/client/byoc/aws/list.go | 3 ++- src/pkg/cli/client/byoc/gcp/byoc.go | 3 ++- src/pkg/cli/client/byoc/{ => state}/parse.go | 2 +- src/pkg/cli/client/byoc/{ => state}/parse_test.go | 2 +- src/pkg/cli/client/byoc/state/state.go | 1 + src/pkg/cli/client/byoc/{ => state}/testdata/aws.json | 0 src/pkg/cli/client/byoc/{ => state}/testdata/empty.json | 0 src/pkg/cli/client/byoc/{ => state}/testdata/gcp.json | 0 src/pkg/cli/client/byoc/{ => state}/testdata/pending.json | 0 9 files changed, 7 insertions(+), 4 deletions(-) rename src/pkg/cli/client/byoc/{ => state}/parse.go (99%) rename src/pkg/cli/client/byoc/{ => state}/parse_test.go (99%) create mode 100644 src/pkg/cli/client/byoc/state/state.go rename src/pkg/cli/client/byoc/{ => state}/testdata/aws.json (100%) rename src/pkg/cli/client/byoc/{ => state}/testdata/empty.json (100%) rename src/pkg/cli/client/byoc/{ => state}/testdata/gcp.json (100%) rename src/pkg/cli/client/byoc/{ => state}/testdata/pending.json (100%) diff --git a/src/pkg/cli/client/byoc/aws/list.go b/src/pkg/cli/client/byoc/aws/list.go index 4bb18de92..99672b5d9 100644 --- a/src/pkg/cli/client/byoc/aws/list.go +++ b/src/pkg/cli/client/byoc/aws/list.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/clouds/aws" "github.com/DefangLabs/defang/src/pkg/clouds/aws/region" "github.com/DefangLabs/defang/src/pkg/term" @@ -67,7 +68,7 @@ func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) if obj.Key == nil || obj.Size == nil { continue } - stack, err := byoc.ParsePulumiStateFile(ctx, s3Obj{obj}, bucketName, func(ctx context.Context, bucket, path string) ([]byte, error) { + stack, err := state.ParsePulumiStateFile(ctx, s3Obj{obj}, bucketName, func(ctx context.Context, bucket, path string) ([]byte, error) { getObjectOutput, err := s3client.GetObject(ctx, &s3.GetObjectInput{ Bucket: &bucket, Key: &path, diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index 948401492..82ba34620 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -17,6 +17,7 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/clouds/gcp" "github.com/DefangLabs/defang/src/pkg/dns" @@ -302,7 +303,7 @@ func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[string term.Debugf("Error listing object in bucket %s: %v", bucketName, annotateGcpError(err)) continue } - stack, err := byoc.ParsePulumiStateFile(ctx, gcpObj{obj}, bucketName, objLoader) + stack, err := state.ParsePulumiStateFile(ctx, gcpObj{obj}, bucketName, objLoader) if err != nil { term.Debugf("Skipping %q in bucket %s: %v", obj.Name, bucketName, annotateGcpError(err)) continue diff --git a/src/pkg/cli/client/byoc/parse.go b/src/pkg/cli/client/byoc/state/parse.go similarity index 99% rename from src/pkg/cli/client/byoc/parse.go rename to src/pkg/cli/client/byoc/state/parse.go index db90374f0..f5bbf8032 100644 --- a/src/pkg/cli/client/byoc/parse.go +++ b/src/pkg/cli/client/byoc/state/parse.go @@ -1,4 +1,4 @@ -package byoc +package state import ( "context" diff --git a/src/pkg/cli/client/byoc/parse_test.go b/src/pkg/cli/client/byoc/state/parse_test.go similarity index 99% rename from src/pkg/cli/client/byoc/parse_test.go rename to src/pkg/cli/client/byoc/state/parse_test.go index f69475d79..f4d00556e 100644 --- a/src/pkg/cli/client/byoc/parse_test.go +++ b/src/pkg/cli/client/byoc/state/parse_test.go @@ -1,4 +1,4 @@ -package byoc +package state import ( "context" diff --git a/src/pkg/cli/client/byoc/state/state.go b/src/pkg/cli/client/byoc/state/state.go new file mode 100644 index 000000000..7bf2df5b4 --- /dev/null +++ b/src/pkg/cli/client/byoc/state/state.go @@ -0,0 +1 @@ +package state diff --git a/src/pkg/cli/client/byoc/testdata/aws.json b/src/pkg/cli/client/byoc/state/testdata/aws.json similarity index 100% rename from src/pkg/cli/client/byoc/testdata/aws.json rename to src/pkg/cli/client/byoc/state/testdata/aws.json diff --git a/src/pkg/cli/client/byoc/testdata/empty.json b/src/pkg/cli/client/byoc/state/testdata/empty.json similarity index 100% rename from src/pkg/cli/client/byoc/testdata/empty.json rename to src/pkg/cli/client/byoc/state/testdata/empty.json diff --git a/src/pkg/cli/client/byoc/testdata/gcp.json b/src/pkg/cli/client/byoc/state/testdata/gcp.json similarity index 100% rename from src/pkg/cli/client/byoc/testdata/gcp.json rename to src/pkg/cli/client/byoc/state/testdata/gcp.json diff --git a/src/pkg/cli/client/byoc/testdata/pending.json b/src/pkg/cli/client/byoc/state/testdata/pending.json similarity index 100% rename from src/pkg/cli/client/byoc/testdata/pending.json rename to src/pkg/cli/client/byoc/state/testdata/pending.json From adf4a1241b92fcd4f9fa7c378b2e05cf6e1a1413 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 9 Feb 2026 14:29:17 -0800 Subject: [PATCH 03/11] refactor cd pulumi stack listing --- src/pkg/cli/cd.go | 19 +++++------ src/pkg/cli/client/byoc/aws/byoc.go | 3 +- src/pkg/cli/client/byoc/aws/list.go | 42 +++++++++++++++++------- src/pkg/cli/client/byoc/aws/list_test.go | 2 +- src/pkg/cli/client/byoc/baseclient.go | 8 ++--- src/pkg/cli/client/byoc/do/byoc.go | 24 ++++++++++++-- src/pkg/cli/client/byoc/gcp/byoc.go | 21 ++++++++---- src/pkg/cli/client/byoc/state/state.go | 7 ++++ src/pkg/cli/client/playground.go | 3 +- src/pkg/cli/client/provider.go | 4 ++- 10 files changed, 94 insertions(+), 39 deletions(-) diff --git a/src/pkg/cli/cd.go b/src/pkg/cli/cd.go index aaee0c48a..0f9e378ac 100644 --- a/src/pkg/cli/cd.go +++ b/src/pkg/cli/cd.go @@ -10,6 +10,7 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/term" @@ -138,20 +139,17 @@ func CdListFromStorage(ctx context.Context, provider client.Provider, allRegions return dryrun.ErrDryRun } - stacks, err := provider.CdList(ctx, allRegions) + stacksIter, err := provider.CdList(ctx, allRegions) if err != nil { return err } - var count int - for stackInfo := range stacks { - count++ - if !allRegions { - stackInfo, _, _ = strings.Cut(stackInfo, " ") // remove extra info like "{workspace} [region]" - } - term.Println(" -", stackInfo) // TODO: json output mode + var stacks []state.StackInfo + for stackInfo := range stacksIter { + stacks = append(stacks, *stackInfo) } - if count == 0 { + + if len(stacks) == 0 { accountInfo, err := provider.AccountInfo(ctx) if err != nil { return err @@ -161,7 +159,8 @@ func CdListFromStorage(ctx context.Context, provider client.Provider, allRegions } term.Printf("No projects found in %v\n", accountInfo) } - return nil + + return term.Table(stacks, "Project", "Stack", "Workspace", "Region") } func GetStatesAndEventsUploadUrls(ctx context.Context, projectName string, provider client.Provider, fabric client.FabricClient) (statesUrl string, eventsUrl string, err error) { diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index 7bac2f776..f134db1f8 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -18,6 +18,7 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/clouds" "github.com/DefangLabs/defang/src/pkg/clouds/aws" @@ -869,7 +870,7 @@ func (b *ByocAws) DeleteConfig(ctx context.Context, secrets *defangv1.Secrets) e return nil } -func (b *ByocAws) CdList(ctx context.Context, allRegions bool) (iter.Seq[string], error) { +func (b *ByocAws) CdList(ctx context.Context, allRegions bool) (iter.Seq[*state.StackInfo], error) { if allRegions { s3Client, err := newS3Client(ctx, b.driver.Region) if err != nil { diff --git a/src/pkg/cli/client/byoc/aws/list.go b/src/pkg/cli/client/byoc/aws/list.go index 99672b5d9..5f909c3af 100644 --- a/src/pkg/cli/client/byoc/aws/list.go +++ b/src/pkg/cli/client/byoc/aws/list.go @@ -2,7 +2,6 @@ package aws import ( "context" - "fmt" "io" "iter" "strings" @@ -27,12 +26,31 @@ func newS3Client(ctx context.Context, region aws.Region) (*s3.Client, error) { return s3client, nil } -func listPulumiStacksInBucket(ctx context.Context, region aws.Region, bucketName string) (iter.Seq[string], error) { +func listPulumiStacksInBucket(ctx context.Context, region aws.Region, bucketName string) (iter.Seq[*state.StackInfo], error) { s3client, err := newS3Client(ctx, region) if err != nil { return nil, err } - return ListPulumiStacks(ctx, s3client, bucketName) + stacks, err := ListPulumiStacks(ctx, s3client, bucketName) + if err != nil { + return nil, err + } + return func(yield func(*state.StackInfo) bool) { + for st := range stacks { + if st == nil { + continue + } + info := &state.StackInfo{ + Project: st.Project, + Name: st.Name, + Workspace: string(st.DefangOrg), + Region: string(region), + } + if !yield(info) { + break + } + } + }, nil } type s3Obj struct{ obj s3types.Object } @@ -52,7 +70,7 @@ type S3Client interface { ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) } -func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) (iter.Seq[string], error) { +func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) (iter.Seq[*state.PulumiState], error) { prefix := `.pulumi/stacks/` // TODO: should we filter on `projectName`? term.Debug("Listing stacks in bucket:", bucketName) @@ -63,12 +81,12 @@ func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) if err != nil { return nil, AnnotateAwsError(err) } - return func(yield func(string) bool) { + return func(yield func(*state.PulumiState) bool) { for _, obj := range out.Contents { if obj.Key == nil || obj.Size == nil { continue } - stack, err := state.ParsePulumiStateFile(ctx, s3Obj{obj}, bucketName, func(ctx context.Context, bucket, path string) ([]byte, error) { + state, err := state.ParsePulumiStateFile(ctx, s3Obj{obj}, bucketName, func(ctx context.Context, bucket, path string) ([]byte, error) { getObjectOutput, err := s3client.GetObject(ctx, &s3.GetObjectInput{ Bucket: &bucket, Key: &path, @@ -82,8 +100,8 @@ func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) term.Debugf("Skipping %q in bucket %s: %v", *obj.Key, bucketName, AnnotateAwsError(err)) continue } - if stack != nil { - if !yield(stack.String()) { + if state != nil { + if !yield(state) { break } } @@ -92,7 +110,7 @@ func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) }, nil } -func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Seq[string], error) { +func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Seq[*state.StackInfo], error) { // Use a single S3 query to list all buckets with the defang-cd- prefix // This is faster than calling CloudFormation DescribeStacks in each region listBucketsOutput, err := s3client.ListBuckets(ctx, &s3.ListBucketsInput{}) @@ -100,10 +118,10 @@ func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Se return nil, AnnotateAwsError(err) } - return func(yield func(string) bool) { + return func(yield func(*state.StackInfo) bool) { ctx, cancel := context.WithCancel(ctx) defer cancel() - stackCh := make(chan string) + stackCh := make(chan *state.StackInfo) // Filter buckets by prefix and get their locations var wg sync.WaitGroup @@ -142,7 +160,7 @@ func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Se select { case <-ctx.Done(): return - case stackCh <- fmt.Sprintf("%s [%s]", stack, region): + case stackCh <- stack: } } }(bucketRegion) diff --git a/src/pkg/cli/client/byoc/aws/list_test.go b/src/pkg/cli/client/byoc/aws/list_test.go index 3a26c7086..2b74ba399 100644 --- a/src/pkg/cli/client/byoc/aws/list_test.go +++ b/src/pkg/cli/client/byoc/aws/list_test.go @@ -67,7 +67,7 @@ func TestListPulumiStacks(t *testing.T) { } count := 0 for stack := range stacks { - if stack != expectedStacks[count] { + if stack.String() != expectedStacks[count] { t.Errorf("expected stack %q, got %q", expectedStacks[count], stack) } count++ diff --git a/src/pkg/cli/client/byoc/baseclient.go b/src/pkg/cli/client/byoc/baseclient.go index d96526cda..01226ec26 100644 --- a/src/pkg/cli/client/byoc/baseclient.go +++ b/src/pkg/cli/client/byoc/baseclient.go @@ -9,6 +9,7 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/dns" "github.com/DefangLabs/defang/src/pkg/stacks" @@ -28,7 +29,7 @@ func (mp ErrMultipleProjects) Error() string { type ProjectBackend interface { CdCommand(context.Context, client.CdCommandRequest) (types.ETag, error) - CdList(context.Context, bool) (iter.Seq[string], error) + CdList(context.Context, bool) (iter.Seq[*state.StackInfo], error) GetPrivateDomain(projectName string) string GetProjectUpdate(context.Context, string) (*defangv1.ProjectUpdate, error) } @@ -101,9 +102,8 @@ func (b *ByocBaseClient) RemoteProjectName(ctx context.Context) (string, error) return "", fmt.Errorf("no cloud projects found: %w", err) } var projectNames []string - for name := range stacks { - projectName := strings.Split(name, "/")[0] // Remove the stack name - projectNames = append(projectNames, projectName) + for stack := range stacks { + projectNames = append(projectNames, stack.Project) } if len(projectNames) == 0 { diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index b0126bb2f..fad6e234d 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -22,6 +22,7 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" awsbyoc "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/aws" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/clouds/aws" "github.com/DefangLabs/defang/src/pkg/clouds/do" @@ -245,7 +246,7 @@ func (b *ByocDo) CdCommand(ctx context.Context, req client.CdCommandRequest) (st return etag, nil } -func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[string], error) { +func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state.StackInfo], error) { s3client, err := b.driver.CreateS3Client() if err != nil { return nil, err @@ -256,7 +257,26 @@ func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[string] return nil, err } - return awsbyoc.ListPulumiStacks(ctx, s3client, bucketName) + stacks, err := awsbyoc.ListPulumiStacks(ctx, s3client, bucketName) + if err != nil { + return nil, err + } + return func(yield func(*state.StackInfo) bool) { + for st := range stacks { + if st == nil { + continue + } + info := &state.StackInfo{ + Project: st.Project, + Name: st.Name, + Workspace: string(st.DefangOrg), + Region: string(b.driver.Region), + } + if !yield(info) { + break + } + } + }, nil } func (b *ByocDo) CreateUploadURL(ctx context.Context, req *defangv1.UploadURLRequest) (*defangv1.UploadURLResponse, error) { diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index 82ba34620..dc0735432 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -277,7 +277,7 @@ func (o gcpObj) Size() int64 { return o.obj.Size } -func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[string], error) { +func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state.StackInfo], error) { bucketName, err := b.driver.GetBucketWithPrefix(ctx, DefangCDProjectName) if err != nil { return nil, annotateGcpError(err) @@ -297,21 +297,28 @@ func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[string if err != nil { return nil, annotateGcpError(err) } - return func(yield func(string) bool) { + return func(yield func(*state.StackInfo) bool) { for obj, err := range seq { if err != nil { term.Debugf("Error listing object in bucket %s: %v", bucketName, annotateGcpError(err)) continue } - stack, err := state.ParsePulumiStateFile(ctx, gcpObj{obj}, bucketName, objLoader) + st, err := state.ParsePulumiStateFile(ctx, gcpObj{obj}, bucketName, objLoader) if err != nil { term.Debugf("Skipping %q in bucket %s: %v", obj.Name, bucketName, annotateGcpError(err)) continue } - if stack != nil { - if !yield(stack.String()) { - break - } + if st == nil { + continue + } + stack := &state.StackInfo{ + Name: st.Name, + Project: st.Project, + Workspace: string(st.DefangOrg), + Region: b.driver.GetRegion(), + } + if !yield(stack) { + break } } }, nil diff --git a/src/pkg/cli/client/byoc/state/state.go b/src/pkg/cli/client/byoc/state/state.go index 7bf2df5b4..d82d4e17a 100644 --- a/src/pkg/cli/client/byoc/state/state.go +++ b/src/pkg/cli/client/byoc/state/state.go @@ -1 +1,8 @@ package state + +type StackInfo struct { + Project string + Name string + Workspace string + Region string +} diff --git a/src/pkg/cli/client/playground.go b/src/pkg/cli/client/playground.go index 6c1b05190..0df9b6c44 100644 --- a/src/pkg/cli/client/playground.go +++ b/src/pkg/cli/client/playground.go @@ -7,6 +7,7 @@ import ( "iter" "os" + byocState "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/dns" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/types" @@ -115,7 +116,7 @@ func (g *PlaygroundProvider) SetUpCD(ctx context.Context) error { return errors.New("this command is not valid for the Defang playground; did you forget --stack or --provider?") } -func (g *PlaygroundProvider) CdList(context.Context, bool) (iter.Seq[string], error) { +func (g *PlaygroundProvider) CdList(context.Context, bool) (iter.Seq[*byocState.StackInfo], error) { return nil, errors.New("this command is not valid for the Defang playground; did you forget --stack or --provider?") } diff --git a/src/pkg/cli/client/provider.go b/src/pkg/cli/client/provider.go index ddea9fa6e..4a464532e 100644 --- a/src/pkg/cli/client/provider.go +++ b/src/pkg/cli/client/provider.go @@ -9,6 +9,8 @@ import ( "github.com/DefangLabs/defang/src/pkg/types" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" composeTypes "github.com/compose-spec/compose-go/v2/types" + + byocState "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" ) type DNSResolver interface { @@ -66,7 +68,7 @@ type Provider interface { DNSResolver AccountInfo(context.Context) (*AccountInfo, error) CdCommand(context.Context, CdCommandRequest) (types.ETag, error) - CdList(context.Context, bool) (iter.Seq[string], error) + CdList(context.Context, bool) (iter.Seq[*byocState.StackInfo], error) CreateUploadURL(context.Context, *defangv1.UploadURLRequest) (*defangv1.UploadURLResponse, error) DelayBeforeRetry(context.Context) error DeleteConfig(context.Context, *defangv1.Secrets) error From 62ef442b0d7dbc52061a9d207e089464d7b3961c Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 9 Feb 2026 14:39:13 -0800 Subject: [PATCH 04/11] improve cd teardown output --- src/cmd/cli/command/cd.go | 2 +- src/pkg/cli/client/byoc/aws/byoc.go | 1 + src/pkg/cli/client/byoc/do/byoc.go | 1 + src/pkg/cli/client/byoc/gcp/byoc.go | 1 + src/pkg/cli/teardown_cd.go | 34 +++++++++++++++-------------- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/cmd/cli/command/cd.go b/src/cmd/cli/command/cd.go index fc5b69a72..936fb6915 100644 --- a/src/cmd/cli/command/cd.go +++ b/src/cmd/cli/command/cd.go @@ -109,7 +109,7 @@ var cdTearDownCmd = &cobra.Command{ } err = cli.TearDownCD(ctx, session.Provider, force) - if errors.Is(err, cli.ErrExistingProjects) { + if errors.Is(err, cli.ErrExistingStacks) { printDefangHint("Use `defang cd destroy --force` to force teardown the CD cluster, leaving existing projects orphaned") } return err diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index f134db1f8..d19005202 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -834,6 +834,7 @@ func (b *ByocAws) UpdateServiceInfo(ctx context.Context, si *defangv1.ServiceInf } func (b *ByocAws) TearDownCD(ctx context.Context) error { + term.Warn("Deleting the Defang CD cluster; currently existing stacks or configs will not be deleted, but they will be orphaned and they will need to be cleaned up manually") return b.driver.TearDown(ctx) } diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index fad6e234d..2861359f9 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -481,6 +481,7 @@ func (b *ByocDo) QueryLogs(ctx context.Context, req *defangv1.TailRequest) (clie } func (b *ByocDo) TearDownCD(ctx context.Context) error { + term.Warn("Deleting the Defang CD app; currently existing stacks or configs will not be deleted, but they will be orphaned and they will need to be cleaned up manually") app, err := b.getAppByName(ctx, appPlatform.CdName) if err != nil { return err diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index dc0735432..de5c2a77e 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -794,6 +794,7 @@ func LogEntriesToString(logEntries []*loggingpb.LogEntry) string { } func (b *ByocGcp) TearDownCD(ctx context.Context) error { + // term.Warn("Deleting Defang CD; currently existing stacks or configs will not be deleted, but they will be orphaned and they will need to be cleaned up manually") // FIXME: implement return client.ErrNotImplemented("GCP TearDown") } diff --git a/src/pkg/cli/teardown_cd.go b/src/pkg/cli/teardown_cd.go index e69fb5dee..a84c979a3 100644 --- a/src/pkg/cli/teardown_cd.go +++ b/src/pkg/cli/teardown_cd.go @@ -6,34 +6,36 @@ import ( "fmt" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/term" ) -var ErrExistingProjects = errors.New("there are still deployed projects") +var ErrExistingStacks = errors.New("there are still deployed stacks") func TearDownCD(ctx context.Context, provider client.Provider, force bool) error { if dryrun.DoDryRun { return errors.New("dry run") } - if !force { - list, err := provider.CdList(ctx, false) - if err != nil { - return fmt.Errorf("could not get list of projects: %w", err) - } + list, err := provider.CdList(ctx, false) + if err != nil { + return fmt.Errorf("could not get list of deployed stacks: %w", err) + } - found := false - for project := range list { - if !found { - term.Info("There are still deployed projects:") - } - fmt.Println(project) - found = true + var stacks []state.StackInfo + for stackInfo := range list { + stacks = append(stacks, *stackInfo) + } + + if len(stacks) > 0 { + term.Info("There following stacks are currently deployed:") + for _, stack := range stacks { + term.Infof(" - (%s) %s/%s [%s]\n", stack.Workspace, stack.Project, stack.Name, stack.Region) } - if found { - return ErrExistingProjects + if !force { + return ErrExistingStacks } } - term.Warn("Deleting the CD cluster; this does not delete projects or configs!") + return provider.TearDownCD(ctx) } From 120fd024aceec69a3f247156373da7078d453aba Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 9 Feb 2026 14:41:35 -0800 Subject: [PATCH 05/11] suggest how to teardown existing projects --- src/pkg/cli/teardown_cd.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/pkg/cli/teardown_cd.go b/src/pkg/cli/teardown_cd.go index a84c979a3..de6229dc5 100644 --- a/src/pkg/cli/teardown_cd.go +++ b/src/pkg/cli/teardown_cd.go @@ -1,9 +1,11 @@ package cli import ( + "cmp" "context" "errors" "fmt" + "slices" "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" @@ -27,10 +29,21 @@ func TearDownCD(ctx context.Context, provider client.Provider, force bool) error stacks = append(stacks, *stackInfo) } + // sort stacks by workspace, project, stack for easier readability + slices.SortFunc(stacks, func(a, b state.StackInfo) int { + if a.Workspace != b.Workspace { + return cmp.Compare(a.Workspace, b.Workspace) + } + if a.Project != b.Project { + return cmp.Compare(a.Project, b.Project) + } + return cmp.Compare(a.Name, b.Name) + }) + if len(stacks) > 0 { - term.Info("There following stacks are currently deployed:") + term.Info("Some stacks are currently deployed. Run the following commands to tear them down:") for _, stack := range stacks { - term.Infof(" - (%s) %s/%s [%s]\n", stack.Workspace, stack.Project, stack.Name, stack.Region) + term.Infof(" `defang down --workspace %s --project-name %s --stack %s`\n", stack.Workspace, stack.Project, stack.Name) } if !force { return ErrExistingStacks From bbc45690ac30af23a66a4ca4707498261b5864e9 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 9 Feb 2026 15:02:08 -0800 Subject: [PATCH 06/11] print stack name as Stack in cd ls output --- src/pkg/cli/cd.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/pkg/cli/cd.go b/src/pkg/cli/cd.go index 0f9e378ac..6970e644f 100644 --- a/src/pkg/cli/cd.go +++ b/src/pkg/cli/cd.go @@ -10,7 +10,6 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/term" @@ -133,6 +132,13 @@ func SplitProjectStack(name string) (projectName string, stackName string) { return projectName, stackName } +type StackLineItem struct { + Project string + Stack string + Workspace string + Region string +} + func CdListFromStorage(ctx context.Context, provider client.Provider, allRegions bool) error { term.Debug("Running CD list") if dryrun.DoDryRun { @@ -144,9 +150,14 @@ func CdListFromStorage(ctx context.Context, provider client.Provider, allRegions return err } - var stacks []state.StackInfo + var stacks []StackLineItem for stackInfo := range stacksIter { - stacks = append(stacks, *stackInfo) + stacks = append(stacks, StackLineItem{ + Project: stackInfo.Project, + Stack: stackInfo.Name, + Workspace: stackInfo.Workspace, + Region: stackInfo.Region, + }) } if len(stacks) == 0 { From 6bb195e1099a9adce633a9fbcaf4ad3c48ccad92 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 10 Feb 2026 11:54:53 -0800 Subject: [PATCH 07/11] s/DefangOrg/Workspace/g --- src/pkg/cli/client/byoc/aws/list.go | 2 +- src/pkg/cli/client/byoc/do/byoc.go | 2 +- src/pkg/cli/client/byoc/gcp/byoc.go | 2 +- src/pkg/cli/client/byoc/state/parse.go | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pkg/cli/client/byoc/aws/list.go b/src/pkg/cli/client/byoc/aws/list.go index 5f909c3af..dc1481666 100644 --- a/src/pkg/cli/client/byoc/aws/list.go +++ b/src/pkg/cli/client/byoc/aws/list.go @@ -43,7 +43,7 @@ func listPulumiStacksInBucket(ctx context.Context, region aws.Region, bucketName info := &state.StackInfo{ Project: st.Project, Name: st.Name, - Workspace: string(st.DefangOrg), + Workspace: string(st.Workspace), Region: string(region), } if !yield(info) { diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index 2861359f9..0a240280b 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -269,7 +269,7 @@ func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state. info := &state.StackInfo{ Project: st.Project, Name: st.Name, - Workspace: string(st.DefangOrg), + Workspace: string(st.Workspace), Region: string(b.driver.Region), } if !yield(info) { diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index de5c2a77e..9f971949e 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -314,7 +314,7 @@ func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state stack := &state.StackInfo{ Name: st.Name, Project: st.Project, - Workspace: string(st.DefangOrg), + Workspace: string(st.Workspace), Region: b.driver.GetRegion(), } if !yield(stack) { diff --git a/src/pkg/cli/client/byoc/state/parse.go b/src/pkg/cli/client/byoc/state/parse.go index f5bbf8032..76e19b37e 100644 --- a/src/pkg/cli/client/byoc/state/parse.go +++ b/src/pkg/cli/client/byoc/state/parse.go @@ -20,7 +20,7 @@ type BucketObj interface { type PulumiState struct { Project string Name string - DefangOrg types.TenantLabel + Workspace types.TenantLabel Pending []string } @@ -35,8 +35,8 @@ func (ps PulumiState) String() string { } pending.WriteByte(')') } - if ps.DefangOrg != "" { - org = " {" + string(ps.DefangOrg) + "}" + if ps.Workspace != "" { + org = " {" + string(ps.Workspace) + "}" } return fmt.Sprintf("%s/%s%s%s", ps.Project, ps.Name, org, pending.String()) } @@ -107,7 +107,7 @@ func ParsePulumiStateFile(ctx context.Context, obj BucketObj, bucket string, obj DefangOrg string `json:"defang-org,omitempty"` } if err := json.Unmarshal([]byte(res.Inputs.DefaultLabels), &labels); err == nil && labels.DefangOrg != "" { - stack.DefangOrg = types.TenantLabel(labels.DefangOrg) + stack.Workspace = types.TenantLabel(labels.DefangOrg) break } } else if res.Inputs.DefaultTags != "" { @@ -117,7 +117,7 @@ func ParsePulumiStateFile(ctx context.Context, obj BucketObj, bucket string, obj } } if err := json.Unmarshal([]byte(res.Inputs.DefaultTags), &tags); err == nil && tags.Tags.DefangOrg != "" { - stack.DefangOrg = types.TenantLabel(tags.Tags.DefangOrg) + stack.Workspace = types.TenantLabel(tags.Tags.DefangOrg) break } } From 53829610a963fba299fc993894fcc23c40f14396 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 10 Feb 2026 12:11:44 -0800 Subject: [PATCH 08/11] use slices.Collect --- src/pkg/cli/cd.go | 20 +++++++++++--------- src/pkg/cli/teardown_cd.go | 12 +++++++----- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/pkg/cli/cd.go b/src/pkg/cli/cd.go index 6970e644f..ce892f1a8 100644 --- a/src/pkg/cli/cd.go +++ b/src/pkg/cli/cd.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "slices" "strings" "time" @@ -150,15 +151,16 @@ func CdListFromStorage(ctx context.Context, provider client.Provider, allRegions return err } - var stacks []StackLineItem - for stackInfo := range stacksIter { - stacks = append(stacks, StackLineItem{ - Project: stackInfo.Project, - Stack: stackInfo.Name, - Workspace: stackInfo.Workspace, - Region: stackInfo.Region, - }) - } + stacks := slices.Collect(func(yield func(*state.Info) bool) { + for stackInfo := range stacksIter { + if stackInfo == nil { + continue + } + if !yield(stackInfo) { + return + } + } + }) if len(stacks) == 0 { accountInfo, err := provider.AccountInfo(ctx) diff --git a/src/pkg/cli/teardown_cd.go b/src/pkg/cli/teardown_cd.go index de6229dc5..548c0764c 100644 --- a/src/pkg/cli/teardown_cd.go +++ b/src/pkg/cli/teardown_cd.go @@ -23,11 +23,13 @@ func TearDownCD(ctx context.Context, provider client.Provider, force bool) error if err != nil { return fmt.Errorf("could not get list of deployed stacks: %w", err) } - - var stacks []state.StackInfo - for stackInfo := range list { - stacks = append(stacks, *stackInfo) - } + stacks := slices.Collect(func(yield func(state.Info) bool) { + for stackInfo := range list { + if !yield(*stackInfo) { + return + } + } + }) // sort stacks by workspace, project, stack for easier readability slices.SortFunc(stacks, func(a, b state.StackInfo) int { From d3d1ae8f1796bafa79948e55a0eb55656656c1c3 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 10 Feb 2026 12:12:07 -0800 Subject: [PATCH 09/11] rename StackInfo to Info and Name to Stack --- src/pkg/cli/cd.go | 8 +------- src/pkg/cli/client/byoc/aws/byoc.go | 2 +- src/pkg/cli/client/byoc/aws/list.go | 14 +++++++------- src/pkg/cli/client/byoc/baseclient.go | 2 +- src/pkg/cli/client/byoc/do/byoc.go | 8 ++++---- src/pkg/cli/client/byoc/gcp/byoc.go | 8 ++++---- src/pkg/cli/client/byoc/state/state.go | 4 ++-- src/pkg/cli/client/playground.go | 2 +- src/pkg/cli/client/provider.go | 2 +- src/pkg/cli/teardown_cd.go | 6 +++--- 10 files changed, 25 insertions(+), 31 deletions(-) diff --git a/src/pkg/cli/cd.go b/src/pkg/cli/cd.go index ce892f1a8..3d1258df3 100644 --- a/src/pkg/cli/cd.go +++ b/src/pkg/cli/cd.go @@ -11,6 +11,7 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/state" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/term" @@ -133,13 +134,6 @@ func SplitProjectStack(name string) (projectName string, stackName string) { return projectName, stackName } -type StackLineItem struct { - Project string - Stack string - Workspace string - Region string -} - func CdListFromStorage(ctx context.Context, provider client.Provider, allRegions bool) error { term.Debug("Running CD list") if dryrun.DoDryRun { diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index d19005202..9e80fbdd5 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -871,7 +871,7 @@ func (b *ByocAws) DeleteConfig(ctx context.Context, secrets *defangv1.Secrets) e return nil } -func (b *ByocAws) CdList(ctx context.Context, allRegions bool) (iter.Seq[*state.StackInfo], error) { +func (b *ByocAws) CdList(ctx context.Context, allRegions bool) (iter.Seq[*state.Info], error) { if allRegions { s3Client, err := newS3Client(ctx, b.driver.Region) if err != nil { diff --git a/src/pkg/cli/client/byoc/aws/list.go b/src/pkg/cli/client/byoc/aws/list.go index dc1481666..111adb5dd 100644 --- a/src/pkg/cli/client/byoc/aws/list.go +++ b/src/pkg/cli/client/byoc/aws/list.go @@ -26,7 +26,7 @@ func newS3Client(ctx context.Context, region aws.Region) (*s3.Client, error) { return s3client, nil } -func listPulumiStacksInBucket(ctx context.Context, region aws.Region, bucketName string) (iter.Seq[*state.StackInfo], error) { +func listPulumiStacksInBucket(ctx context.Context, region aws.Region, bucketName string) (iter.Seq[*state.Info], error) { s3client, err := newS3Client(ctx, region) if err != nil { return nil, err @@ -35,14 +35,14 @@ func listPulumiStacksInBucket(ctx context.Context, region aws.Region, bucketName if err != nil { return nil, err } - return func(yield func(*state.StackInfo) bool) { + return func(yield func(*state.Info) bool) { for st := range stacks { if st == nil { continue } - info := &state.StackInfo{ + info := &state.Info{ Project: st.Project, - Name: st.Name, + Stack: st.Name, Workspace: string(st.Workspace), Region: string(region), } @@ -110,7 +110,7 @@ func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) }, nil } -func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Seq[*state.StackInfo], error) { +func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Seq[*state.Info], error) { // Use a single S3 query to list all buckets with the defang-cd- prefix // This is faster than calling CloudFormation DescribeStacks in each region listBucketsOutput, err := s3client.ListBuckets(ctx, &s3.ListBucketsInput{}) @@ -118,10 +118,10 @@ func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Se return nil, AnnotateAwsError(err) } - return func(yield func(*state.StackInfo) bool) { + return func(yield func(*state.Info) bool) { ctx, cancel := context.WithCancel(ctx) defer cancel() - stackCh := make(chan *state.StackInfo) + stackCh := make(chan *state.Info) // Filter buckets by prefix and get their locations var wg sync.WaitGroup diff --git a/src/pkg/cli/client/byoc/baseclient.go b/src/pkg/cli/client/byoc/baseclient.go index 01226ec26..fd4fcccca 100644 --- a/src/pkg/cli/client/byoc/baseclient.go +++ b/src/pkg/cli/client/byoc/baseclient.go @@ -29,7 +29,7 @@ func (mp ErrMultipleProjects) Error() string { type ProjectBackend interface { CdCommand(context.Context, client.CdCommandRequest) (types.ETag, error) - CdList(context.Context, bool) (iter.Seq[*state.StackInfo], error) + CdList(context.Context, bool) (iter.Seq[*state.Info], error) GetPrivateDomain(projectName string) string GetProjectUpdate(context.Context, string) (*defangv1.ProjectUpdate, error) } diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index 0a240280b..6900d94be 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -246,7 +246,7 @@ func (b *ByocDo) CdCommand(ctx context.Context, req client.CdCommandRequest) (st return etag, nil } -func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state.StackInfo], error) { +func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state.Info], error) { s3client, err := b.driver.CreateS3Client() if err != nil { return nil, err @@ -261,14 +261,14 @@ func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state. if err != nil { return nil, err } - return func(yield func(*state.StackInfo) bool) { + return func(yield func(*state.Info) bool) { for st := range stacks { if st == nil { continue } - info := &state.StackInfo{ + info := &state.Info{ Project: st.Project, - Name: st.Name, + Stack: st.Name, Workspace: string(st.Workspace), Region: string(b.driver.Region), } diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index 9f971949e..694c170fb 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -277,7 +277,7 @@ func (o gcpObj) Size() int64 { return o.obj.Size } -func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state.StackInfo], error) { +func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state.Info], error) { bucketName, err := b.driver.GetBucketWithPrefix(ctx, DefangCDProjectName) if err != nil { return nil, annotateGcpError(err) @@ -297,7 +297,7 @@ func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state if err != nil { return nil, annotateGcpError(err) } - return func(yield func(*state.StackInfo) bool) { + return func(yield func(*state.Info) bool) { for obj, err := range seq { if err != nil { term.Debugf("Error listing object in bucket %s: %v", bucketName, annotateGcpError(err)) @@ -311,8 +311,8 @@ func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state if st == nil { continue } - stack := &state.StackInfo{ - Name: st.Name, + stack := &state.Info{ + Stack: st.Name, Project: st.Project, Workspace: string(st.Workspace), Region: b.driver.GetRegion(), diff --git a/src/pkg/cli/client/byoc/state/state.go b/src/pkg/cli/client/byoc/state/state.go index d82d4e17a..f4116a5fb 100644 --- a/src/pkg/cli/client/byoc/state/state.go +++ b/src/pkg/cli/client/byoc/state/state.go @@ -1,8 +1,8 @@ package state -type StackInfo struct { +type Info struct { Project string - Name string + Stack string Workspace string Region string } diff --git a/src/pkg/cli/client/playground.go b/src/pkg/cli/client/playground.go index 0df9b6c44..aa6c277d1 100644 --- a/src/pkg/cli/client/playground.go +++ b/src/pkg/cli/client/playground.go @@ -116,7 +116,7 @@ func (g *PlaygroundProvider) SetUpCD(ctx context.Context) error { return errors.New("this command is not valid for the Defang playground; did you forget --stack or --provider?") } -func (g *PlaygroundProvider) CdList(context.Context, bool) (iter.Seq[*byocState.StackInfo], error) { +func (g *PlaygroundProvider) CdList(context.Context, bool) (iter.Seq[*byocState.Info], error) { return nil, errors.New("this command is not valid for the Defang playground; did you forget --stack or --provider?") } diff --git a/src/pkg/cli/client/provider.go b/src/pkg/cli/client/provider.go index 4a464532e..0db75312b 100644 --- a/src/pkg/cli/client/provider.go +++ b/src/pkg/cli/client/provider.go @@ -68,7 +68,7 @@ type Provider interface { DNSResolver AccountInfo(context.Context) (*AccountInfo, error) CdCommand(context.Context, CdCommandRequest) (types.ETag, error) - CdList(context.Context, bool) (iter.Seq[*byocState.StackInfo], error) + CdList(context.Context, bool) (iter.Seq[*byocState.Info], error) CreateUploadURL(context.Context, *defangv1.UploadURLRequest) (*defangv1.UploadURLResponse, error) DelayBeforeRetry(context.Context) error DeleteConfig(context.Context, *defangv1.Secrets) error diff --git a/src/pkg/cli/teardown_cd.go b/src/pkg/cli/teardown_cd.go index 548c0764c..5c80a9859 100644 --- a/src/pkg/cli/teardown_cd.go +++ b/src/pkg/cli/teardown_cd.go @@ -32,20 +32,20 @@ func TearDownCD(ctx context.Context, provider client.Provider, force bool) error }) // sort stacks by workspace, project, stack for easier readability - slices.SortFunc(stacks, func(a, b state.StackInfo) int { + slices.SortFunc(stacks, func(a, b state.Info) int { if a.Workspace != b.Workspace { return cmp.Compare(a.Workspace, b.Workspace) } if a.Project != b.Project { return cmp.Compare(a.Project, b.Project) } - return cmp.Compare(a.Name, b.Name) + return cmp.Compare(a.Stack, b.Stack) }) if len(stacks) > 0 { term.Info("Some stacks are currently deployed. Run the following commands to tear them down:") for _, stack := range stacks { - term.Infof(" `defang down --workspace %s --project-name %s --stack %s`\n", stack.Workspace, stack.Project, stack.Name) + term.Infof(" `defang down --workspace %s --project-name %s --stack %s`\n", stack.Workspace, stack.Project, stack.Stack) } if !force { return ErrExistingStacks From 3c45fe2fa94a8339f4758d5f786afe77efe028d4 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 10 Feb 2026 12:14:43 -0800 Subject: [PATCH 10/11] avoid unnecessary collect before sort --- src/pkg/cli/teardown_cd.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/pkg/cli/teardown_cd.go b/src/pkg/cli/teardown_cd.go index 5c80a9859..c1c61b8b1 100644 --- a/src/pkg/cli/teardown_cd.go +++ b/src/pkg/cli/teardown_cd.go @@ -23,16 +23,7 @@ func TearDownCD(ctx context.Context, provider client.Provider, force bool) error if err != nil { return fmt.Errorf("could not get list of deployed stacks: %w", err) } - stacks := slices.Collect(func(yield func(state.Info) bool) { - for stackInfo := range list { - if !yield(*stackInfo) { - return - } - } - }) - - // sort stacks by workspace, project, stack for easier readability - slices.SortFunc(stacks, func(a, b state.Info) int { + stacks := slices.SortedFunc(list, func(a, b *state.Info) int { if a.Workspace != b.Workspace { return cmp.Compare(a.Workspace, b.Workspace) } From e2d6c0f817457c6e06013d7307e2387ff6059f3a Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Tue, 10 Feb 2026 12:19:20 -0800 Subject: [PATCH 11/11] avoid pointer iterators for state info --- src/pkg/cli/cd.go | 5 +---- src/pkg/cli/client/byoc/aws/byoc.go | 2 +- src/pkg/cli/client/byoc/aws/list.go | 21 +++++++++------------ src/pkg/cli/client/byoc/baseclient.go | 2 +- src/pkg/cli/client/byoc/do/byoc.go | 9 +++------ src/pkg/cli/client/byoc/gcp/byoc.go | 6 +++--- src/pkg/cli/client/playground.go | 2 +- src/pkg/cli/client/provider.go | 2 +- src/pkg/cli/teardown_cd.go | 2 +- 9 files changed, 21 insertions(+), 30 deletions(-) diff --git a/src/pkg/cli/cd.go b/src/pkg/cli/cd.go index 3d1258df3..b4de0e109 100644 --- a/src/pkg/cli/cd.go +++ b/src/pkg/cli/cd.go @@ -145,11 +145,8 @@ func CdListFromStorage(ctx context.Context, provider client.Provider, allRegions return err } - stacks := slices.Collect(func(yield func(*state.Info) bool) { + stacks := slices.Collect(func(yield func(state.Info) bool) { for stackInfo := range stacksIter { - if stackInfo == nil { - continue - } if !yield(stackInfo) { return } diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index 9e80fbdd5..39807bba8 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -871,7 +871,7 @@ func (b *ByocAws) DeleteConfig(ctx context.Context, secrets *defangv1.Secrets) e return nil } -func (b *ByocAws) CdList(ctx context.Context, allRegions bool) (iter.Seq[*state.Info], error) { +func (b *ByocAws) CdList(ctx context.Context, allRegions bool) (iter.Seq[state.Info], error) { if allRegions { s3Client, err := newS3Client(ctx, b.driver.Region) if err != nil { diff --git a/src/pkg/cli/client/byoc/aws/list.go b/src/pkg/cli/client/byoc/aws/list.go index 111adb5dd..52005632d 100644 --- a/src/pkg/cli/client/byoc/aws/list.go +++ b/src/pkg/cli/client/byoc/aws/list.go @@ -26,7 +26,7 @@ func newS3Client(ctx context.Context, region aws.Region) (*s3.Client, error) { return s3client, nil } -func listPulumiStacksInBucket(ctx context.Context, region aws.Region, bucketName string) (iter.Seq[*state.Info], error) { +func listPulumiStacksInBucket(ctx context.Context, region aws.Region, bucketName string) (iter.Seq[state.Info], error) { s3client, err := newS3Client(ctx, region) if err != nil { return nil, err @@ -35,12 +35,9 @@ func listPulumiStacksInBucket(ctx context.Context, region aws.Region, bucketName if err != nil { return nil, err } - return func(yield func(*state.Info) bool) { + return func(yield func(state.Info) bool) { for st := range stacks { - if st == nil { - continue - } - info := &state.Info{ + info := state.Info{ Project: st.Project, Stack: st.Name, Workspace: string(st.Workspace), @@ -70,7 +67,7 @@ type S3Client interface { ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) } -func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) (iter.Seq[*state.PulumiState], error) { +func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) (iter.Seq[state.PulumiState], error) { prefix := `.pulumi/stacks/` // TODO: should we filter on `projectName`? term.Debug("Listing stacks in bucket:", bucketName) @@ -81,7 +78,7 @@ func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) if err != nil { return nil, AnnotateAwsError(err) } - return func(yield func(*state.PulumiState) bool) { + return func(yield func(state.PulumiState) bool) { for _, obj := range out.Contents { if obj.Key == nil || obj.Size == nil { continue @@ -101,7 +98,7 @@ func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) continue } if state != nil { - if !yield(state) { + if !yield(*state) { break } } @@ -110,7 +107,7 @@ func ListPulumiStacks(ctx context.Context, s3client S3Client, bucketName string) }, nil } -func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Seq[*state.Info], error) { +func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Seq[state.Info], error) { // Use a single S3 query to list all buckets with the defang-cd- prefix // This is faster than calling CloudFormation DescribeStacks in each region listBucketsOutput, err := s3client.ListBuckets(ctx, &s3.ListBucketsInput{}) @@ -118,10 +115,10 @@ func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Se return nil, AnnotateAwsError(err) } - return func(yield func(*state.Info) bool) { + return func(yield func(state.Info) bool) { ctx, cancel := context.WithCancel(ctx) defer cancel() - stackCh := make(chan *state.Info) + stackCh := make(chan state.Info) // Filter buckets by prefix and get their locations var wg sync.WaitGroup diff --git a/src/pkg/cli/client/byoc/baseclient.go b/src/pkg/cli/client/byoc/baseclient.go index fd4fcccca..ef6158548 100644 --- a/src/pkg/cli/client/byoc/baseclient.go +++ b/src/pkg/cli/client/byoc/baseclient.go @@ -29,7 +29,7 @@ func (mp ErrMultipleProjects) Error() string { type ProjectBackend interface { CdCommand(context.Context, client.CdCommandRequest) (types.ETag, error) - CdList(context.Context, bool) (iter.Seq[*state.Info], error) + CdList(context.Context, bool) (iter.Seq[state.Info], error) GetPrivateDomain(projectName string) string GetProjectUpdate(context.Context, string) (*defangv1.ProjectUpdate, error) } diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index 6900d94be..a70ae8d5a 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -246,7 +246,7 @@ func (b *ByocDo) CdCommand(ctx context.Context, req client.CdCommandRequest) (st return etag, nil } -func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state.Info], error) { +func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[state.Info], error) { s3client, err := b.driver.CreateS3Client() if err != nil { return nil, err @@ -261,12 +261,9 @@ func (b *ByocDo) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state. if err != nil { return nil, err } - return func(yield func(*state.Info) bool) { + return func(yield func(state.Info) bool) { for st := range stacks { - if st == nil { - continue - } - info := &state.Info{ + info := state.Info{ Project: st.Project, Stack: st.Name, Workspace: string(st.Workspace), diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index 694c170fb..af008ef61 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -277,7 +277,7 @@ func (o gcpObj) Size() int64 { return o.obj.Size } -func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state.Info], error) { +func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[state.Info], error) { bucketName, err := b.driver.GetBucketWithPrefix(ctx, DefangCDProjectName) if err != nil { return nil, annotateGcpError(err) @@ -297,7 +297,7 @@ func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state if err != nil { return nil, annotateGcpError(err) } - return func(yield func(*state.Info) bool) { + return func(yield func(state.Info) bool) { for obj, err := range seq { if err != nil { term.Debugf("Error listing object in bucket %s: %v", bucketName, annotateGcpError(err)) @@ -311,7 +311,7 @@ func (b *ByocGcp) CdList(ctx context.Context, _allRegions bool) (iter.Seq[*state if st == nil { continue } - stack := &state.Info{ + stack := state.Info{ Stack: st.Name, Project: st.Project, Workspace: string(st.Workspace), diff --git a/src/pkg/cli/client/playground.go b/src/pkg/cli/client/playground.go index aa6c277d1..3b6ec9a02 100644 --- a/src/pkg/cli/client/playground.go +++ b/src/pkg/cli/client/playground.go @@ -116,7 +116,7 @@ func (g *PlaygroundProvider) SetUpCD(ctx context.Context) error { return errors.New("this command is not valid for the Defang playground; did you forget --stack or --provider?") } -func (g *PlaygroundProvider) CdList(context.Context, bool) (iter.Seq[*byocState.Info], error) { +func (g *PlaygroundProvider) CdList(context.Context, bool) (iter.Seq[byocState.Info], error) { return nil, errors.New("this command is not valid for the Defang playground; did you forget --stack or --provider?") } diff --git a/src/pkg/cli/client/provider.go b/src/pkg/cli/client/provider.go index 0db75312b..ddaa56d8e 100644 --- a/src/pkg/cli/client/provider.go +++ b/src/pkg/cli/client/provider.go @@ -68,7 +68,7 @@ type Provider interface { DNSResolver AccountInfo(context.Context) (*AccountInfo, error) CdCommand(context.Context, CdCommandRequest) (types.ETag, error) - CdList(context.Context, bool) (iter.Seq[*byocState.Info], error) + CdList(context.Context, bool) (iter.Seq[byocState.Info], error) CreateUploadURL(context.Context, *defangv1.UploadURLRequest) (*defangv1.UploadURLResponse, error) DelayBeforeRetry(context.Context) error DeleteConfig(context.Context, *defangv1.Secrets) error diff --git a/src/pkg/cli/teardown_cd.go b/src/pkg/cli/teardown_cd.go index c1c61b8b1..584da8567 100644 --- a/src/pkg/cli/teardown_cd.go +++ b/src/pkg/cli/teardown_cd.go @@ -23,7 +23,7 @@ func TearDownCD(ctx context.Context, provider client.Provider, force bool) error if err != nil { return fmt.Errorf("could not get list of deployed stacks: %w", err) } - stacks := slices.SortedFunc(list, func(a, b *state.Info) int { + stacks := slices.SortedFunc(list, func(a, b state.Info) int { if a.Workspace != b.Workspace { return cmp.Compare(a.Workspace, b.Workspace) }