beaker 0.0.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 +15 -0
- data/.gitignore +17 -0
- data/.rspec +2 -0
- data/.simplecov +14 -0
- data/DOCUMENTING.md +167 -0
- data/Gemfile +3 -0
- data/LICENSE +17 -0
- data/README.md +332 -0
- data/Rakefile +121 -0
- data/beaker.gemspec +42 -0
- data/beaker.rb +10 -0
- data/bin/beaker +9 -0
- data/lib/beaker.rb +36 -0
- data/lib/beaker/answers.rb +29 -0
- data/lib/beaker/answers/version28.rb +104 -0
- data/lib/beaker/answers/version30.rb +194 -0
- data/lib/beaker/cli.rb +113 -0
- data/lib/beaker/command.rb +241 -0
- data/lib/beaker/command_factory.rb +21 -0
- data/lib/beaker/dsl.rb +85 -0
- data/lib/beaker/dsl/assertions.rb +87 -0
- data/lib/beaker/dsl/helpers.rb +625 -0
- data/lib/beaker/dsl/install_utils.rb +299 -0
- data/lib/beaker/dsl/outcomes.rb +99 -0
- data/lib/beaker/dsl/roles.rb +97 -0
- data/lib/beaker/dsl/structure.rb +63 -0
- data/lib/beaker/dsl/wrappers.rb +100 -0
- data/lib/beaker/host.rb +193 -0
- data/lib/beaker/host/aix.rb +15 -0
- data/lib/beaker/host/aix/file.rb +16 -0
- data/lib/beaker/host/aix/group.rb +35 -0
- data/lib/beaker/host/aix/user.rb +32 -0
- data/lib/beaker/host/unix.rb +54 -0
- data/lib/beaker/host/unix/exec.rb +15 -0
- data/lib/beaker/host/unix/file.rb +16 -0
- data/lib/beaker/host/unix/group.rb +40 -0
- data/lib/beaker/host/unix/pkg.rb +22 -0
- data/lib/beaker/host/unix/user.rb +32 -0
- data/lib/beaker/host/windows.rb +44 -0
- data/lib/beaker/host/windows/exec.rb +18 -0
- data/lib/beaker/host/windows/file.rb +15 -0
- data/lib/beaker/host/windows/group.rb +36 -0
- data/lib/beaker/host/windows/pkg.rb +26 -0
- data/lib/beaker/host/windows/user.rb +32 -0
- data/lib/beaker/hypervisor.rb +37 -0
- data/lib/beaker/hypervisor/aixer.rb +52 -0
- data/lib/beaker/hypervisor/blimper.rb +123 -0
- data/lib/beaker/hypervisor/fusion.rb +56 -0
- data/lib/beaker/hypervisor/solaris.rb +65 -0
- data/lib/beaker/hypervisor/vagrant.rb +118 -0
- data/lib/beaker/hypervisor/vcloud.rb +175 -0
- data/lib/beaker/hypervisor/vsphere.rb +80 -0
- data/lib/beaker/hypervisor/vsphere_helper.rb +200 -0
- data/lib/beaker/logger.rb +167 -0
- data/lib/beaker/network_manager.rb +73 -0
- data/lib/beaker/options_parsing.rb +323 -0
- data/lib/beaker/result.rb +55 -0
- data/lib/beaker/shared.rb +15 -0
- data/lib/beaker/shared/error_handler.rb +17 -0
- data/lib/beaker/shared/host_handler.rb +46 -0
- data/lib/beaker/shared/repetition.rb +28 -0
- data/lib/beaker/ssh_connection.rb +198 -0
- data/lib/beaker/test_case.rb +225 -0
- data/lib/beaker/test_config.rb +148 -0
- data/lib/beaker/test_suite.rb +288 -0
- data/lib/beaker/utils.rb +7 -0
- data/lib/beaker/utils/ntp_control.rb +42 -0
- data/lib/beaker/utils/repo_control.rb +92 -0
- data/lib/beaker/utils/setup_helper.rb +77 -0
- data/lib/beaker/utils/validator.rb +27 -0
- data/spec/beaker/command_spec.rb +94 -0
- data/spec/beaker/dsl/assertions_spec.rb +104 -0
- data/spec/beaker/dsl/helpers_spec.rb +230 -0
- data/spec/beaker/dsl/install_utils_spec.rb +70 -0
- data/spec/beaker/dsl/outcomes_spec.rb +43 -0
- data/spec/beaker/dsl/roles_spec.rb +86 -0
- data/spec/beaker/dsl/structure_spec.rb +60 -0
- data/spec/beaker/dsl/wrappers_spec.rb +52 -0
- data/spec/beaker/host_spec.rb +95 -0
- data/spec/beaker/logger_spec.rb +117 -0
- data/spec/beaker/options_parsing_spec.rb +37 -0
- data/spec/beaker/puppet_command_spec.rb +128 -0
- data/spec/beaker/ssh_connection_spec.rb +39 -0
- data/spec/beaker/test_case_spec.rb +6 -0
- data/spec/beaker/test_suite_spec.rb +44 -0
- data/spec/mocks_and_helpers.rb +34 -0
- data/spec/spec_helper.rb +15 -0
- metadata +359 -0
@@ -0,0 +1,63 @@
|
|
1
|
+
module Beaker
|
2
|
+
module DSL
|
3
|
+
# These are simple structural elements necessary for writing
|
4
|
+
# understandable tests and ensuring cleanup actions happen. If using a
|
5
|
+
# third party test runner they are unnecessary.
|
6
|
+
#
|
7
|
+
# To include this in your own test runner a method #logger should be
|
8
|
+
# available to yield a logger that implements
|
9
|
+
# {Beaker::Logger}'s interface. As well as a method
|
10
|
+
# #teardown_procs that yields an array.
|
11
|
+
#
|
12
|
+
# @example Structuring a test case.
|
13
|
+
# test_name 'Look at me testing things!' do
|
14
|
+
# teardown do
|
15
|
+
# ...clean up actions...
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# step 'Prepare the things' do
|
19
|
+
# ...setup steps...
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# step 'Test the things' do
|
23
|
+
# ...tests...
|
24
|
+
# end
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
module Structure
|
28
|
+
|
29
|
+
# Provides a method to help structure tests into coherent steps.
|
30
|
+
# @param [String] step_name The name of the step to be logged.
|
31
|
+
# @param [Proc] block The actions to be performed in this step.
|
32
|
+
# @api dsl
|
33
|
+
def step step_name, &block
|
34
|
+
logger.notify "\n * #{step_name}\n"
|
35
|
+
yield if block_given?
|
36
|
+
end
|
37
|
+
|
38
|
+
# Provides a method to name tests.
|
39
|
+
#
|
40
|
+
# @param [String] my_name The name of the test to be logged.
|
41
|
+
# @param [Proc] block The actions to be performed during this test.
|
42
|
+
#
|
43
|
+
# @api dsl
|
44
|
+
def test_name my_name, &block
|
45
|
+
logger.notify "\n#{my_name}\n"
|
46
|
+
yield if block_given?
|
47
|
+
end
|
48
|
+
|
49
|
+
# Declare a teardown process that will be called after a test case is
|
50
|
+
# complete.
|
51
|
+
#
|
52
|
+
# @param block [Proc] block of code to execute during teardown
|
53
|
+
# @example Always remove /etc/puppet/modules
|
54
|
+
# teardown do
|
55
|
+
# on(master, puppet_resource('file', '/etc/puppet/modules',
|
56
|
+
# 'ensure=absent', 'purge=true'))
|
57
|
+
# end
|
58
|
+
def teardown &block
|
59
|
+
@teardown_procs << block
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
module Beaker
|
2
|
+
module DSL
|
3
|
+
# These are wrappers to equivalent {Beaker::Command} objects
|
4
|
+
# so that command line actions are executed within an appropriate and
|
5
|
+
# configurable environment.
|
6
|
+
#
|
7
|
+
# I find most of these adapters of suspicious value and have deprecated
|
8
|
+
# many of them.
|
9
|
+
module Wrappers
|
10
|
+
|
11
|
+
# This is hairy and because of legacy code it will take a bit more
|
12
|
+
# work to disentangle all of the things that are being passed into
|
13
|
+
# this catchall param.
|
14
|
+
#
|
15
|
+
# @api dsl
|
16
|
+
def facter(*args)
|
17
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
18
|
+
options['ENV'] ||= {}
|
19
|
+
options['ENV'] = options['ENV'].merge( Command::DEFAULT_GIT_ENV )
|
20
|
+
Command.new('facter', args, options )
|
21
|
+
end
|
22
|
+
|
23
|
+
# This is hairy and because of legacy code it will take a bit more
|
24
|
+
# work to disentangle all of the things that are being passed into
|
25
|
+
# this catchall param.
|
26
|
+
#
|
27
|
+
# @api dsl
|
28
|
+
def hiera(*args)
|
29
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
30
|
+
options['ENV'] ||= {}
|
31
|
+
options['ENV'] = options['ENV'].merge( Command::DEFAULT_GIT_ENV )
|
32
|
+
Command.new('hiera', args, options )
|
33
|
+
end
|
34
|
+
|
35
|
+
# @param [String] command_string A string of to be interpolated
|
36
|
+
# within the context of a host in
|
37
|
+
# question
|
38
|
+
# @example Usage
|
39
|
+
# @!visibility private
|
40
|
+
def host_command(command_string)
|
41
|
+
HostCommand.new(command_string)
|
42
|
+
end
|
43
|
+
|
44
|
+
# This is hairy and because of legacy code it will take a bit more
|
45
|
+
# work to disentangle all of the things that are being passed into
|
46
|
+
# this catchall param.
|
47
|
+
#
|
48
|
+
# @api dsl
|
49
|
+
def puppet(*args)
|
50
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
51
|
+
options['ENV'] ||= {}
|
52
|
+
options['ENV'] = options['ENV'].merge( Command::DEFAULT_GIT_ENV )
|
53
|
+
# we assume that an invocation with `puppet()` will have it's first argument
|
54
|
+
# a face or sub command
|
55
|
+
cmd = "puppet #{args.shift}"
|
56
|
+
Command.new( cmd, args, options )
|
57
|
+
end
|
58
|
+
|
59
|
+
# @!visibility private
|
60
|
+
def puppet_resource(*args)
|
61
|
+
puppet( 'resource', *args )
|
62
|
+
end
|
63
|
+
|
64
|
+
# @!visibility private
|
65
|
+
def puppet_doc(*args)
|
66
|
+
puppet( 'doc', *args )
|
67
|
+
end
|
68
|
+
|
69
|
+
# @!visibility private
|
70
|
+
def puppet_kick(*args)
|
71
|
+
puppet( 'kick', *args )
|
72
|
+
end
|
73
|
+
|
74
|
+
# @!visibility private
|
75
|
+
def puppet_cert(*args)
|
76
|
+
puppet( 'cert', *args )
|
77
|
+
end
|
78
|
+
|
79
|
+
# @!visibility private
|
80
|
+
def puppet_apply(*args)
|
81
|
+
puppet( 'apply', *args )
|
82
|
+
end
|
83
|
+
|
84
|
+
# @!visibility private
|
85
|
+
def puppet_master(*args)
|
86
|
+
puppet( 'master', *args )
|
87
|
+
end
|
88
|
+
|
89
|
+
# @!visibility private
|
90
|
+
def puppet_agent(*args)
|
91
|
+
puppet( 'agent', *args )
|
92
|
+
end
|
93
|
+
|
94
|
+
# @!visibility private
|
95
|
+
def puppet_filebucket(*args)
|
96
|
+
puppet( 'filebucket', *args )
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
data/lib/beaker/host.rb
ADDED
@@ -0,0 +1,193 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'timeout'
|
3
|
+
|
4
|
+
%w(command ssh_connection).each do |lib|
|
5
|
+
begin
|
6
|
+
require "beaker/#{lib}"
|
7
|
+
rescue LoadError
|
8
|
+
require File.expand_path(File.join(File.dirname(__FILE__), lib))
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
module Beaker
|
13
|
+
class Host
|
14
|
+
|
15
|
+
# This class providers array syntax for using puppet --configprint on a host
|
16
|
+
class PuppetConfigReader
|
17
|
+
def initialize(host, command)
|
18
|
+
@host = host
|
19
|
+
@command = command
|
20
|
+
end
|
21
|
+
|
22
|
+
def [](k)
|
23
|
+
cmd = PuppetCommand.new(@command, "--configprint #{k.to_s}")
|
24
|
+
@host.exec(cmd).stdout.strip
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.create name, options, config
|
29
|
+
case config['HOSTS'][name]['platform']
|
30
|
+
when /windows/
|
31
|
+
Windows::Host.new name, options, config
|
32
|
+
when /aix/
|
33
|
+
Aix::Host.new name, options, config
|
34
|
+
else
|
35
|
+
Unix::Host.new name, options, config
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
attr_accessor :logger
|
40
|
+
attr_reader :name, :defaults
|
41
|
+
def initialize name, options, config
|
42
|
+
@logger = options[:logger]
|
43
|
+
@name, @options, @config = name, options.dup, config
|
44
|
+
|
45
|
+
# This is annoying and its because of drift/lack of enforcement/lack of having
|
46
|
+
# a explict relationship between our defaults, our setup steps and how they're
|
47
|
+
# related through 'type' and the differences between the assumption of our two
|
48
|
+
# configurations we have for many of our products
|
49
|
+
type = is_pe? ? :pe : :foss
|
50
|
+
@defaults = merge_defaults_for_type @config, type
|
51
|
+
end
|
52
|
+
|
53
|
+
def merge_defaults_for_type config, type
|
54
|
+
defaults = self.class.send "#{type}_defaults".to_sym
|
55
|
+
defaults.merge(config['CONFIG']).merge(config['HOSTS'][name])
|
56
|
+
end
|
57
|
+
|
58
|
+
def node_name
|
59
|
+
# TODO: might want to consider caching here; not doing it for now because
|
60
|
+
# I haven't thought through all of the possible scenarios that could
|
61
|
+
# cause the value to change after it had been cached.
|
62
|
+
result = puppet['node_name_value'].strip
|
63
|
+
end
|
64
|
+
|
65
|
+
def port_open? port
|
66
|
+
Timeout.timeout 1 do
|
67
|
+
begin
|
68
|
+
TCPSocket.new(reachable_name, port).close
|
69
|
+
return true
|
70
|
+
rescue Errno::ECONNREFUSED
|
71
|
+
return false
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def up?
|
77
|
+
require 'socket'
|
78
|
+
begin
|
79
|
+
Socket.getaddrinfo( reachable_name, nil )
|
80
|
+
return true
|
81
|
+
rescue SocketError
|
82
|
+
return false
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def reachable_name
|
87
|
+
self['ip'] || self['vmhostname'] || name
|
88
|
+
end
|
89
|
+
|
90
|
+
# Returning our PuppetConfigReader here allows users of the Host
|
91
|
+
# class to do things like `host.puppet['vardir']` to query the
|
92
|
+
# 'main' section or, if they want the configuration for a
|
93
|
+
# particular run type, `host.puppet('agent')['vardir']`
|
94
|
+
def puppet(command='agent')
|
95
|
+
PuppetConfigReader.new(self, command)
|
96
|
+
end
|
97
|
+
|
98
|
+
def []= k, v
|
99
|
+
@defaults[k] = v
|
100
|
+
end
|
101
|
+
|
102
|
+
def [] k
|
103
|
+
@defaults[k]
|
104
|
+
end
|
105
|
+
|
106
|
+
def has_key? k
|
107
|
+
@defaults.has_key?(k)
|
108
|
+
end
|
109
|
+
|
110
|
+
def to_str
|
111
|
+
@defaults['vmhostname'] || @name
|
112
|
+
end
|
113
|
+
|
114
|
+
def to_s
|
115
|
+
@defaults['vmhostname'] || @name
|
116
|
+
end
|
117
|
+
|
118
|
+
def + other
|
119
|
+
@name + other
|
120
|
+
end
|
121
|
+
|
122
|
+
def is_pe?
|
123
|
+
@config.is_pe?
|
124
|
+
end
|
125
|
+
|
126
|
+
def connection
|
127
|
+
@connection ||= SshConnection.connect( reachable_name,
|
128
|
+
self['user'],
|
129
|
+
self['ssh'] )
|
130
|
+
end
|
131
|
+
|
132
|
+
def close
|
133
|
+
@connection.close if @connection
|
134
|
+
@connection = nil
|
135
|
+
end
|
136
|
+
|
137
|
+
def exec command, options={}
|
138
|
+
# I've always found this confusing
|
139
|
+
cmdline = command.cmd_line(self)
|
140
|
+
|
141
|
+
if options[:silent]
|
142
|
+
output_callback = nil
|
143
|
+
else
|
144
|
+
if @defaults['vmhostname']
|
145
|
+
@logger.debug "\n#{self} (#{@name}) $ #{cmdline}"
|
146
|
+
else
|
147
|
+
@logger.debug "\n#{self} $ #{cmdline}"
|
148
|
+
end
|
149
|
+
output_callback = logger.method(:host_output)
|
150
|
+
end
|
151
|
+
|
152
|
+
unless $dry_run
|
153
|
+
# is this returning a result object?
|
154
|
+
# the options should come at the end of the method signature (rubyism)
|
155
|
+
# and they shouldn't be ssh specific
|
156
|
+
result = connection.execute(cmdline, options, output_callback)
|
157
|
+
|
158
|
+
unless options[:silent]
|
159
|
+
# What?
|
160
|
+
result.log(@logger)
|
161
|
+
# No, TestCase has the knowledge about whether its failed, checking acceptable
|
162
|
+
# exit codes at the host level and then raising...
|
163
|
+
# is it necessary to break execution??
|
164
|
+
unless result.exit_code_in?(options[:acceptable_exit_codes] || [0])
|
165
|
+
limit = 10
|
166
|
+
raise "Host '#{self}' exited with #{result.exit_code} running:\n #{cmdline}\nLast #{limit} lines of output were:\n#{result.formatted_output(limit)}"
|
167
|
+
end
|
168
|
+
end
|
169
|
+
# Danger, so we have to return this result?
|
170
|
+
result
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def do_scp_to source, target, options
|
175
|
+
|
176
|
+
@logger.debug "localhost $ scp #{source} #{@name}:#{target} #{options.to_s}"
|
177
|
+
result = connection.scp_to(source, target, options, $dry_run)
|
178
|
+
return result
|
179
|
+
end
|
180
|
+
|
181
|
+
def do_scp_from source, target, options
|
182
|
+
|
183
|
+
@logger.debug "localhost $ scp #{@name}:#{source} #{target} #{options.to_s}"
|
184
|
+
result = connection.scp_from(source, target, options, $dry_run)
|
185
|
+
return result
|
186
|
+
end
|
187
|
+
|
188
|
+
end
|
189
|
+
|
190
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'host/windows'))
|
191
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'host/unix'))
|
192
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'host/aix'))
|
193
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'host'))
|
2
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'command_factory'))
|
3
|
+
|
4
|
+
module Aix
|
5
|
+
class Host < Unix::Host
|
6
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'aix', 'user'))
|
7
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'aix', 'group'))
|
8
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'aix', 'file'))
|
9
|
+
|
10
|
+
include Aix::User
|
11
|
+
include Aix::Group
|
12
|
+
include Aix::File
|
13
|
+
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Aix::File
|
2
|
+
include Beaker::CommandFactory
|
3
|
+
|
4
|
+
def tmpfile(name)
|
5
|
+
execute("rndnum=${RANDOM} && touch /tmp/#{name}.${rndnum} && echo /tmp/#{name}.${rndnum}")
|
6
|
+
end
|
7
|
+
|
8
|
+
def tmpdir(name)
|
9
|
+
execute("rndnum=${RANDOM} && mkdir /tmp/#{name}.${rndnum} && echo /tmp/#{name}.${rndnum}")
|
10
|
+
end
|
11
|
+
|
12
|
+
def path_split(paths)
|
13
|
+
paths.split(':')
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Aix::Group
|
2
|
+
include Beaker::CommandFactory
|
3
|
+
|
4
|
+
def group_list(&block)
|
5
|
+
execute("lsgroup -a ALL") do |result|
|
6
|
+
yield result if block_given?
|
7
|
+
|
8
|
+
result.stdout.lines.map(&:strip)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def group_get(name, &block)
|
13
|
+
execute("lsgroup #{name}") do |result|
|
14
|
+
fail_test "failed to get group #{name}" unless result.stdout =~ /^#{name} id/
|
15
|
+
|
16
|
+
yield result if block_given?
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def group_gid(name)
|
21
|
+
execute("lsgroup -a id #{name}") do |result|
|
22
|
+
# Format is:
|
23
|
+
# staff id=500
|
24
|
+
result.stdout.split('=').last.strip
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def group_present(name, &block)
|
29
|
+
execute("if ! lsgroup #{name}; then mkgroup #{name}; fi", {}, &block)
|
30
|
+
end
|
31
|
+
|
32
|
+
def group_absent(name, &block)
|
33
|
+
execute("if lsgroup #{name}; then rmgroup #{name}; fi", {}, &block)
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Aix::User
|
2
|
+
include Beaker::CommandFactory
|
3
|
+
|
4
|
+
def user_list(&block)
|
5
|
+
execute("lsuser ALL") do |result|
|
6
|
+
users = []
|
7
|
+
result.stdout.each_line do |line|
|
8
|
+
users << line.split(' ')[0]
|
9
|
+
end
|
10
|
+
|
11
|
+
yield result if block_given?
|
12
|
+
|
13
|
+
users
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def user_get(name, &block)
|
18
|
+
execute("lsuser #{name}") do |result|
|
19
|
+
fail_test "failed to get user #{name}" unless result.stdout =~ /^#{name} id/
|
20
|
+
|
21
|
+
yield result if block_given?
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def user_present(name, &block)
|
26
|
+
execute("if ! lsuser #{name}; then mkuser #{name}; fi", {}, &block)
|
27
|
+
end
|
28
|
+
|
29
|
+
def user_absent(name, &block)
|
30
|
+
execute("if lsuser #{name}; then rmuser #{name}; fi", {}, &block)
|
31
|
+
end
|
32
|
+
end
|