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,302 @@
1
+ module OpenStax::Aws
2
+ class Secrets
3
+
4
+ # We store "secrets" in the AWS Parameter store. Secrets can be truly secret
5
+ # values (e.g. keys) or just some configuration values.
6
+
7
+ GENERATED_WITH_PREFIX = "Generated with"
8
+
9
+ attr_reader :region, :dry_run, :namespace
10
+
11
+ def initialize(region:, dry_run: true, namespace:)
12
+ @region = region
13
+ @dry_run = dry_run
14
+ @namespace = namespace
15
+ @client = Aws::SSM::Client.new(region: region)
16
+ @substitutions = {}
17
+ end
18
+
19
+ # `create` and `update` take secrets specifications and secrets substitutions.
20
+ # if you just want to call `create` and `update` with no arguments, you can
21
+ # define the specifications and substitutions ahead of time with this method, e.g.
22
+ #
23
+ # my_secrets.define(specifications: my_specifications, substitutions: my_substitutions)
24
+ # ...
25
+ # my_secrets.create
26
+ #
27
+ # See the README for more discussion about specifications and substitutions.
28
+ #
29
+ def define(specifications:, substitutions: {})
30
+ @specifications = specifications
31
+ @substitutions = substitutions
32
+ end
33
+
34
+ def create(specifications: nil, substitutions: nil)
35
+ # Build all secrets first so we hit any errors before we send any to AWS
36
+ built_secrets = build_secrets(specifications: specifications, substitutions: substitutions)
37
+
38
+ OpenStax::Aws.logger.info("**** DRY RUN ****") if dry_run
39
+
40
+ OpenStax::Aws.logger.info("Creating the following secrets in the AWS parameter store: #{built_secrets}")
41
+
42
+ # Ship 'em
43
+ if !dry_run
44
+ built_secrets.each do |built_secret|
45
+ client.put_parameter(built_secret.merge(overwrite: true))
46
+ sleep(0.1)
47
+ end
48
+ end
49
+ end
50
+
51
+ def update(specifications: nil, substitutions: nil, force_update_these: [])
52
+ existing_secrets = data!
53
+ built_secrets = build_secrets(specifications: specifications, substitutions: substitutions)
54
+ changed_secrets = self.class.changed_secrets(existing_secrets, built_secrets)
55
+
56
+ force_update_these.each do |force_update_this|
57
+ built_secrets.select{|built_secret| built_secret[:name].match(force_update_this)}.each do |forced|
58
+ changed_secrets.push(forced)
59
+ end
60
+ end
61
+ changed_secrets.uniq!
62
+
63
+ OpenStax::Aws.logger.info("**** DRY RUN ****") if dry_run
64
+
65
+ if changed_secrets.empty?
66
+ OpenStax::Aws.logger.info("Secrets did not change")
67
+ return false
68
+ else
69
+ OpenStax::Aws.logger.info("Updating the following secrets in the AWS parameter store: #{changed_secrets}")
70
+
71
+ # Ship 'em
72
+ if !dry_run
73
+ changed_secrets.each do |changed_secret|
74
+ client.put_parameter(changed_secret.merge(overwrite: true))
75
+ end
76
+ end
77
+
78
+ return true
79
+ end
80
+ end
81
+
82
+ def self.changed_secrets(existing_secrets_hash, new_secrets_array)
83
+ existing_secrets_hash = existing_secrets_hash.with_indifferent_access
84
+ new_secrets_array = new_secrets_array.map(&:with_indifferent_access)
85
+
86
+ new_secrets_array.each_with_object([]) do |new_secret, array|
87
+
88
+ existing_secret = existing_secrets_hash[new_secret[:name]]
89
+
90
+ if existing_secret
91
+ # No need to update if the value is the same
92
+ next if existing_secret[:value] == new_secret[:value]
93
+
94
+ # Don't update if different values but generated from same specification
95
+ next if new_secret[:description].try(:starts_with?, GENERATED_WITH_PREFIX) &&
96
+ new_secret[:description] == existing_secret[:description]
97
+ end
98
+
99
+ array.push(new_secret)
100
+ end
101
+ end
102
+
103
+ def build_secrets(specifications:, substitutions:)
104
+ specifications ||= @specifications
105
+ substitutions ||= @substitutions
106
+
107
+ specifications = [specifications].flatten
108
+
109
+ raise "Cannot build secrets without a specification" if specifications.empty?
110
+
111
+ expanded_data = {}
112
+
113
+ # later specifications override earlier ones
114
+ specifications.reverse.each do |specification|
115
+ expanded_data.merge!(specification.expanded_data)
116
+ end
117
+
118
+ expanded_data.map do |secret_name, spec_value|
119
+ build_secret(secret_name, spec_value, substitutions)
120
+ end
121
+ end
122
+
123
+ def build_secret(secret_name, spec_value, substitutions)
124
+ secret = {
125
+ name: "#{key_prefix}/#{secret_name}"
126
+ }
127
+
128
+ case spec_value
129
+ when Array
130
+ processed_items = spec_value.map do |item|
131
+ process_individual_spec_value(item, substitutions)[:value]
132
+ end
133
+ secret[:value] = processed_items.join(",")
134
+ secret[:type] = "StringList"
135
+ else
136
+ spec_value = spec_value.to_s.strip
137
+ secret.merge!(process_individual_spec_value(spec_value, substitutions))
138
+ end
139
+
140
+ if (generated = secret.delete(:generated))
141
+ secret[:description] = "#{GENERATED_WITH_PREFIX} #{spec_value}"
142
+ end
143
+
144
+ secret
145
+ end
146
+
147
+ def process_individual_spec_value(spec_value, substitutions)
148
+ generated = false
149
+ type = "String"
150
+
151
+ value = case spec_value
152
+ when /^random\(hex,(\d+)\)$/
153
+ generated = true
154
+ num_characters = $1.to_i
155
+ SecureRandom.hex(num_characters)[0..num_characters-1]
156
+ when /^random\(base64,(\d+)\)$/
157
+ generated = true
158
+ num_characters = $1.to_i
159
+ SecureRandom.urlsafe_base64(num_characters)[0..num_characters-1]
160
+ when /^rsa\((\d+)\)$/
161
+ type = "SecureString"
162
+ generated = true
163
+ key_length = $1.to_i
164
+ OpenSSL::PKey::RSA.new(key_length).to_s
165
+ when "uuid"
166
+ generated = true
167
+ SecureRandom.uuid
168
+ when /{([^{}]+)}/
169
+ spec_value.gsub(/({{\W*(\w+)\W*}})/) do |match|
170
+ if (!substitutions.has_key?($2) && !substitutions.has_key?($2.to_sym))
171
+ raise "no substitution provided for #{$2}"
172
+ end
173
+
174
+ substitutions[$2] || substitutions[$2.to_sym]
175
+ end
176
+ when /^ssm\((.*)\)$/
177
+ begin
178
+ parameter_name = $1.starts_with?("/") ? $1 : (substitutions[$1] || substitutions[$1.to_sym])
179
+
180
+ if parameter_name.blank?
181
+ raise "#{$1} is neither a literal parameter name nor available in the given substitutions"
182
+ end
183
+
184
+ parameter = client.get_parameter({
185
+ name: parameter_name,
186
+ with_decryption: true
187
+ }).parameter
188
+
189
+ type = parameter.type
190
+
191
+ parameter.value
192
+ rescue Aws::SSM::Errors::ParameterNotFound => ee
193
+ raise "Could not get secret '#{$1}'"
194
+ end
195
+ else # use literal value
196
+ spec_value
197
+ end
198
+
199
+ {
200
+ value: value,
201
+ type: type,
202
+ generated: generated
203
+ }
204
+ end
205
+
206
+ def data
207
+ @data ||= data!
208
+ end
209
+
210
+ def data!
211
+ {}.tap do |hash|
212
+ client.get_parameters_by_path({
213
+ path: key_prefix,
214
+ recursive: true,
215
+ with_decryption: true
216
+ }).each do |response|
217
+ response.parameters.each do |parameter|
218
+ hash[parameter.name] = ReadOnlyParameter.new(parameter, client)
219
+ end
220
+ end
221
+ end
222
+ end
223
+
224
+ class ReadOnlyParameter
225
+ # Helper object to hide the fact that tags and descriptions have to be accessed
226
+ # through separate API calls
227
+
228
+ def initialize(parameter, client)
229
+ @raw_parameter = parameter
230
+ @client = client
231
+ end
232
+
233
+ def [](key)
234
+ case key
235
+ when :type
236
+ @raw_parameter.type
237
+ when :value
238
+ @raw_parameter.value
239
+ when :tags
240
+ raise "Not yet tested!"
241
+ @tags ||= begin
242
+ (@client.list_tags_for_resource({
243
+ resource_type: "Parameter",
244
+ resource_id: @raw_parameter.arn
245
+ }).tag_list || []).map do |tag| {
246
+ key: tag.key,
247
+ value: tag.value
248
+ } end
249
+ end
250
+ when :description
251
+ @description ||= begin
252
+ @client.describe_parameters({
253
+ parameter_filters: [{
254
+ key: "Name",
255
+ option: "Equals",
256
+ values: [@raw_parameter.name]
257
+ }],
258
+ max_results: 1
259
+ }).parameters[0].description
260
+ end
261
+ end
262
+ end
263
+ end
264
+
265
+ def get(local_name)
266
+ local_name = local_name.to_s
267
+ local_name = "/#{local_name}" unless local_name.chr == "/"
268
+ data["#{key_prefix}#{local_name}"].try(:[], :value)
269
+ end
270
+
271
+ def delete
272
+ secret_names = data!.keys
273
+ return if secret_names.empty?
274
+
275
+ OpenStax::Aws.logger.info("**** DRY RUN ****") if dry_run
276
+
277
+ OpenStax::Aws.logger.info("Deleting the following secrets in the AWS parameter store: #{secret_names}")
278
+
279
+ if !dry_run
280
+ @data = nil # remove cached values as they are about to get cleared remotely
281
+
282
+ # Can send max 10 secret names at a time
283
+ secret_names.each_slice(10) do |some_secret_names|
284
+ response = client.delete_parameters({names: some_secret_names})
285
+
286
+ if response.invalid_parameters.any?
287
+ OpenStax::Aws.logger.debug("Unable to delete some secrets (likely already deleted): #{response.invalid_parameters}")
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ def key_prefix
294
+ "/" + [namespace].flatten.reject(&:blank?).join("/")
295
+ end
296
+
297
+ protected
298
+
299
+ attr_reader :client
300
+
301
+ end
302
+ end
@@ -0,0 +1,126 @@
1
+ module OpenStax::Aws
2
+ class SecretsFactory
3
+ def initialize(region:, context:, namespace:, dry_run: true,
4
+ for_create_or_update:, shared_substitutions_block: nil)
5
+ raise ArgumentError, "context cannot be nil" if context.nil?
6
+ @context = context
7
+ @specification_blocks = []
8
+ @substitutions_block = nil
9
+ @region = region
10
+ @top_namespace = namespace
11
+ @next_namespace = nil
12
+ @dry_run = dry_run
13
+ @for_create_or_update = for_create_or_update
14
+ @shared_substitutions_block = shared_substitutions_block
15
+ end
16
+
17
+ def namespace(*args, &block)
18
+ @next_namespace = args.any? ? args[0] : @context.instance_eval(&block)
19
+ end
20
+
21
+ def specification(&block)
22
+ @specification_blocks.push(block)
23
+ end
24
+
25
+ def substitutions(&block)
26
+ @substitutions_block = block
27
+ end
28
+
29
+ def specification_instances
30
+ raise "Must define secrets specification" if @specification_blocks.empty?
31
+
32
+ @specification_blocks.map do |specification_block|
33
+ factory = SecretsSpecificationFactory.new(@context)
34
+ factory.instance_eval &specification_block
35
+ attributes = factory.attributes
36
+
37
+ if attributes.has_key?(:org_slash_repo)
38
+ OpenStax::Aws::SecretsSpecification.from_git(
39
+ org_slash_repo: attributes[:org_slash_repo],
40
+ sha: attributes[:sha],
41
+ path: attributes[:path],
42
+ format: attributes[:format].to_sym,
43
+ top_key: attributes[:top_key].to_sym,
44
+ preparser: attributes[:preparser]
45
+ )
46
+ elsif attributes.has_key?(:content)
47
+ OpenStax::Aws::SecretsSpecification.from_content(
48
+ content: attributes[:content],
49
+ format: attributes[:format],
50
+ top_key: attributes[:top_key],
51
+ preparser: attributes[:preparser]
52
+ )
53
+ else
54
+ raise "Cannot build a secrets specification"
55
+ end
56
+ end
57
+ end
58
+
59
+ def substitutions_hash
60
+ shared_substitutions = substitutions_hash_from_block(@shared_substitutions_block)
61
+ local_substitutions = substitutions_hash_from_block(@substitutions_block)
62
+
63
+ shared_substitutions.merge(local_substitutions)
64
+ end
65
+
66
+ def substitutions_hash_from_block(block)
67
+ return {} if block.nil?
68
+
69
+ factory = SecretsSubstitutionsFactory.new(@context)
70
+ factory.instance_eval &block
71
+ factory.attributes
72
+ end
73
+
74
+ def full_namespace
75
+ [@top_namespace, @next_namespace].flatten.reject(&:blank?)
76
+ end
77
+
78
+ def instance
79
+ Secrets.new(region: @region, namespace: full_namespace, dry_run: @dry_run).tap do |secrets|
80
+ if @for_create_or_update
81
+ secrets.define(specifications: specification_instances, substitutions: substitutions_hash)
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ class SecretsSpecificationFactory
88
+ attr_reader :attributes
89
+
90
+ def initialize(context)
91
+ raise ArgumentError, "context cannot be nil" if context.nil?
92
+ @context = context
93
+ @attributes = {}
94
+ end
95
+
96
+ def format(&block)
97
+ store_attribute(:format, &block)
98
+ end
99
+
100
+ def method_missing(name, *args, &block)
101
+ store_attribute(name, *args, &block)
102
+ end
103
+
104
+ def store_attribute(name, *args, &block)
105
+ raise "Secrets specification option `#{name}` cannot be called with arguments, only a block" if !args.empty?
106
+ raise "Secrets specification option `#{name}` must be called with a block to set the value" if !block_given?
107
+ attributes[name.to_sym] = @context.instance_eval(&block)
108
+ end
109
+ end
110
+
111
+ class SecretsSubstitutionsFactory
112
+ attr_reader :attributes
113
+
114
+ def initialize(context)
115
+ raise ArgumentError, "context cannot be nil" if context.nil?
116
+ @context = context
117
+ @attributes = {}
118
+ end
119
+
120
+ def method_missing(name, *args, &block)
121
+ raise "Secrets substitition `#{name}` cannot be called with arguments, only a block" if !args.empty?
122
+ raise "Secrets substitution `#{name}` must be called with a block to set the substitution value" if !block_given?
123
+ attributes[name.to_sym] = @context.instance_eval(&block)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,21 @@
1
+ module OpenStax::Aws
2
+ class SecretsSet
3
+
4
+ def initialize(secrets_array)
5
+ @secrets_array = [secrets_array].flatten
6
+ end
7
+
8
+ def create
9
+ @secrets_array.each(&:create)
10
+ end
11
+
12
+ def update
13
+ @secrets_array.map(&:update).any?
14
+ end
15
+
16
+ def delete
17
+ @secrets_array.each(&:delete)
18
+ end
19
+
20
+ end
21
+ end