onering-agent 0.4.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/bin/onering +68 -0
- data/lib/etc/facter.list +19 -0
- data/lib/onering.rb +32 -0
- data/lib/onering/api.rb +322 -0
- data/lib/onering/cli.rb +92 -0
- data/lib/onering/cli/assets.rb +138 -0
- data/lib/onering/cli/automation.rb +62 -0
- data/lib/onering/cli/call.rb +53 -0
- data/lib/onering/cli/devices.rb +98 -0
- data/lib/onering/cli/fact.rb +22 -0
- data/lib/onering/cli/reporter.rb +121 -0
- data/lib/onering/config.rb +62 -0
- data/lib/onering/logger.rb +141 -0
- data/lib/onering/plugins/assets.rb +54 -0
- data/lib/onering/plugins/authentication.rb +35 -0
- data/lib/onering/plugins/automation.rb +70 -0
- data/lib/onering/plugins/reporter.rb +360 -0
- data/lib/onering/util.rb +150 -0
- data/lib/onering/version.rb +8 -0
- metadata +188 -0
checksums.yaml
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
---
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
NTliNjI0MzU5MTllMGFkNDg1OGQxMjBhNzBmZmFhOTIyMWY2ZTBkYg==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
YjQ5Y2NiOTlkZjQ5YjE3OTIyZmZiNjZhNjdhOWI3MGJhNjhhMzgxZQ==
|
7
|
+
SHA512:
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
YzFiYjNlMWYyMmY0MzZkM2NhMjBlZGVlYzE4Y2UyZjRjNDlhOGNmNzk1ZDdk
|
10
|
+
M2E2Njg2MDM2ZDg2YzcwYzk3ZjY0YmRlYWMyNzgzMmMyZDNiZGJhMWJjNWZj
|
11
|
+
MGNkNGU1NjNhYmRhMDY4MDA2NWVhYTNhMzk5N2Y4NThiMTI3MjA=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
MjE5MzQ5ZTlhYzc1ZjIxNDkxMmE5NDM5OWRlYjgzNWVlYzQyNmVhN2U4ZTQ4
|
14
|
+
YjM2ZWM2MTZjODEwNWI2ZDM3Mjc5NTNkMjlkMTQzZDk1NWJlOTVmZDIyYmRm
|
15
|
+
OWIzOGU4MTZlZGNiNTQ5MzRhMmRiZGFmNDdkNTc2YTg5ZGUwZGU=
|
data/bin/onering
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
$:.push 'lib'
|
3
|
+
require 'trollop'
|
4
|
+
require 'onering'
|
5
|
+
require 'hashlib'
|
6
|
+
require 'rainbow'
|
7
|
+
require 'pp'
|
8
|
+
|
9
|
+
plugins = Onering::CLI::Plugin.registered_plugins.collect{|i| i.name.split('::').last.downcase }
|
10
|
+
exclude_plugins = %w{devices}
|
11
|
+
|
12
|
+
global = Trollop::options do
|
13
|
+
banner <<-EOS
|
14
|
+
onering command line client utility - version #{Onering::Client::VERSION}
|
15
|
+
|
16
|
+
Usage:
|
17
|
+
onering [global] [plugin] [subcommand] [options]
|
18
|
+
|
19
|
+
Plugins (onering <plugin> --help for usage details):
|
20
|
+
#{(plugins - exclude_plugins).sort.join(', ')}
|
21
|
+
|
22
|
+
where [global] options are:
|
23
|
+
EOS
|
24
|
+
|
25
|
+
opt :url, "The URL of the Onering server to connect to", :short => '-s', :type => :string
|
26
|
+
opt :path, "The base path to prepend to all requests (default: /api)", :type => :string
|
27
|
+
opt :source, "Specify the source IP address to use (i.e. which network interface the request should originate from)", :short => '-I', :type => :string
|
28
|
+
opt :param, "Additional query string parameters to include with the request in the format FIELD=VALUE. Can be specified multiple times.", :short => '-p', :type => :string, :multi => true
|
29
|
+
opt :format, "The output format for return values (i.e.: json, yaml, text)", :short => '-t', :type => :string
|
30
|
+
opt :sslkey, "Location of the SSL client key to use for authentication", :short => '-c', :type => :string
|
31
|
+
opt :nosslverify, "Disable verification of the server SSL certificate", :type => :boolean
|
32
|
+
opt :apikey, "The API token to use for authentication", :short => '-k', :type => :string
|
33
|
+
opt :quiet, "Suppress standard output", :short => '-q'
|
34
|
+
opt :separator, "A string used to separate output values of delimited tabular data", :short => '-S', :default => "\t"
|
35
|
+
opt :verbosity, "Set the log level (fatal, error, warn, info, debug)", :short => '-v', :type => :string, :default => 'warn'
|
36
|
+
|
37
|
+
stop_on plugins
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
plugin = ARGV.shift
|
42
|
+
Trollop::die("plugin argument is requried") if plugin.nil?
|
43
|
+
|
44
|
+
Onering::Logger.setup({
|
45
|
+
:destination => 'stdout',
|
46
|
+
:threshold => global[:verbosity]
|
47
|
+
})
|
48
|
+
|
49
|
+
if plugins.include?(plugin)
|
50
|
+
begin
|
51
|
+
plugin = Onering::CLI.const_get(plugin.capitalize)
|
52
|
+
plugin.configure(global)
|
53
|
+
|
54
|
+
Onering::Logger.debug("Executing plugin #{plugin}\#run()", $0)
|
55
|
+
rv = plugin.run(ARGV)
|
56
|
+
Onering::CLI.output(rv, (global[:format] || plugin.default_format(rv, ARGV) || 'text'))
|
57
|
+
|
58
|
+
rescue Onering::API::Errors::Exception => e
|
59
|
+
Onering::Logger.fatal(e.message, e.class.name.split('::').last) rescue nil
|
60
|
+
exit 1
|
61
|
+
|
62
|
+
rescue Onering::Client::FatalError => e
|
63
|
+
exit 255
|
64
|
+
|
65
|
+
end
|
66
|
+
else
|
67
|
+
Trollop::die("unknown plugin #{plugin}")
|
68
|
+
end
|
data/lib/etc/facter.list
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
serialnumber:serial
|
2
|
+
manufacturer:make
|
3
|
+
productname:model
|
4
|
+
boardmanufacturer:mbmake
|
5
|
+
boardproductname:mbmodel
|
6
|
+
boardserialnumber:mbserial
|
7
|
+
hostname
|
8
|
+
fqdn
|
9
|
+
default_ipaddress:ip
|
10
|
+
default_macaddress:mac
|
11
|
+
default_gateway:gateway
|
12
|
+
kernelrelease:kernel
|
13
|
+
kernelarguments:@kernelarguments
|
14
|
+
architecture:arch
|
15
|
+
operatingsystem:distro
|
16
|
+
operatingsystemrelease:version
|
17
|
+
signature
|
18
|
+
boottime:booted_at
|
19
|
+
onering:@onering
|
data/lib/onering.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
$: << File.expand_path(File.dirname(__FILE__))
|
2
|
+
|
3
|
+
module Onering
|
4
|
+
FULL_CLIENT = true
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'onering/version'
|
8
|
+
require 'onering/logger'
|
9
|
+
|
10
|
+
if not ENV['ONERING_LOGLEVEL'].nil?
|
11
|
+
Onering::Logger.setup({
|
12
|
+
:destination => 'stderr',
|
13
|
+
:threshold => ENV['ONERING_LOGLEVEL']
|
14
|
+
})
|
15
|
+
end
|
16
|
+
|
17
|
+
require 'onering/util'
|
18
|
+
require 'onering/api'
|
19
|
+
require 'onering/cli'
|
20
|
+
|
21
|
+
# require plugins
|
22
|
+
Dir[File.join(File.expand_path(File.dirname(__FILE__)),"onering","plugins","*.rb")].each do |i|
|
23
|
+
require i
|
24
|
+
end
|
25
|
+
|
26
|
+
# require cli submodules
|
27
|
+
Dir[File.join(File.expand_path(File.dirname(__FILE__)),"onering","cli","*.rb")].each do |i|
|
28
|
+
require i
|
29
|
+
end
|
30
|
+
|
31
|
+
# you've loaded the library, now load the config
|
32
|
+
Onering::Config.load()
|
data/lib/onering/api.rb
ADDED
@@ -0,0 +1,322 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'yaml'
|
3
|
+
require 'hashlib'
|
4
|
+
require 'deep_merge'
|
5
|
+
require 'addressable/uri'
|
6
|
+
require 'httparty'
|
7
|
+
require 'onering/config'
|
8
|
+
|
9
|
+
module Onering
|
10
|
+
class API
|
11
|
+
module Actions
|
12
|
+
class Retry < ::Exception; end
|
13
|
+
end
|
14
|
+
|
15
|
+
module Errors
|
16
|
+
class Exception < ::Exception; end
|
17
|
+
class NotConnected < Exception; end
|
18
|
+
|
19
|
+
class ClientError < Exception; end
|
20
|
+
class Unauthorized < ClientError; end
|
21
|
+
class Forbidden < ClientError; end
|
22
|
+
class NotFound < ClientError; end
|
23
|
+
|
24
|
+
class ServerError < Exception; end
|
25
|
+
class ConnectionTimeout < Exception; end
|
26
|
+
class AuthenticationMissing < Exception; end
|
27
|
+
end
|
28
|
+
|
29
|
+
include Onering::Util
|
30
|
+
include ::HTTParty
|
31
|
+
|
32
|
+
attr_accessor :url
|
33
|
+
format :json
|
34
|
+
|
35
|
+
DEFAULT_BASE="https://onering"
|
36
|
+
DEFAULT_PATH="/api"
|
37
|
+
DEFAULT_CLIENT_PEM=["~/.onering/client.pem", "/etc/onering/client.pem"]
|
38
|
+
DEFAULT_CLIENT_KEY=["~/.onering/client.key", "/etc/onering/client.key"]
|
39
|
+
DEFAULT_VALIDATION_PEM="/etc/onering/validation.pem"
|
40
|
+
|
41
|
+
|
42
|
+
def initialize(options={})
|
43
|
+
@_plugins = {}
|
44
|
+
options = {} if options.nil?
|
45
|
+
@_connection_options = options
|
46
|
+
|
47
|
+
# load and merge all config file sources
|
48
|
+
Onering::Config.load(@_connection_options[:configfile], @_connection_options.get(:config, {}))
|
49
|
+
|
50
|
+
if options.get('config.nosslverify', false) == true
|
51
|
+
# deliberately break SSL verification
|
52
|
+
Onering::Logger.warn("Disabling SSL peer verification for #{options.get('config.url')}")
|
53
|
+
OpenSSL::SSL.send(:const_set, :OLD_VERIFY_PEER, OpenSSL::SSL::VERIFY_PEER)
|
54
|
+
OpenSSL::SSL.send(:remove_const, :VERIFY_PEER)
|
55
|
+
OpenSSL::SSL.send(:const_set, :VERIFY_PEER, OpenSSL::SSL::VERIFY_NONE)
|
56
|
+
else
|
57
|
+
# restore SSL verification if it's currently broken
|
58
|
+
if defined?(OpenSSL::SSL::OLD_VERIFY_PEER)
|
59
|
+
if OpenSSL::SSL::VERIFY_PEER == OpenSSL::SSL::VERIFY_NONE and OpenSSL::SSL::OLD_VERIFY_PEER != OpenSSL::SSL::VERIFY_NONE
|
60
|
+
OpenSSL::SSL.send(:remove_const, :VERIFY_PEER)
|
61
|
+
OpenSSL::SSL.send(:const_set, :VERIFY_PEER, OpenSSL::SSL::OLD_VERIFY_PEER)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
if OpenSSL::SSL::VERIFY_PEER == OpenSSL::SSL::VERIFY_NONE
|
67
|
+
Onering::Logger.warn("Disabling SSL peer verification for #{options.get('config.url')}")
|
68
|
+
end
|
69
|
+
|
70
|
+
# source interface specified
|
71
|
+
# !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !! HAX !!
|
72
|
+
# Due to certain versions of Ruby's Net::HTTP not allowing you explicitly
|
73
|
+
# specify the source IP/interface to use, this horrific monkey patch is
|
74
|
+
# necessary, if not right.
|
75
|
+
#
|
76
|
+
# If at least some of your code doesn't make you feel bottomless shame
|
77
|
+
# then you aren't coding hard enough.
|
78
|
+
#
|
79
|
+
if options.get('config.source').is_a?(String)
|
80
|
+
if options.get('config.source') =~ /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/
|
81
|
+
# insert firing pin into the hack
|
82
|
+
TCPSocket.instance_eval do
|
83
|
+
(class << self; self; end).instance_eval do
|
84
|
+
alias_method :_stock_open, :open
|
85
|
+
attr_writer :_hack_local_ip
|
86
|
+
|
87
|
+
define_method(:open) do |conn_address, conn_port|
|
88
|
+
_stock_open(conn_address, conn_port, @_hack_local_ip)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# arm the hack
|
94
|
+
TCPSocket._hack_local_ip = options.get('config.source')
|
95
|
+
|
96
|
+
# sound the siren
|
97
|
+
Onering::Logger.info("Using local interface #{options.get('config.source')} to connect", "Onering::API")
|
98
|
+
|
99
|
+
else
|
100
|
+
raise "Invalid source IP address #{options.get('config.source')}"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# set API connectivity details
|
105
|
+
Onering::API.base_uri(options.get('config.url', Onering::Config.get(:url, DEFAULT_BASE)))
|
106
|
+
Onering::Logger.info("Server URL is #{Onering::API.base_uri}", "Onering::API")
|
107
|
+
|
108
|
+
# add default parameters
|
109
|
+
options.get('config.params',{}).each do |k,v|
|
110
|
+
_default_param(k,v)
|
111
|
+
end
|
112
|
+
|
113
|
+
connect(options) if options.get(:autoconnect, true)
|
114
|
+
end
|
115
|
+
|
116
|
+
def connect(options={})
|
117
|
+
# setup authentication
|
118
|
+
_setup_auth()
|
119
|
+
|
120
|
+
Onering::Logger.debug("Connection setup complete", "Onering::API")
|
121
|
+
return self
|
122
|
+
end
|
123
|
+
|
124
|
+
|
125
|
+
def request(method, endpoint, options={})
|
126
|
+
endpoint = [Onering::Config.get(:path, DEFAULT_PATH).strip, endpoint.sub(/^\//,'')].join('/')
|
127
|
+
|
128
|
+
Onering::Logger.debug("#{method.to_s.upcase} #{endpoint}#{(options[:query] || {}).empty? ? '' : '?'+options[:query].join('=', '&')}", "Onering::API")
|
129
|
+
options.get(:headers,[]).each do |name, value|
|
130
|
+
next if name == 'Content-Type' and value == 'application/json'
|
131
|
+
Onering::Logger.debug("+#{name}: #{value}", "Onering::API")
|
132
|
+
end
|
133
|
+
|
134
|
+
begin
|
135
|
+
case (method.to_sym rescue method)
|
136
|
+
when :post
|
137
|
+
rv = Onering::API.post(endpoint, options)
|
138
|
+
when :put
|
139
|
+
rv = Onering::API.put(endpoint, options)
|
140
|
+
when :delete
|
141
|
+
rv = Onering::API.delete(endpoint, options)
|
142
|
+
when :head
|
143
|
+
rv = Onering::API.head(endpoint, options)
|
144
|
+
else
|
145
|
+
rv = Onering::API.get(endpoint, options)
|
146
|
+
end
|
147
|
+
rescue SocketError => e
|
148
|
+
Onering::Logger.fatal!("Unable to connect to #{Onering::API.base_uri}", "Onering::API")
|
149
|
+
end
|
150
|
+
|
151
|
+
if rv.code >= 500
|
152
|
+
raise Errors::ServerError.new("HTTP #{rv.code} - #{Onering::Util.http_status(rv.code)} #{rv.parsed_response.get('error.message','') rescue ''}")
|
153
|
+
elsif rv.code >= 400
|
154
|
+
message = "HTTP #{rv.code} - #{Onering::Util.http_status(rv.code)} #{rv.parsed_response.get('error.message', '') rescue ''}"
|
155
|
+
|
156
|
+
case rv.code
|
157
|
+
when 401
|
158
|
+
raise Errors::Unauthorized.new(message)
|
159
|
+
when 403
|
160
|
+
raise Errors::Forbidden.new(message)
|
161
|
+
when 404
|
162
|
+
raise Errors::NotFound.new(message)
|
163
|
+
else
|
164
|
+
raise Errors::ClientError.new(message)
|
165
|
+
end
|
166
|
+
else
|
167
|
+
rv
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
|
172
|
+
def get(endpoint, options={})
|
173
|
+
request(:get, endpoint, options)
|
174
|
+
end
|
175
|
+
|
176
|
+
def post(endpoint, options={}, &block)
|
177
|
+
if block_given?
|
178
|
+
request(:post, endpoint, options.merge({
|
179
|
+
:body => yield
|
180
|
+
}))
|
181
|
+
else
|
182
|
+
request(:post, endpoint, options)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def put(endpoint, options={}, &block)
|
187
|
+
if block_given?
|
188
|
+
request(:put, endpoint, options.merge({
|
189
|
+
:body => yield
|
190
|
+
}))
|
191
|
+
else
|
192
|
+
request(:put, endpoint, options)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
def delete(endpoint, options={})
|
197
|
+
request(:delete, endpoint, options)
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
# I'm not a huge fan of what's happening here, but metaprogramming is hard...
|
202
|
+
#
|
203
|
+
# "Don't let the perfect be the enemy of the good."
|
204
|
+
#
|
205
|
+
def method_missing(method, *args, &block)
|
206
|
+
modname = method.to_s.split('_').map(&:capitalize).join
|
207
|
+
|
208
|
+
if not (plugin = (Onering::API.const_get(modname) rescue nil)).nil?
|
209
|
+
@_plugins[method] ||= plugin.new.connect(@_connection_options)
|
210
|
+
return @_plugins[method]
|
211
|
+
else
|
212
|
+
super
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def status()
|
217
|
+
Onering::API.new.get("/").parsed_response
|
218
|
+
end
|
219
|
+
|
220
|
+
# -----------------------------------------------------------------------------
|
221
|
+
def _setup_auth()
|
222
|
+
type = Onering::Config.get('authentication.type', :auto)
|
223
|
+
_setup_auth_token()
|
224
|
+
end
|
225
|
+
|
226
|
+
# -----------------------------------------------------------------------------
|
227
|
+
def _default_param(key, value)
|
228
|
+
@_default_params ||= {}
|
229
|
+
@_default_params[key] = value
|
230
|
+
Onering::API.default_params(@_default_params)
|
231
|
+
end
|
232
|
+
|
233
|
+
# -----------------------------------------------------------------------------
|
234
|
+
def _setup_auth_token()
|
235
|
+
Onering::Logger.info("Using token authentication mechanism", "Onering::API")
|
236
|
+
|
237
|
+
# get first keyfile found
|
238
|
+
key = Onering::Config.get('authentication.key', Onering::Config.get('authentication.keyfile'))
|
239
|
+
|
240
|
+
if key.nil?
|
241
|
+
if Onering::Config.get('authentication.bootstrap.enabled', true)
|
242
|
+
Onering::Logger.warn("Authentication token not found, attempting to autoregister client", "Onering::API")
|
243
|
+
|
244
|
+
if not (bootstrap = Onering::Config.get('authentication.bootstrap.key')).nil?
|
245
|
+
if bootstrap.to_s =~ /[0-9a-f]{32,64}/
|
246
|
+
# attempt to create key.yml from least-specific to most, first writable path wins
|
247
|
+
clients = [{
|
248
|
+
:path => "/etc/onering",
|
249
|
+
:name => fact('hardwareid'),
|
250
|
+
:keyname => 'system',
|
251
|
+
:autodelete => true
|
252
|
+
},{
|
253
|
+
:path => "~/.onering",
|
254
|
+
:name => ENV['USER'],
|
255
|
+
:keyname => 'cli',
|
256
|
+
:autodelete => false
|
257
|
+
}]
|
258
|
+
|
259
|
+
# for each client attempt...
|
260
|
+
clients.each do |client|
|
261
|
+
# expand and assemble path
|
262
|
+
client[:path] = (File.expand_path(client[:path]) rescue client[:path])
|
263
|
+
keyfile = File.join(client[:path], 'key.yml')
|
264
|
+
|
265
|
+
# skip this if we can't write to the parent directory
|
266
|
+
next unless File.writable?(client[:path])
|
267
|
+
Dir.mkdir(client[:path]) unless File.directory?(client[:path])
|
268
|
+
next if File.exists?(keyfile)
|
269
|
+
|
270
|
+
self.class.headers({
|
271
|
+
'X-Auth-Bootstrap-Token' => bootstrap
|
272
|
+
})
|
273
|
+
|
274
|
+
# attempt to create/download the keyfile
|
275
|
+
Onering::Logger.debug("Requesting authentication token for #{client[:name].strip}; #{bootstrap}", "Onering::API")
|
276
|
+
response = self.class.get("/api/users/#{client[:name].strip}/tokens/#{client[:keyname]}")
|
277
|
+
|
278
|
+
# if successful, write the file
|
279
|
+
if response.code < 400 and response.body
|
280
|
+
File.open(keyfile, 'w').puts(YAML.dump({
|
281
|
+
'authentication' => {
|
282
|
+
'key' => response.body.strip.chomp
|
283
|
+
}
|
284
|
+
}))
|
285
|
+
|
286
|
+
key = response.body.strip.chomp
|
287
|
+
|
288
|
+
else
|
289
|
+
# all errors are fatal at this stage
|
290
|
+
Onering::Logger.fatal!("Cannot autoregister client: HTTP #{response.code} - #{(response.parsed_response || {}).get('error.message', 'Unknown error')}", "Onering::API")
|
291
|
+
end
|
292
|
+
|
293
|
+
self.class.headers({})
|
294
|
+
|
295
|
+
# we're done here...
|
296
|
+
break
|
297
|
+
end
|
298
|
+
else
|
299
|
+
raise Errors::AuthenticationMissing.new("Autoregistration failed: invalid bootstrap token specified")
|
300
|
+
end
|
301
|
+
|
302
|
+
else
|
303
|
+
raise Errors::AuthenticationMissing.new("Autoregistration failed: no bootstrap token specified")
|
304
|
+
end
|
305
|
+
|
306
|
+
else
|
307
|
+
raise Errors::AuthenticationMissing.new("Authentication token not found, and autoregistration disabled")
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
raise Errors::AuthenticationMissing.new("Token authentication specified, but cannot find a token config or as a command line argument") if key.nil?
|
312
|
+
|
313
|
+
# set auth mechanism
|
314
|
+
Onering::API.headers({
|
315
|
+
'X-Auth-Mechanism' => 'token'
|
316
|
+
})
|
317
|
+
|
318
|
+
# set default parameters
|
319
|
+
_default_param(:token, key)
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|