moonshot 0.7.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.
@@ -0,0 +1,52 @@
1
+ # Mixin providing the Thor::Shell methods and other shell execution helpers.
2
+ module Moonshot::Shell
3
+ # Run a command, returning stdout. Stderr is suppressed unless the command
4
+ # returns non-zero.
5
+ def sh_out(cmd, fail: true, stdin: '') # rubocop:disable AbcSize
6
+ r_in, w_in = IO.pipe
7
+ r_out, w_out = IO.pipe
8
+ r_err, w_err = IO.pipe
9
+ w_in.write(stdin)
10
+ w_in.close
11
+ pid = Process.spawn(cmd, in: r_in, out: w_out, err: w_err)
12
+ Process.wait(pid)
13
+
14
+ r_in.close
15
+ w_out.close
16
+ w_err.close
17
+ stdout = r_out.read
18
+ r_out.close
19
+ stderr = r_err.read
20
+ r_err.close
21
+
22
+ if fail && $CHILD_STATUS.exitstatus != 0
23
+ raise "`#{cmd}` exited #{$CHILD_STATUS.exitstatus}\n" \
24
+ "stdout:\n" \
25
+ "#{stdout}\n" \
26
+ "stderr:\n" \
27
+ "#{stderr}\n"
28
+ end
29
+ stdout
30
+ end
31
+ module_function :sh_out
32
+
33
+ def shell
34
+ @thor_shell ||= Thor::Base.shell.new
35
+ end
36
+
37
+ Thor::Shell::Basic.public_instance_methods(false).each do |meth|
38
+ define_method(meth) { |*args| shell.public_send(meth, *args) }
39
+ end
40
+
41
+ def sh_step(cmd, args = {})
42
+ msg = args.delete(:msg) || cmd
43
+ if msg.length > (terminal_width - 18)
44
+ msg = "#{msg[0..(terminal_width - 22)]}..."
45
+ end
46
+ ilog.start_threaded(msg) do |step|
47
+ out = sh_out(cmd, args)
48
+ yield step, out if block_given?
49
+ step.success
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,345 @@
1
+ require_relative 'creds_helper'
2
+ require_relative 'doctor_helper'
3
+
4
+ require_relative 'stack_template'
5
+ require_relative 'stack_parameter_printer'
6
+ require_relative 'stack_output_printer'
7
+ require_relative 'stack_asg_printer'
8
+ require_relative 'unicode_table'
9
+ require 'yaml'
10
+
11
+ module Moonshot
12
+ # The Stack wraps all CloudFormation actions performed by Moonshot. It
13
+ # stores the state of the active stack running on AWS, but contains a
14
+ # reference to the StackTemplate that would be applied with an update
15
+ # action.
16
+ class Stack # rubocop:disable ClassLength
17
+ include CredsHelper
18
+ include DoctorHelper
19
+
20
+ attr_reader :app_name
21
+ attr_reader :name
22
+
23
+ # TODO: Refactor more of these parameters into the config object.
24
+ def initialize(name, app_name:, log:, ilog:, config: StackConfig.new)
25
+ @name = name
26
+ @app_name = app_name
27
+ @log = log
28
+ @ilog = ilog
29
+ @config = config
30
+ yield @config if block_given?
31
+ end
32
+
33
+ def create
34
+ import_parent_parameters
35
+
36
+ should_wait = true
37
+ @ilog.start "Creating #{stack_name}." do |s|
38
+ if stack_exists?
39
+ s.success "#{stack_name} already exists."
40
+ should_wait = false
41
+ else
42
+ create_stack
43
+ s.success "Created #{stack_name}."
44
+ end
45
+ end
46
+
47
+ should_wait ? wait_for_stack_state(:stack_create_complete, 'created') : true
48
+ end
49
+
50
+ def update
51
+ raise Thor::Error, "No stack found #{@name.blue}!" unless stack_exists?
52
+
53
+ should_wait = true
54
+ @ilog.start "Updating #{stack_name}." do |s|
55
+ if update_stack
56
+ s.success "Initiated update for #{stack_name}."
57
+ else
58
+ s.success 'No Stack update required.'
59
+ should_wait = false
60
+ end
61
+ end
62
+
63
+ should_wait ? wait_for_stack_state(:stack_update_complete, 'updated') : true
64
+ end
65
+
66
+ def delete
67
+ should_wait = true
68
+ @ilog.start "Deleting #{stack_name}." do |s|
69
+ if stack_exists?
70
+ cf_client.delete_stack(stack_name: @name)
71
+ s.success "Initiated deletion of #{stack_name}."
72
+ else
73
+ s.success "#{stack_name} does not exist."
74
+ should_wait = false
75
+ end
76
+ end
77
+
78
+ should_wait ? wait_for_stack_state(:stack_delete_complete, 'deleted') : true
79
+ end
80
+
81
+ def status
82
+ if exists?
83
+ puts "#{stack_name} exists."
84
+ t = UnicodeTable.new('')
85
+ StackParameterPrinter.new(self, t).print
86
+ StackOutputPrinter.new(self, t).print
87
+ StackASGPrinter.new(self, t).print
88
+ t.draw_children
89
+ else
90
+ puts "#{stack_name} does NOT exist."
91
+ end
92
+ end
93
+
94
+ def parameters
95
+ get_stack(@name)
96
+ .parameters
97
+ .map { |p| [p.parameter_key, p.parameter_value] }
98
+ .to_h
99
+ end
100
+
101
+ def outputs
102
+ get_stack(@name)
103
+ .outputs
104
+ .map { |o| [o.output_key, o.output_value] }
105
+ .to_h
106
+ end
107
+
108
+ def exists?
109
+ cf_client.describe_stacks(stack_name: @name)
110
+ true
111
+ rescue Aws::CloudFormation::Errors::ValidationError
112
+ false
113
+ end
114
+ alias stack_exists? exists?
115
+
116
+ def resource_summaries
117
+ cf_client.list_stack_resources(stack_name: @name).stack_resource_summaries
118
+ end
119
+
120
+ # @return [String, nil]
121
+ def physical_id_for(logical_id)
122
+ resource_summary = resource_summaries.find do |r|
123
+ r.logical_resource_id == logical_id
124
+ end
125
+ resource_summary.physical_resource_id if resource_summary
126
+ end
127
+
128
+ # @return [Array<Aws::CloudFormation::Types::StackResourceSummary>]
129
+ def resources_of_type(type)
130
+ resource_summaries.select do |r|
131
+ r.resource_type == type
132
+ end
133
+ end
134
+
135
+ # Build a hash of overrides that would be applied to this stack by an
136
+ # update.
137
+ def overrides
138
+ if File.exist?(parameters_file)
139
+ YAML.load_file(parameters_file)
140
+ else
141
+ {}
142
+ end
143
+ end
144
+
145
+ # Return a Hash of the default values defined in the stack template.
146
+ def default_values
147
+ h = {}
148
+ JSON.parse(template.body).fetch('Parameters', {}).map do |k, v|
149
+ h[k] = v['Default']
150
+ end
151
+ h
152
+ end
153
+
154
+ def template
155
+ @template ||= StackTemplate.new(template_file, log: @log)
156
+ end
157
+
158
+ # @return [String] the path to the template file.
159
+ def template_file
160
+ File.join(Dir.pwd, 'cloud_formation', "#{@app_name}.json")
161
+ end
162
+
163
+ # @return [String] the path to the parameters file.
164
+ def parameters_file
165
+ File.join(Dir.pwd, 'cloud_formation', 'parameters', "#{@name}.yml")
166
+ end
167
+
168
+ def add_parameter_overrides(hash)
169
+ new_overrides = hash.merge(overrides)
170
+ File.open(parameters_file, 'w') do |f|
171
+ YAML.dump(new_overrides, f)
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ def stack_name
178
+ "CloudFormation Stack #{@name.blue}"
179
+ end
180
+
181
+ def load_parameters_file
182
+ @ilog.msg "Loading stack parameters file '#{parameters_file}'."
183
+ result = stack_parameter_overrides
184
+
185
+ if result.empty?
186
+ @ilog.msg "No parameters file for #{@name.blue}, using defaults."
187
+ return result
188
+ end
189
+
190
+ @ilog.msg 'Setting stack parameter overrides:'
191
+ result.each do |e|
192
+ @ilog.msg " #{e[:parameter_key]}: #{e[:parameter_value]}"
193
+ end
194
+ end
195
+
196
+ def stack_parameter_overrides
197
+ overrides.map do |k, v|
198
+ { parameter_key: k, parameter_value: v.to_s }
199
+ end
200
+ end
201
+
202
+ def stack_parameters
203
+ @stack_parameters ||= JSON.parse(template.body).fetch('Parameters', {}).keys
204
+ end
205
+
206
+ def import_parent_parameters
207
+ add_parameter_overrides(parent_stack_outputs)
208
+ end
209
+
210
+ # Return a Hash of parent stack outputs that match parameter names for this
211
+ # stack.
212
+ def parent_stack_outputs
213
+ result = {}
214
+
215
+ @config.parent_stacks.each do |stack_name|
216
+ resp = cf_client.describe_stacks(stack_name: stack_name)
217
+ raise "Parent Stack #{stack_name} not found!" unless resp.stacks.size == 1
218
+
219
+ # If there is an input parameters matching a stack output, pass it.
220
+ resp.stacks[0].outputs.each do |output|
221
+ if stack_parameters.include?(output.output_key)
222
+ result[output.output_key] = output.output_value
223
+ end
224
+ end
225
+ end
226
+
227
+ result
228
+ end
229
+
230
+ # @return [Aws::CloudFormation::Types::Stack]
231
+ def get_stack(name)
232
+ stacks = cf_client.describe_stacks(stack_name: name).stacks
233
+ raise Thor::Error, "Could not describe stack: #{name}" if stacks.empty?
234
+
235
+ stacks.first
236
+ rescue Aws::CloudFormation::Errors::ValidationError
237
+ raise Thor::Error, "Could not describe stack: #{name}"
238
+ end
239
+
240
+ def create_stack
241
+ cf_client.create_stack(
242
+ stack_name: @name,
243
+ template_body: template.body,
244
+ capabilities: ['CAPABILITY_IAM'],
245
+ parameters: load_parameters_file,
246
+ tags: [
247
+ { key: 'ah_stage', value: @name }
248
+ ]
249
+ )
250
+ rescue Aws::CloudFormation::Errors::AccessDenied
251
+ raise Thor::Error, 'You are not authorized to perform create_stack calls.'
252
+ end
253
+
254
+ # @return [Boolean]
255
+ # true if a stack update was required and initiated, false otherwise.
256
+ def update_stack
257
+ cf_client.update_stack(
258
+ stack_name: @name,
259
+ template_body: template.body,
260
+ capabilities: ['CAPABILITY_IAM'],
261
+ parameters: stack_parameter_overrides
262
+ )
263
+ true
264
+ rescue Aws::CloudFormation::Errors::ValidationError => e
265
+ raise Thor::Error, e.message unless
266
+ e.message == 'No updates are to be performed.'
267
+ false
268
+ end
269
+
270
+ # TODO: Refactor this into it's own class.
271
+ def wait_for_stack_state(wait_target, past_tense_verb) # rubocop:disable AbcSize
272
+ result = true
273
+
274
+ stack_id = get_stack(@name).stack_id
275
+
276
+ events = StackEventsPoller.new(cf_client, stack_id)
277
+ events.show_only_errors unless @config.show_all_events
278
+
279
+ @ilog.start_threaded "Waiting for #{stack_name} to be successfully #{past_tense_verb}." do |s|
280
+ begin
281
+ cf_client.wait_until(wait_target, stack_name: stack_id) do |w|
282
+ w.delay = 10
283
+ w.max_attempts = 180 # 30 minutes.
284
+ w.before_wait do |attempt, resp|
285
+ begin
286
+ events.latest_events.each { |e| @ilog.error(format_event(e)) }
287
+ # rubocop:disable Lint/HandleExceptions
288
+ rescue Aws::CloudFormation::Errors::ValidationError
289
+ # Do nothing. The above event logging block may result in
290
+ # a ValidationError while waiting for a stack to delete.
291
+ end
292
+ # rubocop:enable Lint/HandleExceptions
293
+
294
+ if attempt == w.max_attempts - 1
295
+ s.failure "#{stack_name} was not #{past_tense_verb} after 30 minutes."
296
+ result = false
297
+
298
+ # We don't want the interactive logger to catch an exception.
299
+ throw :success
300
+ end
301
+ s.continue "Waiting for CloudFormation Stack to be successfully #{past_tense_verb}, current status '#{resp.stacks.first.stack_status}'." # rubocop:disable LineLength
302
+ end
303
+ end
304
+
305
+ s.success "#{stack_name} successfully #{past_tense_verb}." if result
306
+ rescue Aws::Waiters::Errors::FailureStateError
307
+ result = false
308
+ s.failure "#{stack_name} failed to update."
309
+ end
310
+ end
311
+
312
+ result
313
+ end
314
+
315
+ def format_event(event)
316
+ str = case event.resource_status
317
+ when /FAILED/
318
+ event.resource_status.red
319
+ when /IN_PROGRESS/
320
+ event.resource_status.yellow
321
+ else
322
+ event.resource_status.green
323
+ end
324
+ str << " #{event.logical_resource_id}"
325
+ str << " #{event.resource_status_reason.light_black}" if event.resource_status_reason
326
+
327
+ str
328
+ end
329
+
330
+ def doctor_check_template_exists
331
+ if File.exist?(template_file)
332
+ success "CloudFormation template found at '#{template_file}'."
333
+ else
334
+ critical "CloudFormation template not found at '#{template_file}'!"
335
+ end
336
+ end
337
+
338
+ def doctor_check_template_against_aws
339
+ cf_client.validate_template(template_body: template.body)
340
+ success('CloudFormation template is valid.')
341
+ rescue => e
342
+ critical('Invalid CloudFormation template!', e.message)
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,151 @@
1
+ # coding: utf-8
2
+ require 'colorize'
3
+ require 'ruby-duration'
4
+
5
+ module Moonshot
6
+ # Display information about the AutoScaling Groups, associated ELBs, and
7
+ # managed instances to the user.
8
+ class StackASGPrinter # rubocop:disable ClassLength
9
+ include CredsHelper
10
+
11
+ def initialize(stack, table)
12
+ @stack = stack
13
+ @table = table
14
+ end
15
+
16
+ def print
17
+ asgs.each do |asg|
18
+ asg_info = as_client.describe_auto_scaling_groups(
19
+ auto_scaling_group_names: [asg.physical_resource_id])
20
+ .auto_scaling_groups.first
21
+ t_asg_info = @table.add_leaf("ASG: #{asg.logical_resource_id}")
22
+
23
+ add_asg_info(t_asg_info, asg_info)
24
+ instances_leaf = t_asg_info.add_leaf('Instances')
25
+
26
+ if asg_info.instances.empty?
27
+ instances_leaf.add_line('There are no instances in this Auto-Scaling Group.')
28
+ else
29
+ instances_leaf.add_table(create_instance_table(asg_info))
30
+ end
31
+
32
+ add_recent_activity_leaf(t_asg_info, asg.physical_resource_id)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def asgs
39
+ @stack.resources_of_type('AWS::AutoScaling::AutoScalingGroup')
40
+ end
41
+
42
+ def status_with_color(status)
43
+ case status
44
+ when 'Successful'
45
+ status.green
46
+ when 'Failed'
47
+ status.red
48
+ else
49
+ status.yellow
50
+ end
51
+ end
52
+
53
+ def lifecycle_color(lifecycle)
54
+ case lifecycle
55
+ when 'InService'
56
+ lifecycle.green
57
+ else
58
+ lifecycle.red
59
+ end
60
+ end
61
+
62
+ def health_color(health)
63
+ case health
64
+ when 'Healthy'
65
+ health.green
66
+ else
67
+ health.red
68
+ end
69
+ end
70
+
71
+ # Get additional information about instances not returned by the ASG API.
72
+ def get_addl_info(instance_ids)
73
+ resp = ec2_client.describe_instances(instance_ids: instance_ids)
74
+
75
+ data = {}
76
+ resp.reservations.map(&:instances).flatten.each do |instance|
77
+ data[instance.instance_id] = instance
78
+ end
79
+ data
80
+ end
81
+
82
+ def add_asg_info(table, asg_info) # rubocop:disable AbcSize
83
+ name = asg_info.auto_scaling_group_name.blue
84
+ table.add_line "Name: #{name}"
85
+
86
+ hc = asg_info.health_check_type.blue
87
+ gp = (asg_info.health_check_grace_period.to_s << 's').blue
88
+ table.add_line "Using #{hc} health checks, with a #{gp} health check grace period." # rubocop:disable LineLength
89
+
90
+ dc = asg_info.desired_capacity.to_s.blue
91
+ min = asg_info.min_size.to_s.blue
92
+ max = asg_info.max_size.to_s.blue
93
+ table.add_line "Desired Capacity is #{dc} (Min: #{min}, Max: #{max})."
94
+
95
+ lbs = asg_info.load_balancer_names
96
+ table.add_line "Has #{lbs.count.to_s.blue} Load Balancer(s): #{lbs.map(&:blue).join(', ')}" # rubocop:disable LineLength
97
+ end
98
+
99
+ def create_instance_table(asg_info)
100
+ current_lc = asg_info.launch_configuration_name
101
+ ec2_info = get_addl_info(asg_info.instances.map(&:instance_id))
102
+ asg_info.instances.map do |asg_instance|
103
+ row = instance_row(asg_instance,
104
+ ec2_info[asg_instance.instance_id])
105
+ row << if current_lc == asg_instance.launch_configuration_name
106
+ '(launch config up to date)'.green
107
+ else
108
+ '(launch config out of date)'.red
109
+ end
110
+ end
111
+ end
112
+
113
+ def instance_row(asg_instance, ec2_instance)
114
+ [
115
+ asg_instance.instance_id,
116
+ # @todo What about ASGs with only private IPs?
117
+ ec2_instance.public_ip_address,
118
+ lifecycle_color(asg_instance.lifecycle_state),
119
+ health_color(asg_instance.health_status),
120
+ uptime_format(ec2_instance.launch_time)
121
+ ]
122
+ end
123
+
124
+ def uptime_format(launch_time)
125
+ # %td is "total days", instead of counting up again to weeks.
126
+ Duration.new(Time.now.to_i - launch_time.to_i)
127
+ .format('%tdd %hh %mm %ss')
128
+ end
129
+
130
+ def add_recent_activity_leaf(table, asg_name)
131
+ recent = table.add_leaf('Recent Activity')
132
+ resp = as_client.describe_scaling_activities(
133
+ auto_scaling_group_name: asg_name).activities
134
+
135
+ rows = resp.sort_by(&:start_time).reverse.first(10).map do |activity|
136
+ row_for_activity(activity)
137
+ end
138
+
139
+ recent.add_table(rows)
140
+ end
141
+
142
+ def row_for_activity(activity)
143
+ [
144
+ activity.start_time.to_s.light_black,
145
+ activity.description,
146
+ status_with_color(activity.status_code),
147
+ activity.progress.to_s << '%'
148
+ ]
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,12 @@
1
+ module Moonshot
2
+ # Configuration for the Moonshot::Stack class.
3
+ class StackConfig
4
+ attr_accessor :parent_stacks
5
+ attr_accessor :show_all_events
6
+
7
+ def initialize
8
+ @parent_stacks = []
9
+ @show_all_events = false
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,56 @@
1
+ module Moonshot
2
+ # The StackEventsPoller queries DescribeStackEvents every time #latest_events
3
+ # is invoked, filtering out events that have already been returned. It can
4
+ # also, optionally, filter all non-error events (@see #show_errors_only).
5
+ class StackEventsPoller
6
+ def initialize(cf_client, stack_name)
7
+ @cf_client = cf_client
8
+ @stack_name = stack_name
9
+
10
+ # Start showing events from now.
11
+ @last_time = Time.now
12
+ end
13
+
14
+ def show_only_errors
15
+ @errors_only = true
16
+ end
17
+
18
+ # Build a list of events that have occurred since the last call to this
19
+ # method.
20
+ #
21
+ # @return [Array<Aws::CloudFormation::Event>]
22
+ def latest_events
23
+ events = get_stack_events.select do |event|
24
+ event.timestamp > @last_time
25
+ end
26
+
27
+ @last_time = Time.now
28
+
29
+ filter_events(events.sort_by(&:timestamp))
30
+ end
31
+
32
+ def filter_events(events)
33
+ if @errors_only
34
+ events.select do |event|
35
+ %w(CREATE_FAILED UPDATE_FAILED DELETE_FAILED).include?(event.resource_status)
36
+ end
37
+ else
38
+ events
39
+ end
40
+ end
41
+
42
+ def get_stack_events(token = nil)
43
+ opts = {
44
+ stack_name: @stack_name
45
+ }
46
+
47
+ opts[:next_token] = token if token
48
+
49
+ result = @cf_client.describe_stack_events(**opts)
50
+ events = result.stack_events
51
+ events += get_stack_events(result.next_token) if result.next_token
52
+
53
+ events
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,20 @@
1
+ module Moonshot
2
+ # The StackLister is world renoun for it's ability to list stacks.
3
+ class StackLister
4
+ include CredsHelper
5
+
6
+ def initialize(app_name, log:)
7
+ @app_name = app_name
8
+ @log = log
9
+ end
10
+
11
+ def list
12
+ all_stacks = cf_client.describe_stacks.stacks
13
+ app_stacks = all_stacks.reject { |s| s.stack_name !~ /^#{@app_name}/ }
14
+
15
+ app_stacks.each do |stack|
16
+ puts stack.stack_name
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ module Moonshot
2
+ # Display the stack outputs to the user.
3
+ class StackOutputPrinter
4
+ def initialize(stack, table)
5
+ @stack = stack
6
+ @table = table
7
+ end
8
+
9
+ def print
10
+ o_table = @table.add_leaf('Stack Outputs')
11
+ @stack.outputs.each do |k, v|
12
+ o_table.add_line("#{k}: #{v}")
13
+ end
14
+ end
15
+ end
16
+ end