ridoku 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,717 @@
1
+ #
2
+ # Base Ridoku class for running commands
3
+
4
+ require 'aws'
5
+ require 'active_support/inflector'
6
+ require 'securerandom'
7
+ require 'restclient'
8
+
9
+ module Ridoku
10
+ class InvalidConfig < StandardError
11
+ attr_accessor :type, :error
12
+
13
+ def initialize(type, error)
14
+ self.type = type
15
+ self.error = error
16
+ end
17
+ end
18
+
19
+ class NoSshAccess < StandardError; end
20
+
21
+ class Base
22
+ class << self
23
+ attr_accessor :config, :aws_client, :iam_client, :stack, :custom_json,
24
+ :app, :layers, :instances, :account, :permissions, :stack_list,
25
+ :app_list, :layer_list, :instance_list, :account_id, :ec2_client
26
+
27
+ @config = {}
28
+
29
+ POSTGRES_GROUP_NAME = 'Ridoku-PostgreSQL-Server'
30
+
31
+ def load_config(path)
32
+ if File.exists?(path)
33
+ File.open(path, 'r') do |file|
34
+ self.config = JSON.parse(file.read, symbolize_names: true)
35
+ end
36
+ end
37
+
38
+ (self.config ||= {}).tap do |default|
39
+ default[:wait] = true
40
+ end
41
+ end
42
+
43
+ def save_config(path, limit = [:app, :stack, :ssh_key, :local_init,
44
+ :shell_user, :service_arn, :instance_arn, :backup_bucket])
45
+ save = {}
46
+ if limit.length
47
+ limit.each do |lc|
48
+ save[lc] = config[lc]
49
+ end
50
+ else
51
+ save = config
52
+ end
53
+ File.open(path, 'w') do |file|
54
+ file.write(save.to_json)
55
+ end
56
+ end
57
+
58
+ def configure_opsworks_client
59
+ opsworks = AWS::OpsWorks.new
60
+ self.aws_client = opsworks.client
61
+ end
62
+
63
+ def fetch_stack(options = {})
64
+ return stack if stack && !options[:force]
65
+
66
+ configure_opsworks_client
67
+
68
+ stack_name = config[:stack]
69
+
70
+ fail InvalidConfig.new(:stack, :none) unless stack_name ||
71
+ !options[:force]
72
+
73
+ self.stack_list = aws_client.describe_stacks[:stacks]
74
+ self.stack = nil
75
+
76
+ stack_list.each do |stck|
77
+ self.stack = stck if stack_name == stck[:name]
78
+ end
79
+
80
+ fail InvalidConfig.new(:stack, :invalid) if !stack &&
81
+ !options[:force]
82
+
83
+ self.custom_json = JSON.parse(stack[:custom_json]) if stack
84
+
85
+ return stack
86
+ end
87
+
88
+ def save_stack
89
+ aws_client.update_stack(
90
+ stack_id: stack[:stack_id],
91
+ custom_json: custom_json.to_json,
92
+ service_role_arn: stack[:service_role_arn]
93
+ ) if stack
94
+ end
95
+
96
+ def fetch_app(options = {})
97
+ return app if app && !options[:force]
98
+
99
+ fetch_stack
100
+ app_name = config[:app]
101
+
102
+ fail InvalidConfig.new(:app, :none) unless app_name
103
+
104
+ self.app_list = aws_client.describe_apps(stack_id: stack[:stack_id])[:apps]
105
+ self.app = nil
106
+
107
+ app_list.each do |sapp|
108
+ self.app = sapp if app_name == sapp[:name]
109
+ end
110
+
111
+ fail InvalidConfig.new(:app, :invalid) unless app
112
+
113
+ return app
114
+ end
115
+
116
+ def save_app(values)
117
+ values = [values] unless values.is_a?(Array)
118
+ unless app
119
+ $stderr.puts "Unable to save information because no app is " +
120
+ "specified."
121
+ return
122
+ end
123
+
124
+ save_info = {
125
+ app_id: app[:app_id]
126
+ }
127
+
128
+ save_info.tap do |info|
129
+ values.each do |val|
130
+ info[val] = app[val]
131
+ end
132
+ end
133
+
134
+ aws_client.update_app(save_info)
135
+ end
136
+
137
+ def fetch_layer(shortname = :all, options = {})
138
+ return layers if layers && !options[:force]
139
+ fetch_stack
140
+
141
+ unless self.layer_list
142
+ self.layers = self.layer_list = aws_client.describe_layers(
143
+ stack_id: stack[:stack_id])[:layers]
144
+ end
145
+
146
+ if shortname != :all
147
+ shortname = [shortname] unless shortname.is_a?(Array)
148
+ self.layers = []
149
+
150
+ shortname.each do |short|
151
+ self.layers << self.layer_list.select do |layer|
152
+ layer[:shortname] == short
153
+ end
154
+ end
155
+
156
+ self.layers.flatten!
157
+ end
158
+ end
159
+
160
+ def get_layer_ids(shortname)
161
+ fetch_stack
162
+ layers = aws_client.describe_layers(stack_id: stack[:stack_id])[:layers]
163
+ layers.select { |l| l[:shortname] == shortname }
164
+ .map { |l| l[:layer_id] }
165
+ end
166
+
167
+ def save_layer(layer, values)
168
+ values = [values] unless values.is_a?(Array)
169
+
170
+ return unless values.length > 0
171
+
172
+ save_info = {
173
+ layer_id: layer[:layer_id]
174
+ }
175
+
176
+ save_info.tap do |info|
177
+ values.each do |val|
178
+ info[val] = layer[val]
179
+ end
180
+ end
181
+
182
+ aws_client.update_layer(save_info)
183
+ end
184
+
185
+ def instance_by_id(id)
186
+ fetch_instance
187
+ instance_list.select { |is| is[:instance_id] == id }.first
188
+ end
189
+
190
+ # 'lb' - load balancing layers
191
+ # 'rails-app'
192
+ # 'custom'
193
+ def fetch_instance(shortname = :all, options = {})
194
+ return instances if instances && !options[:force]
195
+
196
+ fetch_stack
197
+ unless instance_list
198
+ self.instance_list = self.instances =
199
+ aws_client.describe_instances(stack_id: stack[:stack_id])[:instances]
200
+ end
201
+
202
+ if shortname != :all
203
+ fetch_layer(shortname, force: true)
204
+ self.instances = []
205
+
206
+ layers.each do |layer|
207
+ instance = aws_client.describe_instances(
208
+ layer_id: layer[:layer_id])
209
+ self.instances << instance[:instances]
210
+ end
211
+
212
+ self.instances.flatten!
213
+ end
214
+ end
215
+
216
+ def get_instances_for_layer(layer)
217
+ layer_ids = get_layer_ids(layer)
218
+ instances = aws_client
219
+ .describe_instances(stack_id: stack[:stack_id])[:instances]
220
+ ret = []
221
+ layer_ids.each do |id|
222
+ instances.each do |inst|
223
+ ret << inst if inst[:layer_ids].include?(id)
224
+ end
225
+ end
226
+ ret
227
+ end
228
+
229
+ def configure_iam_client
230
+ return if self.iam_client
231
+
232
+ iam = AWS::IAM.new
233
+ self.iam_client = iam.client
234
+ end
235
+
236
+ def configure_ec2_client
237
+ return if self.ec2_client
238
+
239
+ self.ec2_client = AWS::EC2.new
240
+ end
241
+
242
+ def postgresql_group_exists?(region = 'us-west-1')
243
+ configure_ec2_client
244
+
245
+ ec2_client.security_groups.filter('group-name', POSTGRES_GROUP_NAME).length > 0
246
+ end
247
+
248
+ def update_pg_security_groups_in_all_regions
249
+ AWS.regions.each do |region|
250
+ $stdout.puts "Checking region: #{region.name}"
251
+ update_pg_security_group(region.ec2)
252
+ end
253
+ end
254
+
255
+ def update_pg_security_group(client = self.ec2_client)
256
+ fetch_stack
257
+
258
+ port = 5432
259
+
260
+ if custom_json.key?('postgresql') &&
261
+ custom_json['postgresql'].key?('config')
262
+ custom_json['postgresql']['config'].key?('port')
263
+ port = custom_json['postgresql']['config']['port']
264
+ end
265
+
266
+ perm_match = false
267
+ group = client.security_groups.filter('group-name', POSTGRES_GROUP_NAME).first
268
+
269
+ unless group
270
+ $stdout.puts "Creating security group: #{POSTGRES_GROUP_NAME} in #{client.regions.first.name}"
271
+ group = client.security_groups.create(POSTGRES_GROUP_NAME)
272
+ else
273
+ group.ingress_ip_permissions.each do |ipperm|
274
+ if ipperm.protocol == :tcp && ipperm.port_range == port..port
275
+ perm_match = true
276
+ else
277
+ ipperm.revoke
278
+ end
279
+ end
280
+ end
281
+
282
+ group.authorize_ingress(:tcp, port) unless perm_match
283
+ end
284
+
285
+ def fetch_account(options = {})
286
+ return account if account && !options[:force]
287
+
288
+ configure_iam_client
289
+
290
+ self.account = iam_client.get_user
291
+
292
+ self.account_id = nil
293
+
294
+ account[:user][:arn].match(/.*:.*:.*:.*:([0-9]+)/) do |m|
295
+ self.account_id = m[1]
296
+ end
297
+
298
+ fail StandardError.new('Failed to determine account ID from user info (it was me not you!)!') unless
299
+ account_id
300
+
301
+ account
302
+ end
303
+
304
+
305
+ def fetch_permissions(options = {})
306
+ fetch_stack
307
+ fetch_account
308
+
309
+ return permissions if permissions && !options[:force]
310
+
311
+ self.permissions = aws_client.describe_permissions(
312
+ iam_user_arn: account[:user][:arn],
313
+ stack_id: stack[:stack_id]
314
+ )
315
+ end
316
+
317
+ def fetch_roles
318
+ configure_iam_client
319
+
320
+ service = 'aws-opsworks-service-role'
321
+ instance = 'aws-opsworks-ec2-role'
322
+
323
+ iam_client.list_roles[:roles].each do |role|
324
+ config[:instance_arn] = role[:arn] if role[:role_name] == instance && !config.key?(:instance_arn)
325
+ config[:service_arn] = role[:arn] if role[:role_name] == service && !config.key?(:service_arn)
326
+ end
327
+ end
328
+
329
+ def if_debug?(&block)
330
+ yield if config[:debug]
331
+ end
332
+
333
+ def roles_configured?
334
+ fetch_roles
335
+ service_role_configured? && instance_role_configured?
336
+ end
337
+
338
+ def service_role_configured?
339
+ fetch_roles
340
+ config.key?(:service_arn) && config[:service_arn] != nil
341
+ end
342
+
343
+ def instance_role_configured?
344
+ fetch_roles
345
+ config.key?(:instance_arn) && config[:instance_arn] != nil
346
+ end
347
+
348
+ def configure_roles
349
+ configure_service_roles
350
+ configure_instance_roles
351
+ end
352
+
353
+ def configure_instance_roles
354
+ return true if instance_role_configured?
355
+ fetch_account
356
+
357
+ instance_role = "%7B%22Version%22%3A%222008-10-17%22%2C%22Statement%22%3A%5B%7B%22Sid%22%3A%22%22%2C%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22ec2.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D"
358
+ instance_resource = 'role/aws-opsworks-ec2-role'
359
+ instance_role_arn = "arn:aws:iam::#{account_id}:#{instance_resource}"
360
+ end
361
+
362
+ def configure_service_roles
363
+ return true if service_role_configured?
364
+ fetch_account
365
+
366
+ opsworks_role = "%7B%22Version%22%3A%222008-10-17%22%2C%22Statement%22%3A%5B%7B%22Sid%22%3A%22%22%2C%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22opsworks.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D"
367
+ opsworks_resource = 'role/aws-opsworks-service-role'
368
+ opsworks_role_arn = "arn:aws:iam::#{account_id}:#{opsworks_resource}"
369
+ end
370
+
371
+ def create_role(conf)
372
+ if config[:practice]
373
+ puts conf.to_s
374
+ else
375
+ iam_client.create_role(conf)
376
+ end
377
+ end
378
+
379
+ def create_app(conf)
380
+ conf[:stack_id] = stack[:stack_id]
381
+
382
+ # Ensure key exists
383
+ key_file = conf[:app_source][:ssh_key]
384
+
385
+ fail ArgumentError.new('Key file doesn\'t exist.') unless
386
+ File.exists?(key_file)
387
+
388
+ File.open(key_file, 'r') { |f| conf[:app_source][:ssh_key] = f.read }
389
+
390
+ # Config[:attributes] must be a hash of <string,string> type.
391
+ conf[:attributes].tap do |opt|
392
+ opt.keys.each do |k|
393
+ opt[k.to_s.camelize] = opt.delete(k).to_s unless k.is_a?(String)
394
+ end
395
+ end
396
+
397
+ # Ensure attribute 'rails_env' is specified
398
+ fail ArgumentError.new('attribute:rails_env must be specified.') unless
399
+ conf[:attributes]['RailsEnv'].length > 0
400
+
401
+ if config[:practice]
402
+ $stdout.puts conf.to_s
403
+ else
404
+ aws_client.create_app(conf)
405
+ initialize_app_environment(conf)
406
+ end
407
+ end
408
+
409
+ def initialize_app_environment(conf)
410
+ fetch_stack
411
+ fetch_layer
412
+ fetch_instance
413
+
414
+ app_layer = layer_list.select do |lyr|
415
+ lyr[:shortname] == 'rails-app'
416
+ end.first
417
+
418
+ db_layer = layer_list.select do |lyr|
419
+ lyr[:shortname] == 'postgresql'
420
+ end.first
421
+
422
+ deploy_info = custom_json['deploy']
423
+
424
+ app = conf[:shortname]
425
+
426
+ instance = instances.select do |inst|
427
+ inst[:status] == 'online' &&
428
+ inst[:layer_ids].index(app_layer[:layer_id]) != nil
429
+ end.first
430
+
431
+ db_instance = instances.select do |inst|
432
+ inst[:layer_ids].index(db_layer[:layer_id]) != nil
433
+ end.first
434
+
435
+ dbase_info = {
436
+ database: app,
437
+ username: SecureRandom.hex(12),
438
+ user_password: SecureRandom.hex(12)
439
+ }
440
+
441
+ ((custom_json['postgresql'] ||= {})['databases'] ||= []) << dbase_info
442
+
443
+ deploy_info[app] = {
444
+ auto_assets_precompile_on_deploy: true,
445
+ assetmaster: instance[:hostname],
446
+ app_env: {
447
+ 'RAILS_ENV' => conf[:attributes]['RailsEnv']
448
+ },
449
+ database: {
450
+ adapter: 'postgresql',
451
+ username: dbase_info[:username],
452
+ database: dbase_info[:database],
453
+ host: db_instance[:public_ip],
454
+ password: dbase_info[:user_password],
455
+ port: custom_json['postgresql']['config']['port']
456
+ }
457
+ }
458
+
459
+ save_stack
460
+
461
+ # Update add our changes to the database.
462
+ run_command({
463
+ instance_ids: [db_instance[:instance_id]],
464
+ command: {
465
+ name: 'execute_recipes',
466
+ args: { 'recipes' => 'postgresql::create_databases' }
467
+ }
468
+ })
469
+ end
470
+
471
+ def valid_instances?(args)
472
+ args = [args] unless args.is_a?(Array)
473
+
474
+ return false if args.length == 0
475
+
476
+ fetch_instance
477
+
478
+ inst_names = instances.map do |inst|
479
+ # if requested is stop, its definitely invalid.
480
+ return false if args.index(inst[:hostname]) != nil &&
481
+ inst[:status] == 'stopped'
482
+
483
+ inst[:hostname]
484
+ end
485
+
486
+ # if a requested is not in the list, then its an invalid list.
487
+ args.each do |arg|
488
+ return false if inst_names.index(arg) == nil
489
+ end
490
+
491
+ true
492
+ end
493
+
494
+ def select_instances(args)
495
+ fetch_instance
496
+ return instance_list unless args
497
+
498
+ args = [args] unless args.is_a?(Array)
499
+ return nil if args.length == 0
500
+
501
+ self.instances = instance_list.select do |inst|
502
+ args.index(inst[:hostname]) != nil
503
+ end
504
+ end
505
+
506
+ def pretty_instances(io)
507
+ inststr = []
508
+
509
+ instances.each do |inst|
510
+ val = "#{inst[:hostname]} [#{inst[:status]}]"
511
+ inststr << io.colorize(val,
512
+ [:bold, inst[:status] == 'online' ? :green : :red])
513
+ end
514
+
515
+ inststr
516
+ end
517
+
518
+ def run_command(deployment)
519
+ fetch_stack
520
+ fetch_app
521
+
522
+ deployment[:stack_id] = stack[:stack_id]
523
+
524
+ if config[:practice]
525
+ $stdout.puts "Would run command: #{deployment[:command][:name]}"
526
+ $stdout.puts 'On instances:'
527
+ instances.each do |inst|
528
+ next unless
529
+ deployment[:instance_ids].index(inst[:instance_id]) != nil
530
+
531
+ $stdout.puts " #{inst[:hostname]}: #{$stdout.colorize(
532
+ inst[:status], inst[:status] == 'online' ? :green : :red)}"
533
+
534
+ end
535
+
536
+ if deployment.key?(:custom_json)
537
+ $stdout.puts 'With custom_json:'
538
+ $stdout.puts JSON.pretty_generate(deployment[:custom_json])
539
+ end
540
+ else
541
+ if deployment.key?(:custom_json)
542
+ deployment[:custom_json] = JSON.generate(deployment[:custom_json])
543
+ end
544
+
545
+ depid = aws_client.create_deployment(deployment)[:deployment_id]
546
+
547
+ $stdout.puts $stdout.colorize('Command Sent', :green) if
548
+ config[:verbose]
549
+
550
+ monitor_deployment(depid) if config[:wait]
551
+ end
552
+ end
553
+
554
+ def extract_instance_ids(layers = nil)
555
+ Base.fetch_instance(layers || Base.config[:layers] || :all, force: true)
556
+
557
+ names = Base.config[:instances] || []
558
+ instances = Base.instances.select do |inst|
559
+ if names.length > 0
560
+ names.index(inst[:hostname]) != nil && inst[:status] != 'offline'
561
+ else
562
+ inst[:status] == 'online'
563
+ end
564
+ end
565
+
566
+ instances.map do |inst|
567
+ inst[:instance_id]
568
+ end
569
+ end
570
+
571
+ def base_command(app_id, instance_ids, comment)
572
+ fail ArgumentError.new('[ERROR] No instances selected.') if
573
+ !instance_ids.is_a?(Array) || instance_ids.empty?
574
+
575
+ {}.tap do |cmd|
576
+ cmd[:instance_ids] = instance_ids
577
+ cmd[:app_id] = app_id if app_id
578
+ cmd[:comment] = comment if comment
579
+ end
580
+ end
581
+
582
+ def update_cookbooks(instance_ids)
583
+ command = Base.base_command(nil, instance_ids,
584
+ Base.config[:comment])
585
+ command[:command] = { name: 'update_custom_cookbooks' }
586
+ command
587
+ end
588
+
589
+ def execute_recipes(app_id, instance_ids, comment, recipes,
590
+ custom_json = nil)
591
+ base_command(app_id, instance_ids, comment).tap do |cmd|
592
+ cmd[:command] = {
593
+ name: 'execute_recipes',
594
+ args: { 'recipes' => [recipes].flatten }
595
+ }
596
+ cmd[:custom_json] = custom_json if custom_json
597
+ end
598
+ end
599
+
600
+ def deploy(app_id, instance_ids, comment, custom_json = nil)
601
+ base_command(app_id, instance_ids, comment).tap do |cmd|
602
+ cmd[:command] = {
603
+ name: 'deploy'
604
+ }
605
+ cmd[:custom_json] = custom_json if custom_json
606
+ end
607
+ end
608
+
609
+ def rollback(app_id, instance_ids, comment, custom_json = nil)
610
+ dep = deploy(app_id, instance_ids, comment, custom_json)
611
+ dep[:command] = { name: 'rollback' }
612
+
613
+ dep
614
+ end
615
+
616
+ def standard_deploy(layer = :all, custom_json = nil)
617
+ fetch_instance(layer, force: true)
618
+ fetch_app
619
+
620
+ instances.select! { |inst| inst[:status] == 'online' }
621
+ instance_ids = instances.map { |inst| inst[:instance_id] }
622
+
623
+ unless config[:quiet]
624
+ $stdout.puts "Application:"
625
+ $stdout.puts " #{$stdout.colorize(app[:name], :bold)}"
626
+
627
+ $stdout.puts "#{instances.length} instance(s):"
628
+
629
+ pretty_instances($stdout).each do |inst|
630
+ $stdout.puts " #{inst}"
631
+ end
632
+
633
+ $stdout.puts "Repository:"
634
+ $stdout.puts " #{$stdout.colorize(app[:app_source][:url], :bold)}"\
635
+ " @ #{$stdout.colorize(app[:app_source][:revision], :bold)}"
636
+ end
637
+
638
+ run_command(deploy(app[:app_id], instance_ids, config[:comment],
639
+ custom_json))
640
+ end
641
+
642
+ def color_code_logs(logs)
643
+ $stderr.puts(logs.gsub(%r((?<color>\[[0-9]{1,2}m)),"\e\\k<color>"))
644
+ end
645
+
646
+ def monitor_deployment(dep_ids)
647
+ cmds = aws_client.describe_commands(deployment_id: dep_ids)
648
+
649
+ commands = cmds[:commands].map do |cmd|
650
+ { command: cmd, instance: instance_by_id(cmd[:instance_id]) }
651
+ end
652
+
653
+ $stdout.puts "Command issued to #{commands.length} instances:"
654
+ commands.each do |cmd|
655
+ $stdout.puts " #{$stdout.colorize(cmd[:instance][:hostname],
656
+ :green)}"
657
+ end
658
+
659
+ # Iterate a reasonable number of times... 100*5 => 500 seconds
660
+ 20.times do |time|
661
+ cmds = aws_client.describe_commands(deployment_id: dep_ids)
662
+
663
+ success = cmds[:commands].select do |cmd|
664
+ cmd[:status] == 'successful'
665
+ end
666
+
667
+ # Show we are still thinking...
668
+ case time % 4
669
+ when 0
670
+ print "\\\r"
671
+ when 1
672
+ print "|\r"
673
+ when 2
674
+ print "/\r"
675
+ when 3
676
+ print "-\r"
677
+ end
678
+
679
+ if cmds.length == success.length
680
+ $stdout.puts 'Command executed successfully.'
681
+ return
682
+ end
683
+
684
+ # Collect the non-[running,pending,successful] command entries
685
+ not_ok = cmds[:commands].select do |cmd|
686
+ ['running', 'pending', 'successful'].index(cmd[:status]) == nil
687
+ end.map do |cmd|
688
+ {
689
+ command: cmd,
690
+ instance: instance_by_id(cmd[:instance_id])
691
+ }
692
+ end
693
+
694
+ # Print each one that has failed.
695
+ not_ok.each do |item|
696
+ $stderr.puts "#{item[:instance][:hostname]}"
697
+ $stderr.puts " Status: " +
698
+ $stderr.colorize(item[:command][:status], :red)
699
+ $stderr.puts " Url: " + item[:command][:log_url]
700
+ color_code_logs(RestClient.get(item[:command][:log_url]))
701
+ exit 1
702
+ end
703
+
704
+ sleep 5
705
+ end
706
+ end
707
+ end
708
+ end
709
+ end
710
+
711
+ BYTE_UNITS2 =[[1073741824, "GB"], [1048576, "MB"], [1024, "KB"], [0,
712
+ "B"]]
713
+
714
+ def nice_bytes(n)
715
+ unit = BYTE_UNITS2.detect{ |u| n > u[0] }
716
+ "#{n/unit[0]} #{unit[1]}"
717
+ end