ec2ctl 0.7.9 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/ec2ctl/client.rb CHANGED
@@ -1,39 +1,567 @@
1
1
  require "aws-sdk"
2
+ require "timeout"
2
3
 
3
4
  module EC2Ctl
4
5
  class Client
5
- def initialize(config = {})
6
- @ec2_resource = Aws::EC2::Resource.new(config)
7
- end
6
+ PingCheckError = Class.new RuntimeError
7
+ CommandNotSucceeded = Class.new RuntimeError
8
+ NoInstanceForExecCommand = Class.new RuntimeError
9
+ InvalidFilter = Class.new RuntimeError
8
10
 
9
- def instance_infos(attributes = [], search = {})
10
- @ec2_resource.instances.inject Array.new do |acc, instance|
11
- instance_info = attributes.inject Hash.new do |acc, attribute|
12
- acc[attribute] = case attribute
13
- when /\Atag:/i
14
- tag = instance.tags.find {|tag| tag.key == attribute.split(":")[1..-1].join(":")}
11
+ INSTANCE_IDS_LIMIT = 50
12
+ COMMAND_STATUS_FINISHED = %w(Success TimedOut Cancelled Failed).freeze
13
+ COMMAND_STATUS_NOT_SUCCEEDED = %w(TimedOut Cancelled Failed).freeze
15
14
 
16
- if tag
17
- tag.value
18
- else
19
- nil
20
- end
15
+ SSM_DOCUMENT_NAMES = {
16
+ Linux: "AWS-RunShellScript",
17
+ Windows: "AWS-RunPowerShellScript",
18
+ }.freeze
19
+
20
+ def initialize(
21
+ logger: nil,
22
+ load_balancer_name: nil,
23
+ platform_type: "Linux",
24
+ skip_ping_check: false,
25
+ commands: [],
26
+ working_directory: nil,
27
+ execution_timeout: 3600,
28
+ skip_command_waits: false,
29
+ wait_interval: 5,
30
+ timeout_seconds: nil,
31
+ comment: "Started at #{Time.now} (#{self.class}@#{VERSION})",
32
+ output_s3_bucket_name: nil,
33
+ output_s3_key_prefix: nil,
34
+ service_role_arn: nil,
35
+ notification_arn: nil,
36
+ notification_events: nil,
37
+ notification_type: nil,
38
+ rolling_group_size: 1,
39
+ skip_draining_waits: false,
40
+ skip_inservice_waits: false,
41
+ inservice_wait_timeout: 180,
42
+ instance_ids: [],
43
+ filters: [],
44
+ attributes: [],
45
+ search: []
46
+ )
47
+ @logger = logger
48
+
49
+ @ec2_resource = Aws::EC2::Resource.new
50
+ @elb_client = Aws::ElasticLoadBalancing::Client.new
51
+ @ssm_client = Aws::SSM::Client.new
52
+
53
+ @load_balancer_name = load_balancer_name
54
+ @platform_type = platform_type
55
+ @skip_ping_check = skip_ping_check
56
+ @commands = commands
57
+ @working_directory = working_directory
58
+ @execution_timeout = execution_timeout
59
+ @skip_command_waits = skip_command_waits
60
+ @wait_interval = wait_interval
61
+ @timeout_seconds = timeout_seconds
62
+ @comment = comment
63
+ @output_s3_bucket_name = output_s3_bucket_name
64
+ @output_s3_key_prefix = output_s3_key_prefix
65
+ @service_role_arn = service_role_arn
66
+ @notification_arn = notification_arn
67
+ @notification_events = notification_events
68
+ @notification_type = notification_type
69
+ @skip_draining_waits = skip_draining_waits
70
+ @skip_inservice_waits = skip_inservice_waits
71
+ @inservice_wait_timeout = inservice_wait_timeout
72
+ @instance_ids = instance_ids
73
+ @filters = filters
74
+ @attributes = attributes
75
+ @search = search
76
+
77
+ if @load_balancer_name
78
+ @elb_instance_ids = elb_instance_states.map(&:instance_id).select do |instance_id|
79
+ if instance_ids.empty?
80
+ true
21
81
  else
22
- attribute.split(".").map(&:intern).inject instance.data do |acc, method|
23
- acc.send method
82
+ instance_ids.include? instance_id
83
+ end
84
+ end
85
+
86
+ @rolling_group_size = [rolling_group_size, @elb_instance_ids.size].min
87
+ end
88
+ end
89
+
90
+ def ec2_list
91
+ if @logger
92
+ if @logger.debug?
93
+ @logger.debug ec2_instances: ec2_instances
94
+ else
95
+ ec2_instances_summary = ec2_instances.each_with_object Array.new do |instance, instances_memo|
96
+ instance_summary = @attributes.each_with_object Hash.new do |attribute, attributes_memo|
97
+ attributes_memo[attribute] = query_instance_attribute(instance, attribute)
24
98
  end
99
+
100
+ instances_memo.push instance_summary
25
101
  end
26
102
 
27
- acc
103
+ @logger.info ec2_instances_summary: ec2_instances_summary
28
104
  end
105
+ end
106
+
107
+ {ec2_instances: ec2_instances}
108
+ end
29
109
 
30
- if search.empty?
31
- acc.push instance_info
110
+ def ec2_execute
111
+ ping_check ec2_instances.map(&:instance_id) unless @skip_ping_check
112
+ execute_commands ec2_instances.map(&:instance_id)
113
+ end
114
+
115
+ def elb_list
116
+ if @logger
117
+ if @logger.debug?
118
+ @logger.debug load_balancer_descriptions: load_balancer_descriptions
32
119
  else
33
- acc.push instance_info if search.all? {|sk, sv| instance_info[sk].match Regexp.new(sv)}
120
+ @logger.info load_balancer_descriptions_summary: load_balancer_descriptions.map {|lb|
121
+ {
122
+ load_balancer_name: lb.load_balancer_name,
123
+ dns_name: lb.dns_name,
124
+ instances: lb.instances.size,
125
+ }
126
+ }
127
+ end
128
+ end
129
+
130
+ {load_balancer_descriptions: load_balancer_descriptions}
131
+ end
132
+
133
+ def elb_status
134
+ load_balancer_description = load_balancer_descriptions.first
135
+
136
+ if @logger
137
+ @logger.info elb_instance_counts: elb_instance_counts
138
+ @logger.info elb_instance_states: elb_instance_states
139
+
140
+ instance_ids = elb_instance_states.map(&:instance_id)
141
+
142
+ @logger.debug ssm_instance_ping_counts: ssm_instance_ping_counts(instance_ids)
143
+ @logger.debug ssm_instance_states: ssm_instance_states(instance_ids)
144
+ @logger.debug load_balancer_description: load_balancer_description
145
+ @logger.debug load_balancer_attributes: load_balancer_attributes
146
+ end
147
+
148
+ {
149
+ load_balancer_description: load_balancer_description,
150
+ load_balancer_attributes: load_balancer_attributes,
151
+ elb_instance_states: elb_instance_states,
152
+ }
153
+ end
154
+
155
+ def elb_attach
156
+ attach_instances @instance_ids
157
+ end
158
+
159
+ def elb_detach
160
+ detach_instances @instance_ids
161
+ end
162
+
163
+ def elb_execute
164
+ ping_check @elb_instance_ids unless @skip_ping_check
165
+ execute_commands @elb_instance_ids
166
+ end
167
+
168
+ def elb_graceful
169
+ ping_check @elb_instance_ids unless @skip_ping_check
170
+
171
+ @elb_instance_ids.each_slice(@rolling_group_size).to_a.tap do |instance_id_groups|
172
+ instance_id_groups.each.with_index do |instance_id_group, group_index|
173
+ @logger.info(progress: {
174
+ completed: @rolling_group_size * group_index,
175
+ remaining: @elb_instance_ids.size - @rolling_group_size * group_index,
176
+ total: @elb_instance_ids.size,
177
+ }) if @logger
178
+
179
+ detach_instances instance_id_group
180
+ wait_draining instance_id_group unless @skip_draining_waits
181
+ execute_commands instance_id_group
182
+ attach_instances instance_id_group
183
+ wait_inservice instance_id_group unless @skip_inservice_waits
184
+ end
185
+
186
+ @logger.info "Everything done!" if @logger
187
+ end
188
+ end
189
+
190
+ def detach_instances(instance_ids = [])
191
+ response = @elb_client.deregister_instances_from_load_balancer(
192
+ load_balancer_name: @load_balancer_name,
193
+ instances: instance_ids.map {|i| {instance_id: i}},
194
+ )
195
+
196
+ status = {
197
+ detached: instance_ids,
198
+ registered: response.instances.map(&:instance_id),
199
+ }
200
+
201
+ if @logger
202
+ if @logger.debug?
203
+ @logger.debug status: status
204
+ else
205
+ @logger.info detached: instance_ids
206
+ end
207
+ end
208
+
209
+ status
210
+ end
211
+
212
+ def attach_instances(instance_ids = [])
213
+ response = @elb_client.register_instances_with_load_balancer(
214
+ load_balancer_name: @load_balancer_name,
215
+ instances: instance_ids.map {|i| {instance_id: i}},
216
+ )
217
+
218
+ status = {
219
+ attached: instance_ids,
220
+ registered: response.instances.map(&:instance_id),
221
+ }
222
+
223
+ if @logger
224
+ if @logger.debug?
225
+ @logger.debug status: status
226
+ else
227
+ @logger.info attached: instance_ids
228
+ end
229
+ end
230
+
231
+ status
232
+ end
233
+
234
+ def wait_draining(instance_ids = [])
235
+ connection_draining = load_balancer_attributes.connection_draining
236
+
237
+ unless connection_draining.enabled
238
+ @logger.info wait_draining_timeout: "Disabled.".freeze if @logger
239
+ return
240
+ end
241
+
242
+ if @logger
243
+ @logger.debug detached_instances: elb_instance_states!(instance_ids)
244
+ @logger.info wait_draining_timeout: connection_draining.timeout
245
+ end
246
+
247
+ sleep connection_draining.timeout
248
+ end
249
+
250
+ def wait_inservice(instance_ids = [])
251
+ s = elb_instance_states! instance_ids
252
+
253
+ @logger.info wait_instance_inservice: s if @logger
254
+
255
+ Timeout.timeout @inservice_wait_timeout do
256
+ loop do
257
+ sleep @wait_interval
258
+
259
+ s = elb_instance_states! instance_ids
260
+ @logger.debug wait_instance_inservice: s if @logger
261
+
262
+ break if s.all? {|i| i.state == "InService".freeze}
263
+ end
264
+ end
265
+
266
+ @logger.info wait_instance_inservice: s if @logger
267
+ end
268
+
269
+ private
270
+
271
+ def elb_instance_states!(instance_ids = nil)
272
+ @elb_instance_states = @elb_client.describe_instance_health(
273
+ load_balancer_name: @load_balancer_name
274
+ ).instance_states
275
+
276
+ if instance_ids
277
+ elb_instance_states.select do |i|
278
+ instance_ids.include? i.instance_id
279
+ end
280
+ else
281
+ @elb_instance_states
282
+ end
283
+ end
284
+
285
+ def command_console_url(command_id)
286
+ [
287
+ "https://".freeze,
288
+ @ssm_client.config.region,
289
+ ".console.aws.amazon.com/ec2/v2/home?region=".freeze,
290
+ @ssm_client.config.region,
291
+ "#Commands:CommandId=".freeze,
292
+ command_id,
293
+ ].join
294
+ end
295
+
296
+ def wait_command_finish(command_id)
297
+ Timeout.timeout(@execution_timeout + @wait_interval * 3) do
298
+ loop do
299
+ command_invocations = get_command_invocations(command_id)
300
+
301
+ @logger.debug command_invocations: command_invocations if @logger
302
+
303
+ return command_invocations if command_invocations.all? {|i| COMMAND_STATUS_FINISHED.include? i.status}
304
+
305
+ sleep @wait_interval
306
+ end
307
+ end
308
+ end
309
+
310
+ def get_command_invocations(command_id)
311
+ command_invocations = []
312
+ next_token = nil
313
+
314
+ loop do
315
+ response = @ssm_client.list_command_invocations(
316
+ command_id: command_id,
317
+ details: true,
318
+ next_token: next_token,
319
+ )
320
+
321
+ command_invocations += response.command_invocations
322
+ next_token = response.next_token
323
+
324
+ return command_invocations unless next_token
325
+ end
326
+ end
327
+
328
+ def command_params
329
+ return @command_params if @command_params
330
+
331
+ @command_params = {
332
+ commands: @commands,
333
+ }
334
+
335
+ @command_params.update workingDirectory: [@working_directory] if @working_directory
336
+ @command_params.update executionTimeout: [@execution_timeout.to_s] if @execution_timeout
337
+ @command_params
338
+ end
339
+
340
+ def nortification_config
341
+ return @nortification_config if @nortification_config
342
+
343
+ @nortification_config = {}
344
+ @nortification_config.update notification_arn: @notification_arn if @notification_arn
345
+ @nortification_config.update notification_events: @notification_events if @notification_events
346
+ @nortification_config.update notification_type: @notification_type if @notification_type
347
+ @nortification_config
348
+ end
349
+
350
+ def send_command_params(instance_ids)
351
+ {
352
+ document_name: SSM_DOCUMENT_NAMES[@platform_type.intern],
353
+ instance_ids: instance_ids,
354
+ parameters: command_params,
355
+ comment: @comment,
356
+ output_s3_bucket_name: @output_s3_bucket_name,
357
+ output_s3_key_prefix: @output_s3_key_prefix,
358
+ service_role_arn: @service_role_arn,
359
+ notification_config: @notification_config,
360
+ }
361
+ end
362
+
363
+ def ping_check(instance_ids = [])
364
+ messages = []
365
+
366
+ check = instance_ids.each_with_object(Hash.new(0)) do |instance_id, memo|
367
+ instance = ssm_instance_states(instance_ids).find {|si| si.instance_id == instance_id}
368
+ status = instance.nil? ? "NotFound" : instance.ping_status
369
+
370
+ messages.push "#{instance_id}'s SSM agent is #{status}." unless status == "Online"
371
+
372
+ memo[status] += 1
373
+ end
374
+
375
+ @logger.debug ping_check: check if @logger
376
+
377
+ unless check.reject {|k, v| k == "Online"}.empty?
378
+ fail PingCheckError, messages.join(" ")
379
+ end
380
+ end
381
+
382
+ def execute_commands(instance_ids = [])
383
+ fail NoInstanceForExecCommand, "No instance for executing commands." if instance_ids.empty?
384
+
385
+ instance_ids.each_slice INSTANCE_IDS_LIMIT do |instance_ids_slice|
386
+ send_command_result = @ssm_client.send_command(send_command_params(instance_ids_slice))
387
+ command_id = send_command_result.command.command_id
388
+
389
+ if @logger
390
+ if @logger.debug?
391
+ @logger.debug command: send_command_result.command
392
+ else
393
+ @logger.info(
394
+ command_summary: {
395
+ command_id: command_id,
396
+ console_url: command_console_url(command_id),
397
+ commands: @commands,
398
+ instance_ids: instance_ids_slice,
399
+ }
400
+ )
401
+ end
34
402
  end
35
403
 
36
- acc
404
+ unless @skip_command_waits
405
+ command_invocations = wait_command_finish(command_id)
406
+
407
+ if @logger
408
+ if @logger.debug?
409
+ @logger.debug command_invocations: command_invocations
410
+ else
411
+ @logger.info(
412
+ command_invocations_status_counts: command_invocations.each_with_object(Hash.new(0)) {|invocation, memo|
413
+ memo[invocation.status] += 1
414
+ }
415
+ )
416
+
417
+ @logger.info(
418
+ command_invocations_summary: command_invocations.map {|invocation|
419
+ {
420
+ instance_id: invocation.instance_id,
421
+ status: invocation.status,
422
+ output: invocation.command_plugins.first.output,
423
+ }
424
+ }
425
+ )
426
+ end
427
+ end
428
+
429
+ if command_invocations.any? {|i| COMMAND_STATUS_NOT_SUCCEEDED.include? i.status}
430
+ fail CommandNotSucceeded, "One or more command invocation(s) has not succeeded."
431
+ end
432
+ end
433
+
434
+ get_command_invocations(command_id)
435
+ end
436
+ end
437
+
438
+ def elb_instance_states
439
+ @elb_instance_states ||= elb_instance_states!
440
+ end
441
+
442
+ def ssm_instance_states(instance_ids = [])
443
+ return @ssm_instance_states if @ssm_instance_states
444
+
445
+ @ssm_instance_states = []
446
+
447
+ loop do
448
+ next_token = nil
449
+
450
+ response = @ssm_client.describe_instance_information(
451
+ next_token: next_token,
452
+
453
+ instance_information_filter_list: [
454
+ {
455
+ key: "PlatformTypes".freeze,
456
+ value_set: [@platform_type],
457
+ },
458
+ {
459
+ key: "InstanceIds".freeze,
460
+ value_set: instance_ids,
461
+ },
462
+ ]
463
+ )
464
+
465
+ @ssm_instance_states += response.instance_information_list
466
+ next_token = response.next_token
467
+ break unless next_token
468
+ end
469
+
470
+ @ssm_instance_states
471
+ end
472
+
473
+ def load_balancer_descriptions
474
+ return @load_balancer_descriptions if @load_balancer_descriptions
475
+
476
+ @load_balancer_descriptions = []
477
+
478
+ loop do
479
+ next_marker = nil
480
+
481
+ params = {marker: next_marker}
482
+ params.update load_balancer_names: [@load_balancer_name] if @load_balancer_name
483
+
484
+ response = @elb_client.describe_load_balancers params
485
+
486
+ @load_balancer_descriptions += response.load_balancer_descriptions
487
+ next_marker = response.next_marker
488
+ break unless next_marker
489
+ end
490
+
491
+ @load_balancer_descriptions
492
+ end
493
+
494
+ def load_balancer_attributes
495
+ @load_balancer_attributes ||= @elb_client.describe_load_balancer_attributes(
496
+ load_balancer_name: @load_balancer_name,
497
+ ).load_balancer_attributes
498
+ end
499
+
500
+ def elb_instance_counts
501
+ elb_instance_states.each_with_object Hash.new(0) do |i, memo|
502
+ memo[i.state] += 1
503
+ end
504
+ end
505
+
506
+ def ssm_instance_ping_counts(instance_ids = [])
507
+ ssm_instance_states(instance_ids).each_with_object Hash.new(0) do |i, memo|
508
+ memo[i.ping_status] += 1
509
+ end
510
+ end
511
+
512
+ def ec2_instances
513
+ return @ec2_instances if @ec2_instances
514
+
515
+ params = {}
516
+
517
+ params.update instance_ids: @instance_ids unless @instance_ids.empty?
518
+
519
+ unless @filters.empty?
520
+ filters = @filters.map do |f|
521
+ match = f.match(/\A(.*)=(.*)\z/)
522
+
523
+ fail InvalidFilter, "Filter should be `key=value` format (got `#{f}`)." unless match
524
+
525
+ {
526
+ name: match[1],
527
+ values: [match[2]],
528
+ }
529
+ end
530
+
531
+ params.update filters: filters
532
+ end
533
+
534
+ @ec2_instances = @ec2_resource.instances(params)
535
+
536
+ unless @search.empty?
537
+ search_hash = @search.each_with_object Hash.new do |s, search_memo|
538
+ match = s.match(/\A(.*)=(.*)\z/)
539
+
540
+ fail InvalidFilter, "Search should be `key=value` format (got `#{s}`)." unless match
541
+
542
+ search_memo.update match[1] => match[2]
543
+ end
544
+
545
+ @ec2_instances = @ec2_instances.select do |instance|
546
+ search_hash.all? do |k, v|
547
+ Regexp.new(v).match query_instance_attribute(instance, k)
548
+ end
549
+ end
550
+ end
551
+
552
+ @ec2_instances
553
+ end
554
+
555
+ def query_instance_attribute(instance, attribute)
556
+ case attribute
557
+ when /\Atag:/i
558
+ tag = instance.tags.find {|t| t.key == attribute.split(":")[1..-1].join(":")}
559
+
560
+ tag ? tag.value : nil
561
+ else
562
+ attribute.split(".").map(&:intern).inject instance.data do |_acc, method|
563
+ _acc.send method
564
+ end
37
565
  end
38
566
  end
39
567
  end