openstax_aws 1.0.0

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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.travis.yml +12 -0
  4. data/CHANGELOG.md +11 -0
  5. data/Gemfile +6 -0
  6. data/Gemfile.lock +120 -0
  7. data/LICENSE.txt +1 -0
  8. data/README.md +927 -0
  9. data/Rakefile +6 -0
  10. data/TODO.md +1 -0
  11. data/assets/secrets_sequence_diagram.png +0 -0
  12. data/bin/console +14 -0
  13. data/bin/create_development_environment +26 -0
  14. data/bin/get_latest_ubuntu_ami +31 -0
  15. data/bin/setup +8 -0
  16. data/bin/templates/aws_ruby_development.yml +221 -0
  17. data/examples/deployment.rb +90 -0
  18. data/ideas.md +15 -0
  19. data/lib/openstax/aws/auto_scaling_group.rb +28 -0
  20. data/lib/openstax/aws/auto_scaling_instance.rb +96 -0
  21. data/lib/openstax/aws/build_image_command_1.rb +53 -0
  22. data/lib/openstax/aws/change_set.rb +100 -0
  23. data/lib/openstax/aws/deployment_base.rb +372 -0
  24. data/lib/openstax/aws/distribution.rb +56 -0
  25. data/lib/openstax/aws/ec2_instance_data.rb +18 -0
  26. data/lib/openstax/aws/extensions.rb +19 -0
  27. data/lib/openstax/aws/git_helper.rb +18 -0
  28. data/lib/openstax/aws/image.rb +34 -0
  29. data/lib/openstax/aws/msk_cluster.rb +19 -0
  30. data/lib/openstax/aws/packer_1_2_5.rb +63 -0
  31. data/lib/openstax/aws/packer_1_4_1.rb +72 -0
  32. data/lib/openstax/aws/packer_factory.rb +25 -0
  33. data/lib/openstax/aws/rds_instance.rb +25 -0
  34. data/lib/openstax/aws/s3_text_file.rb +50 -0
  35. data/lib/openstax/aws/sam_stack.rb +85 -0
  36. data/lib/openstax/aws/secrets.rb +302 -0
  37. data/lib/openstax/aws/secrets_factory.rb +126 -0
  38. data/lib/openstax/aws/secrets_set.rb +21 -0
  39. data/lib/openstax/aws/secrets_specification.rb +68 -0
  40. data/lib/openstax/aws/stack.rb +465 -0
  41. data/lib/openstax/aws/stack_event.rb +28 -0
  42. data/lib/openstax/aws/stack_factory.rb +153 -0
  43. data/lib/openstax/aws/stack_parameters.rb +19 -0
  44. data/lib/openstax/aws/stack_status.rb +125 -0
  45. data/lib/openstax/aws/system.rb +21 -0
  46. data/lib/openstax/aws/tag.rb +31 -0
  47. data/lib/openstax/aws/template.rb +129 -0
  48. data/lib/openstax/aws/version.rb +5 -0
  49. data/lib/openstax/aws/wait_message.rb +20 -0
  50. data/lib/openstax_aws.rb +154 -0
  51. data/openstax_aws.gemspec +58 -0
  52. metadata +350 -0
@@ -0,0 +1,68 @@
1
+ require 'yaml'
2
+ require 'erb'
3
+
4
+ module OpenStax::Aws
5
+ class SecretsSpecification
6
+
7
+ attr_reader :data
8
+
9
+ def self.from_file_name(file_name:, format:, top_key: nil, preparser: nil)
10
+ file = File.open(file_name, "r")
11
+ content = file.read
12
+ file.close
13
+ new(content: content, format: format, top_key: top_key, preparser: preparser)
14
+ end
15
+
16
+ def self.from_git(org_slash_repo:, sha:, path:, format:, top_key: nil, preparser: nil)
17
+ content = OpenStax::Aws::GitHelper.file_content_at_sha(
18
+ org_slash_repo: org_slash_repo,
19
+ sha: sha,
20
+ path: path
21
+ )
22
+ new(content: content, format: format, top_key: top_key, preparser: preparser)
23
+ end
24
+
25
+ def self.from_content(content:, format: nil, top_key: nil, preparser: nil)
26
+ new(content: content, format: format, top_key: top_key, preparser: preparser)
27
+ end
28
+
29
+ def initialize(content:, format: nil, top_key: nil, preparser: nil)
30
+ case content
31
+ when Hash
32
+ @data = content.dup
33
+ when String
34
+ raise "#{format} is not yet handled" if :yml != format
35
+
36
+ case (preparser || 'none').to_sym
37
+ when :erb
38
+ content = parse_erb(content)
39
+ end
40
+
41
+ @data = YAML.load(content)
42
+ else
43
+ raise "Unknown secrets specification inline content type: #{content.class}"
44
+ end
45
+
46
+ @data = data.with_indifferent_access
47
+ @data = data[top_key.to_s] if top_key
48
+ end
49
+
50
+ def expanded_data
51
+ flat_hash(@data).map{|k,v| [k.join('/'), v]}.to_h
52
+ end
53
+
54
+ protected
55
+
56
+ # https://stackoverflow.com/a/23861946
57
+ def flat_hash(h,f=[],g={})
58
+ return g.update({ f=>h }) unless h.is_a? Hash
59
+ h.each { |k,r| flat_hash(r,f+[k],g) }
60
+ g
61
+ end
62
+
63
+ def parse_erb(string)
64
+ (ERB.new string).result(binding)
65
+ end
66
+
67
+ end
68
+ end
@@ -0,0 +1,465 @@
1
+ require 'json'
2
+
3
+ module OpenStax::Aws
4
+ class Stack
5
+ attr_reader :name, :tags, :id, :absolute_template_path, :dry_run,
6
+ :enable_termination_protection, :region, :parameter_defaults,
7
+ :volatile_parameters_block, :secrets_blocks
8
+
9
+ delegate :failed?, :updating?, :creating?, :deleting?, :exists?, to: :status
10
+
11
+ def initialize(id: nil, name:, tags: {},
12
+ region:, enable_termination_protection: false,
13
+ absolute_template_path: nil,
14
+ capabilities: nil, parameter_defaults: {},
15
+ volatile_parameters_block: nil,
16
+ secrets_blocks: [], secrets_context: nil, secrets_namespace: nil,
17
+ shared_secrets_substitutions_block: nil,
18
+ cycle_if_different_parameter: nil,
19
+ dry_run: true)
20
+ @id = id
21
+
22
+ raise "Stack name must not be blank" if name.blank?
23
+ @name = name
24
+
25
+ raise "`tags` must be a hash" if !tags.is_a?(Hash)
26
+
27
+ @tags = tags.map{|key, value| OpenStax::Aws::Tag.new(key, value)}
28
+
29
+ @region = region || raise("region is not set for stack #{name}")
30
+ @enable_termination_protection = enable_termination_protection
31
+
32
+ @absolute_template_path = absolute_template_path
33
+
34
+ set_capabilities(capabilities)
35
+ @parameter_defaults = parameter_defaults.dup.freeze
36
+ @volatile_parameters_block = volatile_parameters_block
37
+
38
+ @secrets_blocks = [secrets_blocks].flatten.compact
39
+ @secrets_context = secrets_context
40
+ @secrets_namespace = secrets_namespace
41
+ @shared_secrets_substitutions_block = shared_secrets_substitutions_block
42
+
43
+ @cycle_if_different_parameter = (
44
+ cycle_if_different_parameter ||
45
+ OpenStax::Aws.configuration.default_cycle_if_different_parameter
46
+ ).underscore.to_sym
47
+
48
+ @dry_run = dry_run
49
+ end
50
+
51
+ def template
52
+ @template ||= begin
53
+ if absolute_template_path.present?
54
+ OpenStax::Aws::Template.from_absolute_file_path(absolute_template_path)
55
+ else
56
+ body = client.get_template({stack_name: name}).template_body
57
+ OpenStax::Aws::Template.from_body(body)
58
+ end
59
+ end
60
+ end
61
+
62
+ def create(params: {}, wait: false, skip_if_exists: false)
63
+ logger.info("**** DRY RUN ****") if dry_run
64
+
65
+ check_for_required_tags
66
+
67
+ if skip_if_exists && exists?
68
+ logger.info("Skipping #{name} stack - exists...")
69
+ return
70
+ end
71
+
72
+ params = parameter_defaults.merge(params)
73
+
74
+ if defines_secrets?
75
+ logger.info("Creating #{name} stack secrets...")
76
+ secrets(parameters: params, for_create_or_update: true).create
77
+ end
78
+
79
+ options = {
80
+ stack_name: name,
81
+ template_url: template.s3_url,
82
+ capabilities: capabilities,
83
+ parameters: self.class.format_hash_as_stack_parameters(params),
84
+ enable_termination_protection: enable_termination_protection,
85
+ tags: self.class.format_hash_as_tag_parameters(@tags),
86
+ }
87
+
88
+ logger.info("Creating #{name} stack...")
89
+ client.create_stack(options) if !dry_run
90
+
91
+ wait_for_creation if wait
92
+ end
93
+
94
+ def parameters_for_update(overrides: {})
95
+ parameters = {}
96
+
97
+ # Start populating the parameters hash by using `:use_previous_value` for any
98
+ # parameter that is currently in the template that is also currently on the stack,
99
+ # and using the defined default value for any other parameter.
100
+
101
+ continuing_parameter_keys.each do |continuing_parameter_key|
102
+ parameters[continuing_parameter_key] = :use_previous_value
103
+ end
104
+
105
+ new_parameter_keys.each do |new_parameter_key|
106
+ parameters[new_parameter_key] = parameter_defaults[new_parameter_key]
107
+ end
108
+
109
+ # Volatile parameters can be changed outside of cloudformation updates. Here
110
+ # we get their current values by executing the block in the context of this
111
+ # stack, and then we merge them in (overwriting any values already in the
112
+ # parameters hash).
113
+
114
+ parameters.merge!(volatile_parameters)
115
+
116
+ # Lastly, we merge in the overrides hash (e.g. things purposefully set
117
+ # by an outside caller) -- they take precendence over all previous values.
118
+
119
+ parameters.merge!(overrides)
120
+
121
+ # Leave out nil-valued parameters as they are not valid (and likely not
122
+ # intentional)
123
+
124
+ parameters.compact
125
+ end
126
+
127
+ def volatile_parameters
128
+ return {} if volatile_parameters_block.nil?
129
+
130
+ volatile_parameters_factory = StackFactory::VolatileParametersFactory.new(self)
131
+ volatile_parameters_factory.instance_eval(&volatile_parameters_block)
132
+ volatile_parameters_factory.attributes
133
+ end
134
+
135
+ def deployed_parameters
136
+ begin
137
+ @deployed_parameters ||= aws_stack.parameters.each_with_object({}) do |parameter, hash|
138
+ hash[parameter.parameter_key.underscore.to_sym] = parameter.parameter_value
139
+ end
140
+ rescue Aws::CloudFormation::Errors::ValidationError => ee
141
+ if ee.message =~ /Stack.*does not exist/
142
+ {}
143
+ else
144
+ raise
145
+ end
146
+ end
147
+ end
148
+
149
+ def secrets(parameters: {}, for_create_or_update: false)
150
+ SecretsSet.new(
151
+ secrets_blocks.map do |secrets_block|
152
+ secrets_factory = SecretsFactory.new(
153
+ region: region,
154
+ namespace: @secrets_namespace,
155
+ context: @secrets_context,
156
+ dry_run: dry_run,
157
+ for_create_or_update: for_create_or_update,
158
+ shared_substitutions_block: @shared_secrets_substitutions_block
159
+ )
160
+
161
+ secrets_factory.namespace(id)
162
+ secrets_factory.instance_exec parameters, &secrets_block
163
+ secrets_factory.instance
164
+ end
165
+ )
166
+ end
167
+
168
+ def create_change_set(options)
169
+ OpenStax::Aws::ChangeSet.new(client: client).create(options: options)
170
+ end
171
+
172
+ def apply_change_set(params: {}, wait: false)
173
+ logger.info("**** DRY RUN ****") if dry_run
174
+
175
+ check_for_required_tags
176
+
177
+ logger.info("Updating #{name} stack...")
178
+
179
+ params = parameters_for_update(overrides: params)
180
+
181
+ if defines_secrets?
182
+ logger.info("Updating #{name} stack secrets...")
183
+
184
+ secrets_changed = secrets(
185
+ parameters: StackParameters.new(params: params, stack: self),
186
+ for_create_or_update: true
187
+ ).update
188
+
189
+ if secrets_changed && template_parameter_keys.include?(@cycle_if_different_parameter)
190
+ logger.info("Secrets changed, setting stack parameter to trigger server cycling")
191
+ params[@cycle_if_different_parameter] = SecureRandom.hex(10)
192
+ end
193
+ end
194
+
195
+ options = {
196
+ stack_name: name,
197
+ template_url: template.s3_url,
198
+ capabilities: capabilities,
199
+ parameters: self.class.format_hash_as_stack_parameters(params),
200
+ change_set_name: "#{name}-#{Time.now.utc.strftime("%Y%m%d-%H%M%S")}",
201
+ tags: self.class.format_hash_as_tag_parameters(@tags),
202
+ }
203
+
204
+ change_set = create_change_set(options)
205
+
206
+ if change_set.created?
207
+ resource_changes = change_set.resource_change_summaries
208
+
209
+ logger.info("#{resource_changes.size} resource change(s)#{':' if !resource_changes.empty?}")
210
+ resource_changes.each do |resource_change|
211
+ logger.debug(resource_change)
212
+ end
213
+
214
+ if dry_run
215
+ logger.info("Deleting change set (because this is a dry run)")
216
+ change_set.delete
217
+ else
218
+ logger.info("Executing change set")
219
+ change_set.execute
220
+ reset_cached_remote_state
221
+ end
222
+
223
+ wait_for_update if wait
224
+ end
225
+
226
+ change_set
227
+ end
228
+
229
+ def delete(retain_resources: [], wait: false)
230
+ logger.info("**** DRY RUN ****") if dry_run
231
+
232
+ if defines_secrets?
233
+ logger.info("Deleting #{name} stack secrets...")
234
+ secrets.delete
235
+ end
236
+
237
+ logger.info("Deleting #{name} stack...")
238
+
239
+ if exists?
240
+ client.delete_stack(stack_name: name, retain_resources: retain_resources) if !dry_run
241
+ else
242
+ logger.info("Cannot delete #{name} stack as it does not exist")
243
+ end
244
+
245
+ wait_for_deletion if wait
246
+ end
247
+
248
+ def output_value(key:)
249
+ if dry_run
250
+ "undefined-in-dry-run"
251
+ else
252
+ output = aws_stack.outputs.find {|output| output.output_key == key}
253
+ raise "No output with key #{key} in stack #{name}" if output.nil?
254
+ output.output_value
255
+ end
256
+ end
257
+
258
+ def wait_for_creation
259
+ if !dry_run
260
+ return if !creating?
261
+ wait_for_stack_event(waiter_class: Aws::CloudFormation::Waiters::StackCreateComplete,
262
+ word: "created")
263
+ end
264
+ end
265
+
266
+ def wait_for_update
267
+ if !dry_run
268
+ return if !updating? # if not updating, waiting for an updated message will thrash until timeout
269
+ wait_for_stack_event(waiter_class: Aws::CloudFormation::Waiters::StackUpdateComplete,
270
+ word: "updated")
271
+ end
272
+ end
273
+
274
+ def wait_for_deletion
275
+ if !dry_run
276
+ begin
277
+ return if !deleting?
278
+ wait_for_stack_event(waiter_class: Aws::CloudFormation::Waiters::StackDeleteComplete,
279
+ word: "deleted")
280
+ rescue Aws::CloudFormation::Errors::ValidationError => ee
281
+ if ee.message =~ /Stack.*does not exist/
282
+ logger.warn("Waiting for stack #{name} to be deleted failed because it does not exist")
283
+ else
284
+ raise
285
+ end
286
+ end
287
+ end
288
+ end
289
+
290
+ def resource(logical_id)
291
+ stack_resource = aws_stack.resource(logical_id)
292
+
293
+ case stack_resource.resource_type
294
+ when "AWS::AutoScaling::AutoScalingGroup"
295
+ name = stack_resource.physical_resource_id
296
+ client = Aws::AutoScaling::Client.new(region: region)
297
+ Aws::AutoScaling::AutoScalingGroup.new(name: name, client: client)
298
+ when "AWS::RDS::DBInstance"
299
+ db_instance_identifier = stack_resource.physical_resource_id
300
+ OpenStax::Aws::RdsInstance.new(db_instance_identifier: db_instance_identifier, region: region)
301
+ when "AWS::MSK::Cluster"
302
+ msk_cluster_arn = stack_resource.physical_resource_id
303
+ OpenStax::Aws::MskCluster.new(cluster_arn: msk_cluster_arn, region: region)
304
+ else
305
+ raise "'#{stack_resource.resource_type}' is not yet implemented in `Stack#resource`"
306
+ end
307
+ end
308
+
309
+ def capabilities
310
+ set_capabilities(default_capabilities) if @capabilities.nil?
311
+ @capabilities
312
+ end
313
+
314
+ def default_capabilities
315
+ if OpenStax::Aws.configuration.infer_stack_capabilities
316
+ template.required_capabilities
317
+ else
318
+ []
319
+ end
320
+ end
321
+
322
+ def self.format_hash_as_stack_parameters(params={})
323
+ params.map do |key, value|
324
+ {
325
+ parameter_key: key.to_s.split('_').collect(&:capitalize).join,
326
+ }.tap do |hash|
327
+ if value == :use_previous_value
328
+ hash[:use_previous_value] = true
329
+ else
330
+ hash[:parameter_value] = value.to_s
331
+ end
332
+ end
333
+ end
334
+ end
335
+
336
+ def self.format_hash_as_tag_parameters(tags)
337
+ tags.map{|tag| {key: tag.key, value: tag.value}}
338
+ end
339
+
340
+ def status(reload: false)
341
+ @status = nil if reload
342
+ @status ||= Status.new(self)
343
+ end
344
+
345
+ def events
346
+ (aws_stack&.events || []).map{|aws_event| Event.new(aws_event)}
347
+ end
348
+
349
+ def defines_secrets?
350
+ !secrets_blocks.empty?
351
+ end
352
+
353
+ def self.query(regex: /.*/, regions: %w(us-east-1 us-east-2 us-west-1 us-west-2), active: true, reload: false)
354
+ stack_status_filter = active ? Status.active_status_texts : nil
355
+
356
+ if reload
357
+ @all_stacks = {}
358
+ else
359
+ @all_stacks ||= {}
360
+ end
361
+
362
+ # Memoize the query results to speed up subsequent queries
363
+ @all_stacks[stack_status_filter + regions] ||= regions.map do |region|
364
+ client = Aws::CloudFormation::Client.new(region: region)
365
+ client.list_stacks(stack_status_filter: stack_status_filter).map do |response|
366
+ response.stack_summaries.map do |summary|
367
+ OpenStax::Aws.configuration.without_required_stack_tags do
368
+ new(name: summary.stack_name, region: region)
369
+ end
370
+ end
371
+ end
372
+ end.flatten
373
+
374
+ @all_stacks[stack_status_filter + regions].select{|stack| stack.name.match(regex)}
375
+ end
376
+
377
+ def aws_stack
378
+ ::Aws::CloudFormation::Stack.new(name: name, client: client)
379
+ end
380
+
381
+ protected
382
+
383
+ def wait_for_stack_event(waiter_class:, word:)
384
+ wait_message = OpenStax::Aws::WaitMessage.new(
385
+ message: "Waiting for #{name} stack to be #{word}"
386
+ )
387
+
388
+ begin
389
+ waiter_class.new(
390
+ client: client,
391
+ before_attempt: ->(*) { wait_message.say_it },
392
+ delay: OpenStax::Aws.configuration.stack_waiter_delay,
393
+ max_attempts: OpenStax::Aws.configuration.stack_waiter_max_attempts
394
+ ).wait(stack_name: name)
395
+ rescue Aws::Waiters::Errors::WaiterFailed => error
396
+ logger.error("Waiting failed: #{error.message}")
397
+ raise
398
+ end
399
+ logger.info "#{name} has been #{word}!"
400
+ end
401
+
402
+ SHORT_CAPABILITIES = {
403
+ iam: "CAPABILITY_IAM",
404
+ named_iam: "CAPABILITY_NAMED_IAM",
405
+ auto_expand: "CAPABILITY_AUTO_EXPAND"
406
+ }.freeze
407
+
408
+ def set_capabilities(capabilities)
409
+ return if capabilities.nil?
410
+
411
+ capabilities = [capabilities].flatten.compact
412
+
413
+ valid_capabilities = SHORT_CAPABILITIES.keys + SHORT_CAPABILITIES.values
414
+
415
+ capabilities.each do |capability|
416
+ if !valid_capabilities.include?(capability)
417
+ raise "Capabilities must be in #{valid_capabilities}"
418
+ end
419
+ end
420
+
421
+ @capabilities = capabilities.map do |capability|
422
+ SHORT_CAPABILITIES[capability] || capability
423
+ end.freeze
424
+ end
425
+
426
+ def template_parameter_keys
427
+ @tpks ||= template.parameter_names.map(&:underscore).map(&:to_sym)
428
+ end
429
+
430
+ def continuing_parameter_keys
431
+ template_parameter_keys & deployed_parameters.keys
432
+ end
433
+
434
+ def new_parameter_keys
435
+ template_parameter_keys - continuing_parameter_keys
436
+ end
437
+
438
+ def reset_cached_remote_state
439
+ @deployed_parameters = nil
440
+ end
441
+
442
+ def logger
443
+ OpenStax::Aws.configuration.logger
444
+ end
445
+
446
+ def client
447
+ @client ||= ::Aws::CloudFormation::Client.new(region: region)
448
+ end
449
+
450
+ def tag(name)
451
+ tags.select{|tag| tag.key == name}.first
452
+ end
453
+
454
+ def check_for_required_tags
455
+ OpenStax::Aws.configuration.required_stack_tags.each do |required_tag|
456
+ tag = tag(required_tag)
457
+ if tag.nil? || tag.value.blank?
458
+ raise "The '#{required_tag}' tag is required on the '#{name}' stack but is blank or missing"
459
+ end
460
+ end
461
+
462
+ end
463
+
464
+ end
465
+ end