cloudformation.go 17 KB

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