knife-essentials 0.9.2 → 0.9.3
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/chef/knife/raw_essentials.rb +3 -61
- data/lib/chef_fs/data_handler/data_handler_base.rb +2 -1
- data/lib/chef_fs/file_system/base_fs_object.rb +75 -1
- data/lib/chef_fs/file_system/chef_repository_file_system_entry.rb +23 -0
- data/lib/chef_fs/file_system/cookbook_dir.rb +27 -13
- data/lib/chef_fs/file_system/cookbooks_dir.rb +89 -30
- data/lib/chef_fs/file_system/data_bags_dir.rb +5 -1
- data/lib/chef_fs/file_system/nodes_dir.rb +8 -14
- data/lib/chef_fs/file_system/rest_list_dir.rb +6 -2
- data/lib/chef_fs/file_system/rest_list_entry.rb +15 -3
- data/lib/chef_fs/knife.rb +28 -17
- data/lib/chef_fs/version.rb +1 -1
- data/spec/chef_fs/file_system/chef_server_root_dir_spec.rb +26 -12
- data/spec/chef_fs/file_system/cookbook_dir_spec.rb +582 -0
- data/spec/chef_fs/file_system/cookbooks_dir_spec.rb +81 -489
- data/spec/chef_fs/file_system/data_bags_dir_spec.rb +63 -49
- data/spec/integration/deps_spec.rb +32 -32
- data/spec/integration/diff_spec.rb +425 -133
- data/spec/integration/download_spec.rb +743 -211
- data/spec/integration/upload_spec.rb +814 -244
- data/spec/support/integration_helper.rb +33 -6
- data/spec/support/knife_support.rb +4 -2
- metadata +19 -2
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'json'
|
2
|
+
require 'chef_fs/data_handler/data_handler_base'
|
2
3
|
|
3
4
|
class Chef
|
4
5
|
class Knife
|
@@ -41,7 +42,7 @@ class Chef
|
|
41
42
|
end
|
42
43
|
chef_rest = Chef::REST.new(Chef::Config[:chef_server_url])
|
43
44
|
begin
|
44
|
-
output api_request(chef_rest, config[:method].to_sym, chef_rest.create_url(name_args[0]), {}, data)
|
45
|
+
output ChefFS::FileSystem::BaseFSObject.api_request(chef_rest, config[:method].to_sym, chef_rest.create_url(name_args[0]), {}, data)
|
45
46
|
rescue Net::HTTPServerException => e
|
46
47
|
ui.error "Server responded with error #{e.response.code} \"#{e.response.message}\""
|
47
48
|
ui.error "Error Body: #{e.response.body}" if e.response.body && e.response.body != ''
|
@@ -49,66 +50,7 @@ class Chef
|
|
49
50
|
end
|
50
51
|
end
|
51
52
|
|
52
|
-
|
53
|
-
ENCODING_GZIP_DEFLATE = "gzip;q=1.0,deflate;q=0.6,identity;q=0.3".freeze
|
54
|
-
|
55
|
-
def redirected_to(response)
|
56
|
-
return nil unless response.kind_of?(Net::HTTPRedirection)
|
57
|
-
# Net::HTTPNotModified is undesired subclass of Net::HTTPRedirection so test for this
|
58
|
-
return nil if response.kind_of?(Net::HTTPNotModified)
|
59
|
-
response['location']
|
60
|
-
end
|
61
|
-
|
62
|
-
def api_request(chef_rest, method, url, headers={}, data=false)
|
63
|
-
json_body = data
|
64
|
-
# json_body = data ? Chef::JSONCompat.to_json(data) : nil
|
65
|
-
# Force encoding to binary to fix SSL related EOFErrors
|
66
|
-
# cf. http://tickets.opscode.com/browse/CHEF-2363
|
67
|
-
# http://redmine.ruby-lang.org/issues/5233
|
68
|
-
# json_body.force_encoding(Encoding::BINARY) if json_body.respond_to?(:force_encoding)
|
69
|
-
headers = build_headers(chef_rest, method, url, headers, json_body)
|
70
|
-
|
71
|
-
chef_rest.retriable_rest_request(method, url, json_body, headers) do |rest_request|
|
72
|
-
response = rest_request.call {|r| r.read_body}
|
73
|
-
|
74
|
-
response_body = chef_rest.decompress_body(response)
|
75
|
-
|
76
|
-
if response.kind_of?(Net::HTTPSuccess)
|
77
|
-
if config[:pretty] && response['content-type'] =~ /json/
|
78
|
-
JSON.pretty_generate(JSON.parse(response_body, :create_additions => false))
|
79
|
-
else
|
80
|
-
response_body
|
81
|
-
end
|
82
|
-
elsif redirect_location = redirected_to(response)
|
83
|
-
raise "Redirected to #{create_url(redirect_location)}"
|
84
|
-
follow_redirect {api_request(:GET, create_url(redirect_location))}
|
85
|
-
else
|
86
|
-
# have to decompress the body before making an exception for it. But the body could be nil.
|
87
|
-
response.body.replace(chef_rest.decompress_body(response)) if response.body.respond_to?(:replace)
|
88
|
-
|
89
|
-
if response['content-type'] =~ /json/
|
90
|
-
exception = response_body
|
91
|
-
msg = "HTTP Request Returned #{response.code} #{response.message}: "
|
92
|
-
msg << (exception["error"].respond_to?(:join) ? exception["error"].join(", ") : exception["error"].to_s)
|
93
|
-
Chef::Log.info(msg)
|
94
|
-
end
|
95
|
-
response.error!
|
96
|
-
end
|
97
|
-
end
|
98
|
-
end
|
99
|
-
|
100
|
-
def build_headers(chef_rest, method, url, headers={}, json_body=false, raw=false)
|
101
|
-
# headers = @default_headers.merge(headers)
|
102
|
-
#headers['Accept'] = "application/json" unless raw
|
103
|
-
headers['Accept'] = "application/json" unless raw
|
104
|
-
headers["Content-Type"] = 'application/json' if json_body
|
105
|
-
headers['Content-Length'] = json_body.bytesize.to_s if json_body
|
106
|
-
headers[Chef::REST::RESTRequest::ACCEPT_ENCODING] = Chef::REST::RESTRequest::ENCODING_GZIP_DEFLATE
|
107
|
-
headers.merge!(chef_rest.authentication_headers(method, url, json_body)) if chef_rest.sign_requests?
|
108
|
-
headers.merge!(Chef::Config[:custom_http_headers]) if Chef::Config[:custom_http_headers]
|
109
|
-
headers
|
110
|
-
end
|
111
|
-
end
|
53
|
+
end # class Raw
|
112
54
|
end
|
113
55
|
end
|
114
56
|
|
@@ -103,6 +103,11 @@ module ChefFS
|
|
103
103
|
[]
|
104
104
|
end
|
105
105
|
|
106
|
+
def chef_hash
|
107
|
+
raise NotFoundError.new(self) if !exists?
|
108
|
+
nil
|
109
|
+
end
|
110
|
+
|
106
111
|
# Expand this entry into a chef object (Chef::Role, ::Node, etc.)
|
107
112
|
def chef_object
|
108
113
|
raise NotFoundError.new(self) if !exists?
|
@@ -171,7 +176,76 @@ module ChefFS
|
|
171
176
|
# Important directory attributes: name, parent, path, root
|
172
177
|
# Overridable attributes: dir?, child(name), path_for_printing
|
173
178
|
# Abstract: read, write, delete, children, can_have_child?, create_child, compare_to
|
174
|
-
|
179
|
+
|
180
|
+
# Consider putting this into a concern module and including it instead
|
181
|
+
def raw_request(_api_path)
|
182
|
+
self.class.api_request(rest, :GET, rest.create_url(_api_path), {}, false)
|
183
|
+
end
|
184
|
+
|
185
|
+
|
186
|
+
class << self
|
187
|
+
# Copied so that it does not automatically inflate an object
|
188
|
+
# This is also used by knife raw_essentials
|
189
|
+
|
190
|
+
ACCEPT_ENCODING = "Accept-Encoding".freeze
|
191
|
+
ENCODING_GZIP_DEFLATE = "gzip;q=1.0,deflate;q=0.6,identity;q=0.3".freeze
|
192
|
+
|
193
|
+
def redirected_to(response)
|
194
|
+
return nil unless response.kind_of?(Net::HTTPRedirection)
|
195
|
+
# Net::HTTPNotModified is undesired subclass of Net::HTTPRedirection so test for this
|
196
|
+
return nil if response.kind_of?(Net::HTTPNotModified)
|
197
|
+
response['location']
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
def build_headers(chef_rest, method, url, headers={}, json_body=false, raw=false)
|
202
|
+
# headers = @default_headers.merge(headers)
|
203
|
+
#headers['Accept'] = "application/json" unless raw
|
204
|
+
headers['Accept'] = "application/json" unless raw
|
205
|
+
headers["Content-Type"] = 'application/json' if json_body
|
206
|
+
headers['Content-Length'] = json_body.bytesize.to_s if json_body
|
207
|
+
headers[Chef::REST::RESTRequest::ACCEPT_ENCODING] = Chef::REST::RESTRequest::ENCODING_GZIP_DEFLATE
|
208
|
+
headers.merge!(chef_rest.authentication_headers(method, url, json_body)) if chef_rest.sign_requests?
|
209
|
+
headers.merge!(Chef::Config[:custom_http_headers]) if Chef::Config[:custom_http_headers]
|
210
|
+
headers
|
211
|
+
end
|
212
|
+
|
213
|
+
def api_request(chef_rest, method, url, headers={}, data=false)
|
214
|
+
json_body = data
|
215
|
+
# json_body = data ? Chef::JSONCompat.to_json(data) : nil
|
216
|
+
# Force encoding to binary to fix SSL related EOFErrors
|
217
|
+
# cf. http://tickets.opscode.com/browse/CHEF-2363
|
218
|
+
# http://redmine.ruby-lang.org/issues/5233
|
219
|
+
# json_body.force_encoding(Encoding::BINARY) if json_body.respond_to?(:force_encoding)
|
220
|
+
headers = build_headers(chef_rest, method, url, headers, json_body)
|
221
|
+
|
222
|
+
chef_rest.retriable_rest_request(method, url, json_body, headers) do |rest_request|
|
223
|
+
response = rest_request.call {|r| r.read_body}
|
224
|
+
|
225
|
+
response_body = chef_rest.decompress_body(response)
|
226
|
+
|
227
|
+
if response.kind_of?(Net::HTTPSuccess)
|
228
|
+
response_body
|
229
|
+
elsif redirect_location = redirected_to(response)
|
230
|
+
raise "Redirected to #{create_url(redirect_location)}"
|
231
|
+
follow_redirect {api_request(:GET, create_url(redirect_location))}
|
232
|
+
else
|
233
|
+
# have to decompress the body before making an exception for it. But the body could be nil.
|
234
|
+
response.body.replace(chef_rest.decompress_body(response)) if response.body.respond_to?(:replace)
|
235
|
+
|
236
|
+
if response['content-type'] =~ /json/
|
237
|
+
exception = response_body
|
238
|
+
msg = "HTTP Request Returned #{response.code} #{response.message}: "
|
239
|
+
msg << (exception["error"].respond_to?(:join) ? exception["error"].join(", ") : exception["error"].to_s)
|
240
|
+
Chef::Log.info(msg)
|
241
|
+
end
|
242
|
+
response.error!
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
end # class BaseFsObject
|
175
249
|
end
|
176
250
|
end
|
177
251
|
|
@@ -1,5 +1,6 @@
|
|
1
1
|
#
|
2
2
|
# Author:: John Keiser (<jkeiser@opscode.com>)
|
3
|
+
# Author:: Ho-Sheng Hsiao (<hosh@opscode.com>)
|
3
4
|
# Copyright:: Copyright (c) 2012 Opscode, Inc.
|
4
5
|
# License:: Apache License, Version 2.0
|
5
6
|
#
|
@@ -45,6 +46,17 @@ module ChefFS
|
|
45
46
|
begin
|
46
47
|
if parent.path == '/cookbooks'
|
47
48
|
loader = Chef::Cookbook::CookbookVersionLoader.new(file_path, parent.chefignore)
|
49
|
+
# We need the canonical cookbook name if we are using versioned cookbooks, but we don't
|
50
|
+
# want to spend a lot of time adding code to the main Chef libraries
|
51
|
+
if Chef::Config[:versioned_cookbooks]
|
52
|
+
|
53
|
+
_canonical_name = canonical_cookbook_name(File.basename(file_path))
|
54
|
+
fail "When versioned_cookbooks mode is on, cookbook #{file_path} must match format <cookbook_name>-x.y.z" unless _canonical_name
|
55
|
+
|
56
|
+
# KLUDGE: We shouldn't have to use instance_variable_set
|
57
|
+
loader.instance_variable_set(:@cookbook_name, _canonical_name)
|
58
|
+
end
|
59
|
+
|
48
60
|
loader.load_cookbooks
|
49
61
|
return loader.cookbook_version
|
50
62
|
end
|
@@ -57,6 +69,17 @@ module ChefFS
|
|
57
69
|
nil
|
58
70
|
end
|
59
71
|
|
72
|
+
# Exposed as a class method so that it can be used elsewhere
|
73
|
+
def self.canonical_cookbook_name(entry_name)
|
74
|
+
name_match = ChefFS::FileSystem::CookbookDir::VALID_VERSIONED_COOKBOOK_NAME.match(entry_name)
|
75
|
+
return nil if name_match.nil?
|
76
|
+
return name_match[1]
|
77
|
+
end
|
78
|
+
|
79
|
+
def canonical_cookbook_name(entry_name)
|
80
|
+
self.class.canonical_cookbook_name(entry_name)
|
81
|
+
end
|
82
|
+
|
60
83
|
def children
|
61
84
|
@children ||=
|
62
85
|
Dir.entries(file_path).
|
@@ -26,12 +26,24 @@ require 'chef/cookbook_uploader'
|
|
26
26
|
module ChefFS
|
27
27
|
module FileSystem
|
28
28
|
class CookbookDir < BaseFSDir
|
29
|
-
def initialize(name, parent,
|
29
|
+
def initialize(name, parent, options = {})
|
30
30
|
super(name, parent)
|
31
|
-
@
|
31
|
+
@exists = options[:exists]
|
32
|
+
# If the name is apache2-1.0.0 and versioned_cookbooks is on, we know
|
33
|
+
# the actual cookbook_name and version.
|
34
|
+
if Chef::Config[:versioned_cookbooks]
|
35
|
+
if name =~ VALID_VERSIONED_COOKBOOK_NAME
|
36
|
+
@cookbook_name = $1
|
37
|
+
@version = $2
|
38
|
+
else
|
39
|
+
@exists = false
|
40
|
+
end
|
41
|
+
else
|
42
|
+
@cookbook_name = name
|
43
|
+
end
|
32
44
|
end
|
33
45
|
|
34
|
-
attr_reader :
|
46
|
+
attr_reader :cookbook_name, :version
|
35
47
|
|
36
48
|
COOKBOOK_SEGMENT_INFO = {
|
37
49
|
:attributes => { :ruby_only => true },
|
@@ -45,12 +57,16 @@ module ChefFS
|
|
45
57
|
:root_files => { }
|
46
58
|
}
|
47
59
|
|
60
|
+
# See Erchef code
|
61
|
+
# https://github.com/opscode/chef_objects/blob/968a63344d38fd507f6ace05f73d53e9cd7fb043/src/chef_regex.erl#L94
|
62
|
+
VALID_VERSIONED_COOKBOOK_NAME = /^([.a-zA-Z0-9_-]+)-(\d+\.\d+\.\d+)$/
|
63
|
+
|
48
64
|
def add_child(child)
|
49
65
|
@children << child
|
50
66
|
end
|
51
67
|
|
52
68
|
def api_path
|
53
|
-
"#{parent.api_path}/#{
|
69
|
+
"#{parent.api_path}/#{cookbook_name}/#{version || "_latest"}"
|
54
70
|
end
|
55
71
|
|
56
72
|
def child(name)
|
@@ -67,11 +83,8 @@ module ChefFS
|
|
67
83
|
|
68
84
|
def can_have_child?(name, is_dir)
|
69
85
|
# A cookbook's root may not have directories unless they are segment directories
|
70
|
-
if is_dir
|
71
|
-
|
72
|
-
COOKBOOK_SEGMENT_INFO.keys.any? { |segment| segment.to_s == name }
|
73
|
-
end
|
74
|
-
true
|
86
|
+
return name != 'root_files' && COOKBOOK_SEGMENT_INFO.keys.include?(name.to_sym) if is_dir
|
87
|
+
return true
|
75
88
|
end
|
76
89
|
|
77
90
|
def children
|
@@ -123,12 +136,13 @@ module ChefFS
|
|
123
136
|
end
|
124
137
|
end
|
125
138
|
|
139
|
+
# In versioned cookbook mode, actually check if the version exists
|
140
|
+
# Probably want to cache this.
|
126
141
|
def exists?
|
127
|
-
if
|
128
|
-
|
129
|
-
@versions = child.versions if child
|
142
|
+
if @exists.nil?
|
143
|
+
@exists = parent.children.any? { |child| child.name == name }
|
130
144
|
end
|
131
|
-
|
145
|
+
@exists
|
132
146
|
end
|
133
147
|
|
134
148
|
def compare_to(other)
|
@@ -19,6 +19,8 @@
|
|
19
19
|
require 'chef_fs/file_system/rest_list_dir'
|
20
20
|
require 'chef_fs/file_system/cookbook_dir'
|
21
21
|
|
22
|
+
require 'tmpdir'
|
23
|
+
|
22
24
|
module ChefFS
|
23
25
|
module FileSystem
|
24
26
|
class CookbooksDir < RestListDir
|
@@ -27,12 +29,32 @@ module ChefFS
|
|
27
29
|
end
|
28
30
|
|
29
31
|
def child(name)
|
30
|
-
|
31
|
-
|
32
|
+
if @children
|
33
|
+
result = self.children.select { |child| child.name == name }.first
|
34
|
+
if result
|
35
|
+
result
|
36
|
+
else
|
37
|
+
NonexistentFSObject.new(name, self)
|
38
|
+
end
|
39
|
+
else
|
40
|
+
CookbookDir.new(name, self)
|
41
|
+
end
|
32
42
|
end
|
33
43
|
|
34
44
|
def children
|
35
|
-
@children ||=
|
45
|
+
@children ||= begin
|
46
|
+
if Chef::Config[:versioned_cookbooks]
|
47
|
+
result = []
|
48
|
+
rest.get_rest("#{api_path}/?num_versions=all").each_pair do |cookbook_name, cookbooks|
|
49
|
+
cookbooks['versions'].each do |cookbook_version|
|
50
|
+
result << CookbookDir.new("#{cookbook_name}-#{cookbook_version['version']}", self, :exists => true)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
else
|
54
|
+
result = rest.get_rest(api_path).keys.map { |cookbook_name| CookbookDir.new(cookbook_name, self, :exists => true) }
|
55
|
+
end
|
56
|
+
result.sort_by(&:name)
|
57
|
+
end
|
36
58
|
end
|
37
59
|
|
38
60
|
def create_child_from(other)
|
@@ -40,38 +62,75 @@ module ChefFS
|
|
40
62
|
end
|
41
63
|
|
42
64
|
def upload_cookbook_from(other)
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
65
|
+
Chef::Config[:versioned_cookbooks] ? upload_versioned_cookbook(other) : upload_unversioned_cookbook(other)
|
66
|
+
rescue Net::HTTPServerException => e
|
67
|
+
case e.response.code
|
68
|
+
when "409"
|
69
|
+
ui.error "Version #{other_cookbook_version.version} of cookbook #{other_cookbook_version.name} is frozen. Use --force to override."
|
70
|
+
Chef::Log.debug(e)
|
71
|
+
raise Exceptions::CookbookFrozen
|
72
|
+
else
|
73
|
+
raise
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Knife currently does not understand versioned cookbooks
|
78
|
+
# Cookbook Version uploader also requires a lot of refactoring
|
79
|
+
# to make this work. So instead, we make a temporary cookbook
|
80
|
+
# symlinking back to real cookbook, and upload the proxy.
|
81
|
+
def upload_versioned_cookbook(other)
|
82
|
+
cookbook_name = ChefFS::FileSystem::ChefRepositoryFileSystemEntry.canonical_cookbook_name(other.name)
|
83
|
+
|
84
|
+
Dir.mktmpdir do |temp_cookbooks_path|
|
85
|
+
proxy_cookbook_path = "#{temp_cookbooks_path}/#{cookbook_name}"
|
86
|
+
|
87
|
+
# Make a symlink
|
88
|
+
File.symlink other.file_path, proxy_cookbook_path
|
89
|
+
|
90
|
+
# Instantiate a proxy loader using the temporary symlink
|
91
|
+
proxy_loader = Chef::Cookbook::CookbookVersionLoader.new(proxy_cookbook_path, other.parent.chefignore)
|
92
|
+
proxy_loader.load_cookbooks
|
93
|
+
|
94
|
+
# Instantiate a new uploader based on the proxy loader
|
95
|
+
uploader = Chef::CookbookUploader.new(proxy_loader.cookbook_version, proxy_cookbook_path, :rest => rest)
|
96
|
+
|
97
|
+
with_actual_cookbooks_dir(temp_cookbooks_path) do
|
98
|
+
upload_cookbook!(uploader)
|
69
99
|
end
|
70
100
|
end
|
71
101
|
end
|
72
102
|
|
103
|
+
def upload_unversioned_cookbook(other)
|
104
|
+
uploader = Chef::CookbookUploader.new(other.chef_object, other.parent.file_path, :rest => rest)
|
105
|
+
|
106
|
+
with_actual_cookbooks_dir(other.parent.file_path) do
|
107
|
+
upload_cookbook!(uploader)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Work around the fact that CookbookUploader doesn't understand chef_repo_path (yet)
|
112
|
+
def with_actual_cookbooks_dir(actual_cookbook_path)
|
113
|
+
old_cookbook_path = Chef::Config.cookbook_path
|
114
|
+
Chef::Config.cookbook_path = actual_cookbook_path if !Chef::Config.cookbook_path
|
115
|
+
|
116
|
+
yield
|
117
|
+
ensure
|
118
|
+
Chef::Config.cookbook_path = old_cookbook_path
|
119
|
+
end
|
120
|
+
|
121
|
+
# Chef 11 changes this API
|
122
|
+
def upload_cookbook!(uploader)
|
123
|
+
if uploader.respond_to?(:upload_cookbook)
|
124
|
+
uploader.upload_cookbook
|
125
|
+
else
|
126
|
+
uploader.upload_cookbooks
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
73
130
|
def can_have_child?(name, is_dir)
|
74
|
-
is_dir
|
131
|
+
return false if !is_dir
|
132
|
+
return false if Chef::Config[:versioned_cookbooks] && name !~ ChefFS::FileSystem::CookbookDir::VALID_VERSIONED_COOKBOOK_NAME
|
133
|
+
return true
|
75
134
|
end
|
76
135
|
end
|
77
136
|
end
|
@@ -33,7 +33,7 @@ module ChefFS
|
|
33
33
|
|
34
34
|
def children
|
35
35
|
begin
|
36
|
-
@children ||=
|
36
|
+
@children ||= chef_collection.keys.sort.map do |entry|
|
37
37
|
DataBagDir.new(entry, self, true)
|
38
38
|
end
|
39
39
|
rescue Net::HTTPServerException
|
@@ -45,6 +45,10 @@ module ChefFS
|
|
45
45
|
end
|
46
46
|
end
|
47
47
|
|
48
|
+
def chef_collection
|
49
|
+
rest.get_rest(api_path)
|
50
|
+
end
|
51
|
+
|
48
52
|
def can_have_child?(name, is_dir)
|
49
53
|
is_dir
|
50
54
|
end
|