cloud-mu 3.2.0 → 3.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +1 -1
  3. data/bin/mu-adopt +12 -1
  4. data/bin/mu-load-config.rb +2 -1
  5. data/bin/mu-run-tests +14 -2
  6. data/cloud-mu.gemspec +3 -3
  7. data/modules/mu.rb +2 -2
  8. data/modules/mu/adoption.rb +5 -5
  9. data/modules/mu/cleanup.rb +47 -25
  10. data/modules/mu/cloud.rb +29 -1
  11. data/modules/mu/cloud/dnszone.rb +0 -2
  12. data/modules/mu/cloud/resource_base.rb +9 -3
  13. data/modules/mu/cloud/wrappers.rb +4 -0
  14. data/modules/mu/config.rb +1 -1
  15. data/modules/mu/config/bucket.rb +31 -2
  16. data/modules/mu/config/cache_cluster.rb +1 -1
  17. data/modules/mu/config/cdn.rb +100 -0
  18. data/modules/mu/config/container_cluster.rb +1 -1
  19. data/modules/mu/config/database.rb +1 -1
  20. data/modules/mu/config/dnszone.rb +4 -3
  21. data/modules/mu/config/endpoint.rb +1 -0
  22. data/modules/mu/config/function.rb +16 -7
  23. data/modules/mu/config/job.rb +89 -0
  24. data/modules/mu/config/notifier.rb +7 -18
  25. data/modules/mu/config/ref.rb +53 -7
  26. data/modules/mu/config/server.rb +1 -1
  27. data/modules/mu/config/vpc.rb +1 -0
  28. data/modules/mu/defaults/AWS.yaml +26 -26
  29. data/modules/mu/deploy.rb +13 -0
  30. data/modules/mu/master.rb +21 -0
  31. data/modules/mu/mommacat.rb +1 -0
  32. data/modules/mu/mommacat/daemon.rb +13 -7
  33. data/modules/mu/providers/aws.rb +115 -16
  34. data/modules/mu/providers/aws/alarm.rb +2 -2
  35. data/modules/mu/providers/aws/bucket.rb +274 -40
  36. data/modules/mu/providers/aws/cache_cluster.rb +4 -4
  37. data/modules/mu/providers/aws/cdn.rb +782 -0
  38. data/modules/mu/providers/aws/collection.rb +2 -2
  39. data/modules/mu/providers/aws/container_cluster.rb +57 -37
  40. data/modules/mu/providers/aws/database.rb +11 -11
  41. data/modules/mu/providers/aws/dnszone.rb +24 -7
  42. data/modules/mu/providers/aws/endpoint.rb +535 -50
  43. data/modules/mu/providers/aws/firewall_rule.rb +6 -3
  44. data/modules/mu/providers/aws/folder.rb +1 -1
  45. data/modules/mu/providers/aws/function.rb +288 -125
  46. data/modules/mu/providers/aws/group.rb +9 -7
  47. data/modules/mu/providers/aws/habitat.rb +2 -2
  48. data/modules/mu/providers/aws/job.rb +466 -0
  49. data/modules/mu/providers/aws/loadbalancer.rb +9 -8
  50. data/modules/mu/providers/aws/log.rb +3 -3
  51. data/modules/mu/providers/aws/msg_queue.rb +12 -3
  52. data/modules/mu/providers/aws/nosqldb.rb +96 -5
  53. data/modules/mu/providers/aws/notifier.rb +135 -63
  54. data/modules/mu/providers/aws/role.rb +51 -37
  55. data/modules/mu/providers/aws/search_domain.rb +165 -29
  56. data/modules/mu/providers/aws/server.rb +12 -9
  57. data/modules/mu/providers/aws/server_pool.rb +26 -13
  58. data/modules/mu/providers/aws/storage_pool.rb +2 -2
  59. data/modules/mu/providers/aws/user.rb +4 -4
  60. data/modules/mu/providers/aws/userdata/linux.erb +5 -4
  61. data/modules/mu/providers/aws/vpc.rb +3 -3
  62. data/modules/mu/providers/azure/server.rb +2 -1
  63. data/modules/mu/providers/google.rb +1 -0
  64. data/modules/mu/providers/google/bucket.rb +1 -1
  65. data/modules/mu/providers/google/container_cluster.rb +1 -1
  66. data/modules/mu/providers/google/database.rb +1 -1
  67. data/modules/mu/providers/google/firewall_rule.rb +1 -1
  68. data/modules/mu/providers/google/folder.rb +1 -1
  69. data/modules/mu/providers/google/function.rb +1 -1
  70. data/modules/mu/providers/google/group.rb +1 -1
  71. data/modules/mu/providers/google/habitat.rb +1 -1
  72. data/modules/mu/providers/google/loadbalancer.rb +1 -1
  73. data/modules/mu/providers/google/role.rb +4 -2
  74. data/modules/mu/providers/google/server.rb +1 -1
  75. data/modules/mu/providers/google/server_pool.rb +1 -1
  76. data/modules/mu/providers/google/user.rb +1 -1
  77. data/modules/mu/providers/google/vpc.rb +1 -1
  78. data/modules/tests/aws-jobs-functions.yaml +46 -0
  79. data/modules/tests/centos6.yaml +4 -0
  80. data/modules/tests/centos7.yaml +4 -0
  81. data/modules/tests/ecs.yaml +2 -2
  82. data/modules/tests/eks.yaml +1 -1
  83. data/modules/tests/functions/node-function/lambda_function.js +10 -0
  84. data/modules/tests/functions/python-function/lambda_function.py +12 -0
  85. data/modules/tests/microservice_app.yaml +288 -0
  86. data/modules/tests/rds.yaml +5 -5
  87. data/modules/tests/regrooms/rds.yaml +5 -5
  88. data/modules/tests/server-with-scrub-muisms.yaml +1 -1
  89. data/modules/tests/super_complex_bok.yml +2 -2
  90. data/modules/tests/super_simple_bok.yml +2 -2
  91. metadata +12 -4
@@ -242,7 +242,7 @@ module MU
242
242
  # @param region [String]: The cloud provider region
243
243
  # @param wait [Boolean]: Block on the removal of this stack; AWS deletion will continue in the background otherwise if false.
244
244
  # @return [void]
245
- def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, wait: false, credentials: nil, flags: {})
245
+ def self.cleanup(noop: false, deploy_id: MU.deploy_id, ignoremaster: false, region: MU.curRegion, wait: false, credentials: nil, flags: {})
246
246
  MU.log "AWS::Collection.cleanup: need to support flags['known']", MU::DEBUG, details: flags
247
247
  MU.log "Placeholder: AWS Collection artifacts do not support tags, so ignoremaster cleanup flag has no effect", MU::DEBUG, details: ignoremaster
248
248
 
@@ -251,7 +251,7 @@ module MU
251
251
  resp.stacks.each { |stack|
252
252
  ok = false
253
253
  stack.tags.each { |tag|
254
- ok = true if (tag.key == "MU-ID") and tag.value == MU.deploy_id
254
+ ok = true if (tag.key == "MU-ID") and tag.value == deploy_id
255
255
  }
256
256
  if ok
257
257
  MU.log "Deleting CloudFormation stack #{stack.stack_name})"
@@ -287,6 +287,7 @@ MU.log c.name, MU::NOTICE, details: t
287
287
  # @return [OpenStruct]
288
288
  def cloud_desc(use_cache: true)
289
289
  return @cloud_desc_cache if @cloud_desc_cache and use_cache
290
+ return nil if !@cloud_id
290
291
  @cloud_desc_cache = if @config['flavor'] == "EKS" or
291
292
  (@config['flavor'] == "Fargate" and !@config['containers'])
292
293
  resp = MU::Cloud::AWS.eks(region: @config['region'], credentials: @config['credentials']).describe_cluster(
@@ -326,7 +327,7 @@ MU.log c.name, MU::NOTICE, details: t
326
327
  end
327
328
 
328
329
  @@eks_versions = {}
329
- @@eks_version_semaphore = Mutex.new
330
+ @@eks_version_semaphores = {}
330
331
  # Use the AWS SSM API to fetch the current version of the Amazon Linux
331
332
  # ECS-optimized AMI, so we can use it as a default AMI for ECS deploys.
332
333
  # @param flavor [String]: ECS or EKS
@@ -339,24 +340,22 @@ MU.log c.name, MU::NOTICE, details: t
339
340
  names: ["/aws/service/#{flavor.downcase}/optimized-ami/amazon-linux/recommended"]
340
341
  )
341
342
  else
342
- @@eks_version_semaphore.synchronize {
343
+ @@eks_version_semaphores[region] ||= Mutex.new
344
+
345
+ @@eks_version_semaphores[region].synchronize {
343
346
  if !@@eks_versions[region]
344
347
  @@eks_versions[region] ||= []
345
348
  versions = {}
346
- resp = nil
347
- next_token = nil
348
- begin
349
- resp = MU::Cloud::AWS.ssm(region: region).get_parameters_by_path(
350
- path: "/aws/service/#{flavor.downcase}",
351
- recursive: true,
352
- next_token: next_token
353
- )
354
- resp.parameters.each { |p|
355
- p.name.match(/\/aws\/service\/eks\/optimized-ami\/([^\/]+?)\//)
356
- versions[Regexp.last_match[1]] = true
357
- }
358
- next_token = resp.next_token
359
- end while !next_token.nil?
349
+ resp = MU::Cloud::AWS.ssm(region: region).get_parameters_by_path(
350
+ path: "/aws/service/#{flavor.downcase}/optimized-ami",
351
+ recursive: true,
352
+ max_results: 10 # as high as it goes, ugh
353
+ )
354
+
355
+ resp.parameters.each { |p|
356
+ p.name.match(/\/aws\/service\/eks\/optimized-ami\/([^\/]+?)\//)
357
+ versions[Regexp.last_match[1]] = true
358
+ }
360
359
  @@eks_versions[region] = versions.keys.sort { |a, b| MU.version_sort(a, b) }
361
360
  end
362
361
  }
@@ -376,16 +375,31 @@ MU.log c.name, MU::NOTICE, details: t
376
375
  nil
377
376
  end
378
377
 
378
+ @@supported_eks_region_cache = []
379
+ @@eks_region_semaphore = Mutex.new
380
+
379
381
  # Return the list of regions where we know EKS is supported.
380
- def self.EKSRegions(credentials = nil, region: nil)
381
- eks_regions = []
382
- check_regions = region ? [region] : MU::Cloud::AWS.listRegions(credentials: credentials)
383
- check_regions.each { |r|
384
- ami = getStandardImage("EKS", r)
385
- eks_regions << r if ami
386
- }
382
+ def self.EKSRegions(credentials = nil)
383
+ @@eks_region_semaphore.synchronize {
384
+ if @@supported_eks_region_cache and !@@supported_eks_region_cache.empty?
385
+ return @@supported_eks_region_cache
386
+ end
387
+ start = Time.now
388
+ # the SSM API is painfully slow for large result sets, so thread
389
+ # these and do them in parallel
390
+ @@supported_eks_region_cache = []
391
+ region_threads = []
392
+ MU::Cloud::AWS.listRegions(credentials: credentials).each { |region|
393
+ region_threads << Thread.new(region) { |r|
394
+ r_start = Time.now
395
+ ami = getStandardImage("EKS", r)
396
+ @@supported_eks_region_cache << r if ami
397
+ }
398
+ }
399
+ region_threads.each { |t| t.join }
387
400
 
388
- eks_regions
401
+ @@supported_eks_region_cache
402
+ }
389
403
  end
390
404
 
391
405
  # Does this resource type exist as a global (cloud-wide) artifact, or
@@ -406,30 +420,32 @@ MU.log c.name, MU::NOTICE, details: t
406
420
  # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server
407
421
  # @param region [String]: The cloud provider region
408
422
  # @return [void]
409
- def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {})
423
+ def self.cleanup(noop: false, deploy_id: MU.deploy_id, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {})
410
424
  MU.log "AWS::ContainerCluster.cleanup: need to support flags['known']", MU::DEBUG, details: flags
411
425
  MU.log "Placeholder: AWS ContainerCluster artifacts do not support tags, so ignoremaster cleanup flag has no effect", MU::DEBUG, details: ignoremaster
412
426
 
413
- purge_ecs_clusters(noop: noop, region: region, credentials: credentials)
427
+ purge_ecs_clusters(noop: noop, region: region, credentials: credentials, deploy_id: deploy_id)
414
428
 
415
- purge_eks_clusters(noop: noop, region: region, credentials: credentials)
429
+ purge_eks_clusters(noop: noop, region: region, credentials: credentials, deploy_id: deploy_id)
416
430
 
417
431
  end
418
432
 
419
- def self.purge_eks_clusters(noop: false, region: MU.curRegion, credentials: nil)
420
- return if !MU::Cloud::AWS::ContainerCluster.EKSRegions(credentials, region: region).include?(region)
433
+ def self.purge_eks_clusters(noop: false, region: MU.curRegion, credentials: nil, deploy_id: MU.deploy_id)
421
434
  resp = begin
422
435
  MU::Cloud::AWS.eks(credentials: credentials, region: region).list_clusters
423
436
  rescue Aws::EKS::Errors::AccessDeniedException
424
437
  # EKS isn't actually live in this region, even though SSM lists
425
438
  # base images for it
439
+ if @@supported_eks_region_cache
440
+ @@supported_eks_region_cache.delete(region)
441
+ end
426
442
  return
427
443
  end
428
444
 
429
445
  return if !resp or !resp.clusters
430
446
 
431
447
  resp.clusters.each { |cluster|
432
- if cluster.match(/^#{MU.deploy_id}-/)
448
+ if cluster.match(/^#{deploy_id}-/)
433
449
 
434
450
  desc = MU::Cloud::AWS.eks(credentials: credentials, region: region).describe_cluster(
435
451
  name: cluster
@@ -473,13 +489,14 @@ MU.log c.name, MU::NOTICE, details: t
473
489
  end
474
490
  private_class_method :purge_eks_clusters
475
491
 
476
- def self.purge_ecs_clusters(noop: false, region: MU.curRegion, credentials: nil)
492
+ def self.purge_ecs_clusters(noop: false, region: MU.curRegion, credentials: nil, deploy_id: MU.deploy_id)
493
+ start = Time.now
477
494
  resp = MU::Cloud::AWS.ecs(credentials: credentials, region: region).list_clusters
478
495
 
479
496
  return if !resp or !resp.cluster_arns or resp.cluster_arns.empty?
480
497
 
481
498
  resp.cluster_arns.each { |arn|
482
- if arn.match(/:cluster\/(#{MU.deploy_id}[^:]+)$/)
499
+ if arn.match(/:cluster\/(#{deploy_id}[^:]+)$/)
483
500
  cluster = Regexp.last_match[1]
484
501
 
485
502
  svc_resp = MU::Cloud::AWS.ecs(region: region, credentials: credentials).list_services(
@@ -525,7 +542,7 @@ MU.log c.name, MU::NOTICE, details: t
525
542
  }
526
543
 
527
544
  tasks = MU::Cloud::AWS.ecs(region: region, credentials: credentials).list_task_definitions(
528
- family_prefix: MU.deploy_id
545
+ family_prefix: deploy_id
529
546
  )
530
547
 
531
548
  if tasks and tasks.task_definition_arns
@@ -1221,12 +1238,12 @@ start = Time.now
1221
1238
 
1222
1239
  cluster["flavor"] = "EKS" if cluster["flavor"].match(/^Kubernetes$/i)
1223
1240
 
1224
- if cluster["flavor"] == "ECS" and cluster["kubernetes"] and !MU::Cloud::AWS.isGovCloud?(cluster["region"]) and !cluster["containers"] and MU::Cloud::AWS::ContainerCluster.EKSRegions(cluster['credentials'], region: cluster['region']).include?(cluster['region'])
1241
+ if cluster["flavor"] == "ECS" and cluster["kubernetes"] and !MU::Cloud::AWS.isGovCloud?(cluster["region"]) and !cluster["containers"] and MU::Cloud::AWS::ContainerCluster.EKSRegions(cluster['credentials']).include?(cluster['region'])
1225
1242
  cluster["flavor"] = "EKS"
1226
1243
  MU.log "Setting flavor of ContainerCluster '#{cluster['name']}' to EKS ('kubernetes' stanza was specified)", MU::NOTICE
1227
1244
  end
1228
1245
 
1229
- if cluster["flavor"] == "EKS" and !MU::Cloud::AWS::ContainerCluster.EKSRegions(cluster['credentials'], region: cluster['region']).include?(cluster['region'])
1246
+ if cluster["flavor"] == "EKS" and !MU::Cloud::AWS::ContainerCluster.EKSRegions(cluster['credentials']).include?(cluster['region'])
1230
1247
  MU.log "EKS is only available in some regions", MU::ERR, details: MU::Cloud::AWS::ContainerCluster.EKSRegions
1231
1248
  ok = false
1232
1249
  end
@@ -1484,7 +1501,8 @@ start = Time.now
1484
1501
  worker_pool[k] = cluster[k]
1485
1502
  end
1486
1503
  }
1487
-
1504
+ else
1505
+ worker_pool["groom"] = false # don't meddle with ECS workers unnecessarily
1488
1506
  end
1489
1507
 
1490
1508
  configurator.insertKitten(worker_pool, "server_pools")
@@ -1731,7 +1749,7 @@ start = Time.now
1731
1749
  @deploy.findLitterMate(type: "server_pools", name: @config["name"]+"workers")
1732
1750
  end
1733
1751
  serverpool.listNodes.each { |mynode|
1734
- resources = resource_lookup[node.cloud_desc.instance_type]
1752
+ resources = resource_lookup[mynode.cloud_desc.instance_type]
1735
1753
  threads << Thread.new(mynode) { |node|
1736
1754
  ident_doc = nil
1737
1755
  ident_doc_sig = nil
@@ -1932,6 +1950,8 @@ start = Time.now
1932
1950
  task_params[:network_mode] = "awsvpc"
1933
1951
  task_params[:cpu] = cpu_total.to_i.to_s
1934
1952
  task_params[:memory] = mem_total.to_i.to_s
1953
+ elsif @config['vpc']
1954
+ task_params[:network_mode] = "awsvpc"
1935
1955
  end
1936
1956
 
1937
1957
  MU.log "Registering task definition #{service_name} with #{container_definitions.size.to_s} containers"
@@ -680,7 +680,7 @@ dependencies
680
680
  # Return the metadata for this ContainerCluster
681
681
  # @return [Hash]
682
682
  def notify
683
- deploy_struct = MU.structToHash(cloud_desc)
683
+ deploy_struct = MU.structToHash(cloud_desc, stringify_keys: true)
684
684
  deploy_struct['cloud_id'] = @cloud_id
685
685
  deploy_struct["region"] ||= @config['region']
686
686
  deploy_struct["db_name"] ||= @config['db_name']
@@ -761,7 +761,7 @@ dependencies
761
761
  end
762
762
 
763
763
  # @return [Array<Thread>]
764
- def self.threaded_resource_purge(describe_method, list_method, id_method, arn_type, region, credentials, ignoremaster, known: [])
764
+ def self.threaded_resource_purge(describe_method, list_method, id_method, arn_type, region, credentials, ignoremaster, known: [], deploy_id: MU.deploy_id)
765
765
  deletia = []
766
766
 
767
767
  resp = MU::Cloud::AWS.rds(credentials: credentials, region: region).send(describe_method)
@@ -774,7 +774,7 @@ dependencies
774
774
  next
775
775
  end
776
776
 
777
- if should_delete?(tags, resource.send(id_method), ignoremaster, MU.deploy_id, MU.mu_public_ip, known)
777
+ if should_delete?(tags, resource.send(id_method), ignoremaster, deploy_id, MU.mu_public_ip, known)
778
778
  deletia << resource.send(id_method)
779
779
  end
780
780
  }
@@ -795,18 +795,18 @@ dependencies
795
795
  # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server
796
796
  # @param region [String]: The cloud provider region in which to operate
797
797
  # @return [void]
798
- def self.cleanup(noop: false, ignoremaster: false, credentials: nil, region: MU.curRegion, flags: {})
798
+ def self.cleanup(noop: false, deploy_id: MU.deploy_id, ignoremaster: false, credentials: nil, region: MU.curRegion, flags: {})
799
799
 
800
800
  ["instance", "cluster"].each { |type|
801
- threaded_resource_purge("describe_db_#{type}s".to_sym, "db_#{type}s".to_sym, "db_#{type}_identifier".to_sym, (type == "instance" ? "db" : "cluster"), region, credentials, ignoremaster, known: flags['known']) { |id|
802
- terminate_rds_instance(nil, noop: noop, skipsnapshots: flags["skipsnapshots"], region: region, deploy_id: MU.deploy_id, cloud_id: id, mu_name: id.upcase, credentials: credentials, cluster: (type == "cluster"), known: flags['known'])
801
+ threaded_resource_purge("describe_db_#{type}s".to_sym, "db_#{type}s".to_sym, "db_#{type}_identifier".to_sym, (type == "instance" ? "db" : "cluster"), region, credentials, ignoremaster, known: flags['known'], deploy_id: deploy_id) { |id|
802
+ terminate_rds_instance(nil, noop: noop, skipsnapshots: flags["skipsnapshots"], region: region, deploy_id: deploy_id, cloud_id: id, mu_name: id.upcase, credentials: credentials, cluster: (type == "cluster"), known: flags['known'])
803
803
 
804
804
  }.each { |t|
805
805
  t.join
806
806
  }
807
807
  }
808
808
 
809
- threads = threaded_resource_purge(:describe_db_subnet_groups, :db_subnet_groups, :db_subnet_group_name, "subgrp", region, credentials, ignoremaster, known: flags['known']) { |id|
809
+ threads = threaded_resource_purge(:describe_db_subnet_groups, :db_subnet_groups, :db_subnet_group_name, "subgrp", region, credentials, ignoremaster, known: flags['known'], deploy_id: deploy_id) { |id|
810
810
  MU.log "Deleting RDS subnet group #{id}"
811
811
  MU.retrier([Aws::RDS::Errors::InvalidDBSubnetGroupStateFault], wait: 30, max: 5, ignoreme: [Aws::RDS::Errors::DBSubnetGroupNotFoundFault]) {
812
812
  MU::Cloud::AWS.rds(region: region).delete_db_subnet_group(db_subnet_group_name: id) if !noop
@@ -814,7 +814,7 @@ dependencies
814
814
  }
815
815
 
816
816
  ["db", "db_cluster"].each { |type|
817
- threads.concat threaded_resource_purge("describe_#{type}_parameter_groups".to_sym, "#{type}_parameter_groups".to_sym, "#{type}_parameter_group_name".to_sym, (type == "db" ? "pg" : "cluster-pg"), region, credentials, ignoremaster, known: flags['known']) { |id|
817
+ threads.concat threaded_resource_purge("describe_#{type}_parameter_groups".to_sym, "#{type}_parameter_groups".to_sym, "#{type}_parameter_group_name".to_sym, (type == "db" ? "pg" : "cluster-pg"), region, credentials, ignoremaster, known: flags['known'], deploy_id: deploy_id) { |id|
818
818
  MU.log "Deleting RDS #{type} parameter group #{id}"
819
819
  MU.retrier([Aws::RDS::Errors::InvalidDBParameterGroupState], wait: 30, max: 5, ignoreme: [Aws::RDS::Errors::DBParameterGroupNotFound]) {
820
820
  MU::Cloud::AWS.rds(region: region).send("delete_#{type}_parameter_group", { "#{type}_parameter_group_name".to_sym => id }) if !noop
@@ -1274,7 +1274,7 @@ dependencies
1274
1274
 
1275
1275
 
1276
1276
  def add_cluster_node
1277
- cluster = MU::Config::Ref.get(@config["member_of_cluster"]).kitten(@deploy, debug: true)
1277
+ cluster = MU::Config::Ref.get(@config["member_of_cluster"]).kitten(@deploy)
1278
1278
  if cluster.nil? or cluster.cloud_id.nil?
1279
1279
  raise MuError.new "Failed to resolve parent cluster of #{@mu_name}", details: @config["member_of_cluster"].to_h
1280
1280
  end
@@ -1355,7 +1355,7 @@ dependencies
1355
1355
 
1356
1356
  # creation_style = point_in_time
1357
1357
  def create_point_in_time
1358
- @config["source"].kitten(@deploy, debug: true)
1358
+ @config["source"].kitten(@deploy)
1359
1359
  if !@config["source"].id
1360
1360
  raise MuError.new "Database '#{@config['name']}' couldn't resolve cloud id for source database", details: @config["source"].to_h
1361
1361
  end
@@ -1381,7 +1381,7 @@ dependencies
1381
1381
 
1382
1382
  # creation_style = new, existing and read_replica_of is not nil
1383
1383
  def create_read_replica
1384
- @config["source"].kitten(@deploy, debug: true)
1384
+ @config["source"].kitten(@deploy)
1385
1385
  if !@config["source"].id
1386
1386
  raise MuError.new "Database '#{@config['name']}' couldn't resolve cloud id for source database", details: @config["source"].to_h
1387
1387
  end
@@ -42,7 +42,7 @@ module MU
42
42
  params = {
43
43
  :name => @config['name'],
44
44
  :hosted_zone_config => {
45
- :comment => MU.deploy_id
45
+ :comment => @deploy.deploy_id
46
46
  },
47
47
  :caller_reference => @deploy.getResourceName(@config['name'])
48
48
  }
@@ -173,11 +173,29 @@ module MU
173
173
  return resp.hosted_zone if @config["create_zone"]
174
174
  end
175
175
 
176
+ # Resolve a record entry (as in {MU::Config::BasketofKittens::dnszones::records} to the full DNS name we would assign it
177
+ def self.recordToName(record)
178
+ shortname = record['name']
179
+ shortname += ".#{MU.environment.downcase}" if record["append_environment_name"]
180
+
181
+ zone = if record['zone'].has_key?("id")
182
+ MU::Cloud::DNSZone.find(cloud_id: record['zone']['id']).values.first
183
+ else
184
+ MU::Cloud::DNSZone.find(cloud_id: record['zone']['name']).values.first
185
+ end
186
+
187
+ if zone.nil?
188
+ raise MuError.new "Failed to locate Route53 DNS Zone", details: record['zone']
189
+ end
190
+
191
+ shortname+"."+zone.name.sub(/\.$/, '')
192
+ end
193
+
176
194
  # Wrapper for {MU::Cloud::AWS::DNSZone.manageRecord}. Spawns threads to create all
177
195
  # requested records in background and returns immediately.
178
196
  # @param cfg [Array]: An array of parsed {MU::Config::BasketofKittens::dnszones::records} objects.
179
197
  # @param target [String]: Optional target for the records to be created. Overrides targets embedded in cfg records.
180
- def self.createRecordsFromConfig(cfg, target: nil)
198
+ def self.createRecordsFromConfig(cfg, target: nil, name_only: false)
181
199
  return if cfg.nil?
182
200
  record_threads = []
183
201
 
@@ -190,7 +208,6 @@ module MU
190
208
  zone = MU::Cloud::DNSZone.find(cloud_id: record['zone']['name']).values.first
191
209
  end
192
210
 
193
- raise MuError, "Failed to locate Route53 DNS Zone for domain #{record['zone']['name']}" if zone.nil?
194
211
 
195
212
  healthcheck_id = nil
196
213
  record['target'] = target if !target.nil?
@@ -666,7 +683,7 @@ module MU
666
683
 
667
684
  # Called by {MU::Cleanup}. Locates resources that were created by the
668
685
  # currently-loaded deployment, and purges them.
669
- def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {})
686
+ def self.cleanup(noop: false, deploy_id: MU.deploy_id, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {})
670
687
  MU.log "AWS::DNSZone.cleanup: need to support flags['known']", MU::DEBUG, details: flags
671
688
 
672
689
  threads = []
@@ -679,7 +696,7 @@ module MU
679
696
  muid_match = false
680
697
  mumaster_match = false
681
698
  tags.each { |tag|
682
- muid_match = true if tag.key == "MU-ID" and tag.value == MU.deploy_id
699
+ muid_match = true if tag.key == "MU-ID" and tag.value == deploy_id
683
700
  mumaster_match = true if tag.key == "MU-MASTER-IP" and tag.value == MU.mu_public_ip
684
701
  }
685
702
 
@@ -723,7 +740,7 @@ module MU
723
740
  t.join
724
741
  }
725
742
 
726
- zones = MU::Cloud::DNSZone.find(deploy_id: MU.deploy_id, region: region)
743
+ zones = MU::Cloud::DNSZone.find(deploy_id: deploy_id, region: region)
727
744
  zones.values.each { |zone|
728
745
  MU.log "Purging DNS Zone '#{zone.name}' (#{zone.id})"
729
746
  if !noop
@@ -779,7 +796,7 @@ module MU
779
796
 
780
797
  # TO DO: if we have more than one record it will retry the deletion multiple times and will throw Aws::Route53::Errors::InvalidChangeBatch / record not found even though the record was deleted
781
798
  zone_rrsets.each { |record|
782
- if record.name.match(MU.deploy_id.downcase)
799
+ if record.name.match(deploy_id.downcase)
783
800
  resource_records = []
784
801
  record.resource_records.each { |rrecord|
785
802
  resource_records << rrecord.value
@@ -13,22 +13,21 @@ module MU
13
13
 
14
14
  # Called automatically by {MU::Deploy#createResources}
15
15
  def create
16
- resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_rest_api(
16
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).create_rest_api(
17
17
  name: @mu_name,
18
18
  description: @deploy.deploy_id,
19
19
  endpoint_configuration: {
20
20
  types: ["REGIONAL"] # XXX expose in BoK ["REGIONAL", "EDGE", "PRIVATE"]
21
- }
21
+ },
22
+ tags: @tags
22
23
  )
23
24
  @cloud_id = resp.id
24
- generate_methods
25
-
26
-
25
+ generate_methods(false)
27
26
  end
28
27
 
29
28
  # Create/update all of the methods declared for this endpoint
30
- def generate_methods
31
- resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_resources(
29
+ def generate_methods(integrations = true)
30
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_resources(
32
31
  rest_api_id: @cloud_id,
33
32
  )
34
33
  root_resource = resp.items.first.id
@@ -37,24 +36,26 @@ module MU
37
36
  @config['methods'].each { |m|
38
37
  m["auth"] ||= m["iam_role"] ? "AWS_IAM" : "NONE"
39
38
 
40
- method_arn = "arn:#{MU::Cloud::AWS.isGovCloud?(@config["region"]) ? "aws-us-gov" : "aws"}:execute-api:#{@config["region"]}:#{MU::Cloud::AWS.credToAcct(@config['credentials'])}:#{@cloud_id}/*/#{m['type']}/#{m['path']}"
39
+ method_arn = "arn:#{MU::Cloud::AWS.isGovCloud?(@config["region"]) ? "aws-us-gov" : "aws"}:execute-api:#{@config["region"]}:#{MU::Cloud::AWS.credToAcct(@credentials)}:#{@cloud_id}/*/#{m['type']}/#{m['path']}"
40
+ path_part = ["", "/"].include?(m['path']) ? nil : m['path']
41
+ method_arn.sub!(/\/\/$/, '/')
41
42
 
42
- resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_resources(
43
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_resources(
43
44
  rest_api_id: @cloud_id
44
45
  )
45
46
  ext_resource = nil
46
47
  resp.items.each { |resource|
47
- if resource.path_part == m['path']
48
+ if resource.path_part == path_part
48
49
  ext_resource = resource.id
49
50
  end
50
51
  }
51
52
 
52
53
  resp = if ext_resource
53
- MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_resource(
54
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_resource(
54
55
  rest_api_id: @cloud_id,
55
56
  resource_id: ext_resource,
56
57
  )
57
- # MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).update_resource(
58
+ # MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).update_resource(
58
59
  # rest_api_id: @cloud_id,
59
60
  # resource_id: ext_resource,
60
61
  # patch_operations: [
@@ -66,22 +67,22 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
66
67
  # ]
67
68
  # )
68
69
  else
69
- MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_resource(
70
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).create_resource(
70
71
  rest_api_id: @cloud_id,
71
72
  parent_id: root_resource,
72
- path_part: m['path']
73
+ path_part: path_part
73
74
  )
74
75
  end
75
76
  parent_id = resp.id
76
77
 
77
78
  resp = begin
78
- MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_method(
79
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_method(
79
80
  rest_api_id: @cloud_id,
80
81
  resource_id: parent_id,
81
82
  http_method: m['type']
82
83
  )
83
84
  rescue Aws::APIGateway::Errors::NotFoundException
84
- resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).put_method(
85
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).put_method(
85
86
  rest_api_id: @cloud_id,
86
87
  resource_id: parent_id,
87
88
  authorization_type: m['auth'],
@@ -100,6 +101,7 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
100
101
  }
101
102
  if r['headers']
102
103
  params[:response_parameters] = r['headers'].map { |h|
104
+ h['required'] ||= false
103
105
  ["method.response.header."+h['header'], h['required']]
104
106
  }.to_h
105
107
  end
@@ -109,13 +111,13 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
109
111
  params[:response_models] = r['body'].map { |b| [b['content_type'], b['is_error'] ? "Error" : "Empty"] }.to_h
110
112
  end
111
113
 
112
- MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).put_method_response(params)
114
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).put_method_response(params)
113
115
  }
114
116
  rescue Aws::APIGateway::Errors::ConflictException
115
117
  # fine to ignore
116
118
  end
117
119
 
118
- if m['integrate_with']
120
+ if integrations and m['integrate_with']
119
121
  # role_arn = if m['iam_role']
120
122
  # if m['iam_role'].match(/^arn:/)
121
123
  # m['iam_role']
@@ -127,13 +129,17 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
127
129
  # end
128
130
 
129
131
  function_obj = nil
132
+ aws_int_type = m['integrate_with']['proxy'] ? "AWS_PROXY" : "AWS"
130
133
 
131
134
  uri, type = if m['integrate_with']['type'] == "aws_generic"
132
135
  svc, action = m['integrate_with']['aws_generic_action'].split(/:/)
133
- ["arn:aws:apigateway:"+@config['region']+":#{svc}:action/#{action}", "AWS"]
134
- elsif m['integrate_with']['type'] == "function"
135
- function_obj = @deploy.findLitterMate(name: m['integrate_with']['name'], type: "functions").cloudobj
136
- ["arn:aws:apigateway:"+@config['region']+":lambda:path/2015-03-31/functions/"+function_obj.arn+"/invocations", "AWS"]
136
+ ["arn:aws:apigateway:"+@config['region']+":#{svc}:action/#{action}", aws_int_type]
137
+ elsif m['integrate_with']['type'] == "functions"
138
+ function_obj = nil
139
+ MU.retrier([], max: 5, wait: 9, loop_if: Proc.new { function_obj.nil? }) {
140
+ function_obj = @deploy.findLitterMate(name: m['integrate_with']['name'], type: "functions")
141
+ }
142
+ ["arn:aws:apigateway:"+@config['region']+":lambda:path/2015-03-31/functions/"+function_obj.cloudobj.arn+"/invocations", aws_int_type]
137
143
  elsif m['integrate_with']['type'] == "mock"
138
144
  [nil, "MOCK"]
139
145
  end
@@ -143,7 +149,8 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
143
149
  :resource_id => parent_id,
144
150
  :type => type, # XXX Lambda and Firehose can do AWS_PROXY
145
151
  :content_handling => "CONVERT_TO_TEXT", # XXX expose in BoK
146
- :http_method => m['type']
152
+ :http_method => m['type'],
153
+ :timeout_in_millis => m['timeout_in_millis']
147
154
  # credentials: role_arn
148
155
  }
149
156
  params[:uri] = uri if uri
@@ -163,10 +170,15 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
163
170
  params[:request_templates][rt['content_type']] = rt['template']
164
171
  }
165
172
  end
173
+ if m['integrate_with']['parameters']
174
+ params[:request_parameters] = Hash[m['integrate_with']['parameters'].map { |p|
175
+ ["integration.request.#{p['type']}.#{p['name']}", p['value']]
176
+ }]
177
+ end
166
178
 
167
- resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).put_integration(params)
179
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).put_integration(params)
168
180
 
169
- if m['integrate_with']['type'] == "function"
181
+ if m['integrate_with']['type'] =~ /^functions?$/
170
182
  function_obj.addTrigger(method_arn, "apigateway", @config['name'])
171
183
  end
172
184
 
@@ -176,7 +188,7 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
176
188
  :resource_id => parent_id,
177
189
  :http_method => m['type'],
178
190
  :status_code => r['code'].to_s,
179
- :selection_pattern => ""
191
+ :selection_pattern => ".*"
180
192
  }
181
193
  if r['headers']
182
194
  params[:response_parameters] = r['headers'].map { |h|
@@ -184,7 +196,7 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
184
196
  }.to_h
185
197
  end
186
198
 
187
- MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).put_integration_response(params)
199
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).put_integration_response(params)
188
200
 
189
201
  }
190
202
 
@@ -197,24 +209,152 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
197
209
  def groom
198
210
  generate_methods
199
211
 
200
- MU.log "Deploying API Gateway #{@config['name']} to #{@config['deploy_to']}"
201
- MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_deployment(
202
- rest_api_id: @cloud_id,
203
- stage_name: @config['deploy_to']
212
+ deployment = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_deployments(
213
+ rest_api_id: @cloud_id
214
+ ).items.sort { |a, b| a.created_date <=> b.created_date }.last
215
+
216
+ if !deployment
217
+ MU.log "Deploying API Gateway #{@config['name']} to #{@config['deploy_to']}"
218
+ deployment = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).create_deployment(
219
+ rest_api_id: @cloud_id,
220
+ stage_name: @config['deploy_to']
204
221
  # cache_cluster_enabled: false,
205
222
  # cache_cluster_size: 0.5,
206
- )
223
+ )
224
+ end
207
225
  # this automatically creates a stage with the same name, so we don't
208
226
  # have to deal with that
209
227
 
210
- my_url = "https://"+@cloud_id+".execute-api."+@config['region']+".amazonaws.com/"+@config['deploy_to']
228
+ my_hostname = @cloud_id+".execute-api."+@config['region']+".amazonaws.com"
229
+ my_url = "https://"+my_hostname+"/"+@config['deploy_to']
211
230
  MU.log "API Endpoint #{@config['name']}: "+my_url, MU::SUMMARY
212
231
 
213
- # resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_authorizer(
232
+ print_dns_alias = Proc.new { |rec|
233
+ rec['name'] ||= @mu_name.downcase
234
+ dnsname = MU::Cloud.resourceClass("AWS", "DNSZone").recordToName(rec)
235
+ dnsname
236
+ }
237
+
238
+ # if we have any placeholder DNS records that are intended to be
239
+ # filled out with our runtime @mu_name, do so, and add an alias if
240
+ # applicable
241
+ if @config['dns_records'] and !MU::Cloud::AWS.isGovCloud?
242
+ @config['dns_records'].each { |rec|
243
+ dnsname = print_dns_alias.call(rec)
244
+ MU.log "Alias for API Endpoint #{@config['name']}: https://"+dnsname+"/"+@config['deploy_to'], MU::SUMMARY
245
+ }
246
+ MU::Cloud.resourceClass("AWS", "DNSZone").createRecordsFromConfig(@config['dns_records'], target: my_hostname)
247
+ end
248
+
249
+ if @config['domain_names']
250
+ @config['domain_names'].each { |dom|
251
+ dnsname = if dom['dns_record']
252
+ print_dns_alias.call(dom['dns_record'])
253
+ else
254
+ dom['unmanaged_name']
255
+ end
256
+ MU.log "Alias for API Endpoint #{@config['name']}: https://"+dnsname, MU::SUMMARY
257
+
258
+ certfield, dnsfield = if dom['endpoint_type'] == "EDGE"
259
+ [:certificate_arn, :distribution_domain_name]
260
+ else
261
+ [:regional_certificate_arn, :regional_domain_name]
262
+ end
263
+
264
+ dom_desc = begin
265
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_domain_name(domain_name: dnsname)
266
+ rescue ::Aws::APIGateway::Errors::NotFoundException
267
+
268
+ params = {
269
+ domain_name: dnsname,
270
+ endpoint_configuration: {
271
+ types: [dom['endpoint_type']]
272
+ },
273
+ security_policy: dom['security_policy'],
274
+ tags: @tags
275
+ }
276
+ if dom['certificate']
277
+ params[certfield] = dom['certificate']['id']
278
+ end
279
+
280
+ MU.log "Creating API Gateway Domain Name #{dnsname}", MU::NOTICE, details: params
281
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).create_domain_name(params)
282
+ end
283
+
284
+ mappings = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_base_path_mappings(domain_name: dnsname, limit: 500).items
285
+ found = false
286
+ if mappings
287
+ mappings.each { |m|
288
+ if m.rest_api_id == @cloud_id and m.stage == @config['deploy_to']
289
+ found = true
290
+ break
291
+ end
292
+ }
293
+ end
294
+ if !found
295
+ MU.log "Mapping #{dnsname} to API Gateway #{@mu_name}"
296
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).create_base_path_mapping(
297
+ domain_name: dnsname,
298
+ rest_api_id: @cloud_id,
299
+ stage: @config['deploy_to']
300
+ )
301
+ end
302
+
303
+ if dom['dns_record']
304
+ MU::Cloud.resourceClass("AWS", "DNSZone").createRecordsFromConfig([dom['dns_record']], target: dom_desc.send(dnsfield))
305
+ end
306
+ }
307
+ end
308
+
309
+ # The creation of our deployment should have created a matching stage,
310
+ # which we're now going to mess with.
311
+ stage = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_stage(
312
+ rest_api_id: @cloud_id,
313
+ stage_name: @config['deploy_to']
314
+ )
315
+
316
+ if @config['access_logs'] and !stage.access_log_settings
317
+ log_ref = MU::Config::Ref.get(@config['access_logs'])
318
+ MU.log "Enabling API Gateway access logs to CloudWatch Log Group #{log_ref.cloud_id}"
319
+ stage = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).update_stage(
320
+ rest_api_id: @cloud_id,
321
+ stage_name: @config['deploy_to'],
322
+ patch_operations: [
323
+ {
324
+ op: "replace",
325
+ path: "/accessLogSettings/destinationArn",
326
+ value: log_ref.kitten.arn.sub(/:\*$/, '')
327
+ },
328
+ {
329
+ op: "replace",
330
+ path: "/accessLogSettings/format",
331
+ value: '$context.identity.sourceIp $context.identity.caller $context.identity.user [$context.requestTime] "$context.httpMethod $context.resourcePath $context.protocol" $context.status $context.responseLength $context.requestId'
332
+ },
333
+ {
334
+ op: "replace",
335
+ path: "/description",
336
+ value: @deploy.deploy_id
337
+ },
338
+ {
339
+ op: "replace",
340
+ path: "/*/*/logging/dataTrace",
341
+ value: "true"
342
+ },
343
+ {
344
+ op: "replace",
345
+ path: "/*/*/logging/loglevel",
346
+ value: "INFO"
347
+ }
348
+ ]
349
+ )
350
+ end
351
+
352
+
353
+ # resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).create_authorizer(
214
354
  # rest_api_id: @cloud_id,
215
355
  # )
216
356
 
217
- # resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_vpc_link(
357
+ # resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).create_vpc_link(
218
358
  # )
219
359
 
220
360
  end
@@ -223,7 +363,8 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
223
363
  # @return [Struct]
224
364
  def cloud_desc(use_cache: true)
225
365
  return @cloud_desc_cache if @cloud_desc_cache and use_cache
226
- @cloud_desc_cache = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_rest_api(
366
+ return nil if !@cloud_id
367
+ @cloud_desc_cache = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_rest_api(
227
368
  rest_api_id: @cloud_id
228
369
  )
229
370
  @cloud_desc_cache
@@ -232,7 +373,10 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
232
373
  # Return the metadata for this API
233
374
  # @return [Hash]
234
375
  def notify
235
- deploy_struct = MU.structToHash(cloud_desc)
376
+ return nil if !@cloud_id or !cloud_desc(use_cache: false)
377
+ deploy_struct = MU.structToHash(cloud_desc, stringify_keys: true)
378
+ deploy_struct['url'] = "https://"+@cloud_id+".execute-api."+@config['region']+".amazonaws.com"
379
+ deploy_struct['url'] += "/"+@config['deploy_to'] if @config['deploy_to']
236
380
  # XXX stages and whatnot
237
381
  return deploy_struct
238
382
  end
@@ -242,15 +386,45 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
242
386
  # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server
243
387
  # @param region [String]: The cloud provider region
244
388
  # @return [void]
245
- def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {})
389
+ def self.cleanup(noop: false, deploy_id: MU.deploy_id, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {})
246
390
  MU.log "AWS::Endpoint.cleanup: need to support flags['known']", MU::DEBUG, details: flags
247
391
  MU.log "Placeholder: AWS Endpoint artifacts do not support tags, so ignoremaster cleanup flag has no effect", MU::DEBUG, details: ignoremaster
248
392
 
393
+ resp = MU::Cloud::AWS.apig(region: region, credentials: credentials).get_domain_names(limit: 500)
394
+ if resp and resp.items
395
+ resp.items.each { |d|
396
+ next if !d.tags
397
+ if d.tags["MU-ID"] == deploy_id and
398
+ (ignoremaster or d.tags["MU-MASTER-IP"] == MU.mu_public_ip)
399
+ mappings = MU::Cloud::AWS.apig(region: region, credentials: credentials).get_base_path_mappings(domain_name: d.domain_name, limit: 500).items
400
+ mappings.each { |m|
401
+ MU.log "Deleting API Gateway Domain Name mapping #{d.domain_name} => #{m.rest_api_id} path #{m.base_path}"
402
+ if !noop
403
+ MU::Cloud::AWS.apig(region: region, credentials: credentials).delete_base_path_mapping(domain_name: d.domain_name, base_path: m.base_path)
404
+ end
405
+ }
406
+ MU.log "Deleting API Gateway Domain Name #{d.domain_name}"
407
+ if !noop
408
+ MU::Cloud::AWS.apig(region: region, credentials: credentials).delete_domain_name(domain_name: d.domain_name)
409
+ end
410
+ end
411
+ }
412
+ end
413
+
249
414
  resp = MU::Cloud::AWS.apig(region: region, credentials: credentials).get_rest_apis
250
415
  if resp and resp.items
251
416
  resp.items.each { |api|
252
417
  # The stupid things don't have tags
253
- if api.description == MU.deploy_id
418
+ if api.description == deploy_id
419
+ logs = MU::Cloud.resourceClass("AWS", "Log").find(region: region, credentials: credentials)
420
+ logs.each_pair { |log_id, log_desc|
421
+ if log_id =~ /^API-Gateway-Execution-Logs_#{api.id}\//
422
+ MU.log "Deleting CloudWatch Log Group #{log_id}"
423
+ if !noop
424
+ MU::Cloud::AWS.cloudwatchlogs(region: region, credentials: credentials).delete_log_group(log_group_name: log_id)
425
+ end
426
+ end
427
+ }
254
428
  MU.log "Deleting API Gateway #{api.name} (#{api.id})"
255
429
  if !noop
256
430
  MU::Cloud::AWS.apig(region: region, credentials: credentials).delete_rest_api(
@@ -260,6 +434,7 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
260
434
  end
261
435
  }
262
436
  end
437
+
263
438
  end
264
439
 
265
440
  # Locate an existing API.
@@ -283,16 +458,214 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
283
458
  found
284
459
  end
285
460
 
461
+ # Reverse-map our cloud description into a runnable config hash.
462
+ # We assume that any values we have in +@config+ are placeholders, and
463
+ # calculate our own accordingly based on what's live in the cloud.
464
+ def toKitten(**_args)
465
+ bok = {
466
+ "cloud" => "AWS",
467
+ "credentials" => @credentials,
468
+ "cloud_id" => @cloud_id,
469
+ "region" => @config['region']
470
+ }
471
+
472
+ if !cloud_desc
473
+ MU.log "toKitten failed to load a cloud_desc from #{@cloud_id}", MU::ERR, details: @config
474
+ return nil
475
+ end
476
+
477
+ bok['name'] = cloud_desc.name
478
+
479
+ resources = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_resources(
480
+ rest_api_id: @cloud_id,
481
+ ).items
482
+
483
+ resources.each { |r|
484
+ next if !r.respond_to?(:resource_methods) or r.resource_methods.nil?
485
+ r.resource_methods.each_pair { |http_type, m|
486
+ bok['methods'] ||= []
487
+ method = {}
488
+ m_desc = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_method(
489
+ rest_api_id: @cloud_id,
490
+ resource_id: r.id,
491
+ http_method: http_type
492
+ )
493
+
494
+ method['type'] = http_type
495
+ method['path'] = r.path_part || r.path
496
+ if m_desc.method_responses
497
+ m_desc.method_responses.each_pair { |code, resp_desc|
498
+ method['responses'] ||= []
499
+ resp = { "code" => code.to_i }
500
+ if resp_desc.response_parameters
501
+ resp_desc.response_parameters.each_pair { |hdr, reqd|
502
+ resp['headers'] ||= []
503
+ if hdr.match(/^method\.response\.header\.(.*)/)
504
+ resp['headers'] << {
505
+ "header" => Regexp.last_match[1],
506
+ "required" => reqd
507
+ }
508
+ else
509
+ MU.log "I don't know what to do with APIG response parameter #{hdr}", MU::ERR, details: resp_desc
510
+ end
511
+
512
+ }
513
+ end
514
+ if resp_desc.response_models
515
+ resp_desc.response_models.each_pair { |content_type, body|
516
+ resp['body'] ||= []
517
+ resp['body'] << {
518
+ "content_type" => content_type,
519
+ "is_error" => (body == "Error")
520
+ }
521
+ }
522
+
523
+ end
524
+ method['responses'] << resp
525
+
526
+ }
527
+ end
528
+
529
+ if m_desc.method_integration
530
+ if ["AWS", "AWS_PROXY"].include?(m_desc.method_integration.type)
531
+ if m_desc.method_integration.uri.match(/:lambda:path\/\d{4}-\d{2}-\d{2}\/functions\/arn:.*?:function:(.*?)\/invocations$/)
532
+ method['integrate_with'] = MU::Config::Ref.get(
533
+ id: Regexp.last_match[1],
534
+ type: "functions",
535
+ cloud: "AWS",
536
+ integration_http_method: m_desc.method_integration.http_method
537
+ )
538
+ elsif m_desc.method_integration.uri.match(/#{@config['region']}:([^:]+):action\/(.*)/)
539
+ method['integrate_with'] = {
540
+ "type" => "aws_generic",
541
+ "integration_http_method" => m_desc.method_integration.http_method,
542
+ "aws_generic_action" => Regexp.last_match[1]+":"+Regexp.last_match[2]
543
+ }
544
+ else
545
+ MU.log "I don't know what to do with #{m_desc.method_integration.uri}", MU::ERR
546
+ end
547
+ if m_desc.method_integration.http_method
548
+ method['integrate_with']['backend_http_method'] = m_desc.method_integration.http_method
549
+ end
550
+ method['proxy'] = true if m_desc.method_integration.type == "AWS_PROXY"
551
+ elsif m_desc.method_integration.type == "MOCK"
552
+ method['integrate_with'] = {
553
+ "type" => "mock"
554
+ }
555
+ else
556
+ MU.log "I don't know what to do with this integration", MU::ERR, details: m_desc.method_integration
557
+ next
558
+ end
559
+
560
+ if m_desc.method_integration.passthrough_behavior
561
+ method['integrate_with']['passthrough_behavior'] = m_desc.method_integration.passthrough_behavior
562
+ end
563
+
564
+ if m_desc.method_integration.request_templates and
565
+ !m_desc.method_integration.request_templates.empty?
566
+ method['integrate_with']['request_templates'] = m_desc.method_integration.request_templates.keys.map { |rt_content_type, template|
567
+ { "content_type" => rt_content_type, "template" => template }
568
+ }
569
+ end
570
+
571
+ if m_desc.method_integration.request_parameters
572
+ m_desc.method_integration.request_parameters.each_pair { |k, v|
573
+ if !k.match(/^integration\.request\.(header|querystring|path)\.(.*)/)
574
+ MU.log "Don't know how to handle integration request parameter '#{k}', skipping", MU::WARN
575
+ next
576
+ end
577
+ if Regexp.last_match[1] == "header" and
578
+ Regexp.last_match[2] == "X-Amz-Invocation-Type" and
579
+ v == "'Event'"
580
+ method['integrate_with']['async'] = true
581
+ else
582
+ method['integrate_with']['parameters'] ||= []
583
+ method['integrate_with']['parameters'] << {
584
+ "type" => Regexp.last_match[1],
585
+ "name" => Regexp.last_match[2],
586
+ "value" => v
587
+ }
588
+ end
589
+ }
590
+ end
591
+ end
592
+
593
+ bok['methods'] << method
594
+ }
595
+ }
596
+
597
+ deployment = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_deployments(
598
+ rest_api_id: @cloud_id
599
+ ).items.sort { |a, b| a.created_date <=> b.created_date }.last
600
+ stages = MU::Cloud::AWS.apig(region: @config['region'], credentials: @credentials).get_stages(
601
+ rest_api_id: @cloud_id,
602
+ deployment_id: deployment.id
603
+ )
604
+
605
+ # XXX we only support a single stage right now, which is a dumb
606
+ # limitation
607
+ stage = stages.item.first
608
+ if stage
609
+ bok['deploy_to'] = stage.stage_name
610
+ if stage.access_log_settings
611
+ bok['log_requests'] = true
612
+ bok['access_logs'] = MU::Config::Ref.get(
613
+ id: stage.access_log_settings.destination_arn.sub(/.*?:([^:]+)$/, '\1'),
614
+ credentials: @credentials,
615
+ region: @config['region'],
616
+ type: "logs",
617
+ cloud: "AWS"
618
+ )
619
+ end
620
+ end
621
+
622
+
623
+ bok
624
+ end
625
+
286
626
  # Cloud-specific configuration properties.
287
627
  # @param _config [MU::Config]: The calling MU::Config object
288
628
  # @return [Array<Array,Hash>]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource
289
629
  def self.schema(_config)
290
630
  toplevel_required = []
291
631
  schema = {
632
+ "domain_names" => {
633
+ "type" => "array",
634
+ "items" => {
635
+ "description" => "Configure optional Custom Domain Names to map to this API endpoint.",
636
+ "type" => "object",
637
+ "properties" => {
638
+ "certificate" => MU::Config::Ref.schema(type: "certificate", desc: "An existing IAM or ACM SSL certificate to bind to this alternate name endpoint.", omit_fields: ["cloud", "tag", "deploy_id"]),
639
+ "dns_record" => MU::Config::DNSZone.records_primitive(need_target: false, default_type: "CNAME", need_zone: true, embedded_type: "endpoint")["items"],
640
+ "unmanaged_name" => {
641
+ "type" => "string",
642
+ "description" => "If +dns_record+ is not specified, we will map this string as a domain name and assume that an external DNS record will be created pointing to us at a later time."
643
+ },
644
+ "endpoint_type" => {
645
+ "type" => "string",
646
+ "description" => "The type of endpoint to create with this domain name.",
647
+ "default" => "REGIONAL",
648
+ "enum" => ["REGIONAL", "EDGE", "PRIVATE"]
649
+ },
650
+ "security_policy" => {
651
+ "type" => "string",
652
+ "default" => "TLS_1_2",
653
+ "enum" => ["TLS_1_0", "TLS_1_2"],
654
+ "description" => "Acceptable TLS cipher suites. +TLS_1_2+ is strongly recommended."
655
+ }
656
+ }
657
+ }
658
+ },
292
659
  "deploy_to" => {
293
660
  "type" => "string",
294
661
  "description" => "The name of an environment under which to deploy our API. If not specified, will deploy to the name of the global Mu environment for this deployment."
295
662
  },
663
+ "log_requests" => {
664
+ "type" => "boolean",
665
+ "description" => "Log custom access requests to CloudWatch Logs to the log group specified by +access_logs+, as well as enabling built-in CloudWatch Logs at +INFO+ level. If +access_logs+ is unspecified, a reasonable group will be created automatically.",
666
+ "default" => true
667
+ },
668
+ "access_logs" => MU::Config::Ref.schema(type: "logs", desc: "A pre-existing or sibling Mu Cloudwatch Log group reference. If +log_requests+ is specified and this is not, a log group will be generated automatically. Setting this parameter explicitly automatically enables +log_requests+."),
296
669
  "methods" => {
297
670
  "items" => {
298
671
  "type" => "object",
@@ -303,16 +676,48 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
303
676
  "type" => "object",
304
677
  "description" => "Specify what application backend to invoke under this path/method combination",
305
678
  "properties" => {
679
+ "async" => {
680
+ "type" => "boolean",
681
+ "default" => false,
682
+ "description" => "For non-proxy Lambda integrations, adds a static +X-Amz-Invocation-Type+ with value +'Event'+ to invoke the function asynchronously. See also https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-integration-async.html"
683
+ },
684
+ "parameters" => {
685
+ "type" => "array",
686
+ "items" => {
687
+ "description" => "One or headers, paths, or query string parameters to pass as request parameters to our back end. See also: https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html",
688
+ "type" => "object",
689
+ "properties" => {
690
+ "name" => {
691
+ "type" => "string",
692
+ "description" => "A valid and unique integration request parameter name."
693
+ },
694
+ "value" => {
695
+ "type" => "string",
696
+ "description" => "The name of a method request parameter, or a static value contained in single quotes (+'foo'+)."
697
+ },
698
+ "type" => {
699
+ "type" => "string",
700
+ "description" => "Which HTTP artifact to use when presenting the parameter to the back end. ",
701
+ "enum" => ["header", "querystring", "path"]
702
+ }
703
+ }
704
+ }
705
+ },
306
706
  "proxy" => {
307
707
  "type" => "boolean",
308
708
  "default" => false,
309
- "description" => "For HTTP or AWS integrations, specify whether the target is a proxy (((docs unclear, is that actually what this means?)))" # XXX is that actually what this means?
709
+ "description" => "Sets HTTP integrations to HTTP_PROXY and AWS/LAMBDA integrations to AWS_PROXY/LAMBDA_PROXY"
310
710
  },
311
711
  "backend_http_method" => {
312
712
  "type" => "string",
313
713
  "description" => "The HTTP method to use when contacting our integrated backend. If not specified, this will be set to match our front end.",
314
714
  "enum" => ["GET", "POST", "PUT", "HEAD", "DELETE", "CONNECT", "OPTIONS", "TRACE"],
315
715
  },
716
+ "timeout_in_millis" => {
717
+ "type" => "integer",
718
+ "description" => "Custom timeout between +50+ and +29,000+ milliseconds.",
719
+ "default" => 29000
720
+ },
316
721
  "url" => {
317
722
  "type" => "string",
318
723
  "description" => "For HTTP or HTTP_PROXY integrations, this should be a fully-qualified URL"
@@ -380,14 +785,13 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
380
785
  "description" => "A Mu resource name, for integrations with a sibling resource (e.g. a Function)"
381
786
  },
382
787
  "cors" => {
383
- "type" => "boolean",
384
- "description" => "When enabled, this will create an +OPTIONS+ method under this path with request and response header mappings that implement Cross-Origin Resource Sharing",
385
- "default" => true
788
+ "type" => "string",
789
+ "description" => "When enabled, this will create an +OPTIONS+ method under this path with request and response header mappings that implement Cross-Origin Resource Sharing, setting +Access-Control-Allow-Origin+ to the specified value.",
386
790
  },
387
791
  "type" => {
388
792
  "type" => "string",
389
793
  "description" => "A Mu resource type, for integrations with a sibling resource (e.g. a function), or the string +aws_generic+, which we can use in combination with +aws_generic_action+ to integrate with arbitrary AWS services.",
390
- "enum" => ["aws_generic"].concat(MU::Cloud.resource_types.values.map { |t| t[:cfg_name] }.sort)
794
+ "enum" => ["aws_generic"].concat(MU::Cloud.resource_types.values.map { |t| t[:cfg_plural] }.sort)
391
795
  },
392
796
  "aws_generic_action" => {
393
797
  "type" => "string",
@@ -456,7 +860,7 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
456
860
  # Canonical Amazon Resource Number for this resource
457
861
  # @return [String]
458
862
  def arn
459
- "arn:#{MU::Cloud::AWS.isGovCloud?(@config["region"]) ? "aws-us-gov" : "aws"}:execute-api:#{@config["region"]}:#{MU::Cloud::AWS.credToAcct(@config['credentials'])}:#{@cloud_id}"
863
+ "arn:#{MU::Cloud::AWS.isGovCloud?(@config["region"]) ? "aws-us-gov" : "aws"}:execute-api:#{@config["region"]}:#{MU::Cloud::AWS.credToAcct(@credentials)}:#{@cloud_id}"
460
864
  end
461
865
 
462
866
 
@@ -467,9 +871,89 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
467
871
  def self.validateConfig(endpoint, configurator)
468
872
  ok = true
469
873
 
874
+ if endpoint['log_requests'] and !endpoint['access_logs']
875
+ logdesc = {
876
+ "name" => endpoint['name']+"accesslogs",
877
+ }
878
+ logdesc["tags"] = endpoint["tags"] if endpoint['tags']
879
+ configurator.insertKitten(logdesc, "logs")
880
+ endpoint['access_logs'] = MU::Config::Ref.get(
881
+ name: endpoint['name']+"accesslogs",
882
+ type: "log",
883
+ cloud: "AWS",
884
+ credentials: endpoint['credentials'],
885
+ region: endpoint['region']
886
+ )
887
+ end
888
+
889
+ if endpoint['access_logs'] and endpoint["access_logs"]["name"]
890
+ endpoint['log_requests'] = true
891
+ MU::Config.addDependency(endpoint, endpoint["access_logs"]["name"], "log")
892
+ end
893
+
894
+ if endpoint['access_logs']
895
+ resp = MU::Cloud::AWS.apig(credentials: endpoint['credentials'], region: endpoint['region']).get_account
896
+ if !resp.cloudwatch_role_arn
897
+ MU.log "Endpoint '#{endpoint['name']}' is configured to use CloudWatch Logs, but the account-wide API Gateway log role is not configured", MU::ERR, details: "https://aws.amazon.com/premiumsupport/knowledge-center/api-gateway-cloudwatch-logs/"
898
+ ok = false
899
+ else
900
+ roles = MU::Cloud::AWS::Role.find(cloud_id: resp.cloudwatch_role_arn, credentials: endpoint['credentials'], region: endpoint['region'])
901
+ if roles.empty?
902
+ MU.log "Endpoint '#{endpoint['name']}' is configured to use CloudWatch Logs, but the configured account-wide API Gateway log role does not exist", MU::ERR, details: resp.cloudwatch_role_arn
903
+ ok = false
904
+ end
905
+ end
906
+ end
907
+
908
+ if endpoint['domain_names']
909
+ endpoint['domain_names'].each { |dom|
910
+ if dom['certificate']
911
+ cert_arn, cert_domains = MU::Cloud::AWS.resolveSSLCertificate(dom['certificate'], region: dom['region'], credentials: dom['credentials'])
912
+ if !cert_arn
913
+ MU.log "API Gateway #{endpoint['name']}: Failed to resolve SSL certificate in domain_name block", MU::ERR, details: dom
914
+ ok = false
915
+ end
916
+ end
917
+ if !dom['unmanaged_name'] and !dom['dns_record']
918
+ MU.log "API Gateway #{endpoint['name']}: Must specify either unmanaged_name or dns_record in domain_name block", MU::ERR, details: dom
919
+ ok = false
920
+ end
921
+
922
+ # Make at least an attempt to catch when we've specified the same
923
+ # DNS name to point to both the main gateway and this alternative
924
+ # endpoint, because that ish won't work. This check will miss if
925
+ # the end user specifies the zone in competing ways.
926
+ if dom['dns_record'] and endpoint['dns_records']
927
+ endpoint['dns_records'].each { |rec|
928
+ if rec['name'] == dom['dns_record']['name'] and
929
+ rec['zone'] == dom['dns_record']['zone']
930
+ MU.log "API Gateway #{endpoint['name']}: Cannot specify same entry in dns_records and domain_names", MU::ERR, details: rec
931
+ ok = false
932
+ end
933
+ }
934
+ end
935
+ }
936
+ end
937
+
470
938
  append = []
471
939
  endpoint['deploy_to'] ||= MU.environment || $environment || "dev"
472
940
  endpoint['methods'].each { |m|
941
+ if m['integrate_with']['async']
942
+ if m['integrate_with']['type'] == "functions" and
943
+ m['integrate_with']['async']
944
+ m['integrate_with']['parameters'] ||= []
945
+ m['integrate_with']['parameters'] << {
946
+ "name" => "X-Amz-Invocation-Type",
947
+ "value" => "'Event'", # yes the single quotes are required
948
+ "type" => "header"
949
+ }
950
+ if m['integrate_with']['proxy']
951
+ MU.log "Cannot specify both of proxy and async for API Gateway method integration", MU::ERR
952
+ ok = false
953
+ end
954
+ end
955
+ end
956
+
473
957
  if m['integrate_with'] and m['integrate_with']['name']
474
958
  if m['integrate_with']['type'] != "aws_generic"
475
959
  MU::Config.addDependency(endpoint, m['integrate_with']['name'], m['integrate_with']['type'])
@@ -486,15 +970,16 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
486
970
  r['headers'] ||= []
487
971
  r['headers'] << {
488
972
  "header" => "Access-Control-Allow-Origin",
489
- "value" => "*",
973
+ "value" => m['cors'],
490
974
  "required" => true
491
975
  }
492
976
  r['headers'].uniq!
493
977
  }
494
978
 
495
- append << cors_option_integrations(m['path'])
979
+ append << cors_option_integrations(m['path'], m['cors'])
496
980
  end
497
981
 
982
+
498
983
  if !m['iam_role']
499
984
  m['uri'] ||= "*" if m['integrate_with']['type'] == "aws_generic"
500
985
 
@@ -516,7 +1001,7 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
516
1001
  "targets" => [{ "identifier" => m['uri'] }]
517
1002
  }
518
1003
  ]
519
- elsif m['integrate_with']['type'] == "function"
1004
+ elsif m['integrate_with']['type'] == "functions"
520
1005
  roledesc["import"] = ["AWSLambdaBasicExecutionRole"]
521
1006
  end
522
1007
  configurator.insertKitten(roledesc, "roles")
@@ -534,7 +1019,7 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
534
1019
  ok
535
1020
  end
536
1021
 
537
- def self.cors_option_integrations(path)
1022
+ def self.cors_option_integrations(path, origins)
538
1023
  {
539
1024
  "type" => "OPTIONS",
540
1025
  "path" => path,
@@ -555,7 +1040,7 @@ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials
555
1040
  },
556
1041
  {
557
1042
  "header" => "Access-Control-Allow-Origin",
558
- "value" => "*",
1043
+ "value" => origins,
559
1044
  "required" => true
560
1045
  }
561
1046
  ],