diff --git a/src/cmd/cli/command/cd.go b/src/cmd/cli/command/cd.go index b9b1f942f..936fb6915 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.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/cd.go b/src/pkg/cli/cd.go index aaee0c48a..b4de0e109 100644 --- a/src/pkg/cli/cd.go +++ b/src/pkg/cli/cd.go @@ -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" @@ -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 @@ -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) { diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index 7bac2f776..39807bba8 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" @@ -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) } @@ -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 { diff --git a/src/pkg/cli/client/byoc/aws/list.go b/src/pkg/cli/client/byoc/aws/list.go index 4bb18de92..52005632d 100644 --- a/src/pkg/cli/client/byoc/aws/list.go +++ b/src/pkg/cli/client/byoc/aws/list.go @@ -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" @@ -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 } @@ -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) @@ -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, @@ -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 } } @@ -91,7 +107,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.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{}) @@ -99,10 +115,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.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 @@ -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) 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..ef6158548 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.Info], 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..a70ae8d5a 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.Info], error) { s3client, err := b.driver.CreateS3Client() if err != nil { return nil, err @@ -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) { @@ -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 diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index 948401492..af008ef61 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" @@ -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) @@ -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 @@ -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 return client.ErrNotImplemented("GCP TearDown") } diff --git a/src/pkg/cli/client/byoc/parse.go b/src/pkg/cli/client/byoc/state/parse.go similarity index 94% rename from src/pkg/cli/client/byoc/parse.go rename to src/pkg/cli/client/byoc/state/parse.go index db90374f0..76e19b37e 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" @@ -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 } } 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..f4116a5fb --- /dev/null +++ b/src/pkg/cli/client/byoc/state/state.go @@ -0,0 +1,8 @@ +package state + +type Info struct { + Project string + Stack string + Workspace string + Region string +} 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 diff --git a/src/pkg/cli/client/playground.go b/src/pkg/cli/client/playground.go index 6c1b05190..3b6ec9a02 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.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 ddea9fa6e..ddaa56d8e 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.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 c02cae8cf..584da8567 100644 --- a/src/pkg/cli/teardown_cd.go +++ b/src/pkg/cli/teardown_cd.go @@ -1,28 +1,47 @@ 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" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/term" ) +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 { - 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 deployed stacks: %w", err) + } + stacks := slices.SortedFunc(list, 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.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.Stack) + } + if !force { + return ErrExistingStacks } } - term.Warn("Deleting the CD cluster; this does not delete services or configs!") + return provider.TearDownCD(ctx) }