elasticsearch-watcher 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +13 -0
- data/README.md +166 -0
- data/Rakefile +99 -0
- data/elasticsearch-watcher.gemspec +40 -0
- data/lib/elasticsearch-watcher.rb +1 -0
- data/lib/elasticsearch/watcher.rb +37 -0
- data/lib/elasticsearch/watcher/api/actions/ack_watch.rb +27 -0
- data/lib/elasticsearch/watcher/api/actions/delete_watch.rb +29 -0
- data/lib/elasticsearch/watcher/api/actions/execute_watch.rb +27 -0
- data/lib/elasticsearch/watcher/api/actions/get_watch.rb +31 -0
- data/lib/elasticsearch/watcher/api/actions/info.rb +23 -0
- data/lib/elasticsearch/watcher/api/actions/put_watch.rb +29 -0
- data/lib/elasticsearch/watcher/api/actions/restart.rb +24 -0
- data/lib/elasticsearch/watcher/api/actions/start.rb +23 -0
- data/lib/elasticsearch/watcher/api/actions/stats.rb +23 -0
- data/lib/elasticsearch/watcher/api/actions/stop.rb +23 -0
- data/lib/elasticsearch/watcher/version.rb +5 -0
- data/test/test_helper.rb +87 -0
- data/test/unit/ack_watch_test.rb +26 -0
- data/test/unit/delete_watch_test.rb +26 -0
- data/test/unit/execute_watch_test.rb +26 -0
- data/test/unit/get_watch_test.rb +26 -0
- data/test/unit/info_test.rb +26 -0
- data/test/unit/put_watch_test.rb +26 -0
- data/test/unit/restart_test.rb +26 -0
- data/test/unit/start_test.rb +26 -0
- data/test/unit/stats_test.rb +26 -0
- data/test/unit/stop_test.rb +26 -0
- metadata +310 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 997c4ba2c5e12dbd058aa6ce597770019f7f6fbd
|
4
|
+
data.tar.gz: 4b9088c48307f47f57b56426abb7030f7b8c2cfa
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 5852020cbae6eb416ebdf4ed1ca99d0d4271d3dcd6324772a182575bb22fe8a6d388d96a706b12d6c2ca5b1aee0dfc3a507cb993b3b37428079050e34f749ea3
|
7
|
+
data.tar.gz: 2d1327265ba961cda13400847caa802b37bdaf78029994db88fe6c1cd61b402d6b9b97a20273fc91e2bb1b5de90e66f66d98864fd54d165ae53453e3fb7d9bce
|
data/.gitignore
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
.bundle
|
4
|
+
.config
|
5
|
+
.yardoc
|
6
|
+
Gemfile.lock
|
7
|
+
InstalledFiles
|
8
|
+
_yardoc
|
9
|
+
coverage
|
10
|
+
doc/
|
11
|
+
lib/bundler/man
|
12
|
+
pkg
|
13
|
+
rdoc
|
14
|
+
spec/reports
|
15
|
+
test/tmp
|
16
|
+
test/version_tmp
|
17
|
+
tmp
|
18
|
+
*.bundle
|
19
|
+
*.so
|
20
|
+
*.o
|
21
|
+
*.a
|
22
|
+
mkmf.log
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
Copyright (c) 2015 Elasticsearch
|
2
|
+
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
you may not use this file except in compliance with the License.
|
5
|
+
You may obtain a copy of the License at
|
6
|
+
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
See the License for the specific language governing permissions and
|
13
|
+
limitations under the License.
|
data/README.md
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
# Elasticsearch::Watcher
|
2
|
+
|
3
|
+
This library provides Ruby API for the [_Watcher_](https://www.elastic.co/products/watcher) plugin.
|
4
|
+
|
5
|
+
Please refer to the [_Watcher_ documentation](http://www.elastic.co/guide/en/watcher/current/index.html)
|
6
|
+
for information about the plugin.
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
Install the package from [Rubygems](https://rubygems.org):
|
11
|
+
|
12
|
+
gem install elasticsearch-watcher
|
13
|
+
|
14
|
+
To use an unreleased version, either add it to your `Gemfile` for [Bundler](http://gembundler.com):
|
15
|
+
|
16
|
+
gem 'elasticsearch-watcher', git: 'git://github.com/elasticsearch/elasticsearch-ruby.git'
|
17
|
+
|
18
|
+
or install it from a source code checkout:
|
19
|
+
|
20
|
+
git clone https://github.com/elasticsearch/elasticsearch-ruby.git
|
21
|
+
cd elasticsearch-ruby/elasticsearch-watcher
|
22
|
+
bundle install
|
23
|
+
rake install
|
24
|
+
|
25
|
+
## Usage
|
26
|
+
|
27
|
+
The documentation for the Ruby API methods is available at <http://www.rubydoc.info/gems/elasticsearch-watcher>.
|
28
|
+
|
29
|
+
A comprehensive example of registering a watch, triggering the actions, and getting information
|
30
|
+
about the watch execution is quoted below.
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
require 'elasticsearch'
|
34
|
+
require 'elasticsearch/watcher'
|
35
|
+
|
36
|
+
client = Elasticsearch::Client.new url: 'http://localhost:9200', log: true
|
37
|
+
client.transport.logger.formatter = proc do |severity, datetime, progname, msg| "\e[2m#{msg}\e[0m\n" end
|
38
|
+
|
39
|
+
# Delete the Watcher and test indices
|
40
|
+
#
|
41
|
+
client.indices.delete index: ['alerts', 'test', '.watches', '.watch_history*'], ignore: 404
|
42
|
+
|
43
|
+
# Print information about the Watcher plugin
|
44
|
+
#
|
45
|
+
puts "Watcher #{client.watcher.info['version']['number']}"
|
46
|
+
|
47
|
+
# Register a new watch
|
48
|
+
#
|
49
|
+
client.watcher.put_watch id: 'error_500', body: {
|
50
|
+
# Label the watch
|
51
|
+
#
|
52
|
+
metadata: { tags: ['errors'] },
|
53
|
+
|
54
|
+
# Run the watch every 10 seconds
|
55
|
+
#
|
56
|
+
trigger: { schedule: { interval: '10s' } },
|
57
|
+
|
58
|
+
# Search for at least 3 documents matching the condition
|
59
|
+
#
|
60
|
+
condition: { script: { inline: 'ctx.payload.hits.total > 3' } },
|
61
|
+
|
62
|
+
# Throttle the watch execution for 30 seconds
|
63
|
+
#
|
64
|
+
throttle_period: '30s',
|
65
|
+
|
66
|
+
# The search request to execute
|
67
|
+
#
|
68
|
+
input: { search: {
|
69
|
+
request: {
|
70
|
+
indices: ['test'],
|
71
|
+
body: {
|
72
|
+
query: {
|
73
|
+
filtered: {
|
74
|
+
query: { match: { status: 500 } },
|
75
|
+
filter: { range: { timestamp: { from: '{{ctx.trigger.scheduled_time}}||-5m', to: '{{ctx.trigger.triggered_time}}' } } }
|
76
|
+
}
|
77
|
+
},
|
78
|
+
# Return statistics about different hosts
|
79
|
+
#
|
80
|
+
aggregations: {
|
81
|
+
hosts: { terms: { field: 'host' } }
|
82
|
+
}
|
83
|
+
}}}},
|
84
|
+
|
85
|
+
# The actions to perform
|
86
|
+
#
|
87
|
+
actions: {
|
88
|
+
send_email: {
|
89
|
+
transform: {
|
90
|
+
# Transform the data for the template
|
91
|
+
#
|
92
|
+
script: 'return [ total: ctx.payload.hits.total, hosts: ctx.payload.aggregations.hosts.buckets.collect { [ host: it.key, errors: it.doc_count ] }, errors: ctx.payload.hits.hits.collect { it._source } ];'
|
93
|
+
},
|
94
|
+
email: { to: 'alerts@example.com',
|
95
|
+
subject: '[ALERT] {{ctx.watch_id}}',
|
96
|
+
body: "Received {{ctx.payload.total}} error documents in the last 5 minutes.\n\nHosts:\n\n{{#ctx.payload.hosts}}* {{host}} ({{errors}})\n{{/ctx.payload.hosts}}",
|
97
|
+
attach_data: true }
|
98
|
+
},
|
99
|
+
index_payload: {
|
100
|
+
# Transform the data to be stored
|
101
|
+
#
|
102
|
+
transform: { script: 'return [ watch_id: ctx.watch_id, payload: ctx.payload ]' },
|
103
|
+
index: { index: 'alerts', doc_type: 'alert' }
|
104
|
+
},
|
105
|
+
ping_webhook: {
|
106
|
+
webhook: {
|
107
|
+
method: 'POST',
|
108
|
+
host: 'localhost',
|
109
|
+
port: 4567,
|
110
|
+
path: '/',
|
111
|
+
body: %q|{"watch_id" : "{{ctx.watch_id}}", "payload" : "{{ctx.payload}}"}| }
|
112
|
+
}
|
113
|
+
}
|
114
|
+
}
|
115
|
+
|
116
|
+
# Index documents to trigger the watch
|
117
|
+
#
|
118
|
+
5.times do
|
119
|
+
client.index index: 'test', type: 'd',
|
120
|
+
body: { timestamp: Time.now.utc.iso8601, status: 500, host: "10.0.0.#{rand(1..3)}" }
|
121
|
+
end
|
122
|
+
|
123
|
+
# Wait a bit...
|
124
|
+
#
|
125
|
+
print "Waiting 30 seconds..."
|
126
|
+
$i=0; while $i < 30 do
|
127
|
+
sleep(1); print('.'); $i+=1
|
128
|
+
end; puts "\n"
|
129
|
+
|
130
|
+
# Display information about watch execution
|
131
|
+
#
|
132
|
+
puts '='*80, ""
|
133
|
+
client.search(index: '.watch_history*', q: 'watch_id:error_500', sort: 'timestamp:asc')['hits']['hits'].each do |r|
|
134
|
+
puts "#{r['_id']} : #{r['_source']['state']}"
|
135
|
+
end
|
136
|
+
|
137
|
+
# Delete the watch
|
138
|
+
#
|
139
|
+
puts "Deleting the watch..."
|
140
|
+
client.watcher.delete_watch id: 'error_500', master_timeout: '30s', force: true
|
141
|
+
```
|
142
|
+
|
143
|
+
You can run a simple [Sinatra](https://github.com/sinatra/sinatra/) server
|
144
|
+
to test the `webhook` action with the following Ruby code:
|
145
|
+
|
146
|
+
```bash
|
147
|
+
ruby -r sinatra -r json -e 'post("/") { json = JSON.parse(request.body.read); puts %Q~Received #{json["watch_id"]} with payload: #{json["payload"]}~ }'
|
148
|
+
```
|
149
|
+
|
150
|
+
## License
|
151
|
+
|
152
|
+
This software is licensed under the Apache 2 license, quoted below.
|
153
|
+
|
154
|
+
Copyright (c) 2015 Elasticsearch <http://www.elasticsearch.org>
|
155
|
+
|
156
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
157
|
+
you may not use this file except in compliance with the License.
|
158
|
+
You may obtain a copy of the License at
|
159
|
+
|
160
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
161
|
+
|
162
|
+
Unless required by applicable law or agreed to in writing, software
|
163
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
164
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
165
|
+
See the License for the specific language governing permissions and
|
166
|
+
limitations under the License.
|
data/Rakefile
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'bundler/gem_tasks'
|
3
|
+
|
4
|
+
__current__ = Pathname( File.expand_path('..', __FILE__) )
|
5
|
+
|
6
|
+
task(:default) { system "rake --tasks" }
|
7
|
+
task :test => 'test:unit'
|
8
|
+
|
9
|
+
# ----- Test tasks ------------------------------------------------------------
|
10
|
+
|
11
|
+
require 'rake/testtask'
|
12
|
+
namespace :test do
|
13
|
+
Rake::TestTask.new(:unit) do |test|
|
14
|
+
test.libs << 'lib' << 'test'
|
15
|
+
test.test_files = FileList["test/unit/**/*_test.rb"]
|
16
|
+
# test.verbose = true
|
17
|
+
# test.warning = true
|
18
|
+
end
|
19
|
+
|
20
|
+
desc "Run integration tests"
|
21
|
+
task :integration do
|
22
|
+
unless ENV['TEST_REST_API_SPEC']
|
23
|
+
puts "[!] Please export the TEST_REST_API_SPEC variable with a path to the Watcher YAML tests"
|
24
|
+
exit(1)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Define the task
|
28
|
+
t = Rake::TestTask.new(:integration) do |test|
|
29
|
+
test.libs << 'lib' << 'test'
|
30
|
+
test.test_files = FileList["../elasticsearch-api/test/integration/yaml_test_runner.rb", "test/integration/**/*_test.rb"]
|
31
|
+
end
|
32
|
+
|
33
|
+
# Run the task
|
34
|
+
args = [t.ruby_opts_string, t.run_code, t.file_list_string, t.option_list].join(' ')
|
35
|
+
|
36
|
+
ruby args do |ok, status|
|
37
|
+
if !ok && status.respond_to?(:signaled?) && status.signaled?
|
38
|
+
raise SignalException.new(status.termsig)
|
39
|
+
elsif !ok
|
40
|
+
fail "Command failed with status (#{status.exitstatus}): " + "[ruby #{args}]"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
desc "Run unit and integration tests"
|
46
|
+
task :all do
|
47
|
+
Rake::Task['test:ci_reporter'].invoke if ENV['CI']
|
48
|
+
Rake::Task['test:unit'].invoke
|
49
|
+
Rake::Task['test:integration'].invoke
|
50
|
+
end
|
51
|
+
|
52
|
+
desc "Initialize or update the repository with integration tests"
|
53
|
+
task :update do
|
54
|
+
sh "git --git-dir=#{__current__.join('tmp/elasticsearch-watcher/.git')} --work-tree=#{__current__.join('tmp/elasticsearch-watcher')} fetch origin --verbose"
|
55
|
+
begin
|
56
|
+
puts %x[git --git-dir=#{__current__.join('tmp/elasticsearch-watcher/.git')} --work-tree=#{__current__.join('tmp/elasticsearch-watcher')} pull --verbose]
|
57
|
+
rescue Exception => @exception
|
58
|
+
@failed = true
|
59
|
+
end
|
60
|
+
|
61
|
+
if @failed || !$?.success?
|
62
|
+
STDERR.puts "", "[!] Error while pulling -- #{@exception}"
|
63
|
+
end
|
64
|
+
|
65
|
+
puts "\n", "CHANGES:", '-'*80
|
66
|
+
sh "git --git-dir=#{__current__.join('tmp/elasticsearch-watcher/.git')} --work-tree=#{__current__.join('tmp/elasticsearch-watcher')} log --oneline ORIG_HEAD..HEAD | cat", :verbose => false
|
67
|
+
end
|
68
|
+
|
69
|
+
namespace :cluster do
|
70
|
+
desc "Start Elasticsearch nodes for tests"
|
71
|
+
task :start do
|
72
|
+
$LOAD_PATH << File.expand_path('../../elasticsearch-transport/lib', __FILE__) << File.expand_path('../test', __FILE__)
|
73
|
+
require 'elasticsearch/extensions/test/cluster'
|
74
|
+
Elasticsearch::Extensions::Test::Cluster.start
|
75
|
+
end
|
76
|
+
|
77
|
+
desc "Stop Elasticsearch nodes for tests"
|
78
|
+
task :stop do
|
79
|
+
$LOAD_PATH << File.expand_path('../../elasticsearch-transport/lib', __FILE__) << File.expand_path('../test', __FILE__)
|
80
|
+
require 'elasticsearch/extensions/test/cluster'
|
81
|
+
Elasticsearch::Extensions::Test::Cluster.stop
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# ----- Documentation tasks ---------------------------------------------------
|
87
|
+
|
88
|
+
require 'yard'
|
89
|
+
YARD::Rake::YardocTask.new(:doc) do |t|
|
90
|
+
t.options = %w| --embed-mixins --markup=markdown |
|
91
|
+
end
|
92
|
+
|
93
|
+
# ----- Code analysis tasks ---------------------------------------------------
|
94
|
+
|
95
|
+
require 'cane/rake_task'
|
96
|
+
Cane::RakeTask.new(:quality) do |cane|
|
97
|
+
cane.abc_max = 15
|
98
|
+
cane.no_style = true
|
99
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'elasticsearch/watcher/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = "elasticsearch-watcher"
|
8
|
+
s.version = Elasticsearch::Watcher::VERSION
|
9
|
+
s.authors = ["Karel Minarik"]
|
10
|
+
s.email = ["karel.minarik@elasticsearch.org"]
|
11
|
+
s.description = %q{Ruby Integrations for Elasticsearch Watcher plugin (WIP)}
|
12
|
+
s.summary = s.description
|
13
|
+
s.homepage = ""
|
14
|
+
s.license = "Apache 2"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split($/)
|
17
|
+
s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
s.add_dependency "elasticsearch-api"
|
22
|
+
|
23
|
+
s.add_development_dependency "bundler", "~> 1.3"
|
24
|
+
s.add_development_dependency "rake"
|
25
|
+
|
26
|
+
s.add_development_dependency "elasticsearch"
|
27
|
+
s.add_development_dependency "elasticsearch-extensions"
|
28
|
+
|
29
|
+
s.add_development_dependency 'shoulda-context'
|
30
|
+
s.add_development_dependency 'activesupport'
|
31
|
+
s.add_development_dependency 'turn'
|
32
|
+
s.add_development_dependency 'mocha'
|
33
|
+
s.add_development_dependency 'minitest', '~> 4.0'
|
34
|
+
s.add_development_dependency 'minitest-reporters'
|
35
|
+
s.add_development_dependency 'simplecov'
|
36
|
+
s.add_development_dependency 'simplecov-rcov'
|
37
|
+
s.add_development_dependency 'yard'
|
38
|
+
s.add_development_dependency 'cane'
|
39
|
+
s.add_development_dependency 'pry'
|
40
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'elasticsearch/watcher'
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require "elasticsearch/watcher/version"
|
2
|
+
|
3
|
+
Dir[ File.expand_path('../watcher/api/actions/**/*.rb', __FILE__) ].each { |f| require f }
|
4
|
+
|
5
|
+
module Elasticsearch
|
6
|
+
module Watcher
|
7
|
+
def self.included(base)
|
8
|
+
base.__send__ :include, Elasticsearch::API::Watcher
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module Elasticsearch
|
14
|
+
module API
|
15
|
+
module Watcher
|
16
|
+
module Actions; end
|
17
|
+
|
18
|
+
# Client for the "watcher" namespace (includes the {Watcher::Actions} methods)
|
19
|
+
#
|
20
|
+
class WatcherClient
|
21
|
+
include Elasticsearch::API::Common::Client,
|
22
|
+
Elasticsearch::API::Common::Client::Base,
|
23
|
+
Elasticsearch::API::Watcher::Actions
|
24
|
+
end
|
25
|
+
|
26
|
+
# Proxy method for {WatcherClient}, available in the receiving object
|
27
|
+
#
|
28
|
+
def watcher
|
29
|
+
@watcher ||= WatcherClient.new(self)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
Elasticsearch::API.__send__ :include, Elasticsearch::API::Watcher
|
36
|
+
|
37
|
+
Elasticsearch::Transport::Client.__send__ :include, Elasticsearch::API::Watcher if defined?(Elasticsearch::Transport::Client)
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Elasticsearch
|
2
|
+
module API
|
3
|
+
module Watcher
|
4
|
+
module Actions
|
5
|
+
|
6
|
+
# Throttle the execution of the watch by acknowledging it
|
7
|
+
#
|
8
|
+
# @option arguments [String] :id Watch ID (*Required*)
|
9
|
+
#
|
10
|
+
# @see http://www.elastic.co/guide/en/watcher/current/appendix-api-ack-watch.html
|
11
|
+
#
|
12
|
+
def ack_watch(arguments={})
|
13
|
+
raise ArgumentError, "Required argument 'id' missing" unless arguments[:id]
|
14
|
+
valid_params = [
|
15
|
+
:master_timeout
|
16
|
+
]
|
17
|
+
method = 'PUT'
|
18
|
+
path = "_watcher/watch/#{arguments[:id]}/_ack"
|
19
|
+
params = {}
|
20
|
+
body = nil
|
21
|
+
|
22
|
+
perform_request(method, path, params, body).body
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Elasticsearch
|
2
|
+
module API
|
3
|
+
module Watcher
|
4
|
+
module Actions
|
5
|
+
|
6
|
+
# Delete a specific watch
|
7
|
+
#
|
8
|
+
# @option arguments [String] :id Watch ID (*Required*)
|
9
|
+
# @option arguments [Boolean] :force Ignore any locks on the watch and force the execution
|
10
|
+
#
|
11
|
+
# @see http://www.elastic.co/guide/en/watcher/current/appendix-api-delete-watch.html
|
12
|
+
#
|
13
|
+
def delete_watch(arguments={})
|
14
|
+
raise ArgumentError, "Required argument 'id' missing" unless arguments[:id]
|
15
|
+
valid_params = [
|
16
|
+
:master_timeout,
|
17
|
+
:force
|
18
|
+
]
|
19
|
+
method = 'DELETE'
|
20
|
+
path = "_watcher/watch/#{arguments[:id]}"
|
21
|
+
params = {}
|
22
|
+
body = nil
|
23
|
+
|
24
|
+
perform_request(method, path, params, body).body
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|