chef-infra-api 0.9.1
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 +7 -0
- data/LICENSE +201 -0
- data/lib/chef-api.rb +96 -0
- data/lib/chef-api/aclable.rb +35 -0
- data/lib/chef-api/authentication.rb +300 -0
- data/lib/chef-api/boolean.rb +6 -0
- data/lib/chef-api/configurable.rb +80 -0
- data/lib/chef-api/connection.rb +507 -0
- data/lib/chef-api/defaults.rb +197 -0
- data/lib/chef-api/error_collection.rb +44 -0
- data/lib/chef-api/errors.rb +64 -0
- data/lib/chef-api/multipart.rb +164 -0
- data/lib/chef-api/resource.rb +21 -0
- data/lib/chef-api/resources/base.rb +960 -0
- data/lib/chef-api/resources/client.rb +84 -0
- data/lib/chef-api/resources/collection_proxy.rb +234 -0
- data/lib/chef-api/resources/cookbook.rb +24 -0
- data/lib/chef-api/resources/cookbook_version.rb +23 -0
- data/lib/chef-api/resources/data_bag.rb +136 -0
- data/lib/chef-api/resources/data_bag_item.rb +53 -0
- data/lib/chef-api/resources/environment.rb +16 -0
- data/lib/chef-api/resources/group.rb +16 -0
- data/lib/chef-api/resources/node.rb +20 -0
- data/lib/chef-api/resources/organization.rb +22 -0
- data/lib/chef-api/resources/partial_search.rb +44 -0
- data/lib/chef-api/resources/principal.rb +11 -0
- data/lib/chef-api/resources/role.rb +18 -0
- data/lib/chef-api/resources/search.rb +47 -0
- data/lib/chef-api/resources/user.rb +82 -0
- data/lib/chef-api/schema.rb +150 -0
- data/lib/chef-api/util.rb +119 -0
- data/lib/chef-api/validator.rb +16 -0
- data/lib/chef-api/validators/base.rb +82 -0
- data/lib/chef-api/validators/required.rb +11 -0
- data/lib/chef-api/validators/type.rb +23 -0
- data/lib/chef-api/version.rb +3 -0
- data/templates/errors/abstract_method.erb +5 -0
- data/templates/errors/cannot_regenerate_key.erb +1 -0
- data/templates/errors/chef_api_error.erb +1 -0
- data/templates/errors/file_not_found.erb +1 -0
- data/templates/errors/http_bad_request.erb +3 -0
- data/templates/errors/http_forbidden_request.erb +3 -0
- data/templates/errors/http_gateway_timeout.erb +3 -0
- data/templates/errors/http_method_not_allowed.erb +3 -0
- data/templates/errors/http_not_acceptable.erb +3 -0
- data/templates/errors/http_not_found.erb +3 -0
- data/templates/errors/http_server_unavailable.erb +1 -0
- data/templates/errors/http_unauthorized_request.erb +3 -0
- data/templates/errors/insufficient_file_permissions.erb +1 -0
- data/templates/errors/invalid_resource.erb +1 -0
- data/templates/errors/invalid_validator.erb +1 -0
- data/templates/errors/missing_url_parameter.erb +1 -0
- data/templates/errors/not_a_directory.erb +1 -0
- data/templates/errors/resource_already_exists.erb +1 -0
- data/templates/errors/resource_not_found.erb +1 -0
- data/templates/errors/resource_not_mutable.erb +1 -0
- data/templates/errors/unknown_attribute.erb +1 -0
- metadata +130 -0
@@ -0,0 +1,84 @@
|
|
1
|
+
module ChefAPI
|
2
|
+
class Resource::Client < Resource::Base
|
3
|
+
include ChefAPI::AclAble
|
4
|
+
collection_path '/clients'
|
5
|
+
|
6
|
+
schema do
|
7
|
+
attribute :name, type: String, primary: true, required: true
|
8
|
+
attribute :admin, type: Boolean, default: false
|
9
|
+
attribute :public_key, type: String
|
10
|
+
attribute :private_key, type: [String, Boolean], default: false
|
11
|
+
attribute :validator, type: Boolean, default: false
|
12
|
+
|
13
|
+
ignore :certificate, :clientname, :orgname
|
14
|
+
end
|
15
|
+
|
16
|
+
# @todo implement
|
17
|
+
protect 'chef-webui', 'chef-validator'
|
18
|
+
|
19
|
+
class << self
|
20
|
+
#
|
21
|
+
# Load the client from a .pem file on disk. Lots of assumptions are made
|
22
|
+
# here.
|
23
|
+
#
|
24
|
+
# @param [String] path
|
25
|
+
# the path to the client on disk
|
26
|
+
#
|
27
|
+
# @return [Resource::Client]
|
28
|
+
#
|
29
|
+
def from_file(path)
|
30
|
+
name, key = Util.safe_read(path)
|
31
|
+
|
32
|
+
if client = fetch(name)
|
33
|
+
client.private_key = key
|
34
|
+
client
|
35
|
+
else
|
36
|
+
new(name: name, private_key: key)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
#
|
42
|
+
# Override the loading of the client. Since HEC and EC both return
|
43
|
+
# +certificate+, but OPC and CZ both use +public_key+. In order to
|
44
|
+
# normalize this discrepancy, the intializer converts the response from
|
45
|
+
# the server OPC format. HEC and EC both handle putting a public key to
|
46
|
+
# the server instead of a certificate.
|
47
|
+
#
|
48
|
+
# @see Resource::Base#initialize
|
49
|
+
#
|
50
|
+
def initialize(attributes = {}, prefix = {})
|
51
|
+
if certificate = attributes.delete(:certificate) ||
|
52
|
+
attributes.delete('certificate')
|
53
|
+
x509 = OpenSSL::X509::Certificate.new(certificate)
|
54
|
+
attributes[:public_key] = x509.public_key.to_pem
|
55
|
+
end
|
56
|
+
|
57
|
+
super
|
58
|
+
end
|
59
|
+
|
60
|
+
#
|
61
|
+
# Generate a new RSA private key for this API client.
|
62
|
+
#
|
63
|
+
# @example Regenerate the private key
|
64
|
+
# key = client.regenerate_key
|
65
|
+
# key #=> "-----BEGIN PRIVATE KEY-----\nMIGfMA0GCS..."
|
66
|
+
#
|
67
|
+
# @note For security reasons, you should perform this operation sparingly!
|
68
|
+
# The resulting private key is committed to this object, meaning it is
|
69
|
+
# saved to memory somewhere. You should set this resource's +private_key+
|
70
|
+
# to +nil+ after you have committed it to disk and perform a manual GC to
|
71
|
+
# be ultra-secure.
|
72
|
+
#
|
73
|
+
# @note Regenerating the private key also regenerates the public key!
|
74
|
+
#
|
75
|
+
# @return [self]
|
76
|
+
# the current resource with the new public and private key attributes
|
77
|
+
#
|
78
|
+
def regenerate_keys
|
79
|
+
raise Error::CannotRegenerateKey if new_resource?
|
80
|
+
update(private_key: true).save!
|
81
|
+
self
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,234 @@
|
|
1
|
+
module ChefAPI
|
2
|
+
class Resource::CollectionProxy
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
#
|
6
|
+
# Create a new collection proxy from the given parent class and collection
|
7
|
+
# information. The collection proxy aims to make working with nested
|
8
|
+
# resource collections a bit easier. The proxy waits until non-existing
|
9
|
+
# data is requested before making another HTTP request. In this way, it
|
10
|
+
# helps reduce bandwidth and API requets.
|
11
|
+
#
|
12
|
+
# Additionally, the collection proxy caches the results of an object request
|
13
|
+
# in memory by +id+, so additional requests for the same object will hit the
|
14
|
+
# cache, again reducing HTTP requests.
|
15
|
+
#
|
16
|
+
# @param [Resource::Base] parent
|
17
|
+
# the parent resource that created the collection
|
18
|
+
# @param [Class] klass
|
19
|
+
# the class the resulting objects should be
|
20
|
+
# @param [String] endpoint
|
21
|
+
# the relative path for the RESTful endpoint
|
22
|
+
#
|
23
|
+
# @return [CollectionProxy]
|
24
|
+
#
|
25
|
+
def initialize(parent, klass, endpoint, prefix = {})
|
26
|
+
@parent = parent
|
27
|
+
@klass = klass
|
28
|
+
@endpoint = "#{parent.resource_path}/#{endpoint}"
|
29
|
+
@prefix = prefix
|
30
|
+
@collection = load_collection
|
31
|
+
end
|
32
|
+
|
33
|
+
#
|
34
|
+
# Force a reload of this collection proxy and all its elements. This is
|
35
|
+
# useful if you think additional items have been added to the remote
|
36
|
+
# collection and need access to them locally. This will also clear any
|
37
|
+
# existing cached responses, so use with caution.
|
38
|
+
#
|
39
|
+
# @return [self]
|
40
|
+
#
|
41
|
+
def reload!
|
42
|
+
cache.clear
|
43
|
+
@collection = load_collection
|
44
|
+
|
45
|
+
self
|
46
|
+
end
|
47
|
+
|
48
|
+
#
|
49
|
+
# Fetch a specific resource in the collection by id.
|
50
|
+
#
|
51
|
+
# @example Fetch a resource
|
52
|
+
# Bacon.first.items.fetch('crispy')
|
53
|
+
#
|
54
|
+
# @param [String, Symbol] id
|
55
|
+
# the id of the resource to fetch
|
56
|
+
#
|
57
|
+
# @return [Resource::Base, nil]
|
58
|
+
# the fetched class, or nil if it does not exists
|
59
|
+
#
|
60
|
+
def fetch(id)
|
61
|
+
return nil unless exists?(id)
|
62
|
+
cached(id) { klass.from_url(get(id), prefix) }
|
63
|
+
end
|
64
|
+
|
65
|
+
#
|
66
|
+
# Determine if the resource with the given id exists on the remote Chef
|
67
|
+
# Server. This method does not actually query the Chef Server, but rather
|
68
|
+
# delegates to the cached collection. To guarantee the most fresh set of
|
69
|
+
# data, you should call +reload!+ before +exists?+ to ensure you have the
|
70
|
+
# most up-to-date collection of resources.
|
71
|
+
#
|
72
|
+
# @param [String, Symbol] id
|
73
|
+
# the unique id of the resource to find.
|
74
|
+
#
|
75
|
+
# @return [Boolean]
|
76
|
+
# true if the resource exists, false otherwise
|
77
|
+
#
|
78
|
+
def exists?(id)
|
79
|
+
collection.has_key?(id.to_s)
|
80
|
+
end
|
81
|
+
|
82
|
+
#
|
83
|
+
# Get the full list of all entries in the collection. This method is
|
84
|
+
# incredibly expensive and should only be used when you absolutely need
|
85
|
+
# all resources. If you need a specific resource, you should use an iterator
|
86
|
+
# such as +select+ or +find+ instead, since it will minimize HTTP requests.
|
87
|
+
# Once all the objects are requested, they are cached, reducing the number
|
88
|
+
# of HTTP requests.
|
89
|
+
#
|
90
|
+
# @return [Array<Resource::Base>]
|
91
|
+
#
|
92
|
+
def all
|
93
|
+
entries
|
94
|
+
end
|
95
|
+
|
96
|
+
#
|
97
|
+
# The custom iterator for looping over each object in this collection. For
|
98
|
+
# more information, please see the +Enumerator+ module in Ruby core.
|
99
|
+
#
|
100
|
+
def each(&block)
|
101
|
+
collection.each do |id, url|
|
102
|
+
object = cached(id) { klass.from_url(url, prefix) }
|
103
|
+
block.call(object) if block
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
#
|
108
|
+
# The total number of items in this collection. This method does not make
|
109
|
+
# an API request, but rather counts the number of keys in the given
|
110
|
+
# collection.
|
111
|
+
#
|
112
|
+
def count
|
113
|
+
collection.length
|
114
|
+
end
|
115
|
+
alias_method :size, :count
|
116
|
+
|
117
|
+
#
|
118
|
+
# The string representation of this collection proxy.
|
119
|
+
#
|
120
|
+
# @return [String]
|
121
|
+
#
|
122
|
+
def to_s
|
123
|
+
"#<#{self.class.name}>"
|
124
|
+
end
|
125
|
+
|
126
|
+
#
|
127
|
+
# The detailed string representation of this collection proxy.
|
128
|
+
#
|
129
|
+
# @return [String]
|
130
|
+
#
|
131
|
+
def inspect
|
132
|
+
objects = collection
|
133
|
+
.map { |id, _| cached(id) || klass.new(klass.schema.primary_key => id) }
|
134
|
+
.map { |object| object.to_s }
|
135
|
+
|
136
|
+
"#<#{self.class.name} [#{objects.join(', ')}]>"
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
attr_reader :collection
|
142
|
+
attr_reader :endpoint
|
143
|
+
attr_reader :klass
|
144
|
+
attr_reader :parent
|
145
|
+
attr_reader :prefix
|
146
|
+
|
147
|
+
#
|
148
|
+
# Fetch the object collection from the Chef Server. Since the Chef Server's
|
149
|
+
# API is completely insane and all over the place, it might return a Hash
|
150
|
+
# where the key is the id of the resource and the value is the url for that
|
151
|
+
# item on the Chef Server:
|
152
|
+
#
|
153
|
+
# { "key" => "url" }
|
154
|
+
#
|
155
|
+
# Or if the Chef Server's fancy is tickled, it might just return an array
|
156
|
+
# of the list of items:
|
157
|
+
#
|
158
|
+
# ["item_1", "item_2"]
|
159
|
+
#
|
160
|
+
# Or if the Chef Server is feeling especially magical, it might return the
|
161
|
+
# actual objects, but prefixed with the JSON id:
|
162
|
+
#
|
163
|
+
# [{"organization" => {"_id" => "..."}}, {"organization" => {...}}]
|
164
|
+
#
|
165
|
+
# So, this method attempts to intelligent handle these use cases. That being
|
166
|
+
# said, I can almost guarantee that someone is going to do some crazy
|
167
|
+
# strange edge case with this library and hit a bug here, so it will likely
|
168
|
+
# be changed in the future. For now, it "works on my machine".
|
169
|
+
#
|
170
|
+
# @return [Hash]
|
171
|
+
#
|
172
|
+
def load_collection
|
173
|
+
case response = Resource::Base.connection.get(endpoint)
|
174
|
+
when Array
|
175
|
+
if response.first.is_a?(Hash)
|
176
|
+
key = klass.schema.primary_key.to_s
|
177
|
+
|
178
|
+
{}.tap do |hash|
|
179
|
+
response.each do |results|
|
180
|
+
results.each do |_, info|
|
181
|
+
hash[key] = klass.resource_path(info[key])
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
else
|
186
|
+
Hash[*response.map { |item| [item, klass.resource_path(item)] }.flatten]
|
187
|
+
end
|
188
|
+
when Hash
|
189
|
+
response
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
#
|
194
|
+
# Retrieve a cached value. This method helps significantly reduce the
|
195
|
+
# number of HTTP requests made against the remote server.
|
196
|
+
#
|
197
|
+
# @param [String, Symbol] key
|
198
|
+
# the cache key (typically the +name+ of the resource)
|
199
|
+
# @param [Proc] block
|
200
|
+
# the block to evaluate to set the value if it doesn't exist
|
201
|
+
#
|
202
|
+
# @return [Object]
|
203
|
+
# the value at the cache
|
204
|
+
#
|
205
|
+
def cached(key, &block)
|
206
|
+
cache[key.to_sym] ||= block ? block.call : nil
|
207
|
+
end
|
208
|
+
|
209
|
+
#
|
210
|
+
# The cache...
|
211
|
+
#
|
212
|
+
# @return [Hash]
|
213
|
+
#
|
214
|
+
def cache
|
215
|
+
@cache ||= {}
|
216
|
+
end
|
217
|
+
|
218
|
+
#
|
219
|
+
# Retrieve a specific item in the collection. Note, this will always return
|
220
|
+
# the original raw record (with the key => URL pairing), not a cached
|
221
|
+
# resource.
|
222
|
+
#
|
223
|
+
# @param [String, Symbol] id
|
224
|
+
# the id of the resource to fetch
|
225
|
+
#
|
226
|
+
# @return [String, nil]
|
227
|
+
# the URL to retrieve the item in the collection, or nil if it does not
|
228
|
+
# exist
|
229
|
+
#
|
230
|
+
def get(id)
|
231
|
+
collection[id.to_s]
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module ChefAPI
|
2
|
+
#
|
3
|
+
# In the real world, a "cookbook" is a single entity with multiple versions.
|
4
|
+
# In Chef land, a "cookbook" is actually just a wrapper around a collection
|
5
|
+
# of +cookbook_version+ objects that fully detail the layout of a cookbook.
|
6
|
+
#
|
7
|
+
class Resource::Cookbook < Resource::Base
|
8
|
+
collection_path '/cookbooks'
|
9
|
+
|
10
|
+
schema do
|
11
|
+
attribute :name, type: String, primary: true, required: true
|
12
|
+
end
|
13
|
+
|
14
|
+
has_many :versions,
|
15
|
+
class_name: CookbookVersion,
|
16
|
+
rest_endpoint: '/?num_versions=all'
|
17
|
+
|
18
|
+
class << self
|
19
|
+
def from_json(response, prefix = {})
|
20
|
+
new(name: response.keys.first)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module ChefAPI
|
2
|
+
class Resource::CookbookVersion < Resource::Base
|
3
|
+
collection_path '/cookbooks/:cookbook'
|
4
|
+
|
5
|
+
schema do
|
6
|
+
attribute :name, type: String, primary: true, required: true
|
7
|
+
attribute :cookbook_name, type: String, required: true
|
8
|
+
attribute :metadata, type: Hash, required: true
|
9
|
+
attribute :version, type: String, required: true
|
10
|
+
attribute :frozen?, type: Boolean, default: false
|
11
|
+
|
12
|
+
attribute :attributes, type: Array, default: []
|
13
|
+
attribute :definitions, type: Array, default: []
|
14
|
+
attribute :files, type: Array, default: []
|
15
|
+
attribute :libraries, type: Array, default: []
|
16
|
+
attribute :providers, type: Array, default: []
|
17
|
+
attribute :recipes, type: Array, default: []
|
18
|
+
attribute :resources, type: Array, default: []
|
19
|
+
attribute :root_files, type: Array, default: []
|
20
|
+
attribute :templates, type: Array, default: []
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
module ChefAPI
|
2
|
+
class Resource::DataBag < Resource::Base
|
3
|
+
collection_path '/data'
|
4
|
+
|
5
|
+
schema do
|
6
|
+
attribute :name, type: String, primary: true, required: true
|
7
|
+
end
|
8
|
+
|
9
|
+
class << self
|
10
|
+
#
|
11
|
+
# Load the data bag from a collection of JSON files on disk. Just like
|
12
|
+
# +knife+, the basename of the folder is assumed to be the name of the
|
13
|
+
# data bag and all containing items a proper JSON data bag.
|
14
|
+
#
|
15
|
+
# This will load **all** items in the data bag, returning an array of
|
16
|
+
# those items. To load an individual data bag item, see
|
17
|
+
# {DataBagItem.from_file}.
|
18
|
+
#
|
19
|
+
# **This method does NOT return an instance of a {DataBag}!**
|
20
|
+
#
|
21
|
+
# @param [String] path
|
22
|
+
# the path to the data bag **folder** on disk
|
23
|
+
# @param [String] name
|
24
|
+
# the name of the data bag
|
25
|
+
#
|
26
|
+
# @return [Array<DataBagItem>]
|
27
|
+
#
|
28
|
+
def from_file(path, name = File.basename(path))
|
29
|
+
path = File.expand_path(path)
|
30
|
+
|
31
|
+
raise Error::FileNotFound.new(path: path) unless File.exists?(path)
|
32
|
+
raise Error::NotADirectory.new(path: path) unless File.directory?(path)
|
33
|
+
|
34
|
+
raise ArgumentError unless File.directory?(path)
|
35
|
+
|
36
|
+
bag = new(name: name)
|
37
|
+
|
38
|
+
Util.fast_collect(Dir["#{path}/*.json"]) do |item|
|
39
|
+
Resource::DataBagItem.from_file(item, bag)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
#
|
44
|
+
#
|
45
|
+
#
|
46
|
+
def fetch(id, prefix = {})
|
47
|
+
return nil if id.nil?
|
48
|
+
|
49
|
+
path = resource_path(id, prefix)
|
50
|
+
response = connection.get(path)
|
51
|
+
new(name: id)
|
52
|
+
rescue Error::HTTPNotFound
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
|
56
|
+
#
|
57
|
+
#
|
58
|
+
#
|
59
|
+
def each(&block)
|
60
|
+
collection.each do |name, path|
|
61
|
+
result = new(name: name)
|
62
|
+
block.call(result) if block
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
#
|
68
|
+
# This is the same as +has_many :items+, but creates a special collection
|
69
|
+
# for data bag items, which is mutable and handles some special edge cases
|
70
|
+
# that only data bags encounter.
|
71
|
+
#
|
72
|
+
# @see Base.has_many
|
73
|
+
#
|
74
|
+
def items
|
75
|
+
associations[:items] ||= Resource::DataBagItemCollectionProxy.new(self)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
module ChefAPI
|
81
|
+
#
|
82
|
+
# The mutable collection is a special kind of collection proxy that permits
|
83
|
+
# Rails-like attribtue creation, like:
|
84
|
+
#
|
85
|
+
# DataBag.first.items.create(id: 'me', thing: 'bar', zip: 'zap')
|
86
|
+
#
|
87
|
+
class Resource::DataBagItemCollectionProxy < Resource::CollectionProxy
|
88
|
+
def initialize(bag)
|
89
|
+
# Delegate to the superclass
|
90
|
+
super(bag, Resource::DataBagItem, nil, bag: bag.name)
|
91
|
+
end
|
92
|
+
|
93
|
+
# @see klass.new
|
94
|
+
def new(data = {})
|
95
|
+
klass.new(data, prefix, parent)
|
96
|
+
end
|
97
|
+
|
98
|
+
# @see klass.destroy
|
99
|
+
def destroy(id)
|
100
|
+
klass.destroy(id, prefix)
|
101
|
+
ensure
|
102
|
+
reload!
|
103
|
+
end
|
104
|
+
|
105
|
+
# @see klass.destroy_all
|
106
|
+
def destroy_all
|
107
|
+
klass.destroy_all(prefix)
|
108
|
+
ensure
|
109
|
+
reload!
|
110
|
+
end
|
111
|
+
|
112
|
+
# @see klass.build
|
113
|
+
def build(data = {})
|
114
|
+
klass.build(data, prefix)
|
115
|
+
end
|
116
|
+
|
117
|
+
# @see klass.create
|
118
|
+
def create(data = {})
|
119
|
+
klass.create(data, prefix)
|
120
|
+
ensure
|
121
|
+
reload!
|
122
|
+
end
|
123
|
+
|
124
|
+
# @see klass.create!
|
125
|
+
def create!(data = {})
|
126
|
+
klass.create!(data, prefix)
|
127
|
+
ensure
|
128
|
+
reload!
|
129
|
+
end
|
130
|
+
|
131
|
+
# @see klass.update
|
132
|
+
def update(id, data = {})
|
133
|
+
klass.update(id, data, prefix)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|