cloudformation.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. package backend
  2. import (
  3. "fmt"
  4. "regexp"
  5. "strings"
  6. ecsapi "github.com/aws/aws-sdk-go/service/ecs"
  7. "github.com/aws/aws-sdk-go/service/elbv2"
  8. cloudmapapi "github.com/aws/aws-sdk-go/service/servicediscovery"
  9. "github.com/awslabs/goformation/v4/cloudformation"
  10. "github.com/awslabs/goformation/v4/cloudformation/ec2"
  11. "github.com/awslabs/goformation/v4/cloudformation/ecs"
  12. "github.com/awslabs/goformation/v4/cloudformation/elasticloadbalancingv2"
  13. "github.com/awslabs/goformation/v4/cloudformation/iam"
  14. "github.com/awslabs/goformation/v4/cloudformation/logs"
  15. cloudmap "github.com/awslabs/goformation/v4/cloudformation/servicediscovery"
  16. "github.com/awslabs/goformation/v4/cloudformation/tags"
  17. "github.com/compose-spec/compose-go/compatibility"
  18. "github.com/compose-spec/compose-go/errdefs"
  19. "github.com/compose-spec/compose-go/types"
  20. "github.com/docker/ecs-plugin/pkg/compose"
  21. "github.com/sirupsen/logrus"
  22. )
  23. const (
  24. ParameterClusterName = "ParameterClusterName"
  25. ParameterVPCId = "ParameterVPCId"
  26. ParameterSubnet1Id = "ParameterSubnet1Id"
  27. ParameterSubnet2Id = "ParameterSubnet2Id"
  28. ParameterLoadBalancerARN = "ParameterLoadBalancerARN"
  29. )
  30. // Convert a compose project into a CloudFormation template
  31. func (b Backend) Convert(project *types.Project) (*cloudformation.Template, error) {
  32. var checker compatibility.Checker = &FargateCompatibilityChecker{
  33. compatibility.AllowList{
  34. Supported: compatibleComposeAttributes,
  35. },
  36. }
  37. compatibility.Check(project, checker)
  38. for _, err := range checker.Errors() {
  39. if errdefs.IsIncompatibleError(err) {
  40. logrus.Error(err.Error())
  41. } else {
  42. logrus.Warn(err.Error())
  43. }
  44. }
  45. if !compatibility.IsCompatible(checker) {
  46. return nil, fmt.Errorf("compose file is incompatible with Amazon ECS")
  47. }
  48. template := cloudformation.NewTemplate()
  49. template.Description = "CloudFormation template created by Docker for deploying applications on Amazon ECS"
  50. template.Parameters[ParameterClusterName] = cloudformation.Parameter{
  51. Type: "String",
  52. Description: "Name of the ECS cluster to deploy to (optional)",
  53. }
  54. template.Parameters[ParameterVPCId] = cloudformation.Parameter{
  55. Type: "AWS::EC2::VPC::Id",
  56. Description: "ID of the VPC",
  57. }
  58. /*
  59. FIXME can't set subnets: Ref("SubnetIds") see https://github.com/awslabs/goformation/issues/282
  60. template.Parameters["SubnetIds"] = cloudformation.Parameter{
  61. Type: "List<AWS::EC2::Subnet::Id>",
  62. Description: "The list of SubnetIds, for at least two Availability Zones in the region in your VPC",
  63. }
  64. */
  65. template.Parameters[ParameterSubnet1Id] = cloudformation.Parameter{
  66. Type: "AWS::EC2::Subnet::Id",
  67. Description: "SubnetId, for Availability Zone 1 in the region in your VPC",
  68. }
  69. template.Parameters[ParameterSubnet2Id] = cloudformation.Parameter{
  70. Type: "AWS::EC2::Subnet::Id",
  71. Description: "SubnetId, for Availability Zone 2 in the region in your VPC",
  72. }
  73. template.Parameters[ParameterLoadBalancerARN] = cloudformation.Parameter{
  74. Type: "String",
  75. Description: "Name of the LoadBalancer to connect to (optional)",
  76. }
  77. // Create Cluster is `ParameterClusterName` parameter is not set
  78. template.Conditions["CreateCluster"] = cloudformation.Equals("", cloudformation.Ref(ParameterClusterName))
  79. cluster := createCluster(project, template)
  80. networks := map[string]string{}
  81. for _, net := range project.Networks {
  82. networks[net.Name] = convertNetwork(project, net, cloudformation.Ref(ParameterVPCId), template)
  83. }
  84. logGroup := fmt.Sprintf("/docker-compose/%s", project.Name)
  85. template.Resources["LogGroup"] = &logs.LogGroup{
  86. LogGroupName: logGroup,
  87. }
  88. // Private DNS namespace will allow DNS name for the services to be <service>.<project>.local
  89. createCloudMap(project, template)
  90. loadBalancerARN := createLoadBalancer(project, template)
  91. for _, service := range project.Services {
  92. definition, err := Convert(project, service)
  93. if err != nil {
  94. return nil, err
  95. }
  96. taskExecutionRole, err := createTaskExecutionRole(service, err, definition, template)
  97. if err != nil {
  98. return template, err
  99. }
  100. definition.ExecutionRoleArn = cloudformation.Ref(taskExecutionRole)
  101. taskDefinition := fmt.Sprintf("%sTaskDefinition", normalizeResourceName(service.Name))
  102. template.Resources[taskDefinition] = definition
  103. var healthCheck *cloudmap.Service_HealthCheckConfig
  104. if service.HealthCheck != nil && !service.HealthCheck.Disable {
  105. // FIXME ECS only support HTTP(s) health checks, while Docker only support CMD
  106. }
  107. serviceRegistry := createServiceRegistry(service, template, healthCheck)
  108. serviceSecurityGroups := []string{}
  109. for net := range service.Networks {
  110. serviceSecurityGroups = append(serviceSecurityGroups, networks[net])
  111. }
  112. dependsOn := []string{}
  113. serviceLB := []ecs.Service_LoadBalancer{}
  114. if len(service.Ports) > 0 {
  115. for _, port := range service.Ports {
  116. protocol := strings.ToUpper(port.Protocol)
  117. if getLoadBalancerType(project) == elbv2.LoadBalancerTypeEnumApplication {
  118. protocol = elbv2.ProtocolEnumHttps
  119. if port.Published == 80 {
  120. protocol = elbv2.ProtocolEnumHttp
  121. }
  122. }
  123. if loadBalancerARN != "" {
  124. targetGroupName := createTargetGroup(project, service, port, template, protocol)
  125. listenerName := createListener(service, port, template, targetGroupName, loadBalancerARN, protocol)
  126. dependsOn = append(dependsOn, listenerName)
  127. serviceLB = append(serviceLB, ecs.Service_LoadBalancer{
  128. ContainerName: service.Name,
  129. ContainerPort: int(port.Target),
  130. TargetGroupArn: cloudformation.Ref(targetGroupName),
  131. })
  132. }
  133. }
  134. }
  135. desiredCount := 1
  136. if service.Deploy != nil && service.Deploy.Replicas != nil {
  137. desiredCount = int(*service.Deploy.Replicas)
  138. }
  139. for _, dependency := range service.DependsOn {
  140. dependsOn = append(dependsOn, serviceResourceName(dependency))
  141. }
  142. template.Resources[serviceResourceName(service.Name)] = &ecs.Service{
  143. AWSCloudFormationDependsOn: dependsOn,
  144. Cluster: cluster,
  145. DesiredCount: desiredCount,
  146. DeploymentController: &ecs.Service_DeploymentController{
  147. Type: ecsapi.DeploymentControllerTypeEcs,
  148. },
  149. DeploymentConfiguration: &ecs.Service_DeploymentConfiguration{
  150. MaximumPercent: 200,
  151. MinimumHealthyPercent: 100,
  152. },
  153. LaunchType: ecsapi.LaunchTypeFargate,
  154. LoadBalancers: serviceLB,
  155. NetworkConfiguration: &ecs.Service_NetworkConfiguration{
  156. AwsvpcConfiguration: &ecs.Service_AwsVpcConfiguration{
  157. AssignPublicIp: ecsapi.AssignPublicIpEnabled,
  158. SecurityGroups: serviceSecurityGroups,
  159. Subnets: []string{
  160. cloudformation.Ref(ParameterSubnet1Id),
  161. cloudformation.Ref(ParameterSubnet2Id),
  162. },
  163. },
  164. },
  165. PropagateTags: ecsapi.PropagateTagsService,
  166. SchedulingStrategy: ecsapi.SchedulingStrategyReplica,
  167. ServiceRegistries: []ecs.Service_ServiceRegistry{serviceRegistry},
  168. Tags: []tags.Tag{
  169. {
  170. Key: compose.ProjectTag,
  171. Value: project.Name,
  172. },
  173. {
  174. Key: compose.ServiceTag,
  175. Value: service.Name,
  176. },
  177. },
  178. TaskDefinition: cloudformation.Ref(normalizeResourceName(taskDefinition)),
  179. }
  180. }
  181. return template, nil
  182. }
  183. func getLoadBalancerType(project *types.Project) string {
  184. for _, service := range project.Services {
  185. for _, port := range service.Ports {
  186. if port.Published != 80 && port.Published != 443 {
  187. return elbv2.LoadBalancerTypeEnumNetwork
  188. }
  189. }
  190. }
  191. return elbv2.LoadBalancerTypeEnumApplication
  192. }
  193. func getLoadBalancerSecurityGroups(project *types.Project, template *cloudformation.Template) []string {
  194. securityGroups := []string{}
  195. for _, network := range project.Networks {
  196. if !network.Internal {
  197. net := convertNetwork(project, network, cloudformation.Ref(ParameterVPCId), template)
  198. securityGroups = append(securityGroups, net)
  199. }
  200. }
  201. return uniqueStrings(securityGroups)
  202. }
  203. func createLoadBalancer(project *types.Project, template *cloudformation.Template) string {
  204. ports := 0
  205. for _, service := range project.Services {
  206. ports += len(service.Ports)
  207. }
  208. if ports == 0 {
  209. // Project do not expose any port (batch jobs?)
  210. // So no need to create a LoadBalancer
  211. return ""
  212. }
  213. // load balancer names are limited to 32 characters total
  214. loadBalancerName := fmt.Sprintf("%.32s", fmt.Sprintf("%sLoadBalancer", strings.Title(project.Name)))
  215. // Create LoadBalancer if `ParameterLoadBalancerName` is not set
  216. template.Conditions["CreateLoadBalancer"] = cloudformation.Equals("", cloudformation.Ref(ParameterLoadBalancerARN))
  217. loadBalancerType := getLoadBalancerType(project)
  218. securityGroups := []string{}
  219. if loadBalancerType == elbv2.LoadBalancerTypeEnumApplication {
  220. securityGroups = getLoadBalancerSecurityGroups(project, template)
  221. }
  222. template.Resources[loadBalancerName] = &elasticloadbalancingv2.LoadBalancer{
  223. Name: loadBalancerName,
  224. Scheme: elbv2.LoadBalancerSchemeEnumInternetFacing,
  225. SecurityGroups: securityGroups,
  226. Subnets: []string{
  227. cloudformation.Ref(ParameterSubnet1Id),
  228. cloudformation.Ref(ParameterSubnet2Id),
  229. },
  230. Tags: []tags.Tag{
  231. {
  232. Key: compose.ProjectTag,
  233. Value: project.Name,
  234. },
  235. },
  236. Type: loadBalancerType,
  237. AWSCloudFormationCondition: "CreateLoadBalancer",
  238. }
  239. return cloudformation.If("CreateLoadBalancer", cloudformation.Ref(loadBalancerName), cloudformation.Ref(ParameterLoadBalancerARN))
  240. }
  241. func createListener(service types.ServiceConfig, port types.ServicePortConfig, template *cloudformation.Template, targetGroupName string, loadBalancerARN string, protocol string) string {
  242. listenerName := fmt.Sprintf(
  243. "%s%s%dListener",
  244. normalizeResourceName(service.Name),
  245. strings.ToUpper(port.Protocol),
  246. port.Target,
  247. )
  248. //add listener to dependsOn
  249. //https://stackoverflow.com/questions/53971873/the-target-group-does-not-have-an-associated-load-balancer
  250. template.Resources[listenerName] = &elasticloadbalancingv2.Listener{
  251. DefaultActions: []elasticloadbalancingv2.Listener_Action{
  252. {
  253. ForwardConfig: &elasticloadbalancingv2.Listener_ForwardConfig{
  254. TargetGroups: []elasticloadbalancingv2.Listener_TargetGroupTuple{
  255. {
  256. TargetGroupArn: cloudformation.Ref(targetGroupName),
  257. },
  258. },
  259. },
  260. Type: elbv2.ActionTypeEnumForward,
  261. },
  262. },
  263. LoadBalancerArn: loadBalancerARN,
  264. Protocol: protocol,
  265. Port: int(port.Target),
  266. }
  267. return listenerName
  268. }
  269. func createTargetGroup(project *types.Project, service types.ServiceConfig, port types.ServicePortConfig, template *cloudformation.Template, protocol string) string {
  270. targetGroupName := fmt.Sprintf(
  271. "%s%s%dTargetGroup",
  272. normalizeResourceName(service.Name),
  273. strings.ToUpper(port.Protocol),
  274. port.Published,
  275. )
  276. template.Resources[targetGroupName] = &elasticloadbalancingv2.TargetGroup{
  277. Name: targetGroupName,
  278. Port: int(port.Target),
  279. Protocol: protocol,
  280. Tags: []tags.Tag{
  281. {
  282. Key: compose.ProjectTag,
  283. Value: project.Name,
  284. },
  285. },
  286. VpcId: cloudformation.Ref(ParameterVPCId),
  287. TargetType: elbv2.TargetTypeEnumIp,
  288. }
  289. return targetGroupName
  290. }
  291. func createServiceRegistry(service types.ServiceConfig, template *cloudformation.Template, healthCheck *cloudmap.Service_HealthCheckConfig) ecs.Service_ServiceRegistry {
  292. serviceRegistration := fmt.Sprintf("%sServiceDiscoveryEntry", normalizeResourceName(service.Name))
  293. serviceRegistry := ecs.Service_ServiceRegistry{
  294. RegistryArn: cloudformation.GetAtt(serviceRegistration, "Arn"),
  295. }
  296. template.Resources[serviceRegistration] = &cloudmap.Service{
  297. Description: fmt.Sprintf("%q service discovery entry in Cloud Map", service.Name),
  298. HealthCheckConfig: healthCheck,
  299. HealthCheckCustomConfig: &cloudmap.Service_HealthCheckCustomConfig{
  300. FailureThreshold: 1,
  301. },
  302. Name: service.Name,
  303. NamespaceId: cloudformation.Ref("CloudMap"),
  304. DnsConfig: &cloudmap.Service_DnsConfig{
  305. DnsRecords: []cloudmap.Service_DnsRecord{
  306. {
  307. TTL: 60,
  308. Type: cloudmapapi.RecordTypeA,
  309. },
  310. },
  311. RoutingPolicy: cloudmapapi.RoutingPolicyMultivalue,
  312. },
  313. }
  314. return serviceRegistry
  315. }
  316. func createTaskExecutionRole(service types.ServiceConfig, err error, definition *ecs.TaskDefinition, template *cloudformation.Template) (string, error) {
  317. taskExecutionRole := fmt.Sprintf("%sTaskExecutionRole", normalizeResourceName(service.Name))
  318. policy, err := getPolicy(definition)
  319. if err != nil {
  320. return taskExecutionRole, err
  321. }
  322. rolePolicies := []iam.Role_Policy{}
  323. if policy != nil {
  324. rolePolicies = append(rolePolicies, iam.Role_Policy{
  325. PolicyDocument: policy,
  326. PolicyName: fmt.Sprintf("%sGrantAccessToSecrets", service.Name),
  327. })
  328. }
  329. template.Resources[taskExecutionRole] = &iam.Role{
  330. AssumeRolePolicyDocument: assumeRolePolicyDocument,
  331. Policies: rolePolicies,
  332. ManagedPolicyArns: []string{
  333. ECSTaskExecutionPolicy,
  334. ECRReadOnlyPolicy,
  335. },
  336. }
  337. return taskExecutionRole, nil
  338. }
  339. func createCluster(project *types.Project, template *cloudformation.Template) string {
  340. template.Resources["Cluster"] = &ecs.Cluster{
  341. ClusterName: project.Name,
  342. Tags: []tags.Tag{
  343. {
  344. Key: compose.ProjectTag,
  345. Value: project.Name,
  346. },
  347. },
  348. AWSCloudFormationCondition: "CreateCluster",
  349. }
  350. cluster := cloudformation.If("CreateCluster", cloudformation.Ref("Cluster"), cloudformation.Ref(ParameterClusterName))
  351. return cluster
  352. }
  353. func createCloudMap(project *types.Project, template *cloudformation.Template) {
  354. template.Resources["CloudMap"] = &cloudmap.PrivateDnsNamespace{
  355. Description: fmt.Sprintf("Service Map for Docker Compose project %s", project.Name),
  356. Name: fmt.Sprintf("%s.local", project.Name),
  357. Vpc: cloudformation.Ref(ParameterVPCId),
  358. }
  359. }
  360. func convertNetwork(project *types.Project, net types.NetworkConfig, vpc string, template *cloudformation.Template) string {
  361. if sg, ok := net.Extensions[compose.ExtensionSecurityGroup]; ok {
  362. logrus.Debugf("Security Group for network %q set by user to %q", net.Name, sg)
  363. return sg.(string)
  364. }
  365. var ingresses []ec2.SecurityGroup_Ingress
  366. if !net.Internal {
  367. for _, service := range project.Services {
  368. if _, ok := service.Networks[net.Name]; ok {
  369. for _, port := range service.Ports {
  370. ingresses = append(ingresses, ec2.SecurityGroup_Ingress{
  371. CidrIp: "0.0.0.0/0",
  372. Description: fmt.Sprintf("%s:%d/%s", service.Name, port.Target, port.Protocol),
  373. FromPort: int(port.Target),
  374. IpProtocol: strings.ToUpper(port.Protocol),
  375. ToPort: int(port.Target),
  376. })
  377. }
  378. }
  379. }
  380. }
  381. securityGroup := networkResourceName(project, net.Name)
  382. template.Resources[securityGroup] = &ec2.SecurityGroup{
  383. GroupDescription: fmt.Sprintf("%s %s Security Group", project.Name, net.Name),
  384. GroupName: securityGroup,
  385. SecurityGroupIngress: ingresses,
  386. VpcId: vpc,
  387. Tags: []tags.Tag{
  388. {
  389. Key: compose.ProjectTag,
  390. Value: project.Name,
  391. },
  392. {
  393. Key: compose.NetworkTag,
  394. Value: net.Name,
  395. },
  396. },
  397. }
  398. ingress := securityGroup + "Ingress"
  399. template.Resources[ingress] = &ec2.SecurityGroupIngress{
  400. Description: fmt.Sprintf("Allow communication within network %s", net.Name),
  401. IpProtocol: "-1", // all protocols
  402. GroupId: cloudformation.Ref(securityGroup),
  403. SourceSecurityGroupId: cloudformation.Ref(securityGroup),
  404. }
  405. return cloudformation.Ref(securityGroup)
  406. }
  407. func networkResourceName(project *types.Project, network string) string {
  408. return fmt.Sprintf("%s%sNetwork", normalizeResourceName(project.Name), normalizeResourceName(network))
  409. }
  410. func serviceResourceName(dependency string) string {
  411. return fmt.Sprintf("%sService", normalizeResourceName(dependency))
  412. }
  413. func normalizeResourceName(s string) string {
  414. return strings.Title(regexp.MustCompile("[^a-zA-Z0-9]+").ReplaceAllString(s, ""))
  415. }
  416. func getPolicy(taskDef *ecs.TaskDefinition) (*PolicyDocument, error) {
  417. arns := []string{}
  418. for _, container := range taskDef.ContainerDefinitions {
  419. if container.RepositoryCredentials != nil {
  420. arns = append(arns, container.RepositoryCredentials.CredentialsParameter)
  421. }
  422. if len(container.Secrets) > 0 {
  423. for _, s := range container.Secrets {
  424. arns = append(arns, s.ValueFrom)
  425. }
  426. }
  427. }
  428. if len(arns) > 0 {
  429. return &PolicyDocument{
  430. Statement: []PolicyStatement{
  431. {
  432. Effect: "Allow",
  433. Action: []string{ActionGetSecretValue, ActionGetParameters, ActionDecrypt},
  434. Resource: arns,
  435. }},
  436. }, nil
  437. }
  438. return nil, nil
  439. }
  440. func uniqueStrings(items []string) []string {
  441. keys := make(map[string]bool)
  442. unique := []string{}
  443. for _, item := range items {
  444. if _, val := keys[item]; !val {
  445. keys[item] = true
  446. unique = append(unique, item)
  447. }
  448. }
  449. return unique
  450. }