arborist 0.0.1.pre20160106113421
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.document +4 -0
- data/.simplecov +9 -0
- data/ChangeLog +417 -0
- data/Events.md +20 -0
- data/History.md +4 -0
- data/LICENSE +29 -0
- data/Manifest.txt +72 -0
- data/Monitors.md +141 -0
- data/Nodes.md +0 -0
- data/Observers.md +72 -0
- data/Protocol.md +214 -0
- data/README.md +75 -0
- data/Rakefile +81 -0
- data/TODO.md +24 -0
- data/bin/amanagerd +10 -0
- data/bin/amonitord +12 -0
- data/bin/aobserverd +12 -0
- data/lib/arborist.rb +182 -0
- data/lib/arborist/client.rb +191 -0
- data/lib/arborist/event.rb +61 -0
- data/lib/arborist/event/node_acked.rb +18 -0
- data/lib/arborist/event/node_delta.rb +20 -0
- data/lib/arborist/event/node_matching.rb +34 -0
- data/lib/arborist/event/node_update.rb +19 -0
- data/lib/arborist/event/sys_reloaded.rb +15 -0
- data/lib/arborist/exceptions.rb +21 -0
- data/lib/arborist/manager.rb +508 -0
- data/lib/arborist/manager/event_publisher.rb +97 -0
- data/lib/arborist/manager/tree_api.rb +207 -0
- data/lib/arborist/mixins.rb +363 -0
- data/lib/arborist/monitor.rb +377 -0
- data/lib/arborist/monitor/socket.rb +163 -0
- data/lib/arborist/monitor_runner.rb +217 -0
- data/lib/arborist/node.rb +700 -0
- data/lib/arborist/node/host.rb +87 -0
- data/lib/arborist/node/root.rb +60 -0
- data/lib/arborist/node/service.rb +112 -0
- data/lib/arborist/observer.rb +176 -0
- data/lib/arborist/observer/action.rb +125 -0
- data/lib/arborist/observer/summarize.rb +105 -0
- data/lib/arborist/observer_runner.rb +181 -0
- data/lib/arborist/subscription.rb +82 -0
- data/spec/arborist/client_spec.rb +282 -0
- data/spec/arborist/event/node_update_spec.rb +71 -0
- data/spec/arborist/event_spec.rb +64 -0
- data/spec/arborist/manager/event_publisher_spec.rb +66 -0
- data/spec/arborist/manager/tree_api_spec.rb +458 -0
- data/spec/arborist/manager_spec.rb +442 -0
- data/spec/arborist/mixins_spec.rb +195 -0
- data/spec/arborist/monitor/socket_spec.rb +195 -0
- data/spec/arborist/monitor_runner_spec.rb +152 -0
- data/spec/arborist/monitor_spec.rb +251 -0
- data/spec/arborist/node/host_spec.rb +104 -0
- data/spec/arborist/node/root_spec.rb +29 -0
- data/spec/arborist/node/service_spec.rb +98 -0
- data/spec/arborist/node_spec.rb +552 -0
- data/spec/arborist/observer/action_spec.rb +205 -0
- data/spec/arborist/observer/summarize_spec.rb +294 -0
- data/spec/arborist/observer_spec.rb +146 -0
- data/spec/arborist/subscription_spec.rb +71 -0
- data/spec/arborist_spec.rb +146 -0
- data/spec/data/monitors/pings.rb +80 -0
- data/spec/data/monitors/port_checks.rb +27 -0
- data/spec/data/monitors/system_resources.rb +30 -0
- data/spec/data/monitors/web_services.rb +17 -0
- data/spec/data/nodes/duir.rb +20 -0
- data/spec/data/nodes/localhost.rb +15 -0
- data/spec/data/nodes/sidonie.rb +29 -0
- data/spec/data/nodes/yevaud.rb +26 -0
- data/spec/data/observers/auditor.rb +23 -0
- data/spec/data/observers/webservices.rb +18 -0
- data/spec/spec_helper.rb +117 -0
- metadata +368 -0
@@ -0,0 +1,146 @@
|
|
1
|
+
#!/usr/bin/env rspec -cfd
|
2
|
+
|
3
|
+
require_relative '../spec_helper'
|
4
|
+
|
5
|
+
describe Arborist::Observer do
|
6
|
+
|
7
|
+
|
8
|
+
it "can be created with just a description" do
|
9
|
+
observer = described_class.new( "the description" )
|
10
|
+
expect( observer ).to be_a( described_class )
|
11
|
+
expect( observer.description ).to eq( "the description" )
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
it "yields itself to the provided block for the DSL" do
|
16
|
+
block_self = nil
|
17
|
+
observer = described_class.new( "testing observer" ) do
|
18
|
+
block_self = self
|
19
|
+
end
|
20
|
+
|
21
|
+
expect( block_self ).to be( observer )
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
it "can specify a subscription for an event it's interested in" do
|
26
|
+
observer = described_class.new( "testing observer" ) do
|
27
|
+
subscribe to: 'node.delta'
|
28
|
+
end
|
29
|
+
|
30
|
+
expect( observer.subscriptions ).to be_an( Array )
|
31
|
+
expect( observer.subscriptions.length ).to eq( 1 )
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
it "can specify a subscription for more than one event it's interested in" do
|
36
|
+
observer = described_class.new( "testing observer" ) do
|
37
|
+
subscribe to: 'node.delta'
|
38
|
+
subscribe to: 'sys.reload'
|
39
|
+
end
|
40
|
+
|
41
|
+
expect( observer.subscriptions ).to be_an( Array )
|
42
|
+
expect( observer.subscriptions.length ).to eq( 2 )
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
it "can specify an action to run when a subscribed event is received" do
|
47
|
+
observer = described_class.new( "testing observer" ) do
|
48
|
+
action do |uuid, event|
|
49
|
+
puts( uuid )
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
expect( observer.actions ).to be_an( Array )
|
54
|
+
expect( observer.actions.length ).to eq( 1 )
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
it "can specify more than one action to run when a subscribed event is received" do
|
59
|
+
observer = described_class.new( "testing observer" ) do
|
60
|
+
action do |uuid, event|
|
61
|
+
puts( uuid )
|
62
|
+
end
|
63
|
+
action do |uuid, event|
|
64
|
+
$stderr.puts( uuid )
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
expect( observer.actions ).to be_an( Array )
|
69
|
+
expect( observer.actions.length ).to eq( 2 )
|
70
|
+
expect( observer.actions ).to all( be_a(Arborist::Observer::Action) )
|
71
|
+
end
|
72
|
+
|
73
|
+
|
74
|
+
it "can specify a summary action" do
|
75
|
+
observer = described_class.new( "testing observer" ) do
|
76
|
+
summarize( every: 5 ) do |events|
|
77
|
+
puts( events.size )
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
expect( observer.actions ).to be_an( Array )
|
82
|
+
expect( observer.actions.length ).to eq( 1 )
|
83
|
+
expect( observer.actions.first ).to be_a( Arborist::Observer::Summarize )
|
84
|
+
end
|
85
|
+
|
86
|
+
|
87
|
+
it "can specify a mix of regular and summary actions" do
|
88
|
+
observer = described_class.new( "testing observer" ) do
|
89
|
+
summarize( every: 5 ) do |events|
|
90
|
+
puts( events.size )
|
91
|
+
end
|
92
|
+
action do |uuid, event|
|
93
|
+
$stderr.puts( uuid )
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
expect( observer.actions ).to be_an( Array )
|
98
|
+
expect( observer.actions.length ).to eq( 2 )
|
99
|
+
expect( observer.actions.first ).to be_a( Arborist::Observer::Summarize )
|
100
|
+
expect( observer.actions.last ).to be_a( Arborist::Observer::Action )
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
it "passes events it is given to handle to its actions" do
|
105
|
+
observer = described_class.new( "testing observer" ) do
|
106
|
+
summarize( every: 5 ) do |events|
|
107
|
+
puts( events.size )
|
108
|
+
end
|
109
|
+
action do |uuid, event|
|
110
|
+
$stderr.puts( uuid )
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
event = {}
|
115
|
+
expect( observer.actions.first ).to receive( :handle_event ).with( event )
|
116
|
+
expect( observer.actions.last ).to receive( :handle_event ).with( event )
|
117
|
+
|
118
|
+
observer.handle_event( "eventid", event )
|
119
|
+
end
|
120
|
+
|
121
|
+
|
122
|
+
it "can build a list of timer callbacks for its actions" do
|
123
|
+
summarize_called = false
|
124
|
+
|
125
|
+
observer = described_class.new( "testing observer" ) do
|
126
|
+
summarize( every: 5 ) do |events|
|
127
|
+
summarize_called = true
|
128
|
+
end
|
129
|
+
action do |uuid, event|
|
130
|
+
$stderr.puts( uuid )
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
results = observer.timers
|
135
|
+
|
136
|
+
expect( results ).to be_an( Array )
|
137
|
+
expect( results.size ).to eq( 1 )
|
138
|
+
expect( results ).to all( be_an(Array) )
|
139
|
+
expect( results.last[0] ).to eq( 5 )
|
140
|
+
|
141
|
+
observer.handle_event( "eventid", {} )
|
142
|
+
expect { results.last[1].call }.to change { summarize_called }.to( true )
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
|
@@ -0,0 +1,71 @@
|
|
1
|
+
#!/usr/bin/env rspec -cfd
|
2
|
+
|
3
|
+
require_relative '../spec_helper'
|
4
|
+
|
5
|
+
require 'arborist/subscription'
|
6
|
+
|
7
|
+
|
8
|
+
describe Arborist::Subscription do
|
9
|
+
|
10
|
+
let( :host_node ) do
|
11
|
+
Arborist::Node.create( 'host', 'testnode' ) do
|
12
|
+
description "Test host"
|
13
|
+
address '192.168.1.1'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
let( :service_node ) do
|
17
|
+
host_node.service( 'ssh' )
|
18
|
+
end
|
19
|
+
let( :publisher ) do
|
20
|
+
instance_double( Arborist::Manager::EventPublisher )
|
21
|
+
end
|
22
|
+
let( :subscription ) do
|
23
|
+
described_class.new( publisher, 'node.delta', type: 'host' )
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
it "generates a unique ID when it's created" do
|
28
|
+
expect( subscription.id ).to match( /^\S{16,}$/ )
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
it "publishes events which are of the desired type and have matching criteria" do
|
33
|
+
event = Arborist::Event.create( 'node_delta', host_node, status: ['up', 'down'] )
|
34
|
+
|
35
|
+
expect( publisher ).to receive( :publish ).with( subscription.id, event )
|
36
|
+
|
37
|
+
subscription.on_events( event )
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
it "publishes events which are of any type if the specified type is `nil`" do
|
42
|
+
subscription = described_class.new( publisher )
|
43
|
+
event1 = Arborist::Event.create( 'node_delta', host_node, status: ['up', 'down'] )
|
44
|
+
event2 = Arborist::Event.create( 'sys_reloaded' )
|
45
|
+
|
46
|
+
expect( publisher ).to receive( :publish ).with( subscription.id, event1 )
|
47
|
+
expect( publisher ).to receive( :publish ).with( subscription.id, event2 )
|
48
|
+
|
49
|
+
subscription.on_events( event1, event2 )
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
it "doesn't publish events which are of the desired type but don't have matching criteria" do
|
54
|
+
event = Arborist::Event.create( 'node_delta', service_node, status: ['up', 'down'] )
|
55
|
+
|
56
|
+
expect( publisher ).to_not receive( :publish )
|
57
|
+
|
58
|
+
subscription.on_events( event )
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
it "doesn't publish events which have matching criteria but aren't of the desired type" do
|
63
|
+
event = Arborist::Event.create( 'node_update', host_node )
|
64
|
+
|
65
|
+
expect( publisher ).to_not receive( :publish )
|
66
|
+
|
67
|
+
subscription.on_events( event )
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
|
@@ -0,0 +1,146 @@
|
|
1
|
+
#!/usr/bin/env rspec -cfd
|
2
|
+
|
3
|
+
require_relative 'spec_helper'
|
4
|
+
|
5
|
+
require 'pathname'
|
6
|
+
require 'arborist'
|
7
|
+
|
8
|
+
|
9
|
+
describe Arborist do
|
10
|
+
|
11
|
+
before( :all ) do
|
12
|
+
@original_config_env = ENV[Arborist::CONFIG_ENV]
|
13
|
+
@data_dir = Pathname( __FILE__ ).dirname + 'data'
|
14
|
+
@nodes_dir = @data_dir + 'nodes'
|
15
|
+
end
|
16
|
+
|
17
|
+
before( :each ) do
|
18
|
+
ENV.delete(Arborist::CONFIG_ENV)
|
19
|
+
end
|
20
|
+
|
21
|
+
after( :all ) do
|
22
|
+
ENV[Arborist::CONFIG_ENV] = @original_config_env
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
it "has a semantic version" do
|
27
|
+
expect( described_class::VERSION ).to match( /^\d+\.\d+\.\d+/ )
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
describe "configurability" do
|
32
|
+
|
33
|
+
before( :each ) do
|
34
|
+
Configurability.configure_objects( Configurability.default_config )
|
35
|
+
end
|
36
|
+
|
37
|
+
after( :all ) do
|
38
|
+
Configurability.reset
|
39
|
+
end
|
40
|
+
|
41
|
+
|
42
|
+
it "can return the loaded configuration" do
|
43
|
+
expect( described_class.config ).to be( Configurability.loaded_config )
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
it "knows whether or not the config has been loaded" do
|
48
|
+
expect( described_class ).to be_config_loaded
|
49
|
+
Configurability.reset
|
50
|
+
expect( described_class ).to_not be_config_loaded
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
it "will load a local config file if it exists and none is specified" do
|
55
|
+
config_object = double( "Configurability::Config object" )
|
56
|
+
allow( config_object ).to receive( :[] ).with( :arborist ).and_return( {} )
|
57
|
+
|
58
|
+
expect( Configurability ).to receive( :gather_defaults ).
|
59
|
+
and_return( {} )
|
60
|
+
expect( Arborist::LOCAL_CONFIG_FILE ).to receive( :exist? ).
|
61
|
+
and_return( true )
|
62
|
+
expect( Configurability::Config ).to receive( :load ).
|
63
|
+
with( Arborist::LOCAL_CONFIG_FILE, {} ).
|
64
|
+
and_return( config_object )
|
65
|
+
expect( config_object ).to receive( :install )
|
66
|
+
|
67
|
+
Arborist.load_config
|
68
|
+
end
|
69
|
+
|
70
|
+
|
71
|
+
it "will load a default config file if none is specified and there's no local config" do
|
72
|
+
config_object = double( "Configurability::Config object" )
|
73
|
+
allow( config_object ).to receive( :[] ).with( :arborist ).and_return( {} )
|
74
|
+
|
75
|
+
expect( Configurability ).to receive( :gather_defaults ).
|
76
|
+
and_return( {} )
|
77
|
+
expect( Arborist::LOCAL_CONFIG_FILE ).to receive( :exist? ).
|
78
|
+
and_return( false )
|
79
|
+
expect( Configurability::Config ).to receive( :load ).
|
80
|
+
with( Arborist::DEFAULT_CONFIG_FILE, {} ).
|
81
|
+
and_return( config_object )
|
82
|
+
expect( config_object ).to receive( :install )
|
83
|
+
|
84
|
+
Arborist.load_config
|
85
|
+
end
|
86
|
+
|
87
|
+
|
88
|
+
it "will load a config file given in an environment variable" do
|
89
|
+
ENV['ARBORIST_CONFIG'] = '/usr/local/etc/config.yml'
|
90
|
+
|
91
|
+
config_object = double( "Configurability::Config object" )
|
92
|
+
allow( config_object ).to receive( :[] ).with( :arborist ).and_return( {} )
|
93
|
+
|
94
|
+
expect( Configurability ).to receive( :gather_defaults ).
|
95
|
+
and_return( {} )
|
96
|
+
expect( Configurability::Config ).to receive( :load ).
|
97
|
+
with( '/usr/local/etc/config.yml', {} ).
|
98
|
+
and_return( config_object )
|
99
|
+
expect( config_object ).to receive( :install )
|
100
|
+
|
101
|
+
Arborist.load_config
|
102
|
+
end
|
103
|
+
|
104
|
+
|
105
|
+
it "will load a config file and install it if one is given" do
|
106
|
+
config_object = double( "Configurability::Config object" )
|
107
|
+
allow( config_object ).to receive( :[] ).with( :arborist ).and_return( {} )
|
108
|
+
|
109
|
+
expect( Configurability ).to receive( :gather_defaults ).
|
110
|
+
and_return( {} )
|
111
|
+
expect( Configurability::Config ).to receive( :load ).
|
112
|
+
with( 'a/configfile.yml', {} ).
|
113
|
+
and_return( config_object )
|
114
|
+
expect( config_object ).to receive( :install )
|
115
|
+
|
116
|
+
Arborist.load_config( 'a/configfile.yml' )
|
117
|
+
end
|
118
|
+
|
119
|
+
|
120
|
+
it "will override default values when loading the config if they're given" do
|
121
|
+
config_object = double( "Configurability::Config object" )
|
122
|
+
allow( config_object ).to receive( :[] ).with( :arborist ).and_return( {} )
|
123
|
+
|
124
|
+
expect( Configurability ).to_not receive( :gather_defaults )
|
125
|
+
expect( Configurability::Config ).to receive( :load ).
|
126
|
+
with( 'a/different/configfile.yml', {database: {dbname: 'test'}} ).
|
127
|
+
and_return( config_object )
|
128
|
+
expect( config_object ).to receive( :install )
|
129
|
+
|
130
|
+
Arborist.load_config( 'a/different/configfile.yml', database: {dbname: 'test'} )
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
134
|
+
|
135
|
+
|
136
|
+
it "can load all nodes in a directory and return a manager for them" do
|
137
|
+
expect( described_class.manager_for(@nodes_dir) ).to be_a( Arborist::Manager )
|
138
|
+
end
|
139
|
+
|
140
|
+
|
141
|
+
it "has a ZMQ context" do
|
142
|
+
expect( described_class.zmq_context ).to be_a( ZMQ::Context )
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
#encoding: utf-8
|
3
|
+
|
4
|
+
require 'loggability'
|
5
|
+
require 'arborist/monitor'
|
6
|
+
require 'arborist/mixins'
|
7
|
+
|
8
|
+
using Arborist::TimeRefinements
|
9
|
+
|
10
|
+
module FPingWrapper
|
11
|
+
extend Loggability
|
12
|
+
log_to :arborist
|
13
|
+
|
14
|
+
attr_accessor :identifiers
|
15
|
+
|
16
|
+
def exec_arguments( nodes )
|
17
|
+
self.identifiers = nodes.each_with_object({}) do |(identifier, props), hash|
|
18
|
+
next unless props.key?( 'addresses' )
|
19
|
+
address = props[ 'addresses' ].first
|
20
|
+
hash[ address ] = identifier
|
21
|
+
end
|
22
|
+
|
23
|
+
return {} if self.identifiers.empty?
|
24
|
+
|
25
|
+
return self.identifiers.keys
|
26
|
+
end
|
27
|
+
|
28
|
+
def handle_results( pid, stdout, stderr )
|
29
|
+
# 8.8.8.8 is alive (32.1 ms)
|
30
|
+
# 8.8.4.4 is alive (14.9 ms)
|
31
|
+
# 8.8.0.1 is unreachable
|
32
|
+
|
33
|
+
return stdout.each_line.with_object({}) do |line, hash|
|
34
|
+
address, remainder = line.split( ' ', 2 )
|
35
|
+
identifier = self.identifiers[ address ] or next
|
36
|
+
|
37
|
+
self.log.debug " parsing result for %s(%s): %p" % [ identifier, address, remainder ]
|
38
|
+
|
39
|
+
if remainder =~ /is alive \((\d+\.\d+) ms\)/
|
40
|
+
hash[ identifier ] = { rtt: Float( $1 ) }
|
41
|
+
else
|
42
|
+
hash[ identifier ] = { error: remainder.chomp }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
Arborist::Monitor 'ping check' do
|
51
|
+
every 20.seconds
|
52
|
+
splay 5.seconds
|
53
|
+
match type: 'host'
|
54
|
+
exclude tag: :laptop
|
55
|
+
use :addresses
|
56
|
+
|
57
|
+
exec 'fping', '-e', '-t', '150'
|
58
|
+
exec_callbacks( FPingWrapper )
|
59
|
+
end
|
60
|
+
|
61
|
+
Arborist::Monitor 'ping check downed hosts' do
|
62
|
+
every 40.seconds
|
63
|
+
splay 15.seconds
|
64
|
+
match type: 'host', status: 'down'
|
65
|
+
include_down true
|
66
|
+
use :addresses
|
67
|
+
|
68
|
+
exec 'fping', '-e', '-t', '150'
|
69
|
+
exec_callbacks( FPingWrapper )
|
70
|
+
end
|
71
|
+
|
72
|
+
Arborist::Monitor 'transient host pings' do
|
73
|
+
every 5.minutes
|
74
|
+
match type: 'host', tag: 'laptop'
|
75
|
+
use :addresses
|
76
|
+
|
77
|
+
exec 'fping', '-e', '-t', '500'
|
78
|
+
exec_callbacks( FPingWrapper )
|
79
|
+
end
|
80
|
+
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
#encoding: utf-8
|
3
|
+
|
4
|
+
require 'arborist/monitor'
|
5
|
+
require 'arborist/monitor/socket'
|
6
|
+
|
7
|
+
using Arborist::TimeRefinements
|
8
|
+
|
9
|
+
Arborist::Monitor 'port checks on all tcp services' do
|
10
|
+
every 5.seconds
|
11
|
+
match type: 'service', protocol: 'tcp'
|
12
|
+
use :addresses, :port
|
13
|
+
exec( Arborist::Monitor::Socket::TCP )
|
14
|
+
end
|
15
|
+
|
16
|
+
Arborist::Monitor 'port checks on downed tcp services' do
|
17
|
+
every 10.seconds
|
18
|
+
match type: 'service', protocol: 'tcp', status: 'down'
|
19
|
+
include_down true
|
20
|
+
use :addresses, :port
|
21
|
+
exec( Arborist::Monitor::Socket::TCP )
|
22
|
+
end
|
23
|
+
|
24
|
+
# :MAHLON: A possible equivalent way to provide the above as a default for some monitors:
|
25
|
+
# Arborist::Monitor::Socket::TCP.default_monitor
|
26
|
+
|
27
|
+
|