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.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.travis.yml +12 -0
- data/CHANGELOG.md +11 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +120 -0
- data/LICENSE.txt +1 -0
- data/README.md +927 -0
- data/Rakefile +6 -0
- data/TODO.md +1 -0
- data/assets/secrets_sequence_diagram.png +0 -0
- data/bin/console +14 -0
- data/bin/create_development_environment +26 -0
- data/bin/get_latest_ubuntu_ami +31 -0
- data/bin/setup +8 -0
- data/bin/templates/aws_ruby_development.yml +221 -0
- data/examples/deployment.rb +90 -0
- data/ideas.md +15 -0
- data/lib/openstax/aws/auto_scaling_group.rb +28 -0
- data/lib/openstax/aws/auto_scaling_instance.rb +96 -0
- data/lib/openstax/aws/build_image_command_1.rb +53 -0
- data/lib/openstax/aws/change_set.rb +100 -0
- data/lib/openstax/aws/deployment_base.rb +372 -0
- data/lib/openstax/aws/distribution.rb +56 -0
- data/lib/openstax/aws/ec2_instance_data.rb +18 -0
- data/lib/openstax/aws/extensions.rb +19 -0
- data/lib/openstax/aws/git_helper.rb +18 -0
- data/lib/openstax/aws/image.rb +34 -0
- data/lib/openstax/aws/msk_cluster.rb +19 -0
- data/lib/openstax/aws/packer_1_2_5.rb +63 -0
- data/lib/openstax/aws/packer_1_4_1.rb +72 -0
- data/lib/openstax/aws/packer_factory.rb +25 -0
- data/lib/openstax/aws/rds_instance.rb +25 -0
- data/lib/openstax/aws/s3_text_file.rb +50 -0
- data/lib/openstax/aws/sam_stack.rb +85 -0
- data/lib/openstax/aws/secrets.rb +302 -0
- data/lib/openstax/aws/secrets_factory.rb +126 -0
- data/lib/openstax/aws/secrets_set.rb +21 -0
- data/lib/openstax/aws/secrets_specification.rb +68 -0
- data/lib/openstax/aws/stack.rb +465 -0
- data/lib/openstax/aws/stack_event.rb +28 -0
- data/lib/openstax/aws/stack_factory.rb +153 -0
- data/lib/openstax/aws/stack_parameters.rb +19 -0
- data/lib/openstax/aws/stack_status.rb +125 -0
- data/lib/openstax/aws/system.rb +21 -0
- data/lib/openstax/aws/tag.rb +31 -0
- data/lib/openstax/aws/template.rb +129 -0
- data/lib/openstax/aws/version.rb +5 -0
- data/lib/openstax/aws/wait_message.rb +20 -0
- data/lib/openstax_aws.rb +154 -0
- data/openstax_aws.gemspec +58 -0
- 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
|