awsResources.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  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. "strconv"
  18. "strings"
  19. "github.com/docker/compose-cli/api/compose"
  20. "github.com/docker/compose-cli/errdefs"
  21. "github.com/aws/aws-sdk-go/aws/arn"
  22. "github.com/aws/aws-sdk-go/service/elbv2"
  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/efs"
  27. "github.com/awslabs/goformation/v4/cloudformation/elasticloadbalancingv2"
  28. "github.com/compose-spec/compose-go/types"
  29. "github.com/pkg/errors"
  30. "github.com/sirupsen/logrus"
  31. )
  32. // awsResources hold the AWS component being used or created to support services definition
  33. type awsResources struct {
  34. vpc string // shouldn't this also be an awsResource ?
  35. subnets []awsResource
  36. cluster awsResource
  37. loadBalancer awsResource
  38. loadBalancerType string
  39. securityGroups map[string]string
  40. filesystems map[string]awsResource
  41. }
  42. func (r *awsResources) serviceSecurityGroups(service types.ServiceConfig) []string {
  43. var groups []string
  44. for net := range service.Networks {
  45. groups = append(groups, r.securityGroups[net])
  46. }
  47. return groups
  48. }
  49. func (r *awsResources) allSecurityGroups() []string {
  50. var securityGroups []string
  51. for _, r := range r.securityGroups {
  52. securityGroups = append(securityGroups, r)
  53. }
  54. return securityGroups
  55. }
  56. func (r *awsResources) subnetsIDs() []string {
  57. var ids []string
  58. for _, r := range r.subnets {
  59. ids = append(ids, r.ID())
  60. }
  61. return ids
  62. }
  63. // awsResource is abstract representation for any (existing or future) AWS resource that we can refer both by ID or full ARN
  64. type awsResource interface {
  65. ARN() string
  66. ID() string
  67. }
  68. // existingAWSResource hold references to an existing AWS component
  69. type existingAWSResource struct {
  70. arn string
  71. id string
  72. }
  73. func (r existingAWSResource) ARN() string {
  74. return r.arn
  75. }
  76. func (r existingAWSResource) ID() string {
  77. return r.id
  78. }
  79. // cloudformationResource hold references to a future AWS resource managed by CloudFormation
  80. // to be used by CloudFormation resources where Ref returns the Amazon Resource ID
  81. type cloudformationResource struct {
  82. logicalName string
  83. }
  84. func (r cloudformationResource) ARN() string {
  85. return cloudformation.GetAtt(r.logicalName, "Arn")
  86. }
  87. func (r cloudformationResource) ID() string {
  88. return cloudformation.Ref(r.logicalName)
  89. }
  90. // cloudformationARNResource hold references to a future AWS resource managed by CloudFormation
  91. // to be used by CloudFormation resources where Ref returns the Amazon Resource Name (ARN)
  92. type cloudformationARNResource struct {
  93. logicalName string
  94. nameProperty string
  95. }
  96. func (r cloudformationARNResource) ARN() string {
  97. return cloudformation.Ref(r.logicalName)
  98. }
  99. func (r cloudformationARNResource) ID() string {
  100. return cloudformation.GetAtt(r.logicalName, r.nameProperty)
  101. }
  102. // parse look into compose project for configured resource to use, and check they are valid
  103. func (b *ecsAPIService) parse(ctx context.Context, project *types.Project, template *cloudformation.Template) (awsResources, error) {
  104. r := awsResources{}
  105. var err error
  106. r.cluster, err = b.parseClusterExtension(ctx, project, template)
  107. if err != nil {
  108. return r, err
  109. }
  110. err = b.parseLoadBalancerExtension(ctx, project, &r)
  111. if err != nil {
  112. return r, err
  113. }
  114. err = b.parseVPCExtension(ctx, project, &r)
  115. if err != nil {
  116. return r, err
  117. }
  118. r.securityGroups, err = b.parseExternalNetworks(ctx, project)
  119. if err != nil {
  120. return r, err
  121. }
  122. r.filesystems, err = b.parseExternalVolumes(ctx, project)
  123. if err != nil {
  124. return r, err
  125. }
  126. return r, nil
  127. }
  128. func (b *ecsAPIService) parseClusterExtension(ctx context.Context, project *types.Project, template *cloudformation.Template) (awsResource, error) {
  129. if x, ok := project.Extensions[extensionCluster]; ok {
  130. nameOrArn := x.(string) // can be name _or_ ARN.
  131. cluster, err := b.aws.ResolveCluster(ctx, nameOrArn)
  132. if err != nil {
  133. return nil, err
  134. }
  135. if !ok {
  136. return nil, errors.Wrapf(errdefs.ErrNotFound, "cluster %q does not exist", cluster)
  137. }
  138. template.Metadata["Cluster"] = cluster.ARN()
  139. return cluster, nil
  140. }
  141. return nil, nil
  142. }
  143. func (b *ecsAPIService) parseVPCExtension(ctx context.Context, project *types.Project, r *awsResources) error {
  144. var vpc string
  145. if x, ok := project.Extensions[extensionVPC]; ok {
  146. vpc = x.(string)
  147. ARN, err := arn.Parse(vpc)
  148. if err == nil {
  149. // User has set an ARN, like the one Terraform shows as output, while we expect an ID
  150. id := ARN.Resource
  151. i := strings.LastIndex(id, "/")
  152. vpc = id[i+1:]
  153. }
  154. if r.vpc != "" {
  155. if r.vpc != vpc {
  156. return fmt.Errorf("load balancer set by %s is attached to VPC %s", extensionLoadBalancer, r.vpc)
  157. }
  158. return nil
  159. }
  160. err = b.aws.CheckVPC(ctx, vpc)
  161. if err != nil {
  162. return err
  163. }
  164. } else {
  165. if r.vpc != "" {
  166. return nil
  167. }
  168. defaultVPC, err := b.aws.GetDefaultVPC(ctx)
  169. if err != nil {
  170. return err
  171. }
  172. vpc = defaultVPC
  173. }
  174. subNets, err := b.aws.GetSubNets(ctx, vpc)
  175. if err != nil {
  176. return err
  177. }
  178. var publicSubNets []awsResource
  179. for _, subNet := range subNets {
  180. isPublic, err := b.aws.IsPublicSubnet(ctx, subNet.ID())
  181. if err != nil {
  182. return err
  183. }
  184. if isPublic {
  185. publicSubNets = append(publicSubNets, subNet)
  186. }
  187. }
  188. if len(publicSubNets) < 2 {
  189. return fmt.Errorf("VPC %s should have at least 2 associated public subnets in different availability zones", vpc)
  190. }
  191. r.vpc = vpc
  192. r.subnets = subNets
  193. return nil
  194. }
  195. func (b *ecsAPIService) parseLoadBalancerExtension(ctx context.Context, project *types.Project, r *awsResources) error {
  196. if x, ok := project.Extensions[extensionLoadBalancer]; ok {
  197. nameOrArn := x.(string)
  198. loadBalancer, loadBalancerType, vpc, subnets, err := b.aws.ResolveLoadBalancer(ctx, nameOrArn)
  199. if err != nil {
  200. return err
  201. }
  202. required := getRequiredLoadBalancerType(project)
  203. if loadBalancerType != required {
  204. return fmt.Errorf("load balancer %q is of type %s, project require a %s", nameOrArn, loadBalancerType, required)
  205. }
  206. r.loadBalancer = loadBalancer
  207. r.loadBalancerType = loadBalancerType
  208. r.vpc = vpc
  209. r.subnets = subnets
  210. return err
  211. }
  212. return nil
  213. }
  214. func (b *ecsAPIService) parseExternalNetworks(ctx context.Context, project *types.Project) (map[string]string, error) {
  215. securityGroups := make(map[string]string, len(project.Networks))
  216. for name, net := range project.Networks {
  217. // FIXME remove this for G.A
  218. if x, ok := net.Extensions[extensionSecurityGroup]; ok {
  219. logrus.Warn("to use an existing security-group, use `network.external` and `network.name` in your compose file")
  220. logrus.Debugf("Security Group for network %q set by user to %q", net.Name, x)
  221. net.External.External = true
  222. net.Name = x.(string)
  223. project.Networks[name] = net
  224. }
  225. if !net.External.External {
  226. continue
  227. }
  228. exists, err := b.aws.SecurityGroupExists(ctx, net.Name)
  229. if err != nil {
  230. return nil, err
  231. }
  232. if !exists {
  233. return nil, errors.Wrapf(errdefs.ErrNotFound, "security group %q doesn't exist", net.Name)
  234. }
  235. securityGroups[name] = net.Name
  236. }
  237. return securityGroups, nil
  238. }
  239. func (b *ecsAPIService) parseExternalVolumes(ctx context.Context, project *types.Project) (map[string]awsResource, error) {
  240. filesystems := make(map[string]awsResource, len(project.Volumes))
  241. for name, vol := range project.Volumes {
  242. if vol.External.External {
  243. arn, err := b.aws.ResolveFileSystem(ctx, vol.Name)
  244. if err != nil {
  245. return nil, err
  246. }
  247. filesystems[name] = arn
  248. continue
  249. }
  250. logrus.Debugf("searching for existing filesystem as volume %q", name)
  251. tags := map[string]string{
  252. compose.ProjectTag: project.Name,
  253. compose.VolumeTag: name,
  254. }
  255. previous, err := b.aws.ListFileSystems(ctx, tags)
  256. if err != nil {
  257. return nil, err
  258. }
  259. if len(previous) > 1 {
  260. return nil, fmt.Errorf("multiple filesystems are tags as project=%q, volume=%q", project.Name, name)
  261. }
  262. if len(previous) == 1 {
  263. filesystems[name] = previous[0]
  264. }
  265. }
  266. return filesystems, nil
  267. }
  268. // ensureResources create required resources in template if not yet defined
  269. func (b *ecsAPIService) ensureResources(resources *awsResources, project *types.Project, template *cloudformation.Template) error {
  270. b.ensureCluster(resources, project, template)
  271. b.ensureNetworks(resources, project, template)
  272. err := b.ensureVolumes(resources, project, template)
  273. if err != nil {
  274. return err
  275. }
  276. b.ensureLoadBalancer(resources, project, template)
  277. return nil
  278. }
  279. func (b *ecsAPIService) ensureCluster(r *awsResources, project *types.Project, template *cloudformation.Template) {
  280. if r.cluster != nil {
  281. return
  282. }
  283. template.Resources["Cluster"] = &ecs.Cluster{
  284. ClusterName: project.Name,
  285. Tags: projectTags(project),
  286. }
  287. r.cluster = cloudformationResource{logicalName: "Cluster"}
  288. }
  289. func (b *ecsAPIService) ensureNetworks(r *awsResources, project *types.Project, template *cloudformation.Template) {
  290. if r.securityGroups == nil {
  291. r.securityGroups = make(map[string]string, len(project.Networks))
  292. }
  293. for name, net := range project.Networks {
  294. if _, ok := r.securityGroups[name]; ok {
  295. continue
  296. }
  297. securityGroup := networkResourceName(name)
  298. template.Resources[securityGroup] = &ec2.SecurityGroup{
  299. GroupDescription: fmt.Sprintf("%s Security Group for %s network", project.Name, name),
  300. VpcId: r.vpc,
  301. Tags: networkTags(project, net),
  302. }
  303. ingress := securityGroup + "Ingress"
  304. template.Resources[ingress] = &ec2.SecurityGroupIngress{
  305. Description: fmt.Sprintf("Allow communication within network %s", name),
  306. IpProtocol: allProtocols,
  307. GroupId: cloudformation.Ref(securityGroup),
  308. SourceSecurityGroupId: cloudformation.Ref(securityGroup),
  309. }
  310. r.securityGroups[name] = cloudformation.Ref(securityGroup)
  311. }
  312. }
  313. func (b *ecsAPIService) ensureVolumes(r *awsResources, project *types.Project, template *cloudformation.Template) error {
  314. for name, volume := range project.Volumes {
  315. if _, ok := r.filesystems[name]; ok {
  316. continue
  317. }
  318. var backupPolicy *efs.FileSystem_BackupPolicy
  319. if backup, ok := volume.DriverOpts["backup_policy"]; ok {
  320. backupPolicy = &efs.FileSystem_BackupPolicy{
  321. Status: backup,
  322. }
  323. }
  324. var lifecyclePolicies []efs.FileSystem_LifecyclePolicy
  325. if policy, ok := volume.DriverOpts["lifecycle_policy"]; ok {
  326. lifecyclePolicies = append(lifecyclePolicies, efs.FileSystem_LifecyclePolicy{
  327. TransitionToIA: strings.TrimSpace(policy),
  328. })
  329. }
  330. var provisionedThroughputInMibps float64
  331. if t, ok := volume.DriverOpts["provisioned_throughput"]; ok {
  332. v, err := strconv.ParseFloat(t, 64)
  333. if err != nil {
  334. return err
  335. }
  336. provisionedThroughputInMibps = v
  337. }
  338. var performanceMode = volume.DriverOpts["performance_mode"]
  339. var throughputMode = volume.DriverOpts["throughput_mode"]
  340. var kmsKeyID = volume.DriverOpts["kms_key_id"]
  341. n := volumeResourceName(name)
  342. template.Resources[n] = &efs.FileSystem{
  343. BackupPolicy: backupPolicy,
  344. Encrypted: true,
  345. FileSystemPolicy: nil,
  346. FileSystemTags: []efs.FileSystem_ElasticFileSystemTag{
  347. {
  348. Key: compose.ProjectTag,
  349. Value: project.Name,
  350. },
  351. {
  352. Key: compose.VolumeTag,
  353. Value: name,
  354. },
  355. {
  356. Key: "Name",
  357. Value: fmt.Sprintf("%s_%s", project.Name, name),
  358. },
  359. },
  360. KmsKeyId: kmsKeyID,
  361. LifecyclePolicies: lifecyclePolicies,
  362. PerformanceMode: performanceMode,
  363. ProvisionedThroughputInMibps: provisionedThroughputInMibps,
  364. ThroughputMode: throughputMode,
  365. AWSCloudFormationDeletionPolicy: "Retain",
  366. }
  367. r.filesystems[name] = cloudformationResource{logicalName: n}
  368. }
  369. return nil
  370. }
  371. func (b *ecsAPIService) ensureLoadBalancer(r *awsResources, project *types.Project, template *cloudformation.Template) {
  372. if r.loadBalancer != nil {
  373. return
  374. }
  375. if allServices(project.Services, func(it types.ServiceConfig) bool {
  376. return len(it.Ports) == 0
  377. }) {
  378. logrus.Debug("Application does not expose any public port, so no need for a LoadBalancer")
  379. return
  380. }
  381. balancerType := getRequiredLoadBalancerType(project)
  382. var securityGroups []string
  383. if balancerType == elbv2.LoadBalancerTypeEnumApplication {
  384. // see https://docs.aws.amazon.com/elasticloadbalancing/latest/network/target-group-register-targets.html#target-security-groups
  385. // Network Load Balancers do not have associated security groups
  386. securityGroups = r.getLoadBalancerSecurityGroups(project)
  387. }
  388. var loadBalancerAttributes []elasticloadbalancingv2.LoadBalancer_LoadBalancerAttribute
  389. if balancerType == elbv2.LoadBalancerTypeEnumNetwork {
  390. loadBalancerAttributes = append(
  391. loadBalancerAttributes,
  392. elasticloadbalancingv2.LoadBalancer_LoadBalancerAttribute{
  393. Key: "load_balancing.cross_zone.enabled",
  394. Value: "true",
  395. })
  396. }
  397. template.Resources["LoadBalancer"] = &elasticloadbalancingv2.LoadBalancer{
  398. Scheme: elbv2.LoadBalancerSchemeEnumInternetFacing,
  399. SecurityGroups: securityGroups,
  400. Subnets: r.subnetsIDs(),
  401. Tags: projectTags(project),
  402. Type: balancerType,
  403. LoadBalancerAttributes: loadBalancerAttributes,
  404. }
  405. r.loadBalancer = cloudformationARNResource{
  406. logicalName: "LoadBalancer",
  407. nameProperty: "LoadBalancerName",
  408. }
  409. r.loadBalancerType = balancerType
  410. }
  411. func (r *awsResources) getLoadBalancerSecurityGroups(project *types.Project) []string {
  412. securityGroups := []string{}
  413. for name, network := range project.Networks {
  414. if !network.Internal {
  415. securityGroups = append(securityGroups, r.securityGroups[name])
  416. }
  417. }
  418. return securityGroups
  419. }
  420. func getRequiredLoadBalancerType(project *types.Project) string {
  421. loadBalancerType := elbv2.LoadBalancerTypeEnumNetwork
  422. if allServices(project.Services, func(it types.ServiceConfig) bool {
  423. return allPorts(it.Ports, portIsHTTP)
  424. }) {
  425. loadBalancerType = elbv2.LoadBalancerTypeEnumApplication
  426. }
  427. return loadBalancerType
  428. }
  429. func portIsHTTP(it types.ServicePortConfig) bool {
  430. if v, ok := it.Extensions[extensionProtocol]; ok {
  431. protocol := v.(string)
  432. return protocol == "http" || protocol == "https"
  433. }
  434. return it.Target == 80 || it.Target == 443
  435. }
  436. // predicate[types.ServiceConfig]
  437. type servicePredicate func(it types.ServiceConfig) bool
  438. // all[types.ServiceConfig]
  439. func allServices(services types.Services, p servicePredicate) bool {
  440. for _, s := range services {
  441. if !p(s) {
  442. return false
  443. }
  444. }
  445. return true
  446. }
  447. // predicate[types.ServicePortConfig]
  448. type portPredicate func(it types.ServicePortConfig) bool
  449. // all[types.ServicePortConfig]
  450. func allPorts(ports []types.ServicePortConfig, p portPredicate) bool {
  451. for _, s := range ports {
  452. if !p(s) {
  453. return false
  454. }
  455. }
  456. return true
  457. }