awsResources.go 15 KB

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