awsResources.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  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. r.vpc, r.subnets, err = b.parseVPCExtension(ctx, project)
  111. if err != nil {
  112. return r, err
  113. }
  114. r.loadBalancer, r.loadBalancerType, err = b.parseLoadBalancerExtension(ctx, project)
  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) (string, []awsResource, 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. err = b.aws.CheckVPC(ctx, vpc)
  155. if err != nil {
  156. return "", nil, err
  157. }
  158. } else {
  159. defaultVPC, err := b.aws.GetDefaultVPC(ctx)
  160. if err != nil {
  161. return "", nil, err
  162. }
  163. vpc = defaultVPC
  164. }
  165. subNets, err := b.aws.GetSubNets(ctx, vpc)
  166. if err != nil {
  167. return "", nil, err
  168. }
  169. var publicSubNets []awsResource
  170. for _, subNet := range subNets {
  171. isPublic, err := b.aws.IsPublicSubnet(ctx, vpc, subNet.ID())
  172. if err != nil {
  173. return "", nil, err
  174. }
  175. if isPublic {
  176. publicSubNets = append(publicSubNets, subNet)
  177. }
  178. }
  179. if len(publicSubNets) < 2 {
  180. return "", nil, fmt.Errorf("VPC %s should have at least 2 associated public subnets in different availability zones", vpc)
  181. }
  182. return vpc, publicSubNets, nil
  183. }
  184. func (b *ecsAPIService) parseLoadBalancerExtension(ctx context.Context, project *types.Project) (awsResource, string, error) {
  185. if x, ok := project.Extensions[extensionLoadBalancer]; ok {
  186. nameOrArn := x.(string)
  187. loadBalancer, loadBalancerType, err := b.aws.ResolveLoadBalancer(ctx, nameOrArn)
  188. if err != nil {
  189. return nil, "", err
  190. }
  191. required := getRequiredLoadBalancerType(project)
  192. if loadBalancerType != required {
  193. return nil, "", fmt.Errorf("load balancer %q is of type %s, project require a %s", nameOrArn, loadBalancerType, required)
  194. }
  195. return loadBalancer, loadBalancerType, err
  196. }
  197. return nil, "", nil
  198. }
  199. func (b *ecsAPIService) parseExternalNetworks(ctx context.Context, project *types.Project) (map[string]string, error) {
  200. securityGroups := make(map[string]string, len(project.Networks))
  201. for name, net := range project.Networks {
  202. // FIXME remove this for G.A
  203. if x, ok := net.Extensions[extensionSecurityGroup]; ok {
  204. logrus.Warn("to use an existing security-group, use `network.external` and `network.name` in your compose file")
  205. logrus.Debugf("Security Group for network %q set by user to %q", net.Name, x)
  206. net.External.External = true
  207. net.Name = x.(string)
  208. project.Networks[name] = net
  209. }
  210. if !net.External.External {
  211. continue
  212. }
  213. exists, err := b.aws.SecurityGroupExists(ctx, net.Name)
  214. if err != nil {
  215. return nil, err
  216. }
  217. if !exists {
  218. return nil, errors.Wrapf(errdefs.ErrNotFound, "security group %q doesn't exist", net.Name)
  219. }
  220. securityGroups[name] = net.Name
  221. }
  222. return securityGroups, nil
  223. }
  224. func (b *ecsAPIService) parseExternalVolumes(ctx context.Context, project *types.Project) (map[string]awsResource, error) {
  225. filesystems := make(map[string]awsResource, len(project.Volumes))
  226. for name, vol := range project.Volumes {
  227. if vol.External.External {
  228. arn, err := b.aws.ResolveFileSystem(ctx, vol.Name)
  229. if err != nil {
  230. return nil, err
  231. }
  232. filesystems[name] = arn
  233. continue
  234. }
  235. logrus.Debugf("searching for existing filesystem as volume %q", name)
  236. tags := map[string]string{
  237. compose.ProjectTag: project.Name,
  238. compose.VolumeTag: name,
  239. }
  240. previous, err := b.aws.ListFileSystems(ctx, tags)
  241. if err != nil {
  242. return nil, err
  243. }
  244. if len(previous) > 1 {
  245. return nil, fmt.Errorf("multiple filesystems are tags as project=%q, volume=%q", project.Name, name)
  246. }
  247. if len(previous) == 1 {
  248. filesystems[name] = previous[0]
  249. }
  250. }
  251. return filesystems, nil
  252. }
  253. // ensureResources create required resources in template if not yet defined
  254. func (b *ecsAPIService) ensureResources(resources *awsResources, project *types.Project, template *cloudformation.Template) error {
  255. b.ensureCluster(resources, project, template)
  256. b.ensureNetworks(resources, project, template)
  257. err := b.ensureVolumes(resources, project, template)
  258. if err != nil {
  259. return err
  260. }
  261. b.ensureLoadBalancer(resources, project, template)
  262. return nil
  263. }
  264. func (b *ecsAPIService) ensureCluster(r *awsResources, project *types.Project, template *cloudformation.Template) {
  265. if r.cluster != nil {
  266. return
  267. }
  268. template.Resources["Cluster"] = &ecs.Cluster{
  269. ClusterName: project.Name,
  270. Tags: projectTags(project),
  271. }
  272. r.cluster = cloudformationResource{logicalName: "Cluster"}
  273. }
  274. func (b *ecsAPIService) ensureNetworks(r *awsResources, project *types.Project, template *cloudformation.Template) {
  275. if r.securityGroups == nil {
  276. r.securityGroups = make(map[string]string, len(project.Networks))
  277. }
  278. for name, net := range project.Networks {
  279. if _, ok := r.securityGroups[name]; ok {
  280. continue
  281. }
  282. securityGroup := networkResourceName(name)
  283. template.Resources[securityGroup] = &ec2.SecurityGroup{
  284. GroupDescription: fmt.Sprintf("%s Security Group for %s network", project.Name, name),
  285. VpcId: r.vpc,
  286. Tags: networkTags(project, net),
  287. }
  288. ingress := securityGroup + "Ingress"
  289. template.Resources[ingress] = &ec2.SecurityGroupIngress{
  290. Description: fmt.Sprintf("Allow communication within network %s", name),
  291. IpProtocol: allProtocols,
  292. GroupId: cloudformation.Ref(securityGroup),
  293. SourceSecurityGroupId: cloudformation.Ref(securityGroup),
  294. }
  295. r.securityGroups[name] = cloudformation.Ref(securityGroup)
  296. }
  297. }
  298. func (b *ecsAPIService) ensureVolumes(r *awsResources, project *types.Project, template *cloudformation.Template) error {
  299. for name, volume := range project.Volumes {
  300. if _, ok := r.filesystems[name]; ok {
  301. continue
  302. }
  303. var backupPolicy *efs.FileSystem_BackupPolicy
  304. if backup, ok := volume.DriverOpts["backup_policy"]; ok {
  305. backupPolicy = &efs.FileSystem_BackupPolicy{
  306. Status: backup,
  307. }
  308. }
  309. var lifecyclePolicies []efs.FileSystem_LifecyclePolicy
  310. if policy, ok := volume.DriverOpts["lifecycle_policy"]; ok {
  311. lifecyclePolicies = append(lifecyclePolicies, efs.FileSystem_LifecyclePolicy{
  312. TransitionToIA: strings.TrimSpace(policy),
  313. })
  314. }
  315. var provisionedThroughputInMibps float64
  316. if t, ok := volume.DriverOpts["provisioned_throughput"]; ok {
  317. v, err := strconv.ParseFloat(t, 64)
  318. if err != nil {
  319. return err
  320. }
  321. provisionedThroughputInMibps = v
  322. }
  323. var performanceMode = volume.DriverOpts["performance_mode"]
  324. var throughputMode = volume.DriverOpts["throughput_mode"]
  325. var kmsKeyID = volume.DriverOpts["kms_key_id"]
  326. n := volumeResourceName(name)
  327. template.Resources[n] = &efs.FileSystem{
  328. BackupPolicy: backupPolicy,
  329. Encrypted: true,
  330. FileSystemPolicy: nil,
  331. FileSystemTags: []efs.FileSystem_ElasticFileSystemTag{
  332. {
  333. Key: compose.ProjectTag,
  334. Value: project.Name,
  335. },
  336. {
  337. Key: compose.VolumeTag,
  338. Value: name,
  339. },
  340. {
  341. Key: "Name",
  342. Value: fmt.Sprintf("%s_%s", project.Name, name),
  343. },
  344. },
  345. KmsKeyId: kmsKeyID,
  346. LifecyclePolicies: lifecyclePolicies,
  347. PerformanceMode: performanceMode,
  348. ProvisionedThroughputInMibps: provisionedThroughputInMibps,
  349. ThroughputMode: throughputMode,
  350. AWSCloudFormationDeletionPolicy: "Retain",
  351. }
  352. r.filesystems[name] = cloudformationResource{logicalName: n}
  353. }
  354. return nil
  355. }
  356. func (b *ecsAPIService) ensureLoadBalancer(r *awsResources, project *types.Project, template *cloudformation.Template) {
  357. if r.loadBalancer != nil {
  358. return
  359. }
  360. if allServices(project.Services, func(it types.ServiceConfig) bool {
  361. return len(it.Ports) == 0
  362. }) {
  363. logrus.Debug("Application does not expose any public port, so no need for a LoadBalancer")
  364. return
  365. }
  366. balancerType := getRequiredLoadBalancerType(project)
  367. var securityGroups []string
  368. if balancerType == elbv2.LoadBalancerTypeEnumApplication {
  369. // see https://docs.aws.amazon.com/elasticloadbalancing/latest/network/target-group-register-targets.html#target-security-groups
  370. // Network Load Balancers do not have associated security groups
  371. securityGroups = r.getLoadBalancerSecurityGroups(project)
  372. }
  373. var loadBalancerAttributes []elasticloadbalancingv2.LoadBalancer_LoadBalancerAttribute
  374. if balancerType == elbv2.LoadBalancerTypeEnumNetwork {
  375. loadBalancerAttributes = append(
  376. loadBalancerAttributes,
  377. elasticloadbalancingv2.LoadBalancer_LoadBalancerAttribute{
  378. Key: "load_balancing.cross_zone.enabled",
  379. Value: "true",
  380. })
  381. }
  382. template.Resources["LoadBalancer"] = &elasticloadbalancingv2.LoadBalancer{
  383. Scheme: elbv2.LoadBalancerSchemeEnumInternetFacing,
  384. SecurityGroups: securityGroups,
  385. Subnets: r.subnetsIDs(),
  386. Tags: projectTags(project),
  387. Type: balancerType,
  388. LoadBalancerAttributes: loadBalancerAttributes,
  389. }
  390. r.loadBalancer = cloudformationARNResource{
  391. logicalName: "LoadBalancer",
  392. nameProperty: "LoadBalancerName",
  393. }
  394. r.loadBalancerType = balancerType
  395. }
  396. func (r *awsResources) getLoadBalancerSecurityGroups(project *types.Project) []string {
  397. securityGroups := []string{}
  398. for name, network := range project.Networks {
  399. if !network.Internal {
  400. securityGroups = append(securityGroups, r.securityGroups[name])
  401. }
  402. }
  403. return securityGroups
  404. }
  405. func getRequiredLoadBalancerType(project *types.Project) string {
  406. loadBalancerType := elbv2.LoadBalancerTypeEnumNetwork
  407. if allServices(project.Services, func(it types.ServiceConfig) bool {
  408. return allPorts(it.Ports, portIsHTTP)
  409. }) {
  410. loadBalancerType = elbv2.LoadBalancerTypeEnumApplication
  411. }
  412. return loadBalancerType
  413. }
  414. func portIsHTTP(it types.ServicePortConfig) bool {
  415. if v, ok := it.Extensions[extensionProtocol]; ok {
  416. protocol := v.(string)
  417. return protocol == "http" || protocol == "https"
  418. }
  419. return it.Target == 80 || it.Target == 443
  420. }
  421. // predicate[types.ServiceConfig]
  422. type servicePredicate func(it types.ServiceConfig) bool
  423. // all[types.ServiceConfig]
  424. func allServices(services types.Services, p servicePredicate) bool {
  425. for _, s := range services {
  426. if !p(s) {
  427. return false
  428. }
  429. }
  430. return true
  431. }
  432. // predicate[types.ServicePortConfig]
  433. type portPredicate func(it types.ServicePortConfig) bool
  434. // all[types.ServicePortConfig]
  435. func allPorts(ports []types.ServicePortConfig, p portPredicate) bool {
  436. for _, s := range ports {
  437. if !p(s) {
  438. return false
  439. }
  440. }
  441. return true
  442. }