beaker-docker 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ ---
2
+ HOSTS:
3
+ ubuntu1604-64-1:
4
+ platform: ubuntu-1604-x86_64
5
+ hypervisor: docker
6
+ image: ubuntu:16.04
7
+ roles:
8
+ - master
9
+ - agent
10
+ - dashboard
11
+ - database
12
+ - classifier
13
+ - default
14
+ docker_cmd: '["/sbin/init"]'
15
+ ubuntu1604-64-2:
16
+ platform: ubuntu-1604-x86_64
17
+ hypervisor: docker
18
+ image: ubuntu:16.04
19
+ roles:
20
+ - agent
21
+ docker_cmd: '["/sbin/init"]'
22
+ CONFIG:
23
+ nfs_server: none
24
+ consoleport: 443
25
+ log_level: verbose
@@ -0,0 +1,38 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
3
+ require 'beaker-docker/version'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "beaker-docker"
7
+ s.version = BeakerDocker::VERSION
8
+ s.authors = ["Rishi Javia, Kevin Imber, Tony Vu"]
9
+ s.email = ["rishi.javia@puppet.com, kevin.imber@puppet.com, tony.vu@puppet.com"]
10
+ s.homepage = "https://github.com/puppetlabs/beaker-docker"
11
+ s.summary = %q{Beaker DSL Extension Helpers!}
12
+ s.description = %q{For use for the Beaker acceptance testing tool}
13
+ s.license = 'Apache2'
14
+
15
+ s.files = `git ls-files`.split("\n")
16
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
17
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
18
+ s.require_paths = ["lib"]
19
+
20
+ # Testing dependencies
21
+ s.add_development_dependency 'rspec', '~> 3.0'
22
+ s.add_development_dependency 'rspec-its'
23
+ s.add_development_dependency 'fakefs', '~> 0.6'
24
+ s.add_development_dependency 'rake', '~> 10.1'
25
+ s.add_development_dependency 'simplecov'
26
+ s.add_development_dependency 'pry', '~> 0.10'
27
+
28
+ # Documentation dependencies
29
+ s.add_development_dependency 'yard'
30
+ s.add_development_dependency 'markdown'
31
+ s.add_development_dependency 'thin'
32
+
33
+ # Run time dependencies
34
+ s.add_runtime_dependency 'stringify-hash', '~> 0.0.0'
35
+ s.add_runtime_dependency 'docker-api'
36
+
37
+ end
38
+
data/bin/beaker-docker ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems' unless defined?(Gem)
4
+ require 'beaker-docker'
5
+
6
+ VERSION_STRING =
7
+ "
8
+ _ .--.
9
+ ( ` )
10
+ beaker-docker .-' `--,
11
+ _..----.. ( )`-.
12
+ .'_|` _|` _|( .__, )
13
+ /_| _| _| _( (_, .-'
14
+ ;| _| _| _| '-'__,--'`--'
15
+ | _| _| _| _| |
16
+ _ || _| _| _| _| %s
17
+ _( `--.\\_| _| _| _|/
18
+ .-' )--,| _| _|.`
19
+ (__, (_ ) )_| _| /
20
+ `-.__.\\ _,--'\\|__|__/
21
+ ;____;
22
+ \\YT/
23
+ ||
24
+ |\"\"|
25
+ '=='
26
+ "
27
+
28
+
29
+
30
+ puts BeakerDocker::VERSION
31
+
32
+ exit 0
data/docker.md ADDED
@@ -0,0 +1,148 @@
1
+ This option allows for testing against Docker containers.
2
+
3
+
4
+ ### Why?
5
+
6
+ Using docker as a hypervisor significantly speeds up the provisioning process, as you don't have to spin up an entire VM to run the tests, which has significant overhead.
7
+
8
+ ### How?
9
+
10
+ So first of all, install Docker using the instructions [here](https://docs.docker.com/installation/#installation).
11
+
12
+ In the real world, it's generally seen as [bad practice to have sshd running in a Docker container](http://blog.docker.com/2014/06/why-you-dont-need-to-run-sshd-in-docker/). However, for the purpose of a disposable test instance, we're not going to worry about that!
13
+
14
+ ### Basic docker hosts file ###
15
+ The base image to use for the container is named by the image key.
16
+
17
+ HOSTS:
18
+ ubuntu-12-10:
19
+ platform: ubuntu-12.10-x64
20
+ image: ubuntu:12.10
21
+ hypervisor: docker
22
+ CONFIG:
23
+ type: foss
24
+
25
+ ### Docker hosts file, with image modification ###
26
+ You can specify extra commands to be executed in order to modify the image with the `docker_image_commands` key.
27
+
28
+ HOSTS:
29
+ ubuntu-12-10:
30
+ platform: ubuntu-12.10-x64
31
+ image: ubuntu:12.10
32
+ hypervisor: docker
33
+ docker_image_commands:
34
+ - 'apt-get install -y myapp'
35
+ - 'myapp --setup'
36
+ CONFIG:
37
+ type: foss
38
+
39
+ ### Docker hosts files, with modified start commands ###
40
+ By default the docker container just runs an sshd which is adequate for 'puppet apply' style testing. You can specify a different command to start with the `docker_cmd` key. This gives you scope to run something with more service supervision baked into it, but it is is important that this command starts an sshd listening on port 22 so that beaker can drive the container.
41
+
42
+ HOSTS:
43
+ ubuntu-12-10:
44
+ platform: ubuntu-12.10-x64
45
+ image: ubuntu:12.10
46
+ hypervisor: docker
47
+ docker_cmd: '["/sbin/init"]'
48
+ CONFIG:
49
+ type: foss
50
+
51
+ ### Preserve Docker Image ###
52
+ Unless the image configuration changes you might want to keep the Docker image for multiple spec runs. Use `docker_preserve_image` option for a host.
53
+
54
+ HOSTS:
55
+ ubuntu-12-10:
56
+ platform: ubuntu-12.10-x64
57
+ image: ubuntu:12.10
58
+ hypervisor: docker
59
+ docker_preserve_image: true
60
+ CONFIG:
61
+ type: foss
62
+
63
+ ### Reuse Docker Image ###
64
+ In case you want to rerun the puppet again on the docker container, you can pass BEAKER_provision=no on the command line to set the env. Add this line in you default.ml file
65
+
66
+ ```
67
+ HOSTS:
68
+ centos6-64:
69
+ roles:
70
+ - agent
71
+ platform: el-6-x86_64
72
+ image: centos:6.6
73
+ hypervisor: docker
74
+ CONFIG:
75
+ type: foss
76
+ log_level: verbose
77
+ ssh:
78
+ password: root
79
+ auth_methods: ["password"]
80
+ ```
81
+
82
+ ### Mounting volumes into your docker container ###
83
+ You can mount folders into a docker container:
84
+
85
+ HOSTS:
86
+ ubuntu-12-10:
87
+ platform: ubuntu-12.10-x64
88
+ image: ubuntu:12.10
89
+ hypervisor: docker
90
+ mount_folders:
91
+ name1:
92
+ host_path: host_path1
93
+ container_path: container_path1
94
+ name2:
95
+ host_path: host_path2
96
+ container_path: container_path2
97
+ opts: rw
98
+ CONFIG:
99
+ type: foss
100
+
101
+ ### Example Output
102
+
103
+ For this example made a new docker nodeset file in the [puppetlabs-inifile](https://github.com/puppetlabs/puppetlabs-inifile) repo and ran the ini_setting_spec.rb spec:
104
+
105
+ ```bash
106
+ $ bundle exec rspec spec/acceptance/ini_setting_spec.rb
107
+ Hypervisor for debian-7 is docker
108
+ Beaker::Hypervisor, found some docker boxes to create
109
+ Provisioning docker
110
+ provisioning debian-7
111
+ Creating image
112
+ Dockerfile is FROM debian:7.4
113
+ RUN apt-get update
114
+ RUN apt-get install -y openssh-server openssh-client curl ntpdate lsb-release
115
+ RUN mkdir -p /var/run/sshd
116
+ RUN echo root:root | chpasswd
117
+ RUN apt-get install -yq lsb-release wget net-tools ruby rubygems ruby1.8-dev libaugeas-dev libaugeas-ruby ntpdate locales-all
118
+ RUN REALLY_GEM_UPDATE_SYSTEM=1 gem update --system --no-ri --no-rdoc
119
+ EXPOSE 22
120
+ CMD ["/sbin/init"]
121
+ ```
122
+
123
+ This step may take a while, as Docker will have to download the image. The subsequent runs will be a lot faster (as long as `docker_preserve_image: true` has been enabled).
124
+
125
+ For example, running this took 5 minutes to download and setup the `debian:7.4` image, but runs instantly the second time.
126
+
127
+ You should then see something like:
128
+
129
+ ```
130
+ Creating container from image 3a86e5aba94d
131
+ post
132
+ /v1.15/containers/create
133
+ {}
134
+ {"Image":"3a86e5aba94d","Hostname":"debian-7"}
135
+ Starting container b8b31702b34b4aedd137c8a6a72fe730560bb00533e68764ba6263405f9244e4
136
+ post
137
+ /v1.15/containers/b8b31702b34b4aedd137c8a6a72fe730560bb00533e68764ba6263405f9244e4/start
138
+ {}
139
+ {"PublishAllPorts":true,"Privileged":true}
140
+ Using docker server at 192.168.59.103
141
+ get
142
+ /v1.15/containers/b8b31702b34b4aedd137c8a6a72fe730560bb00533e68764ba6263405f9244e4/json
143
+ {}
144
+
145
+ node available as ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no root@192.168.59.103 -p 49155
146
+ ```
147
+
148
+ The tests should then run as normal from there.
@@ -0,0 +1,3 @@
1
+ module BeakerDocker
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,336 @@
1
+ module Beaker
2
+ class Docker < Beaker::Hypervisor
3
+
4
+ # Docker hypvervisor initializtion
5
+ # Env variables supported:
6
+ # DOCKER_REGISTRY: Docker registry URL
7
+ # DOCKER_HOST: Remote docker host
8
+ # DOCKER_BUILDARGS: Docker buildargs map
9
+ # @param [Host, Array<Host>, String, Symbol] hosts One or more hosts to act upon,
10
+ # or a role (String or Symbol) that identifies one or more hosts.
11
+ # @param [Hash{Symbol=>String}] options Options to pass on to the hypervisor
12
+ def initialize(hosts, options)
13
+ require 'docker'
14
+ @options = options
15
+ @logger = options[:logger]
16
+ @hosts = hosts
17
+
18
+ # increase the http timeouts as provisioning images can be slow
19
+ default_docker_options = { :write_timeout => 300, :read_timeout => 300 }.merge(::Docker.options || {})
20
+ # Merge docker options from the entry in hosts file
21
+ ::Docker.options = default_docker_options.merge(@options[:docker_options] || {})
22
+ # assert that the docker-api gem can talk to your docker
23
+ # enpoint. Will raise if there is a version mismatch
24
+ begin
25
+ ::Docker.validate_version!
26
+ rescue Excon::Errors::SocketError => e
27
+ raise "Docker instance not connectable.\nError was: #{e}\nCheck your DOCKER_HOST variable has been set\nIf you are on OSX or Windows, you might not have Docker Machine setup correctly: https://docs.docker.com/machine/\n"
28
+ end
29
+
30
+ # Pass on all the logging from docker-api to the beaker logger instance
31
+ ::Docker.logger = @logger
32
+
33
+ # Find out what kind of remote instance we are talking against
34
+ if ::Docker.version['Version'] =~ /swarm/
35
+ @docker_type = 'swarm'
36
+ unless ENV['DOCKER_REGISTRY']
37
+ raise "Using Swarm with beaker requires a private registry. Please setup the private registry and set the 'DOCKER_REGISTRY' env var"
38
+ else
39
+ @registry = ENV['DOCKER_REGISTRY']
40
+ end
41
+ else
42
+ @docker_type = 'docker'
43
+ end
44
+
45
+ end
46
+
47
+ def provision
48
+ @logger.notify "Provisioning docker"
49
+
50
+ @hosts.each do |host|
51
+ @logger.notify "provisioning #{host.name}"
52
+
53
+ @logger.debug("Creating image")
54
+ image = ::Docker::Image.build(dockerfile_for(host), {
55
+ :rm => true, :buildargs => buildargs_for(host)
56
+ })
57
+
58
+ if @docker_type == 'swarm'
59
+ image_name = "#{@registry}/beaker/#{image.id}"
60
+ ret = ::Docker::Image.search(:term => image_name)
61
+ if ret.first.nil?
62
+ @logger.debug("Image does not exist on registry. Pushing.")
63
+ image.tag({:repo => image_name, :force => true})
64
+ image.push
65
+ end
66
+ else
67
+ image_name = image.id
68
+ end
69
+
70
+ container_opts = {
71
+ 'Image' => image_name,
72
+ 'Hostname' => host.name,
73
+ }
74
+ container = find_container(host)
75
+
76
+ # If the specified container exists, then use it rather creating a new one
77
+ if container.nil?
78
+ unless host['mount_folders'].nil?
79
+ container_opts['HostConfig'] ||= {}
80
+ container_opts['HostConfig']['Binds'] = host['mount_folders'].values.map do |mount|
81
+ a = [ File.expand_path(mount['host_path']), mount['container_path'] ]
82
+ a << mount['opts'] if mount.has_key?('opts')
83
+ a.join(':')
84
+ end
85
+ end
86
+
87
+ if @options[:provision]
88
+ if host['docker_container_name']
89
+ container_opts['name'] = host['docker_container_name']
90
+ end
91
+
92
+ @logger.debug("Creating container from image #{image_name}")
93
+ container = ::Docker::Container.create(container_opts)
94
+ end
95
+ end
96
+
97
+ if container.nil?
98
+ raise RuntimeError, 'Cannot continue because no existing container ' +
99
+ 'could be found and provisioning is disabled.'
100
+ end
101
+
102
+ fix_ssh(container) if @options[:provision] == false
103
+
104
+ @logger.debug("Starting container #{container.id}")
105
+ container.start({"PublishAllPorts" => true, "Privileged" => true})
106
+
107
+ # Find out where the ssh port is from the container
108
+ # When running on swarm DOCKER_HOST points to the swarm manager so we have to get the
109
+ # IP of the swarm slave via the container data
110
+ # When we are talking to a normal docker instance DOCKER_HOST can point to a remote docker instance.
111
+
112
+ # Talking against a remote docker host which is a normal docker host
113
+ if @docker_type == 'docker' && ENV['DOCKER_HOST']
114
+ ip = URI.parse(ENV['DOCKER_HOST']).host
115
+ else
116
+ # Swarm or local docker host
117
+ ip = container.json["NetworkSettings"]["Ports"]["22/tcp"][0]["HostIp"]
118
+ end
119
+
120
+ @logger.info("Using docker server at #{ip}")
121
+ port = container.json["NetworkSettings"]["Ports"]["22/tcp"][0]["HostPort"]
122
+
123
+ forward_ssh_agent = @options[:forward_ssh_agent] || false
124
+
125
+ # Update host metadata
126
+ host['ip'] = ip
127
+ host['port'] = port
128
+ host['ssh'] = {
129
+ :password => root_password,
130
+ :port => port,
131
+ :forward_agent => forward_ssh_agent,
132
+ }
133
+
134
+ @logger.debug("node available as ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no root@#{ip} -p #{port}")
135
+ host['docker_container'] = container
136
+ host['docker_image'] = image
137
+ host['vm_ip'] = container.json["NetworkSettings"]["IPAddress"].to_s
138
+
139
+ end
140
+
141
+ hack_etc_hosts @hosts, @options
142
+
143
+ end
144
+
145
+ def cleanup
146
+ @logger.notify "Cleaning up docker"
147
+ @hosts.each do |host|
148
+ if container = host['docker_container']
149
+ @logger.debug("stop container #{container.id}")
150
+ begin
151
+ container.kill
152
+ sleep 2 # avoid a race condition where the root FS can't unmount
153
+ rescue Excon::Errors::ClientError => e
154
+ @logger.warn("stop of container #{container.id} failed: #{e.response.body}")
155
+ end
156
+ @logger.debug("delete container #{container.id}")
157
+ begin
158
+ container.delete
159
+ rescue Excon::Errors::ClientError => e
160
+ @logger.warn("deletion of container #{container.id} failed: #{e.response.body}")
161
+ end
162
+ end
163
+
164
+ # Do not remove the image if docker_reserve_image is set to true, otherwise remove it
165
+ if image = (host['docker_preserve_image'] ? nil : host['docker_image'])
166
+ @logger.debug("delete image #{image.id}")
167
+ begin
168
+ image.delete
169
+ rescue Excon::Errors::ClientError => e
170
+ @logger.warn("deletion of image #{image.id} failed: #{e.response.body}")
171
+ rescue ::Docker::Error::DockerError => e
172
+ @logger.warn("deletion of image #{image.id} caused internal Docker error: #{e.message}")
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ private
179
+
180
+ def root_password
181
+ 'root'
182
+ end
183
+
184
+ def buildargs_for(host)
185
+ docker_buildargs = {}
186
+ docker_buildargs_env = ENV['DOCKER_BUILDARGS']
187
+ if docker_buildargs_env != nil
188
+ docker_buildargs_env.split(/ +|\t+/).each do |arg|
189
+ key,value=arg.split(/=/)
190
+ if key
191
+ docker_buildargs[key]=value
192
+ else
193
+ @logger.warn("DOCKER_BUILDARGS environment variable appears invalid, no key found for value #{value}" )
194
+ end
195
+ end
196
+ end
197
+ if docker_buildargs.empty?
198
+ buildargs = host['docker_buildargs'] || {}
199
+ else
200
+ buildargs = docker_buildargs
201
+ end
202
+ @logger.debug("Docker build buildargs: #{buildargs}")
203
+ JSON.generate(buildargs)
204
+ end
205
+
206
+ def dockerfile_for(host)
207
+ if host['dockerfile'] then
208
+ @logger.debug("attempting to load user Dockerfile from #{host['dockerfile']}")
209
+ if File.exist?(host['dockerfile']) then
210
+ dockerfile = File.read(host['dockerfile'])
211
+ else
212
+ raise "requested Dockerfile #{host['dockerfile']} does not exist"
213
+ end
214
+ else
215
+ raise("Docker image undefined!") if (host['image']||= nil).to_s.empty?
216
+
217
+ # specify base image
218
+ dockerfile = <<-EOF
219
+ FROM #{host['image']}
220
+ ENV container docker
221
+ EOF
222
+
223
+ # additional options to specify to the sshd
224
+ # may vary by platform
225
+ sshd_options = ''
226
+
227
+ # add platform-specific actions
228
+ service_name = "sshd"
229
+ case host['platform']
230
+ when /ubuntu/, /debian/
231
+ service_name = "ssh"
232
+ dockerfile += <<-EOF
233
+ RUN apt-get update
234
+ RUN apt-get install -y openssh-server openssh-client #{Beaker::HostPrebuiltSteps::DEBIAN_PACKAGES.join(' ')}
235
+ EOF
236
+ when /cumulus/
237
+ dockerfile += <<-EOF
238
+ RUN apt-get update
239
+ RUN apt-get install -y openssh-server openssh-client #{Beaker::HostPrebuiltSteps::CUMULUS_PACKAGES.join(' ')}
240
+ EOF
241
+ when /fedora-(2[2-9])/
242
+ dockerfile += <<-EOF
243
+ RUN dnf clean all
244
+ RUN dnf install -y sudo openssh-server openssh-clients #{Beaker::HostPrebuiltSteps::UNIX_PACKAGES.join(' ')}
245
+ RUN ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key
246
+ RUN ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key
247
+ EOF
248
+ when /^el-/, /centos/, /fedora/, /redhat/, /eos/
249
+ dockerfile += <<-EOF
250
+ RUN yum clean all
251
+ RUN yum install -y sudo openssh-server openssh-clients #{Beaker::HostPrebuiltSteps::UNIX_PACKAGES.join(' ')}
252
+ RUN ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key
253
+ RUN ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key
254
+ EOF
255
+ when /opensuse/, /sles/
256
+ dockerfile += <<-EOF
257
+ RUN zypper -n in openssh #{Beaker::HostPrebuiltSteps::SLES_PACKAGES.join(' ')}
258
+ RUN ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key
259
+ RUN ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key
260
+ RUN sed -ri 's/^#?UsePAM .*/UsePAM no/' /etc/ssh/sshd_config
261
+ EOF
262
+ else
263
+ # TODO add more platform steps here
264
+ raise "platform #{host['platform']} not yet supported on docker"
265
+ end
266
+
267
+ # Make sshd directory, set root password
268
+ dockerfile += <<-EOF
269
+ RUN mkdir -p /var/run/sshd
270
+ RUN echo root:#{root_password} | chpasswd
271
+ EOF
272
+
273
+ # Configure sshd service to allowroot login using password
274
+ # Also, disable reverse DNS lookups to prevent every. single. ssh
275
+ # operation taking 30 seconds while the lookup times out.
276
+ dockerfile += <<-EOF
277
+ RUN sed -ri 's/^#?PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config
278
+ RUN sed -ri 's/^#?PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config
279
+ RUN sed -ri 's/^#?UseDNS .*/UseDNS no/' /etc/ssh/sshd_config
280
+ EOF
281
+
282
+
283
+ # Any extra commands specified for the host
284
+ dockerfile += (host['docker_image_commands'] || []).map { |command|
285
+ "RUN #{command}\n"
286
+ }.join('')
287
+
288
+ # Override image entrypoint
289
+ if host['docker_image_entrypoint']
290
+ dockerfile += "ENTRYPOINT #{host['docker_image_entrypoint']}\n"
291
+ end
292
+
293
+ # How to start a sshd on port 22. May be an init for more supervision
294
+ # Ensure that the ssh server can be restarted (done from set_env) and container keeps running
295
+ cmd = host['docker_cmd'] || ["sh","-c","service #{service_name} start ; tail -f /dev/null"]
296
+ dockerfile += <<-EOF
297
+ EXPOSE 22
298
+ CMD #{cmd}
299
+ EOF
300
+
301
+ end
302
+
303
+ @logger.debug("Dockerfile is #{dockerfile}")
304
+ return dockerfile
305
+ end
306
+
307
+ # a puppet run may have changed the ssh config which would
308
+ # keep us out of the container. This is a best effort to fix it.
309
+ def fix_ssh(container)
310
+ @logger.debug("Fixing ssh on container #{container.id}")
311
+ container.exec(['sed','-ri',
312
+ 's/^#?PermitRootLogin .*/PermitRootLogin yes/',
313
+ '/etc/ssh/sshd_config'])
314
+ container.exec(['sed','-ri',
315
+ 's/^#?PasswordAuthentication .*/PasswordAuthentication yes/',
316
+ '/etc/ssh/sshd_config'])
317
+ container.exec(['sed','-ri',
318
+ 's/^#?UseDNS .*/UseDNS no/',
319
+ '/etc/ssh/sshd_config'])
320
+ container.exec(%w(service ssh restart))
321
+ end
322
+
323
+
324
+ # return the existing container if we're not provisioning
325
+ # and docker_container_name is set
326
+ def find_container(host)
327
+ return nil if host['docker_container_name'].nil? || @options[:provision]
328
+ @logger.debug("Looking for an existing container called #{host['docker_container_name']}")
329
+
330
+ ::Docker::Container.all.select do |c|
331
+ c.info['Names'].include? "/#{host['docker_container_name']}"
332
+ end.first
333
+ end
334
+
335
+ end
336
+ end