opzworks 0.3.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.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/CHANGELOG.md +30 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +212 -0
- data/Rakefile +11 -0
- data/bin/opzworks +9 -0
- data/circle.yml +6 -0
- data/img/flow.png +0 -0
- data/lib/opzworks/cli.rb +47 -0
- data/lib/opzworks/commands/berks.rb +147 -0
- data/lib/opzworks/commands/elastic.rb +103 -0
- data/lib/opzworks/commands/include/elastic.rb +134 -0
- data/lib/opzworks/commands/include/manage_berks_repos.rb +24 -0
- data/lib/opzworks/commands/include/populate_stack.rb +35 -0
- data/lib/opzworks/commands/include/run_local.rb +9 -0
- data/lib/opzworks/commands/json.rb +81 -0
- data/lib/opzworks/commands/ssh.rb +100 -0
- data/lib/opzworks/config.rb +35 -0
- data/lib/opzworks/meta.rb +7 -0
- data/lib/opzworks.rb +15 -0
- data/opzworks.gemspec +41 -0
- metadata +236 -0
@@ -0,0 +1,134 @@
|
|
1
|
+
def opsworks_list_ips(options = {})
|
2
|
+
response = @client.describe_instances options
|
3
|
+
@ip_addrs = []
|
4
|
+
response[:instances].each { |instance| @ip_addrs << instance.private_ip if instance[:status] == 'online' }
|
5
|
+
rescue StandardError => e
|
6
|
+
abort "Exception raised: #{e}".foreground(:red)
|
7
|
+
end
|
8
|
+
|
9
|
+
def es_get_input(input, data = {}, *cmd)
|
10
|
+
match = {}
|
11
|
+
count = 0
|
12
|
+
|
13
|
+
data[:stacks].each do |stack|
|
14
|
+
next unless stack[:name].chomp =~ /#{input}/
|
15
|
+
count = count += 1
|
16
|
+
match = stack.to_hash
|
17
|
+
end
|
18
|
+
|
19
|
+
# break?
|
20
|
+
if count < 1
|
21
|
+
puts 'No matching stacks found for input '.foreground(:yellow) + input.foreground(:green) + ', skipping.'.foreground(:yellow)
|
22
|
+
@get_data_failure = true
|
23
|
+
elsif count > 1
|
24
|
+
puts 'Found more than one stack matching input '.foreground(:yellow) + input.foreground(:green) + ', skipping.'.foreground(:yellow)
|
25
|
+
@get_data_failure = true
|
26
|
+
else
|
27
|
+
puts 'Operating on stack '.foreground(:blue) + "#{match[:name]}".foreground(:green)
|
28
|
+
layers = @client.describe_layers(stack_id: match[:stack_id])
|
29
|
+
layers[:layers].each { |layer| printf("%-30s %-50s\n", layer[:name], layer[:layer_id]) }
|
30
|
+
|
31
|
+
STDOUT.print 'Specify a layer: '.foreground(:blue)
|
32
|
+
layer = STDIN.gets.chomp
|
33
|
+
|
34
|
+
unless cmd.include? 'start'
|
35
|
+
STDOUT.print 'Disable shard allocation before starting? (true/false, default is true): '.foreground(:blue)
|
36
|
+
disable_allocation = STDIN.gets.chomp
|
37
|
+
case disable_allocation
|
38
|
+
when 'false'
|
39
|
+
@disable_shard_allocation = false
|
40
|
+
else
|
41
|
+
@disable_shard_allocation = true
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
options = {}
|
46
|
+
if layer == ''
|
47
|
+
puts 'Must specify a layer.'.foreground(:red)
|
48
|
+
abort
|
49
|
+
else
|
50
|
+
options[:layer_id] = layer
|
51
|
+
end
|
52
|
+
opsworks_list_ips(options)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def es_enable_allocation(ip, type)
|
57
|
+
puts "Cluster routing.allocation is being set to #{type}".foreground(:blue)
|
58
|
+
conn = Faraday.new(url: "http://#{ip}:9200") do |f|
|
59
|
+
f.adapter :net_http
|
60
|
+
end
|
61
|
+
|
62
|
+
count = 0
|
63
|
+
loop do
|
64
|
+
begin
|
65
|
+
conn.put do |req|
|
66
|
+
req.url '/_cluster/settings'
|
67
|
+
req.body = "{\"transient\": {\"cluster.routing.allocation.enable\": \"#{type}\"}}"
|
68
|
+
req.options[:timeout] = 5
|
69
|
+
req.options[:open_timeout] = 2
|
70
|
+
end
|
71
|
+
break
|
72
|
+
rescue StandardError => e
|
73
|
+
puts 'Caught exception while trying to change allocation state: '.foreground(:yellow) + "#{e}".foreground(:red) + ', looping around...'.foreground(:yellow) if count == 0
|
74
|
+
count += 1
|
75
|
+
sleep 1
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def es_service(command, ips = [])
|
81
|
+
puts "Operating on ES with command #{command}".foreground(:yellow)
|
82
|
+
user = ENV['USER']
|
83
|
+
|
84
|
+
Net::SSH::Multi.start do |session|
|
85
|
+
ips.each do |ip|
|
86
|
+
session.use "#{user}@#{ip}"
|
87
|
+
end
|
88
|
+
|
89
|
+
session.exec "sudo service elasticsearch #{command}"
|
90
|
+
session.loop
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def es_wait_for_status(ip, color)
|
95
|
+
puts 'Waiting for cluster to go '.foreground(:blue) + "#{color}".foreground(:"#{color}")
|
96
|
+
conn = Faraday.new(url: "http://#{ip}:9200") do |f|
|
97
|
+
f.adapter :net_http
|
98
|
+
end
|
99
|
+
|
100
|
+
count = 0
|
101
|
+
rescue_count = 0
|
102
|
+
loop do
|
103
|
+
begin
|
104
|
+
response = conn.get do |req|
|
105
|
+
req.url '/_cluster/health'
|
106
|
+
req.options[:timeout] = 5
|
107
|
+
req.options[:open_timeout] = 2
|
108
|
+
end
|
109
|
+
json = JSON.parse response.body
|
110
|
+
rescue StandardError => e
|
111
|
+
puts 'Caught exception while trying to check cluster status: '.foreground(:yellow) + "#{e}".foreground(:red) + ', looping around...'.foreground(:yellow) if rescue_count == 0
|
112
|
+
rescue_count += 1
|
113
|
+
printf '.'
|
114
|
+
sleep 1
|
115
|
+
else
|
116
|
+
case json['status']
|
117
|
+
when color
|
118
|
+
puts "\nCluster is now ".foreground(:blue) + "#{color}".foreground(:"#{color}")
|
119
|
+
break
|
120
|
+
when 'green'
|
121
|
+
puts "\nCluster is green, proceeding without waiting for requested status of #{color}".foreground(:green)
|
122
|
+
break
|
123
|
+
else
|
124
|
+
count += 1
|
125
|
+
if count == 10
|
126
|
+
puts "\nStill waiting, cluster is currently ".foreground(:blue) + "#{json['status']}".foreground(:"#{json['status']}")
|
127
|
+
count = 0
|
128
|
+
end
|
129
|
+
printf '.'
|
130
|
+
sleep 1
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
def manage_berks_repos
|
2
|
+
config = OpzWorks.config
|
3
|
+
@target_path = File.expand_path(config.berks_repository_path + "/opsworks-#{@project}", File.dirname(__FILE__))
|
4
|
+
|
5
|
+
if !File.directory?(@target_path)
|
6
|
+
if config.berks_github_org.nil?
|
7
|
+
puts "#{@target_path} does not exist, and 'berks-github-org' is not set in ~/.aws/config, skipping.".foreground(:yellow)
|
8
|
+
@berks_repo_failure = true
|
9
|
+
else
|
10
|
+
puts "#{@target_path} does not exist!".foreground(:red)
|
11
|
+
puts 'Attempting git clone of '.foreground(:blue) + "opsworks-#{@project}.".foreground(:green)
|
12
|
+
run_local <<-BASH
|
13
|
+
cd #{config.berks_repository_path}
|
14
|
+
git clone git@github.com:#{config.berks_github_org}/opsworks-#{@project}.git
|
15
|
+
BASH
|
16
|
+
end
|
17
|
+
else
|
18
|
+
puts "Git pull from #{@target_path}, branch: ".foreground(:blue) + @branch.foreground(:green)
|
19
|
+
run_local <<-BASH
|
20
|
+
cd #{@target_path}
|
21
|
+
git checkout #{@branch} && git pull origin #{@branch}
|
22
|
+
BASH
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
def populate_stack(input, data = {})
|
2
|
+
# loops over inputs
|
3
|
+
match = {}
|
4
|
+
count = 0
|
5
|
+
|
6
|
+
data[:stacks].each do |stack|
|
7
|
+
next unless stack[:name].chomp =~ /#{input}/
|
8
|
+
count = count += 1
|
9
|
+
match = stack.to_hash
|
10
|
+
end
|
11
|
+
|
12
|
+
# break?
|
13
|
+
if count < 1
|
14
|
+
puts 'No matching stacks found for input '.foreground(:yellow) + input.foreground(:green) + ', skipping.'.foreground(:yellow)
|
15
|
+
@populate_stack_failure = true
|
16
|
+
elsif count > 1
|
17
|
+
puts 'Found more than one stack matching input '.foreground(:yellow) + input.foreground(:green) + ', skipping.'.foreground(:yellow)
|
18
|
+
@populate_stack_failure = true
|
19
|
+
else
|
20
|
+
@stack_json = match[:custom_json]
|
21
|
+
@project = match[:name].split('::').first
|
22
|
+
@s3_path = match[:name].gsub('::', '-')
|
23
|
+
@stack_id = match[:stack_id]
|
24
|
+
@branch = (match[:name].split('::')[1] + '-' + match[:name].split('::')[2]).gsub('::', '-')
|
25
|
+
|
26
|
+
hash = {
|
27
|
+
'PROJECT:' => @project,
|
28
|
+
'STACK ID:' => @stack_id,
|
29
|
+
'S3 PATH:' => @s3_path,
|
30
|
+
'BRANCH:' => @branch
|
31
|
+
}
|
32
|
+
puts "\n"
|
33
|
+
hash.each { |k, v| printf("%-25s %-25s\n", k.foreground(:green), v.foreground(:red)) }
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'aws-sdk'
|
2
|
+
require 'trollop'
|
3
|
+
require 'diffy'
|
4
|
+
require 'opzworks'
|
5
|
+
require 'rainbow/ext/string'
|
6
|
+
|
7
|
+
require_relative 'include/run_local'
|
8
|
+
require_relative 'include/populate_stack'
|
9
|
+
require_relative 'include/manage_berks_repos'
|
10
|
+
|
11
|
+
module OpzWorks
|
12
|
+
class Commands
|
13
|
+
class JSON
|
14
|
+
def self.banner
|
15
|
+
'Update stack json'
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.run
|
19
|
+
options = Trollop.options do
|
20
|
+
banner <<-EOS.unindent
|
21
|
+
#{JSON.banner}
|
22
|
+
|
23
|
+
opzworks json stack1 stack2 ...
|
24
|
+
|
25
|
+
The stack name can be passed as any unique regex. If there is
|
26
|
+
more than one match, it will simply be skipped.
|
27
|
+
|
28
|
+
Options:
|
29
|
+
EOS
|
30
|
+
opt :quiet, 'Update the stack json without confirmation', short: 'q', default: false
|
31
|
+
opt :context, 'Change the number lines of diff context to show', short: 'c', default: 5
|
32
|
+
end
|
33
|
+
ARGV.empty? ? Trollop.die('no stacks specified') : false
|
34
|
+
|
35
|
+
config = OpzWorks.config
|
36
|
+
client = Aws::OpsWorks::Client.new(region: config.aws_region, profile: config.aws_profile)
|
37
|
+
response = client.describe_stacks
|
38
|
+
|
39
|
+
# loops over inputs
|
40
|
+
ARGV.each do |opt|
|
41
|
+
populate_stack(opt, response)
|
42
|
+
next if @populate_stack_failure == true
|
43
|
+
|
44
|
+
manage_berks_repos
|
45
|
+
next if @berks_repo_failure == true
|
46
|
+
|
47
|
+
json = File.read("#{@target_path}/stack.json")
|
48
|
+
diff = Diffy::Diff.new(@stack_json + "\n", json, context: options[:context])
|
49
|
+
diff_str = diff.to_s(:color).chomp
|
50
|
+
|
51
|
+
hash = {}
|
52
|
+
hash[:stack_id] = @stack_id
|
53
|
+
hash[:custom_json] = json
|
54
|
+
|
55
|
+
if diff_str.empty?
|
56
|
+
puts 'There are no differences between the existing stack json and the json you\'re asking to push.'.foreground(:yellow)
|
57
|
+
else
|
58
|
+
if options[:quiet]
|
59
|
+
puts 'Quiet mode detected. Pushing the following updated json:'.foreground(:yellow)
|
60
|
+
puts diff_str
|
61
|
+
|
62
|
+
client.update_stack(hash)
|
63
|
+
puts 'Done!'.color(:green)
|
64
|
+
else
|
65
|
+
puts "The following is a partial diff of the existing stack json and the json you're asking to push:".foreground(:yellow)
|
66
|
+
puts diff_str
|
67
|
+
STDOUT.print "\nType ".foreground(:yellow) + 'yes '.foreground(:blue) + 'to continue, any other key will abort: '.foreground(:yellow)
|
68
|
+
input = STDIN.gets.chomp
|
69
|
+
if input =~ /^y/i
|
70
|
+
client.update_stack(hash)
|
71
|
+
puts 'Done!'.color(:green)
|
72
|
+
else
|
73
|
+
puts 'Update skipped.'.foreground(:red)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'aws-sdk'
|
2
|
+
require 'trollop'
|
3
|
+
require 'opzworks'
|
4
|
+
|
5
|
+
SSH_PREFIX = '# --- OpzWorks ---'
|
6
|
+
SSH_POSTFIX = '# --- End of OpzWorks ---'
|
7
|
+
|
8
|
+
module OpzWorks
|
9
|
+
class Commands
|
10
|
+
class SSH
|
11
|
+
def self.banner
|
12
|
+
'Generate and update SSH configuration files'
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.run
|
16
|
+
options = Trollop.options do
|
17
|
+
banner <<-EOS.unindent
|
18
|
+
#{SSH.banner}
|
19
|
+
|
20
|
+
opzworks ssh {stack1} {stack2} {...}
|
21
|
+
|
22
|
+
The stack name can be passed as any unique regex. If no
|
23
|
+
arguments are passed, the command will iterate over all stacks.
|
24
|
+
|
25
|
+
Options:
|
26
|
+
EOS
|
27
|
+
opt :update, 'Update ~/.ssh/config directly'
|
28
|
+
opt :backup, 'Backup old SSH config before updating'
|
29
|
+
opt :quiet, 'Use SSH LogLevel quiet', default: true
|
30
|
+
opt :private, 'Use private ips to populate SSH config, rather than public', default: false
|
31
|
+
end
|
32
|
+
|
33
|
+
config = OpzWorks.config
|
34
|
+
client = Aws::OpsWorks::Client.new(region: config.aws_region, profile: config.aws_profile)
|
35
|
+
|
36
|
+
stacks = []
|
37
|
+
stack_data = client.describe_stacks
|
38
|
+
|
39
|
+
if ARGV.empty?
|
40
|
+
stack_data[:stacks].each { |stack| stacks.push(stack) }
|
41
|
+
else
|
42
|
+
ARGV.each do |arg|
|
43
|
+
stack_data[:stacks].each do |stack|
|
44
|
+
stacks.push(stack) if stack[:name] =~ /#{arg}/
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
stacks.each do |stack|
|
50
|
+
instances = []
|
51
|
+
stack_name = ''
|
52
|
+
|
53
|
+
stack_name = stack[:name].gsub('::', '-')
|
54
|
+
|
55
|
+
result = client.describe_instances(stack_id: stack[:stack_id])
|
56
|
+
instances += result.instances.select { |i| i[:status] != 'stopped' }
|
57
|
+
|
58
|
+
instances.map! do |instance|
|
59
|
+
if options[:private]
|
60
|
+
ip = instance[:private_ip]
|
61
|
+
else
|
62
|
+
instance[:elastic_ip].nil? ? ip = instance[:public_ip] : ip = instance[:elastic_ip]
|
63
|
+
end
|
64
|
+
parameters = {
|
65
|
+
'Host' => "#{instance[:hostname]}-#{stack_name}",
|
66
|
+
'HostName' => ip,
|
67
|
+
'User' => config.ssh_user_name
|
68
|
+
}
|
69
|
+
parameters['LogLevel'] = 'quiet' if options[:quiet]
|
70
|
+
parameters.map { |param| param.join(' ') }.join("\n ")
|
71
|
+
end
|
72
|
+
|
73
|
+
new_contents = "#{instances.join("\n")}\n"
|
74
|
+
|
75
|
+
if options[:update]
|
76
|
+
ssh_config = "#{ENV['HOME']}/.ssh/config"
|
77
|
+
old_contents = File.read(ssh_config)
|
78
|
+
|
79
|
+
if options[:backup]
|
80
|
+
backup_name = ssh_config + '.backup'
|
81
|
+
File.open(backup_name, 'w') { |file| file.puts old_contents }
|
82
|
+
end
|
83
|
+
|
84
|
+
File.open(ssh_config, 'w') do |file|
|
85
|
+
file.puts old_contents.gsub(
|
86
|
+
/\n?\n?#{SSH_PREFIX}.*#{SSH_POSTFIX}\n?\n?/m,
|
87
|
+
''
|
88
|
+
)
|
89
|
+
file.puts new_contents
|
90
|
+
end
|
91
|
+
|
92
|
+
puts "Successfully updated #{ssh_config} with #{instances.length} instances!"
|
93
|
+
else
|
94
|
+
puts new_contents.strip
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'inifile'
|
2
|
+
|
3
|
+
module OpzWorks
|
4
|
+
def self.config
|
5
|
+
@config ||= Config.new
|
6
|
+
end
|
7
|
+
|
8
|
+
class Config
|
9
|
+
attr_reader :ssh_user_name, :berks_repository_path, :aws_region, :aws_profile,
|
10
|
+
:berks_base_path, :berks_s3_bucket, :berks_tarball_name, :berks_github_org
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
file = ENV['AWS_CONFIG_FILE'] || "#{ENV['HOME']}/.aws/config"
|
14
|
+
|
15
|
+
fail 'AWS config file not found' unless File.exist? file
|
16
|
+
ini = IniFile.load(file)
|
17
|
+
|
18
|
+
# set the region and the profile we want to pick up from ~/.aws/credentials
|
19
|
+
@aws_profile = ENV['AWS_PROFILE'] || 'default'
|
20
|
+
@aws_region = ENV['AWS_REGION'] || ini[@aws_profile]['region']
|
21
|
+
|
22
|
+
@ssh_user_name = ini['opzworks']['ssh-user-name'].strip
|
23
|
+
@berks_repository_path = ini['opzworks']['berks-repository-path'].strip
|
24
|
+
|
25
|
+
@berks_base_path =
|
26
|
+
ini['opzworks']['berks-base-path'].strip unless ini['opzworks']['berks-base-path'].nil?
|
27
|
+
@berks_s3_bucket =
|
28
|
+
ini['opzworks']['berks-s3-bucket'].strip unless ini['opzworks']['berks-s3-bucket'].nil?
|
29
|
+
@berks_tarball_name =
|
30
|
+
ini['opzworks']['berks-tarball-name'].strip unless ini['opzworks']['berks-tarball-name'].nil?
|
31
|
+
@berks_github_org =
|
32
|
+
ini['opzworks']['berks-github-org'].strip unless ini['opzworks']['berks-github-org'].nil?
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/opzworks.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'opzworks/meta'
|
2
|
+
require 'opzworks/config'
|
3
|
+
require 'opzworks/commands/ssh'
|
4
|
+
require 'opzworks/commands/json'
|
5
|
+
require 'opzworks/commands/berks'
|
6
|
+
require 'opzworks/commands/elastic'
|
7
|
+
|
8
|
+
class String
|
9
|
+
def unindent
|
10
|
+
gsub(/^#{self[/\A\s*/]}/, '')
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module OpsWorks
|
15
|
+
end
|
data/opzworks.gemspec
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
root = File.expand_path('..', __FILE__)
|
4
|
+
lib = File.expand_path('lib', root)
|
5
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
6
|
+
require 'opzworks/meta'
|
7
|
+
|
8
|
+
Gem::Specification.new do |spec|
|
9
|
+
spec.name = 'opzworks'
|
10
|
+
spec.version = OpzWorks::VERSION
|
11
|
+
spec.authors = OpzWorks::AUTHORS
|
12
|
+
spec.email = OpzWorks::EMAIL
|
13
|
+
spec.description = OpzWorks::DESCRIPTION
|
14
|
+
spec.summary = OpzWorks::SUMMARY
|
15
|
+
spec.homepage = 'https://github.com/mapzen/opzworks'
|
16
|
+
spec.license = 'MIT'
|
17
|
+
|
18
|
+
ignores = File.readlines('.gitignore').grep(/\S+/).map(&:chomp)
|
19
|
+
spec.files = Dir['**/*'].reject do |f|
|
20
|
+
File.directory?(f) || ignores.any? { |i| File.fnmatch(i, f) }
|
21
|
+
end
|
22
|
+
spec.files += ['.gitignore']
|
23
|
+
|
24
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
25
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
26
|
+
spec.require_paths = ['lib']
|
27
|
+
|
28
|
+
spec.add_dependency 'aws-sdk', '~> 2.2.7'
|
29
|
+
spec.add_dependency 'trollop', '~> 2.0'
|
30
|
+
spec.add_dependency 'inifile', '~> 2.0.2'
|
31
|
+
spec.add_dependency 'rubocop', '~> 0.35.0'
|
32
|
+
spec.add_dependency 'diffy', '~> 3.1.0'
|
33
|
+
spec.add_dependency 'rainbow', '~> 2.0.0'
|
34
|
+
spec.add_dependency 'faraday', '~> 0.9.2'
|
35
|
+
spec.add_dependency 'net-ssh', '~> 3.0.1'
|
36
|
+
spec.add_dependency 'net-ssh-multi', '~> 1.2.1'
|
37
|
+
|
38
|
+
spec.add_development_dependency 'bundler', '~> 1.3'
|
39
|
+
spec.add_development_dependency 'rake'
|
40
|
+
spec.add_development_dependency 'awesome_print'
|
41
|
+
end
|