github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/pkg/clouds/aws/ecs/cfn/template.go (about) 1 package cfn 2 3 import ( 4 "crypto/sha256" 5 "encoding/hex" 6 "encoding/json" 7 "math" 8 "os" 9 "strconv" 10 "strings" 11 12 "github.com/aws/smithy-go/ptr" 13 "github.com/awslabs/goformation/v7/cloudformation" 14 "github.com/awslabs/goformation/v7/cloudformation/ec2" 15 "github.com/awslabs/goformation/v7/cloudformation/ecr" 16 "github.com/awslabs/goformation/v7/cloudformation/ecs" 17 "github.com/awslabs/goformation/v7/cloudformation/iam" 18 "github.com/awslabs/goformation/v7/cloudformation/logs" 19 "github.com/awslabs/goformation/v7/cloudformation/policies" 20 "github.com/awslabs/goformation/v7/cloudformation/s3" 21 "github.com/awslabs/goformation/v7/cloudformation/secretsmanager" 22 "github.com/awslabs/goformation/v7/cloudformation/tags" 23 awsecs "github.com/defang-io/defang/src/pkg/clouds/aws/ecs" 24 "github.com/defang-io/defang/src/pkg/clouds/aws/ecs/cfn/outputs" 25 "github.com/defang-io/defang/src/pkg/types" 26 ) 27 28 const ( 29 createVpcResources = true // TODO: make this configurable, add an option to use the default VPC 30 maxCachePrefixLength = 20 // prefix must be 2-20 characters long; should be 30 https://github.com/hashicorp/terraform-provider-aws/pull/34716 31 ) 32 33 var ( 34 dockerHubUsername = os.Getenv("DOCKERHUB_USERNAME") // TODO: support DOCKER_AUTH_CONFIG 35 dockerHubAccessToken = os.Getenv("DOCKERHUB_ACCESS_TOKEN") 36 retainBucket = true // set to false in unit tests 37 ) 38 39 func getCacheRepoPrefix(prefix, suffix string) string { 40 repo := prefix + suffix 41 if len(repo) > maxCachePrefixLength { 42 // Cache repo name is too long; hash it and use the first 6 chars 43 hash := sha256.Sum256([]byte(prefix)) 44 return hex.EncodeToString(hash[:])[:6] + "-" + suffix 45 } 46 return repo 47 } 48 49 type TemplateOverrides struct { 50 VpcID string 51 } 52 53 func createTemplate(stack string, containers []types.Container, overrides TemplateOverrides, spot bool) *cloudformation.Template { 54 prefix := stack + "-" 55 56 defaultTags := []tags.Tag{ 57 { 58 Key: "CreatedBy", 59 Value: awsecs.ProjectName, 60 }, 61 } 62 63 template := cloudformation.NewTemplate() 64 65 // 1. bucket (for deployment state) 66 const _bucket = "Bucket" 67 var bucketDeletionPolicy policies.DeletionPolicy 68 if retainBucket { 69 bucketDeletionPolicy = "RetainExceptOnCreate" 70 } 71 template.Resources[_bucket] = &s3.Bucket{ 72 Tags: defaultTags, 73 // BucketName: ptr.String(PREFIX + "bucket" + SUFFIX), // optional; TODO: might want to fix this name to allow Pulumi destroy after stack deletion 74 AWSCloudFormationDeletionPolicy: bucketDeletionPolicy, 75 VersioningConfiguration: &s3.Bucket_VersioningConfiguration{ 76 Status: "Enabled", 77 }, 78 } 79 80 // 2. ECS cluster 81 const _cluster = "Cluster" 82 template.Resources[_cluster] = &ecs.Cluster{ 83 Tags: defaultTags, 84 // ClusterName: ptr.String(PREFIX + "cluster" + SUFFIX), // optional 85 } 86 87 // 3. ECS capacity provider 88 capacityProvider := "FARGATE" 89 if spot { 90 capacityProvider = "FARGATE_SPOT" 91 } 92 const _capacityProvider = "CapacityProvider" 93 template.Resources[_capacityProvider] = &ecs.ClusterCapacityProviderAssociations{ 94 Cluster: cloudformation.Ref(_cluster), 95 CapacityProviders: []string{ 96 capacityProvider, 97 }, 98 DefaultCapacityProviderStrategy: []ecs.ClusterCapacityProviderAssociations_CapacityProviderStrategy{ 99 { 100 CapacityProvider: capacityProvider, 101 Weight: ptr.Int(1), 102 }, 103 }, 104 } 105 106 // 4. CloudWatch log group 107 const _logGroup = "LogGroup" 108 template.Resources[_logGroup] = &logs.LogGroup{ 109 Tags: defaultTags, 110 // LogGroupName: ptr.String(PREFIX + "log-group-test" + SUFFIX), // optional 111 RetentionInDays: ptr.Int(1), 112 // Make sure the log group cannot be deleted while the cluster is up 113 AWSCloudFormationDependsOn: []string{ 114 _cluster, 115 }, 116 } 117 118 const _privateRepoSecret = "PrivateRepoSecret" 119 // 5. ECR pull-through cache rules 120 // TODO: Creating pull through cache rules isn't supported in the following Regions: 121 // * China (Beijing) (cn-north-1) 122 // * China (Ningxia) (cn-northwest-1) 123 // * AWS GovCloud (US-East) (us-gov-east-1) 124 // * AWS GovCloud (US-West) (us-gov-west-1) 125 images := make([]string, 0, len(containers)) 126 for _, task := range containers { 127 image := task.Image 128 if repo, ok := strings.CutPrefix(image, awsecs.EcrPublicRegistry); ok { 129 const _pullThroughCache = "PullThroughCache" 130 ecrPublicPrefix := getCacheRepoPrefix(prefix, "ecr-public") 131 132 // 5. The pull-through cache rule 133 template.Resources[_pullThroughCache] = &ecr.PullThroughCacheRule{ 134 EcrRepositoryPrefix: ptr.String(ecrPublicPrefix), 135 UpstreamRegistryUrl: ptr.String(awsecs.EcrPublicRegistry), 136 } 137 138 image = cloudformation.Sub("${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/" + ecrPublicPrefix + repo) 139 } else if repo, ok := strings.CutPrefix(image, awsecs.DockerRegistry); ok && dockerHubUsername != "" && dockerHubAccessToken != "" { 140 const _pullThroughCache = "PullThroughCacheDocker" 141 dockerPublicPrefix := getCacheRepoPrefix(prefix, "docker-public") 142 143 // 5a. When creating the Secrets Manager secret that contains the upstream registry credentials, the secret name must use the `ecr-pullthroughcache/` prefix. 144 // This is the struct AWS wants, see https://docs.aws.amazon.com/AmazonECR/latest/userguide/pull-through-cache-creating-secret.html 145 bytes, err := json.Marshal(struct { 146 Username string `json:"username"` 147 AccessToken string `json:"accessToken"` 148 }{dockerHubUsername, dockerHubAccessToken}) 149 if err != nil { 150 panic(err) // there's no reason this should ever fail 151 } 152 153 // This is $0.40 per secret per month, so make it opt-in (only done when DOCKERHUB_* env vars are set) 154 template.Resources[_privateRepoSecret] = &secretsmanager.Secret{ 155 Tags: defaultTags, 156 Description: ptr.String("Docker Hub credentials for the ECR pull-through cache rule"), 157 Name: ptr.String("ecr-pullthroughcache/" + dockerPublicPrefix), 158 SecretString: ptr.String(string(bytes)), 159 } 160 161 // 5b. The pull-through cache rule 162 template.Resources[_pullThroughCache] = &ecr.PullThroughCacheRule{ 163 EcrRepositoryPrefix: ptr.String(dockerPublicPrefix), 164 UpstreamRegistryUrl: ptr.String("registry-1.docker.io"), 165 CredentialArn: cloudformation.RefPtr(_privateRepoSecret), 166 } 167 168 image = cloudformation.Sub("${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/" + dockerPublicPrefix + repo) 169 } else { 170 // TODO: support pull through cache for other registries 171 // TODO: support private repos (with or without pull-through cache) 172 } 173 images = append(images, image) 174 } 175 176 // 6. IAM roles for ECS task 177 assumeRolePolicyDocumentECS := map[string]any{ 178 "Version": "2012-10-17", 179 "Statement": []map[string]any{ 180 { 181 "Effect": "Allow", 182 "Principal": map[string]any{ 183 "Service": []string{ 184 "ecs-tasks.amazonaws.com", 185 }, 186 }, 187 "Action": []string{ 188 "sts:AssumeRole", 189 }, 190 }, 191 }, 192 } 193 194 // 6a. IAM exec role for task 195 execPolicies := []iam.Role_Policy{{ 196 // From https://docs.aws.amazon.com/AmazonECR/latest/userguide/pull-through-cache.html#pull-through-cache-iam 197 PolicyName: "AllowECRPassThrough", 198 PolicyDocument: map[string]any{ 199 "Version": "2012-10-17", 200 "Statement": []map[string]any{ 201 { 202 "Effect": "Allow", 203 "Action": []string{ 204 "ecr:CreatePullThroughCacheRule", 205 "ecr:BatchImportUpstreamImage", // should be registry permission instead 206 "ecr:CreateRepository", // can be registry permission instead 207 }, 208 "Resource": "*", // FIXME: restrict cloudformation.Sub("arn:${AWS::Partition}:ecr:${AWS::Region}:${AWS::AccountId}:repository/${PullThroughCache}:*"), 209 }, 210 }, 211 }, 212 }} 213 if _, ok := template.Resources[_privateRepoSecret]; ok { 214 execPolicies = append(execPolicies, iam.Role_Policy{ 215 PolicyName: "AllowGetRepoSecret", 216 PolicyDocument: map[string]any{ 217 "Version": "2012-10-17", 218 "Statement": []map[string]any{ 219 { 220 "Effect": "Allow", 221 "Action": []string{ 222 "secretsmanager:GetSecretValue", 223 "ssm:GetParameters", 224 // "kms:Decrypt", Required only if your key uses a custom KMS key and not the default key 225 }, 226 "Resource": cloudformation.Ref(_privateRepoSecret), 227 }, 228 }, 229 }, 230 }) 231 } 232 const _executionRole = "ExecutionRole" 233 template.Resources[_executionRole] = &iam.Role{ 234 Tags: defaultTags, 235 // RoleName: ptr.String(PREFIX + "execution-role" + SUFFIX), // optional 236 ManagedPolicyArns: []string{ 237 "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", 238 }, 239 AssumeRolePolicyDocument: assumeRolePolicyDocumentECS, 240 Policies: execPolicies, 241 } 242 243 // 6b. IAM role for task (optional) 244 const _taskRole = "TaskRole" 245 template.Resources[_taskRole] = &iam.Role{ 246 Tags: defaultTags, 247 // RoleName: ptr.String(PREFIX + "task-role" + SUFFIX), // optional 248 ManagedPolicyArns: []string{ 249 "arn:aws:iam::aws:policy/AdministratorAccess", // TODO: make this configurable 250 }, 251 AssumeRolePolicyDocument: assumeRolePolicyDocumentECS, 252 Policies: []iam.Role_Policy{ 253 { 254 // From https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html#ecs-exec-required-iam-permissions 255 PolicyName: "AllowExecuteCommand", 256 PolicyDocument: map[string]any{ 257 "Version": "2012-10-17", 258 "Statement": []map[string]any{ 259 { 260 "Effect": "Allow", 261 "Action": []string{ 262 "ssmmessages:CreateDataChannel", 263 "ssmmessages:OpenDataChannel", 264 "ssmmessages:OpenControlChannel", 265 "ssmmessages:CreateControlChannel", 266 }, 267 "Resource": "*", // TODO: restrict 268 }, 269 }, 270 }, 271 }, 272 { 273 PolicyName: "AllowPassRole", 274 PolicyDocument: map[string]any{ 275 "Version": "2012-10-17", 276 "Statement": []map[string]any{ 277 { 278 "Effect": "Allow", 279 "Action": []string{ 280 "iam:PassRole", 281 }, 282 "Resource": "*", // TODO: restrict to roles that are needed/created by the task 283 }, 284 }, 285 }, 286 }, 287 { 288 PolicyName: "AllowAssumeRole", 289 PolicyDocument: map[string]any{ 290 "Version": "2012-10-17", 291 "Statement": []map[string]any{ 292 { 293 "Effect": "Allow", 294 "Action": []string{ 295 "sts:AssumeRole", 296 }, 297 "Resource": "*", 298 }, 299 }, 300 }, 301 }, 302 }, 303 } 304 305 // 7. ECS task definition 306 var totalCpu, totalMiB float64 307 var platform string 308 for _, task := range containers { 309 totalCpu += float64(task.Cpus) 310 totalMiB += math.Max(float64(task.Memory)/1024/1024, 6) // 6MiB min for the container 311 if platform == "" { 312 platform = task.Platform 313 } else if platform != task.Platform { 314 panic("all containers must have the same platform") 315 } 316 } 317 mCpu, mib := awsecs.FixupFargateConfig(totalCpu, totalMiB) 318 arch, os := awsecs.PlatformToArchOS(platform) 319 var archP, osP *string 320 if arch != "" { 321 archP = ptr.String(arch) 322 } 323 if os != "" { 324 osP = ptr.String(os) 325 } 326 327 var volumes []ecs.TaskDefinition_Volume 328 var containerDefinitions []ecs.TaskDefinition_ContainerDefinition 329 for i, container := range containers { 330 for _, v := range container.Volumes { 331 volumes = append(volumes, ecs.TaskDefinition_Volume{ 332 Name: ptr.String(v.Source), 333 }) 334 } 335 336 volumesFrom := make([]ecs.TaskDefinition_VolumeFrom, 0, len(container.VolumesFrom)) 337 for _, v := range container.VolumesFrom { 338 parts := strings.SplitN(v, ":", 2) 339 ro := false 340 if len(parts) == 2 && parts[1] == "ro" { 341 ro = true 342 } 343 volumesFrom = append(volumesFrom, ecs.TaskDefinition_VolumeFrom{ 344 ReadOnly: ptr.Bool(ro), 345 SourceContainer: ptr.String(parts[0]), 346 }) 347 } 348 349 mountPoints := make([]ecs.TaskDefinition_MountPoint, 0, len(container.Volumes)) 350 for _, v := range container.Volumes { 351 mountPoints = append(mountPoints, ecs.TaskDefinition_MountPoint{ 352 ContainerPath: ptr.String(v.Target), 353 SourceVolume: ptr.String(v.Source), 354 ReadOnly: ptr.Bool(v.ReadOnly), 355 }) 356 } 357 358 var cpuShares *int 359 if container.Cpus > 0 { 360 cpuShares = ptr.Int(int(container.Cpus * 1024)) 361 } 362 name := container.Name 363 if name == "" { 364 name = awsecs.ContainerName // TODO: backwards compat; remove this 365 } 366 367 var dependsOn []ecs.TaskDefinition_ContainerDependency 368 if container.DependsOn != nil { 369 for name, condition := range container.DependsOn { 370 dependsOn = append(dependsOn, ecs.TaskDefinition_ContainerDependency{ 371 Condition: ptr.String(string(condition)), 372 ContainerName: ptr.String(name), 373 }) 374 } 375 } 376 377 def := ecs.TaskDefinition_ContainerDefinition{ 378 Name: name, 379 Image: images[i], 380 StopTimeout: ptr.Int(120), // TODO: make this configurable 381 Essential: container.Essential, 382 Cpu: cpuShares, 383 LogConfiguration: &ecs.TaskDefinition_LogConfiguration{ 384 LogDriver: "awslogs", 385 Options: map[string]string{ 386 "awslogs-group": cloudformation.Ref(_logGroup), 387 "awslogs-region": cloudformation.Ref("AWS::Region"), 388 "awslogs-stream-prefix": awsecs.AwsLogsStreamPrefix, 389 }, 390 }, 391 VolumesFrom: volumesFrom, 392 MountPoints: mountPoints, 393 EntryPoint: container.EntryPoint, 394 Command: container.Command, 395 WorkingDirectory: container.WorkDir, 396 DependsOnProp: dependsOn, 397 } 398 containerDefinitions = append(containerDefinitions, def) 399 } 400 401 const _taskDefinition = "TaskDefinition" 402 template.Resources[_taskDefinition] = &ecs.TaskDefinition{ 403 Tags: defaultTags, 404 RuntimePlatform: &ecs.TaskDefinition_RuntimePlatform{ 405 CpuArchitecture: archP, 406 OperatingSystemFamily: osP, 407 }, 408 Volumes: volumes, 409 ContainerDefinitions: containerDefinitions, 410 Cpu: ptr.String(strconv.FormatUint(uint64(mCpu), 10)), // MilliCPU 411 ExecutionRoleArn: cloudformation.RefPtr(_executionRole), 412 Memory: ptr.String(strconv.FormatUint(uint64(mib), 10)), // MiB 413 NetworkMode: ptr.String("awsvpc"), 414 RequiresCompatibilities: []string{"FARGATE"}, 415 TaskRoleArn: cloudformation.RefPtr(_taskRole), 416 // Family: cloudformation.SubPtr("${AWS::StackName}-TaskDefinition"), // optional, but needed to avoid TaskDef replacement 417 } 418 419 var vpcId *string 420 if overrides.VpcID == "" && createVpcResources { 421 // 8a. a VPC 422 const _vpc = "VPC" 423 template.Resources[_vpc] = &ec2.VPC{ 424 Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "vpc"}}, defaultTags...), 425 CidrBlock: ptr.String("10.0.0.0/16"), 426 } 427 vpcId = cloudformation.RefPtr(_vpc) 428 // 8b. an internet gateway; FIXME: make internet access optional 429 const _internetGateway = "InternetGateway" 430 template.Resources[_internetGateway] = &ec2.InternetGateway{ 431 Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "igw"}}, defaultTags...), 432 } 433 // 8c. an internet gateway attachment for the VPC 434 const _internetGatewayAttachment = "InternetGatewayAttachment" 435 template.Resources[_internetGatewayAttachment] = &ec2.VPCGatewayAttachment{ 436 VpcId: cloudformation.Ref(_vpc), 437 InternetGatewayId: cloudformation.RefPtr(_internetGateway), 438 } 439 // 8d. a route table 440 const _routeTable = "RouteTable" 441 template.Resources[_routeTable] = &ec2.RouteTable{ 442 Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "routetable"}}, defaultTags...), 443 VpcId: cloudformation.Ref(_vpc), 444 } 445 // 8e. a route for the route table and internet gateway 446 const _route = "Route" 447 template.Resources[_route] = &ec2.Route{ 448 RouteTableId: cloudformation.Ref(_routeTable), 449 DestinationCidrBlock: ptr.String("0.0.0.0/0"), 450 GatewayId: cloudformation.RefPtr(_internetGateway), 451 } 452 // 8f. a public subnet 453 const _subnet = "Subnet" 454 template.Resources[_subnet] = &ec2.Subnet{ 455 Tags: append([]tags.Tag{{Key: "Name", Value: prefix + "subnet"}}, defaultTags...), 456 // AvailabilityZone:; TODO: parse region suffix 457 CidrBlock: ptr.String("10.0.0.0/20"), 458 VpcId: cloudformation.Ref(_vpc), 459 MapPublicIpOnLaunch: ptr.Bool(true), 460 } 461 // 8g. a subnet / route table association 462 const _subnetRouteTableAssociation = "SubnetRouteTableAssociation" 463 template.Resources[_subnetRouteTableAssociation] = &ec2.SubnetRouteTableAssociation{ 464 SubnetId: cloudformation.Ref(_subnet), 465 RouteTableId: cloudformation.Ref(_routeTable), 466 } 467 // 8h. S3 gateway endpoint (to avoid S3 bandwidth charges) 468 const _s3GatewayEndpoint = "S3GatewayEndpoint" 469 template.Resources[_s3GatewayEndpoint] = &ec2.VPCEndpoint{ 470 VpcEndpointType: ptr.String("Gateway"), 471 VpcId: cloudformation.Ref(_vpc), 472 ServiceName: cloudformation.Sub("com.amazonaws.${AWS::Region}.s3"), 473 } 474 475 template.Outputs[outputs.SubnetID] = cloudformation.Output{ 476 Value: cloudformation.Ref(_subnet), 477 Description: ptr.String("ID of the subnet"), 478 } 479 } 480 481 if overrides.VpcID != "" { 482 vpcId = ptr.String(overrides.VpcID) 483 } 484 485 const _securityGroup = "SecurityGroup" 486 template.Resources[_securityGroup] = &ec2.SecurityGroup{ 487 Tags: defaultTags, // Name tag is ignored 488 GroupDescription: "Security group for the ECS task that allows all outbound and inbound traffic", 489 VpcId: vpcId, 490 SecurityGroupIngress: []ec2.SecurityGroup_Ingress{ 491 { 492 IpProtocol: "tcp", 493 FromPort: ptr.Int(1), 494 ToPort: ptr.Int(65535), 495 CidrIp: ptr.String("0.0.0.0/0"), // from anywhere; FIXME: make optional and/or restrict to "my ip" 496 }, 497 }, 498 // SecurityGroupEgress: []ec2.SecurityGroup_Egress{; FIXME: add ability to restrict outbound traffic 499 // { 500 // IpProtocol: "tcp", 501 // FromPort: ptr.Int(1), 502 // ToPort: ptr.Int(65535), 503 // // CidrIp: ptr.String(" 504 // }, 505 // }, 506 } 507 508 // Declare stack outputs 509 template.Outputs[outputs.TaskDefArn] = cloudformation.Output{ 510 Value: cloudformation.Ref(_taskDefinition), 511 Description: ptr.String("ARN of the ECS task definition"), 512 } 513 template.Outputs[outputs.ClusterName] = cloudformation.Output{ 514 Value: cloudformation.Ref(_cluster), 515 Description: ptr.String("Name of the ECS cluster"), 516 } 517 template.Outputs[outputs.LogGroupARN] = cloudformation.Output{ 518 Value: cloudformation.GetAtt(_logGroup, "Arn"), 519 Description: ptr.String("ARN of the CloudWatch log group"), 520 } 521 template.Outputs[outputs.SecurityGroupID] = cloudformation.Output{ 522 Value: cloudformation.Ref(_securityGroup), 523 Description: ptr.String("ID of the security group"), 524 } 525 template.Outputs[outputs.BucketName] = cloudformation.Output{ 526 Value: cloudformation.Ref(_bucket), 527 Description: ptr.String("Name of the S3 bucket"), 528 } 529 530 return template 531 }