diff --git a/src/cmd/cli/command/cd.go b/src/cmd/cli/command/cd.go index b9b1f942f..d4b28c640 100644 --- a/src/cmd/cli/command/cd.go +++ b/src/cmd/cli/command/cd.go @@ -141,7 +141,7 @@ var cdListCmd = &cobra.Command{ // FIXME: this needs auth because it spawns the CD task return cli.CdCommandAndTail(cmd.Context(), session.Provider, "", global.Verbose, client.CdCommandList, global.Client) } else { - return cli.CdListFromStorage(cmd.Context(), session.Provider, all) + return cli.CdListFromStorage(cmd.Context(), session.Provider, all || global.Verbose) } }, } diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index 7bac2f776..40003d6f6 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -36,11 +36,11 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" - "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" cwTypes "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/aws/aws-sdk-go-v2/service/servicequotas" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/smithy-go" "github.com/bufbuild/connect-go" @@ -166,7 +166,7 @@ func (b *ByocAws) SetUpCD(ctx context.Context) error { // Delete default SecurityGroup rules to comply with stricter AWS account security policies if sgId := b.driver.DefaultSecurityGroupID; sgId != "" { term.Debugf("Cleaning up default Security Group rules (%s)", sgId) - if err := b.driver.RevokeDefaultSecurityGroupRules(ctx, sgId); err != nil { + if err := aws.RevokeDefaultSecurityGroupRules(ctx, sgId, b.driver.CDRegion); err != nil { term.Warnf("Could not clean up default Security Group rules: %v", err) } } @@ -195,7 +195,7 @@ func (b *ByocAws) Preview(ctx context.Context, req *client.DeployRequest) (*defa } func (b *ByocAws) deploy(ctx context.Context, req *client.DeployRequest, cmd string) (*defangv1.DeployResponse, error) { - cfg, err := b.driver.LoadConfig(ctx) + appCfg, err := b.driver.LoadConfigForApp(ctx) if err != nil { return nil, AnnotateAwsError(err) } @@ -210,7 +210,7 @@ func (b *ByocAws) deploy(ctx context.Context, req *client.DeployRequest, cmd str return nil, err } - quotaClient = NewServiceQuotasClient(cfg) + quotaClient = servicequotas.NewFromConfig(appCfg) if err = validateGPUResources(ctx, project); err != nil { return nil, err } @@ -309,7 +309,7 @@ func (b *ByocAws) putDockerHubSecret(ctx context.Context, projectName string, us return "", err } - cfg, err := b.driver.LoadConfig(ctx) + cfg, err := b.driver.LoadConfigForCD(ctx) if err != nil { return "", err } @@ -357,7 +357,7 @@ func (b *ByocAws) checkRequiresDockerHubToken(ctx context.Context, project *comp tag = "latest" } - found, err := b.driver.CheckImageExistOnPublicECR(ctx, ecrRepo, tag) + found, err := aws.CheckImageExistOnPublicECR(ctx, ecrRepo, tag) if err != nil { term.Debugf("Error checking image %q on Public ECR: %v, assuming credentials needed", image, err) found = false @@ -380,7 +380,7 @@ func (b *ByocAws) checkRequiresDockerHubToken(ctx context.Context, project *comp } func (b *ByocAws) findZone(ctx context.Context, domain, roleARN string) (string, error) { - cfg, err := b.driver.LoadConfig(ctx) + cfg, err := b.driver.LoadConfigForApp(ctx) if err != nil { return "", AnnotateAwsError(err) } @@ -413,7 +413,7 @@ func (b *ByocAws) findZone(ctx context.Context, domain, roleARN string) (string, } func (b *ByocAws) PrepareDomainDelegation(ctx context.Context, req client.PrepareDomainDelegationRequest) (*client.PrepareDomainDelegationResponse, error) { - cfg, err := b.driver.LoadConfig(ctx) + cfg, err := b.driver.LoadConfigForApp(ctx) if err != nil { return nil, AnnotateAwsError(err) } @@ -433,7 +433,7 @@ func (b *ByocAws) PrepareDomainDelegation(ctx context.Context, req client.Prepar func (b *ByocAws) AccountInfo(ctx context.Context) (*client.AccountInfo, error) { // Use STS to get the account ID - cfg, err := b.driver.LoadConfig(ctx) + cfg, err := b.driver.LoadConfigForApp(ctx) if err != nil { return nil, AnnotateAwsError(err) } @@ -470,14 +470,13 @@ func (b *ByocAws) bucketName() string { } func (b *ByocAws) environment(projectName string) (map[string]string, error) { - region := b.driver.Region // TODO: this should be the destination region, not the CD region; make customizable - defangStateUrl := fmt.Sprintf(`s3://%s?region=%s&awssdk=v2`, b.bucketName(), region) + defangStateUrl := fmt.Sprintf(`s3://%s?region=%s&awssdk=v2`, b.bucketName(), b.driver.CDRegion) pulumiBackendKey, pulumiBackendValue, err := byoc.GetPulumiBackend(defangStateUrl) if err != nil { return nil, err } env := map[string]string{ - // "AWS_REGION": region.String(), should be set by ECS (because of CD task role) + "AWS_REGION": string(b.driver.Region), "DEFANG_DEBUG": os.Getenv("DEFANG_DEBUG"), // TODO: use the global DoDebug flag "DEFANG_JSON": os.Getenv("DEFANG_JSON"), "DEFANG_ORG": string(b.TenantLabel), // Keep this as DEFANG_ORG for backward compatibility CD depends on this variable name @@ -550,7 +549,7 @@ func (b *ByocAws) runCdCommand(ctx context.Context, cmd cdCommand) (ecs.TaskArn, if os.Getenv("DEFANG_PULUMI_DIR") != "" { // Convert the environment to a human-readable array of KEY=VALUE strings for debugging - debugEnv := []string{"AWS_REGION=" + b.driver.Region.String()} + debugEnv := []string{"AWS_REGION=" + string(b.driver.Region)} if awsProfile := os.Getenv("AWS_PROFILE"); awsProfile != "" { debugEnv = append(debugEnv, "AWS_PROFILE="+awsProfile) } @@ -581,7 +580,7 @@ func (b *ByocAws) GetProjectUpdate(ctx context.Context, projectName string) (*de if bucketName == "" { if err := b.driver.FillOutputs(ctx); err != nil { // FillOutputs might fail if the stack is not created yet; return empty update in that case - var cfnErr *cfn.ErrStackNotFoundException + var cfnErr *cfn.ErrStackNotFound if errors.As(err, &cfnErr) { term.Debugf("FillOutputs: %v", err) return nil, nil // no bucket = no services yet @@ -591,7 +590,7 @@ func (b *ByocAws) GetProjectUpdate(ctx context.Context, projectName string) (*de bucketName = b.bucketName() } - cfg, err := b.driver.LoadConfig(ctx) + cfg, err := b.driver.LoadConfigForCD(ctx) if err != nil { return nil, AnnotateAwsError(err) } @@ -686,12 +685,6 @@ func (b *ByocAws) QueryLogs(ctx context.Context, req *defangv1.TailRequest) (cli term.Warnf("Unable to show CD logs: %v", err) // TODO: could skip this warning if the user wasn't asking for CD logs } - var err error - cwClient, err := cw.NewCloudWatchLogsClient(ctx, b.driver.Region) // assume all log groups are in the same region - if err != nil { - return nil, AnnotateAwsError(err) - } - // How to tail multiple tasks/services at once? // * Etag is invalid: treat Etag as CD task ID and tail only that task's logs // * No Etag, no services: tail all tasks/services @@ -702,10 +695,10 @@ func (b *ByocAws) QueryLogs(ctx context.Context, req *defangv1.TailRequest) (cli etag, err := types.ParseEtag(req.Etag) if err != nil && req.Etag != "" { // Assume invalid "etag" is the task ID of the CD task - tailStream, err = b.queryCdLogs(ctx, cwClient, req) + tailStream, err = b.queryCdLogs(ctx, req) // no need to filter events by etag because we only show logs from the specified task ID } else { - tailStream, err = b.queryLogs(ctx, cwClient, req) + tailStream, err = b.queryLogs(ctx, req) } if err != nil { @@ -714,12 +707,16 @@ func (b *ByocAws) QueryLogs(ctx context.Context, req *defangv1.TailRequest) (cli return newByocServerStream(tailStream, etag, req.Services, b, b), nil } -func (b *ByocAws) queryCdLogs(ctx context.Context, cwClient *cloudwatchlogs.Client, req *defangv1.TailRequest) (cw.LiveTailStream, error) { - var err error +func (b *ByocAws) queryCdLogs(ctx context.Context, req *defangv1.TailRequest) (cw.LiveTailStream, error) { + cwClient, err := cw.NewCloudWatchLogsClient(ctx, b.driver.CDRegion) + if err != nil { + return nil, AnnotateAwsError(err) + } b.cdTaskArn, err = b.driver.GetTaskArn(req.Etag) // only fails on missing task ID if err != nil { return nil, err } + if req.Follow { return b.driver.TailTaskID(ctx, cwClient, req.Etag) } else { @@ -729,7 +726,7 @@ func (b *ByocAws) queryCdLogs(ctx context.Context, cwClient *cloudwatchlogs.Clie } } -func (b *ByocAws) queryLogs(ctx context.Context, cwClient *cloudwatchlogs.Client, req *defangv1.TailRequest) (cw.LiveTailStream, error) { +func (b *ByocAws) queryLogs(ctx context.Context, req *defangv1.TailRequest) (cw.LiveTailStream, error) { start := timeutils.AsTime(req.Since, time.Time{}) end := timeutils.AsTime(req.Until, time.Time{}) @@ -737,72 +734,96 @@ func (b *ByocAws) queryLogs(ctx context.Context, cwClient *cloudwatchlogs.Client if len(req.Services) == 1 { service = req.Services[0] } - lgis := b.getLogGroupInputs(req.Etag, req.Project, service, req.Pattern, logs.LogType(req.LogType)) - if req.Follow { - return cw.QueryAndTailLogGroups( - ctx, - cwClient, - start, - end, - lgis..., - ) - } else { - evtsChan, errsChan := cw.QueryLogGroups( - ctx, - cwClient, - start, - end, - req.Limit, - lgis..., - ) - if evtsChan == nil { - var errs []error - for err := range errsChan { - errs = append(errs, err) - } - return nil, errors.Join(errs...) + // Escape the filter pattern to avoid problems with the CloudWatch Logs pattern syntax + // See https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html + var pattern string // TODO: add etag to filter + if req.Pattern != "" { + pattern = strconv.Quote(req.Pattern) + } + + // Split log groups by region: CD logs are in CDRegion, app logs are in app Region + cdLgis := b.getCdLogGroupInputs(req.Etag, pattern, logs.LogType(req.LogType)) + appLgis := b.getAppLogGroupInputs(req.Etag, req.Project, service, pattern, logs.LogType(req.LogType)) + + var streams []cw.LiveTailStream + if len(cdLgis) > 0 { + s, err := b.queryLogGroupsInRegion(ctx, b.driver.CDRegion, start, end, req.Follow, req.Limit, cdLgis) + if err != nil { + term.Warnf("Unable to query CD logs: %v", err) + } else { + streams = append(streams, s) + } + } + if len(appLgis) > 0 { + s, err := b.queryLogGroupsInRegion(ctx, b.driver.Region, start, end, req.Follow, req.Limit, appLgis) + if err != nil { + return nil, AnnotateAwsError(err) } - // TODO: any errors from errsChan should be reported but get dropped - return cw.NewStaticLogStream(evtsChan, func() {}), nil + streams = append(streams, s) + } + if len(streams) == 0 { + return nil, errors.New("no log groups to query") } + return cw.MergeLiveTailStreams(streams...), nil } -func (b *ByocAws) makeLogGroupARN(name string) string { - return b.driver.MakeARN("logs", "log-group:"+name) +func (b *ByocAws) queryLogGroupsInRegion(ctx context.Context, region aws.Region, start, end time.Time, follow bool, limit int32, lgis []cw.LogGroupInput) (cw.LiveTailStream, error) { + cwClient, err := cw.NewCloudWatchLogsClient(ctx, region) + if err != nil { + return nil, err + } + if follow { + return cw.QueryAndTailLogGroups(ctx, cwClient, start, end, lgis...) + } + evtsChan, errsChan := cw.QueryLogGroups(ctx, cwClient, start, end, limit, lgis...) + if evtsChan == nil { + var errs []error + for err := range errsChan { + errs = append(errs, err) + } + return nil, errors.Join(errs...) + } + // TODO: any errors from errsChan should be reported but get dropped + return cw.NewStaticLogStream(evtsChan, func() {}), nil } -func (b *ByocAws) getLogGroupInputs(etag types.ETag, projectName, service, filter string, logType logs.LogType) []cw.LogGroupInput { - // Escape the filter pattern to avoid problems with the CloudWatch Logs pattern syntax - // See https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html - var pattern string // TODO: add etag to filter - if filter != "" { - pattern = strconv.Quote(filter) - } +func (b *ByocAws) makeAppLogGroupARN(name string) string { + return b.driver.MakeARN("logs", "log-group:"+name) // app region +} +func (b *ByocAws) getCdLogGroupInputs(etag types.ETag, pattern string, logType logs.LogType) []cw.LogGroupInput { var groups []cw.LogGroupInput - // Tail CD and builds - if logType.Has(logs.LogTypeBuild) { + if logType.Has(logs.LogTypeBuild) || logType.Has(logs.LogTypeCD) { if b.driver.LogGroupARN == "" { term.Debug("CD stack LogGroupARN is not set; skipping CD logs") } else { + // FIXME: filter on etag and/or projectName cdTail := cw.LogGroupInput{LogGroupARN: b.driver.LogGroupARN, LogEventFilterPattern: pattern} // If we know the CD task ARN, only tail the logstream for that CD task; FIXME: store the task ID in the project's ProjectUpdate in S3 and use that if b.cdTaskArn != nil && b.cdEtag == etag { cdTail.LogStreamNames = []string{ecs.GetCDLogStreamForTaskID(ecs.GetTaskID(b.cdTaskArn))} } + term.Debug("Query CD logs", cdTail.LogGroupARN, cdTail.LogStreamNames, pattern) groups = append(groups, cdTail) - term.Debug("Query CD logs", cdTail.LogGroupARN, cdTail.LogStreamNames, filter) } - buildsTail := cw.LogGroupInput{LogGroupARN: b.makeLogGroupARN(b.StackDir(projectName, "builds")), LogEventFilterPattern: pattern} // must match logic in ecs/common.ts; TODO: filter by etag/service - term.Debug("Query builds logs", buildsTail.LogGroupARN, filter) + } + return groups +} + +func (b *ByocAws) getAppLogGroupInputs(etag types.ETag, projectName, service, pattern string, logType logs.LogType) []cw.LogGroupInput { + var groups []cw.LogGroupInput + // Tail build logs + if logType.Has(logs.LogTypeBuild) { + buildsTail := cw.LogGroupInput{LogGroupARN: b.makeAppLogGroupARN(b.StackDir(projectName, "builds")), LogEventFilterPattern: pattern} // must match logic in ecs/common.ts; TODO: filter by etag/service + term.Debug("Query builds logs", buildsTail.LogGroupARN, pattern) groups = append(groups, buildsTail) - ecsTail := cw.LogGroupInput{LogGroupARN: b.makeLogGroupARN(b.StackDir(projectName, "ecs")), LogEventFilterPattern: pattern} // must match logic in ecs/common.ts; TODO: filter by etag/service/deploymentId - term.Debug("Query ecs events logs", ecsTail.LogGroupARN, filter) + ecsTail := cw.LogGroupInput{LogGroupARN: b.makeAppLogGroupARN(b.StackDir(projectName, "ecs")), LogEventFilterPattern: pattern} // must match logic in ecs/common.ts; TODO: filter by etag/service/deploymentId + term.Debug("Query ecs events logs", ecsTail.LogGroupARN, pattern) groups = append(groups, ecsTail) } // Tail services if logType.Has(logs.LogTypeRun) { - servicesTail := cw.LogGroupInput{LogGroupARN: b.makeLogGroupARN(b.StackDir(projectName, "logs")), LogEventFilterPattern: pattern} // must match logic in ecs/common.ts + servicesTail := cw.LogGroupInput{LogGroupARN: b.makeAppLogGroupARN(b.StackDir(projectName, "logs")), LogEventFilterPattern: pattern} // must match logic in ecs/common.ts if service != "" && etag != "" { servicesTail.LogStreamNamePrefix = service + "/" + service + "_" + etag } diff --git a/src/pkg/cli/client/byoc/aws/list.go b/src/pkg/cli/client/byoc/aws/list.go index 4bb18de92..8d31f9ef4 100644 --- a/src/pkg/cli/client/byoc/aws/list.go +++ b/src/pkg/cli/client/byoc/aws/list.go @@ -5,12 +5,10 @@ import ( "fmt" "io" "iter" - "strings" "sync" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" "github.com/DefangLabs/defang/src/pkg/clouds/aws" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/region" "github.com/DefangLabs/defang/src/pkg/term" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" @@ -45,9 +43,8 @@ func (a s3Obj) Size() int64 { } type S3Client interface { - GetBucketLocation(ctx context.Context, params *s3.GetBucketLocationInput, optFns ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) + aws.S3Lister GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) - ListBuckets(ctx context.Context, params *s3.ListBucketsInput, optFns ...func(*s3.Options)) (*s3.ListBucketsOutput, error) ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) } @@ -91,10 +88,11 @@ 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 aws.S3Lister) (iter.Seq[string], 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{}) + // Filter by prefix: defang-cd- + buckets, err := aws.ListBucketsByPrefix(ctx, s3client, byoc.CdTaskPrefix+"-") if err != nil { return nil, AnnotateAwsError(err) } @@ -106,34 +104,11 @@ func listPulumiStacksAllRegions(ctx context.Context, s3client S3Client) (iter.Se // Filter buckets by prefix and get their locations var wg sync.WaitGroup - for _, bucket := range listBucketsOutput.Buckets { - if bucket.Name == nil { - continue - } - // Filter by prefix: defang-cd- - if !strings.HasPrefix(*bucket.Name, byoc.CdTaskPrefix+"-") { - continue - } - - // Get bucket location - locationOutput, err := s3client.GetBucketLocation(ctx, &s3.GetBucketLocationInput{ - Bucket: bucket.Name, - }) - if err != nil { - term.Debugf("Skipping bucket %s: failed to get location: %v", *bucket.Name, AnnotateAwsError(err)) - continue - } - - // GetBucketLocation returns empty string for us-east-1 buckets - bucketRegion := aws.Region(locationOutput.LocationConstraint) - if bucketRegion == "" { - bucketRegion = region.USEast1 - } - + for bucket, bucketRegion := range buckets { wg.Add(1) go func(region aws.Region) { defer wg.Done() - stacks, err := listPulumiStacksInBucket(ctx, region, *bucket.Name) + stacks, err := listPulumiStacksInBucket(ctx, region, bucket) if err != nil { return } diff --git a/src/pkg/cli/client/byoc/aws/validation.go b/src/pkg/cli/client/byoc/aws/validation.go index b888589fd..02a4abcc3 100644 --- a/src/pkg/cli/client/byoc/aws/validation.go +++ b/src/pkg/cli/client/byoc/aws/validation.go @@ -25,10 +25,6 @@ var ErrAWSNoConnection = errors.New("no connect to AWS service quotas") var ErrGPUQuotaZero = errors.New("no GPUs enabled. To resolve see https://s.defang.io/deploy-gpu") var ErrNoQuotasReceived = errors.New("no service quotas received") -func NewServiceQuotasClient(cfg aws.Config) *servicequotas.Client { - return servicequotas.NewFromConfig(cfg) -} - func hasGPUQuota(ctx context.Context) (bool, error) { if quotaClient == nil { return false, ErrAWSNoConnection diff --git a/src/pkg/cli/compose/context.go b/src/pkg/cli/compose/context.go index 40d1106a1..feb0f132e 100644 --- a/src/pkg/cli/compose/context.go +++ b/src/pkg/cli/compose/context.go @@ -265,14 +265,12 @@ func uploadArchive(ctx context.Context, provider client.Provider, projectName st } defer resp.Body.Close() if resp.StatusCode != 200 { - return "", fmt.Errorf("HTTP PUT failed with status code %v", resp.Status) + return "", fmt.Errorf("Upload failed: HTTP PUT status %v", resp.Status) } url := http.RemoveQueryParam(res.Url) - const gcpPrefix = "https://storage.googleapis.com/" - if strings.HasPrefix(url, gcpPrefix) { - url = "gs://" + url[len(gcpPrefix):] - } + // Only gs:// is supported in the URL as http get in gcpcd does not handle auth yet + url = strings.Replace(url, "https://storage.googleapis.com/", "gs://", 1) return url, nil } diff --git a/src/pkg/cli/configSet_test.go b/src/pkg/cli/configSet_test.go index e3b9486e9..13aa86fab 100644 --- a/src/pkg/cli/configSet_test.go +++ b/src/pkg/cli/configSet_test.go @@ -15,7 +15,7 @@ type mockConfigManager struct { mock.Mock } -func (m mockConfigManager) ListConfig(ctx context.Context, req *defangv1.ListConfigsRequest) (*defangv1.Secrets, error) { +func (m *mockConfigManager) ListConfig(ctx context.Context, req *defangv1.ListConfigsRequest) (*defangv1.Secrets, error) { args := m.Called(ctx, req) secret, ok := args.Get(0).(*defangv1.Secrets) if !ok && args.Get(0) != nil { @@ -24,7 +24,7 @@ func (m mockConfigManager) ListConfig(ctx context.Context, req *defangv1.ListCon return secret, args.Error(1) } -func (m mockConfigManager) PutConfig(ctx context.Context, req *defangv1.PutConfigRequest) error { +func (m *mockConfigManager) PutConfig(ctx context.Context, req *defangv1.PutConfigRequest) error { args := m.Called(ctx, req) return args.Error(0) } diff --git a/src/pkg/clouds/aws/common.go b/src/pkg/clouds/aws/common.go index 891c205f4..ff652b166 100644 --- a/src/pkg/clouds/aws/common.go +++ b/src/pkg/clouds/aws/common.go @@ -9,21 +9,33 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials/processcreds" + "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/aws/aws-sdk-go-v2/service/sts" ) -type Region string +type Region = types.VPCRegion type Aws struct { AccountID string Region Region } -func (r Region) String() string { - return string(r) +func MakeARN(service string, region Region, accountID, resource string) string { + return strings.Join([]string{ + "arn", + "aws", + service, + string(region), + accountID, + resource, + }, ":") } -func (a *Aws) LoadConfig(ctx context.Context) (aws.Config, error) { +func (a *Aws) MakeARN(service, resource string) string { + return MakeARN(service, a.Region, a.AccountID, resource) +} + +func (a *Aws) LoadConfigForApp(ctx context.Context) (aws.Config, error) { cfg, err := LoadDefaultConfig(ctx, a.Region) if err != nil { return cfg, err diff --git a/src/pkg/clouds/aws/cw/stream.go b/src/pkg/clouds/aws/cw/stream.go index bc279b217..eb388948c 100644 --- a/src/pkg/clouds/aws/cw/stream.go +++ b/src/pkg/clouds/aws/cw/stream.go @@ -3,6 +3,7 @@ package cw import ( "context" "errors" + "sync" "time" "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" @@ -143,6 +144,33 @@ func newEventStream(cancel func()) *eventStream { } } +// MergeLiveTailStreams merges multiple LiveTailStreams into one. +func MergeLiveTailStreams(streams ...LiveTailStream) LiveTailStream { + if len(streams) == 1 { + return streams[0] + } + ctx, cancel := context.WithCancel(context.Background()) + es := newEventStream(func() { + cancel() + for _, s := range streams { + s.Close() + } + }) + var wg sync.WaitGroup + for _, s := range streams { + wg.Add(1) + go func() { + defer wg.Done() + es.err = es.pipeEvents(ctx, s) + }() + } + go func() { + wg.Wait() + close(es.ch) + }() + return es +} + func NewStaticLogStream(ch <-chan LogEvent, cancel func()) EventStream[types.StartLiveTailResponseStream] { es := newEventStream(cancel) diff --git a/src/pkg/clouds/aws/ec2.go b/src/pkg/clouds/aws/ec2.go index d5414522f..cdc8af62b 100644 --- a/src/pkg/clouds/aws/ec2.go +++ b/src/pkg/clouds/aws/ec2.go @@ -10,8 +10,8 @@ import ( "github.com/aws/smithy-go/ptr" ) -func (a *Aws) RevokeDefaultSecurityGroupRules(ctx context.Context, sgId string) error { - cfg, err := a.LoadConfig(ctx) +func RevokeDefaultSecurityGroupRules(ctx context.Context, sgId string, region Region) error { + cfg, err := LoadDefaultConfig(ctx, region) if err != nil { return err } diff --git a/src/pkg/clouds/aws/ecs/cfn/setup.go b/src/pkg/clouds/aws/ecs/cfn/setup.go index 3173cf352..cfb759c4b 100644 --- a/src/pkg/clouds/aws/ecs/cfn/setup.go +++ b/src/pkg/clouds/aws/ecs/cfn/setup.go @@ -11,12 +11,12 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/clouds" - common "github.com/DefangLabs/defang/src/pkg/clouds/aws" + "github.com/DefangLabs/defang/src/pkg/clouds/aws" awsecs "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/region" "github.com/DefangLabs/defang/src/pkg/term" "github.com/aws/aws-sdk-go-v2/service/cloudformation" cfnTypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/smithy-go" "github.com/aws/smithy-go/ptr" ) @@ -37,38 +37,84 @@ func OptionVPCAndSubnetID(ctx context.Context, vpcID, subnetID string) func(clou } } -func New(stack string, region region.Region) *AwsEcsCfn { +func New(stack string, region aws.Region) *AwsEcsCfn { if stack == "" { panic("stack must be set") } return &AwsEcsCfn{ stackName: stack, AwsEcs: awsecs.AwsEcs{ - Aws: common.Aws{Region: region}, + Aws: aws.Aws{Region: region}, + CDRegion: region, // assume CD runs in the same region as the app RetainBucket: true, // Spot: true, }, } } -func (a *AwsEcsCfn) newClient(ctx context.Context) (*cloudformation.Client, error) { - cfg, err := a.LoadConfig(ctx) +func (a *AwsEcsCfn) newCloudFormationClient(ctx context.Context) (*cloudformation.Client, error) { + cfg, err := a.LoadConfigForCD(ctx) if err != nil { return nil, err } - return cloudformation.NewFromConfig(cfg), nil } +func withRegion(region aws.Region) func(*cloudformation.Options) { + return func(opts *cloudformation.Options) { + if region != "" { + opts.Region = string(region) + } + } +} + +func (a *AwsEcsCfn) describeStacksAllRegions(ctx context.Context, cfn *cloudformation.Client) (*cloudformation.DescribeStacksOutput, error) { + input := &cloudformation.DescribeStacksInput{StackName: &a.stackName} + + // First, try the current region of the CloudFormation client + var noStackErr error + if dso, err := cfn.DescribeStacks(ctx, input); err != nil { + err = annotateCfnError(err) + if snf := new(ErrStackNotFound); !errors.As(err, &snf) { + return nil, err + } + // CD stack not found in this region; try other regions before returning not found error + noStackErr = err + } else { + return dso, nil + } + + // Use a single S3 query to list all buckets with the defang-cd- prefix + // This is faster than calling CloudFormation DescribeStacks in each region + cfg, err := a.LoadConfigForCD(ctx) + if err != nil { + return nil, err + } + buckets, err := aws.ListBucketsByPrefix(ctx, s3.NewFromConfig(cfg), a.stackName+"-") + if err != nil { + return nil, err + } + for _, bucketRegion := range buckets { + if bucketRegion == a.CDRegion { + continue // skip, already done above + } + if dso, err := cfn.DescribeStacks(ctx, input, withRegion(bucketRegion)); err == nil { + a.CDRegion = bucketRegion + term.Debug("Reusing CloudFormation stack in region", bucketRegion) + return dso, nil + } + } + return nil, noStackErr +} + func (a *AwsEcsCfn) updateStackAndWait(ctx context.Context, templateBody string, parameters []cfnTypes.Parameter) error { - cfn, err := a.newClient(ctx) + cfn, err := a.newCloudFormationClient(ctx) if err != nil { return err } // Check the template version first, to avoid updating to an outdated template; TODO: can we use StackPolicy/Conditions instead? - // TODO: should check all regions - if dso, err := cfn.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{StackName: &a.stackName}); err == nil && len(dso.Stacks) == 1 { + if dso, err := a.describeStacksAllRegions(ctx, cfn); err == nil && len(dso.Stacks) == 1 { for _, output := range dso.Stacks[0].Outputs { if *output.OutputKey == OutputsTemplateVersion { deployedRev, _ := strconv.Atoi(*output.OutputValue) @@ -108,7 +154,7 @@ func (a *AwsEcsCfn) updateStackAndWait(ctx context.Context, templateBody string, return err // might call createStackAndWait depending on the error } - term.Info("Waiting for CloudFormation stack", a.stackName, "to be updated...") // TODO: verbose only + term.Infof("Waiting for CloudFormation stack %s to be updated in %s...", a.stackName, a.CDRegion) dso, err := cloudformation.NewStackUpdateCompleteWaiter(cfn, update1s).WaitForOutput(ctx, &cloudformation.DescribeStacksInput{ StackName: uso.StackId, }, stackTimeout) @@ -119,7 +165,7 @@ func (a *AwsEcsCfn) updateStackAndWait(ctx context.Context, templateBody string, } func (a *AwsEcsCfn) createStackAndWait(ctx context.Context, templateBody string, parameters []cfnTypes.Parameter) error { - cfn, err := a.newClient(ctx) + cfn, err := a.newCloudFormationClient(ctx) if err != nil { return err } @@ -140,7 +186,7 @@ func (a *AwsEcsCfn) createStackAndWait(ctx context.Context, templateBody string, } } - term.Info("Waiting for CloudFormation stack", a.stackName, "to be created...") // TODO: verbose only + term.Infof("Waiting for CloudFormation stack %s to be created in %s...", a.stackName, a.CDRegion) dso, err := cloudformation.NewStackCreateCompleteWaiter(cfn, create1s).WaitForOutput(ctx, &cloudformation.DescribeStacksInput{ StackName: ptr.String(a.stackName), }, stackTimeout) @@ -203,8 +249,8 @@ func (a *AwsEcsCfn) upsertStackAndWait(ctx context.Context, templateBody []byte, // Upsert with parameters if err := a.updateStackAndWait(ctx, string(templateBody), parameters); err != nil { // Check if the stack doesn't exist; if so, create it, otherwise return the error - var apiError smithy.APIError - if ok := errors.As(err, &apiError); !ok || (apiError.ErrorCode() != "ValidationError") || !strings.HasSuffix(apiError.ErrorMessage(), "does not exist") { + err = annotateCfnError(err) + if snf := new(ErrStackNotFound); !errors.As(err, &snf) { return err } return a.createStackAndWait(ctx, string(templateBody), parameters) @@ -212,10 +258,19 @@ func (a *AwsEcsCfn) upsertStackAndWait(ctx context.Context, templateBody []byte, return nil } -type ErrStackNotFoundException = cfnTypes.StackNotFoundException +type ErrStackNotFound = cfnTypes.StackNotFoundException + +func annotateCfnError(err error) error { + // Check if the stack doesn't exist (ValidationError); if so, return a nice error; workaround for https://github.com/aws/aws-sdk-go-v2/issues/2296 + var ae smithy.APIError + if errors.As(err, &ae) && ae.ErrorCode() == "ValidationError" && strings.HasSuffix(ae.ErrorMessage(), " does not exist") { + err = &ErrStackNotFound{Message: ptr.String(ae.ErrorMessage())} + } + return err +} func (a *AwsEcsCfn) FillOutputs(ctx context.Context) error { - cfn, err := a.newClient(ctx) + cfn, err := a.newCloudFormationClient(ctx) if err != nil { return err } @@ -225,12 +280,7 @@ func (a *AwsEcsCfn) FillOutputs(ctx context.Context) error { StackName: ptr.String(a.stackName), }) if err != nil { - // Check if the stack doesn't exist (ValidationError); if so, return a nice error; https://github.com/aws/aws-sdk-go-v2/issues/2296 - var ae smithy.APIError - if errors.As(err, &ae) && ae.ErrorCode() == "ValidationError" { - return &ErrStackNotFoundException{Message: ptr.String(ae.ErrorMessage())} - } - return err + return annotateCfnError(err) } return a.fillWithOutputs(dso) @@ -265,7 +315,7 @@ func (a *AwsEcsCfn) fillWithOutputs(dso *cloudformation.DescribeStacksOutput) er } if a.AccountID == "" && a.LogGroupARN != "" { - a.AccountID = common.GetAccountID(a.LogGroupARN) + a.AccountID = aws.GetAccountID(a.LogGroupARN) } return nil } @@ -300,7 +350,7 @@ func (a *AwsEcsCfn) GetInfo(ctx context.Context, taskArn awsecs.TaskArn) (*cloud } func (a *AwsEcsCfn) TearDown(ctx context.Context) error { - cfn, err := a.newClient(ctx) + cfn, err := a.newCloudFormationClient(ctx) if err != nil { return err } @@ -320,7 +370,7 @@ func (a *AwsEcsCfn) TearDown(ctx context.Context) error { return err } - term.Info("Waiting for CloudFormation stack", a.stackName, "to be deleted...") // TODO: verbose only + term.Infof("Waiting for CloudFormation stack %s to be deleted in %s...", a.stackName, a.CDRegion) return cloudformation.NewStackDeleteCompleteWaiter(cfn, delete1s).Wait(ctx, &cloudformation.DescribeStacksInput{ StackName: ptr.String(a.stackName), }, stackTimeout) diff --git a/src/pkg/clouds/aws/ecs/cfn/setup_test.go b/src/pkg/clouds/aws/ecs/cfn/setup_test.go index 99364e3be..256a1c40d 100644 --- a/src/pkg/clouds/aws/ecs/cfn/setup_test.go +++ b/src/pkg/clouds/aws/ecs/cfn/setup_test.go @@ -5,12 +5,14 @@ package cfn import ( "context" "io" + "os" "testing" "time" "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/clouds" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/region" + "github.com/DefangLabs/defang/src/pkg/clouds/aws" + "github.com/stretchr/testify/assert" ) func TestCloudFormation(t *testing.T) { @@ -19,13 +21,14 @@ func TestCloudFormation(t *testing.T) { } user := pkg.GetCurrentUser() // avoid conflict with other users in the same account - aws := New("crun-test-"+user, region.Region("us-west-2")) - if aws == nil { + cfn := New("crun-test-"+user, aws.Region("us-west-2")) + if cfn == nil { t.Fatal("aws is nil") } - aws.RetainBucket = false // delete bucket after test - aws.Spot = true + cfn.RetainBucket = false // delete bucket after test + cfn.Spot = true + println("AWS_PROFILE=", os.Getenv("AWS_PROFILE")) ctx := t.Context() t.Run("SetUp", func(t *testing.T) { @@ -34,19 +37,40 @@ func TestCloudFormation(t *testing.T) { t.Setenv("DOCKERHUB_USERNAME", "defanglabs2") t.Setenv("DOCKERHUB_ACCESS_TOKEN", "defanglabs") - err := aws.SetUp(ctx, testContainers) + err := cfn.SetUp(ctx, testContainers) if err != nil { t.Fatal(err) } - if aws.BucketName == "" { + if cfn.BucketName == "" { t.Error("bucket name is empty") } }) + t.Run("Use other region", func(t *testing.T) { + cfnOtherRegion := New("crun-test-"+user, aws.Region("us-east-1")) + err := cfnOtherRegion.SetUp(ctx, testContainers) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, cfn.Region, cfnOtherRegion.Region) + assert.Equal(t, cfn.AccountID, cfnOtherRegion.AccountID) + assert.Equal(t, cfn.BucketName, cfnOtherRegion.BucketName) + assert.Equal(t, cfn.CIRoleARN, cfnOtherRegion.CIRoleARN) + assert.Equal(t, cfn.ClusterName, cfnOtherRegion.ClusterName) + assert.Equal(t, cfn.DefaultSecurityGroupID, cfnOtherRegion.DefaultSecurityGroupID) + assert.Equal(t, cfn.LogGroupARN, cfnOtherRegion.LogGroupARN) + assert.Equal(t, cfn.RetainBucket, cfnOtherRegion.RetainBucket) + assert.Equal(t, cfn.SecurityGroupID, cfnOtherRegion.SecurityGroupID) + assert.Equal(t, cfn.Spot, cfnOtherRegion.Spot) + assert.Equal(t, cfn.SubNetID, cfnOtherRegion.SubNetID) + assert.Equal(t, cfn.TaskDefARN, cfnOtherRegion.TaskDefARN) + assert.Equal(t, cfn.VpcID, cfnOtherRegion.VpcID) + }) + var taskid clouds.TaskID t.Run("Run", func(t *testing.T) { var err error - taskid, err = aws.Run(ctx, nil, "echo", "hello") + taskid, err = cfn.Run(ctx, nil, "echo", "hello") if err != nil { t.Fatal(err) } @@ -60,8 +84,8 @@ func TestCloudFormation(t *testing.T) { t.Skip("task id is empty") } ctx, cancel := context.WithTimeout(ctx, time.Minute) - defer cancel() - err := aws.Tail(ctx, taskid) + t.Cleanup(cancel) + err := cfn.Tail(ctx, taskid) if err != nil && err != io.EOF { t.Fatal(err) } @@ -71,7 +95,7 @@ func TestCloudFormation(t *testing.T) { if taskid == nil { t.Skip("task id is empty") } - err := aws.Stop(ctx, taskid) + err := cfn.Stop(ctx, taskid) if err != nil { t.Fatal(err) } @@ -79,7 +103,7 @@ func TestCloudFormation(t *testing.T) { t.Run("Teardown", func(t *testing.T) { // This will fail if the task is still running - err := aws.TearDown(ctx) + err := cfn.TearDown(ctx) if err != nil { t.Fatal(err) } diff --git a/src/pkg/clouds/aws/ecs/common.go b/src/pkg/clouds/aws/ecs/common.go index 8d013516a..ebd3cee89 100644 --- a/src/pkg/clouds/aws/ecs/common.go +++ b/src/pkg/clouds/aws/ecs/common.go @@ -1,10 +1,12 @@ package ecs import ( + "context" "strings" "github.com/DefangLabs/defang/src/pkg/clouds" "github.com/DefangLabs/defang/src/pkg/clouds/aws" + awsx "github.com/aws/aws-sdk-go-v2/aws" ) const ( @@ -18,6 +20,7 @@ type TaskArn = clouds.TaskID type AwsEcs struct { aws.Aws + CDRegion aws.Region BucketName string CIRoleARN string ClusterName string @@ -31,6 +34,15 @@ type AwsEcs struct { VpcID string } +func (a *AwsEcs) LoadConfigForCD(ctx context.Context) (awsx.Config, error) { + cfg, err := aws.LoadDefaultConfig(ctx, a.CDRegion) + // If we don't have an region override for CD, use the current AWS region + if a.CDRegion == "" { + a.CDRegion = aws.Region(cfg.Region) + } + return cfg, err +} + func PlatformToArchOS(platform string) (string, string) { parts := strings.SplitN(platform, "/", 3) // Can be "os/arch/variant" like "linux/arm64/v8" @@ -58,12 +70,12 @@ func (a *AwsEcs) GetVpcID() string { return a.VpcID } -func (a *AwsEcs) MakeARN(service, resource string) string { +func (a *AwsEcs) MakeCdARN(service, resource string) string { return strings.Join([]string{ "arn", "aws", service, - string(a.Region), + string(a.CDRegion), a.AccountID, resource, }, ":") diff --git a/src/pkg/clouds/aws/ecs/info.go b/src/pkg/clouds/aws/ecs/info.go index aa7238b0c..530e15bfc 100644 --- a/src/pkg/clouds/aws/ecs/info.go +++ b/src/pkg/clouds/aws/ecs/info.go @@ -11,7 +11,7 @@ import ( ) func (a AwsEcs) Info(ctx context.Context, id TaskArn) (*clouds.TaskInfo, error) { - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForCD(ctx) if err != nil { return nil, err } diff --git a/src/pkg/clouds/aws/ecs/run.go b/src/pkg/clouds/aws/ecs/run.go index 1728d2f20..5a16011ba 100644 --- a/src/pkg/clouds/aws/ecs/run.go +++ b/src/pkg/clouds/aws/ecs/run.go @@ -18,7 +18,7 @@ import ( const taskCount = 1 func (a *AwsEcs) PopulateVPCandSubnetID(ctx context.Context, vpcID, subnetID string) error { - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForCD(ctx) if err != nil { return err } @@ -39,7 +39,7 @@ var sanitizeStartedBy = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) // letters (upperc func (a *AwsEcs) Run(ctx context.Context, env map[string]string, cmd ...string) (TaskArn, error) { // a.Refresh(ctx) - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForCD(ctx) if err != nil { return nil, err } diff --git a/src/pkg/clouds/aws/ecs/stop.go b/src/pkg/clouds/aws/ecs/stop.go index 4162a928d..c33f84441 100644 --- a/src/pkg/clouds/aws/ecs/stop.go +++ b/src/pkg/clouds/aws/ecs/stop.go @@ -9,7 +9,7 @@ import ( ) func (a AwsEcs) Stop(ctx context.Context, id clouds.TaskID) error { - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForCD(ctx) if err != nil { return err } diff --git a/src/pkg/clouds/aws/ecs/tail.go b/src/pkg/clouds/aws/ecs/tail.go index fe201ee32..7e35ecea4 100644 --- a/src/pkg/clouds/aws/ecs/tail.go +++ b/src/pkg/clouds/aws/ecs/tail.go @@ -61,7 +61,7 @@ func (a *AwsEcs) GetTaskArn(taskID string) (TaskArn, error) { if a.ClusterName == "" { return nil, errors.New("ClusterName is required") } - taskArn := a.MakeARN("ecs", "task/"+a.ClusterName+"/"+taskID) + taskArn := a.MakeCdARN("ecs", "task/"+a.ClusterName+"/"+taskID) return &taskArn, nil } diff --git a/src/pkg/clouds/aws/ecs/upload.go b/src/pkg/clouds/aws/ecs/upload.go index 9a9c05e7b..0554fe485 100644 --- a/src/pkg/clouds/aws/ecs/upload.go +++ b/src/pkg/clouds/aws/ecs/upload.go @@ -16,7 +16,7 @@ var s3InvalidCharsRegexp = regexp.MustCompile(`[^a-zA-Z0-9!_.*'()-]`) const prefix = "uploads/" func (a *AwsEcs) CreateUploadURL(ctx context.Context, name string) (string, error) { - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForCD(ctx) if err != nil { return "", err } diff --git a/src/pkg/clouds/aws/publicecr.go b/src/pkg/clouds/aws/publicecr.go index 7834ddafc..5eb298281 100644 --- a/src/pkg/clouds/aws/publicecr.go +++ b/src/pkg/clouds/aws/publicecr.go @@ -19,12 +19,11 @@ type PublicECRAPI interface { var ecrPublicAuthToken string var newPublicECRClientFromConfig = func(cfg aws.Config) PublicECRAPI { - cfg.Region = "us-east-1" // ECR Public is only in us-east-1 return ecrpublic.NewFromConfig(cfg) } -func (a *Aws) CheckImageExistOnPublicECR(ctx context.Context, repo, tag string) (bool, error) { - cfg, err := a.LoadConfig(ctx) +func CheckImageExistOnPublicECR(ctx context.Context, repo, tag string) (bool, error) { + cfg, err := LoadDefaultConfig(ctx, "us-east-1") // ECR Public is only in us-east-1 if err != nil { return false, err } diff --git a/src/pkg/clouds/aws/publicecr_test.go b/src/pkg/clouds/aws/publicecr_test.go index f6cb559bd..e86770a43 100644 --- a/src/pkg/clouds/aws/publicecr_test.go +++ b/src/pkg/clouds/aws/publicecr_test.go @@ -91,8 +91,7 @@ func TestCheckImageExistOnPublicECR(t *testing.T) { defanghttp.DefaultClient = &http.Client{Transport: &MockHTTPRoundTripper{StatusCode: tt.mockStatusCode, Body: tt.mockBody}} newPublicECRClientFromConfig = func(cfg aws.Config) PublicECRAPI { return MockPublicECRClient{} } - awsInstance := &Aws{Region: "us-west-2"} - exists, err := awsInstance.CheckImageExistOnPublicECR(t.Context(), tt.repo, tt.tag) + exists, err := CheckImageExistOnPublicECR(t.Context(), tt.repo, tt.tag) if err != nil { if tt.expectedError == "" { t.Fatalf("unexpected error: %v", err) diff --git a/src/pkg/clouds/aws/region/region.go b/src/pkg/clouds/aws/region/region.go index 9b3359cb0..98342c503 100644 --- a/src/pkg/clouds/aws/region/region.go +++ b/src/pkg/clouds/aws/region/region.go @@ -49,3 +49,8 @@ func FromArn(arn string) Region { } return Region(parts[3]) } + +func Values() []Region { + var zero Region + return zero.Values() +} diff --git a/src/pkg/clouds/aws/s3.go b/src/pkg/clouds/aws/s3.go index 4714c1516..d3572577b 100644 --- a/src/pkg/clouds/aws/s3.go +++ b/src/pkg/clouds/aws/s3.go @@ -1,8 +1,13 @@ package aws import ( + "context" "errors" + "iter" + "strings" + "github.com/DefangLabs/defang/src/pkg/term" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" ) @@ -13,3 +18,42 @@ func IsS3NoSuchKeyError(err error) bool { var e *types.NoSuchKey return errors.As(err, &e) } + +type S3Lister interface { + GetBucketLocation(ctx context.Context, params *s3.GetBucketLocationInput, optFns ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) + ListBuckets(ctx context.Context, params *s3.ListBucketsInput, optFns ...func(*s3.Options)) (*s3.ListBucketsOutput, error) +} + +func ListBucketsByPrefix(ctx context.Context, s3client S3Lister, prefix string) (iter.Seq2[string, Region], error) { + out, err := s3client.ListBuckets(ctx, &s3.ListBucketsInput{}) + if err != nil { + return nil, err + } + return func(yield func(string, Region) bool) { + for _, bucket := range out.Buckets { + if bucket.Name == nil { + continue + } + // Filter by prefix + if !strings.HasPrefix(*bucket.Name, prefix) { + continue + } + // Get bucket location + locationOutput, err := s3client.GetBucketLocation(ctx, &s3.GetBucketLocationInput{ + Bucket: bucket.Name, + }) + if err != nil { + term.Debugf("Skipping bucket %s: failed to get location: %v", *bucket.Name, err) + continue + } + // GetBucketLocation returns empty LocationConstraint for us-east-1 buckets + bucketRegion := Region(locationOutput.LocationConstraint) + if bucketRegion == "" { + bucketRegion = "us-east-1" + } + if !yield(*bucket.Name, bucketRegion) { + break + } + } + }, nil +} diff --git a/src/pkg/clouds/aws/secrets.go b/src/pkg/clouds/aws/secrets.go index 6519c20fb..31ff8c4eb 100644 --- a/src/pkg/clouds/aws/secrets.go +++ b/src/pkg/clouds/aws/secrets.go @@ -37,7 +37,7 @@ func IsParameterNotFoundError(err error) bool { } func (a *Aws) DeleteSecrets(ctx context.Context, names ...string) error { - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForApp(ctx) if err != nil { return err } @@ -57,7 +57,7 @@ func (a *Aws) DeleteSecrets(ctx context.Context, names ...string) error { } func (a *Aws) IsValidSecret(ctx context.Context, name string) (bool, error) { - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForApp(ctx) if err != nil { return false, err } @@ -83,7 +83,7 @@ func (a *Aws) IsValidSecret(ctx context.Context, name string) (bool, error) { } func (a *Aws) PutSecret(ctx context.Context, name, value string) error { - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForApp(ctx) if err != nil { return err } @@ -112,7 +112,7 @@ func (a *Aws) ListSecrets(ctx context.Context) ([]string, error) { } func (a *Aws) ListSecretsByPrefix(ctx context.Context, prefix string) ([]string, error) { - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForApp(ctx) if err != nil { return nil, err } diff --git a/src/pkg/clouds/aws/secrets_test.go b/src/pkg/clouds/aws/secrets_test.go index a56bdc9cf..a13063e64 100644 --- a/src/pkg/clouds/aws/secrets_test.go +++ b/src/pkg/clouds/aws/secrets_test.go @@ -17,7 +17,7 @@ func TestPutSecret(t *testing.T) { a := Aws{Region: Region(pkg.Getenv("AWS_REGION", "us-west-2"))} ctx := context.Background() - cfg, err := a.LoadConfig(ctx) + cfg, err := a.LoadConfigForApp(ctx) if err != nil { t.Fatal(err) } diff --git a/src/pkg/logs/log_type.go b/src/pkg/logs/log_type.go index 4a497f257..1029aca76 100644 --- a/src/pkg/logs/log_type.go +++ b/src/pkg/logs/log_type.go @@ -16,11 +16,11 @@ func (e ErrInvalidLogType) Error() string { } const ( - _LogTypeUnused LogType = 1 << iota + LogTypeUnspecified LogType = 0 + LogTypeCD LogType = 1 << iota LogTypeRun LogTypeBuild - LogTypeUnspecified LogType = 0 - LogTypeAll LogType = 0xFFFFFFFF + LogTypeAll LogType = 0xFFFFFFFF ) var AllLogTypes = []LogType{ @@ -32,12 +32,14 @@ var AllLogTypes = []LogType{ var ( logType_name = map[LogType]string{ LogTypeUnspecified: "UNSPECIFIED", + LogTypeCD: "CD", LogTypeRun: "RUN", LogTypeBuild: "BUILD", LogTypeAll: "ALL", } logType_value = map[string]LogType{ "UNSPECIFIED": LogTypeUnspecified, + "CD": LogTypeCD, "RUN": LogTypeRun, "BUILD": LogTypeBuild, "ALL": LogTypeAll, diff --git a/src/pkg/stacks/manager.go b/src/pkg/stacks/manager.go index 83de8da48..3c6bf5798 100644 --- a/src/pkg/stacks/manager.go +++ b/src/pkg/stacks/manager.go @@ -202,7 +202,7 @@ func (sm *manager) GetStack(ctx context.Context, opts GetStackOpts) (*Parameters if opts.Default.Name != "" { return sm.getSpecifiedStack(ctx, opts.Default.Name) // TODO: merge with opts.Default? } - // use --provider if available + // use --provider if available (legacy mode) if opts.Default.Provider != client.ProviderAuto && opts.Default.Provider != "" { whence := "DEFANG_PROVIDER" var fromEnv client.ProviderID