knife-scaleway 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.
@@ -0,0 +1,23 @@
1
+ # encoding: utf-8
2
+ require 'rubygems'
3
+ require 'bundler'
4
+ Bundler.setup
5
+ Bundler::GemHelper.install_tasks
6
+
7
+ require 'rspec/core/rake_task'
8
+
9
+ RSpec::Core::RakeTask.new(:spec)
10
+
11
+ require 'rubocop/rake_task'
12
+ desc 'Run RuboCop on the lib directory'
13
+ RuboCop::RakeTask.new(:rubocop) do |task|
14
+ task.patterns = ['lib/**/*.rb']
15
+ end
16
+
17
+ desc 'Display LOC stats'
18
+ task :loc do
19
+ puts "\n## LOC Stats"
20
+ sh 'countloc -r lib/chef/knife'
21
+ end
22
+
23
+ task default: :spec
@@ -0,0 +1,36 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'knife-scaleway/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = 'knife-scaleway'
8
+ gem.version = Knife::Scaleway::VERSION
9
+ gem.authors = ['Lukas Diener']
10
+ gem.email = ['lukas.diener@hotmail.com']
11
+ gem.description = "A plugin for chef's knife to manage instances of Scaleway servers"
12
+ gem.summary = "A plugin for chef's knife to manage instances of Scaleway servers"
13
+ gem.homepage = 'https://github.com/LukasSkywalker/knife-scaleway'
14
+ gem.license = 'Apache 2.0'
15
+
16
+ gem.add_dependency 'chef', '>= 10.18'
17
+
18
+ gem.add_development_dependency 'rspec', '~> 3.1'
19
+ gem.add_development_dependency 'rubocop', '~> 0.27'
20
+ gem.add_development_dependency 'rake'
21
+ gem.add_development_dependency 'knife-solo'
22
+ gem.add_development_dependency 'knife-zero'
23
+ gem.add_development_dependency 'webmock', '~> 1.20'
24
+ gem.add_development_dependency 'vcr', '~> 2.9'
25
+ gem.add_development_dependency 'guard', '~> 2.8'
26
+ gem.add_development_dependency 'guard-rspec', '~> 4.3'
27
+ gem.add_development_dependency 'coveralls'
28
+ gem.add_development_dependency 'countloc'
29
+ gem.add_development_dependency 'simplecov'
30
+ gem.add_development_dependency 'simplecov-console'
31
+
32
+ gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
33
+ gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
34
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
35
+ gem.require_paths = ['lib']
36
+ end
@@ -0,0 +1,110 @@
1
+ require 'rest-client'
2
+ require 'json'
3
+
4
+ module Scaleway
5
+ class Client
6
+ attr_accessor :access_key, :token
7
+
8
+ def initialize(access_key, token)
9
+ @host1 = 'https://api.scaleway.com'
10
+ @host2 = 'https://account.scaleway.com'
11
+ @access_key = access_key
12
+ @token = token
13
+ @instance = self
14
+ end
15
+
16
+ def self.instance
17
+ #raise StandardError, 'Create client before accessing methods' if @instance.nil?
18
+ #@instance
19
+ Scaleway::Client.new(Chef::Config[:knife][:scaleway_access_key], Chef::Config[:knife][:scaleway_token])
20
+ end
21
+
22
+ def request(path, method, payload = nil)
23
+ host = @host1 if path.index('/servers') || path.index('/images') || path.index('/volumes') || path.index('/ips')
24
+ host = @host2 if path.index('/organizations')
25
+ raise StandardError, "Add /#{path.split('/')[1]} to host map" if host.nil?
26
+
27
+ headers = {:'X-Auth-Token' => @token, :'Content-Type' => 'application/json'}
28
+ url = host + path
29
+
30
+ options = { url: url, method: method, verify_ssl: false, headers: headers }
31
+ if method == :post
32
+ options.merge!(payload: payload)
33
+ puts payload if ENV['DEBUG']
34
+ end
35
+
36
+ begin
37
+ puts "### Req #{method.upcase} #{host + path}" if ENV['DEBUG']
38
+ JSON.parse(RestClient::Request.execute(options).body, object_class: OpenStruct, array_class: Array)
39
+ rescue => e
40
+ data = JSON.parse(e.response, object_class: OpenStruct)
41
+ puts "#{data.type}: #{data.message}"
42
+ end
43
+ end
44
+
45
+ def get(path)
46
+ request(path, :get)
47
+ end
48
+
49
+ def post(path, data)
50
+ request(path, :post, data)
51
+ end
52
+
53
+ def put(path, data)
54
+ request(path, :put, data)
55
+ end
56
+ end
57
+
58
+ class Organization
59
+ def self.all
60
+ Client.instance.get('/organizations')
61
+ end
62
+ end
63
+
64
+ class Ip
65
+ def self.all
66
+ Client.instance.get('/ips').ips
67
+ end
68
+ end
69
+
70
+ class Image
71
+ def self.all
72
+ Client.instance.get('/images').images
73
+ end
74
+
75
+ def self.find(query)
76
+ response = Client.instance.get('/images')
77
+ response.images.select do |image|
78
+ image.name.downcase.index(query.downcase)
79
+ end
80
+ end
81
+ end
82
+
83
+ class Server
84
+ def self.all
85
+ Client.instance.get('/servers').servers
86
+ end
87
+
88
+ def self.find(id)
89
+ Client.instance.get("/servers/#{id}").server
90
+ end
91
+
92
+ def self.create(name, image, commercial_type)
93
+ Client.instance.post('/servers', { name: name, organization: Client.instance.access_key, image: image, commercial_type: commercial_type}.to_json).server
94
+ end
95
+
96
+ def self.actions(id)
97
+ Client.instance.get("/servers/#{id}/action")
98
+ end
99
+
100
+ def self.action(id, act)
101
+ Client.instance.post("/servers/#{id}/action", { action: act }.to_json)
102
+ end
103
+ end
104
+
105
+ class Volume
106
+ def self.all
107
+ Client.instance.get('/volumes').volumes
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,45 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+ #
13
+ require 'chef/knife/scaleway_base'
14
+
15
+ class Chef
16
+ class Knife
17
+ class ScalewayAccountInfo < Knife
18
+ include Knife::ScalewayBase
19
+
20
+ banner 'knife scaleway account info (options)'
21
+
22
+ def run
23
+ $stdout.sync = true
24
+
25
+ validate!
26
+
27
+ account_info = [
28
+ ui.color('UUID', :bold),
29
+ ui.color('Email', :bold),
30
+ ui.color('Droplet Limit', :bold),
31
+ ui.color('Email Verified', :bold)
32
+ ]
33
+
34
+ account = client.account.info
35
+
36
+ account_info << account.uuid.to_s
37
+ account_info << account.email.to_s
38
+ account_info << account.server_limit.to_s
39
+ account_info << account.email_verified.to_s
40
+
41
+ puts ui.list(account_info, :uneven_columns_across, 4)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,89 @@
1
+ # lots of awesome stoff stolen from opscode/knife-azure ;-)
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ class Chef
16
+ class Knife
17
+ module ScalewayBase
18
+ def self.load_deps
19
+ require_relative 'scaleway'
20
+ require 'json'
21
+ require 'chef/mixin/shell_out'
22
+ end
23
+
24
+ def self.included(includer)
25
+ includer.class_eval do
26
+ category 'scaleway'
27
+
28
+ # Lazy load our dependencies. Later calls to `Knife#deps` override
29
+ # previous ones, so if the including class calls it, it needs to also
30
+ # call our #load_deps, i.e:
31
+ #
32
+ # Include Chef::Knife::ScalewayBase
33
+ #
34
+ # deps do
35
+ # require 'foo'
36
+ # require 'bar'
37
+ # Chef::Knife::ScalewayBase.load_deps
38
+ # end
39
+ #
40
+ deps { Chef::Knife::ScalewayBase.load_deps }
41
+
42
+ option :scaleway_access_token,
43
+ short: '-A ACCESS_TOKEN',
44
+ long: '--scaleway_access_token ACCESS_TOKEN',
45
+ description: 'Your Scaleway ACCESS_TOKEN',
46
+ proc: proc { |access_token| Chef::Config[:knife][:scaleway_access_token] = access_token }
47
+ end
48
+ end
49
+
50
+ def client
51
+ Scaleway::Client.new(Chef::Config[:knife][:scaleway_access_key], Chef::Config[:knife][:scaleway_token])
52
+ end
53
+
54
+ def validate!(keys = [:scaleway_access_key, :scaleway_token])
55
+ errors = []
56
+
57
+ keys.each do |k|
58
+ if locate_config_value(k).nil?
59
+ errors << "You did not provide a valid '#{k}' value. " \
60
+ "Please set knife[:#{k}] in your knife.rb or pass as an option."
61
+ end
62
+ end
63
+
64
+ exit 1 if errors.each { |e| ui.error(e) }.any?
65
+ end
66
+
67
+ def locate_config_value(key)
68
+ key = key.to_sym
69
+ config[key] || Chef::Config[:knife][key]
70
+ end
71
+
72
+ def wait_for_status(result, status: 'in-progress', sleep: 3)
73
+ print "Waiting for state #{status}"
74
+ result = Scaleway::Server.find(locate_config_value(:id))
75
+ while result.state != status
76
+ sleep sleep
77
+ print('.')
78
+
79
+ #if status == 'starting' || status == 'stopping'
80
+ #break if client.servers.find(id: locate_config_value(:id)).status != 'in-progress'
81
+ #else
82
+ break if Scaleway::Server.find(locate_config_value(:id)).state == status
83
+ #end
84
+ end
85
+ ui.info 'OK'
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,338 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+ #
13
+ require 'chef/knife/scaleway_base'
14
+
15
+ class Chef
16
+ class Knife
17
+ class ScalewayServerCreate < Knife
18
+ include Knife::ScalewayBase
19
+
20
+ deps do
21
+ require 'socket'
22
+ require 'chef/knife/bootstrap'
23
+ Chef::Knife::Bootstrap.load_deps
24
+ Chef::Knife::ScalewayBase.load_deps
25
+ # Knife loads subcommands automatically, so we can just check if the
26
+ # class exists.
27
+ Chef::Knife::SoloBootstrap.load_deps if defined? Chef::Knife::SoloBootstrap
28
+ if defined? Chef::Knife::ZeroBootstrap
29
+ Chef::Knife::ZeroBootstrap.load_deps
30
+ self.options = Chef::Knife::ZeroBootstrap.options.merge(options)
31
+ end
32
+ end
33
+
34
+ banner 'knife scaleway server create (options)'
35
+
36
+ option :server_name,
37
+ short: '-N NAME',
38
+ long: '--server-name NAME',
39
+ description: 'The server name',
40
+ proc: proc { |server_name| Chef::Config[:knife][:server_name] = server_name }
41
+
42
+ option :image,
43
+ short: '-I IMAGE',
44
+ long: '--image IMAGE',
45
+ description: 'Your Scaleway Image',
46
+ proc: proc { |image| Chef::Config[:knife][:image] = image }
47
+
48
+ option :size,
49
+ short: '-S SIZE',
50
+ long: '--size SIZE',
51
+ description: 'Your Scaleway Size',
52
+ proc: proc { |size| Chef::Config[:knife][:size] = size }
53
+
54
+ option :location,
55
+ short: '-L REGION',
56
+ long: '--location REGION',
57
+ description: 'Scaleway Location (Region)',
58
+ proc: proc { |location| Chef::Config[:knife][:location] = location }
59
+
60
+ option :ssh_key_ids,
61
+ short: '-K KEYID',
62
+ long: '--ssh-keys KEY_ID',
63
+ description: 'Comma spearated list of your SSH key ids',
64
+ proc: ->(o) { o.split(/[\s,]+/) }
65
+
66
+ option :identity_file,
67
+ short: '-i IDENTITY_FILE',
68
+ long: '--identity-file IDENTITY_FILE',
69
+ description: 'The SSH identity file used for authentication',
70
+ proc: proc { |identity| Chef::Config[:knife][:identity_file] = identity }
71
+
72
+ option :bootstrap,
73
+ short: '-B',
74
+ long: '--bootstrap',
75
+ description: 'Do a chef-client bootstrap on the created server (for use with chef-server)'
76
+
77
+ option :solo,
78
+ long: '--[no-]solo',
79
+ description: 'Do a chef-solo bootstrap on the server using knife-solo',
80
+ proc: proc { |s| Chef::Config[:knife][:solo] = s }
81
+
82
+ option :zero,
83
+ long: '--[no-]zero',
84
+ description: 'Do a chef-zero bootstrap on the server using knife-zero',
85
+ proc: proc { |z| Chef::Config[:knife][:zero] = z }
86
+
87
+ option :ssh_user,
88
+ short: '-x USERNAME',
89
+ long: '--ssh-user USERNAME',
90
+ description: 'The ssh username; default is "root"',
91
+ default: 'root'
92
+
93
+ option :distro,
94
+ short: '-d DISTRO',
95
+ long: '--distro DISTRO',
96
+ description: 'Chef-Bootstrap a distro using a template; default is "chef-full"',
97
+ proc: proc { |d| Chef::Config[:knife][:distro] = d },
98
+ default: 'chef-full'
99
+
100
+ option :run_list,
101
+ short: '-r RUN_LIST',
102
+ long: '--run-list RUN_LIST',
103
+ description: 'Comma separated list of roles/recipes to apply',
104
+ proc: ->(o) { o.split(/[\s,]+/) },
105
+ default: []
106
+
107
+ option :template_file,
108
+ long: '--template-file TEMPLATE',
109
+ description: 'Full path to location of template to use',
110
+ proc: proc { |t| Chef::Config[:knife][:template_file] = t },
111
+ default: false
112
+
113
+ option :host_key_verify,
114
+ long: '--[no-]host-key-verify',
115
+ description: 'Verify host key, enabled by default',
116
+ default: true
117
+
118
+ option :prerelease,
119
+ long: '--prerelease',
120
+ description: 'Install the pre-release chef gems'
121
+
122
+ option :bootstrap_version,
123
+ long: '--bootstrap-version VERSION',
124
+ description: 'The version of Chef to install',
125
+ proc: proc { |v| Chef::Config[:knife][:bootstrap_version] = v }
126
+
127
+ option :environment,
128
+ short: '-E ENVIRONMENT',
129
+ long: '--environment ENVIRONMENT',
130
+ description: 'The name of the chef environment to use',
131
+ proc: proc { |e| Chef::Config[:knife][:environment] = e },
132
+ default: '_default'
133
+
134
+ option :json_attributes,
135
+ short: '-j JSON',
136
+ long: '--json-attributes JSON',
137
+ description: 'A JSON string to be added to the first run of chef-client',
138
+ proc: ->(o) { JSON.parse(o) }
139
+
140
+ option :private_networking,
141
+ long: '--private_networking',
142
+ description: 'Enables private networking if the selected region supports it',
143
+ default: false
144
+
145
+ option :secret_file,
146
+ long: '--secret-file SECRET_FILE',
147
+ description: 'A file containing the secret key to use to encrypt data bag item values',
148
+ proc: proc { |sf| Chef::Config[:knife][:secret_file] = sf }
149
+
150
+ option :ssh_port,
151
+ short: '-p PORT',
152
+ long: '--ssh-port PORT',
153
+ description: 'The ssh port',
154
+ default: '22',
155
+ proc: proc { |port| Chef::Config[:knife][:ssh_port] = port }
156
+
157
+ option :backups,
158
+ short: '-b',
159
+ long: '--backups-enabled',
160
+ description: 'Enables backups for the created server',
161
+ default: false
162
+
163
+ option :ipv6,
164
+ short: '-6',
165
+ long: '--ipv6-enabled',
166
+ description: 'Enables ipv6 for the created server',
167
+ default: false
168
+
169
+ def run
170
+ $stdout.sync = true
171
+
172
+ validate!
173
+
174
+ unless locate_config_value(:server_name)
175
+ ui.error('Server Name cannot be empty: -N <servername>')
176
+ exit 1
177
+ end
178
+
179
+ unless locate_config_value(:image)
180
+ ui.error('Image cannot be empty: -I <image>')
181
+ exit 1
182
+ end
183
+ =begin
184
+ unless locate_config_value(:size)
185
+ ui.error('Size cannot be empty: -S <size>')
186
+ exit 1
187
+ end
188
+
189
+ unless locate_config_value(:location)
190
+ ui.error('Location cannot be empty: -L <region>')
191
+ exit 1
192
+ end
193
+
194
+ unless locate_config_value(:ssh_key_ids)
195
+ ui.error('One or more Scaleway SSH key ids missing: -K <KEY1>, <KEY2> ...')
196
+ exit 1
197
+ end
198
+ =end
199
+ if solo_bootstrap? && !defined?(Chef::Knife::SoloBootstrap)
200
+ ui.error [
201
+ 'Knife plugin knife-solo was not found.',
202
+ 'Please add the knife-solo gem to your Gemfile or',
203
+ 'install it manually with `gem install knife-solo`.'
204
+ ].join(' ')
205
+ exit 1
206
+ end
207
+
208
+ if zero_bootstrap? && !defined?(Chef::Knife::ZeroBootstrap)
209
+ ui.error [
210
+ 'Knife plugin knife-zero was not found.',
211
+ 'Please add the knife-zero gem to your Gemfile or',
212
+ 'install it manually with `gem install knife-zero`.'
213
+ ].join(' ')
214
+ exit 1
215
+ end
216
+ =begin
217
+ server = DropletKit::Droplet.new(name: locate_config_value(:server_name),
218
+ size: locate_config_value(:size),
219
+ image: locate_config_value(:image),
220
+ region: locate_config_value(:location),
221
+ ssh_keys: locate_config_value(:ssh_key_ids),
222
+ private_networking: locate_config_value(:private_networking),
223
+ backups: locate_config_value(:backups),
224
+ ipv6: locate_config_value(:ipv6)
225
+ )
226
+ =end
227
+ server = Scaleway::Server.create(locate_config_value(:server_name), locate_config_value(:image), 'VC1S')
228
+
229
+ #server = client.servers.create(server)
230
+
231
+ if Scaleway::Server.find(server.id).state != 'stopped'
232
+ ui.error("Droplet could not be started #{server.inspect}")
233
+ exit 1
234
+ end
235
+
236
+ puts "Droplet creation for #{locate_config_value(:server_name)} started. Droplet-ID is #{server.id}"
237
+
238
+ unless !config.key?(:json_attributes) || config[:json_attributes].empty?
239
+ puts ui.color("JSON Attributes: #{config[:json_attributes]}", :magenta)
240
+ end
241
+
242
+ puts "Starting server #{server.id}"
243
+
244
+ Scaleway::Server.action(server.id, 'poweron')
245
+
246
+ print ui.color('Waiting for IPv4-Address', :magenta)
247
+ print('.') until ip_address = ip_address_available(server.id) do
248
+ puts 'done'
249
+ end
250
+
251
+ puts ui.color("IPv4 address is: #{ip_address.address}", :green)
252
+
253
+ print ui.color('Waiting for sshd:', :magenta)
254
+ print('.') until tcp_test_ssh(ip_address.address) do
255
+ sleep 2
256
+ puts 'done'
257
+ end
258
+
259
+ if locate_config_value(:bootstrap) || solo_bootstrap? || zero_bootstrap?
260
+ bootstrap_for_node(ip_address.address).run
261
+ else
262
+ puts ip_address.address
263
+ exit 0
264
+ end
265
+ end
266
+
267
+ def ip_address_available(server_id)
268
+ server = Scaleway::Server.find(server_id)
269
+ if server.public_ip
270
+ yield
271
+ server.public_ip
272
+ else
273
+ sleep @initial_sleep_delay ||= 10
274
+ false
275
+ end
276
+ end
277
+
278
+ def tcp_test_ssh(hostname)
279
+ port = Chef::Config[:knife][:ssh_port] || config[:ssh_port]
280
+ tcp_socket = TCPSocket.new(hostname, port)
281
+ readable = IO.select([tcp_socket], nil, nil, 5)
282
+ if readable
283
+ Chef::Log.debug("sshd accepting connections on #{hostname}, banner is #{tcp_socket.gets}")
284
+ yield
285
+ true
286
+ else
287
+ false
288
+ end
289
+ rescue Errno::ETIMEDOUT
290
+ false
291
+ rescue Errno::EPERM
292
+ false
293
+ rescue Errno::ECONNREFUSED
294
+ sleep 2
295
+ false
296
+ rescue Errno::EHOSTUNREACH
297
+ sleep 2
298
+ false
299
+ ensure
300
+ tcp_socket && tcp_socket.close
301
+ end
302
+
303
+ def bootstrap_for_node(ip_address)
304
+ bootstrap = bootstrap_class.new
305
+ bootstrap.name_args = [ip_address]
306
+ bootstrap.config.merge! config
307
+ bootstrap.config[:chef_node_name] = locate_config_value(:server_name)
308
+ bootstrap.config[:bootstrap_version] = locate_config_value(:bootstrap_version)
309
+ bootstrap.config[:ssh_port] = Chef::Config[:knife][:ssh_port] || config[:ssh_port]
310
+ bootstrap.config[:distro] = locate_config_value(:distro)
311
+ bootstrap.config[:use_sudo] = true unless config[:ssh_user] == 'root'
312
+ bootstrap.config[:template_file] = locate_config_value(:template_file)
313
+ bootstrap.config[:environment] = locate_config_value(:environment)
314
+ bootstrap.config[:first_boot_attributes] = locate_config_value(:json_attributes) || {}
315
+ bootstrap.config[:secret_file] = locate_config_value(:secret_file) || {}
316
+ bootstrap
317
+ end
318
+
319
+ def bootstrap_class
320
+ if solo_bootstrap?
321
+ Chef::Knife::SoloBootstrap
322
+ elsif zero_bootstrap?
323
+ Chef::Knife::ZeroBootstrap
324
+ else
325
+ Chef::Knife::Bootstrap
326
+ end
327
+ end
328
+
329
+ def solo_bootstrap?
330
+ config[:solo] || (config[:solo].nil? && Chef::Config[:knife][:solo])
331
+ end
332
+
333
+ def zero_bootstrap?
334
+ config[:zero] || (config[:zero].nil? && Chef::Config[:knife][:zero])
335
+ end
336
+ end
337
+ end
338
+ end