cloudformation.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package ecs
  14. import (
  15. "context"
  16. "fmt"
  17. "io/ioutil"
  18. "regexp"
  19. "strings"
  20. ecsapi "github.com/aws/aws-sdk-go/service/ecs"
  21. "github.com/aws/aws-sdk-go/service/elbv2"
  22. cloudmapapi "github.com/aws/aws-sdk-go/service/servicediscovery"
  23. "github.com/awslabs/goformation/v4/cloudformation"
  24. "github.com/awslabs/goformation/v4/cloudformation/ec2"
  25. "github.com/awslabs/goformation/v4/cloudformation/ecs"
  26. "github.com/awslabs/goformation/v4/cloudformation/elasticloadbalancingv2"
  27. "github.com/awslabs/goformation/v4/cloudformation/iam"
  28. "github.com/awslabs/goformation/v4/cloudformation/logs"
  29. "github.com/awslabs/goformation/v4/cloudformation/secretsmanager"
  30. cloudmap "github.com/awslabs/goformation/v4/cloudformation/servicediscovery"
  31. "github.com/compose-spec/compose-go/types"
  32. )
  33. func (b *ecsAPIService) Convert(ctx context.Context, project *types.Project) ([]byte, error) {
  34. err := b.checkCompatibility(project)
  35. if err != nil {
  36. return nil, err
  37. }
  38. resources, err := b.parse(ctx, project)
  39. if err != nil {
  40. return nil, err
  41. }
  42. template, err := b.convert(project, resources)
  43. if err != nil {
  44. return nil, err
  45. }
  46. // Create a NFS inbound rule on each mount target for volumes
  47. // as "source security group" use an arbitrary network attached to service(s) who mounts target volume
  48. for n, vol := range project.Volumes {
  49. err := b.SDK.WithVolumeSecurityGroups(ctx, vol.Name, func(securityGroups []string) error {
  50. return b.createNFSmountIngress(securityGroups, project, n, template)
  51. })
  52. if err != nil {
  53. return nil, err
  54. }
  55. }
  56. err = b.createCapacityProvider(ctx, project, template, resources)
  57. if err != nil {
  58. return nil, err
  59. }
  60. return marshall(template)
  61. }
  62. // Convert a compose project into a CloudFormation template
  63. func (b *ecsAPIService) convert(project *types.Project, resources awsResources) (*cloudformation.Template, error) {
  64. template := cloudformation.NewTemplate()
  65. b.ensureResources(&resources, project, template)
  66. for name, secret := range project.Secrets {
  67. err := b.createSecret(project, name, secret, template)
  68. if err != nil {
  69. return nil, err
  70. }
  71. }
  72. b.createLogGroup(project, template)
  73. // Private DNS namespace will allow DNS name for the services to be <service>.<project>.local
  74. b.createCloudMap(project, template, resources.vpc)
  75. for _, service := range project.Services {
  76. taskExecutionRole := b.createTaskExecutionRole(project, service, template)
  77. taskRole := b.createTaskRole(service, template)
  78. definition, err := b.createTaskExecution(project, service)
  79. if err != nil {
  80. return nil, err
  81. }
  82. definition.ExecutionRoleArn = cloudformation.Ref(taskExecutionRole)
  83. if taskRole != "" {
  84. definition.TaskRoleArn = cloudformation.Ref(taskRole)
  85. }
  86. taskDefinition := fmt.Sprintf("%sTaskDefinition", normalizeResourceName(service.Name))
  87. template.Resources[taskDefinition] = definition
  88. var healthCheck *cloudmap.Service_HealthCheckConfig
  89. serviceRegistry := b.createServiceRegistry(service, template, healthCheck)
  90. var (
  91. dependsOn []string
  92. serviceLB []ecs.Service_LoadBalancer
  93. )
  94. for _, port := range service.Ports {
  95. for net := range service.Networks {
  96. b.createIngress(service, net, port, template, resources)
  97. }
  98. protocol := strings.ToUpper(port.Protocol)
  99. if resources.loadBalancerType == elbv2.LoadBalancerTypeEnumApplication {
  100. // we don't set Https as a certificate must be specified for HTTPS listeners
  101. protocol = elbv2.ProtocolEnumHttp
  102. }
  103. targetGroupName := b.createTargetGroup(project, service, port, template, protocol, resources.vpc)
  104. listenerName := b.createListener(service, port, template, targetGroupName, resources.loadBalancer, protocol)
  105. dependsOn = append(dependsOn, listenerName)
  106. serviceLB = append(serviceLB, ecs.Service_LoadBalancer{
  107. ContainerName: service.Name,
  108. ContainerPort: int(port.Target),
  109. TargetGroupArn: cloudformation.Ref(targetGroupName),
  110. })
  111. }
  112. desiredCount := 1
  113. if service.Deploy != nil && service.Deploy.Replicas != nil {
  114. desiredCount = int(*service.Deploy.Replicas)
  115. }
  116. for dependency := range service.DependsOn {
  117. dependsOn = append(dependsOn, serviceResourceName(dependency))
  118. }
  119. minPercent, maxPercent, err := computeRollingUpdateLimits(service)
  120. if err != nil {
  121. return nil, err
  122. }
  123. assignPublicIP := ecsapi.AssignPublicIpEnabled
  124. launchType := ecsapi.LaunchTypeFargate
  125. platformVersion := "1.4.0" // LATEST which is set to 1.3.0 (?) which doesn’t allow efs volumes.
  126. if requireEC2(service) {
  127. assignPublicIP = ecsapi.AssignPublicIpDisabled
  128. launchType = ecsapi.LaunchTypeEc2
  129. platformVersion = "" // The platform version must be null when specifying an EC2 launch type
  130. }
  131. template.Resources[serviceResourceName(service.Name)] = &ecs.Service{
  132. AWSCloudFormationDependsOn: dependsOn,
  133. Cluster: resources.cluster,
  134. DesiredCount: desiredCount,
  135. DeploymentController: &ecs.Service_DeploymentController{
  136. Type: ecsapi.DeploymentControllerTypeEcs,
  137. },
  138. DeploymentConfiguration: &ecs.Service_DeploymentConfiguration{
  139. MaximumPercent: maxPercent,
  140. MinimumHealthyPercent: minPercent,
  141. },
  142. LaunchType: launchType,
  143. // TODO we miss support for https://github.com/aws/containers-roadmap/issues/631 to select a capacity provider
  144. LoadBalancers: serviceLB,
  145. NetworkConfiguration: &ecs.Service_NetworkConfiguration{
  146. AwsvpcConfiguration: &ecs.Service_AwsVpcConfiguration{
  147. AssignPublicIp: assignPublicIP,
  148. SecurityGroups: resources.serviceSecurityGroups(service),
  149. Subnets: resources.subnets,
  150. },
  151. },
  152. PlatformVersion: platformVersion,
  153. PropagateTags: ecsapi.PropagateTagsService,
  154. SchedulingStrategy: ecsapi.SchedulingStrategyReplica,
  155. ServiceRegistries: []ecs.Service_ServiceRegistry{serviceRegistry},
  156. Tags: serviceTags(project, service),
  157. TaskDefinition: cloudformation.Ref(normalizeResourceName(taskDefinition)),
  158. }
  159. }
  160. return template, nil
  161. }
  162. const allProtocols = "-1"
  163. func (b *ecsAPIService) createIngress(service types.ServiceConfig, net string, port types.ServicePortConfig, template *cloudformation.Template, resources awsResources) {
  164. protocol := strings.ToUpper(port.Protocol)
  165. if protocol == "" {
  166. protocol = allProtocols
  167. }
  168. ingress := fmt.Sprintf("%s%dIngress", normalizeResourceName(net), port.Target)
  169. template.Resources[ingress] = &ec2.SecurityGroupIngress{
  170. CidrIp: "0.0.0.0/0",
  171. Description: fmt.Sprintf("%s:%d/%s on %s nextwork", service.Name, port.Target, port.Protocol, net),
  172. GroupId: resources.securityGroups[net],
  173. FromPort: int(port.Target),
  174. IpProtocol: protocol,
  175. ToPort: int(port.Target),
  176. }
  177. }
  178. func (b *ecsAPIService) createSecret(project *types.Project, name string, s types.SecretConfig, template *cloudformation.Template) error {
  179. if s.External.External {
  180. return nil
  181. }
  182. sensitiveData, err := ioutil.ReadFile(s.File)
  183. if err != nil {
  184. return err
  185. }
  186. resource := fmt.Sprintf("%sSecret", normalizeResourceName(s.Name))
  187. template.Resources[resource] = &secretsmanager.Secret{
  188. Description: fmt.Sprintf("Secret %s", s.Name),
  189. SecretString: string(sensitiveData),
  190. Tags: projectTags(project),
  191. }
  192. s.Name = cloudformation.Ref(resource)
  193. project.Secrets[name] = s
  194. return nil
  195. }
  196. func (b *ecsAPIService) createLogGroup(project *types.Project, template *cloudformation.Template) {
  197. retention := 0
  198. if v, ok := project.Extensions[extensionRetention]; ok {
  199. retention = v.(int)
  200. }
  201. logGroup := fmt.Sprintf("/docker-compose/%s", project.Name)
  202. template.Resources["LogGroup"] = &logs.LogGroup{
  203. LogGroupName: logGroup,
  204. RetentionInDays: retention,
  205. }
  206. }
  207. func computeRollingUpdateLimits(service types.ServiceConfig) (int, int, error) {
  208. maxPercent := 200
  209. minPercent := 100
  210. if service.Deploy == nil || service.Deploy.UpdateConfig == nil {
  211. return minPercent, maxPercent, nil
  212. }
  213. updateConfig := service.Deploy.UpdateConfig
  214. min, okMin := updateConfig.Extensions[extensionMinPercent]
  215. if okMin {
  216. minPercent = min.(int)
  217. }
  218. max, okMax := updateConfig.Extensions[extensionMaxPercent]
  219. if okMax {
  220. maxPercent = max.(int)
  221. }
  222. if okMin && okMax {
  223. return minPercent, maxPercent, nil
  224. }
  225. if updateConfig.Parallelism != nil {
  226. parallelism := int(*updateConfig.Parallelism)
  227. if service.Deploy.Replicas == nil {
  228. return minPercent, maxPercent,
  229. fmt.Errorf("rolling update configuration require deploy.replicas to be set")
  230. }
  231. replicas := int(*service.Deploy.Replicas)
  232. if replicas < parallelism {
  233. return minPercent, maxPercent,
  234. fmt.Errorf("deploy.replicas (%d) must be greater than deploy.update_config.parallelism (%d)", replicas, parallelism)
  235. }
  236. if !okMin {
  237. minPercent = (replicas - parallelism) * 100 / replicas
  238. }
  239. if !okMax {
  240. maxPercent = (replicas + parallelism) * 100 / replicas
  241. }
  242. }
  243. return minPercent, maxPercent, nil
  244. }
  245. func (b *ecsAPIService) createListener(service types.ServiceConfig, port types.ServicePortConfig,
  246. template *cloudformation.Template,
  247. targetGroupName string, loadBalancerARN string, protocol string) string {
  248. listenerName := fmt.Sprintf(
  249. "%s%s%dListener",
  250. normalizeResourceName(service.Name),
  251. strings.ToUpper(port.Protocol),
  252. port.Target,
  253. )
  254. //add listener to dependsOn
  255. //https://stackoverflow.com/questions/53971873/the-target-group-does-not-have-an-associated-load-balancer
  256. template.Resources[listenerName] = &elasticloadbalancingv2.Listener{
  257. DefaultActions: []elasticloadbalancingv2.Listener_Action{
  258. {
  259. ForwardConfig: &elasticloadbalancingv2.Listener_ForwardConfig{
  260. TargetGroups: []elasticloadbalancingv2.Listener_TargetGroupTuple{
  261. {
  262. TargetGroupArn: cloudformation.Ref(targetGroupName),
  263. },
  264. },
  265. },
  266. Type: elbv2.ActionTypeEnumForward,
  267. },
  268. },
  269. LoadBalancerArn: loadBalancerARN,
  270. Protocol: protocol,
  271. Port: int(port.Target),
  272. }
  273. return listenerName
  274. }
  275. func (b *ecsAPIService) createTargetGroup(project *types.Project, service types.ServiceConfig, port types.ServicePortConfig, template *cloudformation.Template, protocol string, vpc string) string {
  276. targetGroupName := fmt.Sprintf(
  277. "%s%s%dTargetGroup",
  278. normalizeResourceName(service.Name),
  279. strings.ToUpper(port.Protocol),
  280. port.Published,
  281. )
  282. template.Resources[targetGroupName] = &elasticloadbalancingv2.TargetGroup{
  283. HealthCheckEnabled: false,
  284. Port: int(port.Target),
  285. Protocol: protocol,
  286. Tags: projectTags(project),
  287. TargetType: elbv2.TargetTypeEnumIp,
  288. VpcId: vpc,
  289. }
  290. return targetGroupName
  291. }
  292. func (b *ecsAPIService) createServiceRegistry(service types.ServiceConfig, template *cloudformation.Template, healthCheck *cloudmap.Service_HealthCheckConfig) ecs.Service_ServiceRegistry {
  293. serviceRegistration := fmt.Sprintf("%sServiceDiscoveryEntry", normalizeResourceName(service.Name))
  294. serviceRegistry := ecs.Service_ServiceRegistry{
  295. RegistryArn: cloudformation.GetAtt(serviceRegistration, "Arn"),
  296. }
  297. template.Resources[serviceRegistration] = &cloudmap.Service{
  298. Description: fmt.Sprintf("%q service discovery entry in Cloud Map", service.Name),
  299. HealthCheckConfig: healthCheck,
  300. HealthCheckCustomConfig: &cloudmap.Service_HealthCheckCustomConfig{
  301. FailureThreshold: 1,
  302. },
  303. Name: service.Name,
  304. NamespaceId: cloudformation.Ref("CloudMap"),
  305. DnsConfig: &cloudmap.Service_DnsConfig{
  306. DnsRecords: []cloudmap.Service_DnsRecord{
  307. {
  308. TTL: 60,
  309. Type: cloudmapapi.RecordTypeA,
  310. },
  311. },
  312. RoutingPolicy: cloudmapapi.RoutingPolicyMultivalue,
  313. },
  314. }
  315. return serviceRegistry
  316. }
  317. func (b *ecsAPIService) createTaskExecutionRole(project *types.Project, service types.ServiceConfig, template *cloudformation.Template) string {
  318. taskExecutionRole := fmt.Sprintf("%sTaskExecutionRole", normalizeResourceName(service.Name))
  319. policies := b.createPolicies(project, service)
  320. template.Resources[taskExecutionRole] = &iam.Role{
  321. AssumeRolePolicyDocument: ecsTaskAssumeRolePolicyDocument,
  322. Policies: policies,
  323. ManagedPolicyArns: []string{
  324. ecsTaskExecutionPolicy,
  325. ecrReadOnlyPolicy,
  326. },
  327. }
  328. return taskExecutionRole
  329. }
  330. func (b *ecsAPIService) createTaskRole(service types.ServiceConfig, template *cloudformation.Template) string {
  331. taskRole := fmt.Sprintf("%sTaskRole", normalizeResourceName(service.Name))
  332. rolePolicies := []iam.Role_Policy{}
  333. if roles, ok := service.Extensions[extensionRole]; ok {
  334. rolePolicies = append(rolePolicies, iam.Role_Policy{
  335. PolicyDocument: roles,
  336. })
  337. }
  338. managedPolicies := []string{}
  339. if v, ok := service.Extensions[extensionManagedPolicies]; ok {
  340. for _, s := range v.([]interface{}) {
  341. managedPolicies = append(managedPolicies, s.(string))
  342. }
  343. }
  344. if len(rolePolicies) == 0 && len(managedPolicies) == 0 {
  345. return ""
  346. }
  347. template.Resources[taskRole] = &iam.Role{
  348. AssumeRolePolicyDocument: ecsTaskAssumeRolePolicyDocument,
  349. Policies: rolePolicies,
  350. ManagedPolicyArns: managedPolicies,
  351. }
  352. return taskRole
  353. }
  354. func (b *ecsAPIService) createCloudMap(project *types.Project, template *cloudformation.Template, vpc string) {
  355. template.Resources["CloudMap"] = &cloudmap.PrivateDnsNamespace{
  356. Description: fmt.Sprintf("Service Map for Docker Compose project %s", project.Name),
  357. Name: fmt.Sprintf("%s.local", project.Name),
  358. Vpc: vpc,
  359. }
  360. }
  361. func (b *ecsAPIService) createPolicies(project *types.Project, service types.ServiceConfig) []iam.Role_Policy {
  362. var arns []string
  363. if value, ok := service.Extensions[extensionPullCredentials]; ok {
  364. arns = append(arns, value.(string))
  365. }
  366. for _, secret := range service.Secrets {
  367. arns = append(arns, project.Secrets[secret.Source].Name)
  368. }
  369. if len(arns) > 0 {
  370. return []iam.Role_Policy{
  371. {
  372. PolicyDocument: &PolicyDocument{
  373. Statement: []PolicyStatement{
  374. {
  375. Effect: "Allow",
  376. Action: []string{actionGetSecretValue, actionGetParameters, actionDecrypt},
  377. Resource: arns,
  378. },
  379. },
  380. },
  381. PolicyName: fmt.Sprintf("%sGrantAccessToSecrets", service.Name),
  382. },
  383. }
  384. }
  385. return nil
  386. }
  387. func networkResourceName(network string) string {
  388. return fmt.Sprintf("%sNetwork", normalizeResourceName(network))
  389. }
  390. func serviceResourceName(service string) string {
  391. return fmt.Sprintf("%sService", normalizeResourceName(service))
  392. }
  393. func normalizeResourceName(s string) string {
  394. return strings.Title(regexp.MustCompile("[^a-zA-Z0-9]+").ReplaceAllString(s, ""))
  395. }