ceph_storage 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.cluster.yml.example +19 -0
- data/.gitignore +1 -0
- data/.rspec +2 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +110 -0
- data/Guardfile +20 -0
- data/README.md +108 -0
- data/Rakefile +7 -0
- data/ceph_storage.gemspec +36 -0
- data/lib/ceph_storage.rb +28 -0
- data/lib/ceph_storage/cluster.rb +93 -0
- data/lib/ceph_storage/cluster_factory.rb +24 -0
- data/lib/ceph_storage/cluster_wrapper.rb +16 -0
- data/lib/ceph_storage/pool.rb +126 -0
- data/lib/ceph_storage/pool_enumerator.rb +24 -0
- data/lib/ceph_storage/pool_factory.rb +15 -0
- data/lib/ceph_storage/pool_wrapper.rb +17 -0
- data/lib/ceph_storage/storage_object.rb +48 -0
- data/lib/ceph_storage/storage_object/file_storage_object.rb +28 -0
- data/lib/ceph_storage/storage_object/rados_storage_object.rb +41 -0
- data/lib/ceph_storage/storage_object/rados_storage_object_enumerator.rb +29 -0
- data/lib/ceph_storage/storage_object/rados_wrapper.rb +21 -0
- data/lib/ceph_storage/storage_object/url_storage_object.rb +34 -0
- data/lib/ceph_storage/storage_object/xattr.rb +18 -0
- data/lib/ceph_storage/storage_object/xattr_enumerator.rb +29 -0
- data/lib/ceph_storage/version.rb +3 -0
- data/spec/ceph_storage_cluster_factory_spec.rb +14 -0
- data/spec/ceph_storage_cluster_spec.rb +76 -0
- data/spec/ceph_storage_file_storage_object_spec.rb +31 -0
- data/spec/ceph_storage_pool_enumerator.rb +34 -0
- data/spec/ceph_storage_pool_factory_spec.rb +14 -0
- data/spec/ceph_storage_pool_spec.rb +167 -0
- data/spec/ceph_storage_rados_storage_object_enumerator_spec.rb +72 -0
- data/spec/ceph_storage_rados_storage_object_spec.rb +112 -0
- data/spec/ceph_storage_storage_object_spec.rb +98 -0
- data/spec/ceph_storage_xattr_enumerator_spec.rb +75 -0
- data/spec/ceph_storage_xattr_spec.rb +69 -0
- data/spec/spec_helper.rb +30 -0
- data/tasks/rspec.rake +4 -0
- data/tasks/rubocop.rake +13 -0
- metadata +281 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: f11517603a21b107977fee01ece18f2974dc0d98
|
4
|
+
data.tar.gz: 033f874f008ff2e9e30f658bb68a8ed195877dd4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 79aa89777a93a9fa26d30e2db0886b6aa70ba0ab6ee008e7a3be6d0abf46597246fc56851cc3e9bda9921af26358e758eedea21237ebf26eaf754ecebe8e1506
|
7
|
+
data.tar.gz: dbe0c85a8d9739bc50f78ac44783341c8717ca13f7692d8fc0bfec40af556812a3c2bb1b2ba61ccdb26c2c07e46049d78e3574a543758a5879e07900dc1654c5
|
@@ -0,0 +1,19 @@
|
|
1
|
+
---
|
2
|
+
:cluster: ceph
|
3
|
+
:config_dir: "/etc/ceph"
|
4
|
+
:user: client.admin
|
5
|
+
:flags: 0
|
6
|
+
:readable: true # User has +r mon cap
|
7
|
+
:writable: true # User has +w mon cap
|
8
|
+
:pool:
|
9
|
+
:name: rspec_test
|
10
|
+
:create_delete: true # Means you can run create and delete on this pool {beware of dragons!}
|
11
|
+
# If you accidentally set this and you have a pool with the same 'name' (see above) it will
|
12
|
+
# delete it at the end of the rspec test along with all of the data in it!
|
13
|
+
# This only works if the user has +w mon cap
|
14
|
+
:rule_id: 2
|
15
|
+
:writable: true # Means this pool has read/write permissions {beware of dragons!}
|
16
|
+
# If you accidentally set this to true and you have objects in your pool
|
17
|
+
# that you want to keep with name = object_name, it will overwrite and then delete them!
|
18
|
+
# this only works if the user has +w osd cap at least on pool[:name]
|
19
|
+
:object_name: rspec_test_object
|
data/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
.cluster.yml
|
data/.rspec
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
ceph_storage (0.1.0)
|
5
|
+
activesupport (~> 3.0)
|
6
|
+
ceph-ruby-livelink (~> 1.5)
|
7
|
+
facets (~> 3.0)
|
8
|
+
ffi (~> 1.9)
|
9
|
+
|
10
|
+
GEM
|
11
|
+
remote: https://rubygems.org/
|
12
|
+
specs:
|
13
|
+
activesupport (3.2.22.2)
|
14
|
+
i18n (~> 0.6, >= 0.6.4)
|
15
|
+
multi_json (~> 1.0)
|
16
|
+
ast (2.2.0)
|
17
|
+
ceph-ruby-livelink (1.5.1)
|
18
|
+
activesupport (~> 3.0)
|
19
|
+
ffi (~> 1.9)
|
20
|
+
coderay (1.1.1)
|
21
|
+
diff-lcs (1.2.5)
|
22
|
+
facets (3.1.0)
|
23
|
+
ffi (1.9.10)
|
24
|
+
formatador (0.2.5)
|
25
|
+
guard (2.14.0)
|
26
|
+
formatador (>= 0.2.4)
|
27
|
+
listen (>= 2.7, < 4.0)
|
28
|
+
lumberjack (~> 1.0)
|
29
|
+
nenv (~> 0.1)
|
30
|
+
notiffany (~> 0.0)
|
31
|
+
pry (>= 0.9.12)
|
32
|
+
shellany (~> 0.0)
|
33
|
+
thor (>= 0.18.1)
|
34
|
+
guard-bundler (2.1.0)
|
35
|
+
bundler (~> 1.0)
|
36
|
+
guard (~> 2.2)
|
37
|
+
guard-compat (~> 1.1)
|
38
|
+
guard-compat (1.2.1)
|
39
|
+
guard-rake (1.0.0)
|
40
|
+
guard
|
41
|
+
rake
|
42
|
+
guard-rspec (4.7.0)
|
43
|
+
guard (~> 2.1)
|
44
|
+
guard-compat (~> 1.1)
|
45
|
+
rspec (>= 2.99.0, < 4.0)
|
46
|
+
i18n (0.7.0)
|
47
|
+
listen (3.0.6)
|
48
|
+
rb-fsevent (>= 0.9.3)
|
49
|
+
rb-inotify (>= 0.9.7)
|
50
|
+
lumberjack (1.0.10)
|
51
|
+
method_source (0.8.2)
|
52
|
+
multi_json (1.12.1)
|
53
|
+
nenv (0.3.0)
|
54
|
+
notiffany (0.1.0)
|
55
|
+
nenv (~> 0.1)
|
56
|
+
shellany (~> 0.0)
|
57
|
+
parser (2.3.1.0)
|
58
|
+
ast (~> 2.2)
|
59
|
+
powerpack (0.1.1)
|
60
|
+
pry (0.10.3)
|
61
|
+
coderay (~> 1.1.0)
|
62
|
+
method_source (~> 0.8.1)
|
63
|
+
slop (~> 3.4)
|
64
|
+
rainbow (2.1.0)
|
65
|
+
rake (11.1.2)
|
66
|
+
rb-fsevent (0.9.7)
|
67
|
+
rb-inotify (0.9.7)
|
68
|
+
ffi (>= 0.5.0)
|
69
|
+
rspec (3.4.0)
|
70
|
+
rspec-core (~> 3.4.0)
|
71
|
+
rspec-expectations (~> 3.4.0)
|
72
|
+
rspec-mocks (~> 3.4.0)
|
73
|
+
rspec-core (3.4.4)
|
74
|
+
rspec-support (~> 3.4.0)
|
75
|
+
rspec-expectations (3.4.0)
|
76
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
77
|
+
rspec-support (~> 3.4.0)
|
78
|
+
rspec-mocks (3.4.1)
|
79
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
80
|
+
rspec-support (~> 3.4.0)
|
81
|
+
rspec-support (3.4.1)
|
82
|
+
rubocop (0.40.0)
|
83
|
+
parser (>= 2.3.1.0, < 3.0)
|
84
|
+
powerpack (~> 0.1)
|
85
|
+
rainbow (>= 1.99.1, < 3.0)
|
86
|
+
ruby-progressbar (~> 1.7)
|
87
|
+
unicode-display_width (~> 1.0, >= 1.0.1)
|
88
|
+
ruby-progressbar (1.8.1)
|
89
|
+
shellany (0.0.1)
|
90
|
+
slop (3.6.0)
|
91
|
+
thor (0.19.1)
|
92
|
+
unicode-display_width (1.0.5)
|
93
|
+
|
94
|
+
PLATFORMS
|
95
|
+
ruby
|
96
|
+
|
97
|
+
DEPENDENCIES
|
98
|
+
bundler (~> 1.3)
|
99
|
+
ceph_storage!
|
100
|
+
guard (~> 2.13)
|
101
|
+
guard-bundler (~> 2.1)
|
102
|
+
guard-rake (~> 1.0)
|
103
|
+
guard-rspec (~> 4.6)
|
104
|
+
listen (= 3.0.6)
|
105
|
+
rake (~> 11.1)
|
106
|
+
rspec (~> 3.4)
|
107
|
+
rubocop (~> 0.39)
|
108
|
+
|
109
|
+
BUNDLED WITH
|
110
|
+
1.11.2
|
data/Guardfile
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# A sample Guardfile
|
2
|
+
# More info at https://github.com/guard/guard#readme
|
3
|
+
|
4
|
+
## Uncomment and set this to only include directories you want to watch
|
5
|
+
# directories %w(app lib config test spec features) \
|
6
|
+
# .select{|d| Dir.exists?(d) ? d : UI.warning("Directory #{d} does not exist")}
|
7
|
+
|
8
|
+
## Note: if you are using the `directories` clause above and you are not
|
9
|
+
## watching the project directory ('.'), then you will want to move
|
10
|
+
## the Guardfile to a watched dir and symlink it back, e.g.
|
11
|
+
#
|
12
|
+
# $ mkdir config
|
13
|
+
# $ mv Guardfile config/
|
14
|
+
# $ ln -s config/Guardfile .
|
15
|
+
#
|
16
|
+
# and, you'll have to watch "config/Guardfile" instead of "Guardfile"
|
17
|
+
|
18
|
+
guard 'rake', task: 'test' do
|
19
|
+
watch(%r{^(spec|lib/.*)/.*.rb})
|
20
|
+
end
|
data/README.md
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
# Ceph::Storage
|
2
|
+
|
3
|
+
A userspace wrapper around the [http://github.com/ceph/ceph-ruby/](Ceph::Ruby) library to allow applications to pool their requests to the ceph backend and conduct thread safe operations with ceph
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
Add this line to your appication's Gemfile
|
7
|
+
gem 'ceph_storage'
|
8
|
+
|
9
|
+
And then execute:
|
10
|
+
$ bundle
|
11
|
+
|
12
|
+
Or install it yourself as:
|
13
|
+
$gem install ceph_storage
|
14
|
+
|
15
|
+
|
16
|
+
## About
|
17
|
+
The Ceph-ruby gem provides an FFI wrapper around librados in order to facilitate connections to ceph and to provide an easy management of objects and cluster settings.
|
18
|
+
There are some limitations of this model in that when using it to download large amounts of files in many scenarios there can be significant overhead of connecting to
|
19
|
+
the cluster.
|
20
|
+
|
21
|
+
This library uses the Facets::Multiton model in order to ensure that if you pass the same attributes into cluster and pool objects, you will always get the same objects
|
22
|
+
back.
|
23
|
+
|
24
|
+
## Pool collection
|
25
|
+
In order to provide thread safety, the Pool object carries with it a collection of `::CephRuby::Pool` objects. As such operations on this will reuse an unused connection or spawn a new pool
|
26
|
+
connection if no free one exists. On completion (or push) the pool object will continue to stay open to serve further objects.
|
27
|
+
|
28
|
+
## Usage
|
29
|
+
|
30
|
+
require 'ceph_storage'
|
31
|
+
config = { cluster: 'ceph', user: 'client.admin', flags: 0, config_dir: '/etc/ceph' } # defaults
|
32
|
+
|
33
|
+
cluster1 = CephStorage::ClusterFactory.build(config)
|
34
|
+
|
35
|
+
cluster2 = CephStorage::ClusterFactory.build(config)
|
36
|
+
|
37
|
+
cluster1.equal? cluster2 # => true
|
38
|
+
|
39
|
+
pool1 = cluster1.pool('foo')
|
40
|
+
|
41
|
+
pool2 = cluster2.pool('foo')
|
42
|
+
|
43
|
+
pool1.equal? pool2 # => true
|
44
|
+
|
45
|
+
|
46
|
+
pool1.storage_object('bar') do |file|
|
47
|
+
puts file.read_file
|
48
|
+
end
|
49
|
+
|
50
|
+
### Using blocks
|
51
|
+
# Will connect to ceph, open a pool, download the contents
|
52
|
+
# of a file
|
53
|
+
CephStorage::ClusterFactory.build(config) do |c|
|
54
|
+
c.pool('foo') do |p|
|
55
|
+
# NB Storage_object as opposed to rados_object
|
56
|
+
p.storage_object('bar') |f|
|
57
|
+
puts f.read_file
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Does the same thing, but should reuse the same objects
|
63
|
+
# Hence the only latency is downloading the file contents
|
64
|
+
CephStorage::ClusterFactory.build(config) do |c|
|
65
|
+
c.pool('foo') do |p|
|
66
|
+
p.storage_object('bar') |f|
|
67
|
+
puts f.read_file
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
## Raw Pool object
|
73
|
+
|
74
|
+
If there is some functionality that you need that isn't provided, you can expose the raw `::CephRuby::Pool` objects by calling `pool.rados_pool`. This will return an object and mark it 'in use' until you push it back into the collection via `rados_object=` Be sure to do this lest your memory leak.
|
75
|
+
|
76
|
+
### Storage Object interfaces
|
77
|
+
|
78
|
+
The `StorageObject` interface provides some additional functionality to ceph on top of the CephRuby bindings
|
79
|
+
|
80
|
+
* `CephStorage::StorageObject::FileStorageObject` (wrapper for ::File)
|
81
|
+
* `CephStorage::StorageObject::URLStorageObject` (wrapper for url-open)
|
82
|
+
|
83
|
+
These classes are designed to allow you to copy and move files from ceph to other storage mechanisms. It is designed to be extensible
|
84
|
+
|
85
|
+
f_storage_obj1 = CephStorage::StorageObject::FileStorageObject.new 'path/to/file1'
|
86
|
+
f_storage_obj2 = CephStorage::StorageObject::URLStorageObject.new 'http://example.com'
|
87
|
+
|
88
|
+
CephStorage::ClusterFactory.build(config) do |c|
|
89
|
+
c.pool('foo') do |p|
|
90
|
+
p.storage_object('bar') |f|
|
91
|
+
f.move f_storage_obj1
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
CephStorage::ClusterFactory.build(config) do |c|
|
97
|
+
c.pool('foo') do |p|
|
98
|
+
p.storage_object('baz') |f|
|
99
|
+
f_storage_obj1.copy f
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
|
105
|
+
In order for this to work, you class should extend CephStorage::StorageObject and implement `read_file` and `write_file` methods.
|
106
|
+
URLStorageObject will raise exception for `write_file` as not implemented (hence move will not work).
|
107
|
+
|
108
|
+
|
data/Rakefile
ADDED
@@ -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 'ceph_storage/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = 'ceph_storage'
|
8
|
+
gem.version = CephStorage::VERSION
|
9
|
+
gem.authors = ['Livelink Technology LTD', 'Stuart Harland']
|
10
|
+
gem.email = ['infraops@livelinktechnology.net']
|
11
|
+
gem.description = 'Easy access of Objects in ceph'
|
12
|
+
gem.summary = 'Ceph-ruby provides an API, however this gem provides '\
|
13
|
+
'consistent connection to ceph pools and clusters '\
|
14
|
+
'using multiton objects'
|
15
|
+
gem.homepage = 'https://github.com/livelink/ceph_storage'
|
16
|
+
gem.license = 'MIT'
|
17
|
+
|
18
|
+
gem.files = `git ls-files`.split($RS)
|
19
|
+
gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
|
20
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
21
|
+
gem.require_paths = ['lib']
|
22
|
+
|
23
|
+
gem.add_dependency('ffi', '~> 1.9')
|
24
|
+
gem.add_dependency('ceph-ruby-livelink', '~> 1.5')
|
25
|
+
gem.add_dependency('activesupport', '~> 3.0')
|
26
|
+
gem.add_dependency('facets', '~> 3.0')
|
27
|
+
gem.add_development_dependency 'guard', '~> 2.13'
|
28
|
+
gem.add_development_dependency 'listen', '= 3.0.6'
|
29
|
+
gem.add_development_dependency 'guard-rake', '~> 1.0'
|
30
|
+
gem.add_development_dependency 'guard-rspec', '~> 4.6'
|
31
|
+
gem.add_development_dependency 'guard-bundler', '~> 2.1'
|
32
|
+
gem.add_development_dependency 'rubocop', '~> 0.39'
|
33
|
+
gem.add_development_dependency 'rspec', '~> 3.4'
|
34
|
+
gem.add_development_dependency 'rake', '~> 11.1'
|
35
|
+
gem.add_development_dependency 'bundler', '~> 1.3'
|
36
|
+
end
|
data/lib/ceph_storage.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'ceph-ruby'
|
3
|
+
require 'facets/multiton'
|
4
|
+
require 'ceph_storage/cluster_wrapper'
|
5
|
+
require 'ceph_storage/cluster_factory'
|
6
|
+
require 'ceph_storage/cluster'
|
7
|
+
require 'ceph_storage/pool_wrapper'
|
8
|
+
require 'ceph_storage/pool_factory'
|
9
|
+
require 'ceph_storage/pool'
|
10
|
+
require 'ceph_storage/pool_enumerator'
|
11
|
+
require 'ceph_storage/storage_object'
|
12
|
+
require 'ceph_storage/storage_object/rados_wrapper'
|
13
|
+
require 'ceph_storage/storage_object/rados_storage_object'
|
14
|
+
require 'ceph_storage/storage_object/rados_storage_object_enumerator'
|
15
|
+
require 'ceph_storage/storage_object/file_storage_object'
|
16
|
+
require 'ceph_storage/storage_object/url_storage_object'
|
17
|
+
require 'ceph_storage/storage_object/xattr'
|
18
|
+
require 'ceph_storage/storage_object/xattr_enumerator'
|
19
|
+
|
20
|
+
# An application for moving files into and out of Ceph
|
21
|
+
module CephStorage
|
22
|
+
mattr_accessor :logger
|
23
|
+
|
24
|
+
def self.log(message)
|
25
|
+
return unless logger
|
26
|
+
logger.info("CephStorage: #{message}")
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module CephStorage
|
2
|
+
# Create a cluster object
|
3
|
+
# Returns only a single cluster object for given settings
|
4
|
+
class Cluster < ClusterFactory
|
5
|
+
extend CephStorage::ClusterWrapper
|
6
|
+
include Multiton
|
7
|
+
# Configure default values for this object
|
8
|
+
attr_reader :cluster_fd, :cluster, :config_dir, :user, :flags
|
9
|
+
|
10
|
+
wrap_me :pool_id_by_name, :pool_name_by_id, :status, :fsid
|
11
|
+
|
12
|
+
private :connect, :setup_using_file
|
13
|
+
|
14
|
+
# Create a new object
|
15
|
+
def initialize(config_dir: CONFIG_DIR, cluster: CLUSTER,
|
16
|
+
user: USER, flags: FLAGS)
|
17
|
+
CephRuby.logger = CephStorage.logger
|
18
|
+
init(config_dir, cluster, user, flags)
|
19
|
+
log("init config_dir #{config_dir}, cluster #{cluster}, user: #{user}")
|
20
|
+
|
21
|
+
open
|
22
|
+
|
23
|
+
yield(@cluster_fd) if block_given?
|
24
|
+
rescue StandardError
|
25
|
+
log("unable to open cluster #{cluster}")
|
26
|
+
raise
|
27
|
+
end
|
28
|
+
|
29
|
+
def rados_cluster
|
30
|
+
return @cluster_fd unless block_given?
|
31
|
+
yield(@cluster_fd)
|
32
|
+
end
|
33
|
+
|
34
|
+
def pool(name)
|
35
|
+
ensure_open
|
36
|
+
p = CephStorage::PoolFactory.build(self, name)
|
37
|
+
yield(p) if block_given?
|
38
|
+
p
|
39
|
+
end
|
40
|
+
|
41
|
+
def pools
|
42
|
+
CephStorage::PoolEnumerator.new(self)
|
43
|
+
end
|
44
|
+
|
45
|
+
def shutdown
|
46
|
+
return unless open?
|
47
|
+
log('shutdown')
|
48
|
+
@cluster_fd.shutdown
|
49
|
+
end
|
50
|
+
|
51
|
+
def open?
|
52
|
+
!@cluster_fd.nil? && !@cluster_fd.handle.nil?
|
53
|
+
end
|
54
|
+
|
55
|
+
def ensure_open
|
56
|
+
return if open?
|
57
|
+
open
|
58
|
+
end
|
59
|
+
|
60
|
+
def open
|
61
|
+
return if open?
|
62
|
+
log('open')
|
63
|
+
fd_init unless @cluster_fd
|
64
|
+
@cluster_fd.connect unless open?
|
65
|
+
end
|
66
|
+
|
67
|
+
def path
|
68
|
+
"ceph://#{cluster}"
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def log(message)
|
74
|
+
CephStorage.log("cluster #{cluster} #{message}")
|
75
|
+
end
|
76
|
+
|
77
|
+
def fd_init
|
78
|
+
@cluster_fd = ::CephRuby::Cluster.new(
|
79
|
+
config_path: config_dir,
|
80
|
+
cluster: cluster,
|
81
|
+
user: user,
|
82
|
+
flags: flags
|
83
|
+
)
|
84
|
+
end
|
85
|
+
|
86
|
+
def init(config_dir, cluster, user, flags)
|
87
|
+
@cluster = cluster
|
88
|
+
@user = user
|
89
|
+
@config_dir = config_dir
|
90
|
+
@flags = flags
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|