cloud-mu 2.0.0.pre.alpha9 → 2.0.0.pre.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/Berksfile.lock +1 -1
  3. data/README.md +2 -0
  4. data/bin/mu-configure +2 -58
  5. data/bin/mu-gen-docs +29 -4
  6. data/bin/mu-load-config.rb +0 -1
  7. data/bin/mu-user-manage +4 -0
  8. data/cloud-mu.gemspec +2 -2
  9. data/cookbooks/mu-master/recipes/default.rb +3 -4
  10. data/cookbooks/mu-master/recipes/init.rb +3 -3
  11. data/cookbooks/mu-tools/files/default/Mu_CA.pem +15 -15
  12. data/cookbooks/mu-tools/libraries/helper.rb +1 -1
  13. data/cookbooks/mu-tools/recipes/eks.rb +3 -3
  14. data/cookbooks/mu-tools/recipes/set_local_fw.rb +1 -1
  15. data/cookbooks/mu-utility/recipes/remi.rb +1 -1
  16. data/cookbooks/nagios/libraries/base.rb +4 -4
  17. data/cookbooks/nagios/libraries/contact.rb +1 -1
  18. data/cookbooks/nagios/libraries/contactgroup.rb +1 -1
  19. data/cookbooks/nagios/libraries/host.rb +2 -2
  20. data/cookbooks/nagios/libraries/hostdependency.rb +3 -3
  21. data/cookbooks/nagios/libraries/hostescalation.rb +3 -3
  22. data/cookbooks/nagios/libraries/hostgroup.rb +2 -2
  23. data/cookbooks/nagios/libraries/nagios.rb +5 -5
  24. data/cookbooks/nagios/libraries/service.rb +3 -3
  25. data/cookbooks/nagios/libraries/servicedependency.rb +2 -2
  26. data/cookbooks/nagios/libraries/serviceescalation.rb +2 -2
  27. data/cookbooks/nagios/libraries/servicegroup.rb +2 -2
  28. data/cookbooks/nagios/libraries/timeperiod.rb +1 -1
  29. data/install/installer +1 -1
  30. data/modules/mu/cleanup.rb +1 -1
  31. data/modules/mu/cloud.rb +43 -1
  32. data/modules/mu/clouds/aws.rb +55 -35
  33. data/modules/mu/clouds/aws/bucket.rb +287 -0
  34. data/modules/mu/clouds/aws/database.rb +65 -11
  35. data/modules/mu/clouds/aws/endpoint.rb +592 -0
  36. data/modules/mu/clouds/aws/firewall_rule.rb +4 -0
  37. data/modules/mu/clouds/aws/function.rb +138 -93
  38. data/modules/mu/clouds/aws/nosqldb.rb +387 -0
  39. data/modules/mu/clouds/aws/role.rb +1 -1
  40. data/modules/mu/clouds/aws/server.rb +5 -5
  41. data/modules/mu/clouds/aws/server_pool.rb +60 -3
  42. data/modules/mu/clouds/azure.rb +0 -1
  43. data/modules/mu/clouds/google.rb +34 -12
  44. data/modules/mu/clouds/google/bucket.rb +179 -0
  45. data/modules/mu/config.rb +1 -1
  46. data/modules/mu/config/bucket.rb +69 -0
  47. data/modules/mu/config/bucket.yml +10 -0
  48. data/modules/mu/config/database.rb +1 -1
  49. data/modules/mu/config/endpoint.rb +71 -0
  50. data/modules/mu/config/function.rb +6 -0
  51. data/modules/mu/config/nosqldb.rb +49 -0
  52. data/modules/mu/config/nosqldb.yml +44 -0
  53. data/modules/mu/config/notifier.yml +2 -2
  54. data/modules/mu/config/vpc.rb +0 -1
  55. data/modules/mu/defaults/amazon_images.yaml +32 -30
  56. data/modules/mu/groomers/chef.rb +1 -1
  57. data/modules/mu/kittens.rb +2430 -1511
  58. data/modules/mu/master/ldap.rb +1 -1
  59. data/modules/tests/super_complex_bok.yml +7 -0
  60. data/modules/tests/super_simple_bok.yml +7 -0
  61. metadata +11 -2
@@ -0,0 +1,287 @@
1
+ # Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved
2
+ #
3
+ # Licensed under the BSD-3 license (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License in the root of the project or at
6
+ #
7
+ # http://egt-labs.com/mu/LICENSE.html
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ module MU
16
+ class Cloud
17
+ class AWS
18
+ # Support for AWS S3
19
+ class Bucket < MU::Cloud::Bucket
20
+ @deploy = nil
21
+ @config = nil
22
+
23
+ @@region_cache = {}
24
+ @@region_cache_semaphore = Mutex.new
25
+
26
+ attr_reader :mu_name
27
+ attr_reader :config
28
+ attr_reader :cloud_id
29
+
30
+ # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member.
31
+ # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::logs}
32
+ def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil)
33
+ @deploy = mommacat
34
+ @config = MU::Config.manxify(kitten_cfg)
35
+ @cloud_id ||= cloud_id
36
+ @mu_name ||= @deploy.getResourceName(@config["name"])
37
+ end
38
+
39
+ # Called automatically by {MU::Deploy#createResources}
40
+ def create
41
+ bucket_name = @deploy.getResourceName(@config["name"], max_length: 63).downcase
42
+ MU::Cloud::AWS.s3(credentials: @config['credentials'], region: @config['region']).create_bucket(
43
+ acl: @config['acl'],
44
+ bucket: bucket_name
45
+ )
46
+ @cloud_id = bucket_name
47
+
48
+ @@region_cache_semaphore.synchronize {
49
+ @@region_cache[@cloud_id] ||= @config['region']
50
+ }
51
+
52
+ tagBucket if !@config['scrub_mu_isms']
53
+ end
54
+
55
+ # Apply tags to this bucket object
56
+ def tagBucket
57
+ tagset = []
58
+
59
+ MU::MommaCat.listStandardTags.each_pair { |key, value|
60
+ tagset << { :key => key, :value => value }
61
+ }
62
+
63
+ if @config['tags']
64
+ @config['tags'].each { |tag|
65
+ tagset << { :key => tag['key'], :value => tag['value'] }
66
+ }
67
+ end
68
+
69
+ if @config['optional_tags']
70
+ MU::MommaCat.listOptionalTags.each { |key, value|
71
+ tagset << { :key => key, :value => value }
72
+ }
73
+ end
74
+
75
+ MU::Cloud::AWS.s3(credentials: @config['credentials'], region: @config['region']).put_bucket_tagging(
76
+ bucket: @cloud_id,
77
+ tagging: {
78
+ tag_set: tagset
79
+ }
80
+ )
81
+
82
+ end
83
+
84
+ # Called automatically by {MU::Deploy#createResources}
85
+ def groom
86
+ @@region_cache_semaphore.synchronize {
87
+ @@region_cache[@cloud_id] ||= @config['region']
88
+ }
89
+ tagBucket if !@config['scrub_mu_isms']
90
+
91
+ current = cloud_desc
92
+
93
+ if @config['web'] and current["website"].nil?
94
+ MU.log "Enabling web service on S3 bucket #{@cloud_id}", MU::NOTICE
95
+ MU::Cloud::AWS.s3(credentials: @config['credentials'], region: @config['region']).put_bucket_website(
96
+ bucket: @cloud_id,
97
+ website_configuration: {
98
+ error_document: {
99
+ key: @config['web_error_object']
100
+ },
101
+ index_document: {
102
+ suffix: @config['web_index_object']
103
+ }
104
+ }
105
+ )
106
+ elsif !@config['web'] and !current["website"].nil?
107
+ MU.log "Disabling web service on S3 bucket #{@cloud_id}", MU::NOTICE
108
+ MU::Cloud::AWS.s3(credentials: @config['credentials'], region: @config['region']).delete_bucket_website(
109
+ bucket: @cloud_id
110
+ )
111
+ end
112
+
113
+ if @config['versioning'] and current["versioning"].status != "Enabled"
114
+ MU.log "Enabling versioning on S3 bucket #{@cloud_id}", MU::NOTICE
115
+ MU::Cloud::AWS.s3(credentials: @config['credentials'], region: @config['region']).put_bucket_versioning(
116
+ bucket: @cloud_id,
117
+ versioning_configuration: {
118
+ mfa_delete: "Disabled",
119
+ status: "Enabled"
120
+ }
121
+ )
122
+ elsif !@config['versioning'] and current["versioning"].status == "Enabled"
123
+ MU.log "Suspending versioning on S3 bucket #{@cloud_id}", MU::NOTICE
124
+ MU::Cloud::AWS.s3(credentials: @config['credentials'], region: @config['region']).put_bucket_versioning(
125
+ bucket: @cloud_id,
126
+ versioning_configuration: {
127
+ mfa_delete: "Disabled",
128
+ status: "Suspended"
129
+ }
130
+ )
131
+ end
132
+ end
133
+
134
+ # Does this resource type exist as a global (cloud-wide) artifact, or
135
+ # is it localized to a region/zone?
136
+ # @return [Boolean]
137
+ def self.isGlobal?
138
+ false
139
+ end
140
+
141
+ # Remove all buckets associated with the currently loaded deployment.
142
+ # @param noop [Boolean]: If true, will only print what would be done
143
+ # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server
144
+ # @param region [String]: The cloud provider region
145
+ # @return [void]
146
+ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {})
147
+
148
+ resp = MU::Cloud::AWS.s3(credentials: credentials, region: region).list_buckets
149
+ if resp and resp.buckets
150
+ resp.buckets.each { |bucket|
151
+ @@region_cache_semaphore.synchronize {
152
+ if @@region_cache[bucket.name]
153
+ next if @@region_cache[bucket.name] != region
154
+ else
155
+ location = MU::Cloud::AWS.s3(credentials: credentials, region: region).get_bucket_location(bucket: bucket.name).location_constraint
156
+
157
+ if location.nil? or location.empty?
158
+ @@region_cache[bucket.name] = region
159
+ else
160
+ @@region_cache[bucket.name] = location
161
+ end
162
+ end
163
+ }
164
+
165
+ if @@region_cache[bucket.name] != region
166
+ MU.log "#{bucket.name} is in #{@@region_cache[bucket.name]} but I'm checking from #{region}, skipping", MU::DEBUG
167
+ next
168
+ end
169
+
170
+ begin
171
+ tags = MU::Cloud::AWS.s3(credentials: credentials, region: region).get_bucket_tagging(bucket: bucket.name).tag_set
172
+ tags.each { |tag|
173
+ if tag.key == "MU-ID" and tag.value == MU.deploy_id
174
+ MU.log "Deleting S3 Bucket #{bucket.name}"
175
+ if !noop
176
+ MU::Cloud::AWS.s3(credentials: credentials, region: region).delete_bucket(bucket: bucket.name)
177
+ end
178
+ break
179
+ end
180
+ }
181
+ rescue Aws::S3::Errors::NoSuchTagSet, Aws::S3::Errors::PermanentRedirect
182
+ next
183
+ end
184
+ }
185
+ end
186
+ end
187
+
188
+ # Canonical Amazon Resource Number for this resource
189
+ # @return [String]
190
+ def arn
191
+ "arn:"+(MU::Cloud::AWS.isGovCloud?(@config["region"]) ? "aws-us-gov" : "aws")+":s3:::"+@cloud_id
192
+ end
193
+
194
+ # Return the metadata for this user cofiguration
195
+ # @return [Hash]
196
+ def notify
197
+ desc = MU::Cloud::AWS::Bucket.describe_bucket(@cloud_id, credentials: @config['credentials'], region: @config['region'])
198
+ MU.structToHash(desc)
199
+ end
200
+
201
+ # Locate an existing bucket.
202
+ # @param cloud_id [String]: The cloud provider's identifier for this resource.
203
+ # @param region [String]: The cloud provider region.
204
+ # @param flags [Hash]: Optional flags
205
+ # @return [OpenStruct]: The cloud provider's complete descriptions of matching bucket.
206
+ def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {})
207
+ found = {}
208
+ if cloud_id
209
+ found[cloud_id] = describe_bucket(cloud_id, minimal: true, credentials: credentials, region: region)
210
+ end
211
+ found
212
+ end
213
+
214
+ # Cloud-specific configuration properties.
215
+ # @param config [MU::Config]: The calling MU::Config object
216
+ # @return [Array<Array,Hash>]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource
217
+ def self.schema(config)
218
+ toplevel_required = []
219
+ schema = {
220
+ "acl" => {
221
+ "type" => "string",
222
+ "enum" => ["private", "public-read", "public-read-write", "authenticated-read"],
223
+ "default" => "private"
224
+ },
225
+ "storage_class" => {
226
+ "type" => "string",
227
+ "enum" => ["STANDARD", "REDUCED_REDUNDANCY", "STANDARD_IA", "ONEZONE_IA", "INTELLIGENT_TIERING", "GLACIER"],
228
+ "default" => "STANDARD"
229
+ }
230
+ }
231
+ [toplevel_required, schema]
232
+ end
233
+
234
+ # Cloud-specific pre-processing of {MU::Config::BasketofKittens::bucket}, bare and unvalidated.
235
+
236
+ # @param bucket [Hash]: The resource to process and validate
237
+ # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member
238
+ # @return [Boolean]: True if validation succeeded, False otherwise
239
+ def self.validateConfig(bucket, configurator)
240
+ ok = true
241
+
242
+ ok
243
+ end
244
+
245
+ private
246
+
247
+ # AWS doesn't really implement a useful describe_ method for S3 buckets;
248
+ # instead we run the million little individual API calls to construct
249
+ # an approximation for our uses
250
+ def self.describe_bucket(bucket, minimal: false, credentials: nil, region: nil)
251
+ @@region_cache = {}
252
+ @@region_cache_semaphore = Mutex.new
253
+ calls = if minimal
254
+ %w{encryption lifecycle lifecycle_configuration location logging policy replication tagging versioning website}
255
+ else
256
+ %w{accelerate_configuration acl cors encryption lifecycle lifecycle_configuration location logging notification notification_configuration policy policy_status replication request_payment tagging versioning website} # XXX analytics_configuration, inventory_configuration, metrics_configuration all require an id of some sort
257
+ end
258
+
259
+ desc = {}
260
+
261
+ calls.each { |method|
262
+ method_sym = ("get_bucket_"+method).to_sym
263
+ # "The horrors of this place claw at your mind"
264
+ begin
265
+ desc[method] = MU::Cloud::AWS.s3(credentials: credentials, region: region).method_missing(method_sym, {:bucket => bucket})
266
+ if method == "location"
267
+ @@region_cache_semaphore.synchronize {
268
+ if desc[method].location_constraint.nil? or desc[method].location_constraint.empty?
269
+ @@region_cache[bucket] = region
270
+ else
271
+ @@region_cache[bucket] = desc[method].location_constraint
272
+ end
273
+ }
274
+ end
275
+
276
+ rescue Aws::S3::Errors::NoSuchCORSConfiguration, Aws::S3::Errors::ServerSideEncryptionConfigurationNotFoundError, Aws::S3::Errors::NoSuchLifecycleConfiguration, Aws::S3::Errors::NoSuchBucketPolicy, Aws::S3::Errors::ReplicationConfigurationNotFoundError, Aws::S3::Errors::NoSuchTagSet, Aws::S3::Errors::NoSuchWebsiteConfiguration => e
277
+ desc[method] = nil
278
+ next
279
+ end
280
+ }
281
+ desc
282
+ end
283
+
284
+ end
285
+ end
286
+ end
287
+ end
@@ -364,18 +364,16 @@ module MU
364
364
  end
365
365
  elsif @config["creation_style"] == "new"
366
366
  MU.log "Creating pristine database instance #{@config['identifier']} (#{@config['name']}) in #{@config['region']}"
367
- puts @config['credentials']
368
- pp config
369
367
  resp = MU::Cloud::AWS.rds(region: @config['region'], credentials: @config['credentials']).create_db_instance(config)
370
368
  end
371
369
  rescue Aws::RDS::Errors::InvalidParameterValue => e
372
370
  if attempts < 5
373
- MU.log "Got #{e.inspect} creating #{@config['identifier']}, will retry a few times in case of transient errors.", MU::WARN
371
+ MU.log "Got #{e.inspect} creating #{@config['identifier']}, will retry a few times in case of transient errors.", MU::WARN, details: config
374
372
  attempts += 1
375
373
  sleep 10
376
374
  retry
377
375
  else
378
- raise MuError, "Exhausted retries trying to create database instance #{@config['identifier']}: e.inspect"
376
+ raise MuError, "Exhausted retries trying to create database instance #{@config['identifier']}: #{e.inspect}"
379
377
  end
380
378
  end
381
379
 
@@ -497,6 +495,18 @@ pp config
497
495
  }
498
496
  cluster_config_struct[:port] = @config["port"] if @config["port"]
499
497
 
498
+ if @config['cluster_mode']
499
+ cluster_config_struct[:engine_mode] = @config['cluster_mode']
500
+ if @config['cluster_mode'] == "serverless"
501
+ cluster_config_struct[:scaling_configuration] = {
502
+ :auto_pause => @config['serverless_scaling']['auto_pause'],
503
+ :min_capacity => @config['serverless_scaling']['min_capacity'],
504
+ :max_capacity => @config['serverless_scaling']['max_capacity'],
505
+ :seconds_until_auto_pause => @config['serverless_scaling']['seconds_until_auto_pause']
506
+ }
507
+ end
508
+ end
509
+
500
510
  if %w{existing_snapshot new_snapshot}.include?(@config["creation_style"])
501
511
  cluster_config_struct[:snapshot_identifier] = @config["snapshot_id"]
502
512
  cluster_config_struct[:engine] = @config["engine"]
@@ -537,12 +547,13 @@ pp config
537
547
  end
538
548
  rescue Aws::RDS::Errors::InvalidParameterValue => e
539
549
  if attempts < 5
540
- MU.log "Got #{e.inspect} while creating database cluster #{@config['identifier']}, will retry a few times in case of transient errors.", MU::WARN
550
+ MU.log "Got #{e.inspect} while creating database cluster #{@config['identifier']}, will retry a few times in case of transient errors.", MU::WARN, details: cluster_config_struct
541
551
  attempts += 1
542
552
  sleep 10
543
553
  retry
544
554
  else
545
- raise MuError, "Exhausted retries trying to create database cluster #{@config['identifier']}", MU::ERR, details: e.inspect
555
+ MU.log "Exhausted retries trying to create database cluster #{@config['identifier']}", MU::ERR, details: e.inspect
556
+ raise MuError, "Exhausted retries trying to create database cluster #{@config['identifier']}"
546
557
  end
547
558
  end
548
559
 
@@ -1414,6 +1425,45 @@ pp config
1414
1425
  schema = {
1415
1426
  "db_parameter_group_parameters" => rds_parameters_primitive,
1416
1427
  "cluster_parameter_group_parameters" => rds_parameters_primitive,
1428
+ "cluster_mode" => {
1429
+ "type" => "string",
1430
+ "description" => "The DB engine mode of the DB cluster",
1431
+ "enum" => ["provisioned", "serverless", "parallelquery", "global"],
1432
+ "default" => "provisioned"
1433
+ },
1434
+ "serverless_scaling" => {
1435
+ "type" => "object",
1436
+ "descriptions" => "Scaling configuration for a +serverless+ Aurora cluster",
1437
+ "default" => {
1438
+ "auto_pause" => false,
1439
+ "min_capacity" => 2,
1440
+ "max_capacity" => 2
1441
+ },
1442
+ "properties" => {
1443
+ "auto_pause" => {
1444
+ "type" => "boolean",
1445
+ "description" => "A value that specifies whether to allow or disallow automatic pause for an Aurora DB cluster in serverless DB engine mode",
1446
+ "default" => false
1447
+ },
1448
+ "min_capacity" => {
1449
+ "type" => "integer",
1450
+ "description" => "The minimum capacity for an Aurora DB cluster in serverless DB engine mode.",
1451
+ "default" => 2,
1452
+ "enum" => [2, 4, 8, 16, 32, 64, 128, 256]
1453
+ },
1454
+ "max_capacity" => {
1455
+ "type" => "integer",
1456
+ "description" => "The maximum capacity for an Aurora DB cluster in serverless DB engine mode.",
1457
+ "default" => 2,
1458
+ "enum" => [2, 4, 8, 16, 32, 64, 128, 256]
1459
+ },
1460
+ "seconds_until_auto_pause" => {
1461
+ "type" => "integer",
1462
+ "description" => "A DB cluster can be paused only when it's idle (it has no connections). If a DB cluster is paused for more than seven days, the DB cluster might be backed up with a snapshot. In this case, the DB cluster is restored when there is a request to connect to it.",
1463
+ "default" => 86400
1464
+ }
1465
+ }
1466
+ },
1417
1467
  "license_model" => {
1418
1468
  "type" => "string",
1419
1469
  "enum" => ["license-included", "bring-your-own-license", "general-public-license", "postgresql-license"]
@@ -1452,7 +1502,11 @@ pp config
1452
1502
  if db['create_cluster'] or db['engine'] == "aurora" or db["member_of_cluster"]
1453
1503
  case db['engine']
1454
1504
  when "mysql", "aurora", "aurora-mysql"
1455
- db["engine"] = "aurora-mysql"
1505
+ if db["engine_version"] == "5.6" or db["cluster_mode"] == "serverless"
1506
+ db["engine"] = "aurora"
1507
+ else
1508
+ db["engine"] = "aurora-mysql"
1509
+ end
1456
1510
  when "postgres", "postgresql", "postgresql-mysql"
1457
1511
  db["engine"] = "aurora-postgresql"
1458
1512
  else
@@ -1671,14 +1725,14 @@ pp config
1671
1725
  end
1672
1726
 
1673
1727
  # Cleanup the database vault
1674
- grommer =
1728
+ groomer =
1675
1729
  if database_obj
1676
1730
  database_obj.config.has_key?("groomer") ? database_obj.config["groomer"] : MU::Config.defaultGroomer
1677
1731
  else
1678
1732
  MU::Config.defaultGroomer
1679
1733
  end
1680
1734
 
1681
- groomclass = MU::Groomer.loadGroomer(grommer)
1735
+ groomclass = MU::Groomer.loadGroomer(groomer)
1682
1736
  groomclass.deleteSecret(vault: db_id.upcase) if !noop
1683
1737
  MU.log "#{db_id} has been terminated"
1684
1738
  end
@@ -1761,14 +1815,14 @@ pp config
1761
1815
  end
1762
1816
 
1763
1817
  # Cleanup the cluster vault
1764
- grommer =
1818
+ groomer =
1765
1819
  if cluster_obj
1766
1820
  cluster_obj.config.has_key?("groomer") ? cluster_obj.config["groomer"] : MU::Config.defaultGroomer
1767
1821
  else
1768
1822
  MU::Config.defaultGroomer
1769
1823
  end
1770
1824
 
1771
- groomclass = MU::Groomer.loadGroomer(grommer)
1825
+ groomclass = MU::Groomer.loadGroomer(groomer)
1772
1826
  groomclass.deleteSecret(vault: cluster_id.upcase) if !noop
1773
1827
 
1774
1828
  MU.log "#{cluster_id} has been terminated"
@@ -0,0 +1,592 @@
1
+ module MU
2
+ class Cloud
3
+ class AWS
4
+ # An API as configured in {MU::Config::BasketofKittens::endpoints}
5
+ class Endpoint < MU::Cloud::Endpoint
6
+ @deploy = nil
7
+ @config = nil
8
+ attr_reader :mu_name
9
+ attr_reader :config
10
+ attr_reader :cloud_id
11
+
12
+ @cloudformation_data = {}
13
+ attr_reader :cloudformation_data
14
+
15
+ # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member.
16
+ # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::endpoints}
17
+ def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil)
18
+ @deploy = mommacat
19
+ @config = MU::Config.manxify(kitten_cfg)
20
+ @cloud_id ||= cloud_id
21
+ @mu_name ||= @deploy.getResourceName(@config["name"])
22
+ end
23
+
24
+ # Called automatically by {MU::Deploy#createResources}
25
+ def create
26
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_rest_api(
27
+ name: @mu_name,
28
+ description: @deploy.deploy_id,
29
+ endpoint_configuration: {
30
+ types: ["REGIONAL"] # XXX expose in BoK ["REGIONAL", "EDGE", "PRIVATE"]
31
+ }
32
+ )
33
+ @cloud_id = resp.id
34
+ generate_methods
35
+
36
+
37
+ end
38
+
39
+ # Create/update all of the methods declared for this endpoint
40
+ def generate_methods
41
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_resources(
42
+ rest_api_id: @cloud_id,
43
+ )
44
+ root_resource = resp.items.first.id
45
+
46
+ # TODO guard this crap so we don't touch it if there are no changes
47
+ @config['methods'].each { |m|
48
+ 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']}"
49
+
50
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_resources(
51
+ rest_api_id: @cloud_id
52
+ )
53
+ ext_resource = nil
54
+ resp.items.each { |resource|
55
+ if resource.path_part == m['path']
56
+ ext_resource = resource.id
57
+ end
58
+ }
59
+
60
+ resp = if ext_resource
61
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_resource(
62
+ rest_api_id: @cloud_id,
63
+ resource_id: ext_resource,
64
+ )
65
+ # MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).update_resource(
66
+ # rest_api_id: @cloud_id,
67
+ # resource_id: ext_resource,
68
+ # patch_operations: [
69
+ # {
70
+ # op: "replace",
71
+ # path: "XXX ??",
72
+ # value: m["path"]
73
+ # }
74
+ # ]
75
+ # )
76
+ else
77
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_resource(
78
+ rest_api_id: @cloud_id,
79
+ parent_id: root_resource,
80
+ path_part: m['path']
81
+ )
82
+ end
83
+ parent_id = resp.id
84
+
85
+ resp = begin
86
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_method(
87
+ rest_api_id: @cloud_id,
88
+ resource_id: parent_id,
89
+ http_method: m['type']
90
+ )
91
+ rescue Aws::APIGateway::Errors::NotFoundException
92
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).put_method(
93
+ rest_api_id: @cloud_id,
94
+ resource_id: parent_id,
95
+ authorization_type: m['auth'],
96
+ http_method: m['type']
97
+ )
98
+ end
99
+
100
+ # XXX effectively a placeholder default
101
+ begin
102
+ m['responses'].each { |r|
103
+ params = {
104
+ :rest_api_id => @cloud_id,
105
+ :resource_id => parent_id,
106
+ :http_method => m['type'],
107
+ :status_code => r['code'].to_s
108
+ }
109
+ if r['headers']
110
+ params[:response_parameters] = r['headers'].map { |h|
111
+ ["method.response.header."+h['header'], h['required']]
112
+ }.to_h
113
+ end
114
+
115
+ if r['body']
116
+ # XXX I'm guessing we can also have arbirary user-defined models somehow, so is_error is probably inadequate to the demand of the times
117
+ params[:response_models] = r['body'].map { |b| [b['content_type'], b['is_error'] ? "Error" : "Empty"] }.to_h
118
+ end
119
+
120
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).put_method_response(params)
121
+ }
122
+ rescue Aws::APIGateway::Errors::ConflictException
123
+ # fine to ignore
124
+ end
125
+
126
+ if m['integrate_with']
127
+ role_arn = if m['iam_role']
128
+ if m['iam_role'].match(/^arn:/)
129
+ m['iam_role']
130
+ else
131
+ sib_role = @deploy.findLitterMate(name: m['iam_role'], type: "roles")
132
+ sib_role.cloudobj.arn
133
+ # XXX make this more like get_role_arn in Function, or just use Role.find?
134
+ end
135
+ end
136
+
137
+ function_obj = nil
138
+
139
+ uri, type = if m['integrate_with']['type'] == "aws_generic"
140
+ svc, action = m['integrate_with']['aws_generic_action'].split(/:/)
141
+ ["arn:aws:apigateway:"+@config['region']+":#{svc}:action/#{action}", "AWS"]
142
+ elsif m['integrate_with']['type'] == "function"
143
+ function_obj = @deploy.findLitterMate(name: m['integrate_with']['name'], type: "functions").cloudobj
144
+ ["arn:aws:apigateway:"+@config['region']+":lambda:path/2015-03-31/functions/"+function_obj.arn+"/invocations", "AWS"]
145
+ elsif m['integrate_with']['type'] == "mock"
146
+ [nil, "MOCK"]
147
+ end
148
+
149
+ params = {
150
+ :rest_api_id => @cloud_id,
151
+ :resource_id => parent_id,
152
+ :type => type, # XXX Lambda and Firehose can do AWS_PROXY
153
+ :content_handling => "CONVERT_TO_TEXT", # XXX expose in BoK
154
+ :http_method => m['type']
155
+ # credentials: role_arn
156
+ }
157
+ params[:uri] = uri if uri
158
+
159
+ if m['integrate_with']['type'] != "mock"
160
+ params[:integration_http_method] = m['integrate_with']['backend_http_method']
161
+ else
162
+ params[:integration_http_method] = nil
163
+ end
164
+
165
+ if m['integrate_with']['passthrough_behavior']
166
+ params[:passthrough_behavior] = m['integrate_with']['passthrough_behavior']
167
+ end
168
+ if m['integrate_with']['request_templates']
169
+ params[:request_templates] = {}
170
+ m['integrate_with']['request_templates'].each { |rt|
171
+ params[:request_templates][rt['content_type']] = rt['template']
172
+ }
173
+ end
174
+
175
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).put_integration(params)
176
+
177
+ if m['integrate_with']['type'] == "function"
178
+ function_obj.addTrigger(method_arn, "apigateway", @config['name'])
179
+ end
180
+
181
+ m['responses'].each { |r|
182
+ params = {
183
+ :rest_api_id => @cloud_id,
184
+ :resource_id => parent_id,
185
+ :http_method => m['type'],
186
+ :status_code => r['code'].to_s,
187
+ :selection_pattern => ""
188
+ }
189
+ if r['headers']
190
+ params[:response_parameters] = r['headers'].map { |h|
191
+ ["method.response.header."+h['header'], "'"+h['value']+"'"]
192
+ }.to_h
193
+ end
194
+
195
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).put_integration_response(params)
196
+
197
+ }
198
+
199
+ end
200
+
201
+ }
202
+ end
203
+
204
+ # Called automatically by {MU::Deploy#createResources}
205
+ def groom
206
+ generate_methods
207
+
208
+ MU.log "Deploying API Gateway #{@config['name']} to #{@config['deploy_to']}"
209
+ resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_deployment(
210
+ rest_api_id: @cloud_id,
211
+ stage_name: @config['deploy_to']
212
+ # cache_cluster_enabled: false,
213
+ # cache_cluster_size: 0.5,
214
+ )
215
+ deployment_id = resp.id
216
+ # this automatically creates a stage with the same name, so we don't
217
+ # have to deal with that
218
+
219
+ my_url = "https://"+@cloud_id+".execute-api."+@config['region']+".amazonaws.com/"+@config['deploy_to']
220
+ MU.log "API Endpoint #{@config['name']}: "+my_url, MU::SUMMARY
221
+
222
+ # resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_authorizer(
223
+ # rest_api_id: @cloud_id,
224
+ # )
225
+
226
+ # resp = MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).create_vpc_link(
227
+ # )
228
+
229
+ end
230
+
231
+ # @return [Struct]
232
+ def cloud_desc
233
+ MU::Cloud::AWS.apig(region: @config['region'], credentials: @config['credentials']).get_rest_api(
234
+ rest_api_id: @cloud_id
235
+ )
236
+ end
237
+
238
+ # Return the metadata for this API
239
+ # @return [Hash]
240
+ def notify
241
+ deploy_struct = MU.structToHash(cloud_desc)
242
+ # XXX stages and whatnot
243
+ return deploy_struct
244
+ end
245
+
246
+ # Remove all APIs associated with the currently loaded deployment.
247
+ # @param noop [Boolean]: If true, will only print what would be done
248
+ # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server
249
+ # @param region [String]: The cloud provider region
250
+ # @return [void]
251
+ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {})
252
+ resp = MU::Cloud::AWS.apig(region: region, credentials: credentials).get_rest_apis
253
+ if resp and resp.items
254
+ resp.items.each { |api|
255
+ # The stupid things don't have tags
256
+ if api.description == MU.deploy_id
257
+ MU.log "Deleting API Gateway #{api.name} (#{api.id})"
258
+ if !noop
259
+ MU::Cloud::AWS.apig(region: region, credentials: credentials).delete_rest_api(
260
+ rest_api_id: api.id
261
+ )
262
+ end
263
+ end
264
+ }
265
+ end
266
+ end
267
+
268
+ # Locate an existing API.
269
+ # @param cloud_id [String]: The cloud provider's identifier for this resource.
270
+ # @param region [String]: The cloud provider region.
271
+ # @param flags [Hash]: Optional flags
272
+ # @return [OpenStruct]: The cloud provider's complete descriptions of matching API.
273
+ def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {})
274
+ if cloud_id
275
+ return MU::Cloud::AWS.apig(region: region, credentials: credentials).get_rest_api(
276
+ rest_api_id: cloud_id
277
+ )
278
+ end
279
+ # resp = MU::Cloud::AWS.apig(region: region, credentials: credentials).get_rest_apis
280
+ # if resp and resp.items
281
+ # resp.items.each { |api|
282
+ # }
283
+ # end
284
+ nil
285
+ end
286
+
287
+ # Cloud-specific configuration properties.
288
+ # @param config [MU::Config]: The calling MU::Config object
289
+ # @return [Array<Array,Hash>]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource
290
+ def self.schema(config)
291
+ toplevel_required = []
292
+ schema = {
293
+ "deploy_to" => {
294
+ "type" => "string",
295
+ "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."
296
+ },
297
+ "methods" => {
298
+ "items" => {
299
+ "type" => "object",
300
+ "description" => "Other cloud resources to integrate as a back end to this API Gateway",
301
+ "required" => ["integrate_with"],
302
+ "properties" => {
303
+ "integrate_with" => {
304
+ "type" => "object",
305
+ "description" => "Specify what application backend to invoke under this path/method combination",
306
+ "properties" => {
307
+ "proxy" => {
308
+ "type" => "boolean",
309
+ "default" => false,
310
+ "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?
311
+ },
312
+ "backend_http_method" => {
313
+ "type" => "string",
314
+ "description" => "The HTTP method to use when contacting our integrated backend. If not specified, this will be set to match our front end.",
315
+ "enum" => ["GET", "POST", "PUT", "HEAD", "DELETE", "CONNECT", "OPTIONS", "TRACE"],
316
+ },
317
+ "url" => {
318
+ "type" => "string",
319
+ "description" => "For HTTP or HTTP_PROXY integrations, this should be a fully-qualified URL"
320
+ },
321
+ "responses"=> {
322
+ "type" => "array",
323
+ "items" => {
324
+ "type" => "object",
325
+ "description" => "Customize the response to the client for this method, by adding headers or transforming through a template. If not specified, we will default to returning an un-transformed HTTP 200 for this method.",
326
+ "properties" => {
327
+ "code" => {
328
+ "type" => "integer",
329
+ "description" => "The HTTP status code to return",
330
+ "default" => 200
331
+ },
332
+ "headers" => {
333
+ "type" => "array",
334
+ "items" => {
335
+ "description" => "One or more headers, used by the API Gateway integration response and filtered through the method response before returning to the client",
336
+ "type" => "object",
337
+ "properties" => {
338
+ "header" => {
339
+ "type" => "string",
340
+ "description" => "The name of a header to return, such as +Access-Control-Allow-Methods+"
341
+ },
342
+ "value" => {
343
+ "type" => "string",
344
+ "description" => "The string to map to this header (ex +GET,OPTIONS+)"
345
+ },
346
+ "required" => {
347
+ "type" => "boolean",
348
+ "description" => "Indicate whether this header is required in order to return a response",
349
+ "default" => true
350
+ }
351
+ }
352
+ }
353
+ },
354
+ "body" => {
355
+ "type" => "array",
356
+ "items" => {
357
+ "type" => "object",
358
+ "description" => "Model for the body of our backend integration's response",
359
+ "properties" => {
360
+ "content_type" => {
361
+ "type" => "string",
362
+ "description" => "An HTTP content type to match to a response, such as +application/json+."
363
+ },
364
+ "is_error" => {
365
+ "type" => "boolean",
366
+ "description" => "Whether this response should be considered an error",
367
+ "default" => false
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
373
+ }
374
+ },
375
+ "arn" => {
376
+ "type" => "string",
377
+ "description" => "For AWS or AWS_PROXY integrations with a compatible Amazon resource outside of Mu, a full-qualified ARN such as `arn:aws:apigateway:us-west-2:s3:action/GetObject&Bucket=`bucket&Key=key`"
378
+ },
379
+ "name" => {
380
+ "type" => "string",
381
+ "description" => "A Mu resource name, for integrations with a sibling resource (e.g. a Function)"
382
+ },
383
+ "cors" => {
384
+ "type" => "boolean",
385
+ "description" => "When enabled, this will create an +OPTIONS+ method under this path with request and response header mappings that implement Cross-Origin Resource Sharing",
386
+ "default" => true
387
+ },
388
+ "type" => {
389
+ "type" => "string",
390
+ "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.",
391
+ "enum" => ["aws_generic"].concat(MU::Cloud.resource_types.values.map { |t| t[:cfg_name] }.sort)
392
+ },
393
+ "aws_generic_action" => {
394
+ "type" => "string",
395
+ "description" => "For use when +type+ is set to +aws_generic+, this should specify the action to be performed in the style of an IAM policy action, e.g. +acm:ListCertificates+ for this integration to return a list of Certificate Manager SSL certificates."
396
+ },
397
+ "deploy_id" => {
398
+ "type" => "string",
399
+ "description" => "A Mu deploy id (e.g. DEMO-DEV-2014111400-NG), for integrations with a sibling resource (e.g. a Function)"
400
+ },
401
+ "iam_role" => {
402
+ "type" => "string",
403
+ "description" => "The name of an IAM role used to grant usage of other AWS artifacts for this integration. If not specified, we will automatically generate an appropriate role."
404
+ },
405
+ "passthrough_behavior" => {
406
+ "type" => "string",
407
+ "description" => "Specifies the pass-through behavior for incoming requests based on the +Content-Type+ header in the request, and the available mapping templates specified in +request_templates+. +WHEN_NO_MATCH+ passes the request body for unmapped content types through to the integration back end without transformation. +WHEN_NO_TEMPLATES+ allows pass-through when the integration has NO content types mapped to templates. +NEVER+ rejects unmapped content types with an HTTP +415+.",
408
+ "enum" => ["WHEN_NO_MATCH", "WHEN_NO_TEMPLATES", "NEVER"],
409
+ "default" => "WHEN_NO_MATCH"
410
+ },
411
+ "request_templates" => {
412
+ "type" => "array",
413
+ "description" => "A JSON-encoded string which represents a map of Velocity templates that are applied on the request payload based on the value of the +Content-Type+ header sent by the client. The content type value is the key in this map, and the template (as a String) is the value.",
414
+ "items" => {
415
+ "type" => "object",
416
+ "description" => "A JSON-encoded string which represents a map of Velocity templates that are applied on the request payload based on the value of the +Content-Type+ header sent by the client. The content type value is the key in this map, and the template (as a String) is the value.",
417
+ "require" => ["content_type", "template"],
418
+ "properties" => {
419
+ "content_type" => {
420
+ "type" => "string",
421
+ "description" => "An HTTP content type to match with a template, such as +application/json+."
422
+ },
423
+ "template" => {
424
+ "type" => "string",
425
+ "description" => "A Velocity template to apply to our reques payload, encoded as a one-line string, like: "+'<tt>"#set($allParams = $input.params())\\n{\\n\\"url_data_json_encoded\\":\\"$input.params(\'url\')\\"\\n}"</tt>'
426
+ }
427
+ }
428
+ }
429
+ }
430
+ }
431
+ },
432
+ "auth" => {
433
+ "type" => "string",
434
+ "enum" => ["NONE", "CUSTOM", "AWS_IAM", "COGNITO_USER_POOLS"],
435
+ "default" => "NONE"
436
+ }
437
+ }
438
+ }
439
+ }
440
+ }
441
+ [toplevel_required, schema]
442
+ end
443
+
444
+ # Does this resource type exist as a global (cloud-wide) artifact, or
445
+ # is it localized to a region/zone?
446
+ # @return [Boolean]
447
+ def self.isGlobal?
448
+ false
449
+ end
450
+
451
+ # Canonical Amazon Resource Number for this resource
452
+ # @return [String]
453
+ def arn
454
+ "arn:#{MU::Cloud::AWS.isGovCloud?(@config["region"]) ? "aws-us-gov" : "aws"}:execute-api:#{@config["region"]}:#{MU::Cloud::AWS.credToAcct(@config['credentials'])}:#{@cloud_id}"
455
+ end
456
+
457
+
458
+ # Cloud-specific pre-processing of {MU::Config::BasketofKittens::endpoints}, bare and unvalidated.
459
+ # @param endpoint [Hash]: The resource to process and validate
460
+ # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member
461
+ # @return [Boolean]: True if validation succeeded, False otherwise
462
+ def self.validateConfig(endpoint, configurator)
463
+ ok = true
464
+
465
+ append = []
466
+ endpoint['deploy_to'] ||= MU.environment || $environment || "dev"
467
+ endpoint['methods'].each { |m|
468
+ if m['integrate_with'] and m['integrate_with']['name']
469
+ if m['integrate_with']['type'] != "aws_generic"
470
+ endpoint['dependencies'] ||= []
471
+ endpoint['dependencies'] << {
472
+ "type" => m['integrate_with']['type'],
473
+ "name" => m['integrate_with']['name']
474
+ }
475
+ end
476
+
477
+ m['integrate_with']['backend_http_method'] ||= m['type']
478
+
479
+ m['responses'] ||= [
480
+ "code" => 200
481
+ ]
482
+
483
+ if m['cors']
484
+ m['responses'].each { |r|
485
+ r['headers'] ||= []
486
+ r['headers'] << {
487
+ "header" => "Access-Control-Allow-Origin",
488
+ "value" => "*",
489
+ "required" => true
490
+ }
491
+ r['headers'].uniq!
492
+ }
493
+
494
+ append << cors_option_integrations(m['path'])
495
+ end
496
+
497
+ if !m['iam_role']
498
+ m['uri'] ||= "*" if m['integrate_with']['type'] == "aws_generic"
499
+
500
+ roledesc = {
501
+ "name" => endpoint['name']+"-"+m['integrate_with']['name'],
502
+ "credentials" => endpoint['credentials'],
503
+ "can_assume" => [
504
+ {
505
+ "entity_id" => "apigateway.amazonaws.com",
506
+ "entity_type" => "service"
507
+ }
508
+ ],
509
+ }
510
+ if m['integrate_with']['type'] == "aws_generic"
511
+ roledesc["policies"] = [
512
+ {
513
+ "name" => m['integrate_with']['aws_generic_action'].gsub(/[^a-z]/i, ""),
514
+ "permissions" => [m['integrate_with']['aws_generic_action']],
515
+ "targets" => [{ "identifier" => m['uri'] }]
516
+ }
517
+ ]
518
+ elsif m['integrate_with']['type'] == "function"
519
+ roledesc["import"] = ["AWSLambdaBasicExecutionRole"]
520
+ end
521
+ configurator.insertKitten(roledesc, "roles")
522
+
523
+ endpoint['dependencies'] ||= []
524
+ m['iam_role'] = endpoint['name']+"-"+m['integrate_with']['name']
525
+
526
+ endpoint['dependencies'] << {
527
+ "type" => "role",
528
+ "name" => endpoint['name']+"-"+m['integrate_with']['name']
529
+ }
530
+ end
531
+ end
532
+ }
533
+ endpoint['methods'].concat(append.uniq) if endpoint['methods']
534
+ # if something_bad
535
+ # ok = false
536
+ # end
537
+
538
+ ok
539
+ end
540
+
541
+ private
542
+
543
+ def self.cors_option_integrations(path)
544
+ {
545
+ "type" => "OPTIONS",
546
+ "path" => path,
547
+ "auth" => "NONE",
548
+ "responses" => [
549
+ {
550
+ "code" => 200,
551
+ "headers" => [
552
+ {
553
+ "header" => "Access-Control-Allow-Headers",
554
+ "value" => "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
555
+ "required" => true
556
+ },
557
+ {
558
+ "header" => "Access-Control-Allow-Methods",
559
+ "value" => "GET,OPTIONS",
560
+ "required" => true
561
+ },
562
+ {
563
+ "header" => "Access-Control-Allow-Origin",
564
+ "value" => "*",
565
+ "required" => true
566
+ }
567
+ ],
568
+ "body" => [
569
+ {
570
+ "content_type" => "application/json"
571
+ }
572
+ ]
573
+ }
574
+ ],
575
+ "integrate_with" => {
576
+ "type" => "mock",
577
+ "passthrough_behavior" => "WHEN_NO_MATCH",
578
+ "backend_http_method" => "OPTIONS",
579
+ "request_templates" => [
580
+ {
581
+ "content_type" => "application/json",
582
+ "template" => '{"statusCode": 200}'
583
+ }
584
+ ]
585
+ }
586
+ }
587
+ end
588
+
589
+ end
590
+ end
591
+ end
592
+ end