cloudformation.go 15 KB

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