sonar_connector 0.8.5

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.
Files changed (42) hide show
  1. data/LICENSE +20 -0
  2. data/README.rdoc +18 -0
  3. data/Rakefile +41 -0
  4. data/VERSION +1 -0
  5. data/bin/sonar-connector +69 -0
  6. data/config/config.example.json +82 -0
  7. data/lib/sonar_connector.rb +40 -0
  8. data/lib/sonar_connector/commands/command.rb +21 -0
  9. data/lib/sonar_connector/commands/commit_seppuku_command.rb +15 -0
  10. data/lib/sonar_connector/commands/increment_status_value_command.rb +14 -0
  11. data/lib/sonar_connector/commands/send_admin_email_command.rb +12 -0
  12. data/lib/sonar_connector/commands/update_disk_usage_command.rb +13 -0
  13. data/lib/sonar_connector/commands/update_status_command.rb +16 -0
  14. data/lib/sonar_connector/config.rb +166 -0
  15. data/lib/sonar_connector/connectors/base.rb +243 -0
  16. data/lib/sonar_connector/connectors/dummy_connector.rb +17 -0
  17. data/lib/sonar_connector/connectors/seppuku_connector.rb +26 -0
  18. data/lib/sonar_connector/consumer.rb +94 -0
  19. data/lib/sonar_connector/controller.rb +164 -0
  20. data/lib/sonar_connector/emailer.rb +16 -0
  21. data/lib/sonar_connector/rspec/spec_helper.rb +61 -0
  22. data/lib/sonar_connector/status.rb +43 -0
  23. data/lib/sonar_connector/utils.rb +39 -0
  24. data/script/console +10 -0
  25. data/spec/sonar_connector/commands/command_spec.rb +34 -0
  26. data/spec/sonar_connector/commands/commit_seppuku_command_spec.rb +25 -0
  27. data/spec/sonar_connector/commands/increment_status_value_command_spec.rb +25 -0
  28. data/spec/sonar_connector/commands/send_admin_email_command_spec.rb +14 -0
  29. data/spec/sonar_connector/commands/update_disk_usage_command_spec.rb +21 -0
  30. data/spec/sonar_connector/commands/update_status_command_spec.rb +24 -0
  31. data/spec/sonar_connector/config_spec.rb +93 -0
  32. data/spec/sonar_connector/connectors/base_spec.rb +207 -0
  33. data/spec/sonar_connector/connectors/dummy_connector_spec.rb +22 -0
  34. data/spec/sonar_connector/connectors/seppuku_connector_spec.rb +37 -0
  35. data/spec/sonar_connector/consumer_spec.rb +116 -0
  36. data/spec/sonar_connector/controller_spec.rb +46 -0
  37. data/spec/sonar_connector/emailer_spec.rb +36 -0
  38. data/spec/sonar_connector/status_spec.rb +78 -0
  39. data/spec/sonar_connector/utils_spec.rb +62 -0
  40. data/spec/spec.opts +2 -0
  41. data/spec/spec_helper.rb +6 -0
  42. metadata +235 -0
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010 Trampoline Systems Ltd
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,18 @@
1
+ Trampoline SONAR Connector framework
2
+
3
+ == Dependencies
4
+
5
+ === Jruby
6
+
7
+ Here's how to install jruby 1.4.0 on OS X:
8
+
9
+ wget http://jruby.kenai.com/downloads/1.4.0/jruby-bin-1.4.0.tar.gz
10
+ sudo tar xfvz jruby-bin-1.4.0.tar.gz -C /usr/local/
11
+ ln -s /usr/local/jruby/bin/jruby /usr/bin/jruby
12
+ ln -s /usr/local/jruby/bin/rake /usr/bin/jrake
13
+ ln -s /usr/local/jruby/bin/gem /usr/bin/jgem
14
+ ln -s /usr/local/jruby/bin/jirb /usr/bin/jirb
15
+
16
+ == Copyright
17
+
18
+ Copyright (c) 2010 Trampoline Systems Ltd. See LICENSE for details.
@@ -0,0 +1,41 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "sonar_connector"
8
+ gem.summary = %Q{A behind-the-firewall connector for Trampoline SONAR}
9
+ gem.description = %Q{Framework that allows arbitrary push and pull connectors to send data to an instance of the Trampoline SONAR server}
10
+ gem.email = "hello@empire42.com"
11
+ gem.homepage = "http://github.com/trampoline/sonar-connector"
12
+ gem.authors = ["Peter MacRobert", "Mark Meyer"]
13
+
14
+ gem.add_dependency "actionmailer", "= 2.3.10"
15
+ gem.add_dependency "actionmailer_extensions", ">= 0.4.2"
16
+ gem.add_dependency "json_pure", ">= 1.2.2"
17
+ gem.add_dependency "uuidtools", ">= 2.1.1"
18
+ gem.add_dependency "sonar_connector_filestore", ">= 0.1.0"
19
+
20
+ gem.add_development_dependency "rspec", ">= 1.2.8"
21
+ gem.add_development_dependency "rr", ">= 0.10.5"
22
+ end
23
+ Jeweler::GemcutterTasks.new
24
+ rescue LoadError
25
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
26
+ end
27
+
28
+ require 'spec/rake/spectask'
29
+ Spec::Rake::SpecTask.new(:spec) do |spec|
30
+ spec.libs << 'lib' << 'spec'
31
+ spec.spec_files = FileList['spec/**/*_spec.rb']
32
+ end
33
+
34
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
35
+ spec.libs << 'lib' << 'spec'
36
+ spec.pattern = 'spec/**/*_spec.rb'
37
+ spec.rcov = true
38
+ end
39
+
40
+ task :default => :spec
41
+ task :spec => :check_dependencies
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.8.5
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require File.join(File.dirname(__FILE__), '..', 'lib', 'sonar_connector')
4
+ require 'optparse'
5
+
6
+ config_filename = File.expand_path File.join(Dir.pwd, "config", "config.json")
7
+ mode = nil
8
+ install_path = nil
9
+
10
+ ARGV.options do |opts|
11
+ script_name = File.basename($0)
12
+
13
+ opts.banner = "Usage: #{script_name} OPTION"
14
+
15
+ opts.separator "Run modes:"
16
+ opts.on("--start", "Start the connector") { mode = :start }
17
+ opts.on("--check", "Validate the connector config") { mode = :check }
18
+ opts.on("--install=PATH", String, "Install the connector working dir and default config to the file system") {|p|
19
+ install_path = p
20
+ mode = :install
21
+ }
22
+ opts.on("--console", "Run IRB console in connector framework environment") { mode = :console }
23
+
24
+ opts.separator "Options:"
25
+
26
+ opts.on("-c", "--config=FILE", String, "Override the path to the config file.") {|s| config_filename = File.expand_path s}
27
+
28
+ opts.separator "Misc:"
29
+ opts.on_tail("-v", "--version", "Show version") { mode = :version }
30
+ opts.on_tail("-h", "--help", "Show this message")
31
+ opts.parse!
32
+ end
33
+
34
+ case mode
35
+ when :start
36
+ puts "Starting SONAR Connector from config file: #{config_filename}"
37
+ connector = Sonar::Connector::Controller.new(config_filename)
38
+ puts "Connector bootstrapped successfully, check log files for details."
39
+ connector.start
40
+ exit
41
+
42
+ when :check
43
+ puts "Checking config file: #{config_filename}"
44
+ Sonar::Connector::Controller.new(config_filename)
45
+ puts "...clean."
46
+ exit
47
+ when :install
48
+ path = File.expand_path install_path
49
+
50
+ if File.directory?(path)
51
+ puts "Error: Directory '#{path}' already exists, aborting."
52
+ exit(1)
53
+ end
54
+
55
+ %W{config log var}.each {|dir| FileUtils.mkdir_p File.join(path, dir)}
56
+ FileUtils.cp File.join(Sonar::Connector::ROOT, '..', "config", "config.example.json"), File.join(path, 'config', 'config.json')
57
+ puts "Success: Set up working directory '#{path}' and associated subdirs."
58
+ exit
59
+ when :console
60
+ lib_path = File.expand_path File.join(File.dirname(__FILE__), '..', 'lib')
61
+ Kernel.system "irb -rubygems -I #{lib_path} -r sonar_connector.rb"
62
+ exit
63
+ when :version
64
+ version_file = File.join File.expand_path(File.dirname(__FILE__)), "..", "VERSION"
65
+ puts "SONAR Connector Framework #{File.read(version_file)}"
66
+ exit
67
+ else
68
+ puts ARGV.options
69
+ end
@@ -0,0 +1,82 @@
1
+ {
2
+ /* log level must be one of: "debug", "info", "warn", "error", "fatal" */
3
+ "log_level" : "debug",
4
+
5
+ /* max log file size in megabytes */
6
+ "log_file_max_size" : "10",
7
+
8
+ /* number of log files to keep */
9
+ "log_files_to_keep" : "7",
10
+
11
+ "email_settings": {
12
+ "admin_recipients": ["admin@server.local"],
13
+ "admin_sender": "Sonar Connector <noreply@server.local>",
14
+ "perform_deliveries": false,
15
+
16
+ /* options are ["smtp", "sendmail", "test"] */
17
+ "delivery_method": "smtp",
18
+ "save_emails_to_disk": true,
19
+
20
+ "smtp_settings": {
21
+ "address": "127.0.0.1",
22
+ "port": 25,
23
+ "domain": "server.local",
24
+ "user_name": null,
25
+ "password": null,
26
+
27
+ /* options are ["plain", "login", "cram_md5"] */
28
+ "authentication": null
29
+ },
30
+
31
+ "sendmail_settings": {
32
+ "location": "/usr/sbin/sendmail",
33
+ "arguments": "-i -t -f nobody@localhost"
34
+ }
35
+ },
36
+
37
+ /*
38
+ Specific configuration for each connector. Each connector must have
39
+ a class and a unique name. The require load path can also be specified if necessary.
40
+ Note that each connector type may have further configuration options
41
+ that are specific to the connector class.
42
+ */
43
+ "connectors": [
44
+ {
45
+ "class": "Sonar::Connector::ImapPullConnector",
46
+ "require": "sonar_imap_pull_connector",
47
+ "name": "gmail_1",
48
+ "repeat_delay": 10,
49
+ "host": "imap.gmail.com",
50
+ "user": "foo@bar.com",
51
+ "password": "---",
52
+ "folders": "[Gmail]/All Mail"
53
+ }
54
+ ,
55
+ {
56
+ "class": "Sonar::Connector::ImapPullConnector",
57
+ "require": "sonar_imap_pull_connector",
58
+ "name": "gmail_2",
59
+ "repeat_delay": 10,
60
+ "host": "imap.gmail.com",
61
+ "user": "baz@bar.com",
62
+ "password": "---",
63
+ "folders": "[Google Mail]/All Mail"
64
+ }
65
+ ,
66
+ {
67
+ "class": "Sonar::Connector::SonarPushConnector",
68
+ "require": "sonar_push_connector",
69
+ "name": "sonar_push",
70
+ "repeat_delay": 10,
71
+ "source_connectors": ["gmail_1", "gmail_2"],
72
+ "uri": "http://localhost:3000/api/1_0/rfc822_messages",
73
+ "connector_credentials": "---"
74
+ },
75
+ {
76
+ "class": "Sonar::Connector::SeppukuConnector",
77
+ "name": "seppuku",
78
+ "enabled": true,
79
+ "repeat_delay": 43200
80
+ }
81
+ ]
82
+ }
@@ -0,0 +1,40 @@
1
+ module Sonar
2
+ module Connector
3
+ ROOT = File.dirname(__FILE__) unless Sonar::Connector.const_defined?("ROOT")
4
+ end
5
+ end
6
+
7
+ require 'rubygems'
8
+ $:.unshift(File.expand_path("..", __FILE__))
9
+
10
+ # Load external deps
11
+ require 'active_support'
12
+ require 'json'
13
+ require 'yaml'
14
+ require 'thread'
15
+ require 'logger'
16
+ require 'action_mailer'
17
+ require 'actionmailer_extensions'
18
+ require 'fileutils'
19
+ require 'sonar_connector_filestore'
20
+
21
+ # Load internal classes
22
+ %W(
23
+ controller
24
+ config
25
+ status
26
+ consumer
27
+ emailer
28
+ utils
29
+ connectors/base
30
+ connectors/dummy_connector
31
+ connectors/seppuku_connector
32
+ commands/command
33
+ commands/update_status_command
34
+ commands/send_admin_email_command
35
+ commands/update_disk_usage_command
36
+ commands/increment_status_value_command
37
+ commands/commit_seppuku_command
38
+ ).each do |file|
39
+ require File.join('sonar_connector', file)
40
+ end
@@ -0,0 +1,21 @@
1
+ module Sonar
2
+ module Connector
3
+
4
+ ##
5
+ # Base command class that all commands should subclass.
6
+
7
+ class Command
8
+
9
+ attr_accessor :proc
10
+
11
+ def initialize(proc)
12
+ @proc = proc
13
+ end
14
+
15
+ def execute(context = nil)
16
+ context.instance_eval(&@proc)
17
+ end
18
+
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ module Sonar
2
+ module Connector
3
+
4
+ class CommitSeppukuCommand < Sonar::Connector::Command
5
+ def initialize
6
+ l = lambda do
7
+ # controller is in scope here because we've jumped thru some serious hoops
8
+ # and shaved the hell out of a yak or three.
9
+ Thread.new {controller.shutdown_lambda.call}
10
+ end
11
+ super(l)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module Sonar
2
+ module Connector
3
+
4
+ class IncrementStatusValueCommand < Sonar::Connector::Command
5
+ def initialize(connector, field, value = 1)
6
+ l = lambda do
7
+ current = status[connector.name] ? status[connector.name][field].to_i : 0
8
+ status.set connector.name, field, current+value
9
+ end
10
+ super(l)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ module Sonar
2
+ module Connector
3
+ class SendAdminEmailCommand < Sonar::Connector::Command
4
+ def initialize(connector, message)
5
+ l = lambda do
6
+ Sonar::Connector::Emailer.deliver_admin_message(connector, message)
7
+ end
8
+ super(l)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ module Sonar
2
+ module Connector
3
+ class UpdateDiskUsageCommand < Sonar::Connector::Command
4
+ def initialize(connector)
5
+ l = lambda do
6
+ du = (Sonar::Connector::Utils.du(connector.connector_dir).to_f / 1024).round
7
+ status.set connector.name, 'disk_usage', "#{du} Kb"
8
+ end
9
+ super(l)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ module Sonar
2
+ module Connector
3
+
4
+ ACTION_OK = 'ok'
5
+ ACTION_FAILED = 'failed'
6
+
7
+ class UpdateStatusCommand < Sonar::Connector::Command
8
+ def initialize(connector, field, value)
9
+ l = lambda do
10
+ status.set connector.name, field, value
11
+ end
12
+ super(l)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,166 @@
1
+ module Sonar
2
+ module Connector
3
+ class InvalidConfig < RuntimeError; end
4
+
5
+ class Config
6
+
7
+ # base params
8
+ attr_reader :base_dir
9
+ attr_reader :log_dir
10
+ attr_reader :connectors_dir
11
+ attr_reader :controller_log_file
12
+ attr_reader :status_file
13
+ attr_reader :connectors
14
+ attr_reader :email_settings
15
+
16
+ # configurable: logger params
17
+ attr_reader :log_level
18
+ attr_reader :log_file_max_size
19
+ attr_reader :log_files_to_keep
20
+
21
+ # Entry-point for creating and setting the CONFIG instance.
22
+ # Give it a path to the JSON settings file and it'll do the rest.
23
+ def self.load(config_file)
24
+ config = Config.new(config_file).parse
25
+ Sonar::Connector.const_set("CONFIG", config)
26
+ end
27
+
28
+ # Helper method to read and parse JSON file from disk. Abstracted for testing purposes.
29
+ def self.read_json_file(config_file)
30
+ JSON.parse IO.read(config_file)
31
+ end
32
+
33
+ def initialize(config_file)
34
+ @config_file = config_file
35
+ end
36
+
37
+ def parse
38
+ @raw_config = Config.read_json_file(config_file)
39
+
40
+ # extract the core config params
41
+ @base_dir = parse_base_dir @raw_config["base_dir"]
42
+ @log_dir = File.join @base_dir, 'log'
43
+ @connectors_dir = File.join @base_dir, 'var'
44
+ @controller_log_file = File.join @log_dir, 'controller.log'
45
+ @status_file = File.join @base_dir, 'status.yml'
46
+ @log_level = parse_log_level @raw_config["log_level"]
47
+ @log_file_max_size = parse_log_file_max_size @raw_config["log_file_max_size"]
48
+ @log_files_to_keep = parse_log_files_to_keep @raw_config["log_files_to_keep"]
49
+ @email_settings = parse_email_settings @raw_config["email_settings"]
50
+
51
+ # extract each connector, locate its class and attempt to parse its config
52
+ @connectors = parse_connectors @raw_config["connectors"]
53
+
54
+ associate_connector_dependencies! @connectors
55
+
56
+ self
57
+ rescue JSON::ParserError => e
58
+ raise InvalidConfig.new("Config file #{config_file} is not in a valid JSON format. Please check the contents carefully. This is the exact error: \n#{e.message}")
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :config_file
64
+ attr_reader :raw_config
65
+
66
+ def parse_base_dir(base_dir)
67
+ d = base_dir.blank? ? File.dirname(File.dirname(@config_file)) : base_dir
68
+ raise InvalidConfig.new("#{d} not a valid directory") unless File.directory?(d)
69
+ d
70
+ end
71
+
72
+ def parse_log_level(log_level)
73
+ raise InvalidConfig.new("Config option 'log_level' is a required parameter.") if log_level.blank?
74
+ valid_log_levels = ["debug", "info", "warn", "error", "fatal"]
75
+ raise InvalidConfig.new("unknown log_level #{log_level}") unless valid_log_levels.include?(log_level)
76
+ Logger.const_get log_level.upcase
77
+ end
78
+
79
+ def parse_log_file_max_size(log_file_max_size)
80
+ raise InvalidConfig.new("invalid log_file_max_size #{log_file_max_size}") if !log_file_max_size.blank? && log_file_max_size.to_i == 0
81
+ log_file_max_size.blank? ? 10*1024*1024 : log_file_max_size.to_i*1024*1024
82
+ end
83
+
84
+ def parse_log_files_to_keep(log_files_to_keep)
85
+ raise InvalidConfig.new("invalid log_files_to_keep #{log_files_to_keep}") if !log_files_to_keep.blank? && log_files_to_keep.to_i == 0
86
+ log_files_to_keep.blank? ? 10 : log_files_to_keep.to_i
87
+ end
88
+
89
+ def parse_email_settings(settings)
90
+ ActionMailer::Base.perform_deliveries = settings["perform_deliveries"]
91
+ ActionMailer::Base.delivery_method = settings["delivery_method"].to_sym
92
+ ActionMailer::Base.raise_delivery_errors = true
93
+
94
+ # ActionMailer needs the smtp and sendmail settings hashes to have symbols for keys
95
+ ActionMailer::Base.smtp_settings = symbolise_hash_keys settings["smtp_settings"]
96
+ ActionMailer::Base.sendmail_settings = symbolise_hash_keys settings["sendmail_settings"]
97
+
98
+ ActionMailer::Base.save_emails_to_disk = settings["save_emails_to_disk"]
99
+ ActionMailer::Base.email_output_dir = File.join @base_dir, 'sent_administrator_emails'
100
+ ActionMailer::Base.safe_recipients = settings["admin_recipients"].to_a
101
+ settings
102
+ end
103
+
104
+ def parse_connectors(connectors_config)
105
+ raise InvalidConfig.new("Connector parameter must be an array and cannot be empty") unless connectors_config.instance_of?(Array) && !connectors_config.empty?
106
+
107
+ c = []
108
+ connectors_config.each do |config|
109
+ c << parse_connector(config)
110
+ end
111
+
112
+ raise InvalidConfig.new("Connector names must be unique. You supplied: #{c.map(&:name).inspect}") if c.map(&:name).uniq.size != c.size
113
+ c
114
+ end
115
+
116
+ def parse_connector(config)
117
+
118
+ # Load the require first, if specified
119
+ begin
120
+ require config["require"] unless config["require"].blank?
121
+ rescue MissingSourceFile
122
+ raise InvalidConfig.new("Error with parameter 'require' in connector settings '#{config.inspect}': require failed - check that the path is correct.")
123
+ end
124
+
125
+ # Insist that class is specified
126
+ raise InvalidConfig.new("Error with parameter 'class' in connector settings '#{config.inspect}': class must be specified.") if config["class"].blank?
127
+
128
+ # Attempt to load the class definition
129
+ begin
130
+ klass = config["class"].constantize
131
+ rescue
132
+ raise InvalidConfig.new("Error with parameter 'class' in connector settings '#{config.inspect}': could not load class.")
133
+ end
134
+
135
+ # sanity-check that the connector class subclasses the base
136
+ raise InvalidConfig.new("Connector class #{klass.name} must subclass Sonar::Connector::Base") unless klass.ancestors.include?(Sonar::Connector::Base)
137
+ klass.new(config, self)
138
+ end
139
+
140
+ def symbolise_hash_keys(hash)
141
+ return nil unless hash
142
+ hash.keys.inject({}){|acc, k| acc[k.to_sym] = hash[k]; acc}
143
+ end
144
+
145
+ # Find all connectors with "source_connectors" specified in config, and map these
146
+ # associations to the connector instances.
147
+ def associate_connector_dependencies!(connectors)
148
+ connectors.each do |connector|
149
+ source_names = [*connector.raw_config["source_connectors"]].compact
150
+ next if source_names.blank?
151
+
152
+ source_connectors = source_names.map do |source_name|
153
+ c = connectors.select{|connector2| connector2.name == source_name}.first
154
+ raise InvalidConfig.new("Connector '#{connector.name}' references a source connector '#{source_name}' but no such connector name is defined.") unless c
155
+ raise InvalidConfig.new("Connector '#{connector.name}' cannot have itself as a source connector.") if c == connector
156
+ c
157
+ end
158
+
159
+ connector.send :source_connectors=, source_connectors
160
+ end
161
+ end
162
+
163
+ end
164
+ end
165
+ end
166
+