blender-serf 0.0.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: cfaa34818e2ebbe4ed6327b625c5927d49291901
4
- data.tar.gz: 167c55fac333e5205c4f1599aad75e68c50c2575
3
+ metadata.gz: 6a360de1c7367a78e1ae4cb4a5a1bb2095f11e3d
4
+ data.tar.gz: 61df6de3ba420247a8e0169005212c1f74baef39
5
5
  SHA512:
6
- metadata.gz: 2cb10ede968b4329a18917e32e9ece5f974f4f1d3a7db8cdcef971641d6d2224014ad37bfe234670dd05cddd69b8294a8c8db597671c3cd48c9c62fc641f6ac7
7
- data.tar.gz: ecab1c61c7e6c29c9906239558a8f81efcbb06360e6e4d88503b564d44b9eaf8fd07a26f3056659d6ce32292eb7e93df97a7ff6c179664f9f46d9e1c77853b5a
6
+ metadata.gz: 43bef41ba273f03c8ebe522e8de7820b215a2fd2606f587cb8916544b2d4d30819e55f8f552d4fbfd1b0a4655aa4ea7183e057aaee58c1503d3769abba33ccac
7
+ data.tar.gz: 4870e0f7baf6977b6ec519d3a997317b9df659cb723eb457be3f1311b4fa68a261d51841b50fb9338185f7cf4a527d2ee2b7f680ccc91cdac246c51b65a2df39
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
3
  gemspec
4
+
5
+ gem 'pd-blender', github: 'PagerDuty/blender'
data/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # Blender-Serf
2
+
3
+ Provides [Serf](https://serfdom.io/) based host discovery and job dispatch
4
+ mechanism for [Blender](https://github.com/PagerDuty/blender)
5
+
6
+ ## Installation
7
+ ```sh
8
+ gem install blender-serf
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Config
14
+ Blender-serf uses [Serf's RPC](https://serfdom.io/docs/agent/rpc.html)
15
+ interface for communication between serf and ruby. Following serf RPC connection
16
+ specific configuration options are allowed
17
+ - host (defaults to localhost)
18
+ - port (defaults to 7373)
19
+ - authkey (rpc authenticatioin key, default is nil)
20
+
21
+ Example
22
+ ```ruby
23
+ config(:serf, host: '127.0.0.1', port: 7373, authkey: 'foobar')
24
+ ```
25
+ ### Using serf for host discovery
26
+ Serf provides cluster membership list via `members` and `members_filtered` RPC
27
+ calls, among them Blender-Serf uses `members_filtered` RPC command for host discovery.
28
+ Following is an example of obtaining all live members of a cluster
29
+ ```ruby
30
+ require 'blender/serf'
31
+ members(search(:serf, status: 'alive'))
32
+ ruby_task 'test' do
33
+ execute do |host|
34
+ Blender::Log.info(host)
35
+ end
36
+ end
37
+ ```
38
+ Member list can be obtained based on serf tags or name.
39
+
40
+ Filter by tag expect tags to be supplied as hash (key value pair), which
41
+ allows grouping hosts by multiple tags
42
+ ```ruby
43
+ members(search(:serf, tags: {'role'=>'db'}))
44
+ ruby_task 'test' do
45
+ execute do |host|
46
+ Blender::Log.info(host)
47
+ end
48
+ end
49
+ ```
50
+
51
+ Filter by name, the specification string can be a pattern as well
52
+ ```ruby
53
+ members(search(:serf, name: 'foo-baar-*'))
54
+ ruby_task 'test' do
55
+ execute do |host|
56
+ Blender::Log.info(host)
57
+ end
58
+ end
59
+ ```
60
+
61
+ Combinations of all three filtering mechanism is valid as well
62
+ ```ruby
63
+ members(search(:serf, name: 'foo-baar-*', status: 'alive', tags:{'datacenter' => 'eu-east-2a'}))
64
+ ruby_task 'test'
65
+ execute do |host|
66
+ Blender::Log.info(host)
67
+ end
68
+ end
69
+ ```
70
+
71
+ ### Using serf for job dispatch
72
+
73
+ Serf agents allow job execution via [event handlers](https://serfdom.io/docs/agent/event-handlers.html),
74
+ where the execution details is captured by the handler, while trigger mechanism is controlled by
75
+ serf event. _Blender-Serf_ uses [serf query](https://serfdom.io/docs/commands/query.html) event type
76
+ to dispatch event and check for successfull response. The handler itself, and associated serf' configyration
77
+ needs to be setup externally (bake it in your image, or use a configuration managemenet system like
78
+ chef, puppet, ansible or salt for this).
79
+
80
+ Following example will trigger serf query event `test` against 3 nodes, one by one
81
+ ```ruby
82
+ extend Blender::SerfDSL
83
+ members(['node_1', 'node_2', 'node_3'])
84
+ serf_task 'test'
85
+ ```
86
+ A more elaborate example
87
+ ```ruby
88
+ extend Blender::SerfDSL
89
+ members(['node_1', 'node_2', 'node_3'])
90
+ serf_task 'start_nginx' do
91
+ query 'nginx'
92
+ payload 'start'
93
+ timeout 3
94
+ end
95
+ ```
96
+
97
+ Which might be accmoplished by the following handler script (needs to be present aprior)
98
+ ```ruby
99
+ require 'serfx/utils/handler'
100
+
101
+ include Serfx::Utils::Handler
102
+
103
+
104
+ on :query, 'nginx' do |event|
105
+ case event.payload
106
+ when 'start'
107
+ %x{/etc/init.d/nginx start}
108
+ when 'stop'
109
+ # ...
110
+ when 'check'
111
+ # ...
112
+ end
113
+ end
114
+
115
+ run
116
+
117
+ ```
118
+ ## Supported ruby versions
119
+
120
+ Blender-serf currently support the following MRI versions:
121
+
122
+ * *Ruby 1.9.3*
123
+ * *Ruby 2.1*
124
+
125
+ ## License
126
+
127
+ [Apache 2](http://www.apache.org/licenses/LICENSE-2.0)
128
+
129
+ ## Contributing
130
+
131
+ 1. Fork it ( https://github.com/PagerDuty/blender-serf/fork )
132
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
133
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
134
+ 4. Push to the branch (`git push origin my-new-feature`)
135
+ 5. Create a new Pull Request
data/blender-serf.gemspec CHANGED
@@ -4,7 +4,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
4
 
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = 'blender-serf'
7
- spec.version = '0.0.1'
7
+ spec.version = '0.1.0'
8
8
  spec.authors = ['Ranjib Dey']
9
9
  spec.email = ['ranjib@pagerduty.com']
10
10
  spec.summary = %q{Serf backend for blender}
@@ -19,6 +19,7 @@ Gem::Specification.new do |spec|
19
19
 
20
20
  spec.add_dependency 'pd-blender'
21
21
  spec.add_dependency 'serfx'
22
+ spec.add_dependency 'retriable'
22
23
 
23
24
  spec.add_development_dependency 'bundler'
24
25
  spec.add_development_dependency 'rake'
@@ -21,20 +21,38 @@ require 'blender/log'
21
21
  module Blender
22
22
  module Discovery
23
23
  class Serf
24
+ Property = Struct.new(:key, :hook)
25
+ attr_reader :attribute
26
+
24
27
  def initialize(opts = {})
25
- @config = opts
28
+ @config = {}
29
+ @config[:host] = opts[:host] if opts.key?(:host)
30
+ @config[:port] = opts[:port] if opts.key?(:port)
31
+ @config[:authkey] = opts[:authkey] if opts.key?(:authkey)
32
+ @attribute = opts[:attribute] || 'name'
26
33
  end
34
+
27
35
  def search(opts = {})
36
+ opts = {} if opts.nil?
28
37
  tags = opts[:tags] || {}
29
38
  status = opts[:status] || 'alive'
30
39
  name = opts[:name]
31
40
  hosts = []
41
+ Blender::Log.debug("Using Serf agents '#{attribute}' as discovery return values")
42
+ case attribute
43
+ when 'name'
44
+ property = Property.new('Name', lambda{|data| data})
45
+ when 'ipaddress'
46
+ property = Property.new('Addr', lambda{|data| data.unpack('CCCC').join('.')})
47
+ else
48
+ raise ArgumentError, "Attribute can be either 'name' or 'ipaddress', you have provided '#{attribute}'"
49
+ end
32
50
  Blender::Log.debug("Serf memeber list call with arguments: Tags=#{tags}")
33
51
  Blender::Log.debug("Serf memeber list call with arguments: status=#{status}")
34
52
  Blender::Log.debug("Serf memeber list call with arguments: Name=#{name}")
35
53
  Serfx.connect(@config) do |conn|
36
54
  conn.members_filtered(tags, status, name).body['Members'].map do |m|
37
- hosts << m['Name']
55
+ hosts << property.hook.call(m[property.key])
38
56
  end
39
57
  end
40
58
  hosts
@@ -56,7 +56,7 @@ module Blender
56
56
  if command.process
57
57
  command.process.call(responses)
58
58
  end
59
- ExecOutput.new(exit_status(responses, nodes), responses.inspect, '')
59
+ exit_status(responses, nodes)
60
60
  rescue StandardError => e
61
61
  ExecOutput.new( -1, '', e.message)
62
62
  end
@@ -65,9 +65,13 @@ module Blender
65
65
  def exit_status(responses, nodes)
66
66
  case filter_by
67
67
  when :host
68
- responses.size == nodes.size ? 0 : -1
68
+ if responses.size == nodes.size
69
+ ExecOutput.new(0, responses.inspect, '')
70
+ else
71
+ ExecOutput.new(-1, '', "Insufficient number of responses. Expected:#{nodes.size}, Got:#{responses.size}")
72
+ end
69
73
  when :tag, :none
70
- 0
74
+ ExecOutput.new(0, responses.inspect, '')
71
75
  else
72
76
  raise ArgumentError, "Unknown filter_by option: #{filter_by}"
73
77
  end
@@ -20,61 +20,39 @@ require 'blender/log'
20
20
  require 'blender/drivers/serf'
21
21
  require 'json'
22
22
  require 'blender/tasks/serf'
23
+ require 'retriable'
23
24
 
24
25
  module Blender
25
26
  module Driver
26
- class SerfAsync < Serf
27
-
28
- def dup_command(cmd, payload)
29
- Blender::Task::Serf::SerfQuery.new(
30
- cmd.query,
31
- payload,
32
- cmd.timeout,
33
- cmd.noack
34
- )
35
- end
36
-
37
- def start!(cmd, host)
38
- resps = serf_query(dup_command(cmd, 'start'), host)
39
- status = extract_status!(resps.first)
40
- unless status == 'success'
41
- raise RuntimeError, "Failed to start async serf job. Status = #{status}"
42
- end
43
- end
44
-
45
- def finished?(cmd, host)
46
- Blender::Log.debug("Checking for status")
47
- resps = serf_query(dup_command(cmd, 'check'), host)
48
- Blender::Log.debug("Responses: #{resps.inspect}")
49
- Blender::Log.debug("Status:#{extract_status!(resps.first)}")
50
- extract_status!(resps.first) == 'finished'
27
+ class SerfAsync < Blender::Driver::Base
28
+ def initialize(config = {})
29
+ super
30
+ @driver = Blender::Driver::Serf.new(config)
51
31
  end
52
32
 
53
- def reap!(cmd, host)
54
- resps = serf_query(dup_command(cmd, 'reap'), host)
55
- extract_status!(resps.first) == 'success'
56
- end
57
-
58
- def run_command(command, host)
59
- begin
60
- start!(command, host)
61
- until finished?(command, host)
62
- sleep 10
33
+ def execute(tasks, hosts)
34
+ Log.debug("Serf Async query on #{@driver.filter_by}s [#{hosts.inspect}]")
35
+ tasks.each do |task|
36
+ retry_options = task.metadata[:retry_options]
37
+ hosts.each_slice(@driver.concurrency) do |nodes|
38
+ Blender::Log.debug("Start query: #{task.start_query.inspect}")
39
+ start = @driver.run_command(task.start_query.command, nodes)
40
+ if start.exitstatus != 0 and !task.metadata[:ignore_failure]
41
+ raise ExecutionFailed, start.stderr
42
+ end
43
+ Blender::Log.debug("Using check retry options:#{retry_options}")
44
+ Retriable.retriable(retry_options) do
45
+ cmd = @driver.run_command(task.check_query.command, nodes)
46
+ if cmd.exitstatus != 0 and !task.metadata[:ignore_failure]
47
+ raise ExecutionFailed, cmd.stderr
48
+ end
49
+ end
50
+ cmd = @driver.run_command(task.stop_query.command, nodes)
51
+ if cmd.exitstatus != 0 and !task.metadata[:ignore_failure]
52
+ raise ExecutionFailed, cmd.stderr
53
+ end
63
54
  end
64
- reap!(command, host)
65
- ExecOutput.new(0, '', '')
66
- rescue StandardError => e
67
- ExecOutput.new( -1, '', e.message + e.backtrace.join("\n"))
68
- end
69
- end
70
-
71
- def extract_status!(res)
72
- payload = JSON.parse(res['Payload'])
73
- unless payload['code'].zero?
74
- raise RuntimeError, "non zero query response code: #{res}"
75
55
  end
76
- Blender::Log.debug("Payload: #{payload['result'].inspect}")
77
- payload['result']['status']
78
56
  end
79
57
  end
80
58
  end
data/lib/blender/serf.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  require 'blender/discoveries/serf'
2
2
  require 'blender/drivers/serf'
3
+ require 'blender/drivers/serf_async'
3
4
  require 'blender/tasks/serf'
5
+ require 'blender/tasks/serf_async'
4
6
  require 'blender/serf_dsl'
@@ -1,3 +1,20 @@
1
+ #
2
+ # Author:: Ranjib Dey (<ranjib@pagerduty.com>)
3
+ # Copyright:: Copyright (c) 2015 PagerDuty, Inc.
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
1
18
  module Blender
2
19
  module SerfDSL
3
20
  def serf_task(name, &block)
@@ -5,5 +22,10 @@ module Blender
5
22
  task.instance_eval(&block) if block_given?
6
23
  append_task(:serf, task)
7
24
  end
25
+ def serf_async_task(name, &block)
26
+ task = build_task(name, :serf_async)
27
+ task.instance_eval(&block) if block_given?
28
+ append_task(:serf_async, task)
29
+ end
8
30
  end
9
31
  end
@@ -19,17 +19,12 @@ require 'blender/tasks/base'
19
19
  module Blender
20
20
  module Task
21
21
  class Serf < Blender::Task::Base
22
-
23
22
  SerfQuery = Struct.new(:query, :payload, :timeout, :noack, :process)
24
23
 
25
24
  def initialize(name, metadata = {})
26
25
  super
27
26
  @command = SerfQuery.new
28
- @command.query = name
29
- end
30
-
31
- def execute(&block)
32
- @command.instance_eval(&block)
27
+ @command.query= name
33
28
  end
34
29
 
35
30
  def query(q)
@@ -48,8 +43,12 @@ module Blender
48
43
  @command.noack = bool
49
44
  end
50
45
 
51
- def process(callback)
52
- @command.process = callback
46
+ def process(&block)
47
+ @command.process = block if block
48
+ end
49
+
50
+ def execute(&block)
51
+ @command.instance_eval(&block)
53
52
  end
54
53
 
55
54
  def command
@@ -0,0 +1,81 @@
1
+ #
2
+ # Author:: Ranjib Dey (<ranjib@pagerduty.com>)
3
+ # Copyright:: Copyright (c) 2015 PagerDuty, Inc.
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ require 'blender/tasks/serf'
18
+
19
+ module Blender
20
+ module Task
21
+ class SerfAsync < Blender::Task::Base
22
+ attr_reader :start_query, :stop_query, :check_query
23
+
24
+ def initialize(name, options = {})
25
+ super
26
+ @start_query = Blender::Task::Serf.new(name)
27
+ @start_query.payload(metadata[:start])
28
+ @start_query.timeout(metadata[:timeout])
29
+ @start_query.no_ack(metadata[:no_ack])
30
+ @start_query.process(&metadata[:process])
31
+
32
+ @stop_query = Blender::Task::Serf.new(name)
33
+ @stop_query.payload(metadata[:stop])
34
+ @stop_query.timeout(metadata[:timeout])
35
+ @stop_query.no_ack(metadata[:no_ack])
36
+ @stop_query.process(&metadata[:process])
37
+
38
+ @check_query = Blender::Task::Serf.new(name)
39
+ @check_query.payload(metadata[:check])
40
+ @check_query.timeout(metadata[:timeout])
41
+ @check_query.no_ack(metadata[:no_ack])
42
+ @check_query.process(&metadata[:process])
43
+ end
44
+
45
+ def start(&block)
46
+ @start_query.instance_eval(&block)
47
+ end
48
+
49
+ def stop(&block)
50
+ @stop_query.instance_eval(&block)
51
+ end
52
+
53
+ def check(&block)
54
+ @check_query.instance_eval(&block)
55
+ end
56
+
57
+ def execute
58
+ end
59
+
60
+ def command
61
+ end
62
+
63
+ def retry_options(options = {})
64
+ @metadata[:retry_options].merge!(options)
65
+ end
66
+
67
+ def default_metadata
68
+ {
69
+ ignore_failure: false,
70
+ start: 'start',
71
+ stop: 'stop',
72
+ check: 'check',
73
+ timeout: 5,
74
+ no_ack: false,
75
+ process: nil,
76
+ retry_options: { max_elapsed_time: 180 }
77
+ }
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,5 +1,6 @@
1
1
  require 'spec_helper'
2
2
  require 'blender/handlers/base'
3
+ require 'blender/cli'
3
4
 
4
5
  describe Blender::Driver::Serf do
5
6
  let(:driver) do
@@ -46,4 +47,26 @@ describe Blender::Driver::Serf do
46
47
  expect(Serfx).to receive(:connect).with(conn_opts).and_yield(conn).exactly(3).times
47
48
  tag_driver.execute(tasks, hosts)
48
49
  end
50
+ context '#CLI' do
51
+ it '#sync' do
52
+ conn = double('serf connection')
53
+ ev = double('serf event')
54
+ allow(conn).to receive(:query).with('test', 'start', Timeout: 7000000000, FilterNodes: ['node1']).and_yield(ev)
55
+ allow(conn).to receive(:query).with('test', 'start', Timeout: 7000000000, FilterNodes: ['node2']).and_yield(ev)
56
+ allow(conn).to receive(:query).with('test', 'start', Timeout: 7000000000, FilterNodes: ['node3']).and_yield(ev)
57
+ allow(Serfx).to receive(:connect).with(authkey: 'foobar').and_yield(conn)
58
+ Blender::CLI.start(%w{-f spec/data/example.rb})
59
+ end
60
+ it '#async' do
61
+ conn = double('serf connection')
62
+ ev = double('serf event')
63
+ allow(ev).to receive(:body).and_return({'success' => true})
64
+ %w{start stop check}.each do |payload|
65
+ allow(conn).to receive(:query).with('chef', payload , Timeout: 5000000000, FilterNodes: ['node1']).and_yield(ev)
66
+ end
67
+ allow(Serfx).to receive(:connect).with(authkey: 'asyncexample').and_yield(conn)
68
+ expect(Retriable).to receive(:retriable).with(max_elapsed_time: 300).and_yield
69
+ Blender::CLI.start(%w{-f spec/data/async_example.rb})
70
+ end
71
+ end
49
72
  end
@@ -0,0 +1,8 @@
1
+ require 'blender/serf'
2
+ extend SerfDSL
3
+ config(:serf_async, authkey: 'asyncexample')
4
+
5
+ serf_async_task 'chef' do
6
+ members ['node1']
7
+ retry_options(max_elapsed_time: 300)
8
+ end
@@ -0,0 +1,10 @@
1
+ require 'blender/serf'
2
+ extend SerfDSL
3
+ config(:serf, authkey: 'foobar')
4
+
5
+ serf_task 'example serf task' do
6
+ query 'test'
7
+ payload 'start'
8
+ timeout 7
9
+ members ['node1', 'node2', 'node3']
10
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: blender-serf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ranjib Dey
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-09-10 00:00:00.000000000 Z
11
+ date: 2015-02-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pd-blender
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: retriable
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: bundler
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -146,6 +160,7 @@ files:
146
160
  - ".gitignore"
147
161
  - ".travis.yml"
148
162
  - Gemfile
163
+ - README.md
149
164
  - Rakefile
150
165
  - blender-serf.gemspec
151
166
  - lib/blender/discoveries/serf.rb
@@ -154,8 +169,11 @@ files:
154
169
  - lib/blender/serf.rb
155
170
  - lib/blender/serf_dsl.rb
156
171
  - lib/blender/tasks/serf.rb
172
+ - lib/blender/tasks/serf_async.rb
157
173
  - spec/blender/discoveries/serf_spec.rb
158
174
  - spec/blender/drivers/serf_spec.rb
175
+ - spec/data/async_example.rb
176
+ - spec/data/example.rb
159
177
  - spec/spec_helper.rb
160
178
  homepage: http://github.com/PagerDuty/blender-serf
161
179
  licenses:
@@ -184,5 +202,7 @@ summary: Serf backend for blender
184
202
  test_files:
185
203
  - spec/blender/discoveries/serf_spec.rb
186
204
  - spec/blender/drivers/serf_spec.rb
205
+ - spec/data/async_example.rb
206
+ - spec/data/example.rb
187
207
  - spec/spec_helper.rb
188
208
  has_rdoc: