diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index c94bc7130..23e73e8db 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-s7glqopZLTUZIMvYVhyBW9TmnZ8ijL6vMT75CQKzLcc="; + vendorHash = "sha256-qNGoltZteODqZIIWgKwYZrLGTcIIJftkdqj/dRgO1NQ="; subPackages = [ "cmd/cli" ]; diff --git a/src/go.mod b/src/go.mod index 93c157686..503a1d4cd 100644 --- a/src/go.mod +++ b/src/go.mod @@ -19,20 +19,20 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 - github.com/aws/aws-sdk-go-v2 v1.41.0 + github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/config v1.26.6 github.com/aws/aws-sdk-go-v2/service/cloudformation v1.42.6 github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.35.4 + github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 github.com/aws/aws-sdk-go-v2/service/ec2 v1.145.0 github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.7 - github.com/aws/aws-sdk-go-v2/service/ecs v1.38.1 github.com/aws/aws-sdk-go-v2/service/route53 v1.37.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.5 github.com/aws/aws-sdk-go-v2/service/servicequotas v1.25.5 github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 - github.com/aws/smithy-go v1.24.0 + github.com/aws/smithy-go v1.24.2 github.com/awslabs/goformation/v7 v7.14.9 github.com/compose-spec/compose-go/v2 v2.10.1 github.com/digitalocean/godo v1.131.1 @@ -67,7 +67,7 @@ require ( google.golang.org/api v0.236.0 google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.9 + google.golang.org/protobuf v1.36.11 ) require ( @@ -149,8 +149,8 @@ require ( github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.16.16 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect diff --git a/src/go.sum b/src/go.sum index 6368623cd..1175392ae 100644 --- a/src/go.sum +++ b/src/go.sum @@ -52,8 +52,8 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg= github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o= @@ -62,10 +62,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5g github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls= github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 h1:5oE2WzJE56/mVveuDZPJESKlg/00AaS2pY2QZcnxg4M= @@ -74,12 +74,12 @@ github.com/aws/aws-sdk-go-v2/service/cloudformation v1.42.6 h1:XdEBz/eAB4K5QyQ9f github.com/aws/aws-sdk-go-v2/service/cloudformation v1.42.6/go.mod h1:3+AceTAg/X5AUM/SkAbgxzviOBmsGaf9POso/Ymz5vc= github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.35.4 h1:QSIpvF/tE8Uoy+RNkbMpTahLZHLA1c6vi9tbSE7PZUY= github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.35.4/go.mod h1:OfO65DNsDX+wgWmjljN55I+Dzo4nbhWNlNFuco5AAgw= +github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 h1:lQTVEv/YAk8Rw1Yf4XZS/jNNxF9klCN10WcSR3xlMtU= +github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12/go.mod h1:yoa0R6Xku788EmJYkFiARzJBxt4A3hgFjQPRmMAttr0= github.com/aws/aws-sdk-go-v2/service/ec2 v1.145.0 h1:SkSW6wtJmXqJJlBxSc+0mykDdv5nhl9xifMB7JuzNVo= github.com/aws/aws-sdk-go-v2/service/ec2 v1.145.0/go.mod h1:hIsHE0PaWAQakLCshKS7VKWMGXaqrAFp4m95s2W9E6c= github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.7 h1:NHy1+Jq8gVp8fSLF6Z8SazA+R4Qzsbla/0SbHHReH4Y= github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.7/go.mod h1:KxsaVRXo+DeRMHVp65WqyM49XZiS6n74lEGQindkdgA= -github.com/aws/aws-sdk-go-v2/service/ecs v1.38.1 h1:hfIWClwFGAv6s6HSqqf5AxCToWDkgWe3gC7j4n4Iiew= -github.com/aws/aws-sdk-go-v2/service/ecs v1.38.1/go.mod h1:kt+L4lMA2nvv9evq9S6TOH1up95/2RsQG4GXfxoPRfM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 h1:L0ai8WICYHozIKK+OtPzVJBugL7culcuM4E4JOpIEm8= @@ -104,8 +104,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/awslabs/goformation/v7 v7.14.9 h1:sZjjpTqXrcBDz4Fi07JWTT7zKM68XsQkW/7iLAJbA/M= github.com/awslabs/goformation/v7 v7.14.9/go.mod h1:7obldQ8NQ/AkMsgL5K3l4lRMDFB6kCGUloz5dURcXIs= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -456,8 +456,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/src/pkg/auth/interceptor.go b/src/pkg/auth/interceptor.go index ea2d82023..88db9962b 100644 --- a/src/pkg/auth/interceptor.go +++ b/src/pkg/auth/interceptor.go @@ -2,6 +2,7 @@ package auth import ( "context" + "net/http" "strings" "connectrpc.com/connect" @@ -13,32 +14,36 @@ const TenantHeader = "X-Defang-Tenant-Id" type authInterceptor struct { authorization string requestedTenant types.TenantNameOrID + userAgent string } -func NewAuthInterceptor(token string, requestedTenant types.TenantNameOrID) connect.Interceptor { +func NewAuthInterceptor(token string, requestedTenant types.TenantNameOrID, userAgent string) connect.Interceptor { // Only tenant ID/name travels over the wire now; org header is retired. - return &authInterceptor{"Bearer " + strings.TrimSpace(token), requestedTenant} + return &authInterceptor{"Bearer " + strings.TrimSpace(token), requestedTenant, userAgent} } func (a *authInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - req.Header().Set("Authorization", a.authorization) - req.Header().Set("Content-Type", "application/grpc") // same as the gRPC client - if a.requestedTenant.IsSet() { - req.Header().Set(TenantHeader, string(a.requestedTenant)) - } + a.setHeaders(req.Header()) return next(ctx, req) } } +func (a *authInterceptor) setHeaders(header http.Header) { + if a.userAgent != "" { + header.Set("User-Agent", a.userAgent) + } + header.Set("Authorization", a.authorization) + header.Set("Content-Type", "application/grpc") // same as the gRPC client + if a.requestedTenant.IsSet() { + header.Set(TenantHeader, string(a.requestedTenant)) + } +} + func (a *authInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { conn := next(ctx, spec) - conn.RequestHeader().Set("Authorization", a.authorization) - conn.RequestHeader().Set("Content-Type", "application/grpc") // same as the gRPC client - if a.requestedTenant.IsSet() { - conn.RequestHeader().Set(TenantHeader, string(a.requestedTenant)) - } + a.setHeaders(conn.RequestHeader()) return conn } } diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index f7f975777..6afc00a25 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -21,11 +21,10 @@ import ( "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" + awscodebuild "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild" + "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild/cfn" "github.com/DefangLabs/defang/src/pkg/clouds/aws/cw" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs/cfn" "github.com/DefangLabs/defang/src/pkg/dns" "github.com/DefangLabs/defang/src/pkg/dockerhub" "github.com/DefangLabs/defang/src/pkg/http" @@ -53,11 +52,11 @@ type Config = awssdk.Config type ByocAws struct { *byoc.ByocBaseClient - driver *cfn.AwsEcsCfn // TODO: ecs is stateful, contains the output of the cd cfn stack after SetUpCD + driver *cfn.AwsCfn // TODO: ecs is stateful, contains the output of the cd cfn stack after SetUpCD cdEtag types.ETag cdStart time.Time - cdTaskArn ecs.TaskArn // for GetDeploymentStatus + cdBuildId awscodebuild.BuildID // for GetDeploymentStatus needDockerHubCreds bool } @@ -142,13 +141,8 @@ func (b *ByocAws) Authenticate(ctx context.Context, interactive bool) error { return b.driver.Authenticate(ctx, interactive) } -func (b *ByocAws) makeContainers() []clouds.Container { - return makeContainers(b.PulumiVersion, b.CDImage) -} - func (b *ByocAws) PrintCloudFormationTemplate() ([]byte, error) { - containers := b.makeContainers() - template, err := cfn.CreateTemplate(byoc.CdTaskPrefix, containers) + template, err := cfn.CreateTemplate(byoc.CdTaskPrefix) if err != nil { return nil, err } @@ -162,34 +156,30 @@ func (b *ByocAws) SetUpCD(ctx context.Context, force bool) error { term.Debugf("Using CD image: %q", b.CDImage) - created, err := b.driver.SetUp(ctx, b.makeContainers(), force) + _, err := b.driver.SetUp(ctx, force) if err != nil { return AnnotateAwsError(err) } - // Delete default SecurityGroup rules to comply with stricter AWS account security policies; - // only needed when the stack (and its VPC) is first created. - if created { - 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 { - term.Warnf("Could not clean up default Security Group rules: %v", err) - } - } - } - b.SetupDone = true return nil } +func (*ByocAws) Driver() string { + return "codebuild" +} + func (b *ByocAws) GetDeploymentStatus(ctx context.Context) (bool, error) { - done, err := b.driver.GetTaskStatus(ctx, b.cdTaskArn) + cfg, err := b.driver.LoadConfig(ctx) + if err != nil { + return false, AnnotateAwsError(err) + } + done, err := awscodebuild.GetBuildStatus(ctx, cfg, b.cdBuildId) if err != nil { - // check if the task failed; if so, return the a ErrDeploymentFailed error - if taskErr := new(ecs.TaskFailure); errors.As(err, taskErr) { - return done, client.ErrDeploymentFailed{Message: taskErr.Error()} + if buildErr := new(awscodebuild.BuildFailure); errors.As(err, buildErr) { + return done, client.ErrDeploymentFailed{Message: buildErr.Error()} } - return done, err + return done, AnnotateAwsError(err) } return done, nil } @@ -288,13 +278,13 @@ func (b *ByocAws) deploy(ctx context.Context, req *client.DeployRequest, cmd str } } - cdTaskArn, err := b.runCdCommand(ctx, cdCmd) + cdBuildId, err := b.runCdCommand(ctx, cdCmd) if err != nil { return nil, AnnotateAwsError(err) } b.cdEtag = etag b.cdStart = time.Now() - b.cdTaskArn = cdTaskArn + b.cdBuildId = cdBuildId for _, si := range serviceInfos { if si.UseAcmeCert { @@ -322,7 +312,7 @@ func (b *ByocAws) putDockerHubSecret(ctx context.Context, projectName string, us cfg, err := b.driver.LoadConfig(ctx) if err != nil { - return "", err + return "", AnnotateAwsError(err) } secretsmanagerClient := secretsmanager.NewFromConfig(cfg) @@ -535,7 +525,7 @@ type cdCommand struct { eventsUrl string } -func (b *ByocAws) runCdCommand(ctx context.Context, cmd cdCommand) (ecs.TaskArn, error) { +func (b *ByocAws) runCdCommand(ctx context.Context, cmd cdCommand) (awscodebuild.BuildID, error) { // Setup the deployment environment env, err := b.environment(cmd.project) if err != nil { @@ -586,7 +576,9 @@ func (b *ByocAws) runCdCommand(ctx context.Context, cmd cdCommand) (ecs.TaskArn, env["DEFANG_EVENTS_UPLOAD_URL"] = cmd.eventsUrl } - return b.driver.Run(ctx, env, cmd.command...) + // Prepend the entrypoint; CodeBuild runs buildspec commands in a shell, not via Docker ENTRYPOINT + args := append([]string{"node", "lib/index.js"}, cmd.command...) + return b.driver.Run(ctx, "/app", b.CDImage, env, args...) } func (b *ByocAws) GetProjectUpdate(ctx context.Context, projectName string) (*defangv1.ProjectUpdate, error) { @@ -722,8 +714,8 @@ func (b *ByocAws) QueryLogs(ctx context.Context, req *defangv1.TailRequest) (ite // * Valid Etag, no services: tail all tasks/services with that Etag // * Valid Etag, service: tail that task/service var logSeq iter.Seq2[cw.LogEvent, error] - if taskID := b.deriveTaskID(req.Etag); taskID != "" && logs.LogType(req.LogType) == logs.LogTypeCD { - cdSeq, err := b.queryOrTailLogsByTaskID(ctx, cwClient, req, taskID) + if buildID := b.deriveBuildID(req.Etag); buildID != nil && logs.LogType(req.LogType) == logs.LogTypeCD { + cdSeq, err := b.queryOrTailLogsByBuildID(ctx, cwClient, req, buildID) if err != nil { return nil, AnnotateAwsError(err) } @@ -761,32 +753,28 @@ func (b *ByocAws) QueryLogs(ctx context.Context, req *defangv1.TailRequest) (ite }, nil } -func (b *ByocAws) queryOrTailLogsByTaskID(ctx context.Context, cwClient cw.LogsClient, req *defangv1.TailRequest, taskID string) (iter.Seq2[[]cw.LogEvent, error], error) { - if b.cdTaskArn == nil { - var err error - b.cdTaskArn, err = b.driver.GetTaskArn(taskID) // only fails on missing task ID - if err != nil { - return nil, err - } - } +func (b *ByocAws) queryOrTailLogsByBuildID(ctx context.Context, cwClient cw.LogsClient, req *defangv1.TailRequest, buildID awscodebuild.BuildID) (iter.Seq2[[]cw.LogEvent, error], error) { if req.Follow { - return b.driver.TailTaskID(ctx, cwClient, taskID) + return b.driver.TailBuildID(ctx, cwClient, buildID) } else { start := timeutils.AsTime(req.Since, time.Time{}) end := timeutils.AsTime(req.Until, time.Time{}) - return b.driver.QueryTaskID(ctx, cwClient, taskID, start, end, req.Limit) + return b.driver.QueryBuildID(ctx, cwClient, buildID, start, end, req.Limit) } } -// deriveTaskID returns the CD task ID if the etag refers to a CD task, or empty string otherwise. -func (b *ByocAws) deriveTaskID(reqEtag string) string { - if b.cdTaskArn != nil && b.cdEtag == reqEtag { - return ecs.GetTaskID(b.cdTaskArn) +// deriveBuildID returns the BuildID if the etag refers to a CD CodeBuild build, or nil otherwise. +func (b *ByocAws) deriveBuildID(reqEtag string) awscodebuild.BuildID { + if reqEtag == "" { + return nil + } + if b.cdBuildId != nil && b.cdEtag == reqEtag { + return b.cdBuildId } if _, err := types.ParseEtag(reqEtag); err != nil { - return reqEtag // legacy: assume invalid etag is a task ID + return awscodebuild.BuildID(&reqEtag) // legacy: assume invalid etag is a task ID } - return "" + return nil } func (b *ByocAws) queryOrTailLogs(ctx context.Context, cwClient cw.LogsClient, req *defangv1.TailRequest) (iter.Seq2[cw.LogEvent, error], error) { @@ -862,8 +850,8 @@ func (b *ByocAws) getLogGroupInputs(etag types.ETag, projectName, service, filte } else { 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 || ecs.GetTaskID(b.cdTaskArn) == etag) { - cdTail.LogStreamNames = []string{ecs.GetCDLogStreamForTaskID(ecs.GetTaskID(b.cdTaskArn))} + if b.cdBuildId != nil && (b.cdEtag == etag || *b.cdBuildId == etag) { + cdTail.LogStreamNames = []string{awscodebuild.GetLogStreamForBuildID(b.cdBuildId)} } groups = append(groups, cdTail) term.Debug("Query CD logs", cdTail.LogGroupARN, cdTail.LogStreamNames, filter) @@ -926,13 +914,13 @@ func (b *ByocAws) CdCommand(ctx context.Context, req client.CdCommandRequest) (s statesUrl: req.StatesUrl, eventsUrl: req.EventsUrl, } - cdTaskArn, err := b.runCdCommand(ctx, cmd) + cdBuildId, err := b.runCdCommand(ctx, cmd) if err != nil { return "", AnnotateAwsError(err) } b.cdEtag = etag b.cdStart = time.Now() - b.cdTaskArn = cdTaskArn + b.cdBuildId = cdBuildId return etag, nil } diff --git a/src/pkg/cli/client/byoc/aws/byoc_integration_test.go b/src/pkg/cli/client/byoc/aws/byoc_integration_test.go index 911840af6..c70abd1e0 100644 --- a/src/pkg/cli/client/byoc/aws/byoc_integration_test.go +++ b/src/pkg/cli/client/byoc/aws/byoc_integration_test.go @@ -11,7 +11,7 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/clouds/aws" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs/cfn" + "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild/cfn" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "connectrpc.com/connect" ) diff --git a/src/pkg/cli/client/byoc/aws/byoc_test.go b/src/pkg/cli/client/byoc/aws/byoc_test.go index a397d8827..cb97f63b1 100644 --- a/src/pkg/cli/client/byoc/aws/byoc_test.go +++ b/src/pkg/cli/client/byoc/aws/byoc_test.go @@ -16,9 +16,9 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" "github.com/DefangLabs/defang/src/pkg/clouds/aws" + awscodebuild "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild" + "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild/cfn" "github.com/DefangLabs/defang/src/pkg/clouds/aws/cw" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs/cfn" "github.com/DefangLabs/defang/src/pkg/dns" "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/term" @@ -454,7 +454,7 @@ func newTestByocAws() *ByocAws { } b.driver.AccountID = "123456789012" b.driver.LogGroupARN = "arn:aws:logs:us-test-2:123456789012:log-group:defang-cd-LogGroup:*" - b.driver.ClusterName = "test-cluster" + b.driver.ProjectName = "test-project" b.ByocBaseClient = byoc.NewByocBaseClient("tenant1", b, "beta") return b } @@ -691,7 +691,7 @@ func TestQueryCdLogs(t *testing.T) { events: makeMockEvents(tt.numEvents, "crun", ""), } - batchSeq, err := b.queryOrTailLogsByTaskID(t.Context(), mock, tt.req, tt.req.Etag) + batchSeq, err := b.queryOrTailLogsByBuildID(t.Context(), mock, tt.req, awscodebuild.BuildID(&tt.req.Etag)) require.NoError(t, err) // Flatten and collect @@ -702,40 +702,37 @@ func TestQueryCdLogs(t *testing.T) { } } -// TestQueryCdLogs_FollowMode is skipped because TailTaskID polls getTaskStatus -// (real AWS ECS API) when StartLiveTail returns ResourceNotFoundException. -// Testing follow mode for CD logs requires mocking the ECS DescribeTasks API. -func TestQueryCdLogs_FollowMode(t *testing.T) { - t.Skip("requires ECS API mock for getTaskStatus") -} - -func TestDeriveTaskID(t *testing.T) { +func TestDeriveBuildID(t *testing.T) { validEtag := types.NewEtag() tests := []struct { - name string - cdTaskArn ecs.TaskArn - cdEtag string - reqEtag string - wantTaskID string + name string + cdBuildId awscodebuild.BuildID + cdEtag string + reqEtag string + wantBuildID awscodebuild.BuildID }{ { - name: "matching cd etag returns task ID from ARN", - cdTaskArn: ptr.String("arn:aws:ecs:us-west-2:123456789012:task/cluster/abc123def456"), - cdEtag: validEtag, - reqEtag: validEtag, - wantTaskID: "abc123def456", + name: "matching cd etag returns build ID", + cdBuildId: ptr.String("defang:abc123def456"), + cdEtag: validEtag, + reqEtag: validEtag, + wantBuildID: ptr.String("defang:abc123def456"), }, { - name: "invalid etag treated as legacy task ID", - reqEtag: "some-task-id", - wantTaskID: "some-task-id", + name: "invalid etag treated as legacy build ID", + reqEtag: "some-build-id", + wantBuildID: ptr.String("some-build-id"), }, { name: "valid etag not matching cd returns empty", cdEtag: "aaaaaaaaaaaa", reqEtag: "bbbbbbbbbbbb", }, + { + name: "valid etag without cd build ID returns empty", + reqEtag: "cccccccccccc", + }, { name: "empty etag returns empty", }, @@ -744,11 +741,11 @@ func TestDeriveTaskID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { b := newTestByocAws() - b.cdTaskArn = tt.cdTaskArn + b.cdBuildId = tt.cdBuildId b.cdEtag = tt.cdEtag - got := b.deriveTaskID(tt.reqEtag) - assert.Equal(t, tt.wantTaskID, got) + got := b.deriveBuildID(tt.reqEtag) + assert.Equal(t, tt.wantBuildID, got) }) } } diff --git a/src/pkg/cli/client/byoc/aws/cd.go b/src/pkg/cli/client/byoc/aws/cd.go deleted file mode 100644 index e002881ee..000000000 --- a/src/pkg/cli/client/byoc/aws/cd.go +++ /dev/null @@ -1,44 +0,0 @@ -package aws - -import ( - "github.com/DefangLabs/defang/src/pkg/cli/client/byoc" - "github.com/DefangLabs/defang/src/pkg/clouds" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs" - "github.com/aws/smithy-go/ptr" -) - -func makeContainers(pulumiVersion, cdImage string) []clouds.Container { - cdSidecarName := byoc.CdTaskPrefix - return []clouds.Container{ - { - Image: "public.ecr.aws/pulumi/pulumi-nodejs:" + pulumiVersion, - Name: ecs.CdContainerName, - Cpus: 2.0, - Memory: 2048_000_000, // 2G - Essential: ptr.Bool(true), - VolumesFrom: []string{ - cdSidecarName, - }, - WorkDir: "/app", - DependsOn: map[string]clouds.ContainerCondition{cdSidecarName: "START"}, - EntryPoint: []string{"node", "lib/index.js"}, - }, - { - Image: cdImage, - Name: cdSidecarName, - Essential: ptr.Bool(false), - Volumes: []clouds.TaskVolume{ - { - Source: "pulumi-plugins", - Target: "/root/.pulumi/plugins", - ReadOnly: true, - }, - { - Source: "cd", - Target: "/app", - ReadOnly: true, - }, - }, - }, - } -} diff --git a/src/pkg/cli/client/byoc/aws/validation_test.go b/src/pkg/cli/client/byoc/aws/validation_test.go index a444f56aa..695a97644 100644 --- a/src/pkg/cli/client/byoc/aws/validation_test.go +++ b/src/pkg/cli/client/byoc/aws/validation_test.go @@ -8,7 +8,7 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client" "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/ecs/cfn" + "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild/cfn" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/aws/aws-sdk-go-v2/service/servicequotas" quotaTypes "github.com/aws/aws-sdk-go-v2/service/servicequotas/types" diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index 7a8fe1fd8..efe02d769 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -671,6 +671,10 @@ func (b *ByocDo) environment(projectName, delegateDomain string, mode defangv1.D return env, nil } +func (*ByocDo) Driver() string { + return "app platform" +} + func (b *ByocDo) SetUpCD(ctx context.Context, force bool) error { if b.SetupDone { return nil diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index df11c99f6..65d0e268f 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -165,6 +165,10 @@ func getGcpProjectID() string { return projectId } +func (*ByocGcp) Driver() string { + return "cloudbuild" +} + func (b *ByocGcp) SetUpCD(ctx context.Context, force bool) error { if b.setupDone { return nil diff --git a/src/pkg/cli/client/caniuse.go b/src/pkg/cli/client/caniuse.go index 46b74266b..eb9d5b58d 100644 --- a/src/pkg/cli/client/caniuse.go +++ b/src/pkg/cli/client/caniuse.go @@ -41,14 +41,20 @@ func CanIUseProvider(ctx context.Context, client FabricClient, provider Provider Stack: provider.GetStackName(), PreferCdVersion: preferCdVersion, PreferPulumiVersion: preferPulumiVersion, + Driver: provider.Driver(), }) if err != nil { return err } + forcedReason := resp.ForcedReason + if resp.ForcedVersion && forcedReason == "" { + forcedReason = "the previous CD image is incompatible with your CLI" + } + // Resolve each version: env override > client-side pinning > fabric response - resp.CdImage = resolveVersion(cdOverride, resp.CdImage, preferCdVersion, "CD image", allowUpgrade, resp.ForcedVersion) - resp.PulumiVersion = resolveVersion(pulumiOverride, resp.PulumiVersion, preferPulumiVersion, "Pulumi version", allowUpgrade, resp.ForcedVersion) + resp.CdImage = resolveVersion(cdOverride, resp.CdImage, preferCdVersion, "CD image", allowUpgrade, forcedReason) + resp.PulumiVersion = resolveVersion(pulumiOverride, resp.PulumiVersion, preferPulumiVersion, "Pulumi version", allowUpgrade, forcedReason) provider.SetCanIUseConfig(resp) return nil @@ -57,24 +63,24 @@ func CanIUseProvider(ctx context.Context, client FabricClient, provider Provider type versionLabel string // resolveVersion picks the version to use: env override > force upgrade > allow upgrade > pin to previous > latest. -func resolveVersion(fromEnv, fromServer, previous string, label versionLabel, allowUpgrade, serverForced bool) string { +func resolveVersion(fromEnv, fromFabric, previous string, label versionLabel, allowUpgrade bool, forcedReason string) string { if fromEnv != "" { term.Debugf("Using %s from env: %s", label, fromEnv) return fromEnv } - if previous == "" || fromServer == previous { - term.Debugf("Using %s: %s", label, fromServer) - return fromServer + if previous == "" || fromFabric == previous { + term.Debugf("Using %s: %s", label, fromFabric) + return fromFabric } - if serverForced { - term.Debugf("Using %s from server: %s", label, fromServer) - term.Warnf("Force-upgrading %s...", label) - return fromServer + if forcedReason != "" { + term.Debugf("Using %s from fabric: %s", label, fromFabric) + term.Warnf("Overriding %s: %s", label, forcedReason) + return fromFabric } if allowUpgrade { - term.Debugf("Using latest %s: %s", label, fromServer) - term.Infof("Upgrading %s...", label) - return fromServer + term.Debugf("Using latest %s: %s", label, fromFabric) + term.Infof("Upgrading %s to latest", label) + return fromFabric } term.Debugf("Using previous %s: %s", label, previous) term.Warnf("A newer %s is available; using previously deployed version. To upgrade, re-run with --allow-upgrade or set DEFANG_ALLOW_UPGRADE=1", label) diff --git a/src/pkg/cli/client/caniuse_test.go b/src/pkg/cli/client/caniuse_test.go index 05d1df285..d72645e3b 100644 --- a/src/pkg/cli/client/caniuse_test.go +++ b/src/pkg/cli/client/caniuse_test.go @@ -22,6 +22,10 @@ func (m *mockCanIUseProvider) AccountInfo(context.Context) (*AccountInfo, error) return &AccountInfo{AccountID: "123", Provider: ProviderAWS, Region: "us-east-1"}, nil } +func (*mockCanIUseProvider) Driver() string { + return "mock-driver" +} + func (m *mockCanIUseProvider) GetProjectUpdate(_ context.Context, _ string) (*defangv1.ProjectUpdate, error) { if m.projectErr != nil { return nil, m.projectErr @@ -310,7 +314,7 @@ func TestPinVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := resolveVersion("", tt.latest, tt.previous, "test", tt.upgrade, false) + got := resolveVersion("", tt.latest, tt.previous, "test", tt.upgrade, "") if got != tt.want { t.Errorf("resolveVersion(%q, %q, upgrade=%v) = %q, want %q", tt.latest, tt.previous, tt.upgrade, got, tt.want) } diff --git a/src/pkg/cli/client/grpc.go b/src/pkg/cli/client/grpc.go index e589540e7..6f8e2737d 100644 --- a/src/pkg/cli/client/grpc.go +++ b/src/pkg/cli/client/grpc.go @@ -37,7 +37,7 @@ func NewGrpcClient(host, accessToken string, requestedTenant types.TenantNameOrI connect.WithGRPC(), connect.WithInterceptors( grpcLogger{"fabricClient"}, - auth.NewAuthInterceptor(accessToken, requestedTenant), + auth.NewAuthInterceptor(accessToken, requestedTenant, ""), Retrier{}, ), ) diff --git a/src/pkg/cli/client/grpc_logger.go b/src/pkg/cli/client/grpc_logger.go index 393f55c4b..a2b32c409 100644 --- a/src/pkg/cli/client/grpc_logger.go +++ b/src/pkg/cli/client/grpc_logger.go @@ -3,6 +3,7 @@ package client import ( "context" "encoding/json" + "net/http" "connectrpc.com/connect" "github.com/DefangLabs/defang/src/pkg" @@ -17,13 +18,6 @@ type grpcLogger struct { func (g grpcLogger) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - // Add a request ID to the context - requestId := pkg.RandomID() - req.Header().Add("X-Request-Id", requestId) - - // Get the request type name - reqType := req.Spec().Procedure - // Convert request payload to JSON for logging payload, err := json.Marshal(req.Any()) if err != nil { @@ -35,23 +29,22 @@ func (g grpcLogger) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { payload = append(payload[:maxPayloadLength], []byte("…")...) } - term.Debug(g.prefix, requestId, reqType, string(payload)) + g.logRequest(req.Header(), req.Spec().Procedure, string(payload)) return next(ctx, req) } } +func (g grpcLogger) logRequest(header http.Header, reqType, payload string) { + // Add a request ID to the context + requestId := pkg.RandomID() + header.Set("X-Request-Id", requestId) + + term.Debug(g.prefix, requestId, reqType, payload) +} func (g grpcLogger) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { conn := next(ctx, spec) - - // Add a request ID to the context - requestId := pkg.RandomID() - conn.RequestHeader().Add("X-Request-Id", requestId) - - // Get the request type name - reqType := spec.Procedure - - term.Debug(g.prefix, requestId, reqType, "streaming connection established") + g.logRequest(conn.RequestHeader(), spec.Procedure, "streaming connection established") return conn } } diff --git a/src/pkg/cli/client/playground.go b/src/pkg/cli/client/playground.go index e36d2fed2..178f454dc 100644 --- a/src/pkg/cli/client/playground.go +++ b/src/pkg/cli/client/playground.go @@ -50,6 +50,10 @@ func (g *PlaygroundProvider) GetDeploymentStatus(ctx context.Context) (bool, err return true, io.EOF // TODO: implement on fabric, for now assume service is deployed } +func (*PlaygroundProvider) Driver() string { + return "playground" +} + func (g *PlaygroundProvider) Preview(ctx context.Context, req *DeployRequest) (*defangv1.DeployResponse, error) { req.Preview = true return g.Deploy(ctx, req) diff --git a/src/pkg/cli/client/provider.go b/src/pkg/cli/client/provider.go index 03fc183fe..24ca05bb0 100644 --- a/src/pkg/cli/client/provider.go +++ b/src/pkg/cli/client/provider.go @@ -75,6 +75,7 @@ type Provider interface { DelayBeforeRetry(context.Context) error DeleteConfig(context.Context, *defangv1.Secrets) error Deploy(context.Context, *DeployRequest) (*defangv1.DeployResponse, error) + Driver() string GetDeploymentStatus(context.Context) (bool, error) GetProjectUpdate(context.Context, string) (*defangv1.ProjectUpdate, error) GetService(context.Context, *defangv1.GetRequest) (*defangv1.ServiceInfo, error) diff --git a/src/pkg/cli/preview_test.go b/src/pkg/cli/preview_test.go index aea458c1c..ef45449b9 100644 --- a/src/pkg/cli/preview_test.go +++ b/src/pkg/cli/preview_test.go @@ -9,9 +9,8 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cli/compose" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs" + awscodebuild "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild" "github.com/DefangLabs/defang/src/pkg/modes" - "github.com/aws/aws-sdk-go-v2/service/ecs/types" ) func TestPreviewStops(t *testing.T) { @@ -36,7 +35,7 @@ func TestPreviewStops(t *testing.T) { err error wantError string }{ - {"CD task fails", ecs.TaskFailure{Reason: types.TaskStopCodeEssentialContainerExited, Detail: "exit code 1"}, "EssentialContainerExited: exit code 1"}, + {"CD build fails", awscodebuild.BuildFailure{Reason: "exit code 1"}, "CodeBuild: exit code 1"}, {"CD task succeeds", io.EOF, ""}, } diff --git a/src/pkg/cli/tail_test.go b/src/pkg/cli/tail_test.go index 7cb293982..09e45660b 100644 --- a/src/pkg/cli/tail_test.go +++ b/src/pkg/cli/tail_test.go @@ -16,12 +16,12 @@ import ( "connectrpc.com/connect" "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs" + awscodebuild "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild" "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/term" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" cwTypes "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" - "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -304,7 +304,7 @@ func TestTailError(t *testing.T) { }{ {"cancel", context.Canceled, cancelError}, {"timeout", context.DeadlineExceeded, cancelError}, - {"cd task failure", ecs.TaskFailure{Reason: types.TaskStopCodeEssentialContainerExited}, "EssentialContainerExited: "}, + {"cd build failure", awscodebuild.BuildFailure{Reason: "build failed"}, "CodeBuild: build failed"}, {"eof", io.EOF, "EOF"}, {"nil", nil, ""}, } diff --git a/src/pkg/cli/waitForCdTaskExit_test.go b/src/pkg/cli/waitForCdTaskExit_test.go index f22813678..6255f9f02 100644 --- a/src/pkg/cli/waitForCdTaskExit_test.go +++ b/src/pkg/cli/waitForCdTaskExit_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/DefangLabs/defang/src/pkg/cli/client" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs" + awscodebuild "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild" "github.com/stretchr/testify/assert" ) @@ -20,16 +20,16 @@ func (m *mockCdWaiter) GetDeploymentStatus(ctx context.Context) (bool, error) { err := m.getDeploymentStatusErr done := err != nil // This logic was copied from AWS provider, to ensure the errs work correctly - if taskErr := new(ecs.TaskFailure); errors.As(err, taskErr) { - return done, client.ErrDeploymentFailed{Message: taskErr.Error()} + if buildErr := new(awscodebuild.BuildFailure); errors.As(err, buildErr) { + return done, client.ErrDeploymentFailed{Message: buildErr.Error()} } return done, err } func TestWaitForCdTaskExit(t *testing.T) { - t.Run("ECS task failure", func(t *testing.T) { + t.Run("CodeBuild failure", func(t *testing.T) { waiter := &mockCdWaiter{ - getDeploymentStatusErr: ecs.TaskFailure{}, + getDeploymentStatusErr: awscodebuild.BuildFailure{}, } err := WaitForCdTaskExit(t.Context(), waiter) assert.ErrorAs(t, err, &client.ErrDeploymentFailed{}) diff --git a/src/pkg/clouds/aws/ecs/cfn/oidc.go b/src/pkg/clouds/aws/codebuild/cfn/oidc.go similarity index 100% rename from src/pkg/clouds/aws/ecs/cfn/oidc.go rename to src/pkg/clouds/aws/codebuild/cfn/oidc.go diff --git a/src/pkg/clouds/aws/ecs/cfn/oidc_test.go b/src/pkg/clouds/aws/codebuild/cfn/oidc_test.go similarity index 100% rename from src/pkg/clouds/aws/ecs/cfn/oidc_test.go rename to src/pkg/clouds/aws/codebuild/cfn/oidc_test.go diff --git a/src/pkg/clouds/aws/ecs/cfn/oidcprovider.go b/src/pkg/clouds/aws/codebuild/cfn/oidcprovider.go similarity index 100% rename from src/pkg/clouds/aws/ecs/cfn/oidcprovider.go rename to src/pkg/clouds/aws/codebuild/cfn/oidcprovider.go diff --git a/src/pkg/clouds/aws/codebuild/cfn/outputs.go b/src/pkg/clouds/aws/codebuild/cfn/outputs.go new file mode 100644 index 000000000..846636dbb --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/cfn/outputs.go @@ -0,0 +1,9 @@ +package cfn + +const ( + OutputsBucketName = "bucketName" + OutputsCIRoleARN = "ciRoleArn" + OutputsCodeBuildProjectName = "codeBuildProjectName" + OutputsLogGroupARN = "logGroupArn" + OutputsTemplateVersion = "templateVersion" +) diff --git a/src/pkg/clouds/aws/ecs/cfn/params.go b/src/pkg/clouds/aws/codebuild/cfn/params.go similarity index 66% rename from src/pkg/clouds/aws/ecs/cfn/params.go rename to src/pkg/clouds/aws/codebuild/cfn/params.go index 79116d0c3..489ab7146 100644 --- a/src/pkg/clouds/aws/ecs/cfn/params.go +++ b/src/pkg/clouds/aws/codebuild/cfn/params.go @@ -2,10 +2,6 @@ package cfn const ( ParamsCIRoleName = "CIRoleName" // Name of the CI IAM role (optional) - ParamsDockerHubAccessToken = "DockerHubAccessToken" // Access token for Docker Hub authentication (optional) - ParamsDockerHubUsername = "DockerHubUsername" // Username for Docker Hub authentication (optional) - ParamsEnablePullThroughCache = "EnablePullThroughCache" // "true"/"false" - Whether to enable ECR pull-through cache - ParamsExistingVpcId = "ExistingVpcId" // VPC ID string or empty to create new VPC ParamsOidcProviderAudiences = "OidcProviderAudiences" // Comma-delimited list of OIDC provider trusted audiences (optional) ParamsOidcProviderClaims = "OidcProviderClaims" // Comma-delimited list of additional OIDC claim conditions as JSON "key":"value" pairs (optional) ParamsOidcProviderIssuer = "OidcProviderIssuer" // OIDC provider trusted issuer (optional) diff --git a/src/pkg/clouds/aws/ecs/cfn/quick_create.go b/src/pkg/clouds/aws/codebuild/cfn/quick_create.go similarity index 100% rename from src/pkg/clouds/aws/ecs/cfn/quick_create.go rename to src/pkg/clouds/aws/codebuild/cfn/quick_create.go diff --git a/src/pkg/clouds/aws/ecs/cfn/quick_create_test.go b/src/pkg/clouds/aws/codebuild/cfn/quick_create_test.go similarity index 100% rename from src/pkg/clouds/aws/ecs/cfn/quick_create_test.go rename to src/pkg/clouds/aws/codebuild/cfn/quick_create_test.go diff --git a/src/pkg/clouds/aws/ecs/cfn/setup.go b/src/pkg/clouds/aws/codebuild/cfn/setup.go similarity index 58% rename from src/pkg/clouds/aws/ecs/cfn/setup.go rename to src/pkg/clouds/aws/codebuild/cfn/setup.go index a87c30eea..1d42ff74b 100644 --- a/src/pkg/clouds/aws/ecs/cfn/setup.go +++ b/src/pkg/clouds/aws/codebuild/cfn/setup.go @@ -4,15 +4,13 @@ import ( "context" "errors" "fmt" - "os" + "slices" "strconv" "strings" "time" - "github.com/DefangLabs/defang/src/pkg" - "github.com/DefangLabs/defang/src/pkg/clouds" common "github.com/DefangLabs/defang/src/pkg/clouds/aws" - awsecs "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs" + awscodebuild "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild" "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" @@ -21,37 +19,27 @@ import ( "github.com/aws/smithy-go/ptr" ) -type AwsEcsCfn struct { - awsecs.AwsEcs +type AwsCfn struct { + awscodebuild.AwsCodeBuild stackName string } const stackTimeout = time.Minute * 3 -func OptionVPCAndSubnetID(ctx context.Context, vpcID, subnetID string) func(clouds.Driver) error { - return func(d clouds.Driver) error { - if ecs, ok := d.(*AwsEcsCfn); ok { - return ecs.PopulateVPCandSubnetID(ctx, vpcID, subnetID) - } - return errors.New("only AwsEcs driver supports VPC ID and Subnet ID option") - } -} - -func New(stack string, region region.Region) *AwsEcsCfn { +func New(stack string, region region.Region) *AwsCfn { if stack == "" { panic("stack must be set") } - return &AwsEcsCfn{ + return &AwsCfn{ stackName: stack, - AwsEcs: awsecs.AwsEcs{ + AwsCodeBuild: awscodebuild.AwsCodeBuild{ Aws: common.Aws{Region: region}, RetainBucket: true, - // Spot: true, }, } } -func (a *AwsEcsCfn) newClient(ctx context.Context) (*cloudformation.Client, error) { +func (a *AwsCfn) newClient(ctx context.Context) (*cloudformation.Client, error) { cfg, err := a.LoadConfig(ctx) if err != nil { return nil, err @@ -60,36 +48,32 @@ func (a *AwsEcsCfn) newClient(ctx context.Context) (*cloudformation.Client, erro return cloudformation.NewFromConfig(cfg), nil } -func (a *AwsEcsCfn) updateStackAndWait(ctx context.Context, templateBody string, force bool, parameters []cfnTypes.Parameter) error { +func (a *AwsCfn) updateStackAndWait(ctx context.Context, templateBody string, force bool, parameters []cfnTypes.Parameter) error { cfn, err := a.newClient(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? + var deployedRev int // TODO: should check all regions if dso, err := cfn.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{StackName: &a.stackName}); err == nil && len(dso.Stacks) == 1 { for _, output := range dso.Stacks[0].Outputs { if *output.OutputKey == OutputsTemplateVersion { - deployedRev, _ := strconv.Atoi(*output.OutputValue) + deployedRev, _ = strconv.Atoi(*output.OutputValue) if deployedRev > TemplateRevision && !force { return fmt.Errorf("This version of the CLI expects CloudFormation template v%d, but the deployed %s stack is v%d: please update the CLI", TemplateRevision, a.stackName, deployedRev) } } } - // Set "Use previous value" for parameters not in the new parameters list - newParams := map[string]struct{}{} - for _, newParam := range parameters { - newParams[*newParam.ParameterKey] = struct{}{} - } - for _, param := range dso.Stacks[0].Parameters { - if _, ok := newParams[*param.ParameterKey]; !ok { - parameters = append(parameters, cfnTypes.Parameter{ - ParameterKey: param.ParameterKey, - UsePreviousValue: ptr.Bool(true), - }) - } + // Remove any parameters that have UsePreviousValue set, but are not in the previously deployed stack + previousParams := make(map[string]bool) + for _, p := range dso.Stacks[0].Parameters { + previousParams[*p.ParameterKey] = true } + parameters = slices.DeleteFunc(parameters, func(p cfnTypes.Parameter) bool { + return p.UsePreviousValue != nil && *p.UsePreviousValue && !previousParams[*p.ParameterKey] + }) } uso, err := cfn.UpdateStack(ctx, &cloudformation.UpdateStackInput{ @@ -113,12 +97,17 @@ func (a *AwsEcsCfn) updateStackAndWait(ctx context.Context, templateBody string, StackName: uso.StackId, }, stackTimeout) if err != nil { - return fmt.Errorf("failed to update CloudFormation stack: check the CloudFormation console (https://%s.console.aws.amazon.com/cloudformation/home) for the %q stack to learn more: %w", a.AwsEcs.Region, a.stackName, err) + var extra string + if deployedRev == 3 && TemplateRevision == 4 { + // Upgrade from 3->4 involves deleting the VPC, which will fail / timeout when the VPC, or any SG, is in use. + extra = " check if the VPC or any security group is still in use and delete them manually before retrying the update;" + } + return fmt.Errorf("failed to update CloudFormation stack:%s check the CloudFormation console (https://%s.console.aws.amazon.com/cloudformation/home) for the %q stack to learn more: %w", extra, a.AwsCodeBuild.Region, a.stackName, err) } return a.fillWithOutputs(dso) } -func (a *AwsEcsCfn) createStackAndWait(ctx context.Context, templateBody string, parameters []cfnTypes.Parameter) error { +func (a *AwsCfn) createStackAndWait(ctx context.Context, templateBody string, parameters []cfnTypes.Parameter) error { cfn, err := a.newClient(ctx) if err != nil { return err @@ -145,13 +134,13 @@ func (a *AwsEcsCfn) createStackAndWait(ctx context.Context, templateBody string, StackName: ptr.String(a.stackName), }, stackTimeout) if err != nil { - return fmt.Errorf("failed to create CloudFormation stack: check the CloudFormation console (https://%s.console.aws.amazon.com/cloudformation/home) for the %q stack to learn more: %w", a.AwsEcs.Region, a.stackName, err) + return fmt.Errorf("failed to create CloudFormation stack: check the CloudFormation console (https://%s.console.aws.amazon.com/cloudformation/home) for the %q stack to learn more: %w", a.AwsCodeBuild.Region, a.stackName, err) } return a.fillWithOutputs(dso) } -func (a *AwsEcsCfn) SetUp(ctx context.Context, containers []clouds.Container, force bool) (bool, error) { - template, err := CreateTemplate(a.stackName, containers) +func (a *AwsCfn) SetUp(ctx context.Context, force bool) (bool, error) { + template, err := CreateTemplate(a.stackName) if err != nil { return false, fmt.Errorf("failed to create CloudFormation template: %w", err) } @@ -161,45 +150,24 @@ func (a *AwsEcsCfn) SetUp(ctx context.Context, containers []clouds.Container, fo return false, err } - // Set parameter values based on current configuration - parameters := []cfnTypes.Parameter{ - // { - // ParameterKey: ptr.String(ParamsCIRoleName), - // ParameterValue: ptr.String("defang-cd-CDIRole-us-west-2"), - // }, - { - ParameterKey: ptr.String(ParamsExistingVpcId), - ParameterValue: ptr.String(a.VpcID), - }, - { - ParameterKey: ptr.String(ParamsRetainBucket), - ParameterValue: ptr.String(strconv.FormatBool(a.RetainBucket)), - }, - { - ParameterKey: ptr.String(ParamsEnablePullThroughCache), - ParameterValue: ptr.String(strconv.FormatBool(!pkg.GetenvBool("DEFANG_NO_CACHE"))), - }, - } - - // Add Docker Hub credentials if available from environment - if dockerHubUsername := os.Getenv("DOCKERHUB_USERNAME"); dockerHubUsername != "" { - parameters = append(parameters, cfnTypes.Parameter{ - ParameterKey: ptr.String(ParamsDockerHubUsername), - ParameterValue: ptr.String(dockerHubUsername), - }) - } - if dockerHubToken := os.Getenv("DOCKERHUB_ACCESS_TOKEN"); dockerHubToken != "" { - parameters = append(parameters, cfnTypes.Parameter{ - ParameterKey: ptr.String(ParamsDockerHubAccessToken), - ParameterValue: ptr.String(dockerHubToken), - }) + // Set parameter values based on current configuration or leave nil to use previous values or defaults + var parameters []cfnTypes.Parameter + for key := range template.Parameters { + param := cfnTypes.Parameter{ + ParameterKey: ptr.String(key), + UsePreviousValue: ptr.Bool(true), + } + if key == ParamsRetainBucket { + param.ParameterValue = ptr.String(strconv.FormatBool(a.RetainBucket)) + param.UsePreviousValue = nil + } + parameters = append(parameters, param) } - // TODO: support DOCKER_AUTH_CONFIG return a.upsertStackAndWait(ctx, templateBody, force, parameters...) } -func (a *AwsEcsCfn) upsertStackAndWait(ctx context.Context, templateBody []byte, force bool, parameters ...cfnTypes.Parameter) (bool, error) { +func (a *AwsCfn) upsertStackAndWait(ctx context.Context, templateBody []byte, force bool, parameters ...cfnTypes.Parameter) (bool, error) { // Upsert with parameters if err := a.updateStackAndWait(ctx, string(templateBody), force, parameters); err != nil { // Check if the stack doesn't exist; if so, create it, otherwise return the error @@ -214,7 +182,7 @@ func (a *AwsEcsCfn) upsertStackAndWait(ctx context.Context, templateBody []byte, type ErrStackNotFoundException = cfnTypes.StackNotFoundException -func (a *AwsEcsCfn) FillOutputs(ctx context.Context) error { +func (a *AwsCfn) FillOutputs(ctx context.Context) error { cfn, err := a.newClient(ctx) if err != nil { return err @@ -236,31 +204,25 @@ func (a *AwsEcsCfn) FillOutputs(ctx context.Context) error { return a.fillWithOutputs(dso) } -func (a *AwsEcsCfn) fillWithOutputs(dso *cloudformation.DescribeStacksOutput) error { +func (a *AwsCfn) fillWithOutputs(dso *cloudformation.DescribeStacksOutput) error { if len(dso.Stacks) != 1 { return fmt.Errorf("expected 1 CloudFormation stack, got %d", len(dso.Stacks)) } + + a.LogGroupARN = "" + a.BucketName = "" + a.CIRoleARN = "" + a.ProjectName = "" for _, output := range dso.Stacks[0].Outputs { switch *output.OutputKey { - case OutputsSubnetID: - // Only set the SubNetID if it's not already set; this allows the user to override the subnet - if a.SubNetID == "" { - a.SubNetID = *output.OutputValue - } - case OutputsDefaultSecurityGroupID: - a.DefaultSecurityGroupID = *output.OutputValue - case OutputsTaskDefARN: - a.TaskDefARN = *output.OutputValue - case OutputsClusterName: - a.ClusterName = *output.OutputValue case OutputsLogGroupARN: a.LogGroupARN = *output.OutputValue - case OutputsSecurityGroupID: - a.SecurityGroupID = *output.OutputValue case OutputsBucketName: a.BucketName = *output.OutputValue case OutputsCIRoleARN: a.CIRoleARN = *output.OutputValue + case OutputsCodeBuildProjectName: + a.ProjectName = *output.OutputValue } } @@ -270,36 +232,7 @@ func (a *AwsEcsCfn) fillWithOutputs(dso *cloudformation.DescribeStacksOutput) er return nil } -func (a *AwsEcsCfn) Run(ctx context.Context, env map[string]string, cmd ...string) (awsecs.TaskArn, error) { - if err := a.FillOutputs(ctx); err != nil { - return nil, err - } - - return a.AwsEcs.Run(ctx, env, cmd...) -} - -func (a *AwsEcsCfn) Tail(ctx context.Context, taskArn awsecs.TaskArn) error { - if err := a.FillOutputs(ctx); err != nil { - return err - } - return a.AwsEcs.Tail(ctx, taskArn) -} - -func (a *AwsEcsCfn) Stop(ctx context.Context, taskArn awsecs.TaskArn) error { - if err := a.FillOutputs(ctx); err != nil { - return err - } - return a.AwsEcs.Stop(ctx, taskArn) -} - -func (a *AwsEcsCfn) GetInfo(ctx context.Context, taskArn awsecs.TaskArn) (*clouds.TaskInfo, error) { - if err := a.FillOutputs(ctx); err != nil { - return nil, err - } - return a.AwsEcs.Info(ctx, taskArn) -} - -func (a *AwsEcsCfn) TearDown(ctx context.Context) error { +func (a *AwsCfn) TearDown(ctx context.Context) error { cfn, err := a.newClient(ctx) if err != nil { return err diff --git a/src/pkg/clouds/aws/ecs/cfn/setup_test.go b/src/pkg/clouds/aws/codebuild/cfn/setup_test.go similarity index 61% rename from src/pkg/clouds/aws/ecs/cfn/setup_test.go rename to src/pkg/clouds/aws/codebuild/cfn/setup_test.go index 970de7010..c90022efe 100644 --- a/src/pkg/clouds/aws/ecs/cfn/setup_test.go +++ b/src/pkg/clouds/aws/codebuild/cfn/setup_test.go @@ -3,13 +3,9 @@ package cfn import ( - "context" - "io" "testing" - "time" "github.com/DefangLabs/defang/src/pkg" - "github.com/DefangLabs/defang/src/pkg/clouds" "github.com/DefangLabs/defang/src/pkg/clouds/aws/region" ) @@ -24,7 +20,6 @@ func TestCloudFormation(t *testing.T) { t.Fatal("aws is nil") } aws.RetainBucket = false // delete bucket after test - aws.Spot = true ctx := t.Context() @@ -34,51 +29,36 @@ func TestCloudFormation(t *testing.T) { t.Setenv("DOCKERHUB_USERNAME", "defanglabs2") t.Setenv("DOCKERHUB_ACCESS_TOKEN", "defanglabs") - _, err := aws.SetUp(ctx, testContainers, false) + _, err := aws.SetUp(ctx, false) if err != nil { t.Fatal(err) } if aws.BucketName == "" { t.Error("bucket name is empty") } + if aws.ProjectName == "" { + t.Error("project name is empty") + } }) - var taskid clouds.TaskID t.Run("Run", func(t *testing.T) { - var err error - taskid, err = aws.Run(ctx, nil, "echo", "hello") + taskid, err := aws.Run(ctx, "/app", "aws/codebuild/amazonlinux2-x86_64-standard:5.0", nil, "echo", "hello") if err != nil { t.Fatal(err) } if taskid == nil || *taskid == "" { - t.Error("task id is empty") + t.Error("build id is empty") } - }) - t.Run("Tail", func(t *testing.T) { - if taskid == nil { - t.Skip("task id is empty") - } - ctx, cancel := context.WithTimeout(ctx, time.Minute) - defer cancel() - err := aws.Tail(ctx, taskid) - if err != nil && err != io.EOF { - t.Fatal(err) - } - }) - - t.Run("Stop", func(t *testing.T) { - if taskid == nil { - t.Skip("task id is empty") - } - err := aws.Stop(ctx, taskid) - if err != nil { - t.Fatal(err) - } + t.Run("Stop", func(t *testing.T) { + err := aws.Stop(ctx, taskid) + if err != nil { + t.Fatal(err) + } + }) }) t.Run("Teardown", func(t *testing.T) { - // This will fail if the task is still running err := aws.TearDown(ctx) if err != nil { t.Fatal(err) diff --git a/src/pkg/clouds/aws/codebuild/cfn/template.go b/src/pkg/clouds/aws/codebuild/cfn/template.go new file mode 100644 index 000000000..d81cb5555 --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/cfn/template.go @@ -0,0 +1,349 @@ +package cfn + +import ( + awscodebuild "github.com/DefangLabs/defang/src/pkg/clouds/aws/codebuild" + "github.com/aws/smithy-go/ptr" + "github.com/awslabs/goformation/v7/cloudformation" + "github.com/awslabs/goformation/v7/cloudformation/codebuild" + "github.com/awslabs/goformation/v7/cloudformation/iam" + "github.com/awslabs/goformation/v7/cloudformation/logs" + "github.com/awslabs/goformation/v7/cloudformation/policies" + "github.com/awslabs/goformation/v7/cloudformation/s3" + "github.com/awslabs/goformation/v7/cloudformation/tags" +) + +const ( + TagKeyCreatedBy = "defang:CreatedBy" + TagKeyManagedBy = "defang:ManagedBy" + TagKeyPrefix = "defang:Prefix" + TagKeyStackName = "defang:CloudFormationStackName" + TagKeyStackRegion = "defang:CloudFormationStackRegion" +) + +const TemplateRevision = 4 // bump this when the template changes! + +// CreateTemplate creates a parameterized CloudFormation template for the CD infrastructure. +// Uses CodeBuild instead of ECS for running Pulumi deployments. +func CreateTemplate(stack string) (*cloudformation.Template, error) { + const oidcProviderDefaultAud = "sts.amazonaws.com" + + defaultTags := []tags.Tag{ + { + Key: TagKeyCreatedBy, + Value: awscodebuild.CrunProjectName, + }, + { + Key: TagKeyPrefix, + Value: stack, + }, + { + Key: TagKeyManagedBy, + Value: "CloudFormation", + }, + { + Key: TagKeyStackName, + Value: cloudformation.Ref("AWS::StackName"), + }, + { + Key: TagKeyStackRegion, + Value: cloudformation.Ref("AWS::Region"), + }, + } + + template := cloudformation.NewTemplate() + template.Description = "Defang AWS CloudFormation template for the CD task. Do not delete this stack in the AWS console: use the Defang CLI instead. To create this stack, scroll down to acknowledge the risks and press 'Create stack'." + + // Parameters + template.Parameters[ParamsRetainBucket] = cloudformation.Parameter{ + Type: "String", + Default: ptr.String("true"), + AllowedValues: []any{"true", "false"}, + Description: ptr.String("Whether to retain the S3 bucket on stack deletion"), + } + template.Parameters[ParamsOidcProviderIssuer] = cloudformation.Parameter{ + Type: "String", + Default: ptr.String(""), + Description: ptr.String("OIDC provider trusted issuer (optional)"), + } + template.Parameters[ParamsOidcProviderSubjects] = cloudformation.Parameter{ + Type: "CommaDelimitedList", + Default: ptr.String(""), + Description: ptr.String("OIDC provider trusted subject pattern(s) (optional)"), + } + template.Parameters[ParamsOidcProviderThumbprints] = cloudformation.Parameter{ + Type: "CommaDelimitedList", + Default: ptr.String(""), + Description: ptr.String("OIDC provider thumbprint(s) (optional)"), + } + template.Parameters[ParamsCIRoleName] = cloudformation.Parameter{ + Type: "String", + Default: ptr.String(""), + Description: ptr.String("Name of the CI role (optional)"), + } + template.Parameters[ParamsOidcProviderAudiences] = cloudformation.Parameter{ + Type: "CommaDelimitedList", + Default: ptr.String(oidcProviderDefaultAud), + Description: ptr.String("OIDC provider trusted audience(s) (optional)"), + } + template.Parameters[ParamsOidcProviderClaims] = cloudformation.Parameter{ + Type: "CommaDelimitedList", + Default: ptr.String(""), + Description: ptr.String(`Additional OIDC claim conditions as comma-separated JSON "key":"value" pairs (optional)`), + } + + // Metadata - AWS::CloudFormation::Interface for parameter grouping and labels + template.Metadata = map[string]interface{}{ + "AWS::CloudFormation::Interface": map[string]interface{}{ + "ParameterGroups": []map[string]interface{}{ + { + "Label": map[string]string{"default": "CI/CD Integration (OIDC)"}, + "Parameters": []string{ParamsOidcProviderIssuer, ParamsOidcProviderSubjects, ParamsOidcProviderAudiences, ParamsCIRoleName, ParamsOidcProviderThumbprints, ParamsOidcProviderClaims}, + }, + { + "Label": map[string]string{"default": "Storage Configuration"}, + "Parameters": []string{ParamsRetainBucket}, + }, + }, + "ParameterLabels": map[string]interface{}{ + ParamsRetainBucket: map[string]string{"default": "Retain S3 Bucket on Delete"}, + ParamsOidcProviderIssuer: map[string]string{"default": "OIDC Provider Issuer"}, + ParamsOidcProviderSubjects: map[string]string{"default": "OIDC Trusted Subject Patterns"}, + ParamsOidcProviderAudiences: map[string]string{"default": "OIDC Trusted Audiences"}, + ParamsOidcProviderThumbprints: map[string]string{"default": "OIDC Provider Thumbprints"}, + ParamsOidcProviderClaims: map[string]string{"default": "Additional OIDC Claim Conditions"}, + ParamsCIRoleName: map[string]string{"default": "CI Role Name"}, + }, + }, + } + + // Conditions + const _condRetainS3Bucket = "RetainS3Bucket" + template.Conditions[_condRetainS3Bucket] = cloudformation.Equals(cloudformation.Ref(ParamsRetainBucket), "true") + const _condOidcProvider = "OidcProvider" + template.Conditions[_condOidcProvider] = cloudformation.And([]string{ + cloudformation.Not([]string{cloudformation.Equals(cloudformation.Ref(ParamsOidcProviderIssuer), "")}), + cloudformation.Not([]string{cloudformation.Equals(cloudformation.Join("", cloudformation.Ref(ParamsOidcProviderSubjects)), "")}), + }) + const _condOverrideCIRoleName = "OverrideCIRoleName" + template.Conditions[_condOverrideCIRoleName] = cloudformation.Not([]string{cloudformation.Equals(cloudformation.Ref(ParamsCIRoleName), "")}) + const _condOidcClaims = "OidcClaims" + template.Conditions[_condOidcClaims] = cloudformation.Not([]string{cloudformation.Equals(cloudformation.Join("", cloudformation.Ref(ParamsOidcProviderClaims)), "")}) + const _condOidcThumbprints = "OidcThumbprints" + template.Conditions[_condOidcThumbprints] = cloudformation.Not([]string{cloudformation.Equals(cloudformation.Join("", cloudformation.Ref(ParamsOidcProviderThumbprints)), "")}) + + // 1. S3 bucket (for deployment state) + const _bucket = "Bucket" + template.Resources[_bucket] = &s3.Bucket{ + Tags: defaultTags, + AWSCloudFormationDeletionPolicy: policies.DeletionPolicy(cloudformation.If(_condRetainS3Bucket, "RetainExceptOnCreate", "Delete")), + VersioningConfiguration: &s3.Bucket_VersioningConfiguration{ + Status: "Enabled", + }, + PublicAccessBlockConfiguration: &s3.Bucket_PublicAccessBlockConfiguration{ + BlockPublicAcls: ptr.Bool(true), + BlockPublicPolicy: ptr.Bool(true), + IgnorePublicAcls: ptr.Bool(true), + RestrictPublicBuckets: ptr.Bool(true), + }, + } + + // 2. CloudWatch log group + const _logGroup = "LogGroup" + template.Resources[_logGroup] = &logs.LogGroup{ + Tags: defaultTags, + RetentionInDays: ptr.Int(1), + } + + // 3. CodeBuild service role + const _codeBuildServiceRole = "CodeBuildServiceRole" + template.Resources[_codeBuildServiceRole] = &iam.Role{ + Tags: defaultTags, + AssumeRolePolicyDocument: map[string]any{ + "Version": "2012-10-17", + "Statement": []map[string]any{ + { + "Effect": "Allow", + "Principal": map[string]any{ + "Service": "codebuild.amazonaws.com", + }, + "Action": "sts:AssumeRole", + }, + }, + }, + ManagedPolicyArns: []string{ + "arn:aws:iam::aws:policy/PowerUserAccess", + }, + } + + // 3b. IAM policy for CodeBuild service role. The Pulumi CD/tenant stack + // (pulumi/cd/ and pulumi/shared/) creates the following IAM resource types: + // - aws.iam.Role (task roles, execution role, codebuild role, instance profile role) + // - aws.iam.Policy (route53 sidecar, bedrock) + // - aws.iam.RolePolicy + RolePoliciesExclusive (inline policies) + // - aws.iam.RolePolicyAttachment (attaching managed policies) + // - aws.iam.InstanceProfile (EC2/GPU nodes) + // Each Pulumi resource type maps to the CRUD + read actions below, scoped to + // the specific IAM resource types in the current account. + // PassRole is needed because Pulumi passes roles to ECS, EC2, and CodeBuild. + // CreateServiceLinkedRole is needed because ECS/ELB create SLRs on first use. + const _codeBuildIAMPolicy = "CodeBuildIAMPolicy" + template.Resources[_codeBuildIAMPolicy] = &iam.ManagedPolicy{ + Roles: []string{ + cloudformation.Ref(_codeBuildServiceRole), + }, + PolicyDocument: map[string]any{ + "Version": "2012-10-17", + "Statement": []map[string]any{ + { + "Effect": "Allow", + "Action": []string{ + "iam:CreateRole", "iam:GetRole", "iam:UpdateRole", "iam:DeleteRole", + "iam:TagRole", "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + "iam:ListRolePolicies", "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:PutRolePolicy", "iam:GetRolePolicy", "iam:DeleteRolePolicy", + "iam:AttachRolePolicy", "iam:DetachRolePolicy", + }, + "Resource": cloudformation.Sub("arn:aws:iam::${AWS::AccountId}:role/*"), + }, + { + "Effect": "Allow", + "Action": []string{ + "iam:CreatePolicy", "iam:GetPolicy", "iam:DeletePolicy", + "iam:CreatePolicyVersion", "iam:DeletePolicyVersion", + "iam:GetPolicyVersion", "iam:ListPolicyVersions", + "iam:TagPolicy", // "iam:UntagPolicy", + }, + "Resource": cloudformation.Sub("arn:aws:iam::${AWS::AccountId}:policy/*"), + }, + { + "Effect": "Allow", + "Action": []string{ + "iam:CreateInstanceProfile", "iam:GetInstanceProfile", + "iam:DeleteInstanceProfile", + "iam:AddRoleToInstanceProfile", "iam:RemoveRoleFromInstanceProfile", + "iam:TagInstanceProfile", //"iam:UntagInstanceProfile", + }, + "Resource": cloudformation.Sub("arn:aws:iam::${AWS::AccountId}:instance-profile/*"), + }, + { + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": cloudformation.Sub("arn:aws:iam::${AWS::AccountId}:role/*"), + }, + { + "Effect": "Allow", + "Action": "iam:CreateServiceLinkedRole", + "Resource": cloudformation.Sub("arn:aws:iam::${AWS::AccountId}:role/aws-service-role/*"), + }, + }, + }, + } + + // 5. CodeBuild project (CFN does not prefix CodeBuild project names with the stack name) + const _codeBuildProject = "DefangCD" + template.Resources[_codeBuildProject] = &codebuild.Project{ + Tags: defaultTags, + Source: &codebuild.Project_Source{ + Type: "NO_SOURCE", + BuildSpec: ptr.String("version: 0.2\nphases:\n build:\n commands:\n - echo 'buildspec should be overridden at StartBuild time'\n"), + }, + Artifacts: &codebuild.Project_Artifacts{ + Type: "NO_ARTIFACTS", + }, + Cache: &codebuild.Project_ProjectCache{ + Type: "LOCAL", + Modes: []string{"LOCAL_DOCKER_LAYER_CACHE"}, + }, + Environment: &codebuild.Project_Environment{ + ComputeType: "BUILD_GENERAL1_MEDIUM", + Type: "LINUX_CONTAINER", + Image: "aws/codebuild/amazonlinux2-x86_64-standard:5.0", // placeholder; overridden at StartBuild time + ImagePullCredentialsType: ptr.String("CODEBUILD"), + PrivilegedMode: ptr.Bool(true), // required for LOCAL_DOCKER_LAYER_CACHE + }, + ServiceRole: cloudformation.Ref(_codeBuildServiceRole), + LogsConfig: &codebuild.Project_LogsConfig{ + CloudWatchLogs: &codebuild.Project_CloudWatchLogsConfig{ + Status: "ENABLED", + GroupName: cloudformation.RefPtr(_logGroup), + }, + }, + } + + // 6. IAM OIDC provider + const _oidcProvider = "OIDCProvider" + template.Resources[_oidcProvider] = &OIDCProvider{ + AWSCloudFormationCondition: _condOidcProvider, + Tags: defaultTags, + ClientIdList: cloudformation.Ref(ParamsOidcProviderAudiences), + ThumbprintList: cloudformation.If(_condOidcThumbprints, + cloudformation.Ref(ParamsOidcProviderThumbprints), + cloudformation.Ref("AWS::NoValue"), + ), + Url: cloudformation.SubPtr(`https://${` + ParamsOidcProviderIssuer + `}`), + } + + // 7. CI role + const _CIRole = "CIRole" + template.Resources[_CIRole] = &iam.Role{ + AWSCloudFormationCondition: _condOidcProvider, + RoleName: cloudformation.IfPtr(_condOverrideCIRoleName, + cloudformation.Ref(ParamsCIRoleName), + cloudformation.Ref("AWS::NoValue"), + ), + Tags: defaultTags, + AssumeRolePolicyDocument: cloudformation.SubVars(`{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "Federated": "${Provider}" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "${`+ParamsOidcProviderIssuer+`}:aud": [ "${Audiences}" ]${ExtraClaims} + }, + "StringLike": { + "${`+ParamsOidcProviderIssuer+`}:sub": [ "${Subjects}" ] + } + } + }] +}`, map[string]any{ + "Audiences": cloudformation.Join(`","`, cloudformation.Ref(ParamsOidcProviderAudiences)), + "Provider": cloudformation.Ref(_oidcProvider), + "Subjects": cloudformation.Join(`","`, cloudformation.Ref(ParamsOidcProviderSubjects)), + "ExtraClaims": cloudformation.If(_condOidcClaims, cloudformation.Join("", []any{",", cloudformation.Join(",", cloudformation.Ref(ParamsOidcProviderClaims))}), ""), + }), + ManagedPolicyArns: []string{ + "arn:aws:iam::aws:policy/PowerUserAccess", + }, + } + + // Outputs + template.Outputs[OutputsCIRoleARN] = cloudformation.Output{ + Condition: ptr.String(_condOidcProvider), + Description: ptr.String("ARN of the CI role"), + Value: cloudformation.GetAtt(_CIRole, "Arn"), + } + template.Outputs[OutputsLogGroupARN] = cloudformation.Output{ + Description: ptr.String("ARN of the CloudWatch log group"), + Value: cloudformation.GetAtt(_logGroup, "Arn"), + } + template.Outputs[OutputsBucketName] = cloudformation.Output{ + Description: ptr.String("Name of the S3 bucket"), + Value: cloudformation.Ref(_bucket), + } + template.Outputs[OutputsCodeBuildProjectName] = cloudformation.Output{ + Description: ptr.String("Name of the CodeBuild project"), + Value: cloudformation.Ref(_codeBuildProject), + } + template.Outputs[OutputsTemplateVersion] = cloudformation.Output{ + Description: ptr.String("Version of this CloudFormation template"), + Value: cloudformation.Int(TemplateRevision), + } + + return template, nil +} diff --git a/src/pkg/clouds/aws/codebuild/cfn/template_test.go b/src/pkg/clouds/aws/codebuild/cfn/template_test.go new file mode 100644 index 000000000..e529d072b --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/cfn/template_test.go @@ -0,0 +1,51 @@ +package cfn + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "go.yaml.in/yaml/v4" +) + +func createTestTemplate(t *testing.T) []byte { + t.Helper() + template, err := CreateTemplate("test") + if err != nil { + t.Fatalf("Error creating template: %v", err) + } + templateBody, err := template.YAML() + if err != nil { + t.Fatalf("Error generating template YAML: %v", err) + } + return templateBody +} + +func TestCreateTemplate(t *testing.T) { + actual := createTestTemplate(t) + + const goldenYaml = "testdata/template.yaml" + expected, err := os.ReadFile(goldenYaml) + if os.IsNotExist(err) || os.Getenv("UPDATE_GOLDEN") != "" { + if err := os.WriteFile(goldenYaml, actual, 0644); err != nil { + t.Fatalf("Error writing golden file: %v", err) + } + t.Fatalf("Golden file updated: %s", goldenYaml) + } else if err != nil { + t.Fatalf("Error reading golden file: %v", err) + } + + // HACK: Unmarshal and marshal again to normalize indentation and formatting + // Caused by https://github.com/aws/aws-toolkit-vscode/issues/8356 + var goldenObj interface{} + err = yaml.Unmarshal(expected, &goldenObj) + if err != nil { + t.Fatalf("Error unmarshaling expected YAML: %v", err) + } + goldenBytes, err := yaml.Marshal(goldenObj) + if err != nil { + t.Fatalf("Error marshaling expected YAML: %v", err) + } + + assert.Equal(t, string(goldenBytes), string(actual), "Generated template does not match golden file") +} diff --git a/src/pkg/clouds/aws/codebuild/cfn/testdata/template.yaml b/src/pkg/clouds/aws/codebuild/cfn/testdata/template.yaml new file mode 100644 index 000000000..91766d5fd --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/cfn/testdata/template.yaml @@ -0,0 +1,388 @@ +AWSTemplateFormatVersion: "2010-09-09" +Conditions: + OidcClaims: + Fn::Not: + - Fn::Equals: + - Fn::Join: + - "" + - Ref: OidcProviderClaims + - "" + OidcProvider: + Fn::And: + - Fn::Not: + - Fn::Equals: + - Ref: OidcProviderIssuer + - "" + - Fn::Not: + - Fn::Equals: + - Fn::Join: + - "" + - Ref: OidcProviderSubjects + - "" + OidcThumbprints: + Fn::Not: + - Fn::Equals: + - Fn::Join: + - "" + - Ref: OidcProviderThumbprints + - "" + OverrideCIRoleName: + Fn::Not: + - Fn::Equals: + - Ref: CIRoleName + - "" + RetainS3Bucket: + Fn::Equals: + - Ref: RetainBucket + - "true" +Description: 'Defang AWS CloudFormation template for the CD task. Do not delete this stack in the AWS console: use the Defang CLI instead. To create this stack, scroll down to acknowledge the risks and press ''Create stack''.' +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: CI/CD Integration (OIDC) + Parameters: + - OidcProviderIssuer + - OidcProviderSubjects + - OidcProviderAudiences + - CIRoleName + - OidcProviderThumbprints + - OidcProviderClaims + - Label: + default: Storage Configuration + Parameters: + - RetainBucket + ParameterLabels: + CIRoleName: + default: CI Role Name + OidcProviderAudiences: + default: OIDC Trusted Audiences + OidcProviderClaims: + default: Additional OIDC Claim Conditions + OidcProviderIssuer: + default: OIDC Provider Issuer + OidcProviderSubjects: + default: OIDC Trusted Subject Patterns + OidcProviderThumbprints: + default: OIDC Provider Thumbprints + RetainBucket: + default: Retain S3 Bucket on Delete +Outputs: + bucketName: + Description: Name of the S3 bucket + Value: + Ref: Bucket + ciRoleArn: + Condition: OidcProvider + Description: ARN of the CI role + Value: + Fn::GetAtt: + - CIRole + - Arn + codeBuildProjectName: + Description: Name of the CodeBuild project + Value: + Ref: DefangCD + logGroupArn: + Description: ARN of the CloudWatch log group + Value: + Fn::GetAtt: + - LogGroup + - Arn + templateVersion: + Description: Version of this CloudFormation template + Value: 4 +Parameters: + CIRoleName: + Default: "" + Description: Name of the CI role (optional) + Type: String + OidcProviderAudiences: + Default: sts.amazonaws.com + Description: OIDC provider trusted audience(s) (optional) + Type: CommaDelimitedList + OidcProviderClaims: + Default: "" + Description: Additional OIDC claim conditions as comma-separated JSON "key":"value" pairs (optional) + Type: CommaDelimitedList + OidcProviderIssuer: + Default: "" + Description: OIDC provider trusted issuer (optional) + Type: String + OidcProviderSubjects: + Default: "" + Description: OIDC provider trusted subject pattern(s) (optional) + Type: CommaDelimitedList + OidcProviderThumbprints: + Default: "" + Description: OIDC provider thumbprint(s) (optional) + Type: CommaDelimitedList + RetainBucket: + AllowedValues: + - "true" + - "false" + Default: "true" + Description: Whether to retain the S3 bucket on stack deletion + Type: String +Resources: + Bucket: + DeletionPolicy: + Fn::If: + - RetainS3Bucket + - RetainExceptOnCreate + - Delete + Properties: + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + Tags: + - Key: defang:CreatedBy + Value: defang + - Key: defang:Prefix + Value: test + - Key: defang:ManagedBy + Value: CloudFormation + - Key: defang:CloudFormationStackName + Value: + Ref: AWS::StackName + - Key: defang:CloudFormationStackRegion + Value: + Ref: AWS::Region + VersioningConfiguration: + Status: Enabled + Type: AWS::S3::Bucket + CIRole: + Condition: OidcProvider + Properties: + AssumeRolePolicyDocument: + Fn::Sub: + - |- + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "Federated": "${Provider}" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "${OidcProviderIssuer}:aud": [ "${Audiences}" ]${ExtraClaims} + }, + "StringLike": { + "${OidcProviderIssuer}:sub": [ "${Subjects}" ] + } + } + }] + } + - Audiences: + Fn::Join: + - '","' + - Ref: OidcProviderAudiences + ExtraClaims: + Fn::If: + - OidcClaims + - Fn::Join: + - "" + - - ',' + - Fn::Join: + - ',' + - Ref: OidcProviderClaims + - "" + Provider: + Ref: OIDCProvider + Subjects: + Fn::Join: + - '","' + - Ref: OidcProviderSubjects + ManagedPolicyArns: + - arn:aws:iam::aws:policy/PowerUserAccess + RoleName: + Fn::If: + - OverrideCIRoleName + - Ref: CIRoleName + - Ref: AWS::NoValue + Tags: + - Key: defang:CreatedBy + Value: defang + - Key: defang:Prefix + Value: test + - Key: defang:ManagedBy + Value: CloudFormation + - Key: defang:CloudFormationStackName + Value: + Ref: AWS::StackName + - Key: defang:CloudFormationStackRegion + Value: + Ref: AWS::Region + Type: AWS::IAM::Role + CodeBuildIAMPolicy: + Properties: + PolicyDocument: + Statement: + - Action: + - iam:CreateRole + - iam:GetRole + - iam:UpdateRole + - iam:DeleteRole + - iam:TagRole + - iam:UntagRole + - iam:UpdateAssumeRolePolicy + - iam:ListRolePolicies + - iam:ListAttachedRolePolicies + - iam:ListInstanceProfilesForRole + - iam:PutRolePolicy + - iam:GetRolePolicy + - iam:DeleteRolePolicy + - iam:AttachRolePolicy + - iam:DetachRolePolicy + Effect: Allow + Resource: + Fn::Sub: arn:aws:iam::${AWS::AccountId}:role/* + - Action: + - iam:CreatePolicy + - iam:GetPolicy + - iam:DeletePolicy + - iam:CreatePolicyVersion + - iam:DeletePolicyVersion + - iam:GetPolicyVersion + - iam:ListPolicyVersions + - iam:TagPolicy + Effect: Allow + Resource: + Fn::Sub: arn:aws:iam::${AWS::AccountId}:policy/* + - Action: + - iam:CreateInstanceProfile + - iam:GetInstanceProfile + - iam:DeleteInstanceProfile + - iam:AddRoleToInstanceProfile + - iam:RemoveRoleFromInstanceProfile + - iam:TagInstanceProfile + Effect: Allow + Resource: + Fn::Sub: arn:aws:iam::${AWS::AccountId}:instance-profile/* + - Action: iam:PassRole + Effect: Allow + Resource: + Fn::Sub: arn:aws:iam::${AWS::AccountId}:role/* + - Action: iam:CreateServiceLinkedRole + Effect: Allow + Resource: + Fn::Sub: arn:aws:iam::${AWS::AccountId}:role/aws-service-role/* + Version: "2012-10-17" + Roles: + - Ref: CodeBuildServiceRole + Type: AWS::IAM::ManagedPolicy + CodeBuildServiceRole: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: codebuild.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - arn:aws:iam::aws:policy/PowerUserAccess + Tags: + - Key: defang:CreatedBy + Value: defang + - Key: defang:Prefix + Value: test + - Key: defang:ManagedBy + Value: CloudFormation + - Key: defang:CloudFormationStackName + Value: + Ref: AWS::StackName + - Key: defang:CloudFormationStackRegion + Value: + Ref: AWS::Region + Type: AWS::IAM::Role + DefangCD: + Properties: + Artifacts: + Type: NO_ARTIFACTS + Cache: + Modes: + - LOCAL_DOCKER_LAYER_CACHE + Type: LOCAL + Environment: + ComputeType: BUILD_GENERAL1_MEDIUM + Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0 + ImagePullCredentialsType: CODEBUILD + PrivilegedMode: true + Type: LINUX_CONTAINER + LogsConfig: + CloudWatchLogs: + GroupName: + Ref: LogGroup + Status: ENABLED + ServiceRole: + Ref: CodeBuildServiceRole + Source: + BuildSpec: | + version: 0.2 + phases: + build: + commands: + - echo 'buildspec should be overridden at StartBuild time' + Type: NO_SOURCE + Tags: + - Key: defang:CreatedBy + Value: defang + - Key: defang:Prefix + Value: test + - Key: defang:ManagedBy + Value: CloudFormation + - Key: defang:CloudFormationStackName + Value: + Ref: AWS::StackName + - Key: defang:CloudFormationStackRegion + Value: + Ref: AWS::Region + Type: AWS::CodeBuild::Project + LogGroup: + Properties: + RetentionInDays: 1 + Tags: + - Key: defang:CreatedBy + Value: defang + - Key: defang:Prefix + Value: test + - Key: defang:ManagedBy + Value: CloudFormation + - Key: defang:CloudFormationStackName + Value: + Ref: AWS::StackName + - Key: defang:CloudFormationStackRegion + Value: + Ref: AWS::Region + Type: AWS::Logs::LogGroup + OIDCProvider: + Condition: OidcProvider + Properties: + ClientIdList: + Ref: OidcProviderAudiences + Tags: + - Key: defang:CreatedBy + Value: defang + - Key: defang:Prefix + Value: test + - Key: defang:ManagedBy + Value: CloudFormation + - Key: defang:CloudFormationStackName + Value: + Ref: AWS::StackName + - Key: defang:CloudFormationStackRegion + Value: + Ref: AWS::Region + ThumbprintList: + Fn::If: + - OidcThumbprints + - Ref: OidcProviderThumbprints + - Ref: AWS::NoValue + Url: + Fn::Sub: https://${OidcProviderIssuer} + Type: AWS::IAM::OIDCProvider diff --git a/src/pkg/clouds/aws/ecs/cfn/waiter.go b/src/pkg/clouds/aws/codebuild/cfn/waiter.go similarity index 100% rename from src/pkg/clouds/aws/ecs/cfn/waiter.go rename to src/pkg/clouds/aws/codebuild/cfn/waiter.go diff --git a/src/pkg/clouds/aws/codebuild/common.go b/src/pkg/clouds/aws/codebuild/common.go new file mode 100644 index 000000000..62408cf14 --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/common.go @@ -0,0 +1,33 @@ +package codebuild + +import ( + "strings" + + "github.com/DefangLabs/defang/src/pkg/clouds/aws" +) + +const ( + CrunProjectName = "defang" +) + +type BuildID *string + +type AwsCodeBuild struct { + aws.Aws + BucketName string + CIRoleARN string + LogGroupARN string + ProjectName string // CodeBuild project name + RetainBucket bool // CloudFormation template input parameter +} + +func (a *AwsCodeBuild) MakeARN(service, resource string) string { + return strings.Join([]string{ + "arn", + "aws", + service, + string(a.Region), + a.AccountID, + resource, + }, ":") +} diff --git a/src/pkg/clouds/aws/codebuild/run.go b/src/pkg/clouds/aws/codebuild/run.go new file mode 100644 index 000000000..a18eefcd0 --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/run.go @@ -0,0 +1,111 @@ +package codebuild + +import ( + "context" + "errors" + "path" + "strings" + + pkg "github.com/DefangLabs/defang/src/pkg" + "github.com/aws/aws-sdk-go-v2/service/codebuild" + cbtypes "github.com/aws/aws-sdk-go-v2/service/codebuild/types" + "github.com/aws/smithy-go/ptr" + "go.yaml.in/yaml/v4" +) + +type buildspecDoc struct { + Version string `yaml:"version"` + Phases buildspecPhase `yaml:"phases"` +} + +type buildspecPhase struct { + Build buildspecBuild `yaml:"build"` +} + +type buildspecBuild struct { + Commands []string `yaml:"commands"` +} + +func buildspec(workingDir string, cmd ...string) (string, error) { + if workingDir == "" { + return "", errors.New("workingDir must not be empty") + } + if len(cmd) == 0 { + return "", errors.New("cmd must not be empty") + } + + // Validate and clean the working directory path + workingDir = path.Clean(workingDir) + + // Shell-quote the command arguments to preserve argument boundaries + command := pkg.ShellQuote(cmd...) + + // CodeBuild overrides the image's WORKDIR; use mkdir/cd to ensure the directory exists + shellCmd := "mkdir -p " + pkg.ShellQuote(workingDir) + " && cd " + pkg.ShellQuote(workingDir) + " && " + command + + doc := buildspecDoc{ + Version: "0.2", + Phases: buildspecPhase{ + Build: buildspecBuild{ + Commands: []string{shellCmd}, + }, + }, + } + + out, err := yaml.Marshal(doc) + if err != nil { + return "", err + } + return string(out), nil +} + +func (a *AwsCodeBuild) Run(ctx context.Context, workingDir, image string, env map[string]string, cmd ...string) (BuildID, error) { + cfg, err := a.LoadConfig(ctx) + if err != nil { + return nil, err + } + + var envOverrides []cbtypes.EnvironmentVariable + for k, v := range env { + envOverrides = append(envOverrides, cbtypes.EnvironmentVariable{ + Name: ptr.String(k), + Value: ptr.String(v), + }) + } + + spec, err := buildspec(workingDir, cmd...) + if err != nil { + return nil, err + } + + client := codebuild.NewFromConfig(cfg) + input := &codebuild.StartBuildInput{ + ProjectName: ptr.String(a.ProjectName), + ImageOverride: ptr.String(image), + EnvironmentVariablesOverride: envOverrides, + BuildspecOverride: ptr.String(spec), + } + // Use SERVICE_ROLE credentials to pull from ECR (e.g. pull-through cache) + if !strings.HasPrefix(image, "aws/") { + input.ImagePullCredentialsTypeOverride = cbtypes.ImagePullCredentialsTypeServiceRole + } + output, err := client.StartBuild(ctx, input) + if err != nil { + return nil, err + } + + return output.Build.Id, nil +} + +func (a *AwsCodeBuild) Stop(ctx context.Context, buildID BuildID) error { + cfg, err := a.LoadConfig(ctx) + if err != nil { + return err + } + + client := codebuild.NewFromConfig(cfg) + _, err = client.StopBuild(ctx, &codebuild.StopBuildInput{ + Id: buildID, + }) + return err +} diff --git a/src/pkg/clouds/aws/codebuild/run_test.go b/src/pkg/clouds/aws/codebuild/run_test.go new file mode 100644 index 000000000..1c88c00b1 --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/run_test.go @@ -0,0 +1,77 @@ +package codebuild + +import ( + "strings" + "testing" + + "go.yaml.in/yaml/v4" +) + +func TestBuildspec(t *testing.T) { + tests := []struct { + name string + workingDir string + cmd []string + wantErr bool + }{ + { + name: "simple command", + workingDir: "/app", + cmd: []string{"echo", "hello"}, + }, + { + name: "command with spaces in args", + workingDir: "/app", + cmd: []string{"echo", "hello world"}, + }, + { + name: "custom working dir", + workingDir: "/workspace/myproject", + cmd: []string{"node", "lib/index.js"}, + }, + { + name: "empty working dir", + workingDir: "", + cmd: []string{"echo", "hello"}, + wantErr: true, + }, + { + name: "empty command", + workingDir: "/app", + cmd: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildspec(tt.workingDir, tt.cmd...) + if (err != nil) != tt.wantErr { + t.Fatalf("buildspec() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + + // Verify it's valid YAML by round-tripping through the struct + var parsed buildspecDoc + if err := yaml.Unmarshal([]byte(got), &parsed); err != nil { + t.Fatalf("buildspec produced invalid YAML: %v\n%s", err, got) + } + + if parsed.Version != "0.2" { + t.Errorf("expected version 0.2, got %q", parsed.Version) + } + + commands := parsed.Phases.Build.Commands + if len(commands) != 1 { + t.Fatalf("expected 1 command, got %d", len(commands)) + } + + // Should contain cd to working dir + if !strings.Contains(commands[0], tt.workingDir) { + t.Errorf("command %q does not contain working dir %q", commands[0], tt.workingDir) + } + }) + } +} diff --git a/src/pkg/clouds/aws/codebuild/status.go b/src/pkg/clouds/aws/codebuild/status.go new file mode 100644 index 000000000..ee571a1f5 --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/status.go @@ -0,0 +1,82 @@ +package codebuild + +import ( + "context" + "io" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codebuild" + cbtypes "github.com/aws/aws-sdk-go-v2/service/codebuild/types" +) + +// GetBuildStatus returns (done, error). Returns io.EOF on success, an error on failure, nil if still running. +func GetBuildStatus(ctx context.Context, cfg aws.Config, buildID BuildID) (bool, error) { + client := codebuild.NewFromConfig(cfg) + + output, err := client.BatchGetBuilds(ctx, &codebuild.BatchGetBuildsInput{ + Ids: []string{*buildID}, + }) + if err != nil { + return false, err + } + if len(output.Builds) == 0 { + return false, nil // build doesn't exist yet + } + + build := output.Builds[0] // assume only one build per request + return buildStatus(build) +} + +func buildStatus(build cbtypes.Build) (bool, error) { + switch build.BuildStatus { + case cbtypes.StatusTypeInProgress: + return false, nil + case cbtypes.StatusTypeSucceeded: + return true, io.EOF + case cbtypes.StatusTypeStopped: + return true, BuildFailure{Reason: "build stopped"} + case cbtypes.StatusTypeTimedOut: + return true, BuildFailure{Reason: "build timed out"} + default: + reason := getBuildPhaseErrorContexts(build) + return true, BuildFailure{Reason: reason} + } +} + +func getBuildPhaseErrorContexts(build cbtypes.Build) string { + var messages []string + for _, phase := range build.Phases { + for _, context := range phase.Contexts { + if context.Message != nil && *context.Message != "" { + messages = append(messages, *context.Message) + } + } + } + return strings.Join(messages, "\n") +} + +// WaitForBuild polls the CodeBuild build status. Returns io.EOF on success, or an error on failure. +func WaitForBuild(ctx context.Context, cfg aws.Config, buildID BuildID, poll time.Duration) error { + ticker := time.NewTicker(poll) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if done, err := GetBuildStatus(ctx, cfg, buildID); done || err != nil { + return err + } + } + } +} + +type BuildFailure struct { + Reason string +} + +func (f BuildFailure) Error() string { + return "CodeBuild: " + f.Reason +} diff --git a/src/pkg/clouds/aws/codebuild/status_test.go b/src/pkg/clouds/aws/codebuild/status_test.go new file mode 100644 index 000000000..dc34e5545 --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/status_test.go @@ -0,0 +1,146 @@ +package codebuild + +import ( + "encoding/json" + "io" + "os" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + cbtypes "github.com/aws/aws-sdk-go-v2/service/codebuild/types" +) + +// jsonBuild mirrors the subset of the AWS JSON wire format we need for testing. +type jsonBuild struct { + BuildStatus string `json:"buildStatus"` + Phases []struct { + PhaseType string `json:"phaseType"` + PhaseStatus string `json:"phaseStatus"` + Contexts []struct { + Message string `json:"message"` + StatusCode string `json:"statusCode"` + } `json:"contexts"` + } `json:"phases"` +} + +func loadBuild(t *testing.T, path string) cbtypes.Build { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var output struct { + Builds []jsonBuild `json:"builds"` + } + if err := json.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if len(output.Builds) == 0 { + t.Fatal("no builds in JSON") + } + jb := output.Builds[0] + build := cbtypes.Build{ + BuildStatus: cbtypes.StatusType(jb.BuildStatus), + } + for _, jp := range jb.Phases { + phase := cbtypes.BuildPhase{ + PhaseType: cbtypes.BuildPhaseType(jp.PhaseType), + PhaseStatus: cbtypes.StatusType(jp.PhaseStatus), + } + for _, jc := range jp.Contexts { + phase.Contexts = append(phase.Contexts, cbtypes.PhaseContext{ + Message: aws.String(jc.Message), + StatusCode: aws.String(jc.StatusCode), + }) + } + build.Phases = append(build.Phases, phase) + } + return build +} + +func TestGetBuildPhaseErrorContexts_FailedBuild(t *testing.T) { + build := loadBuild(t, "testdata/codebuild-failed.json") + + actual := getBuildPhaseErrorContexts(build) + expected := "Error while executing command: docker buildx build -t 123456789012.dkr.ecr.us-test-2.amazonaws.com/html-css-js/kaniko-build:app-image-103b5989-x86_64 -f Dockerfile --push --platform linux/amd64 ${CODEBUILD_SRC_DIR}. Reason: exit status 1" + + if actual != expected { + t.Errorf("getBuildPhaseErrorContexts() = %q, want %q", actual, expected) + } +} + +func TestGetBuildPhaseErrorContexts_StoppedBuild(t *testing.T) { + build := loadBuild(t, "testdata/codebuild-stopped.json") + + actual := getBuildPhaseErrorContexts(build) + if actual != "" { + t.Errorf("getBuildPhaseErrorContexts() = %q, want empty string", actual) + } +} + +func TestBuildStatus_Failed(t *testing.T) { + build := loadBuild(t, "testdata/codebuild-failed.json") + + done, err := buildStatus(build) + if !done { + t.Error("expected done=true for failed build") + } + bf, ok := err.(BuildFailure) + if !ok { + t.Fatalf("expected BuildFailure, got %T", err) + } + expected := "Error while executing command: docker buildx build -t 123456789012.dkr.ecr.us-test-2.amazonaws.com/html-css-js/kaniko-build:app-image-103b5989-x86_64 -f Dockerfile --push --platform linux/amd64 ${CODEBUILD_SRC_DIR}. Reason: exit status 1" + if bf.Reason != expected { + t.Errorf("reason = %q, want %q", bf.Reason, expected) + } +} + +func TestBuildStatus_Stopped(t *testing.T) { + build := loadBuild(t, "testdata/codebuild-stopped.json") + + done, err := buildStatus(build) + if !done { + t.Error("expected done=true for stopped build") + } + bf, ok := err.(BuildFailure) + if !ok { + t.Fatalf("expected BuildFailure, got %T", err) + } + if bf.Reason != "build stopped" { + t.Errorf("reason = %q, want %q", bf.Reason, "build stopped") + } +} + +func TestBuildStatus_Succeeded(t *testing.T) { + done, err := buildStatus(cbtypes.Build{BuildStatus: cbtypes.StatusTypeSucceeded}) + if !done { + t.Error("expected done=true") + } + if err != io.EOF { + t.Errorf("expected io.EOF, got %v", err) + } +} + +func TestBuildStatus_InProgress(t *testing.T) { + done, err := buildStatus(cbtypes.Build{BuildStatus: cbtypes.StatusTypeInProgress}) + if done { + t.Error("expected done=false") + } + if err != nil { + t.Errorf("expected nil error, got %v", err) + } +} + +func TestBuildStatus_TimedOut(t *testing.T) { + done, err := buildStatus(cbtypes.Build{BuildStatus: cbtypes.StatusTypeTimedOut}) + if !done { + t.Error("expected done=true") + } + bf, ok := err.(BuildFailure) + if !ok { + t.Fatalf("expected BuildFailure, got %T", err) + } + if bf.Reason != "build timed out" { + t.Errorf("reason = %q, want %q", bf.Reason, "build timed out") + } +} diff --git a/src/pkg/clouds/aws/codebuild/tail.go b/src/pkg/clouds/aws/codebuild/tail.go new file mode 100644 index 000000000..92ea9971f --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/tail.go @@ -0,0 +1,73 @@ +package codebuild + +import ( + "context" + "errors" + "iter" + "strings" + "time" + + "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/clouds/aws/cw" + cwTypes "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" +) + +const AwsLogsStreamPrefix = CrunProjectName + +func (a *AwsCodeBuild) QueryBuildID(ctx context.Context, cwClient cw.FilterLogEventsAPIClient, buildID BuildID, start, end time.Time, limit int32) (iter.Seq2[[]cw.LogEvent, error], error) { + if buildID == nil { + return nil, errors.New("buildID is empty") + } + + lgi := cw.LogGroupInput{LogGroupARN: a.LogGroupARN, LogStreamNames: []string{GetLogStreamForBuildID(buildID)}} + logSeq, err := cw.QueryLogGroup(ctx, cwClient, lgi, start, end, limit) + if err != nil { + return nil, err + } + return logSeq, nil +} + +func (a *AwsCodeBuild) TailBuildID(ctx context.Context, cwClient cw.StartLiveTailAPI, buildID BuildID) (iter.Seq2[[]cw.LogEvent, error], error) { + if buildID == nil { + return nil, errors.New("buildID is required") + } + if a.LogGroupARN == "" { + return nil, errors.New("LogGroupARN is required") + } + + cfg, err := a.LoadConfig(ctx) + if err != nil { + return nil, err + } + + lgi := cw.LogGroupInput{LogGroupARN: a.LogGroupARN, LogStreamNames: []string{GetLogStreamForBuildID(buildID)}} + for { + logSeq, err := cw.TailLogGroup(ctx, cwClient, lgi) + if err != nil { + var resourceNotFound *cwTypes.ResourceNotFoundException + if !errors.As(err, &resourceNotFound) { + return nil, err + } + // The log stream doesn't exist yet; check if the build is done + done, err := GetBuildStatus(ctx, cfg, buildID) + if done || err != nil { + return nil, err + } + // Sleep to avoid throttling, then retry + if err := pkg.SleepWithContext(ctx, time.Second); err != nil { + return nil, err + } + continue + } + return logSeq, nil + } +} + +// GetLogStreamForBuildID returns the CloudWatch log stream name for a CodeBuild build. +// CodeBuild log streams use the build UUID (the part after the colon in the build ID). +func GetLogStreamForBuildID(buildID BuildID) string { + if _, after, ok := strings.Cut(*buildID, ":"); ok { + return after + } + return *buildID +} diff --git a/src/pkg/clouds/aws/codebuild/testdata/codebuild-failed.json b/src/pkg/clouds/aws/codebuild/testdata/codebuild-failed.json new file mode 100644 index 000000000..14aa201c7 --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/testdata/codebuild-failed.json @@ -0,0 +1,166 @@ +{ + "$metadata": { + "httpStatusCode": 200, + "requestId": "0ef4e7b3-81bc-4d79-a1e4-0bfdb24bfe50", + "attempts": 1, + "totalRetryDelay": 0 + }, + "builds": [ + { + "arn": "arn:aws:codebuild:us-test-2:123456789012:build/app-image-2beb4dc:188cd8ff-eea1-485a-ab58-7c5b1d2bc882", + "artifacts": { + "location": "", + "overrideArtifactName": false, + "type": "no_artifacts" + }, + "autoRetryConfig": { "autoRetryLimit": 0, "autoRetryNumber": 0 }, + "buildComplete": true, + "buildNumber": 5, + "buildStatus": "FAILED", + "cache": { + "modes": ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_SOURCE_CACHE"], + "type": "LOCAL" + }, + "currentPhase": "COMPLETED", + "encryptionKey": "arn:aws:kms:us-test-2:123456789012:alias/aws/s3", + "endTime": "2025-10-27T22:31:11.266Z", + "environment": { + "computeType": "BUILD_GENERAL1_LARGE", + "customEntrypoint": false, + "environmentVariables": [ + { + "name": "AWS_ACCOUNT_ID", + "type": "PLAINTEXT", + "value": "123456789012" + }, + { + "name": "AWS_DEFAULT_REGION", + "type": "PLAINTEXT", + "value": "us-test-2" + } + ], + "image": "aws/codebuild/amazonlinux-x86_64-standard:5.0", + "imagePullCredentialsType": "CODEBUILD", + "privilegedMode": true, + "type": "LINUX_CONTAINER" + }, + "id": "app-image-2beb4dc:188cd8ff-eea1-485a-ab58-7c5b1d2bc882", + "initiator": "defang-cd-TaskRole-eJ6hq8smnEcq/d92decf976b3456297e62ef9086786b8", + "logs": { + "cloudWatchLogs": { + "groupName": "/Defang/html-css-js/beta/builds", + "status": "ENABLED", + "streamName": "app-image/app_2h5wfow3w4y0" + }, + "cloudWatchLogsArn": "arn:aws:logs:us-test-2:123456789012:log-group:/Defang/html-css-js/beta/builds:log-stream:app-image/app_2h5wfow3w4y0/188cd8ff-eea1-485a-ab58-7c5b1d2bc882", + "deepLink": "https://console.aws.amazon.com/cloudwatch/home?region=us-test-2#logsV2:log-groups/log-group/$252FDefang$252Fhtml-css-js$252Fbeta$252Fbuilds/log-events/app-image$252Fapp_2h5wfow3w4y0$252F188cd8ff-eea1-485a-ab58-7c5b1d2bc882", + "groupName": "/Defang/html-css-js/beta/builds", + "s3Logs": { "encryptionDisabled": false, "status": "DISABLED" }, + "streamName": "app-image/app_2h5wfow3w4y0/188cd8ff-eea1-485a-ab58-7c5b1d2bc882" + }, + "phases": [ + { + "durationInSeconds": 0, + "endTime": "2025-10-27T22:30:37.594Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "SUBMITTED", + "startTime": "2025-10-27T22:30:37.506Z" + }, + { + "durationInSeconds": 0, + "endTime": "2025-10-27T22:30:38.200Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "QUEUED", + "startTime": "2025-10-27T22:30:37.594Z" + }, + { + "contexts": [{ "message": "", "statusCode": "" }], + "durationInSeconds": 13, + "endTime": "2025-10-27T22:30:51.581Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "PROVISIONING", + "startTime": "2025-10-27T22:30:38.200Z" + }, + { + "contexts": [{ "message": "", "statusCode": "" }], + "durationInSeconds": 1, + "endTime": "2025-10-27T22:30:52.935Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "DOWNLOAD_SOURCE", + "startTime": "2025-10-27T22:30:51.581Z" + }, + { + "contexts": [{ "message": "", "statusCode": "" }], + "durationInSeconds": 0, + "endTime": "2025-10-27T22:30:53.006Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "INSTALL", + "startTime": "2025-10-27T22:30:52.935Z" + }, + { + "contexts": [{ "message": "", "statusCode": "" }], + "durationInSeconds": 9, + "endTime": "2025-10-27T22:31:02.787Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "PRE_BUILD", + "startTime": "2025-10-27T22:30:53.006Z" + }, + { + "contexts": [ + { + "message": "Error while executing command: docker buildx build -t 123456789012.dkr.ecr.us-test-2.amazonaws.com/html-css-js/kaniko-build:app-image-103b5989-x86_64 -f Dockerfile --push --platform linux/amd64 ${CODEBUILD_SRC_DIR}. Reason: exit status 1", + "statusCode": "COMMAND_EXECUTION_ERROR" + } + ], + "durationInSeconds": 8, + "endTime": "2025-10-27T22:31:10.838Z", + "phaseStatus": "FAILED", + "phaseType": "BUILD", + "startTime": "2025-10-27T22:31:02.787Z" + }, + { + "contexts": [{ "message": "", "statusCode": "" }], + "durationInSeconds": 0, + "endTime": "2025-10-27T22:31:10.882Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "POST_BUILD", + "startTime": "2025-10-27T22:31:10.838Z" + }, + { + "contexts": [{ "message": "", "statusCode": "" }], + "durationInSeconds": 0, + "endTime": "2025-10-27T22:31:10.949Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "UPLOAD_ARTIFACTS", + "startTime": "2025-10-27T22:31:10.882Z" + }, + { + "contexts": [{ "message": "", "statusCode": "" }], + "durationInSeconds": 0, + "endTime": "2025-10-27T22:31:11.266Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "FINALIZING", + "startTime": "2025-10-27T22:31:10.949Z" + }, + { + "phaseType": "COMPLETED", + "startTime": "2025-10-27T22:31:11.266Z" + } + ], + "projectName": "app-image-2beb4dc", + "queuedTimeoutInMinutes": 480, + "secondarySourceVersions": [], + "secondarySources": [], + "serviceRole": "arn:aws:iam::123456789012:role/Defang-html-css-js-beta-code-build-roleus-west-2", + "source": { + "buildspec": "version: 0.2\nphases:\n pre_build:\n commands: [\"nohup /usr/bin/dockerd --host=unix:///var/run/docker.sock --host=tcp://127.0.0.1:2375 --storage-driver=overlay2 &\",\"timeout 15 sh -c \\\"until docker info; do echo .; sleep 1; done\\\"\",\"echo \\\"Docker daemon is up!\\\"\",\"tar -xzf sha256-IY_H6uXExcLbOvKUa3SCPbLbWNj2fJNK_iApPkDbXcM_.tar.gz -C $CODEBUILD_SRC_DIR\",\"aws ecr get-login-password --region us-test-2 | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-test-2.amazonaws.com/html-css-js/kaniko-build\"]\n build:\n commands:\n - echo Building the Docker image...\n - docker buildx create --use --driver=docker-container --platform linux/amd64\n - docker buildx build -t 123456789012.dkr.ecr.us-test-2.amazonaws.com/html-css-js/kaniko-build:app-image-103b5989-x86_64 -f Dockerfile --push --platform linux/amd64 ${CODEBUILD_SRC_DIR}\n", + "insecureSsl": false, + "location": "defang-cd-bucket-ren6kjmnovwj/uploads/sha256-IY_H6uXExcLbOvKUa3SCPbLbWNj2fJNK_iApPkDbXcM_.tar.gz", + "type": "S3" + }, + "startTime": "2025-10-27T22:30:37.506Z", + "timeoutInMinutes": 60 + } + ], + "buildsNotFound": [] +} diff --git a/src/pkg/clouds/aws/codebuild/testdata/codebuild-stopped.json b/src/pkg/clouds/aws/codebuild/testdata/codebuild-stopped.json new file mode 100644 index 000000000..c9430cdc9 --- /dev/null +++ b/src/pkg/clouds/aws/codebuild/testdata/codebuild-stopped.json @@ -0,0 +1,96 @@ +{ + "$metadata": { + "httpStatusCode": 200, + "requestId": "35faf0c1-f718-4dd6-b313-9e67140f1bdc", + "attempts": 1, + "totalRetryDelay": 0 + }, + "builds": [ + { + "arn": "arn:aws:codebuild:us-west-2:381492210770:build/app-image-accfe92:80805190-2345-4b6b-8f0e-af1edc3d6d7a", + "artifacts": { + "location": "", + "overrideArtifactName": false, + "type": "no_artifacts" + }, + "buildComplete": true, + "buildNumber": 20, + "buildStatus": "STOPPED", + "cache": { + "modes": ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_SOURCE_CACHE"], + "type": "LOCAL" + }, + "currentPhase": "COMPLETED", + "encryptionKey": "arn:aws:kms:us-west-2:381492210770:alias/aws/s3", + "endTime": "2025-11-29T02:02:21.479Z", + "environment": { + "computeType": "BUILD_GENERAL1_LARGE", + "environmentVariables": [ + { + "name": "AWS_ACCOUNT_ID", + "type": "PLAINTEXT", + "value": "381492210770" + }, + { + "name": "AWS_DEFAULT_REGION", + "type": "PLAINTEXT", + "value": "us-west-2" + } + ], + "image": "aws/codebuild/amazonlinux-x86_64-standard:5.0", + "imagePullCredentialsType": "CODEBUILD", + "privilegedMode": true, + "type": "LINUX_CONTAINER" + }, + "id": "app-image-accfe92:80805190-2345-4b6b-8f0e-af1edc3d6d7a", + "initiator": "admin/aws-sdk-js-1764381741099", + "logs": { + "cloudWatchLogs": { + "groupName": "/Defang/html-css-js/test2/builds", + "status": "ENABLED", + "streamName": "app-image/app_1doati2vuc7v" + }, + "cloudWatchLogsArn": "arn:aws:logs:us-west-2:381492210770:log-group:null:log-stream:null", + "deepLink": "https://console.aws.amazon.com/cloudwatch/home?region=us-west-2#logsV2:log-groups", + "s3Logs": { + "encryptionDisabled": false, + "status": "DISABLED" + } + }, + "phases": [ + { + "durationInSeconds": 0, + "endTime": "2025-11-29T02:02:21.409Z", + "phaseStatus": "SUCCEEDED", + "phaseType": "SUBMITTED", + "startTime": "2025-11-29T02:02:21.353Z" + }, + { + "durationInSeconds": 0, + "endTime": "2025-11-29T02:02:21.479Z", + "phaseStatus": "STOPPED", + "phaseType": "QUEUED", + "startTime": "2025-11-29T02:02:21.409Z" + }, + { + "phaseType": "COMPLETED", + "startTime": "2025-11-29T02:02:21.479Z" + } + ], + "projectName": "app-image-accfe92", + "queuedTimeoutInMinutes": 480, + "secondarySourceVersions": [], + "secondarySources": [], + "serviceRole": "arn:aws:iam::381492210770:role/Defang-html-css-js-test2-code-build-roleus-west-2", + "source": { + "buildspec": "version: 0.2\nphases:\n pre_build:\n commands:\n - tar -xzf sha256-T7iSdNuO_vTAJsuH2uQcjFPe93mJ7CuOldFH7FRtLaI_.tar.gz -C $CODEBUILD_SRC_DIR\n - rm sha256-T7iSdNuO_vTAJsuH2uQcjFPe93mJ7CuOldFH7FRtLaI_.tar.gz\n - mkdir -p /root/.docker\n - echo '{\"credHelpers\":{\"public.ecr.aws\":\"ecr-login\",\"381492210770.dkr.ecr.us-west-2.amazonaws.com\":\"ecr-login\"}}' > /root/.docker/config.json\n - mkdir -p /etc/docker/ && echo '{\"registry-mirrors\":[\"https://381492210770.dkr.ecr.us-west-2.amazonaws.com/defang-html-css-js-test280dd33\",\"http://localhost:5000\"]}' > /etc/docker/daemon.json\n - |-\n echo '\n [registry.\"docker.io\"]\n mirrors = [\n \"https://381492210770.dkr.ecr.us-west-2.amazonaws.com/defang-html-css-js-test280dd33\", \"http://localhost:5000\"\n ]\n ' > /tmp/buildkitd.toml\n - kill -HUP $(pidof dockerd)\n - |-\n cat > /tmp/nginx.conf.tmpl << EOF\n events {}\n http {\n server {\n\n set \\$token \"\";\n __TOKEN__\n\n listen 80;\n location = /v2/ {\n proxy_pass https://public.ecr.aws/v2/;\n proxy_intercept_errors on;\n proxy_set_header Authorization \"Bearer \\$token\";\n proxy_ssl_server_name on;\n\n error_page 404 = @fallback;\n }\n\n location ~ ^/v2/(.+) {\n # Capture the rest of the path\n set \\$rest \\$1;\n\n # Build upstream path: /v2/docker/\n proxy_pass https://public.ecr.aws/v2/docker/\\$rest;\n proxy_intercept_errors on;\n proxy_set_header Authorization \"Bearer \\$token\";\n proxy_ssl_server_name on;\n\n error_page 404 = @fallback;\n }\n\n location @fallback {\n proxy_pass https://registry-1.docker.io;\n proxy_intercept_errors on;\n proxy_ssl_server_name on;\n\n error_page 429 = @throttled;\n }\n\n location @throttled {\n return 429 \"Docker Hub rate limit exceeded. Please do `docker login` before deployment.\\n\";\n }\n }\n }\n\n EOF\n - TOKEN_BLOCK=$(echo \"https://public.ecr.aws\" | docker-credential-ecr-login get | jq -j '\"\\(.Username):\\(.Secret)\"' | base64 -w 1024 | awk '{ print \"set $token \\\"${token}\" $0 \"\\\";\" }')\n - awk -v token_block=\"$TOKEN_BLOCK\" '{ if ($0 ~ /__TOKEN__/) { print token_block } else { print } }' /tmp/nginx.conf.tmpl > /tmp/nginx.conf\n - docker run -d -p 5000:80 --restart=always --name dockerhub-ecr-mirror -v /tmp/nginx.conf:/etc/nginx/nginx.conf:ro public.ecr.aws/nginx/nginx:stable-alpine\n - sleep 3\n - docker buildx create --use --driver=docker-container --buildkitd-config=/tmp/buildkitd.toml --driver-opt network=host --use --platform linux/amd64\n build:\n commands:\n - echo Building the Docker image...\n - docker buildx build -t 381492210770.dkr.ecr.us-west-2.amazonaws.com/html-css-js/test2-build:app-image-c2165c65-x86_64 -f Dockerfile --push --cache-from=type=registry,ref=381492210770.dkr.ecr.us-west-2.amazonaws.com/html-css-js/test2-build:defang-cache --cache-to=type=registry,image-manifest=true,oci-mediatypes=true,mode=max,ref=381492210770.dkr.ecr.us-west-2.amazonaws.com/html-css-js/test2-build:defang-cache --platform linux/amd64 ${CODEBUILD_SRC_DIR}\n", + "insecureSsl": false, + "location": "defang-cd-bucket-cybpbzz8hzm7/uploads/sha256-T7iSdNuO_vTAJsuH2uQcjFPe93mJ7CuOldFH7FRtLaI_.tar.gz", + "type": "S3" + }, + "startTime": "2025-11-29T02:02:21.353Z", + "timeoutInMinutes": 45 + } + ], + "buildsNotFound": [] +} diff --git a/src/pkg/clouds/aws/ecs/upload.go b/src/pkg/clouds/aws/codebuild/upload.go similarity index 90% rename from src/pkg/clouds/aws/ecs/upload.go rename to src/pkg/clouds/aws/codebuild/upload.go index 9a9c05e7b..a0afdaa38 100644 --- a/src/pkg/clouds/aws/ecs/upload.go +++ b/src/pkg/clouds/aws/codebuild/upload.go @@ -1,4 +1,4 @@ -package ecs +package codebuild import ( "context" @@ -15,7 +15,7 @@ var s3InvalidCharsRegexp = regexp.MustCompile(`[^a-zA-Z0-9!_.*'()-]`) const prefix = "uploads/" -func (a *AwsEcs) CreateUploadURL(ctx context.Context, name string) (string, error) { +func (a *AwsCodeBuild) CreateUploadURL(ctx context.Context, name string) (string, error) { cfg, err := a.LoadConfig(ctx) if err != nil { return "", err diff --git a/src/pkg/clouds/aws/ecs/cfn/outputs.go b/src/pkg/clouds/aws/ecs/cfn/outputs.go deleted file mode 100644 index 706607a59..000000000 --- a/src/pkg/clouds/aws/ecs/cfn/outputs.go +++ /dev/null @@ -1,13 +0,0 @@ -package cfn - -const ( - OutputsBucketName = "bucketName" - OutputsCIRoleARN = "ciRoleArn" - OutputsClusterName = "clusterName" - OutputsDefaultSecurityGroupID = "defaultSecurityGroupId" - OutputsLogGroupARN = "logGroupArn" - OutputsSecurityGroupID = "securityGroupId" - OutputsSubnetID = "subnetId" - OutputsTaskDefARN = "taskDefArn" - OutputsTemplateVersion = "templateVersion" -) diff --git a/src/pkg/clouds/aws/ecs/cfn/template.go b/src/pkg/clouds/aws/ecs/cfn/template.go deleted file mode 100644 index 2cef8211d..000000000 --- a/src/pkg/clouds/aws/ecs/cfn/template.go +++ /dev/null @@ -1,740 +0,0 @@ -package cfn - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "math" - "strconv" - "strings" - - "github.com/DefangLabs/defang/src/pkg/clouds" - awsecs "github.com/DefangLabs/defang/src/pkg/clouds/aws/ecs" - "github.com/aws/smithy-go/ptr" - "github.com/awslabs/goformation/v7/cloudformation" - "github.com/awslabs/goformation/v7/cloudformation/ec2" - "github.com/awslabs/goformation/v7/cloudformation/ecr" - "github.com/awslabs/goformation/v7/cloudformation/ecs" - "github.com/awslabs/goformation/v7/cloudformation/iam" - "github.com/awslabs/goformation/v7/cloudformation/logs" - "github.com/awslabs/goformation/v7/cloudformation/policies" - "github.com/awslabs/goformation/v7/cloudformation/s3" - "github.com/awslabs/goformation/v7/cloudformation/secretsmanager" - "github.com/awslabs/goformation/v7/cloudformation/tags" -) - -const ( - maxCachePrefixLength = 20 // prefix must be 2-20 characters long; should be 30 https://github.com/hashicorp/terraform-provider-aws/pull/34716 - - TagKeyCreatedBy = "defang:CreatedBy" - TagKeyManagedBy = "defang:ManagedBy" - TagKeyPrefix = "defang:Prefix" - TagKeyStackName = "defang:CloudFormationStackName" - TagKeyStackRegion = "defang:CloudFormationStackRegion" -) - -func getCacheRepoPrefix(prefix, suffix string) string { - repo := prefix + suffix - if len(repo) > maxCachePrefixLength { - // Cache repo name is too long; hash it and use the first 6 chars - hash := sha256.Sum256([]byte(prefix)) - return hex.EncodeToString(hash[:])[:6] + "-" + suffix - } - return repo -} - -const TemplateRevision = 3 // bump this when the template changes! - -// CreateTemplate creates a parameterized CloudFormation template that can be statically served -// All conditional logic is moved to CloudFormation parameters and conditions. -// This allows the template to be generated once and reused across different deployments -// by providing different parameter values during stack creation/update. -func CreateTemplate(stack string, containers []clouds.Container) (*cloudformation.Template, error) { - const oidcProviderDefaultAud = "sts.amazonaws.com" - - prefix := stack + "-" - - defaultTags := []tags.Tag{ - { - Key: TagKeyCreatedBy, - Value: awsecs.CrunProjectName, - }, - { - Key: TagKeyPrefix, - Value: stack, - }, - { - Key: TagKeyManagedBy, - Value: "CloudFormation", - }, - { - Key: TagKeyStackName, - Value: cloudformation.Ref("AWS::StackName"), - }, - { - Key: TagKeyStackRegion, - Value: cloudformation.Ref("AWS::Region"), - }, - } - - template := cloudformation.NewTemplate() - template.Description = "Defang AWS CloudFormation template for the CD task. Do not delete this stack in the AWS console: use the Defang CLI instead. To create this stack, scroll down to acknowledge the risks and press 'Create stack'." - - // Parameters - // TODO: add an option to use the default VPC - template.Parameters[ParamsExistingVpcId] = cloudformation.Parameter{ - Type: "String", // TODO: use "AWS::EC2::VPC::Id" but seems it cannot be optional - Default: ptr.String(""), - Description: ptr.String("ID of existing VPC to use (optional: leave empty to create new VPC)"), - } - template.Parameters[ParamsRetainBucket] = cloudformation.Parameter{ - Type: "String", - Default: ptr.String("true"), - AllowedValues: []any{"true", "false"}, - Description: ptr.String("Whether to retain the S3 bucket on stack deletion"), - } - template.Parameters[ParamsEnablePullThroughCache] = cloudformation.Parameter{ - Type: "String", - Default: ptr.String("true"), - AllowedValues: []any{"true", "false"}, - Description: ptr.String("Whether to enable ECR pull-through cache"), - } - template.Parameters[ParamsDockerHubUsername] = cloudformation.Parameter{ - Type: "String", - Default: ptr.String(""), - Description: ptr.String("Docker Hub username for private registry access (optional)"), - // NoEcho: ptr.Bool(true), allow seeing username in AWS Console - } - template.Parameters[ParamsDockerHubAccessToken] = cloudformation.Parameter{ - Type: "String", - Default: ptr.String(""), - Description: ptr.String("Docker Hub access token for private registry access (optional)"), - NoEcho: ptr.Bool(true), - } - template.Parameters[ParamsOidcProviderIssuer] = cloudformation.Parameter{ - Type: "String", - Default: ptr.String(""), - Description: ptr.String("OIDC provider trusted issuer (optional)"), - } - template.Parameters[ParamsOidcProviderSubjects] = cloudformation.Parameter{ - Type: "CommaDelimitedList", - Default: ptr.String(""), - Description: ptr.String("OIDC provider trusted subject pattern(s) (optional)"), - } - template.Parameters[ParamsOidcProviderThumbprints] = cloudformation.Parameter{ - Type: "CommaDelimitedList", - Default: ptr.String(""), - Description: ptr.String("OIDC provider thumbprint(s) (optional)"), - } - template.Parameters[ParamsCIRoleName] = cloudformation.Parameter{ - Type: "String", - Default: ptr.String(""), - Description: ptr.String("Name of the CI role (optional)"), - } - template.Parameters[ParamsOidcProviderAudiences] = cloudformation.Parameter{ - Type: "CommaDelimitedList", - Default: ptr.String(oidcProviderDefaultAud), - Description: ptr.String("OIDC provider trusted audience(s) (optional)"), - } - template.Parameters[ParamsOidcProviderClaims] = cloudformation.Parameter{ - Type: "CommaDelimitedList", - Default: ptr.String(""), - Description: ptr.String(`Additional OIDC claim conditions as comma-separated JSON "key":"value" pairs (optional)`), - } - - // Metadata - AWS::CloudFormation::Interface for parameter grouping and labels - template.Metadata = map[string]interface{}{ - "AWS::CloudFormation::Interface": map[string]interface{}{ - "ParameterGroups": []map[string]interface{}{ - { - "Label": map[string]string{"default": "CI/CD Integration (OIDC)"}, - "Parameters": []string{ParamsOidcProviderIssuer, ParamsOidcProviderSubjects, ParamsOidcProviderAudiences, ParamsCIRoleName, ParamsOidcProviderThumbprints, ParamsOidcProviderClaims}, - }, - { - "Label": map[string]string{"default": "Network Configuration"}, - "Parameters": []string{ParamsExistingVpcId}, - }, - { - "Label": map[string]string{"default": "Container Registry (ECR Pull-Through Cache)"}, - "Parameters": []string{ParamsEnablePullThroughCache, ParamsDockerHubUsername, ParamsDockerHubAccessToken}, - }, - { - "Label": map[string]string{"default": "Storage Configuration"}, - "Parameters": []string{ParamsRetainBucket}, - }, - }, - "ParameterLabels": map[string]interface{}{ - ParamsExistingVpcId: map[string]string{"default": "Existing VPC ID"}, - ParamsRetainBucket: map[string]string{"default": "Retain S3 Bucket on Delete"}, - ParamsEnablePullThroughCache: map[string]string{"default": "Enable ECR Pull-Through Cache"}, - ParamsDockerHubUsername: map[string]string{"default": "Docker Hub Username"}, - ParamsDockerHubAccessToken: map[string]string{"default": "Docker Hub Access Token"}, - ParamsOidcProviderIssuer: map[string]string{"default": "OIDC Provider Issuer"}, - ParamsOidcProviderSubjects: map[string]string{"default": "OIDC Trusted Subject Patterns"}, - ParamsOidcProviderAudiences: map[string]string{"default": "OIDC Trusted Audiences"}, - ParamsOidcProviderThumbprints: map[string]string{"default": "OIDC Provider Thumbprints"}, - ParamsOidcProviderClaims: map[string]string{"default": "Additional OIDC Claim Conditions"}, - ParamsCIRoleName: map[string]string{"default": "CI Role Name"}, - }, - }, - } - - // Conditions - const _condCreateVpcResources = "CreateVpcResources" - template.Conditions[_condCreateVpcResources] = cloudformation.Equals(cloudformation.Ref(ParamsExistingVpcId), "") - const _condRetainS3Bucket = "RetainS3Bucket" - template.Conditions[_condRetainS3Bucket] = cloudformation.Equals(cloudformation.Ref(ParamsRetainBucket), "true") - const _condEnablePullThroughCache = "EnablePullThroughCache" - template.Conditions[_condEnablePullThroughCache] = cloudformation.Equals(cloudformation.Ref(ParamsEnablePullThroughCache), "true") - const _condEnableDockerPullThroughCache = "EnableDockerPullThroughCache" - template.Conditions[_condEnableDockerPullThroughCache] = cloudformation.And([]string{ - cloudformation.Equals(cloudformation.Ref(ParamsEnablePullThroughCache), "true"), - cloudformation.Not([]string{cloudformation.Equals(cloudformation.Ref(ParamsDockerHubUsername), "")}), - cloudformation.Not([]string{cloudformation.Equals(cloudformation.Ref(ParamsDockerHubAccessToken), "")}), - }) - const _condOidcProvider = "OidcProvider" - template.Conditions[_condOidcProvider] = cloudformation.And([]string{ - cloudformation.Not([]string{cloudformation.Equals(cloudformation.Ref(ParamsOidcProviderIssuer), "")}), - cloudformation.Not([]string{cloudformation.Equals(cloudformation.Join("", cloudformation.Ref(ParamsOidcProviderSubjects)), "")}), - // cloudformation.Not([]string{cloudformation.Equals(cloudformation.Join("", cloudformation.Ref(ParamsOidcProviderThumbprints)), "")}), thumbprints are optional now - }) - const _condOverrideCIRoleName = "OverrideCIRoleName" - template.Conditions[_condOverrideCIRoleName] = cloudformation.Not([]string{cloudformation.Equals(cloudformation.Ref(ParamsCIRoleName), "")}) - const _condOidcClaims = "OidcClaims" - template.Conditions[_condOidcClaims] = cloudformation.Not([]string{cloudformation.Equals(cloudformation.Join("", cloudformation.Ref(ParamsOidcProviderClaims)), "")}) - const _condOidcThumbprints = "OidcThumbprints" - template.Conditions[_condOidcThumbprints] = cloudformation.Not([]string{cloudformation.Equals(cloudformation.Join("", cloudformation.Ref(ParamsOidcProviderThumbprints)), "")}) - - // 1. bucket (for deployment state) - const _bucket = "Bucket" - template.Resources[_bucket] = &s3.Bucket{ - Tags: defaultTags, - // BucketName: ptr.String(PREFIX + "bucket" + SUFFIX), // optional; TODO: might want to fix this name to allow Pulumi destroy after stack deletion - AWSCloudFormationDeletionPolicy: policies.DeletionPolicy(cloudformation.If(_condRetainS3Bucket, "RetainExceptOnCreate", "Delete")), - VersioningConfiguration: &s3.Bucket_VersioningConfiguration{ - Status: "Enabled", - }, - } - // 1b. TODO: add lifecycle policy to the bucket to delete old versions - // const _bucketLifecyclePolicy = "BucketLifecyclePolicy" - // template.Resources[_bucketLifecyclePolicy] = &s3.Bucket_LifecycleConfiguration{ - // Rules: []s3.Bucket_LifecycleConfiguration_Rule{ - // { - // Id: ptr.String("DeleteOldVersions"), - // Status: "Enabled", - // NoncurrentVersionExpiration: &s3.Bucket_LifecycleConfiguration_Rule_NoncurrentVersionExpiration{ - // NoncurrentDays: ptr.Int(30), - // }, - // }, - // }, - // } - - // 2. ECS cluster - const _cluster = "Cluster" - template.Resources[_cluster] = &ecs.Cluster{ - Tags: defaultTags, - // ClusterName: ptr.String(PREFIX + "cluster" + SUFFIX), // optional - } - - // 3. ECS capacity provider - const _capacityProvider = "CapacityProvider" - template.Resources[_capacityProvider] = &ecs.ClusterCapacityProviderAssociations{ - Cluster: cloudformation.Ref(_cluster), - CapacityProviders: []string{"FARGATE", "FARGATE_SPOT"}, - DefaultCapacityProviderStrategy: []ecs.ClusterCapacityProviderAssociations_CapacityProviderStrategy{ - { - CapacityProvider: "FARGATE", // task may override to FARGATE_SPOT - Weight: ptr.Int(1), - }, - }, - } - - // 4. CloudWatch log group - const _logGroup = "LogGroup" - template.Resources[_logGroup] = &logs.LogGroup{ - Tags: defaultTags, - // LogGroupName: ptr.String(PREFIX + "log-group-test" + SUFFIX), // optional - RetentionInDays: ptr.Int(1), - // Make sure the log group cannot be deleted while the cluster is up - AWSCloudFormationDependsOn: []string{ - _cluster, - }, - } - - // 5. ECR pull-through cache rules - // TODO: Creating pull through cache rules isn't supported in the following Regions: - // * China (Beijing) (cn-north-1) - // * China (Ningxia) (cn-northwest-1) - // * AWS GovCloud (US-East) (us-gov-east-1) - // * AWS GovCloud (US-West) (us-gov-west-1) - - // Create pull-through cache resources conditionally - ecrPublicPrefix := getCacheRepoPrefix(prefix, "ecr-public") - dockerPublicPrefix := getCacheRepoPrefix(prefix, "docker-public") - - // 5a. ECR Public pull-through cache - const _pullThroughCache = "PullThroughCache" - template.Resources[_pullThroughCache] = &ecr.PullThroughCacheRule{ - AWSCloudFormationCondition: _condEnablePullThroughCache, - EcrRepositoryPrefix: ptr.String(ecrPublicPrefix), // FIXME: forcing this name causes conflicts if multiple stacks are created with same prefix - UpstreamRegistryUrl: ptr.String(awsecs.EcrPublicRegistry), - } - - // 5b. Docker Hub credentials secret - needs proper JSON format - // When creating the Secrets Manager secret that contains the upstream registry credentials, the secret name must use the `ecr-pullthroughcache/` prefix. - // This is the struct AWS wants, see https://docs.aws.amazon.com/AmazonECR/latest/userguide/pull-through-cache-creating-secret.html - // #nosec G101 - not a secret - const _privateRepoSecret = "PrivateRepoSecret" - template.Resources[_privateRepoSecret] = &secretsmanager.Secret{ - AWSCloudFormationCondition: _condEnableDockerPullThroughCache, - Tags: defaultTags, - Description: ptr.String("Docker Hub credentials for the ECR pull-through cache rule"), - Name: ptr.String("ecr-pullthroughcache/" + dockerPublicPrefix), - SecretString: ptr.String(cloudformation.Sub(`{"username":"${` + ParamsDockerHubUsername + `}","accessToken":"${` + ParamsDockerHubAccessToken + `}"}`)), - } - - // 5c. Docker Hub pull-through cache - const _pullThroughCacheDocker = "PullThroughCacheDocker" - template.Resources[_pullThroughCacheDocker] = &ecr.PullThroughCacheRule{ - AWSCloudFormationCondition: _condEnableDockerPullThroughCache, - EcrRepositoryPrefix: ptr.String(dockerPublicPrefix), // FIXME: forcing this name causes conflicts if multiple stacks are created with same prefix - UpstreamRegistryUrl: ptr.String("registry-1.docker.io"), - CredentialArn: cloudformation.RefPtr(_privateRepoSecret), - } - - // 6. IAM roles for ECS task - assumeRolePolicyDocumentECS := map[string]any{ - "Version": "2012-10-17", - "Statement": []map[string]any{ - { - "Effect": "Allow", - "Principal": map[string]any{ - "Service": []string{ - "ecs-tasks.amazonaws.com", - }, - }, - "Action": []string{ - "sts:AssumeRole", - }, - }, - }, - } - - // 6a. IAM exec role for task - execPolicies := []iam.Role_Policy{ - { - // From https://docs.aws.amazon.com/AmazonECR/latest/userguide/pull-through-cache.html#pull-through-cache-iam - PolicyName: "AllowECRPassThrough", // misnomer - PolicyDocument: map[string]any{ - "Version": "2012-10-17", - "Statement": []any{ - map[string]any{ - "Effect": "Allow", - "Action": []string{ - "ecr:CreatePullThroughCacheRule", - "ecr:BatchImportUpstreamImage", // should be registry permission instead - "ecr:CreateRepository", // can be registry permission instead - }, - "Resource": "*", // FIXME: restrict cloudformation.Sub("arn:${AWS::Partition}:ecr:${AWS::Region}:${AWS::AccountId}:repository/${PullThroughCache}:*"), - }, - cloudformation.If(_condEnableDockerPullThroughCache, - map[string]any{ - "Effect": "Allow", - "Action": []string{ - "secretsmanager:GetSecretValue", - "ssm:GetParameters", - // "kms:Decrypt", Required only if your key uses a custom KMS key and not the default key - }, - "Resource": cloudformation.Ref(_privateRepoSecret), - }, - cloudformation.Ref("AWS::NoValue"), - ), - }, - }, - }, - } - - const _executionRole = "ExecutionRole" - template.Resources[_executionRole] = &iam.Role{ - Tags: defaultTags, - // RoleName: ptr.String(PREFIX + "execution-role" + SUFFIX), // optional - ManagedPolicyArns: []string{ - "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", - }, - AssumeRolePolicyDocument: assumeRolePolicyDocumentECS, - Policies: execPolicies, - } - - // 6b. IAM role for task (optional) - const _taskRole = "TaskRole" - template.Resources[_taskRole] = &iam.Role{ - Tags: defaultTags, - // RoleName: ptr.String(PREFIX + "task-role" + SUFFIX), // optional - ManagedPolicyArns: []string{ - "arn:aws:iam::aws:policy/AdministratorAccess", // TODO: make this configurable - }, - AssumeRolePolicyDocument: assumeRolePolicyDocumentECS, - Policies: []iam.Role_Policy{ - { - // From https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html#ecs-exec-required-iam-permissions - PolicyName: "AllowExecuteCommand", - PolicyDocument: map[string]any{ - "Version": "2012-10-17", - "Statement": []map[string]any{ - { - "Effect": "Allow", - "Action": []string{ - "ssmmessages:CreateDataChannel", - "ssmmessages:OpenDataChannel", - "ssmmessages:OpenControlChannel", - "ssmmessages:CreateControlChannel", - }, - "Resource": "*", // TODO: restrict - }, - }, - }, - }, - { - PolicyName: "AllowPassRole", - PolicyDocument: map[string]any{ - "Version": "2012-10-17", - "Statement": []map[string]any{ - { - "Effect": "Allow", - "Action": []string{ - "iam:PassRole", - }, - "Resource": "*", // TODO: restrict to roles that are needed/created by the task - }, - }, - }, - }, - { - PolicyName: "AllowAssumeRole", - PolicyDocument: map[string]any{ - "Version": "2012-10-17", - "Statement": []map[string]any{ - { - "Effect": "Allow", - "Action": []string{ - "sts:AssumeRole", - }, - "Resource": "*", - }, - }, - }, - }, - }, - } - - // 7. ECS task definition - var totalCpu, totalMiB float64 - var platform string - for _, container := range containers { - totalCpu += float64(container.Cpus) - totalMiB += math.Max(float64(container.Memory)/1024/1024, 6) // 6MiB min for the container - if platform == "" { - platform = container.Platform - } else if platform != container.Platform { - return nil, errors.New("all containers must have the same platform") - } - } - mCpu, mib := awsecs.FixupFargateConfig(totalCpu, totalMiB) - arch, os := awsecs.PlatformToArchOS(platform) - var archP, osP *string - if arch != "" { - archP = ptr.String(arch) - } - if os != "" { - osP = ptr.String(os) - } - - var volumes []ecs.TaskDefinition_Volume - var containerDefinitions []ecs.TaskDefinition_ContainerDefinition - for _, container := range containers { - for _, v := range container.Volumes { - volumes = append(volumes, ecs.TaskDefinition_Volume{ - Name: ptr.String(v.Source), - }) - } - - volumesFrom := make([]ecs.TaskDefinition_VolumeFrom, 0, len(container.VolumesFrom)) - for _, v := range container.VolumesFrom { - parts := strings.SplitN(v, ":", 2) - ro := false - if len(parts) == 2 && parts[1] == "ro" { - ro = true - } - volumesFrom = append(volumesFrom, ecs.TaskDefinition_VolumeFrom{ - ReadOnly: ptr.Bool(ro), - SourceContainer: ptr.String(parts[0]), - }) - } - - mountPoints := make([]ecs.TaskDefinition_MountPoint, 0, len(container.Volumes)) - for _, v := range container.Volumes { - mountPoints = append(mountPoints, ecs.TaskDefinition_MountPoint{ - ContainerPath: ptr.String(v.Target), - SourceVolume: ptr.String(v.Source), - ReadOnly: ptr.Bool(v.ReadOnly), - }) - } - - var cpuShares *int - if container.Cpus > 0 { - cpuShares = ptr.Int(int(container.Cpus * 1024)) - } - name := container.Name - if name == "" { - name = awsecs.CdContainerName // TODO: backwards compat; remove this - } - - var dependsOn []ecs.TaskDefinition_ContainerDependency - if container.DependsOn != nil { - for name, condition := range container.DependsOn { - dependsOn = append(dependsOn, ecs.TaskDefinition_ContainerDependency{ - Condition: ptr.String(string(condition)), - ContainerName: ptr.String(name), - }) - } - } - - image := container.Image - if repo, ok := strings.CutPrefix(image, awsecs.EcrPublicRegistry); ok { - image = cloudformation.If(_condEnablePullThroughCache, - cloudformation.Sub("${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/"+ecrPublicPrefix+repo), - container.Image, - ) - } else if repo, ok := strings.CutPrefix(image, awsecs.DockerRegistry); ok { - image = cloudformation.If(_condEnableDockerPullThroughCache, - cloudformation.Sub("${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/"+dockerPublicPrefix+repo), - container.Image, - ) - } else { - // TODO: support pull through cache for other registries - // TODO: support private repos (with or without pull-through cache) - } - if image == "" { - return nil, fmt.Errorf("container %v is using invalid image: %q", container.Name, image) - } - - def := ecs.TaskDefinition_ContainerDefinition{ - Name: name, - Image: image, - StopTimeout: ptr.Int(120), // TODO: make this configurable - Essential: container.Essential, - Cpu: cpuShares, - LogConfiguration: &ecs.TaskDefinition_LogConfiguration{ - LogDriver: "awslogs", - Options: map[string]string{ - "awslogs-group": cloudformation.Ref(_logGroup), - "awslogs-region": cloudformation.Ref("AWS::Region"), - "awslogs-stream-prefix": awsecs.AwsLogsStreamPrefix, - }, - }, - VolumesFrom: volumesFrom, - MountPoints: mountPoints, - EntryPoint: container.EntryPoint, - Command: container.Command, - DependsOnProp: dependsOn, - } - if container.WorkDir != "" { - def.WorkingDirectory = ptr.String(container.WorkDir) - } - containerDefinitions = append(containerDefinitions, def) - } - - const _taskDefinition = "TaskDefinition" - template.Resources[_taskDefinition] = &ecs.TaskDefinition{ - Tags: defaultTags, - RuntimePlatform: &ecs.TaskDefinition_RuntimePlatform{ - CpuArchitecture: archP, - OperatingSystemFamily: osP, - }, - Volumes: volumes, - ContainerDefinitions: containerDefinitions, - Cpu: ptr.String(strconv.FormatUint(uint64(mCpu), 10)), // MilliCPU - ExecutionRoleArn: cloudformation.RefPtr(_executionRole), - Memory: ptr.String(strconv.FormatUint(uint64(mib), 10)), // MiB - NetworkMode: ptr.String("awsvpc"), - RequiresCompatibilities: []string{"FARGATE"}, - TaskRoleArn: cloudformation.RefPtr(_taskRole), - // Family: cloudformation.SubPtr("${AWS::StackName}-TaskDefinition"), // optional, but needed to avoid TaskDef replacement - } - - // VPC resources - create conditionally - const _vpc = "VPC" - template.Resources[_vpc] = &ec2.VPC{ - AWSCloudFormationCondition: _condCreateVpcResources, - Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "vpc"}}, defaultTags...), - CidrBlock: ptr.String("10.0.0.0/16"), - } - - vpcId := cloudformation.If(_condCreateVpcResources, cloudformation.Ref(_vpc), cloudformation.Ref(ParamsExistingVpcId)) - // 8b. an internet gateway; TODO: make internet access optional - const _internetGateway = "InternetGateway" - template.Resources[_internetGateway] = &ec2.InternetGateway{ - AWSCloudFormationCondition: _condCreateVpcResources, - Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "igw"}}, defaultTags...), - } - // 8c. an internet gateway attachment for the VPC - const _internetGatewayAttachment = "InternetGatewayAttachment" - template.Resources[_internetGatewayAttachment] = &ec2.VPCGatewayAttachment{ - AWSCloudFormationCondition: _condCreateVpcResources, - VpcId: cloudformation.Ref(_vpc), - InternetGatewayId: cloudformation.RefPtr(_internetGateway), - } - // 8d. a route table - const _routeTable = "RouteTable" - template.Resources[_routeTable] = &ec2.RouteTable{ - AWSCloudFormationCondition: _condCreateVpcResources, - Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "routetable"}}, defaultTags...), - VpcId: cloudformation.Ref(_vpc), - } - // 8e. a route for the route table and internet gateway - const _route = "Route" - template.Resources[_route] = &ec2.Route{ - AWSCloudFormationCondition: _condCreateVpcResources, - RouteTableId: cloudformation.Ref(_routeTable), - DestinationCidrBlock: ptr.String("0.0.0.0/0"), - GatewayId: cloudformation.RefPtr(_internetGateway), - } - // 8f. a public subnet - const _subnet = "Subnet" - template.Resources[_subnet] = &ec2.Subnet{ - AWSCloudFormationCondition: _condCreateVpcResources, - Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "subnet"}}, defaultTags...), - // AvailabilityZone:; TODO: parse region suffix - CidrBlock: ptr.String("10.0.0.0/20"), - VpcId: cloudformation.Ref(_vpc), - MapPublicIpOnLaunch: ptr.Bool(true), - } - // 8g. a subnet / route table association - const _subnetRouteTableAssociation = "SubnetRouteTableAssociation" - template.Resources[_subnetRouteTableAssociation] = &ec2.SubnetRouteTableAssociation{ - AWSCloudFormationCondition: _condCreateVpcResources, - SubnetId: cloudformation.Ref(_subnet), - RouteTableId: cloudformation.Ref(_routeTable), - } - // 8h. S3 gateway endpoint (to avoid S3 bandwidth charges) - const _s3GatewayEndpoint = "S3GatewayEndpoint" - template.Resources[_s3GatewayEndpoint] = &ec2.VPCEndpoint{ - AWSCloudFormationCondition: _condCreateVpcResources, - VpcEndpointType: ptr.String("Gateway"), - VpcId: cloudformation.Ref(_vpc), - ServiceName: cloudformation.Sub("com.amazonaws.${AWS::Region}.s3"), - } - - const _defaultSecurityGroup = "DefaultSecurityGroup" - template.Outputs[OutputsDefaultSecurityGroupID] = cloudformation.Output{ - Condition: ptr.String(_condCreateVpcResources), - Description: ptr.String("ID of the default security group"), - Value: cloudformation.GetAtt(_vpc, _defaultSecurityGroup), - } - template.Outputs[OutputsSubnetID] = cloudformation.Output{ - Condition: ptr.String(_condCreateVpcResources), - Value: cloudformation.Ref(_subnet), - Description: ptr.String("ID of the subnet"), - } - - // Don't use the default security group, because its rules might have been removed or modified - const _securityGroup = "SecurityGroup" - template.Resources[_securityGroup] = &ec2.SecurityGroup{ - Tags: defaultTags, // Name tag is ignored - GroupDescription: "Security group for the ECS task that allows all outbound traffic", - VpcId: ptr.String(vpcId), - // SecurityGroupEgress: []ec2.SecurityGroup_Egress{; use default egress; TODO: add ability to restrict outbound traffic - // { - // IpProtocol: "tcp", - // FromPort: ptr.Int(1), - // ToPort: ptr.Int(65535), - // // CidrIp: ptr.String(" - // }, - // }, - } - - // 9a. IAM OIDC provider - // FIXME: You cannot register the same provider multiple times in a single AWS account. If you try to submit a URL that has already been used for an OpenID Connect provider in the AWS account, you will get an error. - const _oidcProvider = "OIDCProvider" - template.Resources[_oidcProvider] = &OIDCProvider{ - AWSCloudFormationCondition: _condOidcProvider, - Tags: defaultTags, - ClientIdList: cloudformation.Ref(ParamsOidcProviderAudiences), - ThumbprintList: cloudformation.If(_condOidcThumbprints, - cloudformation.Ref(ParamsOidcProviderThumbprints), - cloudformation.Ref("AWS::NoValue"), - ), - Url: cloudformation.SubPtr(`https://${` + ParamsOidcProviderIssuer + `}`), - } - - // 9b. CI role - const _CIRole = "CIRole" - template.Resources[_CIRole] = &iam.Role{ - AWSCloudFormationCondition: _condOidcProvider, - RoleName: cloudformation.IfPtr(_condOverrideCIRoleName, - cloudformation.Ref(ParamsCIRoleName), - cloudformation.Ref("AWS::NoValue"), - ), - Tags: defaultTags, - AssumeRolePolicyDocument: cloudformation.SubVars(`{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": { - "Federated": "${Provider}" - }, - "Action": "sts:AssumeRoleWithWebIdentity", - "Condition": { - "StringEquals": { - "${`+ParamsOidcProviderIssuer+`}:aud": [ "${Audiences}" ]${ExtraClaims} - }, - "StringLike": { - "${`+ParamsOidcProviderIssuer+`}:sub": [ "${Subjects}" ] - } - } - }] -}`, map[string]any{ - "Audiences": cloudformation.Join(`","`, cloudformation.Ref(ParamsOidcProviderAudiences)), - "Provider": cloudformation.Ref(_oidcProvider), - "Subjects": cloudformation.Join(`","`, cloudformation.Ref(ParamsOidcProviderSubjects)), - "ExtraClaims": cloudformation.If(_condOidcClaims, cloudformation.Join("", []any{",", cloudformation.Join(",", cloudformation.Ref(ParamsOidcProviderClaims))}), ""), - }), - ManagedPolicyArns: []string{ - "arn:aws:iam::aws:policy/AdministratorAccess", - }, - } - - // Declare the remaining stack outputs - template.Outputs[OutputsCIRoleARN] = cloudformation.Output{ - Condition: ptr.String(_condOidcProvider), - Description: ptr.String("ARN of the CI role"), - Value: cloudformation.GetAtt(_CIRole, "Arn"), - } - template.Outputs[OutputsTaskDefARN] = cloudformation.Output{ - Description: ptr.String("ARN of the ECS task definition"), - Value: cloudformation.Ref(_taskDefinition), - } - template.Outputs[OutputsClusterName] = cloudformation.Output{ - Description: ptr.String("Name of the ECS cluster"), - Value: cloudformation.Ref(_cluster), - } - template.Outputs[OutputsLogGroupARN] = cloudformation.Output{ - Description: ptr.String("ARN of the CloudWatch log group"), - Value: cloudformation.GetAtt(_logGroup, "Arn"), - } - template.Outputs[OutputsSecurityGroupID] = cloudformation.Output{ - Description: ptr.String("ID of the security group"), - Value: cloudformation.Ref(_securityGroup), - } - template.Outputs[OutputsBucketName] = cloudformation.Output{ - Description: ptr.String("Name of the S3 bucket"), - Value: cloudformation.Ref(_bucket), - } - template.Outputs[OutputsTemplateVersion] = cloudformation.Output{ - Description: ptr.String("Version of this CloudFormation template"), - Value: cloudformation.Int(TemplateRevision), - } - - return template, nil -} diff --git a/src/pkg/clouds/aws/ecs/cfn/template_test.go b/src/pkg/clouds/aws/ecs/cfn/template_test.go deleted file mode 100644 index d3f4f74a4..000000000 --- a/src/pkg/clouds/aws/ecs/cfn/template_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package cfn - -import ( - "os" - "testing" - - "github.com/DefangLabs/defang/src/pkg/clouds" - "github.com/stretchr/testify/assert" - "go.yaml.in/yaml/v4" -) - -func TestGetCacheRepoPrefix(t *testing.T) { - // Test cases - tests := []struct { - prefix string - suffix string - want string - }{ - { - prefix: "short-", - suffix: "ecr-public", - want: "short-ecr-public", - }, - { - prefix: "short-", - suffix: "docker-public", - want: "short-docker-public", - }, - { - prefix: "loooooooooong-", - suffix: "docker-public", - want: "fab852-docker-public", - }, - } - for _, tt := range tests { - t.Run(tt.want, func(t *testing.T) { - if got := getCacheRepoPrefix(tt.prefix, tt.suffix); got != tt.want { - t.Errorf("getCacheRepoPrefix() = %q, want %q", got, tt.want) - } else if len(got) > maxCachePrefixLength { - t.Errorf("getCacheRepoPrefix() = %q, want length <= %v", got, maxCachePrefixLength) - } - }) - } -} - -var testContainers = []clouds.Container{ - { - Image: "alpine:latest", - }, - { - Image: "docker.io/library/alpine:latest", - Name: "main2", - }, - { - Name: "main3", - Image: "public.ecr.aws/docker/library/alpine:latest", - Memory: 512_000_000, - Platform: "linux/amd64", - }, -} - -func createTestTemplate(t *testing.T) []byte { - t.Helper() - template, err := CreateTemplate("test", testContainers) - if err != nil { - t.Fatalf("Error creating template: %v", err) - } - templateBody, err := template.YAML() - if err != nil { - t.Fatalf("Error generating template YAML: %v", err) - } - return templateBody -} - -func TestCreateTemplate(t *testing.T) { - actual := createTestTemplate(t) - - const goldenYaml = "testdata/template.yaml" - expected, err := os.ReadFile(goldenYaml) - if err != nil { - if os.IsNotExist(err) { - err := os.WriteFile(goldenYaml, actual, 0644) - t.Fatalf("Golden file created: %s: %v", goldenYaml, err) - } else { - t.Fatalf("Error reading golden file: %v", err) - } - } - - // HACK: Unmarshal and marshal again to normalize indentation and formatting - // Caused by https://github.com/aws/aws-toolkit-vscode/issues/8356 - var goldenObj interface{} - err = yaml.Unmarshal(expected, &goldenObj) - if err != nil { - t.Fatalf("Error unmarshaling expected YAML: %v", err) - } - goldenBytes, err := yaml.Marshal(goldenObj) - if err != nil { - t.Fatalf("Error marshaling expected YAML: %v", err) - } - - assert.Equal(t, string(goldenBytes), string(actual), "Generated template does not match golden file") -} diff --git a/src/pkg/clouds/aws/ecs/cfn/testdata/template.yaml b/src/pkg/clouds/aws/ecs/cfn/testdata/template.yaml deleted file mode 100644 index aefbb1b0d..000000000 --- a/src/pkg/clouds/aws/ecs/cfn/testdata/template.yaml +++ /dev/null @@ -1,712 +0,0 @@ -AWSTemplateFormatVersion: "2010-09-09" -Conditions: - CreateVpcResources: - Fn::Equals: - - Ref: ExistingVpcId - - "" - EnableDockerPullThroughCache: - Fn::And: - - Fn::Equals: - - Ref: EnablePullThroughCache - - "true" - - Fn::Not: - - Fn::Equals: - - Ref: DockerHubUsername - - "" - - Fn::Not: - - Fn::Equals: - - Ref: DockerHubAccessToken - - "" - EnablePullThroughCache: - Fn::Equals: - - Ref: EnablePullThroughCache - - "true" - OidcClaims: - Fn::Not: - - Fn::Equals: - - Fn::Join: - - "" - - Ref: OidcProviderClaims - - "" - OidcProvider: - Fn::And: - - Fn::Not: - - Fn::Equals: - - Ref: OidcProviderIssuer - - "" - - Fn::Not: - - Fn::Equals: - - Fn::Join: - - "" - - Ref: OidcProviderSubjects - - "" - OidcThumbprints: - Fn::Not: - - Fn::Equals: - - Fn::Join: - - "" - - Ref: OidcProviderThumbprints - - "" - OverrideCIRoleName: - Fn::Not: - - Fn::Equals: - - Ref: CIRoleName - - "" - RetainS3Bucket: - Fn::Equals: - - Ref: RetainBucket - - "true" -Description: 'Defang AWS CloudFormation template for the CD task. Do not delete this stack in the AWS console: use the Defang CLI instead. To create this stack, scroll down to acknowledge the risks and press ''Create stack''.' -Metadata: - AWS::CloudFormation::Interface: - ParameterGroups: - - Label: - default: CI/CD Integration (OIDC) - Parameters: - - OidcProviderIssuer - - OidcProviderSubjects - - OidcProviderAudiences - - CIRoleName - - OidcProviderThumbprints - - OidcProviderClaims - - Label: - default: Network Configuration - Parameters: - - ExistingVpcId - - Label: - default: Container Registry (ECR Pull-Through Cache) - Parameters: - - EnablePullThroughCache - - DockerHubUsername - - DockerHubAccessToken - - Label: - default: Storage Configuration - Parameters: - - RetainBucket - ParameterLabels: - CIRoleName: - default: CI Role Name - DockerHubAccessToken: - default: Docker Hub Access Token - DockerHubUsername: - default: Docker Hub Username - EnablePullThroughCache: - default: Enable ECR Pull-Through Cache - ExistingVpcId: - default: Existing VPC ID - OidcProviderAudiences: - default: OIDC Trusted Audiences - OidcProviderClaims: - default: Additional OIDC Claim Conditions - OidcProviderIssuer: - default: OIDC Provider Issuer - OidcProviderSubjects: - default: OIDC Trusted Subject Patterns - OidcProviderThumbprints: - default: OIDC Provider Thumbprints - RetainBucket: - default: Retain S3 Bucket on Delete -Outputs: - bucketName: - Description: Name of the S3 bucket - Value: - Ref: Bucket - ciRoleArn: - Condition: OidcProvider - Description: ARN of the CI role - Value: - Fn::GetAtt: - - CIRole - - Arn - clusterName: - Description: Name of the ECS cluster - Value: - Ref: Cluster - defaultSecurityGroupId: - Condition: CreateVpcResources - Description: ID of the default security group - Value: - Fn::GetAtt: - - VPC - - DefaultSecurityGroup - logGroupArn: - Description: ARN of the CloudWatch log group - Value: - Fn::GetAtt: - - LogGroup - - Arn - securityGroupId: - Description: ID of the security group - Value: - Ref: SecurityGroup - subnetId: - Condition: CreateVpcResources - Description: ID of the subnet - Value: - Ref: Subnet - taskDefArn: - Description: ARN of the ECS task definition - Value: - Ref: TaskDefinition - templateVersion: - Description: Version of this CloudFormation template - Value: 3 -Parameters: - CIRoleName: - Default: "" - Description: Name of the CI role (optional) - Type: String - DockerHubAccessToken: - Default: "" - Description: Docker Hub access token for private registry access (optional) - NoEcho: true - Type: String - DockerHubUsername: - Default: "" - Description: Docker Hub username for private registry access (optional) - Type: String - EnablePullThroughCache: - AllowedValues: - - "true" - - "false" - Default: "true" - Description: Whether to enable ECR pull-through cache - Type: String - ExistingVpcId: - Default: "" - Description: 'ID of existing VPC to use (optional: leave empty to create new VPC)' - Type: String - OidcProviderAudiences: - Default: sts.amazonaws.com - Description: OIDC provider trusted audience(s) (optional) - Type: CommaDelimitedList - OidcProviderClaims: - Default: "" - Description: Additional OIDC claim conditions as comma-separated JSON "key":"value" pairs (optional) - Type: CommaDelimitedList - OidcProviderIssuer: - Default: "" - Description: OIDC provider trusted issuer (optional) - Type: String - OidcProviderSubjects: - Default: "" - Description: OIDC provider trusted subject pattern(s) (optional) - Type: CommaDelimitedList - OidcProviderThumbprints: - Default: "" - Description: OIDC provider thumbprint(s) (optional) - Type: CommaDelimitedList - RetainBucket: - AllowedValues: - - "true" - - "false" - Default: "true" - Description: Whether to retain the S3 bucket on stack deletion - Type: String -Resources: - Bucket: - DeletionPolicy: - Fn::If: - - RetainS3Bucket - - RetainExceptOnCreate - - Delete - Properties: - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - VersioningConfiguration: - Status: Enabled - Type: AWS::S3::Bucket - CIRole: - Condition: OidcProvider - Properties: - AssumeRolePolicyDocument: - Fn::Sub: - - |- - { - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": { - "Federated": "${Provider}" - }, - "Action": "sts:AssumeRoleWithWebIdentity", - "Condition": { - "StringEquals": { - "${OidcProviderIssuer}:aud": [ "${Audiences}" ]${ExtraClaims} - }, - "StringLike": { - "${OidcProviderIssuer}:sub": [ "${Subjects}" ] - } - } - }] - } - - Audiences: - Fn::Join: - - '","' - - Ref: OidcProviderAudiences - ExtraClaims: - Fn::If: - - OidcClaims - - Fn::Join: - - "" - - - ',' - - Fn::Join: - - ',' - - Ref: OidcProviderClaims - - "" - Provider: - Ref: OIDCProvider - Subjects: - Fn::Join: - - '","' - - Ref: OidcProviderSubjects - ManagedPolicyArns: - - arn:aws:iam::aws:policy/AdministratorAccess - RoleName: - Fn::If: - - OverrideCIRoleName - - Ref: CIRoleName - - Ref: AWS::NoValue - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - Type: AWS::IAM::Role - CapacityProvider: - Properties: - CapacityProviders: - - FARGATE - - FARGATE_SPOT - Cluster: - Ref: Cluster - DefaultCapacityProviderStrategy: - - CapacityProvider: FARGATE - Weight: 1 - Type: AWS::ECS::ClusterCapacityProviderAssociations - Cluster: - Properties: - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - Type: AWS::ECS::Cluster - ExecutionRole: - Properties: - AssumeRolePolicyDocument: - Statement: - - Action: - - sts:AssumeRole - Effect: Allow - Principal: - Service: - - ecs-tasks.amazonaws.com - Version: "2012-10-17" - ManagedPolicyArns: - - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy - Policies: - - PolicyDocument: - Statement: - - Action: - - ecr:CreatePullThroughCacheRule - - ecr:BatchImportUpstreamImage - - ecr:CreateRepository - Effect: Allow - Resource: '*' - - Fn::If: - - EnableDockerPullThroughCache - - Action: - - secretsmanager:GetSecretValue - - ssm:GetParameters - Effect: Allow - Resource: - Ref: PrivateRepoSecret - - Ref: AWS::NoValue - Version: "2012-10-17" - PolicyName: AllowECRPassThrough - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - Type: AWS::IAM::Role - InternetGateway: - Condition: CreateVpcResources - Properties: - Tags: - - Key: Name - Value: test-igw - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - Type: AWS::EC2::InternetGateway - InternetGatewayAttachment: - Condition: CreateVpcResources - Properties: - InternetGatewayId: - Ref: InternetGateway - VpcId: - Ref: VPC - Type: AWS::EC2::VPCGatewayAttachment - LogGroup: - DependsOn: - - Cluster - Properties: - RetentionInDays: 1 - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - Type: AWS::Logs::LogGroup - OIDCProvider: - Condition: OidcProvider - Properties: - ClientIdList: - Ref: OidcProviderAudiences - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - ThumbprintList: - Fn::If: - - OidcThumbprints - - Ref: OidcProviderThumbprints - - Ref: AWS::NoValue - Url: - Fn::Sub: https://${OidcProviderIssuer} - Type: AWS::IAM::OIDCProvider - PrivateRepoSecret: - Condition: EnableDockerPullThroughCache - Properties: - Description: Docker Hub credentials for the ECR pull-through cache rule - Name: ecr-pullthroughcache/test-docker-public - SecretString: - Fn::Sub: '{"username":"${DockerHubUsername}","accessToken":"${DockerHubAccessToken}"}' - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - Type: AWS::SecretsManager::Secret - PullThroughCache: - Condition: EnablePullThroughCache - Properties: - EcrRepositoryPrefix: test-ecr-public - UpstreamRegistryUrl: public.ecr.aws - Type: AWS::ECR::PullThroughCacheRule - PullThroughCacheDocker: - Condition: EnableDockerPullThroughCache - Properties: - CredentialArn: - Ref: PrivateRepoSecret - EcrRepositoryPrefix: test-docker-public - UpstreamRegistryUrl: registry-1.docker.io - Type: AWS::ECR::PullThroughCacheRule - Route: - Condition: CreateVpcResources - Properties: - DestinationCidrBlock: 0.0.0.0/0 - GatewayId: - Ref: InternetGateway - RouteTableId: - Ref: RouteTable - Type: AWS::EC2::Route - RouteTable: - Condition: CreateVpcResources - Properties: - Tags: - - Key: Name - Value: test-routetable - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - VpcId: - Ref: VPC - Type: AWS::EC2::RouteTable - S3GatewayEndpoint: - Condition: CreateVpcResources - Properties: - ServiceName: - Fn::Sub: com.amazonaws.${AWS::Region}.s3 - VpcEndpointType: Gateway - VpcId: - Ref: VPC - Type: AWS::EC2::VPCEndpoint - SecurityGroup: - Properties: - GroupDescription: Security group for the ECS task that allows all outbound traffic - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - VpcId: - Fn::If: - - CreateVpcResources - - Ref: VPC - - Ref: ExistingVpcId - Type: AWS::EC2::SecurityGroup - Subnet: - Condition: CreateVpcResources - Properties: - CidrBlock: 10.0.0.0/20 - MapPublicIpOnLaunch: true - Tags: - - Key: Name - Value: test-subnet - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - VpcId: - Ref: VPC - Type: AWS::EC2::Subnet - SubnetRouteTableAssociation: - Condition: CreateVpcResources - Properties: - RouteTableId: - Ref: RouteTable - SubnetId: - Ref: Subnet - Type: AWS::EC2::SubnetRouteTableAssociation - TaskDefinition: - Properties: - ContainerDefinitions: - - Image: alpine:latest - LogConfiguration: - LogDriver: awslogs - Options: - awslogs-group: - Ref: LogGroup - awslogs-region: - Ref: AWS::Region - awslogs-stream-prefix: defang - Name: main - StopTimeout: 120 - - Image: - Fn::If: - - EnableDockerPullThroughCache - - Fn::Sub: ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/test-docker-public/library/alpine:latest - - docker.io/library/alpine:latest - LogConfiguration: - LogDriver: awslogs - Options: - awslogs-group: - Ref: LogGroup - awslogs-region: - Ref: AWS::Region - awslogs-stream-prefix: defang - Name: main2 - StopTimeout: 120 - - Image: - Fn::If: - - EnablePullThroughCache - - Fn::Sub: ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/test-ecr-public/docker/library/alpine:latest - - public.ecr.aws/docker/library/alpine:latest - LogConfiguration: - LogDriver: awslogs - Options: - awslogs-group: - Ref: LogGroup - awslogs-region: - Ref: AWS::Region - awslogs-stream-prefix: defang - Name: main3 - StopTimeout: 120 - Cpu: "256" - ExecutionRoleArn: - Ref: ExecutionRole - Memory: "512" - NetworkMode: awsvpc - RequiresCompatibilities: - - FARGATE - RuntimePlatform: - CpuArchitecture: X86_64 - OperatingSystemFamily: LINUX - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - TaskRoleArn: - Ref: TaskRole - Type: AWS::ECS::TaskDefinition - TaskRole: - Properties: - AssumeRolePolicyDocument: - Statement: - - Action: - - sts:AssumeRole - Effect: Allow - Principal: - Service: - - ecs-tasks.amazonaws.com - Version: "2012-10-17" - ManagedPolicyArns: - - arn:aws:iam::aws:policy/AdministratorAccess - Policies: - - PolicyDocument: - Statement: - - Action: - - ssmmessages:CreateDataChannel - - ssmmessages:OpenDataChannel - - ssmmessages:OpenControlChannel - - ssmmessages:CreateControlChannel - Effect: Allow - Resource: '*' - Version: "2012-10-17" - PolicyName: AllowExecuteCommand - - PolicyDocument: - Statement: - - Action: - - iam:PassRole - Effect: Allow - Resource: '*' - Version: "2012-10-17" - PolicyName: AllowPassRole - - PolicyDocument: - Statement: - - Action: - - sts:AssumeRole - Effect: Allow - Resource: '*' - Version: "2012-10-17" - PolicyName: AllowAssumeRole - Tags: - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - Type: AWS::IAM::Role - VPC: - Condition: CreateVpcResources - Properties: - CidrBlock: 10.0.0.0/16 - Tags: - - Key: Name - Value: test-vpc - - Key: defang:CreatedBy - Value: defang - - Key: defang:Prefix - Value: test - - Key: defang:ManagedBy - Value: CloudFormation - - Key: defang:CloudFormationStackName - Value: - Ref: AWS::StackName - - Key: defang:CloudFormationStackRegion - Value: - Ref: AWS::Region - Type: AWS::EC2::VPC diff --git a/src/pkg/clouds/aws/ecs/common.go b/src/pkg/clouds/aws/ecs/common.go deleted file mode 100644 index 8d013516a..000000000 --- a/src/pkg/clouds/aws/ecs/common.go +++ /dev/null @@ -1,70 +0,0 @@ -package ecs - -import ( - "strings" - - "github.com/DefangLabs/defang/src/pkg/clouds" - "github.com/DefangLabs/defang/src/pkg/clouds/aws" -) - -const ( - CdContainerName = "main" - DockerRegistry = "docker.io" - EcrPublicRegistry = "public.ecr.aws" - CrunProjectName = "defang" -) - -type TaskArn = clouds.TaskID - -type AwsEcs struct { - aws.Aws - BucketName string - CIRoleARN string - ClusterName string - DefaultSecurityGroupID string - LogGroupARN string - RetainBucket bool - SecurityGroupID string - Spot bool - SubNetID string - TaskDefARN string - VpcID string -} - -func PlatformToArchOS(platform string) (string, string) { - parts := strings.SplitN(platform, "/", 3) // Can be "os/arch/variant" like "linux/arm64/v8" - - if len(parts) == 1 { - arch := parts[0] - return normalizedArch(arch), "" - } else { - os := parts[0] - arch := parts[1] - os = strings.ToUpper(os) - return normalizedArch(arch), os - } -} - -func normalizedArch(arch string) string { - // From https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ecs-taskdefinition-runtimeplatform.html#cfn-ecs-taskdefinition-runtimeplatform-cpuarchitecture - arch = strings.ToUpper(arch) - if arch == "AMD64" { - arch = "X86_64" - } - return arch -} - -func (a *AwsEcs) GetVpcID() string { - return a.VpcID -} - -func (a *AwsEcs) MakeARN(service, resource string) string { - return strings.Join([]string{ - "arn", - "aws", - service, - string(a.Region), - a.AccountID, - resource, - }, ":") -} diff --git a/src/pkg/clouds/aws/ecs/common_test.go b/src/pkg/clouds/aws/ecs/common_test.go deleted file mode 100644 index 7b2fe8d6c..000000000 --- a/src/pkg/clouds/aws/ecs/common_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package ecs - -import ( - "testing" -) - -func TestPlatformToArch(t *testing.T) { - tests := []struct { - platform string - wantArch string - wantOs string - }{ - {"", "", ""}, - {"blah", "BLAH", ""}, // invalid platform - {"amd64", "X86_64", ""}, - {"arm64", "ARM64", ""}, - {"linux/amd64", "X86_64", "LINUX"}, - {"linux/arm64", "ARM64", "LINUX"}, - {"linux/arm64/v8", "ARM64", "LINUX"}, - {"linux/blah", "BLAH", "LINUX"}, // invalid platform - {"windows/blah", "BLAH", "WINDOWS"}, // invalid platform - {"windows/amd64", "X86_64", "WINDOWS"}, - } - for _, tt := range tests { - t.Run(tt.platform, func(t *testing.T) { - arch, os := PlatformToArchOS(tt.platform) - if os != tt.wantOs { - t.Errorf("PlatformToArch() os = %q, want %q", os, tt.wantOs) - } - if arch != tt.wantArch { - t.Errorf("PlatformToArch() arch = %q, want %q", arch, tt.wantArch) - } - }) - } -} diff --git a/src/pkg/clouds/aws/ecs/fargate.go b/src/pkg/clouds/aws/ecs/fargate.go deleted file mode 100644 index d67423249..000000000 --- a/src/pkg/clouds/aws/ecs/fargate.go +++ /dev/null @@ -1,52 +0,0 @@ -package ecs - -import ( - "fmt" - "math" -) - -type CpuUnits = uint -type MemoryMiB = uint - -func makeMinMaxCeil(value float64, minValue, maxValue, step uint) uint { - if value <= float64(minValue) || math.IsNaN(value) { - return minValue - } else if value >= float64(maxValue) { - return maxValue - } - return uint(math.Ceil(value/float64(step))) * step -} - -func fixupFargateCPU(vCpu float64) CpuUnits { - return 1 << makeMinMaxCeil(math.Log2(vCpu)+10, 8, 14, 1) // 256…16384 -} - -func fixupFargateMemory(cpu CpuUnits, memoryMiB float64) MemoryMiB { - switch cpu { - case 256: // 0.25 vCPU - return makeMinMaxCeil(memoryMiB, 512, 2048, 1024) - case 512: // 0.5 vCPU - return makeMinMaxCeil(memoryMiB, 1024, 4096, 1024) - case 1024: // 1 vCPU - return makeMinMaxCeil(memoryMiB, 2048, 8192, 1024) - case 2048: // 2 vCPU - return makeMinMaxCeil(memoryMiB, 4096, 16384, 1024) - case 4096: // 4 vCPU - return makeMinMaxCeil(memoryMiB, 8192, 30720, 1024) - case 8192: // 8 vCPU - return makeMinMaxCeil(memoryMiB, 16384, 61440, 4096) - case 16384: // 16 vCPU - return makeMinMaxCeil(memoryMiB, 32768, 122880, 4096) - default: - panic(fmt.Sprintf("Unsupported value for cpu: %v", cpu)) - } -} - -func FixupFargateConfig(vCpu, memoryMiB float64) (cpu CpuUnits, memory MemoryMiB) { - for cpu = fixupFargateCPU(vCpu); ; cpu *= 2 { - memory = fixupFargateMemory(cpu, memoryMiB) - if float64(memory) >= memoryMiB { - return - } - } -} diff --git a/src/pkg/clouds/aws/ecs/fargate_test.go b/src/pkg/clouds/aws/ecs/fargate_test.go deleted file mode 100644 index 954b52847..000000000 --- a/src/pkg/clouds/aws/ecs/fargate_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package ecs - -import ( - "math" - "testing" -) - -func TestFixupFargateCPU(t *testing.T) { - tests := []struct { - vcpu float64 - wantCPU CpuUnits - }{ - {0.0, 256}, - {0.26, 512}, - {111.0, 16384}, - } - - for _, tt := range tests { - if gotCPU := fixupFargateCPU(tt.vcpu); gotCPU != tt.wantCPU { - t.Errorf("fixupFargateCPU(%v) = %v, want %v", tt.vcpu, gotCPU, tt.wantCPU) - } - } -} - -func TestFixupFargateMemory(t *testing.T) { - tests := []struct { - cpu CpuUnits - memMiB float64 - wantMem MemoryMiB - }{ - {256, 0, 512}, - {256, 1023, 1024}, - {256, 1024, 1024}, - {256, 1025, 2048}, - } - - for _, tt := range tests { - if gotMem := fixupFargateMemory(tt.cpu, tt.memMiB); gotMem != tt.wantMem { - t.Errorf("fixupFargateMemory(%v, %v) = %v, want %v", tt.cpu, tt.memMiB, gotMem, tt.wantMem) - } - } -} - -func TestMakeMinMaxCeil(t *testing.T) { - tests := []struct { - value float64 - min uint - max uint - ceil uint - want uint - }{ - {math.NaN(), 5, 100, 10, 5}, - {0, 5, 100, 10, 5}, - {6, 5, 100, 10, 10}, - {1, 1, 100, 10, 1}, - {1.1, 1, 100, 10, 10}, - {89, 1, 100, 10, 90}, - {90, 1, 100, 10, 90}, - {91, 1, 100, 10, 100}, - } - - for _, tt := range tests { - if got := makeMinMaxCeil(tt.value, tt.min, tt.max, tt.ceil); got != tt.want { - t.Errorf("makeMinMaxCeil(%v, %v, %v, %v) = %v, want %v", tt.value, tt.min, tt.max, tt.ceil, got, tt.want) - } - } -} - -func TestFixupFargateConfig(t *testing.T) { - tests := []struct { - vcpu float64 - memMiB float64 - wantCPU CpuUnits - wantMem MemoryMiB - }{ - {0.0, 0.0, 256, 512}, - {0.25, 0, 256, 512}, - {0.0, 1024, 256, 1024}, - {0.26, 0, 512, 1024}, - {0.26, 1024, 512, 1024}, - {111.0, 1024, 16384, 32768}, - } - - for _, tt := range tests { - gotCPU, gotMem := FixupFargateConfig(tt.vcpu, tt.memMiB) - if gotCPU != tt.wantCPU || gotMem != tt.wantMem { - t.Errorf("FixupFargateConfig(%v, %v) = %v, %v, want %v, %v", tt.vcpu, tt.memMiB, gotCPU, gotMem, tt.wantCPU, tt.wantMem) - } - } -} diff --git a/src/pkg/clouds/aws/ecs/info.go b/src/pkg/clouds/aws/ecs/info.go deleted file mode 100644 index aa7238b0c..000000000 --- a/src/pkg/clouds/aws/ecs/info.go +++ /dev/null @@ -1,60 +0,0 @@ -package ecs - -import ( - "context" - "errors" - - "github.com/DefangLabs/defang/src/pkg/clouds" - "github.com/aws/aws-sdk-go-v2/service/ec2" - "github.com/aws/aws-sdk-go-v2/service/ecs" - "github.com/aws/smithy-go/ptr" -) - -func (a AwsEcs) Info(ctx context.Context, id TaskArn) (*clouds.TaskInfo, error) { - cfg, err := a.LoadConfig(ctx) - if err != nil { - return nil, err - } - - ti, err := ecs.NewFromConfig(cfg).DescribeTasks(ctx, &ecs.DescribeTasksInput{ - Cluster: ptr.String(a.ClusterName), - Tasks: []string{*id}, - // Reason: ptr.String("defang stop"), - }) - if err != nil { - return nil, err - } - - // b, err := json.MarshalIndent(ti, "", " ") - // println(string(b)) - - if len(ti.Tasks) == 0 || len(ti.Tasks[0].Attachments) == 0 { - return nil, errors.New("no attachments") - } - - if *ti.Tasks[0].LastStatus == "PROVISIONING" { - return nil, errors.New("task is provisioning") - } - - for _, detail := range ti.Tasks[0].Attachments[0].Details { - if *detail.Name != "networkInterfaceId" { - continue - } - ni, err := ec2.NewFromConfig(cfg).DescribeNetworkInterfaces(ctx, &ec2.DescribeNetworkInterfacesInput{ - NetworkInterfaceIds: []string{*detail.Value}, - }) - if err != nil { - return nil, err - } - if len(ni.NetworkInterfaces) == 0 || ni.NetworkInterfaces[0].Association == nil { - return nil, errors.New("no network interface association") - } - ip := *ni.NetworkInterfaces[0].Association.PublicIp - if ip == "" { - return nil, nil - } - // TODO: add mapped ports / endpoints - return &clouds.TaskInfo{IP: ip}, nil - } - return nil, nil // no public IP? -} diff --git a/src/pkg/clouds/aws/ecs/run.go b/src/pkg/clouds/aws/ecs/run.go deleted file mode 100644 index 1728d2f20..000000000 --- a/src/pkg/clouds/aws/ecs/run.go +++ /dev/null @@ -1,204 +0,0 @@ -package ecs - -import ( - "context" - "errors" - "regexp" - "time" - - "github.com/DefangLabs/defang/src/pkg" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ec2" - ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" - "github.com/aws/aws-sdk-go-v2/service/ecs" - "github.com/aws/aws-sdk-go-v2/service/ecs/types" - "github.com/aws/smithy-go/ptr" -) - -const taskCount = 1 - -func (a *AwsEcs) PopulateVPCandSubnetID(ctx context.Context, vpcID, subnetID string) error { - cfg, err := a.LoadConfig(ctx) - if err != nil { - return err - } - - if vpcID != "" && subnetID == "" { - subnetID, err = getPublicSubnetId(ctx, cfg, vpcID) - } else if vpcID == "" && subnetID != "" { - vpcID, err = getSubnetVPCId(ctx, cfg, subnetID) - } - - a.VpcID = vpcID - a.SubNetID = subnetID - return err -} - -var sanitizeStartedBy = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) // letters (uppercase and lowercase), numbers, hyphens (-), and underscores (_) are allowed - -func (a *AwsEcs) Run(ctx context.Context, env map[string]string, cmd ...string) (TaskArn, error) { - // a.Refresh(ctx) - - cfg, err := a.LoadConfig(ctx) - if err != nil { - return nil, err - } - - var pairs []types.KeyValuePair - for k, v := range env { - pairs = append(pairs, types.KeyValuePair{ - Name: ptr.String(k), - Value: ptr.String(v), - }) - } - - // stsClient := sts.NewFromConfig(cfg) - // cred, err := stsClient.GetCallerIdentity(ctx, nil) - // if err != nil { - // return nil, err - // } - - var securityGroups []string - if a.SecurityGroupID != "" { - securityGroups = []string{a.SecurityGroupID} - } - capacityProvider := "FARGATE" - if a.Spot { - capacityProvider = "FARGATE_SPOT" - } - rti := ecs.RunTaskInput{ - CapacityProviderStrategy: []types.CapacityProviderStrategyItem{ - {CapacityProvider: ptr.String(capacityProvider), Weight: 1}, - }, - Count: ptr.Int32(taskCount), - // LaunchType: types.LaunchTypeFargate, mutually exclusive with CapacityProviderStrategy - TaskDefinition: ptr.String(a.TaskDefARN), - PropagateTags: types.PropagateTagsTaskDefinition, - Cluster: ptr.String(a.ClusterName), - StartedBy: ptr.String(sanitizeStartedBy.ReplaceAllLiteralString(pkg.GetCurrentUser(), "_")), - NetworkConfiguration: &types.NetworkConfiguration{ - AwsvpcConfiguration: &types.AwsVpcConfiguration{ - AssignPublicIp: types.AssignPublicIpEnabled, // only works with public subnets - SecurityGroups: securityGroups, // If you don't specify a security group, the default security group for the VPC is used - Subnets: []string{a.SubNetID}, // TODO: make configurable; must this match the VPC of the SecGroup? - }, - }, - Overrides: &types.TaskOverride{ - // Cpu: ptr.String("256"), - // Memory: ptr.String("512"), - // TaskRoleArn: cred.Arn; TODO: default to caller identity; needs trust + iam:PassRole - ContainerOverrides: []types.ContainerOverride{ - { - Name: ptr.String(CdContainerName), - Command: cmd, - Environment: pairs, - // ResourceRequirements:; TODO: make configurable, support GPUs - // EnvironmentFiles: , - }, - }, - }, - Tags: []types.Tag{ //TODO: add tags to the task - { - Key: ptr.String("StartedAt"), - Value: ptr.String(time.Now().Format(time.RFC3339)), - }, - { - Key: ptr.String("StartedBy"), - Value: ptr.String(pkg.GetCurrentUser()), - }, - }, - } - - ecsOutput, err := ecs.NewFromConfig(cfg).RunTask(ctx, &rti) - if err != nil { - return nil, err - } - failures := make([]error, len(ecsOutput.Failures)) - for i, f := range ecsOutput.Failures { - failures[i] = TaskFailure{types.TaskStopCode(*f.Reason), *f.Detail} - } - if err := errors.Join(failures...); err != nil { - return nil, err - } - if len(ecsOutput.Tasks) == 0 || ecsOutput.Tasks[0].TaskArn == nil { - return nil, errors.New("no task started") - } - // bytes, _ := json.MarshalIndent(ecsOutput.Tasks, "", " ") - // println(string(bytes)) - return TaskArn(ecsOutput.Tasks[0].TaskArn), nil -} - -func getPublicSubnetId(ctx context.Context, cfg aws.Config, vpcId string) (string, error) { - subnetsOutput, err := ec2.NewFromConfig(cfg).DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{ - Filters: []ec2types.Filter{ - { - Name: ptr.String("vpc-id"), - Values: []string{vpcId}, - }, - { - Name: ptr.String("map-public-ip-on-launch"), - Values: []string{"true"}, - }, - }, - }) - if err != nil { - return "", err - } - return *subnetsOutput.Subnets[0].SubnetId, nil // TODO: make configurable/deterministic -} - -func getSubnetVPCId(ctx context.Context, cfg aws.Config, subnetId string) (string, error) { - subnetsOutput, err := ec2.NewFromConfig(cfg).DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{ - SubnetIds: []string{subnetId}, - }) - if err != nil { - return "", err - } - return *subnetsOutput.Subnets[0].VpcId, nil // TODO: make configurable/deterministic -} - -type TaskFailure struct { - Reason types.TaskStopCode - Detail string -} - -func (t TaskFailure) Error() string { - return string(t.Reason) + ": " + t.Detail -} - -/* -func getAwsEnv() awsEnv { - creds := getEcsCreds() - return map[string]string{ - "AWS_ACCESS_KEY_ID": creds.AccessKeyId, - "AWS_SECRET_ACCESS_KEY": creds.SecretAccessKey, - "AWS_SESSION_TOKEN": creds.Token, - // "AWS_REGION": "us-west-2", should not be needed because it's in the stack config and/or env - } -} - -var ( - ecsCredsUrl = "http://169.254.170.2" + os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") -) - -type ecsCreds struct { - AccessKeyId string - Expiration string - RoleArn string - SecretAccessKey string - Token string -} - -func getEcsCreds() (creds ecsCreds) { - // Grab the ECS credentials from the metadata service at AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - res, err := http.Get(ecsCredsUrl) - if err != nil { - log.Panicln(err) - } - defer res.Body.Close() - if err := json.NewDecoder(res.Body).Decode(&creds); err != nil { - log.Panicln(err) - } - return creds -} -*/ diff --git a/src/pkg/clouds/aws/ecs/status.go b/src/pkg/clouds/aws/ecs/status.go deleted file mode 100644 index c2d1ea2dc..000000000 --- a/src/pkg/clouds/aws/ecs/status.go +++ /dev/null @@ -1,108 +0,0 @@ -package ecs - -import ( - "context" - "fmt" - "io" - "strings" - "time" - - "github.com/DefangLabs/defang/src/pkg/clouds/aws/region" - awssdk "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ecs" - ecsTypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" -) - -// GetTaskStatus returns nil if the task is still running, io.EOF if the task is stopped successfully, or an error if the task failed. -// It derives the region from the task ARN and uses the receiver's credentials via LoadConfig. -func (a *AwsEcs) GetTaskStatus(ctx context.Context, taskArn TaskArn) (bool, error) { - cfg, err := a.LoadConfig(ctx) - if err != nil { - return false, err - } - // Override with the region embedded in the task ARN; the task may be in a different region than the driver. - cfg.Region = string(region.FromArn(*taskArn)) - cluster, taskID := SplitClusterTask(taskArn) - return getTaskStatus(ctx, cfg, cluster, taskID) -} - -func isTaskTerminalStatus(status string) bool { - // From https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-lifecycle-explanation.html - switch status { - case "DELETED", "STOPPED", "DEPROVISIONING": - return true - default: - return false // we might still get logs - } -} - -// getTaskStatus returns nil if the task is still running, io.EOF if the task is stopped successfully, or an error if the task failed. -func getTaskStatus(ctx context.Context, cfg awssdk.Config, cluster, taskId string) (bool, error) { - ecsClient := ecs.NewFromConfig(cfg) - - // Use DescribeTasks API to check if the task is still running (same as ecs.NewTasksStoppedWaiter) - ti, _ := ecsClient.DescribeTasks(ctx, &ecs.DescribeTasksInput{ - Cluster: &cluster, - Tasks: []string{taskId}, - }) - if ti == nil || len(ti.Tasks) == 0 { - return false, nil // task doesn't exist (yet); TODO: check the actual error from DescribeTasks - } - task := ti.Tasks[0] - if task.LastStatus == nil || !isTaskTerminalStatus(*task.LastStatus) { - return false, nil // still running - } - - var stoppedReason string - if task.StoppedReason != nil { - stoppedReason = *task.StoppedReason - } - switch task.StopCode { - default: - return true, TaskFailure{task.StopCode, stoppedReason} - case ecsTypes.TaskStopCodeEssentialContainerExited: - for _, c := range task.Containers { - if c.ExitCode != nil && *c.ExitCode != 0 { - if stoppedReason == "" { - stoppedReason = "essential container exited" - } - reason := fmt.Sprintf("%s with code %d", stoppedReason, *c.ExitCode) - return true, TaskFailure{task.StopCode, reason} - } - } - fallthrough - case "": // TODO: shouldn't happen - return true, io.EOF // Success; EOF returned for backward compatibility - } -} - -func SplitClusterTask(taskArn TaskArn) (string, string) { - if !strings.HasPrefix(*taskArn, "arn:aws:ecs:") { - panic("invalid ECS ARN") - } - parts := strings.Split(*taskArn, "/") - if len(parts) != 3 || !strings.HasSuffix(parts[0], ":task") { - panic("invalid task ARN") - } - return parts[1], parts[2] -} - -// WaitForTask polls the ECS task status. It returns io.EOF if the task is stopped successfully, or an error if the task failed. -func (a *AwsEcs) WaitForTask(ctx context.Context, taskArn TaskArn, poll time.Duration) error { - if taskArn == nil { - panic("taskArn is nil") - } - ticker := time.NewTicker(poll) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - // Handle cancellation - return ctx.Err() - case <-ticker.C: - if done, err := a.GetTaskStatus(ctx, taskArn); done || err != nil { - return err - } - } - } -} diff --git a/src/pkg/clouds/aws/ecs/status_test.go b/src/pkg/clouds/aws/ecs/status_test.go deleted file mode 100644 index 99ced6ebc..000000000 --- a/src/pkg/clouds/aws/ecs/status_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package ecs - -import "testing" - -func TestSplitClusterTask(t *testing.T) { - taskArn := "arn:aws:ecs:us-west-2:123456789012:task/cluster-name/12345678123412341234123456789012" - expectedClusterName := "cluster-name" - - clusterName, taskID := SplitClusterTask(&taskArn) - - if clusterName != expectedClusterName { - t.Errorf("Expected cluster name %q, but got %q", expectedClusterName, clusterName) - } - if taskID != "12345678123412341234123456789012" { - t.Errorf("Expected task ID %q, but got %q", taskArn, taskID) - } -} diff --git a/src/pkg/clouds/aws/ecs/stop.go b/src/pkg/clouds/aws/ecs/stop.go deleted file mode 100644 index 4162a928d..000000000 --- a/src/pkg/clouds/aws/ecs/stop.go +++ /dev/null @@ -1,23 +0,0 @@ -package ecs - -import ( - "context" - - "github.com/DefangLabs/defang/src/pkg/clouds" - "github.com/aws/aws-sdk-go-v2/service/ecs" - "github.com/aws/smithy-go/ptr" -) - -func (a AwsEcs) Stop(ctx context.Context, id clouds.TaskID) error { - cfg, err := a.LoadConfig(ctx) - if err != nil { - return err - } - - _, err = ecs.NewFromConfig(cfg).StopTask(ctx, &ecs.StopTaskInput{ - Cluster: ptr.String(a.ClusterName), - Task: id, - // Reason: ptr.String("defang stop"), - }) - return err -} diff --git a/src/pkg/clouds/aws/ecs/tail.go b/src/pkg/clouds/aws/ecs/tail.go deleted file mode 100644 index 68d2025d3..000000000 --- a/src/pkg/clouds/aws/ecs/tail.go +++ /dev/null @@ -1,131 +0,0 @@ -package ecs - -import ( - "context" - "errors" - "fmt" - "iter" - "path" - "time" - - "github.com/DefangLabs/defang/src/pkg" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/cw" - "github.com/DefangLabs/defang/src/pkg/clouds/aws/region" - cwTypes "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" -) - -const AwsLogsStreamPrefix = CrunProjectName - -func (a *AwsEcs) Tail(ctx context.Context, taskArn TaskArn) error { - cfg, err := a.LoadConfig(ctx) - if err != nil { - return err - } - taskId := GetTaskID(taskArn) - a.Region = region.FromArn(*taskArn) - cfg.Region = string(a.Region) - cwClient := cw.NewCloudWatchLogsClient(cfg) - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - tailIter, err := a.TailTaskID(ctx, cwClient, taskId) - if err != nil { - return err - } - - taskch := make(chan error, 1) - go func() { - taskch <- a.WaitForTask(ctx, taskArn, time.Second*3) - cancel() // stop tailing when task finishes - }() - - for batch, err := range tailIter { - if err != nil { - if !errors.Is(err, context.Canceled) { - return err - } - break - } - for _, evt := range batch { - fmt.Println(*evt.Message) - } - } - return <-taskch -} - -func (a *AwsEcs) GetTaskArn(taskID string) (TaskArn, error) { - if taskID == "" { - return nil, errors.New("taskID is required") - } - if a.ClusterName == "" { - return nil, errors.New("ClusterName is required") - } - taskArn := a.MakeARN("ecs", "task/"+a.ClusterName+"/"+taskID) - return &taskArn, nil -} - -func (a *AwsEcs) QueryTaskID(ctx context.Context, cwClient cw.FilterLogEventsAPIClient, taskID string, start, end time.Time, limit int32) (iter.Seq2[[]cw.LogEvent, error], error) { - if taskID == "" { - return nil, errors.New("taskID is empty") - } - - lgi := cw.LogGroupInput{LogGroupARN: a.LogGroupARN, LogStreamNames: []string{GetCDLogStreamForTaskID(taskID)}} - logSeq, err := cw.QueryLogGroup(ctx, cwClient, lgi, start, end, limit) - if err != nil { - return nil, err - } - return logSeq, nil -} - -func (a *AwsEcs) TailTaskID(ctx context.Context, cwClient cw.StartLiveTailAPI, taskID string) (iter.Seq2[[]cw.LogEvent, error], error) { - if taskID == "" { - return nil, errors.New("taskID is required") - } - if a.LogGroupARN == "" { - return nil, errors.New("LogGroupARN is required") - } - if a.ClusterName == "" { - return nil, errors.New("ClusterName is required") - } - - cfg, err := a.LoadConfig(ctx) - if err != nil { - return nil, err - } - - lgi := cw.LogGroupInput{LogGroupARN: a.LogGroupARN, LogStreamNames: []string{GetCDLogStreamForTaskID(taskID)}} - for { - logSeq, err := cw.TailLogGroup(ctx, cwClient, lgi) - if err != nil { - var resourceNotFound *cwTypes.ResourceNotFoundException - if !errors.As(err, &resourceNotFound) { - return nil, err - } - // The log stream doesn't exist yet, so wait for it to be created, but bail out if the task is stopped - done, err := getTaskStatus(ctx, cfg, a.ClusterName, taskID) - if done || err != nil { - return nil, err // TODO: handle transient errors - } - // continue loop, waiting for the log stream to be created; sleep to avoid throttling - if err := pkg.SleepWithContext(ctx, time.Second); err != nil { - return nil, err - } - continue - } - // TODO: should wrap this iter so we can return io.EOF on task stop - return logSeq, nil - } -} - -func GetCDLogStreamForTaskID(taskID string) string { - return GetLogStreamForTaskID(CrunProjectName, CdContainerName, taskID) -} - -func GetLogStreamForTaskID(awslogsStreamPrefix, containerName, taskID string) string { - return path.Join(awslogsStreamPrefix, containerName, taskID) // per "awslogs" driver -} - -func GetTaskID(taskArn TaskArn) string { - return path.Base(*taskArn) -} diff --git a/src/pkg/clouds/aws/ecs/tail_test.go b/src/pkg/clouds/aws/ecs/tail_test.go deleted file mode 100644 index 2603fb137..000000000 --- a/src/pkg/clouds/aws/ecs/tail_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package ecs - -import ( - "testing" -) - -func TestGetLogStreamForTaskID(t *testing.T) { - expectedLogStream := "prefix/main_app/12345678123412341234123456789012" - - logStream := GetLogStreamForTaskID("prefix", "main_app", "12345678123412341234123456789012") - - if logStream != expectedLogStream { - t.Errorf("Expected log stream %q, but got %q", expectedLogStream, logStream) - } -} diff --git a/src/pkg/clouds/driver.go b/src/pkg/clouds/driver.go deleted file mode 100644 index 927cd5a6b..000000000 --- a/src/pkg/clouds/driver.go +++ /dev/null @@ -1,58 +0,0 @@ -package clouds - -import ( - "context" -) - -const ( - ProjectName = "crun" -) - -type TaskID *string -type ContainerCondition string - -const ( - ContainerStarted ContainerCondition = "START" - ContainerSuccess ContainerCondition = "SUCCESS" - ContainerHealthy ContainerCondition = "HEALTHY" -) - -type Container struct { - Image string - Name string - Cpus float32 - Memory uint64 - Platform string - Essential *bool // default true - Volumes []TaskVolume - VolumesFrom []string // container (default rw), container:rw, or container:ro - EntryPoint []string - Command []string // overridden by Run() - WorkDir string - DependsOn map[string]ContainerCondition // container name -> condition -} - -type TaskVolume struct { - Source string - Target string - ReadOnly bool -} - -type Driver interface { - SetUp(ctx context.Context, containers []Container, force bool) (bool, error) // returns true if newly created - TearDown(ctx context.Context) error - Run(ctx context.Context, env map[string]string, args ...string) (TaskID, error) - Tail(ctx context.Context, taskID TaskID) error - // Query(ctx context.Context, taskID TaskID, since time.Time) error - Stop(ctx context.Context, taskID TaskID) error - // Exec(ctx context.Context, taskID TaskID, args ...string) error - GetInfo(ctx context.Context, taskID TaskID) (*TaskInfo, error) - PutSecret(ctx context.Context, name, value string) error - // DeleteSecrets(ctx context.Context, names ...string) error - ListSecrets(ctx context.Context) ([]string, error) // no values - CreateUploadURL(ctx context.Context, name string) (string, error) -} - -type TaskInfo struct { - IP string -} diff --git a/src/pkg/clouds/gcp/cloudrun.go b/src/pkg/clouds/gcp/cloudrun.go index fa0d2f047..606de9fc0 100644 --- a/src/pkg/clouds/gcp/cloudrun.go +++ b/src/pkg/clouds/gcp/cloudrun.go @@ -4,150 +4,16 @@ import ( "context" "fmt" "math" - "os" "path" - "strings" - "time" run "cloud.google.com/go/run/apiv2" "cloud.google.com/go/run/apiv2/runpb" - "github.com/DefangLabs/defang/src/pkg" - "github.com/DefangLabs/defang/src/pkg/clouds" - "google.golang.org/protobuf/types/known/durationpb" ) const ( JobNameCD = "defang-cd" ) -func (gcp Gcp) SetupJob(ctx context.Context, jobId, serviceAccount string, containers []clouds.Container) error { - client, err := run.NewJobsClient(ctx, gcp.Options...) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to create cloud run jobs client: %v\n", err) - return err - } - defer client.Close() - - // TODO: Do not update if the job already exists and have the same configuration - - runContainers := make([]*runpb.Container, 0, len(containers)) - for _, container := range containers { - cpu, memory := FixupGcpConfig(container.Cpus, container.Memory/1024/1024) - - runContainer := &runpb.Container{ - Name: container.Name, - Image: container.Image, - Command: container.EntryPoint, // GCP uses Command as EntryPoint - Args: container.Command, - Resources: &runpb.ResourceRequirements{ - Limits: map[string]string{ - "cpu": strings.TrimRight(fmt.Sprintf("%.2f", cpu), ".0"), // increments of 0.01 - "memory": fmt.Sprintf("%dMi", memory), - }, - CpuIdle: false, // must be false for jobs - StartupCpuBoost: true, - }, - // Ports: []*runpb.ContainerPort{}, // TODO: Ports support - // VolumeMounts: []*runpb.VolumeMount{}, // TODO: add volume mounts - WorkingDir: container.WorkDir, - DependsOn: []string{}, // Not applicable to cloud run jobs - } - - runContainers = append(runContainers, runContainer) - } - - req := &runpb.UpdateJobRequest{ - AllowMissing: true, - Job: &runpb.Job{ - Name: fmt.Sprintf("projects/%s/locations/%s/jobs/%s", gcp.ProjectId, gcp.Region, jobId), - Template: &runpb.ExecutionTemplate{ - Labels: map[string]string{}, // TODO: Add labels - TaskCount: 1, - Template: &runpb.TaskTemplate{ - Containers: runContainers, - Timeout: durationpb.New(30 * time.Minute), // Overall job timeout - ServiceAccount: serviceAccount, // FIXME: create cd service account - Retries: &runpb.TaskTemplate_MaxRetries{MaxRetries: 0}, // FIXME: investigate retries - // VpcAccess: &runpb.VpcAccessConfig{}, // FIXME: investigate VPC access - }, - }, - }, - } - - // Create the service on Cloud Run - op, err := client.UpdateJob(ctx, req) - if err != nil { - return fmt.Errorf("failed to update job: %w", err) - } - - // FIXME: Findout the correct way to wait for the Update job to complete - for { - _, err := op.Poll(ctx) - if err != nil { - if !strings.Contains(err.Error(), "The container exited with an error.") { - return fmt.Errorf("failed to wait for job update to complete: %w", err) - } - } - if op.Done() { - return nil - } - pkg.SleepWithContext(ctx, 1*time.Second) - } -} - -func (gcp Gcp) Run(ctx context.Context, jobId string, env map[string]string, cmd ...string) (string, error) { - client, err := run.NewJobsClient(ctx, gcp.Options...) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to create cloud run jobs client: %v\n", err) - return "", err - } - defer client.Close() - - envs := make([]*runpb.EnvVar, 0, len(env)) - for k, v := range env { - envs = append(envs, &runpb.EnvVar{Name: k, Values: &runpb.EnvVar_Value{Value: v}}) - } - req := &runpb.RunJobRequest{ - Name: fmt.Sprintf("projects/%s/locations/%s/jobs/%s", gcp.ProjectId, gcp.Region, jobId), - Overrides: &runpb.RunJobRequest_Overrides{ - TaskCount: 1, - ContainerOverrides: []*runpb.RunJobRequest_Overrides_ContainerOverride{ - { - Args: cmd, - Env: envs, - }, - }, - }, - } - - op, err := client.RunJob(ctx, req) - if err != nil { - return "", err - } - - // Poll the operation until the execution is created - var execName string - for { - if _, err = op.Poll(ctx); err != nil { - if !strings.Contains(err.Error(), "The container exited with an error.") { - return "", err - } - } - - exec, err := op.Metadata() - if err != nil { - return "", err - } - if exec != nil { - execName = exec.Name - break - } - pkg.SleepWithContext(ctx, 1*time.Second) - } - - return execName, nil -} - func (gcp Gcp) GetExecutionEnv(ctx context.Context, executionName string) (map[string]string, error) { client, err := run.NewExecutionsClient(ctx, gcp.Options...) if err != nil { diff --git a/src/pkg/stacks/selector.go b/src/pkg/stacks/selector.go index 3c9fdd1b7..1c3840247 100644 --- a/src/pkg/stacks/selector.go +++ b/src/pkg/stacks/selector.go @@ -116,13 +116,13 @@ func printStacksInfoMessage(stacks []string) { if betaExists { infoLine := "This project was deployed with an implicit Stack called 'beta' before Stacks were introduced." if len(stacks) == 1 { - infoLine += "\n To update your existing deployment, select the 'beta' Stack.\n" + - "Creating a new Stack will result in a separate deployment instance." + infoLine += "\n - To update your existing deployment, select the 'beta' Stack.\n" + + " - Creating a new Stack will result in a separate deployment instance." } - infoLine += "\n To learn more about Stacks, visit: https://s.defang.io/stacks" + infoLine += "\n - To learn more about Stacks, visit: https://s.defang.io/stacks" term.Println(infoLine) } - term.Printf("To skip this prompt, run this command with --stack=%s\n", "") + term.Printf(" To skip this prompt, run this command with --stack=%s\n", "") } func MakeStackSelectorLabels(stacks []ListItem) map[string]string { diff --git a/src/protos/io/defang/v1/fabric.pb.go b/src/protos/io/defang/v1/fabric.pb.go index 3c18ba36c..5d0f69aa3 100644 --- a/src/protos/io/defang/v1/fabric.pb.go +++ b/src/protos/io/defang/v1/fabric.pb.go @@ -1847,6 +1847,7 @@ type CanIUseRequest struct { ProviderAccountId string `protobuf:"bytes,6,opt,name=provider_account_id,json=providerAccountId,proto3" json:"provider_account_id,omitempty"` PreferCdVersion string `protobuf:"bytes,7,opt,name=prefer_cd_version,json=preferCdVersion,proto3" json:"prefer_cd_version,omitempty"` // currently deployed CD image; empty for new projects or when --allow-upgrade is set PreferPulumiVersion string `protobuf:"bytes,8,opt,name=prefer_pulumi_version,json=preferPulumiVersion,proto3" json:"prefer_pulumi_version,omitempty"` // currently deployed Pulumi version; empty for new projects or when --allow-upgrade is set + Driver string `protobuf:"bytes,9,opt,name=driver,proto3" json:"driver,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1937,6 +1938,13 @@ func (x *CanIUseRequest) GetPreferPulumiVersion() string { return "" } +func (x *CanIUseRequest) GetDriver() string { + if x != nil { + return x.Driver + } + return "" +} + type CanIUseResponse struct { state protoimpl.MessageState `protogen:"open.v1"` CdImage string `protobuf:"bytes,2,opt,name=cd_image,json=cdImage,proto3" json:"cd_image,omitempty"` @@ -1945,6 +1953,7 @@ type CanIUseResponse struct { PulumiVersion string `protobuf:"bytes,5,opt,name=pulumi_version,json=pulumiVersion,proto3" json:"pulumi_version,omitempty"` Signature []byte `protobuf:"bytes,6,opt,name=signature,proto3" json:"signature,omitempty"` ForcedVersion bool `protobuf:"varint,7,opt,name=forced_version,json=forcedVersion,proto3" json:"forced_version,omitempty"` // force use of the returned CD image and Pulumi version + ForcedReason string `protobuf:"bytes,8,opt,name=forced_reason,json=forcedReason,proto3" json:"forced_reason,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2021,6 +2030,13 @@ func (x *CanIUseResponse) GetForcedVersion() bool { return false } +func (x *CanIUseResponse) GetForcedReason() string { + if x != nil { + return x.ForcedReason + } + return "" +} + type DeployRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Deprecated: Marked as deprecated in io/defang/v1/fabric.proto. @@ -5730,7 +5746,7 @@ const file_io_defang_v1_fabric_proto_rawDesc = "" + "\x04arch\x18\x05 \x01(\tR\x04arch\x1a=\n" + "\x0fPropertiesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc1\x02\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd9\x02\n" + "\x0eCanIUseRequest\x12\x18\n" + "\aproject\x18\x01 \x01(\tR\aproject\x122\n" + "\bprovider\x18\x02 \x01(\x0e2\x16.io.defang.v1.ProviderR\bprovider\x12#\n" + @@ -5739,14 +5755,16 @@ const file_io_defang_v1_fabric_proto_rawDesc = "" + "\x06region\x18\x05 \x01(\tR\x06region\x12.\n" + "\x13provider_account_id\x18\x06 \x01(\tR\x11providerAccountId\x12*\n" + "\x11prefer_cd_version\x18\a \x01(\tR\x0fpreferCdVersion\x122\n" + - "\x15prefer_pulumi_version\x18\b \x01(\tR\x13preferPulumiVersion\"\xd5\x01\n" + + "\x15prefer_pulumi_version\x18\b \x01(\tR\x13preferPulumiVersion\x12\x16\n" + + "\x06driver\x18\t \x01(\tR\x06driver\"\xfa\x01\n" + "\x0fCanIUseResponse\x12\x19\n" + "\bcd_image\x18\x02 \x01(\tR\acdImage\x12\x10\n" + "\x03gpu\x18\x03 \x01(\bR\x03gpu\x12#\n" + "\rallow_scaling\x18\x04 \x01(\bR\fallowScaling\x12%\n" + "\x0epulumi_version\x18\x05 \x01(\tR\rpulumiVersion\x12\x1c\n" + "\tsignature\x18\x06 \x01(\fR\tsignature\x12%\n" + - "\x0eforced_version\x18\a \x01(\bR\rforcedVersionJ\x04\b\x01\x10\x02\"\xa6\x02\n" + + "\x0eforced_version\x18\a \x01(\bR\rforcedVersion\x12#\n" + + "\rforced_reason\x18\b \x01(\tR\fforcedReasonJ\x04\b\x01\x10\x02\"\xa6\x02\n" + "\rDeployRequest\x12\x1c\n" + "\aproject\x18\x02 \x01(\tB\x02\x18\x01R\aproject\x120\n" + "\x04mode\x18\x03 \x01(\x0e2\x1c.io.defang.v1.DeploymentModeR\x04mode\x12\x18\n" + diff --git a/src/protos/io/defang/v1/fabric.proto b/src/protos/io/defang/v1/fabric.proto index 280bb55a7..cbf7bf6f0 100644 --- a/src/protos/io/defang/v1/fabric.proto +++ b/src/protos/io/defang/v1/fabric.proto @@ -288,6 +288,7 @@ message CanIUseRequest { string provider_account_id = 6; string prefer_cd_version = 7; // currently deployed CD image; empty for new projects or when --allow-upgrade is set string prefer_pulumi_version = 8; // currently deployed Pulumi version; empty for new projects or when --allow-upgrade is set + string driver = 9; } message CanIUseResponse { @@ -298,6 +299,7 @@ message CanIUseResponse { string pulumi_version = 5; bytes signature = 6; bool forced_version = 7; // force use of the returned CD image and Pulumi version + string forced_reason = 8; } message DeployRequest {