ec2ctl 0.7.9 → 0.8.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.
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