cloudformation.go 15 KB

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