moonshot 0.7.0

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