openstax_aws 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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