Skip to content
6 changes: 5 additions & 1 deletion src/cmd/cli/command/cd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.ErrExistingStacks) {
printDefangHint("Use `defang cd destroy --force` to force teardown the CD cluster, leaving existing projects orphaned")
}
return err
},
}

Expand Down
23 changes: 13 additions & 10 deletions src/pkg/cli/cd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"errors"
"fmt"
"os"
"slices"
"strings"
"time"

"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"
Expand Down Expand Up @@ -138,20 +140,20 @@ 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]"
stacks := slices.Collect(func(yield func(state.Info) bool) {
for stackInfo := range stacksIter {
if !yield(stackInfo) {
return
}
}
term.Println(" -", stackInfo) // TODO: json output mode
}
if count == 0 {
})

if len(stacks) == 0 {
accountInfo, err := provider.AccountInfo(ctx)
if err != nil {
return err
Expand All @@ -161,7 +163,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) {
Expand Down
4 changes: 3 additions & 1 deletion src/pkg/cli/client/byoc/aws/byoc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -833,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)
}

Expand Down Expand Up @@ -869,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[string], 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 {
Expand Down
40 changes: 28 additions & 12 deletions src/pkg/cli/client/byoc/aws/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ package aws

import (
"context"
"fmt"
"io"
"iter"
"strings"
"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"
Expand All @@ -26,12 +26,28 @@ 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.Info], 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.Info) bool) {
for st := range stacks {
info := state.Info{
Project: st.Project,
Stack: st.Name,
Workspace: string(st.Workspace),
Region: string(region),
}
if !yield(info) {
break
}
}
}, nil
}

type s3Obj struct{ obj s3types.Object }
Expand All @@ -51,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[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)
Expand All @@ -62,12 +78,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 := byoc.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,
Expand All @@ -81,8 +97,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
}
}
Expand All @@ -91,18 +107,18 @@ 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.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{})
if err != nil {
return nil, AnnotateAwsError(err)
}

return func(yield func(string) bool) {
return func(yield func(state.Info) bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
stackCh := make(chan string)
stackCh := make(chan state.Info)

// Filter buckets by prefix and get their locations
var wg sync.WaitGroup
Expand Down Expand Up @@ -141,7 +157,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)
Expand Down
2 changes: 1 addition & 1 deletion src/pkg/cli/client/byoc/aws/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand Down
8 changes: 4 additions & 4 deletions src/pkg/cli/client/byoc/baseclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.Info], error)
GetPrivateDomain(projectName string) string
GetProjectUpdate(context.Context, string) (*defangv1.ProjectUpdate, error)
}
Expand Down Expand Up @@ -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)
}
Comment on lines +105 to 107
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 10, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Deduplicate project names to avoid false “multiple projects” errors.

If multiple stacks exist for the same project, the current logic will append duplicates and trigger ErrMultipleProjects incorrectly. Consider de-duping (and sorting) before the length checks.

🔧 Suggested fix
 import (
 	"context"
 	"errors"
 	"fmt"
 	"iter"
+	"sort"
 	"strings"
@@
 	var projectNames []string
-	for stack := range stacks {
-		projectNames = append(projectNames, stack.Project)
-	}
+	projectSet := map[string]struct{}{}
+	for stack := range stacks {
+		projectSet[stack.Project] = struct{}{}
+	}
+	projectNames = make([]string, 0, len(projectSet))
+	for name := range projectSet {
+		projectNames = append(projectNames, name)
+	}
+	sort.Strings(projectNames)
🤖 Prompt for AI Agents
In `@src/pkg/cli/client/byoc/baseclient.go` around lines 105 - 107, The loop
building projectNames from stacks currently appends duplicates (for
stack.Project) which can cause a false ErrMultipleProjects; replace the naive
append with logic that deduplicates (e.g., use a map[string]struct{} to collect
unique stack.Project values), then convert the keys to a slice, sort it, and run
the existing length checks that raise ErrMultipleProjects/ErrNoProject against
this deduped, sorted slice (refer to the stacks range loop and projectNames
variable and the ErrMultipleProjects checks to locate where to change).

Copy link
Member Author

Choose a reason for hiding this comment

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

This is true, but unrelated to our changes here. I'll handle separately.

Copy link
Contributor

Choose a reason for hiding this comment

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

@jordanstephens Understood! Since you'll be handling this separately, would you like me to create a GitHub issue to track the deduplication fix?


🧠 Learnings used
Learnt from: lionello
Repo: DefangLabs/defang PR: 1734
File: src/cmd/cli/command/commands.go:1220-1226
Timestamp: 2025-12-25T04:38:40.356Z
Learning: In the Defang CLI codebase (src/cmd/cli/command/commands.go), empty project names (from inputs like "/stack" or when --project-name is omitted) are acceptable and intentional behavior, as they work similarly to not providing the --project-name flag at all.

Learnt from: edwardrf
Repo: DefangLabs/defang PR: 1747
File: src/pkg/cli/client/byoc/gcp/stream.go:497-512
Timestamp: 2026-01-09T20:31:23.614Z
Learning: In src/pkg/cli/client/byoc/gcp/stream.go, the getReadyServicesCompletedResps helper function intentionally uses variable shadowing. The loop variable `status` from `readyServices` map represents individual service statuses, while the function parameter (to be renamed `cdStatus`) represents only the CD service (defangCD) completion status. Each ready service should retain its original status from the map.

Copy link
Member Author

Choose a reason for hiding this comment

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

done: #1918

Copy link
Contributor

Choose a reason for hiding this comment

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

@jordanstephens Thank you for creating issue #1918! I appreciate you tracking this separately.


🧠 Learnings used
Learnt from: lionello
Repo: DefangLabs/defang PR: 1734
File: src/cmd/cli/command/commands.go:1220-1226
Timestamp: 2025-12-25T04:38:40.356Z
Learning: In the Defang CLI codebase (src/cmd/cli/command/commands.go), empty project names (from inputs like "/stack" or when --project-name is omitted) are acceptable and intentional behavior, as they work similarly to not providing the --project-name flag at all.

Learnt from: edwardrf
Repo: DefangLabs/defang PR: 1747
File: src/pkg/cli/client/byoc/gcp/stream.go:497-512
Timestamp: 2026-01-09T20:31:23.614Z
Learning: In src/pkg/cli/client/byoc/gcp/stream.go, the getReadyServicesCompletedResps helper function intentionally uses variable shadowing. The loop variable `status` from `readyServices` map represents individual service statuses, while the function parameter (to be renamed `cdStatus`) represents only the CD service (defangCD) completion status. Each ready service should retain its original status from the map.


if len(projectNames) == 0 {
Expand Down
22 changes: 20 additions & 2 deletions src/pkg/cli/client/byoc/do/byoc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.Info], error) {
s3client, err := b.driver.CreateS3Client()
if err != nil {
return nil, err
Expand All @@ -256,7 +257,23 @@ 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.Info) bool) {
for st := range stacks {
info := state.Info{
Project: st.Project,
Stack: st.Name,
Workspace: string(st.Workspace),
Region: string(b.driver.Region),
}
if !yield(info) {
break
}
}
}, nil
}

func (b *ByocDo) CreateUploadURL(ctx context.Context, req *defangv1.UploadURLRequest) (*defangv1.UploadURLResponse, error) {
Expand Down Expand Up @@ -461,6 +478,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
Expand Down
23 changes: 16 additions & 7 deletions src/pkg/cli/client/byoc/gcp/byoc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -276,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.Info], error) {
bucketName, err := b.driver.GetBucketWithPrefix(ctx, DefangCDProjectName)
if err != nil {
return nil, annotateGcpError(err)
Expand All @@ -296,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.Info) bool) {
for obj, err := range seq {
if err != nil {
term.Debugf("Error listing object in bucket %s: %v", bucketName, annotateGcpError(err))
continue
}
stack, err := byoc.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.Info{
Stack: st.Name,
Project: st.Project,
Workspace: string(st.Workspace),
Region: b.driver.GetRegion(),
}
if !yield(stack) {
break
}
}
}, nil
Expand Down Expand Up @@ -786,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
Copy link
Member

Choose a reason for hiding this comment

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

It's possible there's really nothing to do here. @edwardrf thoughts?

return client.ErrNotImplemented("GCP TearDown")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package byoc
package state

import (
"context"
Expand All @@ -20,7 +20,7 @@ type BucketObj interface {
type PulumiState struct {
Project string
Name string
DefangOrg types.TenantLabel
Workspace types.TenantLabel
Pending []string
}

Expand All @@ -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())
}
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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
}
}
Expand Down
Loading
Loading