cloudformation.go 15 KB

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