ridoku 0.1.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,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