splunk-sdk-ruby 0.1.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/CHANGELOG.md +160 -0
- data/Gemfile +8 -0
- data/LICENSE +177 -0
- data/README.md +310 -0
- data/Rakefile +40 -0
- data/examples/1_connect.rb +51 -0
- data/examples/2_manage.rb +103 -0
- data/examples/3_blocking_searches.rb +82 -0
- data/examples/4_asynchronous_searches.rb +79 -0
- data/examples/5_stream_data_to_splunk.rb +79 -0
- data/lib/splunk-sdk-ruby.rb +47 -0
- data/lib/splunk-sdk-ruby/ambiguous_entity_reference.rb +28 -0
- data/lib/splunk-sdk-ruby/atomfeed.rb +323 -0
- data/lib/splunk-sdk-ruby/collection.rb +417 -0
- data/lib/splunk-sdk-ruby/collection/apps.rb +35 -0
- data/lib/splunk-sdk-ruby/collection/case_insensitive_collection.rb +58 -0
- data/lib/splunk-sdk-ruby/collection/configuration_file.rb +50 -0
- data/lib/splunk-sdk-ruby/collection/configurations.rb +80 -0
- data/lib/splunk-sdk-ruby/collection/jobs.rb +136 -0
- data/lib/splunk-sdk-ruby/collection/messages.rb +51 -0
- data/lib/splunk-sdk-ruby/context.rb +522 -0
- data/lib/splunk-sdk-ruby/entity.rb +260 -0
- data/lib/splunk-sdk-ruby/entity/index.rb +191 -0
- data/lib/splunk-sdk-ruby/entity/job.rb +339 -0
- data/lib/splunk-sdk-ruby/entity/message.rb +36 -0
- data/lib/splunk-sdk-ruby/entity/saved_search.rb +71 -0
- data/lib/splunk-sdk-ruby/entity/stanza.rb +45 -0
- data/lib/splunk-sdk-ruby/entity_not_ready.rb +26 -0
- data/lib/splunk-sdk-ruby/illegal_operation.rb +27 -0
- data/lib/splunk-sdk-ruby/namespace.rb +239 -0
- data/lib/splunk-sdk-ruby/resultsreader.rb +716 -0
- data/lib/splunk-sdk-ruby/service.rb +339 -0
- data/lib/splunk-sdk-ruby/splunk_http_error.rb +49 -0
- data/lib/splunk-sdk-ruby/synonyms.rb +50 -0
- data/lib/splunk-sdk-ruby/version.rb +27 -0
- data/lib/splunk-sdk-ruby/xml_shim.rb +117 -0
- data/splunk-sdk-ruby.gemspec +27 -0
- data/test/atom_test_data.rb +472 -0
- data/test/data/atom/atom_feed_with_message.xml +19 -0
- data/test/data/atom/atom_with_feed.xml +99 -0
- data/test/data/atom/atom_with_several_entries.xml +101 -0
- data/test/data/atom/atom_with_simple_entries.xml +30 -0
- data/test/data/atom/atom_without_feed.xml +248 -0
- data/test/data/export/4.2.5/export_results.xml +88 -0
- data/test/data/export/4.3.5/export_results.xml +87 -0
- data/test/data/export/5.0.1/export_results.xml +78 -0
- data/test/data/export/5.0.1/nonreporting.xml +232 -0
- data/test/data/results/4.2.5/results-empty.xml +0 -0
- data/test/data/results/4.2.5/results-preview.xml +255 -0
- data/test/data/results/4.2.5/results.xml +336 -0
- data/test/data/results/4.3.5/results-empty.xml +0 -0
- data/test/data/results/4.3.5/results-preview.xml +1057 -0
- data/test/data/results/4.3.5/results.xml +626 -0
- data/test/data/results/5.0.2/results-empty.xml +1 -0
- data/test/data/results/5.0.2/results-empty_preview.xml +1 -0
- data/test/data/results/5.0.2/results-preview.xml +448 -0
- data/test/data/results/5.0.2/results.xml +501 -0
- data/test/export_test_data.json +360 -0
- data/test/resultsreader_test_data.json +1119 -0
- data/test/services.server.info.xml +43 -0
- data/test/services.xml +111 -0
- data/test/test_atomfeed.rb +71 -0
- data/test/test_collection.rb +278 -0
- data/test/test_configuration_file.rb +124 -0
- data/test/test_context.rb +119 -0
- data/test/test_entity.rb +95 -0
- data/test/test_helper.rb +250 -0
- data/test/test_http_error.rb +52 -0
- data/test/test_index.rb +91 -0
- data/test/test_jobs.rb +319 -0
- data/test/test_messages.rb +17 -0
- data/test/test_namespace.rb +188 -0
- data/test/test_restarts.rb +49 -0
- data/test/test_resultsreader.rb +106 -0
- data/test/test_roles.rb +41 -0
- data/test/test_saved_searches.rb +119 -0
- data/test/test_service.rb +65 -0
- data/test/test_users.rb +33 -0
- data/test/test_xml_shim.rb +28 -0
- data/test/testfile.txt +1 -0
- metadata +200 -0
@@ -0,0 +1,35 @@
|
|
1
|
+
#--
|
2
|
+
# Copyright 2011-2013 Splunk, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License"): you may
|
5
|
+
# not use this file except in compliance with the License. You may obtain
|
6
|
+
# a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations
|
14
|
+
# under the License.
|
15
|
+
#++
|
16
|
+
|
17
|
+
##
|
18
|
+
# Provides a class representing a configuration file.
|
19
|
+
#
|
20
|
+
|
21
|
+
require_relative '../collection'
|
22
|
+
|
23
|
+
module Splunk
|
24
|
+
class Apps < Collection
|
25
|
+
def initialize(service, resource, entity_class=Entity)
|
26
|
+
super(service, resource, entity_class)
|
27
|
+
|
28
|
+
# On Splunk 4.2, a newly created app does not have its Atom returned.
|
29
|
+
# Instead, an Atom entity named "Created" is returned, so we have to
|
30
|
+
# refresh the app manually. After 4.2 is no longer supported, we can
|
31
|
+
# remove this line.
|
32
|
+
@always_fetch = true
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
#--
|
2
|
+
# Copyright 2011-2013 Splunk, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License"): you may
|
5
|
+
# not use this file except in compliance with the License. You may obtain
|
6
|
+
# a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations
|
14
|
+
# under the License.
|
15
|
+
#++
|
16
|
+
|
17
|
+
require_relative '../collection'
|
18
|
+
require_relative '../entity'
|
19
|
+
|
20
|
+
##
|
21
|
+
# Provides a class representing the collection of users and roles in Splunk.
|
22
|
+
# This should look identical to Collection to the end user of the SDK.
|
23
|
+
#
|
24
|
+
# Users and roles are both case insensitive to the entity name, and neither
|
25
|
+
# returns the newly created entity.
|
26
|
+
#
|
27
|
+
|
28
|
+
module Splunk
|
29
|
+
class CaseInsensitiveCollection < Collection
|
30
|
+
def initialize(service, resource, entity_class=Entity)
|
31
|
+
super(service, resource, entity_class)
|
32
|
+
|
33
|
+
# +CaseInsensitiveCollection+ is only currently used for users and roles,
|
34
|
+
# both of which require @+always_fetch=true+. This property is not inherent
|
35
|
+
# to +CaseInsensitiveCollection+ in any particular way. It was just a
|
36
|
+
# convenient place to put it.
|
37
|
+
@always_fetch = true
|
38
|
+
end
|
39
|
+
|
40
|
+
# The following methods only downcase the name they are passed, and should
|
41
|
+
# be invisible to the user.
|
42
|
+
def create(name, args={}) # :nodoc:
|
43
|
+
super(name.downcase(), args)
|
44
|
+
end
|
45
|
+
|
46
|
+
def delete(name, namespace=nil) # :nodoc:
|
47
|
+
super(name.downcase(), namespace)
|
48
|
+
end
|
49
|
+
|
50
|
+
def fetch(name, namespace=nil) # :nodoc:
|
51
|
+
super(name.downcase(), namespace)
|
52
|
+
end
|
53
|
+
|
54
|
+
def has_key?(name) # :nodoc:
|
55
|
+
super(name.downcase())
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
#--
|
2
|
+
# Copyright 2011-2013 Splunk, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License"): you may
|
5
|
+
# not use this file except in compliance with the License. You may obtain
|
6
|
+
# a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations
|
14
|
+
# under the License.
|
15
|
+
#++
|
16
|
+
|
17
|
+
##
|
18
|
+
# Provides a class representing a configuration file.
|
19
|
+
#
|
20
|
+
|
21
|
+
require_relative '../collection'
|
22
|
+
|
23
|
+
module Splunk
|
24
|
+
##
|
25
|
+
# +ConfigurationFile+ is a collection containing configuration stanzas.
|
26
|
+
#
|
27
|
+
# This class's API is identical to +Collection+, so a user should not
|
28
|
+
# have to be aware of its existence.
|
29
|
+
#
|
30
|
+
class ConfigurationFile < Collection # :nodoc:
|
31
|
+
# This class is unusual: it is the element of a collection itself,
|
32
|
+
# and its elements are entities.
|
33
|
+
|
34
|
+
def initialize(service, name, namespace=nil)
|
35
|
+
super(service, ["configs", "conf-#{name}"], entity_class=Stanza)
|
36
|
+
@name = name
|
37
|
+
@namespace = namespace || service.namespace
|
38
|
+
end
|
39
|
+
|
40
|
+
def create(name, args={})
|
41
|
+
body_args = args.clone()
|
42
|
+
if !args.has_key?(:namespace)
|
43
|
+
body_args[:namespace] = @namespace
|
44
|
+
end
|
45
|
+
super(name, body_args)
|
46
|
+
end
|
47
|
+
|
48
|
+
attr_reader :name
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
#--
|
2
|
+
# Copyright 2011-2013 Splunk, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License"): you may
|
5
|
+
# not use this file except in compliance with the License. You may obtain
|
6
|
+
# a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations
|
14
|
+
# under the License.
|
15
|
+
#++
|
16
|
+
|
17
|
+
##
|
18
|
+
# Provides +Configurations+, a collection of configuration files in Splunk.
|
19
|
+
# +Configurations+ has an API identical to its superclass, +Collection+,
|
20
|
+
# so a user of the SDK should not have to be aware of its existance.
|
21
|
+
#
|
22
|
+
|
23
|
+
require_relative '../collection'
|
24
|
+
require_relative 'configuration_file'
|
25
|
+
|
26
|
+
module Splunk
|
27
|
+
##
|
28
|
+
# Class representing a collection of configuration files.
|
29
|
+
#
|
30
|
+
# The API of +Configurations+ is identical to +Collection+,
|
31
|
+
# so the user should not need to be aware of this class.
|
32
|
+
#
|
33
|
+
class Configurations < Collection # :nodoc:
|
34
|
+
def initialize(service)
|
35
|
+
super(service, PATH_CONFS, entity_class=ConfigurationFile)
|
36
|
+
end
|
37
|
+
|
38
|
+
def atom_entry_to_entity(entry)
|
39
|
+
name = entry["title"]
|
40
|
+
return ConfigurationFile.new(@service, name)
|
41
|
+
end
|
42
|
+
|
43
|
+
def create(name, args={})
|
44
|
+
# Don't bother catching the response. It either succeeds and returns
|
45
|
+
# an empty body, or fails and throws a +SplunkHTTPError+.
|
46
|
+
request_args = {:method => :POST,
|
47
|
+
:resource => PATH_CONFS,
|
48
|
+
:body => {"__conf" => name}}
|
49
|
+
if args.has_key?(:namespace)
|
50
|
+
request_args[:namespace] = args[:namespace]
|
51
|
+
end
|
52
|
+
@service.request(request_args)
|
53
|
+
return ConfigurationFile.new(@service, name,
|
54
|
+
args[:namespace] || @service.namespace)
|
55
|
+
end
|
56
|
+
|
57
|
+
def delete(name)
|
58
|
+
raise IllegalOperation.new("Cannot delete configuration files from" +
|
59
|
+
" the REST API.")
|
60
|
+
end
|
61
|
+
|
62
|
+
def fetch(name)
|
63
|
+
begin
|
64
|
+
# Make a request to the server to see if _name_ exists.
|
65
|
+
# We don't actually use any information returned from the server
|
66
|
+
# besides the status code.
|
67
|
+
request_args = {:resource => PATH_CONFS + [name]}
|
68
|
+
@service.request(request_args)
|
69
|
+
|
70
|
+
return ConfigurationFile.new(@service, name)
|
71
|
+
rescue SplunkHTTPError => err
|
72
|
+
if err.code == 404
|
73
|
+
return nil
|
74
|
+
else
|
75
|
+
raise err
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
#--
|
2
|
+
# Copyright 2011-2013 Splunk, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License"): you may
|
5
|
+
# not use this file except in compliance with the License. You may obtain
|
6
|
+
# a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations
|
14
|
+
# under the License.
|
15
|
+
#++
|
16
|
+
|
17
|
+
require 'delegate'
|
18
|
+
|
19
|
+
require_relative '../collection'
|
20
|
+
require_relative '../entity/job'
|
21
|
+
|
22
|
+
##
|
23
|
+
# Provides a class representing the collection of jobs in Splunk.
|
24
|
+
#
|
25
|
+
|
26
|
+
module Splunk
|
27
|
+
##
|
28
|
+
# Class representing a search job in Splunk.
|
29
|
+
#
|
30
|
+
# +Jobs+ adds two additional methods to +Collection+ to start additional
|
31
|
+
# kinds of search job. The basic +create+ method starts a normal,
|
32
|
+
# asynchronous search job. The two new methods, +create_oneshot+ and
|
33
|
+
# +create_stream+, creating oneshot and streaming searches, respectively,
|
34
|
+
# which block until the search finishes and return the results directly.
|
35
|
+
#
|
36
|
+
class Jobs < Collection
|
37
|
+
def initialize(service)
|
38
|
+
super(service, PATH_JOBS, entity_class=Job)
|
39
|
+
|
40
|
+
# +Jobs+ is one of the inconsistent collections where 0 means
|
41
|
+
# list all, not -1.
|
42
|
+
@infinite_count = 0
|
43
|
+
end
|
44
|
+
|
45
|
+
def atom_entry_to_entity(entry) # :nodoc:
|
46
|
+
sid = entry["content"]["sid"]
|
47
|
+
return Job.new(@service, sid)
|
48
|
+
end
|
49
|
+
|
50
|
+
##
|
51
|
+
# Creates an asynchronous search job.
|
52
|
+
#
|
53
|
+
# The search job requires a _query_, and takes a hash of other, optional
|
54
|
+
# arguments, which are documented in the {Splunk REST documentation}[http://docs.splunk.com/Documentation/Splunk/latest/RESTAPI/RESTsearch#search.2Fjobs - POST].
|
55
|
+
#
|
56
|
+
def create(query, args={})
|
57
|
+
if args.has_key?(:exec_mode)
|
58
|
+
raise ArgumentError.new("Cannot specify exec_mode for create. Use " +
|
59
|
+
"create_oneshot or create_stream instead.")
|
60
|
+
end
|
61
|
+
|
62
|
+
args['search'] = query
|
63
|
+
response = @service.request(:method => :POST,
|
64
|
+
:resource => @resource,
|
65
|
+
:body => args)
|
66
|
+
sid = Splunk::text_at_xpath("/response/sid", response.body)
|
67
|
+
Job.new(@service, sid)
|
68
|
+
end
|
69
|
+
|
70
|
+
##
|
71
|
+
# Creates a blocking search.
|
72
|
+
#
|
73
|
+
# The +create_oneshot+ method starts a search _query_, and any optional
|
74
|
+
# arguments specified in a hash (which are identical to those taken by
|
75
|
+
# +create+). It then blocks until the job finished, and returns the
|
76
|
+
# results, as transformed by any transforming search commands in _query_
|
77
|
+
# (equivalent to calling the +results+ method on a +Job+).
|
78
|
+
#
|
79
|
+
# Returns: a stream readable by +ResultsReader+.
|
80
|
+
#
|
81
|
+
def create_oneshot(query, args={})
|
82
|
+
args[:search] = query
|
83
|
+
args[:exec_mode] = 'oneshot'
|
84
|
+
response = @service.request(:method => :POST,
|
85
|
+
:resource => @resource,
|
86
|
+
:body => args)
|
87
|
+
return response.body
|
88
|
+
end
|
89
|
+
|
90
|
+
##
|
91
|
+
# Creates a blocking search without transforming search commands.
|
92
|
+
#
|
93
|
+
# The +create_export+ method starts a search _query_, and any optional
|
94
|
+
# arguments specified in a hash (which are identical to those taken by
|
95
|
+
# +create+). It then blocks until the job is finished, and returns the
|
96
|
+
# events found by the job before any transforming search commands
|
97
|
+
# (equivalent to calling +events+ on a +Job+).
|
98
|
+
#
|
99
|
+
# Returns: a stream readable by +MultiResultsReader+.
|
100
|
+
#
|
101
|
+
def create_export(query, args={})
|
102
|
+
args["search"] = query
|
103
|
+
response = @service.request(:method => :GET,
|
104
|
+
:resource => @resource + ["export"],
|
105
|
+
:query => args)
|
106
|
+
return ExportStream.new(response.body)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Deprecated.
|
110
|
+
def create_stream(query, args={}) # :nodoc:
|
111
|
+
warn "[DEPRECATION] Jobs#create_stream is deprecated. Use Jobs#create_export instead."
|
112
|
+
create_export(query, args)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
##
|
117
|
+
# Marks streams returned by the export endpoint for special handling.
|
118
|
+
#
|
119
|
+
# ResultsReader is supposed to handle streams from export differently
|
120
|
+
# from those from other endpoints, so we use this delegator to mark them.
|
121
|
+
#
|
122
|
+
class ExportStream < Delegator
|
123
|
+
def initialize(obj)
|
124
|
+
super # pass obj to Delegator constructor, required
|
125
|
+
@delegate = obj # store obj for future use
|
126
|
+
end
|
127
|
+
|
128
|
+
def __getobj__()
|
129
|
+
@delegate
|
130
|
+
end
|
131
|
+
|
132
|
+
def __setobj__(obj)
|
133
|
+
@delegate = obj
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
#--
|
2
|
+
# Copyright 2011-2013 Splunk, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License"): you may
|
5
|
+
# not use this file except in compliance with the License. You may obtain
|
6
|
+
# a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations
|
14
|
+
# under the License.
|
15
|
+
#++
|
16
|
+
|
17
|
+
require_relative '../collection'
|
18
|
+
|
19
|
+
##
|
20
|
+
# Provides a collection representing system-wide messages on Splunk.
|
21
|
+
#
|
22
|
+
|
23
|
+
module Splunk
|
24
|
+
##
|
25
|
+
# Collection representing system-wide messages on Splunk.
|
26
|
+
#
|
27
|
+
# There is no API difference from +Collection+, and so no reason
|
28
|
+
# for a user to be aware of this class.
|
29
|
+
#
|
30
|
+
class Messages < Collection # :nodoc:
|
31
|
+
def create(name, args)
|
32
|
+
body_args = args.clone()
|
33
|
+
body_args["name"] = name
|
34
|
+
|
35
|
+
request_args = {
|
36
|
+
:method => :POST,
|
37
|
+
:resource => @resource,
|
38
|
+
:body => body_args
|
39
|
+
}
|
40
|
+
if args.has_key?(:namespace)
|
41
|
+
request_args[:namespace] = body_args.delete(:namespace)
|
42
|
+
end
|
43
|
+
|
44
|
+
response = @service.request(request_args)
|
45
|
+
entity = Message.new(@service, Splunk::namespace(:sharing => "system"),
|
46
|
+
@resource, name)
|
47
|
+
return entity
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,522 @@
|
|
1
|
+
#--
|
2
|
+
# Copyright 2011-2013 Splunk, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License"): you may
|
5
|
+
# not use this file except in compliance with the License. You may obtain
|
6
|
+
# a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations
|
14
|
+
# under the License.
|
15
|
+
#++
|
16
|
+
|
17
|
+
##
|
18
|
+
# Provides the +Context+ class, the basic class representing a connection to a
|
19
|
+
# Splunk server. +Context+ is minimal, and only handles authentication and calls
|
20
|
+
# to the REST API. For most uses, you will want to use its subclass +Service+,
|
21
|
+
# which adds convenient methods to access the various collections and entities
|
22
|
+
# on Splunk.
|
23
|
+
#
|
24
|
+
|
25
|
+
require 'net/http'
|
26
|
+
|
27
|
+
require_relative 'splunk_http_error'
|
28
|
+
require_relative 'version'
|
29
|
+
require_relative 'xml_shim'
|
30
|
+
require_relative 'namespace'
|
31
|
+
|
32
|
+
module Splunk
|
33
|
+
DEFAULT_HOST = 'localhost'
|
34
|
+
DEFAULT_PORT = 8089
|
35
|
+
DEFAULT_SCHEME = :https
|
36
|
+
|
37
|
+
# Class encapsulating a connection to a Splunk server.
|
38
|
+
#
|
39
|
+
# This class is used for lower-level REST-based control of Splunk.
|
40
|
+
# For most use, you will want to use +Context+'s subclass +Service+, which
|
41
|
+
# provides convenient access to Splunk's various collections and entities.
|
42
|
+
#
|
43
|
+
# To use the +Context+ class, create a new +Context+ with a hash of arguments
|
44
|
+
# giving the details of the connection, and call the +login+ method on it:
|
45
|
+
#
|
46
|
+
# context = Splunk::Context.new(:username => "admin",
|
47
|
+
# :password => "changeme").login()
|
48
|
+
#
|
49
|
+
# +Context+#+new+ takes a hash of keyword arguments. The keys it understands
|
50
|
+
# are:
|
51
|
+
#
|
52
|
+
# * +:username+ - log in to Splunk as this user (no default)
|
53
|
+
# * +:password+ - password to use when logging in (no default)
|
54
|
+
# * +:host+ - Splunk host (e.g. "10.1.2.3") (default: 'localhost')
|
55
|
+
# * +:port+ - the Splunk management port (default: 8089)
|
56
|
+
# * +:protocol+ - either :https or :http (default: :https)
|
57
|
+
# * +:namespace+ - a +Namespace+ object representing the default namespace for
|
58
|
+
# this context (default: +DefaultNamespace+)
|
59
|
+
# * +:token+ - a preauthenticated Splunk token (default: +nil+)
|
60
|
+
#
|
61
|
+
# If you specify a token, you need not specify a username or password, nor
|
62
|
+
# do you need to call the +login+ method.
|
63
|
+
#
|
64
|
+
# +Context+ provides three other important methods:
|
65
|
+
#
|
66
|
+
# * +connect+ opens a socket to the Splunk server.
|
67
|
+
# * +request+ issues a request to the REST API.
|
68
|
+
# * +restart+ restarts the Splunk server and handles waiting for it to come
|
69
|
+
# back up.
|
70
|
+
#
|
71
|
+
class Context
|
72
|
+
def initialize(args)
|
73
|
+
@token = args.fetch(:token, nil)
|
74
|
+
@scheme = args.fetch(:scheme, DEFAULT_SCHEME).intern()
|
75
|
+
@host = args.fetch(:host, DEFAULT_HOST)
|
76
|
+
@port = Integer(args.fetch(:port, DEFAULT_PORT))
|
77
|
+
@username = args.fetch(:username, nil)
|
78
|
+
@password = args.fetch(:password, nil)
|
79
|
+
# Have to use Splunk::namespace() or we will call the
|
80
|
+
# local accessor.
|
81
|
+
@namespace = args.fetch(:namespace,
|
82
|
+
Splunk::namespace(:sharing => "default"))
|
83
|
+
end
|
84
|
+
|
85
|
+
##
|
86
|
+
# The protocol used to connect.
|
87
|
+
#
|
88
|
+
# Defaults to +:https+.
|
89
|
+
#
|
90
|
+
# Returns: +:http+ or +:https+.
|
91
|
+
#
|
92
|
+
attr_reader :scheme
|
93
|
+
|
94
|
+
##
|
95
|
+
# The host to connect to.
|
96
|
+
#
|
97
|
+
# Defaults to "+localhost+".
|
98
|
+
#
|
99
|
+
# Returns: a +String+.
|
100
|
+
#
|
101
|
+
attr_reader :host
|
102
|
+
|
103
|
+
##
|
104
|
+
# The port to connect to.
|
105
|
+
#
|
106
|
+
# Defaults to +8089+.
|
107
|
+
#
|
108
|
+
# Returns: an +Integer+.
|
109
|
+
#
|
110
|
+
attr_reader :port
|
111
|
+
|
112
|
+
##
|
113
|
+
# The authentication token on Splunk.
|
114
|
+
#
|
115
|
+
# If this +Context+ is not logged in, this is +nil+. Otherwise it is a
|
116
|
+
# +String+ that is passed with each request.
|
117
|
+
#
|
118
|
+
# Returns: a +String+ or +nil+.
|
119
|
+
#
|
120
|
+
attr_reader :token
|
121
|
+
|
122
|
+
##
|
123
|
+
# The username used to connect.
|
124
|
+
#
|
125
|
+
# If a token is provided, this field can be +nil+.
|
126
|
+
#
|
127
|
+
# Returns: a +String+ or +nil+.
|
128
|
+
#
|
129
|
+
attr_reader :username
|
130
|
+
|
131
|
+
##
|
132
|
+
# The password used to connect.
|
133
|
+
#
|
134
|
+
# If a token is provided, this field can be +nil+.
|
135
|
+
#
|
136
|
+
# Returns: a +String+ or +nil+.
|
137
|
+
#
|
138
|
+
attr_reader :password
|
139
|
+
|
140
|
+
##
|
141
|
+
# The default namespace used for requests on this +Context+.
|
142
|
+
#
|
143
|
+
# The namespace must be a +Namespace+ object. If a call to +request+ is
|
144
|
+
# made without a namespace, this namespace is used for the request.
|
145
|
+
#
|
146
|
+
# Defaults to +DefaultNamespace+.
|
147
|
+
#
|
148
|
+
# Returns: a +Namespace+ object.
|
149
|
+
#
|
150
|
+
attr_reader :namespace
|
151
|
+
|
152
|
+
##
|
153
|
+
# Opens a TCP socket to the Splunk HTTP server.
|
154
|
+
#
|
155
|
+
# If the +scheme+ field of this +Context+ is +:https+, this method returns
|
156
|
+
# an +SSLSocket+. If +scheme+ is +:http+, a +TCPSocket+ is returned. Due to
|
157
|
+
# design errors in Ruby's standard library, these two do not share the same
|
158
|
+
# method names, so code written for HTTPS will not work for HTTP.
|
159
|
+
#
|
160
|
+
# Returns: an +SSLSocket+ or +TCPSocket+.
|
161
|
+
#
|
162
|
+
def connect()
|
163
|
+
socket = TCPSocket.new(@host, @port)
|
164
|
+
if scheme == :https
|
165
|
+
ssl_context = OpenSSL::SSL::SSLContext.new()
|
166
|
+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
167
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)
|
168
|
+
ssl_socket.sync_close = true
|
169
|
+
ssl_socket.connect()
|
170
|
+
return ssl_socket
|
171
|
+
else
|
172
|
+
return socket
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
##
|
177
|
+
# Logs into Splunk and set the token field on this +Context+.
|
178
|
+
#
|
179
|
+
# The +login+ method assumes that the +Context+ has a username and password
|
180
|
+
# set. You cannot pass them as arguments to this method. On a successful
|
181
|
+
# login, the token field of the +Context+ is set to the token returned by
|
182
|
+
# Splunk, and all further requests to the server will send this token.
|
183
|
+
#
|
184
|
+
# If this +Context+ already has a token that is not +nil+, it is already
|
185
|
+
# logged in, and this method is a nop.
|
186
|
+
#
|
187
|
+
# Raises +SplunkHTTPError+ if there is a problem logging in.
|
188
|
+
#
|
189
|
+
# Returns: the +Context+.
|
190
|
+
#
|
191
|
+
def login()
|
192
|
+
if @token # If we're already logged in, this method is a nop.
|
193
|
+
return
|
194
|
+
end
|
195
|
+
|
196
|
+
response = request(:namespace => Splunk::namespace(:sharing => "default"),
|
197
|
+
:method => :POST,
|
198
|
+
:resource => ["auth", "login"],
|
199
|
+
:query => {},
|
200
|
+
:headers => {},
|
201
|
+
:body => {:username=>@username, :password=>@password})
|
202
|
+
# The response looks like:
|
203
|
+
# <response>
|
204
|
+
# <sessionKey>da950729652f8255c230afe37bdf8b97</sessionKey>
|
205
|
+
# </response>
|
206
|
+
@token = Splunk::text_at_xpath("//sessionKey", response.body)
|
207
|
+
|
208
|
+
self
|
209
|
+
end
|
210
|
+
|
211
|
+
##
|
212
|
+
# Logs out of Splunk.
|
213
|
+
#
|
214
|
+
# This sets the @token attribute to +nil+.
|
215
|
+
#
|
216
|
+
# Returns: the +Context+.
|
217
|
+
#
|
218
|
+
def logout()
|
219
|
+
@token = nil
|
220
|
+
self
|
221
|
+
end
|
222
|
+
|
223
|
+
##
|
224
|
+
# Issues an HTTP(S) request to the Splunk instance.
|
225
|
+
#
|
226
|
+
# The +request+ method does not take a URL. Instead, it takes a hash of
|
227
|
+
# optional arguments specifying an action in the REST API. This avoids the
|
228
|
+
# problem knowing whether a given piece of data is URL encoded or not.
|
229
|
+
#
|
230
|
+
# The arguments are:
|
231
|
+
#
|
232
|
+
# * +method+: The HTTP method to use (one of +:GET+, +:POST+, or +:DELETE+;
|
233
|
+
# default: +:GET+).
|
234
|
+
# * +namespace+: The namespace to request a resource from Splunk in. Must
|
235
|
+
# be a +Namespace+ object. (default: the value of +@namespace+ on
|
236
|
+
# this +Context+)
|
237
|
+
# * +resource+: An array of strings specifying the components of the path
|
238
|
+
# to the resource after the namespace. The strings should not be URL
|
239
|
+
# encoded, as that will be handled by +request+. (default: [])
|
240
|
+
# * +query+: A hash containing the values to be encoded as
|
241
|
+
# the query (the part following +?+) in the URL. Nothing should be URL
|
242
|
+
# encoded as +request+ will do the encoding. If you need to pass multiple
|
243
|
+
# values for the same key, insert them as an Array as the value of their
|
244
|
+
# key into the Hash, and they will be properly encoded as a sequence of
|
245
|
+
# entries with the same key. (default: {})
|
246
|
+
# * +headers+: A hash containing the values to be encoded as headers. None
|
247
|
+
# should be URL encoded, and the +request+ method will automatically add
|
248
|
+
# headers for +User-Agent+ and Splunk authentication for you. Keys must
|
249
|
+
# be unique, so the values must be strings, not arrays, unlike for
|
250
|
+
# +query+. (default: {})
|
251
|
+
# * +body+: Either a hash to be encoded as the body of a POST request, or
|
252
|
+
# a string to be used as the raw, already encoded body of a POST request.
|
253
|
+
# If you pass a hash, you can pass multiple values for the same key by
|
254
|
+
# encoding them as an Array, which will be properly set as multiple
|
255
|
+
# instances of the same key in the POST body. Nothing in the hash should
|
256
|
+
# be URL encoded, as +request+ will handle all such encoding.
|
257
|
+
# (default: {})
|
258
|
+
#
|
259
|
+
# If Splunk responds with an HTTP code 2xx, the +request+ method returns
|
260
|
+
# an HTTP response object (the import methods of which are +code+,
|
261
|
+
# +message+, and +body+, and +each+ to enumerate over the response
|
262
|
+
# headers). If the HTTP code is not 2xx, +request+ raises a
|
263
|
+
# +SplunkHTTPError+.
|
264
|
+
#
|
265
|
+
# *Examples:*
|
266
|
+
#
|
267
|
+
# c = Splunk::connect(username="admin", password="changeme")
|
268
|
+
# # Get a list of the indexes in this Splunk instance.
|
269
|
+
# c.request(:namespace => Splunk::namespace(),
|
270
|
+
# :resource => ["data", "indexes"])
|
271
|
+
# # Create a new index called "my_new_index"
|
272
|
+
# c.request(:method => :POST,
|
273
|
+
# :resource => ["data", "indexes"],
|
274
|
+
# :body => {"name", "my_new_index"})
|
275
|
+
#
|
276
|
+
def request(args)
|
277
|
+
method = args.fetch(:method, :GET)
|
278
|
+
scheme = @scheme
|
279
|
+
host = @host
|
280
|
+
port = @port
|
281
|
+
namespace = args.fetch(:namespace, @namespace)
|
282
|
+
resource = args.fetch(:resource, [])
|
283
|
+
query = args.fetch(:query, {})
|
284
|
+
headers = args.fetch(:headers, {})
|
285
|
+
body = args.fetch(:body, {})
|
286
|
+
|
287
|
+
if method != :GET && method != :POST && method != :DELETE
|
288
|
+
raise ArgumentError.new("Method must be one of :GET, :POST, or " +
|
289
|
+
":DELETE, found: #{method}")
|
290
|
+
end
|
291
|
+
|
292
|
+
if scheme && scheme != :http && scheme != :https
|
293
|
+
raise ArgumentError.new("Scheme must be one of :http or :https, " +
|
294
|
+
"found: #{scheme}")
|
295
|
+
end
|
296
|
+
|
297
|
+
if port && !port.is_a?(Integer)
|
298
|
+
raise ArgumentError.new("Port must be an Integer, found: #{port}")
|
299
|
+
end
|
300
|
+
|
301
|
+
if !namespace.is_a?(Namespace)
|
302
|
+
raise ArgumentError.new("Namespace must be a Namespace, " +
|
303
|
+
"found: #{namespace}")
|
304
|
+
end
|
305
|
+
|
306
|
+
# Construct the URL for the request.
|
307
|
+
url = ""
|
308
|
+
url << "#{(scheme || @scheme).to_s}://"
|
309
|
+
url << "#{host || @host}:#{(port || @port).to_s}/"
|
310
|
+
url << (namespace.to_path_fragment() + resource).
|
311
|
+
map {|fragment| URI::encode(fragment)}.
|
312
|
+
join("/")
|
313
|
+
|
314
|
+
return request_by_url(:url => url,
|
315
|
+
:method => method,
|
316
|
+
:query => query,
|
317
|
+
:headers => headers,
|
318
|
+
:body => body)
|
319
|
+
end
|
320
|
+
|
321
|
+
##
|
322
|
+
# Makes a request to the Splunk server given a prebuilt URL.
|
323
|
+
#
|
324
|
+
# Unless you are using a URL that was returned by the Splunk server
|
325
|
+
# as part of an Atom feed, you should prefer the +request+ method, which
|
326
|
+
# has much clearer semantics.
|
327
|
+
#
|
328
|
+
# The +request_by_url+ method takes a hash of arguments. The recognized
|
329
|
+
# arguments are:
|
330
|
+
#
|
331
|
+
# * +:url+: (a +URI+ object or a +String+) The URL, including authority, to
|
332
|
+
# make a request to.
|
333
|
+
# * +:method+: (+:GET+, +:POST+, or +:DELETE+) The HTTP method to use.
|
334
|
+
# * +query+: A hash containing the values to be encoded as
|
335
|
+
# the query (the part following +?+) in the URL. Nothing should be URL
|
336
|
+
# encoded as +request+ will do the encoding. If you need to pass multiple
|
337
|
+
# values for the same key, insert them as an +Array+ as the value of their
|
338
|
+
# key into the Hash, and they will be properly encoded as a sequence of
|
339
|
+
# entries with the same key. (default: {})
|
340
|
+
# * +headers+: A hash containing the values to be encoded as headers. None
|
341
|
+
# should be URL encoded, and the +request+ method will automatically add
|
342
|
+
# headers for +User-Agent+ and Splunk authentication for you. Keys must
|
343
|
+
# be unique, so the values must be strings, not arrays, unlike for
|
344
|
+
# +query+. (default: {})
|
345
|
+
# * +body+: Either a hash to be encoded as the body of a POST request, or
|
346
|
+
# a string to be used as the raw, already encoded body of a POST request.
|
347
|
+
# If you pass a hash, you can pass multiple values for the same key by
|
348
|
+
# encoding them as an +Array+, which will be properly set as multiple
|
349
|
+
# instances of the same key in the POST body. Nothing in the hash should
|
350
|
+
# be URL encoded, as +request+ will handle all such encoding.
|
351
|
+
# (default: {})
|
352
|
+
#
|
353
|
+
# If Splunk responds with an HTTP code 2xx, the +request_by_url+ method
|
354
|
+
# returns an HTTP response object (the import methods of which are +code+,
|
355
|
+
# +message+, and +body+, and +each+ to enumerate over the response
|
356
|
+
# headers). If the HTTP code is not 2xx, the +request_by_url+ method
|
357
|
+
# raises a +SplunkHTTPError+.
|
358
|
+
#
|
359
|
+
def request_by_url(args)
|
360
|
+
url = args.fetch(:url)
|
361
|
+
if url.is_a?(String)
|
362
|
+
url = URI(url)
|
363
|
+
end
|
364
|
+
method = args.fetch(:method, :GET)
|
365
|
+
query = args.fetch(:query, {})
|
366
|
+
headers = args.fetch(:headers, {})
|
367
|
+
body = args.fetch(:body, {})
|
368
|
+
|
369
|
+
if !query.empty?
|
370
|
+
url.query = URI.encode_www_form(query)
|
371
|
+
end
|
372
|
+
|
373
|
+
if method == :GET
|
374
|
+
request = Net::HTTP::Get.new(url.request_uri)
|
375
|
+
elsif method == :POST
|
376
|
+
request = Net::HTTP::Post.new(url.request_uri)
|
377
|
+
elsif method == :DELETE
|
378
|
+
request = Net::HTTP::Delete.new(url.request_uri)
|
379
|
+
end
|
380
|
+
|
381
|
+
# Headers
|
382
|
+
request["User-Agent"] = "splunk-sdk-ruby/#{VERSION}"
|
383
|
+
request["Authorization"] = "Splunk #{@token}" if @token
|
384
|
+
headers.each_entry do |key, value|
|
385
|
+
request[key] = value
|
386
|
+
end
|
387
|
+
|
388
|
+
# Body
|
389
|
+
if body.is_a?(String)
|
390
|
+
# This case exists only for submitting an event to an index via HTTP.
|
391
|
+
request.body = body
|
392
|
+
else
|
393
|
+
request.body = URI.encode_www_form(body)
|
394
|
+
end
|
395
|
+
|
396
|
+
# Issue the request.
|
397
|
+
response = Net::HTTP::start(
|
398
|
+
url.hostname, url.port,
|
399
|
+
:use_ssl => url.scheme == 'https',
|
400
|
+
# We don't support certificates.
|
401
|
+
:verify_mode => OpenSSL::SSL::VERIFY_NONE
|
402
|
+
) do |http|
|
403
|
+
http.request(request)
|
404
|
+
end
|
405
|
+
|
406
|
+
# Handle any errors.
|
407
|
+
if !response.is_a?(Net::HTTPSuccess)
|
408
|
+
raise SplunkHTTPError.new(response)
|
409
|
+
else
|
410
|
+
return response
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
##
|
415
|
+
# Restarts this Splunk instance.
|
416
|
+
#
|
417
|
+
# The +restart+ method may be called with an optional timeout. If you pass
|
418
|
+
# a timeout, +restart+ will wait up to that number of seconds for the
|
419
|
+
# server to come back up before returning. If +restart+ did not time out,
|
420
|
+
# it leaves the +Context+ logged in when it returns.
|
421
|
+
#
|
422
|
+
# If the timeout is, omitted, the +restart+ method returns immediately, and
|
423
|
+
# you will have to ascertain if Splunk has come back up yourself, for
|
424
|
+
# example with code like:
|
425
|
+
#
|
426
|
+
# context = Context.new(...).login()
|
427
|
+
# context.restart()
|
428
|
+
# Timeout::timeout(timeout) do
|
429
|
+
# while !context.server_accepting_connections? ||
|
430
|
+
# context.server_requires_restart?
|
431
|
+
# sleep(0.3)
|
432
|
+
# end
|
433
|
+
# end
|
434
|
+
#
|
435
|
+
# Returns: this +Context+.
|
436
|
+
#
|
437
|
+
def restart(timeout=nil)
|
438
|
+
# Set a message saying that restart is required. Otherwise we have no
|
439
|
+
# way of knowing if Splunk has actually gone down for a restart or not.
|
440
|
+
request(:method => :POST,
|
441
|
+
:namespace => Splunk::namespace(:sharing => "default"),
|
442
|
+
:resource => ["messages"],
|
443
|
+
:body => {"name" => "restart_required",
|
444
|
+
"value" => "Message set by restart method" +
|
445
|
+
" of the Splunk Ruby SDK"})
|
446
|
+
|
447
|
+
# Make the actual restart request.
|
448
|
+
request(:resource => ["server", "control", "restart"])
|
449
|
+
|
450
|
+
# Clear our old token, which will no longer work after the restart.
|
451
|
+
logout()
|
452
|
+
|
453
|
+
# If +timeout+ is +nil+, return immediately. If timeout is a positive
|
454
|
+
# integer, wait for +timeout+ seconds for the server to come back up.
|
455
|
+
if !timeout.nil?
|
456
|
+
Timeout::timeout(timeout) do
|
457
|
+
while !server_accepting_connections? || server_requires_restart?
|
458
|
+
sleep(0.3)
|
459
|
+
end
|
460
|
+
end
|
461
|
+
end
|
462
|
+
|
463
|
+
# Return the +Context+.
|
464
|
+
self
|
465
|
+
end
|
466
|
+
|
467
|
+
##
|
468
|
+
# Is the Splunk server accepting connections?
|
469
|
+
#
|
470
|
+
# Returns +true+ if the Splunk server is up and accepting REST API
|
471
|
+
# connections; +false+ otherwise.
|
472
|
+
#
|
473
|
+
def server_accepting_connections?()
|
474
|
+
begin
|
475
|
+
# Can't use login, since it has short circuits
|
476
|
+
# when @token != nil on the Context. Instead, make
|
477
|
+
# a request directly.
|
478
|
+
request(:resource => ["data", "indexes"])
|
479
|
+
rescue Errno::ECONNREFUSED, EOFError, Errno::ECONNRESET
|
480
|
+
return false
|
481
|
+
rescue SplunkHTTPError
|
482
|
+
# Splunk is up, because it responded with a proper HTTP error
|
483
|
+
# that our SplunkHTTPError parser understood.
|
484
|
+
return true
|
485
|
+
else
|
486
|
+
# Or the request worked, so we know that Splunk is up.
|
487
|
+
return true
|
488
|
+
end
|
489
|
+
end
|
490
|
+
|
491
|
+
##
|
492
|
+
# Is the Splunk server in a state requiring a restart?
|
493
|
+
#
|
494
|
+
# Returns +true+ if the Splunk server is down (equivalent to
|
495
|
+
# +server_accepting_connections?+), or if there is a +restart_required+
|
496
|
+
# message on the server; +false+ otherwise.
|
497
|
+
#
|
498
|
+
def server_requires_restart?()
|
499
|
+
begin # We must have two layers of rescue, because the login in the
|
500
|
+
# SplunkHTTPError rescue can also throw Errno::ECONNREFUSED.
|
501
|
+
begin
|
502
|
+
request(:resource => ["messages", "restart_required"])
|
503
|
+
return true
|
504
|
+
rescue SplunkHTTPError => err
|
505
|
+
if err.code == 401
|
506
|
+
# The messages endpoint requires authentication.
|
507
|
+
logout()
|
508
|
+
login()
|
509
|
+
return server_requires_restart?()
|
510
|
+
elsif err.code == 404
|
511
|
+
return false
|
512
|
+
else
|
513
|
+
raise err
|
514
|
+
end
|
515
|
+
end
|
516
|
+
rescue Errno::ECONNREFUSED, EOFError, Errno::ECONNRESET
|
517
|
+
return true
|
518
|
+
end
|
519
|
+
end
|
520
|
+
|
521
|
+
end
|
522
|
+
end
|