openstack 1.0.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.
- data/COPYING +7 -0
- data/README.rdoc +189 -0
- data/VERSION +1 -0
- data/lib/openstack.rb +107 -0
- data/lib/openstack/compute/address.rb +39 -0
- data/lib/openstack/compute/connection.rb +207 -0
- data/lib/openstack/compute/flavor.rb +35 -0
- data/lib/openstack/compute/image.rb +73 -0
- data/lib/openstack/compute/metadata.rb +116 -0
- data/lib/openstack/compute/personalities.rb +23 -0
- data/lib/openstack/compute/server.rb +249 -0
- data/lib/openstack/connection.rb +462 -0
- data/lib/openstack/swift/connection.rb +185 -0
- data/lib/openstack/swift/container.rb +214 -0
- data/lib/openstack/swift/storage_object.rb +311 -0
- metadata +96 -0
@@ -0,0 +1,462 @@
|
|
1
|
+
module OpenStack
|
2
|
+
|
3
|
+
class Connection
|
4
|
+
|
5
|
+
attr_reader :authuser
|
6
|
+
attr_reader :authtenant
|
7
|
+
attr_reader :authkey
|
8
|
+
attr_reader :auth_method
|
9
|
+
attr_accessor :authtoken
|
10
|
+
attr_accessor :authok
|
11
|
+
attr_accessor :service_host
|
12
|
+
attr_accessor :service_path
|
13
|
+
attr_accessor :service_port
|
14
|
+
attr_accessor :service_scheme
|
15
|
+
attr_reader :auth_host
|
16
|
+
attr_reader :auth_port
|
17
|
+
attr_reader :auth_scheme
|
18
|
+
attr_reader :auth_path
|
19
|
+
attr_reader :service_name
|
20
|
+
attr_reader :service_type
|
21
|
+
attr_reader :proxy_host
|
22
|
+
attr_reader :proxy_port
|
23
|
+
attr_reader :region
|
24
|
+
|
25
|
+
attr_reader :http
|
26
|
+
attr_reader :is_debug
|
27
|
+
|
28
|
+
# Creates and returns a new Connection object, depending on the service_type
|
29
|
+
# passed in the options:
|
30
|
+
#
|
31
|
+
# e.g:
|
32
|
+
# os = OpenStack::Connection.create({:username => "herp@derp.com", :api_key=>"password",
|
33
|
+
# :auth_url => "https://region-a.geo-1.identity.cloudsvc.com:35357/v2.0/",
|
34
|
+
# :authtenant=>"herp@derp.com-default-tenant", :service_type=>"object-store")
|
35
|
+
#
|
36
|
+
# Will return an OpenStack::Swift::Connection object.
|
37
|
+
#
|
38
|
+
# options hash:
|
39
|
+
#
|
40
|
+
# :username - Your OpenStack username *required*
|
41
|
+
# :tenant - Your OpenStack tenant *required*. Defaults to username.
|
42
|
+
# :api_key - Your OpenStack API key *required*
|
43
|
+
# :auth_url - Configurable auth_url endpoint.
|
44
|
+
# :service_name - (Optional for v2.0 auth only). The optional name of the compute service to use.
|
45
|
+
# :service_type - (Optional for v2.0 auth only). Defaults to "compute"
|
46
|
+
# :region - (Optional for v2.0 auth only). The specific service region to use. Defaults to first returned region.
|
47
|
+
# :retry_auth - Whether to retry if your auth token expires (defaults to true)
|
48
|
+
# :proxy_host - If you need to connect through a proxy, supply the hostname here
|
49
|
+
# :proxy_port - If you need to connect through a proxy, supply the port here
|
50
|
+
#
|
51
|
+
# The options hash is used to create a new OpenStack::Connection object
|
52
|
+
# (private constructor) and this is passed to the constructor of OpenStack::Compute::Connection
|
53
|
+
# or OpenStack::Swift::Connection (depending on :service_type) where authentication is done using
|
54
|
+
# OpenStack::Authentication.
|
55
|
+
#
|
56
|
+
def self.create(options = {:retry_auth => true})
|
57
|
+
#call private constructor and grab instance vars
|
58
|
+
connection = new(options)
|
59
|
+
case connection.service_type
|
60
|
+
when "compute"
|
61
|
+
OpenStack::Compute::Connection.new(connection)
|
62
|
+
when "object-store"
|
63
|
+
OpenStack::Swift::Connection.new(connection)
|
64
|
+
else
|
65
|
+
raise Exception::InvalidArgument, "Invalid :service_type parameter: #{@service_type}"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private_class_method :new
|
70
|
+
|
71
|
+
def initialize(options = {:retry_auth => true})
|
72
|
+
@authuser = options[:username] || (raise Exception::MissingArgument, "Must supply a :username")
|
73
|
+
@authkey = options[:api_key] || (raise Exception::MissingArgument, "Must supply an :api_key")
|
74
|
+
@auth_url = options[:auth_url] || (raise Exception::MissingArgument, "Must supply an :auth_url")
|
75
|
+
@authtenant = options[:authtenant] || @authuser
|
76
|
+
@auth_method = options[:auth_method] || "password"
|
77
|
+
@service_name = options[:service_name] || nil
|
78
|
+
@service_type = options[:service_type] || "compute"
|
79
|
+
@region = options[:region] || @region = nil
|
80
|
+
@is_debug = options[:is_debug]
|
81
|
+
auth_uri=nil
|
82
|
+
begin
|
83
|
+
auth_uri=URI.parse(@auth_url)
|
84
|
+
rescue Exception => e
|
85
|
+
raise Exception::InvalidArgument, "Invalid :auth_url parameter: #{e.message}"
|
86
|
+
end
|
87
|
+
raise Exception::InvalidArgument, "Invalid :auth_url parameter." if auth_uri.nil? or auth_uri.host.nil?
|
88
|
+
@auth_host = auth_uri.host
|
89
|
+
@auth_port = auth_uri.port
|
90
|
+
@auth_scheme = auth_uri.scheme
|
91
|
+
@auth_path = auth_uri.path
|
92
|
+
@retry_auth = options[:retry_auth]
|
93
|
+
@proxy_host = options[:proxy_host]
|
94
|
+
@proxy_port = options[:proxy_port]
|
95
|
+
@authok = false
|
96
|
+
@http = {}
|
97
|
+
end
|
98
|
+
|
99
|
+
#specialised from of csreq for PUT object... uses body_stream if possible
|
100
|
+
def put_object(server,path,port,scheme,headers = {},data = nil,attempts = 0) # :nodoc:
|
101
|
+
start = Time.now
|
102
|
+
if data.respond_to? :read
|
103
|
+
headers['Transfer-Encoding'] = 'chunked'
|
104
|
+
hdrhash = headerprep(headers)
|
105
|
+
request = Net::HTTP::Put.new(path,hdrhash)
|
106
|
+
chunked = OpenStack::Swift::ChunkedConnectionWrapper.new(data, 65535)
|
107
|
+
request.body_stream = chunked
|
108
|
+
else
|
109
|
+
headers['Content-Length'] = (body.respond_to?(:lstat))? body.lstat.size.to_s : ((body.respond_to?(:size))? body.size.to_s : "0")
|
110
|
+
hdrhash = headerprep(headers)
|
111
|
+
request = Net::HTTP::Put.new(path,hdrhash)
|
112
|
+
request.body = data
|
113
|
+
end
|
114
|
+
start_http(server,path,port,scheme,hdrhash)
|
115
|
+
response = @http[server].request(request)
|
116
|
+
if @is_debug
|
117
|
+
puts "REQUEST: #{method} => #{path}"
|
118
|
+
puts data if data
|
119
|
+
puts "RESPONSE: #{response.body}"
|
120
|
+
puts '----------------------------------------'
|
121
|
+
end
|
122
|
+
raise OpenStack::Exception::ExpiredAuthToken if response.code == "401"
|
123
|
+
response
|
124
|
+
rescue Errno::EPIPE, Timeout::Error, Errno::EINVAL, EOFError
|
125
|
+
# Server closed the connection, retry
|
126
|
+
raise OpenStack::Exception::Connection, "Unable to reconnect to #{server} after #{attempts} attempts" if attempts >= 5
|
127
|
+
attempts += 1
|
128
|
+
@http[server].finish if @http[server].started?
|
129
|
+
start_http(server,path,port,scheme,headers)
|
130
|
+
retry
|
131
|
+
rescue OpenStack::Exception::ExpiredAuthToken
|
132
|
+
raise OpenStack::Exception::Connection, "Authentication token expired and you have requested not to retry" if @retry_auth == false
|
133
|
+
OpenStack::Authentication.init(self)
|
134
|
+
retry
|
135
|
+
end
|
136
|
+
|
137
|
+
|
138
|
+
# This method actually makes the HTTP REST calls out to the server
|
139
|
+
def csreq(method,server,path,port,scheme,headers = {},data = nil,attempts = 0, &block) # :nodoc:
|
140
|
+
start = Time.now
|
141
|
+
hdrhash = headerprep(headers)
|
142
|
+
start_http(server,path,port,scheme,hdrhash)
|
143
|
+
request = Net::HTTP.const_get(method.to_s.capitalize).new(path,hdrhash)
|
144
|
+
request.body = data
|
145
|
+
if block_given?
|
146
|
+
response = @http[server].request(request) do |res|
|
147
|
+
res.read_body do |b|
|
148
|
+
yield b
|
149
|
+
end
|
150
|
+
end
|
151
|
+
else
|
152
|
+
response = @http[server].request(request)
|
153
|
+
end
|
154
|
+
if @is_debug
|
155
|
+
puts "REQUEST: #{method} => #{path}"
|
156
|
+
puts data if data
|
157
|
+
puts "RESPONSE: #{response.body}"
|
158
|
+
puts '----------------------------------------'
|
159
|
+
end
|
160
|
+
raise OpenStack::Exception::ExpiredAuthToken if response.code == "401"
|
161
|
+
response
|
162
|
+
rescue Errno::EPIPE, Timeout::Error, Errno::EINVAL, EOFError
|
163
|
+
# Server closed the connection, retry
|
164
|
+
raise OpenStack::Exception::Connection, "Unable to reconnect to #{server} after #{attempts} attempts" if attempts >= 5
|
165
|
+
attempts += 1
|
166
|
+
@http[server].finish if @http[server].started?
|
167
|
+
start_http(server,path,port,scheme,headers)
|
168
|
+
retry
|
169
|
+
rescue OpenStack::Exception::ExpiredAuthToken
|
170
|
+
raise OpenStack::Exception::Connection, "Authentication token expired and you have requested not to retry" if @retry_auth == false
|
171
|
+
OpenStack::Authentication.init(self)
|
172
|
+
retry
|
173
|
+
end
|
174
|
+
|
175
|
+
# This is a much more sane way to make a http request to the api.
|
176
|
+
# Example: res = conn.req('GET', "/servers/#{id}")
|
177
|
+
def req(method, path, options = {})
|
178
|
+
server = options[:server] || @service_host
|
179
|
+
port = options[:port] || @service_port
|
180
|
+
scheme = options[:scheme] || @service_scheme
|
181
|
+
headers = options[:headers] || {'content-type' => 'application/json'}
|
182
|
+
data = options[:data]
|
183
|
+
attempts = options[:attempts] || 0
|
184
|
+
path = @service_path + path
|
185
|
+
res = csreq(method,server,path,port,scheme,headers,data,attempts)
|
186
|
+
if not res.code.match(/^20.$/)
|
187
|
+
OpenStack::Exception.raise_exception(res)
|
188
|
+
end
|
189
|
+
return res
|
190
|
+
end;
|
191
|
+
|
192
|
+
private
|
193
|
+
|
194
|
+
# Sets up standard HTTP headers
|
195
|
+
def headerprep(headers = {}) # :nodoc:
|
196
|
+
default_headers = {}
|
197
|
+
default_headers["X-Auth-Token"] = @authtoken if authok
|
198
|
+
default_headers["X-Storage-Token"] = @authtoken if authok
|
199
|
+
default_headers["Connection"] = "Keep-Alive"
|
200
|
+
default_headers["User-Agent"] = "OpenStack Ruby API #{VERSION}"
|
201
|
+
default_headers["Accept"] = "application/json"
|
202
|
+
default_headers.merge(headers)
|
203
|
+
end
|
204
|
+
|
205
|
+
# Starts (or restarts) the HTTP connection
|
206
|
+
def start_http(server,path,port,scheme,headers) # :nodoc:
|
207
|
+
if (@http[server].nil?)
|
208
|
+
begin
|
209
|
+
@http[server] = Net::HTTP::Proxy(@proxy_host, @proxy_port).new(server,port)
|
210
|
+
if scheme == "https"
|
211
|
+
@http[server].use_ssl = true
|
212
|
+
@http[server].verify_mode = OpenSSL::SSL::VERIFY_NONE
|
213
|
+
end
|
214
|
+
@http[server].start
|
215
|
+
rescue
|
216
|
+
raise OpenStack::Exception::Connection, "Unable to connect to #{server}"
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
end #end class Connection
|
222
|
+
|
223
|
+
#============================
|
224
|
+
# OpenStack::Authentication
|
225
|
+
#============================
|
226
|
+
|
227
|
+
class Authentication
|
228
|
+
|
229
|
+
# Performs an authentication to the OpenStack auth server.
|
230
|
+
# If it succeeds, it sets the service_host, service_path, service_port,
|
231
|
+
# service_scheme, authtoken, and authok variables on the connection.
|
232
|
+
# If it fails, it raises an exception.
|
233
|
+
|
234
|
+
def self.init(conn)
|
235
|
+
if conn.auth_path =~ /.*v2.0\/?$/
|
236
|
+
AuthV20.new(conn)
|
237
|
+
else
|
238
|
+
AuthV10.new(conn)
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
end
|
243
|
+
|
244
|
+
private
|
245
|
+
|
246
|
+
class AuthV20
|
247
|
+
attr_reader :uri
|
248
|
+
|
249
|
+
def initialize(connection)
|
250
|
+
begin
|
251
|
+
server = Net::HTTP::Proxy(connection.proxy_host, connection.proxy_port).new(connection.auth_host, connection.auth_port)
|
252
|
+
if connection.auth_scheme == "https"
|
253
|
+
server.use_ssl = true
|
254
|
+
server.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
255
|
+
end
|
256
|
+
server.start
|
257
|
+
rescue
|
258
|
+
raise OpenStack::Exception::Connection, "Unable to connect to #{server}"
|
259
|
+
end
|
260
|
+
|
261
|
+
@uri = String.new
|
262
|
+
|
263
|
+
if connection.auth_method == "password"
|
264
|
+
auth_data = JSON.generate({ "auth" => { "passwordCredentials" => { "username" => connection.authuser, "password" => connection.authkey }, "tenantName" => connection.authtenant}})
|
265
|
+
elsif connection.auth_method == "rax-kskey"
|
266
|
+
auth_data = JSON.generate({"auth" => {"RAX-KSKEY:apiKeyCredentials" => {"username" => connection.authuser, "apiKey" => connection.authkey}}})
|
267
|
+
else
|
268
|
+
raise Exception::InvalidArgument, "Unrecognized auth method #{connection.auth_method}"
|
269
|
+
end
|
270
|
+
|
271
|
+
response = server.post(connection.auth_path.chomp("/")+"/tokens", auth_data, {'Content-Type' => 'application/json'})
|
272
|
+
if (response.code =~ /^20./)
|
273
|
+
resp_data=JSON.parse(response.body)
|
274
|
+
connection.authtoken = resp_data['access']['token']['id']
|
275
|
+
resp_data['access']['serviceCatalog'].each do |service|
|
276
|
+
if service['type'] == connection.service_type
|
277
|
+
endpoints = service["endpoints"]
|
278
|
+
if connection.region
|
279
|
+
endpoints.each do |ep|
|
280
|
+
if ep["region"] and ep["region"].upcase == connection.region.upcase
|
281
|
+
@uri = URI.parse(ep["publicURL"])
|
282
|
+
end
|
283
|
+
end
|
284
|
+
else
|
285
|
+
@uri = URI.parse(endpoints[0]["publicURL"])
|
286
|
+
end
|
287
|
+
if @uri == ""
|
288
|
+
raise OpenStack::Exception::Authentication, "No API endpoint for region #{connection.region}"
|
289
|
+
else
|
290
|
+
connection.service_host = @uri.host
|
291
|
+
connection.service_path = @uri.path
|
292
|
+
connection.service_port = @uri.port
|
293
|
+
connection.service_scheme = @uri.scheme
|
294
|
+
connection.authok = true
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
else
|
299
|
+
connection.authtoken = false
|
300
|
+
raise OpenStack::Exception::Authentication, "Authentication failed with response code #{response.code}"
|
301
|
+
end
|
302
|
+
server.finish
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
class AuthV10
|
307
|
+
|
308
|
+
def initialize(connection)
|
309
|
+
hdrhash = { "X-Auth-User" => connection.authuser, "X-Auth-Key" => connection.authkey }
|
310
|
+
begin
|
311
|
+
server = Net::HTTP::Proxy(connection.proxy_host, connection.proxy_port).new(connection.auth_host, connection.auth_port)
|
312
|
+
if connection.auth_scheme == "https"
|
313
|
+
server.use_ssl = true
|
314
|
+
server.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
315
|
+
end
|
316
|
+
server.start
|
317
|
+
rescue
|
318
|
+
raise OpenStack::Exception::Connection, "Unable to connect to #{server}"
|
319
|
+
end
|
320
|
+
response = server.get(connection.auth_path, hdrhash)
|
321
|
+
if (response.code =~ /^20./)
|
322
|
+
connection.authtoken = response["x-auth-token"]
|
323
|
+
case connection.service_type
|
324
|
+
when "compute"
|
325
|
+
uri = URI.parse(response["x-server-management-url"])
|
326
|
+
when "object-store"
|
327
|
+
uri = URI.parse(response["x-storage-url"])
|
328
|
+
end
|
329
|
+
raise OpenStack::Exception::Authentication, "Unexpected Response from #{connection.auth_host} - couldn't get service URLs: \"x-server-management-url\" is: #{response["x-server-management-url"]} and \"x-storage-url\" is: #{response["x-storage-url"]}" if (uri.host.nil? || uri.host=="")
|
330
|
+
connection.service_host = uri.host
|
331
|
+
connection.service_path = uri.path
|
332
|
+
connection.service_port = uri.port
|
333
|
+
connection.service_scheme = uri.scheme
|
334
|
+
connection.authok = true
|
335
|
+
else
|
336
|
+
connection.authok = false
|
337
|
+
raise OpenStack::Exception::Authentication, "Authentication failed with response code #{response.code}"
|
338
|
+
end
|
339
|
+
server.finish
|
340
|
+
end
|
341
|
+
|
342
|
+
end
|
343
|
+
|
344
|
+
|
345
|
+
#============================
|
346
|
+
# OpenStack::Exception
|
347
|
+
#============================
|
348
|
+
|
349
|
+
class Exception
|
350
|
+
|
351
|
+
class ComputeError < StandardError
|
352
|
+
|
353
|
+
attr_reader :response_body
|
354
|
+
attr_reader :response_code
|
355
|
+
|
356
|
+
def initialize(message, code, response_body)
|
357
|
+
@response_code=code
|
358
|
+
@response_body=response_body
|
359
|
+
super(message)
|
360
|
+
end
|
361
|
+
|
362
|
+
end
|
363
|
+
|
364
|
+
class ComputeFault < ComputeError # :nodoc:
|
365
|
+
end
|
366
|
+
class ServiceUnavailable < ComputeError # :nodoc:
|
367
|
+
end
|
368
|
+
class Unauthorized < ComputeError # :nodoc:
|
369
|
+
end
|
370
|
+
class BadRequest < ComputeError # :nodoc:
|
371
|
+
end
|
372
|
+
class OverLimit < ComputeError # :nodoc:
|
373
|
+
end
|
374
|
+
class BadMediaType < ComputeError # :nodoc:
|
375
|
+
end
|
376
|
+
class BadMethod < ComputeError # :nodoc:
|
377
|
+
end
|
378
|
+
class ItemNotFound < ComputeError # :nodoc:
|
379
|
+
end
|
380
|
+
class BuildInProgress < ComputeError # :nodoc:
|
381
|
+
end
|
382
|
+
class ServerCapacityUnavailable < ComputeError # :nodoc:
|
383
|
+
end
|
384
|
+
class BackupOrResizeInProgress < ComputeError # :nodoc:
|
385
|
+
end
|
386
|
+
class ResizeNotAllowed < ComputeError # :nodoc:
|
387
|
+
end
|
388
|
+
class NotImplemented < ComputeError # :nodoc:
|
389
|
+
end
|
390
|
+
class Other < ComputeError # :nodoc:
|
391
|
+
end
|
392
|
+
class ResourceStateConflict < ComputeError # :nodoc:
|
393
|
+
end
|
394
|
+
|
395
|
+
# Plus some others that we define here
|
396
|
+
|
397
|
+
class ExpiredAuthToken < StandardError # :nodoc:
|
398
|
+
end
|
399
|
+
class MissingArgument < StandardError # :nodoc:
|
400
|
+
end
|
401
|
+
class InvalidArgument < StandardError # :nodoc:
|
402
|
+
end
|
403
|
+
class TooManyPersonalityItems < StandardError # :nodoc:
|
404
|
+
end
|
405
|
+
class PersonalityFilePathTooLong < StandardError # :nodoc:
|
406
|
+
end
|
407
|
+
class PersonalityFileTooLarge < StandardError # :nodoc:
|
408
|
+
end
|
409
|
+
class Authentication < StandardError # :nodoc:
|
410
|
+
end
|
411
|
+
class Connection < StandardError # :nodoc:
|
412
|
+
end
|
413
|
+
|
414
|
+
# In the event of a non-200 HTTP status code, this method takes the HTTP response, parses
|
415
|
+
# the JSON from the body to get more information about the exception, then raises the
|
416
|
+
# proper error. Note that all exceptions are scoped in the OpenStack::Compute::Exception namespace.
|
417
|
+
def self.raise_exception(response)
|
418
|
+
return if response.code =~ /^20.$/
|
419
|
+
begin
|
420
|
+
fault = nil
|
421
|
+
info = nil
|
422
|
+
if response.body.nil? && response.code == "404" #HEAD ops no body returned
|
423
|
+
exception_class = self.const_get("ItemNotFound")
|
424
|
+
raise exception_class.new("The resource could not be found", "404", "")
|
425
|
+
else
|
426
|
+
JSON.parse(response.body).each_pair do |key, val|
|
427
|
+
fault=key
|
428
|
+
info=val
|
429
|
+
end
|
430
|
+
exception_class = self.const_get(fault[0,1].capitalize+fault[1,fault.length])
|
431
|
+
raise exception_class.new(info["message"], response.code, response.body)
|
432
|
+
end
|
433
|
+
rescue JSON::ParserError => parse_error
|
434
|
+
deal_with_faulty_error(response, parse_error)
|
435
|
+
rescue NameError
|
436
|
+
raise OpenStack::Exception::Other.new("The server returned status #{response.code}", response.code, response.body)
|
437
|
+
end
|
438
|
+
end
|
439
|
+
|
440
|
+
private
|
441
|
+
|
442
|
+
#e.g. os.delete("non-existant") ==> response.body is:
|
443
|
+
# "404 Not Found\n\nThe resource could not be found.\n\n "
|
444
|
+
# which doesn't parse. Deal with such cases here if possible (JSON::ParserError)
|
445
|
+
def self.deal_with_faulty_error(response, parse_error)
|
446
|
+
case response.code
|
447
|
+
when "404"
|
448
|
+
klass = self.const_get("ItemNotFound")
|
449
|
+
msg = "The resource could not be found"
|
450
|
+
when "409"
|
451
|
+
klass = self.const_get("ResourceStateConflict")
|
452
|
+
msg = "There was a conflict with the state of the resource"
|
453
|
+
else
|
454
|
+
klass = self.const_get("Other")
|
455
|
+
msg = "Oops - not sure what happened: #{parse_error}"
|
456
|
+
end
|
457
|
+
raise klass.new(msg, response.code.to_s, response.body)
|
458
|
+
end
|
459
|
+
end
|
460
|
+
|
461
|
+
end
|
462
|
+
|