stork 0.1.0.pre → 0.2.0.pre

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.ruby-version +1 -1
  4. data/Gemfile.lock +7 -8
  5. data/README.md +521 -4
  6. data/bin/stork +19 -5
  7. data/bin/storkctl +39 -9
  8. data/box/Vagrantfile +41 -0
  9. data/box/bootstrap.sh +317 -0
  10. data/box/integration.sh +33 -0
  11. data/lib/stork/builder.rb +78 -24
  12. data/lib/stork/client/plugins/host_actions.rb +12 -0
  13. data/lib/stork/client/plugins/host_list.rb +1 -1
  14. data/lib/stork/client/plugins/host_reload.rb +16 -0
  15. data/lib/stork/client/plugins/host_show.rb +1 -1
  16. data/lib/stork/client/plugins/host_sync.rb +16 -0
  17. data/lib/stork/collections.rb +1 -0
  18. data/lib/stork/configuration.rb +51 -92
  19. data/lib/stork/database.rb +96 -0
  20. data/lib/stork/deploy/command.rb +8 -0
  21. data/lib/stork/deploy/install_script.rb +3 -5
  22. data/lib/stork/deploy/kickstart_binding.rb +4 -6
  23. data/lib/stork/deploy/section.rb +11 -7
  24. data/lib/stork/deploy/snippet_binding.rb +15 -10
  25. data/lib/stork/plugin.rb +24 -5
  26. data/lib/stork/pxe.rb +2 -2
  27. data/lib/stork/resource/base.rb +0 -2
  28. data/lib/stork/resource/delegator.rb +0 -2
  29. data/lib/stork/resource/host.rb +23 -23
  30. data/lib/stork/resource/logical_volume.rb +1 -1
  31. data/lib/stork/server/application.rb +75 -13
  32. data/lib/stork/server/control.rb +82 -21
  33. data/lib/stork/version.rb +1 -1
  34. data/lib/stork.rb +4 -3
  35. data/specs/builder_spec.rb +5 -1
  36. data/specs/configuration_spec.rb +16 -133
  37. data/specs/database_spec.rb +33 -0
  38. data/specs/deploy_snippet_binding_spec.rb +15 -0
  39. data/specs/kickstart_spec.rb +1 -0
  40. data/specs/pxe_spec.rb +2 -2
  41. data/specs/resource_host_spec.rb +121 -261
  42. data/specs/scripts/kssetup.sh +2 -2
  43. data/specs/server_spec.rb +46 -24
  44. data/specs/spec_helper.rb +3 -2
  45. data/specs/stork/bundles/hosts/example.org.rb +2 -1
  46. data/specs/stork/bundles/public/file.txt +5 -0
  47. data/specs/stork/config.rb +1 -0
  48. data/stork.gemspec +3 -1
  49. metadata +43 -4
@@ -54,270 +54,130 @@ describe "Stork::Resource::Host" do
54
54
  Stork::Resource::Host.new.must_respond_to :run_list=
55
55
  end
56
56
 
57
+ it "must respond to the chef_environment accessors" do
58
+ Stork::Resource::Host.new.must_respond_to :chef_environment
59
+ Stork::Resource::Host.new.must_respond_to :chef_environment=
60
+ end
61
+
57
62
  it "must respond to the repos accessors" do
58
63
  Stork::Resource::Host.new.must_respond_to :repos
59
64
  Stork::Resource::Host.new.must_respond_to :repos=
60
65
  end
61
66
 
62
- # it "must raise an error if the template is not found" do
63
- # proc {
64
- # host = Stork::Resource::Host.build configuration, collection, "example.org" do
65
- # template "invalid"
66
- # end
67
- # }.must_raise(SyntaxError)
68
- # end
69
-
70
- # it "must raise an error if the snippet is not found for pre" do
71
- # proc {
72
- # host = Stork::Resource::Host.build configuration, collection, "example.org" do
73
- # pre_snippet "invalid"
74
- # end
75
- # }.must_raise(SyntaxError)
76
- # end
77
-
78
- # it "must raise an error if the snippet is not found for post" do
79
- # proc {
80
- # host = Stork::Resource::Host.build configuration, collection, "example.org" do
81
- # post_snippet "invalid"
82
- # end
83
- # }.must_raise(SyntaxError)
84
- # end
85
-
86
- # it "must raise an error if a block is not passed to firewall" do
87
- # proc {
88
- # host = Stork::Resource::Host.build configuration, collection, "example.org" do
89
- # firewall
90
- # end
91
- # }.must_raise(SyntaxError)
92
- # end
93
-
94
- # it "must raise an error if a block is not passed to password" do
95
- # proc {
96
- # host = Stork::Resource::Host.build configuration, collection, "example.org" do
97
- # password
98
- # end
99
- # }.must_raise(SyntaxError)
100
- # end
101
-
102
- # it "must build inline" do
103
- # ckey = "./specs/keys/snakeoil-root.pem"
104
- # vkey = "./specs/keys/snakeoil-validation.pem"
105
-
106
- # host = Stork::Resource::Host.build configuration, collection, "example.org" do
107
- # template "default"
108
- # pxemac "00:11:22:33:44:55"
109
-
110
- # repo 'foo', baseurl: 'http://foo.com'
111
-
112
- # distro "centos" do
113
- # kernel "vmlinuz"
114
- # image "initrd.img"
115
- # url "http://mirror.example.com/centos"
116
- # end
117
-
118
- # layout "default" do
119
- # clearpart
120
- # zerombr
121
- # part "/boot" do
122
- # size 100
123
- # type "ext4"
124
- # primary
125
- # end
126
-
127
- # part "swap" do
128
- # type "swap"
129
- # primary
130
- # recommended
131
- # end
132
-
133
- # part "/" do
134
- # size 4096
135
- # type "ext4"
136
- # end
137
-
138
- # part "/home" do
139
- # size 1
140
- # type "ext4"
141
- # grow
142
- # end
143
- # end
144
-
145
- # chef "default" do
146
- # url "https://chef.example.org"
147
- # version "11.6.0"
148
- # client_name "root"
149
- # client_key ckey
150
- # validator_name "chef-validator"
151
- # validation_key vkey
152
- # encrypted_data_bag_secret "secretkey"
153
- # end
154
-
155
- # interface "eth0" do
156
- # bootproto :static
157
- # ip "192.168.1.10"
158
- # netmask "255.255.255.0"
159
- # gateway "192.168.1.1"
160
- # nameserver "192.168.1.253"
161
- # nameserver "192.168.1.252"
162
- # end
163
- # end
164
-
165
- # host.name.must_equal "example.org"
166
- # host.pxemac.must_equal "00:11:22:33:44:55"
167
- # host.distro.name.must_equal "centos"
168
- # host.distro.kernel.must_equal "vmlinuz"
169
- # host.distro.image.must_equal "initrd.img"
170
- # host.distro.url.must_equal "http://mirror.example.com/centos"
171
- # host.layout.clearpart.must_equal true
172
- # host.layout.zerombr.must_equal true
173
- # parts = host.layout.partitions
174
- # parts[0].path.must_equal '/boot'
175
- # parts[0].size.must_equal 100
176
- # parts[0].type.must_equal "ext4"
177
- # parts[0].primary.must_equal true
178
- # parts[1].path.must_equal 'swap'
179
- # parts[1].type.must_equal "swap"
180
- # parts[1].recommended.must_equal true
181
- # parts[2].path.must_equal "/"
182
- # parts[2].size.must_equal 4096
183
- # parts[3].path.must_equal "/home"
184
- # parts[3].type.must_equal "ext4"
185
- # parts[3].grow.must_equal true
186
- # host.chef.url.must_equal "https://chef.example.org"
187
- # host.chef.version.must_equal "11.6.0"
188
- # host.chef.client_name.must_equal "root"
189
- # host.chef.client_key.must_equal File.read(ckey)
190
- # host.chef.validator_name.must_equal "chef-validator"
191
- # host.chef.validation_key.must_equal File.read(vkey)
192
- # host.chef.encrypted_data_bag_secret.must_equal "secretkey"
193
- # interface = host.interfaces.first
194
- # interface.device.must_equal "eth0"
195
- # interface.bootproto.must_equal :static
196
- # interface.ip.must_equal "192.168.1.10"
197
- # interface.netmask.must_equal "255.255.255.0"
198
- # interface.gateway.must_equal "192.168.1.1"
199
- # interface.nameservers.must_equal ["192.168.1.253", "192.168.1.252"]
200
- # end
201
-
202
- # it "must build with collections" do
203
- # ckey = "./specs/files/configs/keys/snakeoil-root.pem"
204
- # vkey = "./specs/files/configs/keys/snakeoil-validation.pem"
205
- # collection = Midwife::Collection.new
206
- #
207
- # distro = Stork::Resource::Distro.build("centos") do
208
- # kernel "vmlinuz"
209
- # image "initrd.img"
210
- # url "http://mirror.example.com/centos"
211
- # end
212
- #
213
- # layout = Stork::Resource::Layout.build("default") do
214
- # clearpart
215
- # zerombr
216
- # part "/boot" do
217
- # size 100
218
- # type "ext4"
219
- # primary
220
- # end
221
- #
222
- # part "swap" do
223
- # type "swap"
224
- # primary
225
- # recommended
226
- # end
227
- #
228
- # part "/" do
229
- # size 4096
230
- # type "ext4"
231
- # end
232
- #
233
- # part "/home" do
234
- # size 1
235
- # type "ext4"
236
- # grow
237
- # end
238
- # end
239
- #
240
- # chef = Stork::Resource::Chef.build("default") do
241
- # url "https://chef.example.org"
242
- # version "11.6.0"
243
- # client_name "root"
244
- # client_key ckey
245
- # validator_name "chef-validator"
246
- # validation_key vkey
247
- # encrypted_data_bag_secret "secretkey"
248
- # end
249
- #
250
- # net = Stork::Resource::Network.build("local") do
251
- # netmask "255.255.255.0"
252
- # gateway "192.168.1.1"
253
- # nameserver "192.168.1.253"
254
- # nameserver "192.168.1.252"
255
- # end
256
- #
257
- # snip = Stork::Resource::Snippet.new(File.dirname(__FILE__) + '/files/configs/snippets/noop.erb')
258
- #
259
- # collection.distros.add(distro)
260
- # collection.layouts.add(layout)
261
- # collection.chefs.add(chef)
262
- # collection.networks.add(net)
263
- # collection.snippets.add(snip)
264
- #
265
- # host = Stork::Resource::Host.build(collection, "example.org") do
266
- # template "default"
267
- # pxemac "00:11:22:33:44:55"
268
- #
269
- # distro "centos"
270
- # layout "default"
271
- # chef "default"
272
- #
273
- # pre_snippet "noop"
274
- # post_snippet "noop"
275
- #
276
- # interface "eth0" do
277
- # bootproto :static
278
- # ip "192.168.1.10"
279
- # network "local"
280
- # end
281
- # end
282
- #
283
- # host.name.must_equal "example.org"
284
- # host.pxemac.must_equal "00:11:22:33:44:55"
285
- # host.distro.name.must_equal "centos"
286
- # host.distro.kernel.must_equal "vmlinuz"
287
- # host.distro.image.must_equal "initrd.img"
288
- # host.distro.url.must_equal "http://mirror.example.com/centos"
289
- # host.layout.clearpart.must_equal true
290
- # host.layout.zerombr.must_equal true
291
- # parts = host.layout.partitions
292
- # parts[0].path.must_equal '/boot'
293
- # parts[0].size.must_equal 100
294
- # parts[0].type.must_equal "ext4"
295
- # parts[0].primary.must_equal true
296
- # parts[1].path.must_equal 'swap'
297
- # parts[1].type.must_equal "swap"
298
- # parts[1].recommended.must_equal true
299
- # parts[2].path.must_equal "/"
300
- # parts[2].size.must_equal 4096
301
- # parts[3].path.must_equal "/home"
302
- # parts[3].type.must_equal "ext4"
303
- # parts[3].grow.must_equal true
304
- # host.chef.url.must_equal "https://chef.example.org"
305
- # host.chef.version.must_equal "11.6.0"
306
- # host.chef.client_name.must_equal "root"
307
- # host.chef.client_key.must_equal File.read(ckey)
308
- # host.chef.validator_name.must_equal "chef-validator"
309
- # host.chef.validation_key.must_equal File.read(vkey)
310
- # host.chef.encrypted_data_bag_secret.must_equal "secretkey"
311
- # interface = host.interfaces.first
312
- # interface.device.must_equal "eth0"
313
- # interface.bootproto.must_equal "static"
314
- # interface.ip.must_equal "192.168.1.10"
315
- # interface.netmask.must_equal "255.255.255.0"
316
- # interface.gateway.must_equal "192.168.1.1"
317
- # interface.nameservers.must_equal ["192.168.1.253", "192.168.1.252"]
318
- # host.post_snippets[0].name.must_equal "noop"
319
- # host.post_snippets[0].content.must_equal "# Default Snippet\n"
320
- # host.pre_snippets[0].name.must_equal "noop"
321
- # host.pre_snippets[0].content.must_equal "# Default Snippet\n"
322
- # end
67
+ it "must return a hash with hashify" do
68
+ host = collection.hosts.get("server.example.org")
69
+ hash = {
70
+ 'name' => 'server.example.org',
71
+ 'distro' => 'centos',
72
+ 'template' => 'default',
73
+ 'chef' => 'default',
74
+ 'chef_environment' => 'testing',
75
+ 'layout' => {
76
+ 'partitions' => [
77
+ {
78
+ 'path' => '/boot',
79
+ 'size' => 100,
80
+ 'type' => 'ext4',
81
+ 'primary' => true,
82
+ 'grow' => false,
83
+ 'recommended' => false
84
+ },
85
+ {
86
+ 'path' => 'swap',
87
+ 'size' => 1,
88
+ 'type' => 'swap',
89
+ 'primary' => true,
90
+ 'grow' => false,
91
+ 'recommended' => true
92
+ },
93
+ {
94
+ 'path' => '/',
95
+ 'size' => 4096,
96
+ 'type' => 'ext4',
97
+ 'primary' => false,
98
+ 'grow' => false,
99
+ 'recommended' => false
100
+ },
101
+ {
102
+ 'path' => 'pv.01',
103
+ 'size' => 1,
104
+ 'type' => 'ext4',
105
+ 'primary' => false,
106
+ 'grow' => true,
107
+ 'recommended' => false
108
+ }
109
+ ],
110
+ 'volume_groups' => [
111
+ {
112
+ 'partition' => 'pv.01',
113
+ 'logical_volumes' => [
114
+ {
115
+ 'path' => '/home',
116
+ 'size' => 1,
117
+ 'type' => 'ext4',
118
+ 'primary' => nil,
119
+ 'grow' => true,
120
+ 'recommended' => false
121
+ }
122
+ ]
123
+ }
124
+ ]
125
+ },
126
+ 'interfaces' => [
127
+ {
128
+ 'ip' => '99.99.1.8',
129
+ 'bootproto' => :static,
130
+ 'netmask' => '255.255.255.0',
131
+ 'gateway' => nil,
132
+ 'nameservers' => [],
133
+ 'search_paths' => []
134
+ },
135
+ {
136
+ 'ip' => '192.168.1.10',
137
+ 'bootproto' => :static,
138
+ 'netmask' => '255.255.255.0',
139
+ 'gateway' => '192.168.1.1',
140
+ 'nameservers' => ['192.168.1.253', '192.168.1.252'],
141
+ 'search_paths' => []
142
+ }
143
+ ],
144
+ 'pre_snippets' => ['setup'],
145
+ 'post_snippets' => [
146
+ 'ntp',
147
+ 'resolv-conf',
148
+ 'chef-bootstrap',
149
+ 'chef-reconfigure',
150
+ 'notify'
151
+ ],
152
+ 'repos' => ['whamcloud-client'],
153
+ 'run_list' => ['role[base]', 'recipe[apache]'],
154
+ 'packages' => [
155
+ '@core',
156
+ 'curl',
157
+ 'openssh-clients',
158
+ 'openssh-server',
159
+ 'finger',
160
+ 'pciutils',
161
+ 'yum',
162
+ 'at',
163
+ 'acpid',
164
+ 'vixie-cron',
165
+ 'cronie-noanacron',
166
+ 'crontabs',
167
+ 'logrotate',
168
+ 'ntp',
169
+ 'ntpdate',
170
+ 'tmpwatch',
171
+ 'rsync',
172
+ 'mailx',
173
+ 'which',
174
+ 'wget',
175
+ 'man',
176
+ 'foo'
177
+ ],
178
+ 'timezone' => 'America/Los_Angeles',
179
+ 'selinux' => 'enforcing'
180
+ }
181
+ host.hashify.must_equal hash
182
+ end
323
183
  end
@@ -8,12 +8,12 @@ if [ "$TRAVIS" = "true" ] ; then
8
8
  cd pykickstart
9
9
  sudo python setup.py install
10
10
  else
11
- pip install virtualenv
11
+ # pip install virtualenv
12
12
  echo "Creating virtual environment: ksvalidator"
13
13
  virtualenv ksvalidator
14
14
  cd ksvalidator
15
15
  echo "Activating!!!"
16
- source bin/activate
16
+ . ./bin/activate
17
17
  echo "Installing packages"
18
18
  pip install pycurl
19
19
  pip install urlgrabber
data/specs/server_spec.rb CHANGED
@@ -6,15 +6,20 @@ require 'rack/test'
6
6
  include Rack::Test::Methods
7
7
 
8
8
  def app
9
- b = Stork::Builder.load(configuration)
9
+ b = Stork::Builder.load
10
10
  a = Stork::Server::Application
11
+
12
+ d = Stork::Database.load('./specs/tmp')
13
+ d.sync_hosts(b.collection.hosts)
14
+
11
15
  a.set :collection, b.collection
12
- a.set :config, configuration
16
+ a.set :database, d
13
17
  a
14
18
  end
15
19
 
16
20
  describe "Stork::Server::Application" do
17
21
  before(:each) do
22
+ load_config
18
23
  FileUtils.mkdir('./specs/tmp/pxeboot')
19
24
  end
20
25
 
@@ -29,14 +34,21 @@ describe "Stork::Server::Application" do
29
34
  end
30
35
 
31
36
  it "should output the kickstart file for a valid host" do
37
+ get '/host/server.example.org/install'
32
38
  get '/host/server.example.org'
33
39
  message = "foo"
34
- last_response.body.wont_equal "{ \"status\":\"404\", \"message\": \"not found\" }"
40
+ last_response.body.wont_equal "{ \"status\":\"404\", \"message\": \"Not found\" }"
41
+ end
42
+
43
+ it "should not output the kickstart file when host action is not set to install" do
44
+ get '/host/server.example.org'
45
+ message = "foo"
46
+ last_response.body.must_equal "{ \"status\":\"404\", \"message\": \"Not found\" }"
35
47
  end
36
48
 
37
49
  it "should error for invalid host" do
38
50
  get '/host/invalid.org'
39
- message = "{ \"status\":\"404\", \"message\": \"not found\" }"
51
+ message = "{ \"status\":\"404\", \"message\": \"Not found\" }"
40
52
  last_response.body.must_equal message
41
53
  end
42
54
 
@@ -44,41 +56,51 @@ describe "Stork::Server::Application" do
44
56
  get '/host/server.example.org/installed'
45
57
  last_response.body.must_equal "{ \"status\":\"200\", \"message\": \"OK\" }"
46
58
 
47
- expected_content = <<-EOS
48
- DEFAULT local
49
- PROMPT 0
50
- TIMEOUT 0
51
- TOTALTIMEOUT 0
52
- ONTIMEOUT local
53
- LABEL local
54
- LOCALBOOT -1
55
- EOS
59
+ expected_content = <<-EOS.gsub(/^ {6}/, '')
60
+ DEFAULT local
61
+ PROMPT 0
62
+ TIMEOUT 0
63
+ TOTALTIMEOUT 0
64
+ ONTIMEOUT local
65
+ LABEL local
66
+ LOCALBOOT 0
67
+ EOS
56
68
  File.read("./specs/tmp/pxeboot/01-00-11-22-33-44-55").must_equal expected_content
57
69
  end
58
70
 
59
71
  it "should error on completed install for invalid host" do
60
72
  get '/host/invalid.org/installed'
61
- last_response.body.must_equal "{ \"status\":\"404\", \"message\": \"not found\" }"
73
+ last_response.body.must_equal "{ \"status\":\"404\", \"message\": \"Not found\" }"
74
+ end
75
+
76
+ it "should send a public file" do
77
+ get '/public/file.txt'
78
+ last_response.status.must_equal 200
79
+ end
80
+
81
+ it "should give a 404 on a non existant file" do
82
+ get '/public/error.txt'
83
+ last_response.status.must_equal 404
62
84
  end
63
85
 
64
86
  it "should notify of an install request" do
65
87
  get '/host/server.example.org/install'
66
88
  last_response.body.must_equal "{ \"status\":\"200\", \"message\": \"OK\" }"
67
89
 
68
- expected_content = <<-EOS
69
- default install
70
- prompt 0
71
- timeout 1
72
- label install
73
- kernel vmlinuz
74
- ipappend 2
75
- append initrd=initrd.img ksdevice=bootif priority=critical kssendmac ks=http://localhost:5000/host/server.example.org
76
- EOS
90
+ expected_content = <<-EOS.gsub(/^ {6}/, '')
91
+ default install
92
+ prompt 0
93
+ timeout 1
94
+ label install
95
+ kernel vmlinuz
96
+ ipappend 2
97
+ append initrd=initrd.img ksdevice=bootif priority=critical kssendmac ks=http://localhost:5000/host/server.example.org
98
+ EOS
77
99
  File.read("./specs/tmp/pxeboot/01-00-11-22-33-44-55").must_equal expected_content
78
100
  end
79
101
 
80
102
  it "should error for invalid host" do
81
103
  get '/host/invalid.org/install'
82
- last_response.body.must_equal "{ \"status\":\"404\", \"message\": \"not found\" }"
104
+ last_response.body.must_equal "{ \"status\":\"404\", \"message\": \"Not found\" }"
83
105
  end
84
106
  end
data/specs/spec_helper.rb CHANGED
@@ -24,10 +24,11 @@ if ENV['DEBUG']
24
24
  require 'minitest/debugger'
25
25
  end
26
26
 
27
- def configuration
27
+ def load_config
28
+ Stork::Configuration.reset
28
29
  Stork::Configuration.from_file('./specs/stork/config.rb')
29
30
  end
30
31
 
31
32
  def collection
32
- Stork::Builder.load(configuration).collection
33
+ Stork::Builder.load.collection
33
34
  end
@@ -32,6 +32,7 @@ host "server.example.org" do
32
32
  post_snippet "notify"
33
33
 
34
34
  run_list %w{ role[base] recipe[apache] }
35
+ chef_environment "testing"
35
36
  end
36
37
 
37
38
  hosts=[
@@ -48,7 +49,7 @@ hosts=[
48
49
  ]
49
50
 
50
51
  hosts.each do |octet, mac|
51
- host "n0#{octet}.example.org" do
52
+ host "c0#{octet}.example.org" do
52
53
  template "default"
53
54
  chef "default"
54
55
  distro "centos"
@@ -0,0 +1,5 @@
1
+ # file.txt
2
+
3
+ This is a regularly scheduled test of the emergency broadcast system.
4
+
5
+ This is a test. This is only a test.
@@ -3,6 +3,7 @@ path "./specs/stork"
3
3
  bundle_path "./specs/stork/bundles"
4
4
  authorized_keys_file "./specs/stork/authorized_keys"
5
5
  pxe_path "./specs/tmp/pxeboot"
6
+ db_path "./specs/tmp"
6
7
  log_file "./specs/tmp/stork.log"
7
8
  pid_file "./specs/tmp/stork.pid"
8
9
  server "localhost"
data/stork.gemspec CHANGED
@@ -19,13 +19,15 @@ Gem::Specification.new do |spec|
19
19
  spec.require_paths = ["lib"]
20
20
 
21
21
  spec.add_runtime_dependency "sinatra"
22
- spec.add_runtime_dependency "thin"
22
+ spec.add_runtime_dependency "webrick"
23
23
  spec.add_runtime_dependency "sqlite3"
24
24
  spec.add_runtime_dependency "rest-client"
25
25
  spec.add_runtime_dependency "highline"
26
+ spec.add_runtime_dependency "mixlib-config"
26
27
 
27
28
  spec.add_development_dependency "bundler", "~> 1.3"
28
29
  spec.add_development_dependency "rake"
30
+ spec.add_development_dependency "yard"
29
31
  spec.add_development_dependency "minitest", "~> 4.7.3"
30
32
  spec.add_development_dependency "rack-test"
31
33
  spec.add_development_dependency "simplecov"