knife-windows 0.8.2 → 0.8.3.rc.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,34 +1,34 @@
1
- #
2
- # Author:: Chirag Jog (<chirag@clogeny.com>)
3
- # Copyright:: Copyright (c) 2013 Opscode, 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
- #
18
-
19
- require 'chef/knife'
20
- require 'chef/knife/winrm'
21
- require 'chef/knife/bootstrap_windows_ssh'
22
- require 'chef/knife/bootstrap_windows_winrm'
23
-
24
- class Chef
25
- class Knife
26
- class WindowsHelper < Knife
27
-
28
- banner "#{BootstrapWindowsWinrm.banner}\n" +
29
- "#{BootstrapWindowsSsh.banner}\n" +
30
- "#{Winrm.banner}"
31
- end
32
- end
33
- end
34
-
1
+ #
2
+ # Author:: Chirag Jog (<chirag@clogeny.com>)
3
+ # Copyright:: Copyright (c) 2013 Opscode, 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
+ #
18
+
19
+ require 'chef/knife'
20
+ require 'chef/knife/winrm'
21
+ require 'chef/knife/bootstrap_windows_ssh'
22
+ require 'chef/knife/bootstrap_windows_winrm'
23
+
24
+ class Chef
25
+ class Knife
26
+ class WindowsHelper < Knife
27
+
28
+ banner "#{BootstrapWindowsWinrm.banner}\n" +
29
+ "#{BootstrapWindowsSsh.banner}\n" +
30
+ "#{Winrm.banner}"
31
+ end
32
+ end
33
+ end
34
+
@@ -1,315 +1,315 @@
1
- #
2
- # Author:: Seth Chisamore (<schisamo@opscode.com>)
3
- # Copyright:: Copyright (c) 2011 Opscode, 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
- #
18
-
19
- require 'chef/knife'
20
- require 'chef/knife/winrm_base'
21
-
22
- class Chef
23
- class Knife
24
- class Winrm < Knife
25
-
26
- include Chef::Knife::WinrmBase
27
-
28
- deps do
29
- require 'readline'
30
- require 'chef/search/query'
31
- require 'em-winrm'
32
- end
33
-
34
- attr_writer :password
35
-
36
- banner "knife winrm QUERY COMMAND (options)"
37
-
38
- option :attribute,
39
- :short => "-a ATTR",
40
- :long => "--attribute ATTR",
41
- :description => "The attribute to use for opening the connection - default is fqdn",
42
- :default => "fqdn"
43
-
44
- option :returns,
45
- :long => "--returns CODES",
46
- :description => "A comma delimited list of return codes which indicate success",
47
- :default => "0"
48
-
49
- option :manual,
50
- :short => "-m",
51
- :long => "--manual-list",
52
- :boolean => true,
53
- :description => "QUERY is a space separated list of servers",
54
- :default => false
55
-
56
-
57
- def session
58
- session_opts = {}
59
- session_opts[:logger] = Chef::Log.logger if Chef::Log.level == :debug
60
- @session ||= begin
61
- s = EventMachine::WinRM::Session.new(session_opts)
62
- s.on_output do |host, data|
63
- print_data(host, data)
64
- end
65
- s.on_error do |host, err|
66
- print_data(host, err, :red)
67
- end
68
- s.on_command_complete do |host|
69
- host = host == :all ? 'All Servers' : host
70
- Chef::Log.debug("command complete on #{host}")
71
- end
72
- s
73
- end
74
-
75
- end
76
-
77
- def success_return_codes
78
- #Redundant if the CLI options parsing occurs
79
- return [0] unless config[:returns]
80
- return config[:returns].split(',').collect {|item| item.to_i}
81
- end
82
-
83
- # TODO: Copied from Knife::Core:GenericPresenter. Should be extracted
84
- def extract_nested_value(data, nested_value_spec)
85
- nested_value_spec.split(".").each do |attr|
86
- if data.nil?
87
- nil # don't get no method error on nil
88
- elsif data.respond_to?(attr.to_sym)
89
- data = data.send(attr.to_sym)
90
- elsif data.respond_to?(:[])
91
- data = data[attr]
92
- else
93
- data = begin
94
- data.send(attr.to_sym)
95
- rescue NoMethodError
96
- nil
97
- end
98
- end
99
- end
100
- ( !data.kind_of?(Array) && data.respond_to?(:to_hash) ) ? data.to_hash : data
101
- end
102
-
103
- def configure_session
104
-
105
- list = case config[:manual]
106
- when true
107
- @name_args[0].split(" ")
108
- when false
109
- r = Array.new
110
- q = Chef::Search::Query.new
111
- @action_nodes = q.search(:node, @name_args[0])[0]
112
- @action_nodes.each do |item|
113
- i = extract_nested_value(item, config[:attribute])
114
- r.push(i) unless i.nil?
115
- end
116
- r
117
- end
118
- if list.length == 0
119
- if @action_nodes.length == 0
120
- ui.fatal("No nodes returned from search!")
121
- else
122
- ui.fatal("#{@action_nodes.length} #{@action_nodes.length > 1 ? "nodes":"node"} found, " +
123
- "but does not have the required attribute (#{config[:attribute]}) to establish the connection. " +
124
- "Try setting another attribute to open the connection using --attribute.")
125
- end
126
- exit 10
127
- end
128
- session_from_list(list)
129
- end
130
-
131
- def session_from_list(list)
132
- list.each do |item|
133
- Chef::Log.debug("Adding #{item}")
134
- session_opts = {}
135
- session_opts[:user] = config[:winrm_user] = Chef::Config[:knife][:winrm_user] || config[:winrm_user]
136
- session_opts[:password] = config[:winrm_password] = Chef::Config[:knife][:winrm_password] || config[:winrm_password]
137
- session_opts[:port] = Chef::Config[:knife][:winrm_port] || config[:winrm_port]
138
- session_opts[:keytab] = Chef::Config[:knife][:kerberos_keytab_file] if Chef::Config[:knife][:kerberos_keytab_file]
139
- session_opts[:realm] = Chef::Config[:knife][:kerberos_realm] if Chef::Config[:knife][:kerberos_realm]
140
- session_opts[:service] = Chef::Config[:knife][:kerberos_service] if Chef::Config[:knife][:kerberos_service]
141
- session_opts[:ca_trust_path] = Chef::Config[:knife][:ca_trust_file] if Chef::Config[:knife][:ca_trust_file]
142
- session_opts[:operation_timeout] = 1800 # 30 min OperationTimeout for long bootstraps fix for KNIFE_WINDOWS-8
143
-
144
- ## If you have a \\ in your name you need to use NTLM domain authentication
145
- username_contains_domain = session_opts[:user].split("\\").length.eql?(2)
146
-
147
- if username_contains_domain
148
- # We cannot use basic_auth for domain authentication
149
- session_opts[:basic_auth_only] = false
150
- else
151
- session_opts[:basic_auth_only] = true
152
- end
153
-
154
- if config.keys.any? {|k| k.to_s =~ /kerberos/ }
155
- session_opts[:transport] = :kerberos
156
- session_opts[:basic_auth_only] = false
157
- else
158
- session_opts[:transport] = (Chef::Config[:knife][:winrm_transport] || config[:winrm_transport]).to_sym
159
-
160
- if Chef::Platform.windows? && session_opts[:transport] == :plaintext && username_contains_domain
161
- ui.warn("Switching to Negotiate authentication, Basic does not support Domain Authentication")
162
- # windows - force only encrypted communication
163
- require 'winrm-s'
164
- session_opts[:transport] = :sspinegotiate
165
- session_opts[:disable_sspi] = false
166
- else
167
- session_opts[:disable_sspi] = true
168
- end
169
- if session_opts[:user] and
170
- (not session_opts[:password])
171
- session_opts[:password] = Chef::Config[:knife][:winrm_password] = config[:winrm_password] = get_password
172
- end
173
- end
174
-
175
- session.use(item, session_opts)
176
-
177
- @longest = item.length if item.length > @longest
178
- end
179
- session
180
- end
181
-
182
- def print_data(host, data, color = :cyan)
183
- if data =~ /\n/
184
- data.split(/\n/).each { |d| print_data(host, d, color) }
185
- else
186
- padding = @longest - host.length
187
- print ui.color(host, color)
188
- padding.downto(0) { print " " }
189
- puts data.chomp
190
- end
191
- end
192
-
193
- def winrm_command(command, subsession=nil)
194
- subsession ||= session
195
- subsession.relay_command(command)
196
- end
197
-
198
- def get_password
199
- @password ||= ui.ask("Enter your password: ") { |q| q.echo = false }
200
- end
201
-
202
- # Present the prompt and read a single line from the console. It also
203
- # detects ^D and returns "exit" in that case. Adds the input to the
204
- # history, unless the input is empty. Loops repeatedly until a non-empty
205
- # line is input.
206
- def read_line
207
- loop do
208
- command = reader.readline("#{ui.color('knife-winrm>', :bold)} ", true)
209
-
210
- if command.nil?
211
- command = "exit"
212
- puts(command)
213
- else
214
- command.strip!
215
- end
216
-
217
- unless command.empty?
218
- return command
219
- end
220
- end
221
- end
222
-
223
- def reader
224
- Readline
225
- end
226
-
227
- def interactive
228
- puts "Connected to #{ui.list(session.servers.collect { |s| ui.color(s.host, :cyan) }, :inline, " and ")}"
229
- puts
230
- puts "To run a command on a list of servers, do:"
231
- puts " on SERVER1 SERVER2 SERVER3; COMMAND"
232
- puts " Example: on latte foamy; echo foobar"
233
- puts
234
- puts "To exit interactive mode, use 'quit!'"
235
- puts
236
- while 1
237
- command = read_line
238
- case command
239
- when 'quit!'
240
- puts 'Bye!'
241
- session.close
242
- break
243
- when /^on (.+?); (.+)$/
244
- raw_list = $1.split(" ")
245
- server_list = Array.new
246
- session.servers.each do |session_server|
247
- server_list << session_server if raw_list.include?(session_server.host)
248
- end
249
- command = $2
250
- winrm_command(command, session.on(*server_list))
251
- else
252
- winrm_command(command)
253
- end
254
- end
255
- end
256
-
257
- def check_for_errors!(exit_codes)
258
-
259
- exit_codes.each do |host, value|
260
- Chef::Log.debug("Exit code found: #{value}")
261
- unless success_return_codes.include? value.to_i
262
- @exit_code = value.to_i
263
- ui.error "Failed to execute command on #{host} return code #{value}"
264
- end
265
- end
266
-
267
- end
268
-
269
- def run
270
-
271
- STDOUT.sync = STDERR.sync = true
272
-
273
- begin
274
- @longest = 0
275
-
276
- configure_session
277
-
278
- case @name_args[1]
279
- when "interactive"
280
- interactive
281
- else
282
- winrm_command(@name_args[1..-1].join(" "))
283
-
284
- if config[:returns]
285
- check_for_errors! session.exit_codes
286
- end
287
-
288
- session.close
289
-
290
- # Knife seems to ignore the return value of this method,
291
- # so we exit to force the process exit code for this
292
- # subcommand if returns is set
293
- exit @exit_code if @exit_code && @exit_code != 0
294
- @exit_code || 0
295
- end
296
- rescue WinRM::WinRMHTTPTransportError => e
297
- case e.message
298
- when /401/
299
- if ! config[:suppress_auth_failure]
300
- # Display errors if the caller hasn't opted to retry
301
- ui.error "Failed to authenticate to #{@name_args[0].split(" ")} as #{config[:winrm_user]}"
302
- ui.info "Response: #{e.message}"
303
- raise e
304
- end
305
- @exit_code = 401
306
- else
307
- raise e
308
- end
309
- end
310
- end
311
-
312
- end
313
- end
314
- end
315
-
1
+ #
2
+ # Author:: Seth Chisamore (<schisamo@opscode.com>)
3
+ # Copyright:: Copyright (c) 2011 Opscode, 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
+ #
18
+
19
+ require 'chef/knife'
20
+ require 'chef/knife/winrm_base'
21
+
22
+ class Chef
23
+ class Knife
24
+ class Winrm < Knife
25
+
26
+ include Chef::Knife::WinrmBase
27
+
28
+ deps do
29
+ require 'readline'
30
+ require 'chef/search/query'
31
+ require 'em-winrm'
32
+ end
33
+
34
+ attr_writer :password
35
+
36
+ banner "knife winrm QUERY COMMAND (options)"
37
+
38
+ option :attribute,
39
+ :short => "-a ATTR",
40
+ :long => "--attribute ATTR",
41
+ :description => "The attribute to use for opening the connection - default is fqdn",
42
+ :default => "fqdn"
43
+
44
+ option :returns,
45
+ :long => "--returns CODES",
46
+ :description => "A comma delimited list of return codes which indicate success",
47
+ :default => "0"
48
+
49
+ option :manual,
50
+ :short => "-m",
51
+ :long => "--manual-list",
52
+ :boolean => true,
53
+ :description => "QUERY is a space separated list of servers",
54
+ :default => false
55
+
56
+
57
+ def session
58
+ session_opts = {}
59
+ session_opts[:logger] = Chef::Log.logger if Chef::Log.level == :debug
60
+ @session ||= begin
61
+ s = EventMachine::WinRM::Session.new(session_opts)
62
+ s.on_output do |host, data|
63
+ print_data(host, data)
64
+ end
65
+ s.on_error do |host, err|
66
+ print_data(host, err, :red)
67
+ end
68
+ s.on_command_complete do |host|
69
+ host = host == :all ? 'All Servers' : host
70
+ Chef::Log.debug("command complete on #{host}")
71
+ end
72
+ s
73
+ end
74
+
75
+ end
76
+
77
+ def success_return_codes
78
+ #Redundant if the CLI options parsing occurs
79
+ return [0] unless config[:returns]
80
+ return config[:returns].split(',').collect {|item| item.to_i}
81
+ end
82
+
83
+ # TODO: Copied from Knife::Core:GenericPresenter. Should be extracted
84
+ def extract_nested_value(data, nested_value_spec)
85
+ nested_value_spec.split(".").each do |attr|
86
+ if data.nil?
87
+ nil # don't get no method error on nil
88
+ elsif data.respond_to?(attr.to_sym)
89
+ data = data.send(attr.to_sym)
90
+ elsif data.respond_to?(:[])
91
+ data = data[attr]
92
+ else
93
+ data = begin
94
+ data.send(attr.to_sym)
95
+ rescue NoMethodError
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ ( !data.kind_of?(Array) && data.respond_to?(:to_hash) ) ? data.to_hash : data
101
+ end
102
+
103
+ def configure_session
104
+
105
+ list = case config[:manual]
106
+ when true
107
+ @name_args[0].split(" ")
108
+ when false
109
+ r = Array.new
110
+ q = Chef::Search::Query.new
111
+ @action_nodes = q.search(:node, @name_args[0])[0]
112
+ @action_nodes.each do |item|
113
+ i = extract_nested_value(item, config[:attribute])
114
+ r.push(i) unless i.nil?
115
+ end
116
+ r
117
+ end
118
+ if list.length == 0
119
+ if @action_nodes.length == 0
120
+ ui.fatal("No nodes returned from search!")
121
+ else
122
+ ui.fatal("#{@action_nodes.length} #{@action_nodes.length > 1 ? "nodes":"node"} found, " +
123
+ "but does not have the required attribute (#{config[:attribute]}) to establish the connection. " +
124
+ "Try setting another attribute to open the connection using --attribute.")
125
+ end
126
+ exit 10
127
+ end
128
+ session_from_list(list)
129
+ end
130
+
131
+ def session_from_list(list)
132
+ list.each do |item|
133
+ Chef::Log.debug("Adding #{item}")
134
+ session_opts = {}
135
+ session_opts[:user] = config[:winrm_user] = Chef::Config[:knife][:winrm_user] || config[:winrm_user]
136
+ session_opts[:password] = config[:winrm_password] = Chef::Config[:knife][:winrm_password] || config[:winrm_password]
137
+ session_opts[:port] = Chef::Config[:knife][:winrm_port] || config[:winrm_port]
138
+ session_opts[:keytab] = Chef::Config[:knife][:kerberos_keytab_file] if Chef::Config[:knife][:kerberos_keytab_file]
139
+ session_opts[:realm] = Chef::Config[:knife][:kerberos_realm] if Chef::Config[:knife][:kerberos_realm]
140
+ session_opts[:service] = Chef::Config[:knife][:kerberos_service] if Chef::Config[:knife][:kerberos_service]
141
+ session_opts[:ca_trust_path] = Chef::Config[:knife][:ca_trust_file] if Chef::Config[:knife][:ca_trust_file]
142
+ session_opts[:operation_timeout] = 1800 # 30 min OperationTimeout for long bootstraps fix for KNIFE_WINDOWS-8
143
+
144
+ ## If you have a \\ in your name you need to use NTLM domain authentication
145
+ username_contains_domain = session_opts[:user].split("\\").length.eql?(2)
146
+
147
+ if username_contains_domain
148
+ # We cannot use basic_auth for domain authentication
149
+ session_opts[:basic_auth_only] = false
150
+ else
151
+ session_opts[:basic_auth_only] = true
152
+ end
153
+
154
+ if config.keys.any? {|k| k.to_s =~ /kerberos/ }
155
+ session_opts[:transport] = :kerberos
156
+ session_opts[:basic_auth_only] = false
157
+ else
158
+ session_opts[:transport] = (Chef::Config[:knife][:winrm_transport] || config[:winrm_transport]).to_sym
159
+
160
+ if Chef::Platform.windows? && session_opts[:transport] == :plaintext && username_contains_domain
161
+ ui.warn("Switching to Negotiate authentication, Basic does not support Domain Authentication")
162
+ # windows - force only encrypted communication
163
+ require 'winrm-s'
164
+ session_opts[:transport] = :sspinegotiate
165
+ session_opts[:disable_sspi] = false
166
+ else
167
+ session_opts[:disable_sspi] = true
168
+ end
169
+ if session_opts[:user] and
170
+ (not session_opts[:password])
171
+ session_opts[:password] = Chef::Config[:knife][:winrm_password] = config[:winrm_password] = get_password
172
+ end
173
+ end
174
+
175
+ session.use(item, session_opts)
176
+
177
+ @longest = item.length if item.length > @longest
178
+ end
179
+ session
180
+ end
181
+
182
+ def print_data(host, data, color = :cyan)
183
+ if data =~ /\n/
184
+ data.split(/\n/).each { |d| print_data(host, d, color) }
185
+ else
186
+ padding = @longest - host.length
187
+ print ui.color(host, color)
188
+ padding.downto(0) { print " " }
189
+ puts data.chomp
190
+ end
191
+ end
192
+
193
+ def winrm_command(command, subsession=nil)
194
+ subsession ||= session
195
+ subsession.relay_command(command)
196
+ end
197
+
198
+ def get_password
199
+ @password ||= ui.ask("Enter your password: ") { |q| q.echo = false }
200
+ end
201
+
202
+ # Present the prompt and read a single line from the console. It also
203
+ # detects ^D and returns "exit" in that case. Adds the input to the
204
+ # history, unless the input is empty. Loops repeatedly until a non-empty
205
+ # line is input.
206
+ def read_line
207
+ loop do
208
+ command = reader.readline("#{ui.color('knife-winrm>', :bold)} ", true)
209
+
210
+ if command.nil?
211
+ command = "exit"
212
+ puts(command)
213
+ else
214
+ command.strip!
215
+ end
216
+
217
+ unless command.empty?
218
+ return command
219
+ end
220
+ end
221
+ end
222
+
223
+ def reader
224
+ Readline
225
+ end
226
+
227
+ def interactive
228
+ puts "Connected to #{ui.list(session.servers.collect { |s| ui.color(s.host, :cyan) }, :inline, " and ")}"
229
+ puts
230
+ puts "To run a command on a list of servers, do:"
231
+ puts " on SERVER1 SERVER2 SERVER3; COMMAND"
232
+ puts " Example: on latte foamy; echo foobar"
233
+ puts
234
+ puts "To exit interactive mode, use 'quit!'"
235
+ puts
236
+ while 1
237
+ command = read_line
238
+ case command
239
+ when 'quit!'
240
+ puts 'Bye!'
241
+ session.close
242
+ break
243
+ when /^on (.+?); (.+)$/
244
+ raw_list = $1.split(" ")
245
+ server_list = Array.new
246
+ session.servers.each do |session_server|
247
+ server_list << session_server if raw_list.include?(session_server.host)
248
+ end
249
+ command = $2
250
+ winrm_command(command, session.on(*server_list))
251
+ else
252
+ winrm_command(command)
253
+ end
254
+ end
255
+ end
256
+
257
+ def check_for_errors!(exit_codes)
258
+
259
+ exit_codes.each do |host, value|
260
+ Chef::Log.debug("Exit code found: #{value}")
261
+ unless success_return_codes.include? value.to_i
262
+ @exit_code = value.to_i
263
+ ui.error "Failed to execute command on #{host} return code #{value}"
264
+ end
265
+ end
266
+
267
+ end
268
+
269
+ def run
270
+
271
+ STDOUT.sync = STDERR.sync = true
272
+
273
+ begin
274
+ @longest = 0
275
+
276
+ configure_session
277
+
278
+ case @name_args[1]
279
+ when "interactive"
280
+ interactive
281
+ else
282
+ winrm_command(@name_args[1..-1].join(" "))
283
+
284
+ if config[:returns]
285
+ check_for_errors! session.exit_codes
286
+ end
287
+
288
+ session.close
289
+
290
+ # Knife seems to ignore the return value of this method,
291
+ # so we exit to force the process exit code for this
292
+ # subcommand if returns is set
293
+ exit @exit_code if @exit_code && @exit_code != 0
294
+ @exit_code || 0
295
+ end
296
+ rescue WinRM::WinRMHTTPTransportError => e
297
+ case e.message
298
+ when /401/
299
+ if ! config[:suppress_auth_failure]
300
+ # Display errors if the caller hasn't opted to retry
301
+ ui.error "Failed to authenticate to #{@name_args[0].split(" ")} as #{config[:winrm_user]}"
302
+ ui.info "Response: #{e.message}"
303
+ raise e
304
+ end
305
+ @exit_code = 401
306
+ else
307
+ raise e
308
+ end
309
+ end
310
+ end
311
+
312
+ end
313
+ end
314
+ end
315
+