ezmobius-nanite 0.4.0 → 0.4.1.1
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/README.rdoc +70 -20
- data/Rakefile +1 -1
- data/bin/nanite-agent +34 -8
- data/bin/nanite-mapper +18 -8
- data/lib/nanite.rb +71 -0
- data/lib/nanite/actor.rb +60 -0
- data/lib/nanite/actor_registry.rb +24 -0
- data/lib/nanite/admin.rb +138 -0
- data/lib/nanite/agent.rb +250 -0
- data/lib/nanite/amqp.rb +47 -0
- data/lib/nanite/cluster.rb +203 -0
- data/lib/nanite/config.rb +102 -0
- data/lib/nanite/console.rb +39 -0
- data/lib/nanite/daemonize.rb +13 -0
- data/lib/nanite/dispatcher.rb +90 -0
- data/lib/nanite/identity.rb +16 -0
- data/lib/nanite/job.rb +104 -0
- data/lib/nanite/local_state.rb +34 -0
- data/lib/nanite/log.rb +64 -0
- data/lib/nanite/log/formatter.rb +39 -0
- data/lib/nanite/mapper.rb +277 -0
- data/lib/nanite/mapper_proxy.rb +56 -0
- data/lib/nanite/packets.rb +231 -0
- data/lib/nanite/pid_file.rb +52 -0
- data/lib/nanite/reaper.rb +38 -0
- data/lib/nanite/security/cached_certificate_store_proxy.rb +24 -0
- data/lib/nanite/security/certificate.rb +55 -0
- data/lib/nanite/security/certificate_cache.rb +66 -0
- data/lib/nanite/security/distinguished_name.rb +34 -0
- data/lib/nanite/security/encrypted_document.rb +46 -0
- data/lib/nanite/security/rsa_key_pair.rb +53 -0
- data/lib/nanite/security/secure_serializer.rb +67 -0
- data/lib/nanite/security/signature.rb +40 -0
- data/lib/nanite/security/static_certificate_store.rb +35 -0
- data/lib/nanite/security_provider.rb +47 -0
- data/lib/nanite/serializer.rb +52 -0
- data/lib/nanite/state.rb +164 -0
- data/lib/nanite/streaming.rb +125 -0
- data/lib/nanite/util.rb +51 -0
- data/spec/actor_registry_spec.rb +62 -0
- data/spec/actor_spec.rb +59 -0
- data/spec/agent_spec.rb +235 -0
- data/spec/cached_certificate_store_proxy_spec.rb +34 -0
- data/spec/certificate_cache_spec.rb +49 -0
- data/spec/certificate_spec.rb +27 -0
- data/spec/cluster_spec.rb +300 -0
- data/spec/dispatcher_spec.rb +136 -0
- data/spec/distinguished_name_spec.rb +24 -0
- data/spec/encrypted_document_spec.rb +21 -0
- data/spec/job_spec.rb +219 -0
- data/spec/local_state_spec.rb +112 -0
- data/spec/packet_spec.rb +218 -0
- data/spec/rsa_key_pair_spec.rb +33 -0
- data/spec/secure_serializer_spec.rb +41 -0
- data/spec/serializer_spec.rb +107 -0
- data/spec/signature_spec.rb +30 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/static_certificate_store_spec.rb +30 -0
- data/spec/util_spec.rb +63 -0
- metadata +63 -2
@@ -0,0 +1,40 @@
|
|
1
|
+
module Nanite
|
2
|
+
|
3
|
+
# Signature that can be validated against certificates
|
4
|
+
class Signature
|
5
|
+
|
6
|
+
FLAGS = OpenSSL::PKCS7::NOCERTS || OpenSSL::PKCS7::BINARY || OpenSSL::PKCS7::NOATTR || OpenSSL::PKCS7::NOSMIMECAP || OpenSSL::PKCS7::DETACH
|
7
|
+
|
8
|
+
# Create signature using certificate and key pair.
|
9
|
+
#
|
10
|
+
# Arguments:
|
11
|
+
# - 'data': Data to be signed
|
12
|
+
# - 'cert': Certificate used for signature
|
13
|
+
# - 'key': RsaKeyPair used for signature
|
14
|
+
#
|
15
|
+
def initialize(data, cert, key)
|
16
|
+
@p7 = OpenSSL::PKCS7.sign(cert.raw_cert, key.raw_key, data, [], FLAGS)
|
17
|
+
@store = OpenSSL::X509::Store.new
|
18
|
+
end
|
19
|
+
|
20
|
+
# Load signature previously serialized via 'data'
|
21
|
+
def self.from_data(data)
|
22
|
+
sig = Signature.allocate
|
23
|
+
sig.instance_variable_set(:@p7, OpenSSL::PKCS7::PKCS7.new(data))
|
24
|
+
sig.instance_variable_set(:@store, OpenSSL::X509::Store.new)
|
25
|
+
sig
|
26
|
+
end
|
27
|
+
|
28
|
+
# 'true' if signature was created using given cert, 'false' otherwise
|
29
|
+
def match?(cert)
|
30
|
+
@p7.verify([cert.raw_cert], @store, nil, OpenSSL::PKCS7::NOVERIFY)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Signature in PEM format
|
34
|
+
def data
|
35
|
+
@p7.to_pem
|
36
|
+
end
|
37
|
+
alias :to_s :data
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Nanite
|
2
|
+
|
3
|
+
# Simple certificate store, serves a static set of certificates.
|
4
|
+
class StaticCertificateStore
|
5
|
+
|
6
|
+
# Initialize store:
|
7
|
+
#
|
8
|
+
# - Signer certificates are used when loading data to check the digital
|
9
|
+
# signature. The signature associated with the serialized data needs
|
10
|
+
# to match with one of the signer certificates for loading to succeed.
|
11
|
+
#
|
12
|
+
# - Recipient certificates are used when serializing data for encryption.
|
13
|
+
# Loading the data can only be done through serializers that have been
|
14
|
+
# initialized with a certificate that's in the recipient certificates if
|
15
|
+
# encryption is enabled.
|
16
|
+
#
|
17
|
+
def initialize(signer_certs, recipients_certs)
|
18
|
+
signer_certs = [ signer_certs ] unless signer_certs.respond_to?(:each)
|
19
|
+
@signer_certs = signer_certs
|
20
|
+
recipients_certs = [ recipients_certs ] unless recipients_certs.respond_to?(:each)
|
21
|
+
@recipients_certs = recipients_certs
|
22
|
+
end
|
23
|
+
|
24
|
+
# Retrieve signer certificate for given id
|
25
|
+
def get_signer(identity)
|
26
|
+
@signer_certs
|
27
|
+
end
|
28
|
+
|
29
|
+
# Recipient certificate(s) that will be able to decrypt the serialized data
|
30
|
+
def get_recipients(obj)
|
31
|
+
@recipients_certs
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Nanite
|
2
|
+
# This class is used to interface the nanite mapper with an external security
|
3
|
+
# module.
|
4
|
+
# There are two points of integration:
|
5
|
+
# 1. When an agent registers with a mapper
|
6
|
+
# 2. When an agent sends a request to another agent
|
7
|
+
#
|
8
|
+
# In both these cases the security module is called back and can deny the
|
9
|
+
# operation.
|
10
|
+
# Note: it's the responsability of the module to do any logging or
|
11
|
+
# notification that is required.
|
12
|
+
class SecurityProvider
|
13
|
+
|
14
|
+
# Register an external security module
|
15
|
+
# This module should expose the 'authorize_registration' and
|
16
|
+
# 'authorize_request' methods.
|
17
|
+
def self.register(mod)
|
18
|
+
@security_module = mod
|
19
|
+
end
|
20
|
+
|
21
|
+
# Used internally by nanite to retrieve the current security module
|
22
|
+
def self.get
|
23
|
+
@security_module || default_security_module
|
24
|
+
end
|
25
|
+
|
26
|
+
# Default security module, authorizes all operations
|
27
|
+
def self.default_security_module
|
28
|
+
@default_sec_mod ||= DefaultSecurityModule.new
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
# Default security module
|
34
|
+
class DefaultSecurityModule
|
35
|
+
|
36
|
+
# Authorize registration of agent (registration is an instance of Register)
|
37
|
+
def authorize_registration(registration)
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
# Authorize given inter-agent request (request is an instance of Request)
|
42
|
+
def authorize_request(request)
|
43
|
+
true
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Nanite
|
2
|
+
class Serializer
|
3
|
+
|
4
|
+
class SerializationError < StandardError
|
5
|
+
attr_accessor :action, :packet
|
6
|
+
def initialize(action, packet, serializers, msg = nil)
|
7
|
+
@action, @packet = action, packet
|
8
|
+
msg = ":\n#{msg}" if msg && !msg.empty?
|
9
|
+
super("Could not #{action} #{packet.inspect} using #{serializers.inspect}#{msg}")
|
10
|
+
end
|
11
|
+
end # SerializationError
|
12
|
+
|
13
|
+
# The secure serializer should not be part of the cascading
|
14
|
+
def initialize(preferred_format = :marshal)
|
15
|
+
preferred_format ||= :marshal
|
16
|
+
if preferred_format.to_s == 'secure'
|
17
|
+
@serializers = [ SecureSerializer ]
|
18
|
+
else
|
19
|
+
preferred_serializer = SERIALIZERS[preferred_format.to_sym]
|
20
|
+
@serializers = SERIALIZERS.values.clone
|
21
|
+
@serializers.unshift(@serializers.delete(preferred_serializer)) if preferred_serializer
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def dump(packet)
|
26
|
+
cascade_serializers(:dump, packet)
|
27
|
+
end
|
28
|
+
|
29
|
+
def load(packet)
|
30
|
+
cascade_serializers(:load, packet)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
SERIALIZERS = {:json => JSON, :marshal => Marshal, :yaml => YAML}.freeze
|
36
|
+
|
37
|
+
def cascade_serializers(action, packet)
|
38
|
+
errors = []
|
39
|
+
@serializers.map do |serializer|
|
40
|
+
begin
|
41
|
+
o = serializer.send(action, packet)
|
42
|
+
rescue Exception => e
|
43
|
+
o = nil
|
44
|
+
errors << "#{e.message}\n\t#{e.backtrace[0]}"
|
45
|
+
end
|
46
|
+
return o if o
|
47
|
+
end
|
48
|
+
raise SerializationError.new(action, packet, @serializers, errors.join("\n"))
|
49
|
+
end
|
50
|
+
|
51
|
+
end # Serializer
|
52
|
+
end # Nanite
|
data/lib/nanite/state.rb
ADDED
@@ -0,0 +1,164 @@
|
|
1
|
+
require 'redis'
|
2
|
+
|
3
|
+
module Nanite
|
4
|
+
class State
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
# this class encapsulates the state of a nanite system using redis as the
|
8
|
+
# data store. here is the schema, for each agent we store a number of items,
|
9
|
+
# for a nanite with the identity: nanite-foobar we store the following things:
|
10
|
+
#
|
11
|
+
# nanite-foobar: 0.72 # load average or 'status'
|
12
|
+
# s-nanite-foobar: { /foo/bar, /foo/nik } # a SET of the provided services
|
13
|
+
# tg-nanite-foobar: { foo-42, customer-12 } # a SET of the tags for this agent
|
14
|
+
# t-nanite-foobar: 123456789 # unix timestamp of the last state update
|
15
|
+
#
|
16
|
+
# also we do an inverted index for quick lookup of agents providing a certain
|
17
|
+
# service, so for each service the agent provides, we add the nanite to a SET
|
18
|
+
# of all the nanites that provide said service:
|
19
|
+
#
|
20
|
+
# foo/bar: { nanite-foobar, nanite-nickelbag, nanite-another } # redis SET
|
21
|
+
#
|
22
|
+
# we do that same thing for tags:
|
23
|
+
# some-tag: { nanite-foobar, nanite-nickelbag, nanite-another } # redis SET
|
24
|
+
#
|
25
|
+
# This way we can do a lookup of what nanites provide a set of services and tags based
|
26
|
+
# on redis SET intersection:
|
27
|
+
#
|
28
|
+
# nanites_for('/gems/list', 'some-tag')
|
29
|
+
# => returns a nested array of nanites and their state that provide the intersection
|
30
|
+
# of these two service tags
|
31
|
+
|
32
|
+
def initialize(redis)
|
33
|
+
Nanite::Log.info("initializing redis state: #{redis}")
|
34
|
+
host, port = redis.split(':')
|
35
|
+
host ||= '127.0.0.1'
|
36
|
+
port ||= '6379'
|
37
|
+
@redis = Redis.new :host => host, :port => port
|
38
|
+
end
|
39
|
+
|
40
|
+
def log_redis_error(meth,&blk)
|
41
|
+
blk.call
|
42
|
+
rescue RedisError => e
|
43
|
+
Nanite::Log.info("redis error in method: #{meth}")
|
44
|
+
raise e
|
45
|
+
end
|
46
|
+
|
47
|
+
def [](nanite)
|
48
|
+
log_redis_error("[]") do
|
49
|
+
status = @redis[nanite]
|
50
|
+
timestamp = @redis["t-#{nanite}"]
|
51
|
+
services = @redis.set_members("s-#{nanite}")
|
52
|
+
tags = @redis.set_members("tg-#{nanite}")
|
53
|
+
return nil unless status && timestamp && services
|
54
|
+
{:services => services, :status => status, :timestamp => timestamp.to_i, :tags => tags}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def []=(nanite, hsh)
|
59
|
+
log_redis_error("[]=") do
|
60
|
+
update_state(nanite, hsh[:status], hsh[:services], hsh[:tags])
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def delete(nanite)
|
65
|
+
log_redis_error("delete") do
|
66
|
+
(@redis.set_members("s-#{nanite}")||[]).each do |srv|
|
67
|
+
@redis.set_delete(srv, nanite)
|
68
|
+
if @redis.set_count(srv) == 0
|
69
|
+
@redis.delete(srv)
|
70
|
+
@redis.set_delete("naniteservices", srv)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
(@redis.set_members("tg-#{nanite}")||[]).each do |tag|
|
74
|
+
@redis.set_delete(tag, nanite)
|
75
|
+
if @redis.set_count(tag) == 0
|
76
|
+
@redis.delete(tag)
|
77
|
+
@redis.set_delete("nanitetags", tag)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
@redis.delete nanite
|
81
|
+
@redis.delete "s-#{nanite}"
|
82
|
+
@redis.delete "t-#{nanite}"
|
83
|
+
@redis.delete "tg-#{nanite}"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def all_services
|
88
|
+
log_redis_error("all_services") do
|
89
|
+
@redis.set_members("naniteservices")
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def all_tags
|
94
|
+
log_redis_error("all_tags") do
|
95
|
+
@redis.set_members("nanitetags")
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def update_state(name, status, services, tags)
|
100
|
+
old_services = @redis.set_members("s-#{name}")
|
101
|
+
if old_services
|
102
|
+
(old_services - services).each do |s|
|
103
|
+
@redis.set_delete(s, name)
|
104
|
+
@redis.set_delete("naniteservices", s)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
old_tags = @redis.set_members("tg-#{name}")
|
108
|
+
if old_tags
|
109
|
+
(old_tags - tags).each do |t|
|
110
|
+
@redis.set_delete(t, name)
|
111
|
+
@redis.set_delete("nanitetags", t)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
@redis.delete("s-#{name}")
|
115
|
+
services.each do |srv|
|
116
|
+
@redis.set_add(srv, name)
|
117
|
+
@redis.set_add("s-#{name}", srv)
|
118
|
+
@redis.set_add("naniteservices", srv)
|
119
|
+
end
|
120
|
+
@redis.delete("tg-#{name}")
|
121
|
+
tags.each do |tag|
|
122
|
+
next if tag.nil?
|
123
|
+
@redis.set_add(tag, name)
|
124
|
+
@redis.set_add("tg-#{name}", tag)
|
125
|
+
@redis.set_add("nanitetags", tag)
|
126
|
+
end
|
127
|
+
@redis[name] = status
|
128
|
+
@redis["t-#{name}"] = Time.now.to_i
|
129
|
+
end
|
130
|
+
|
131
|
+
def list_nanites
|
132
|
+
log_redis_error("list_nanites") do
|
133
|
+
@redis.keys("nanite-*")
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def size
|
138
|
+
list_nanites.size
|
139
|
+
end
|
140
|
+
|
141
|
+
def clear_state
|
142
|
+
log_redis_error("clear_state") do
|
143
|
+
@redis.keys("*").each {|k| @redis.delete k}
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def each
|
148
|
+
list_nanites.each do |nan|
|
149
|
+
yield nan, self[nan]
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def nanites_for(service, *tags)
|
154
|
+
keys = tags.dup << service
|
155
|
+
log_redis_error("nanites_for") do
|
156
|
+
res = []
|
157
|
+
(@redis.set_intersect(keys)||[]).each do |nan|
|
158
|
+
res << [nan, self[nan]]
|
159
|
+
end
|
160
|
+
res
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
module Nanite
|
2
|
+
# Nanite actors can transfer files to each other.
|
3
|
+
#
|
4
|
+
# ==== Options
|
5
|
+
#
|
6
|
+
# filename : you guessed it, name of the file!
|
7
|
+
# domain : part of the routing key used to locate receiver(s)
|
8
|
+
# destination : is a name of the file as it gonna be stored at the destination
|
9
|
+
# meta :
|
10
|
+
#
|
11
|
+
# File streaming is done in chunks. When file streaming starts,
|
12
|
+
# Nanite::FileStart packet is sent, followed by one or more (usually more ;))
|
13
|
+
# Nanite::FileChunk packets each 16384 (16K) in size. Once file streaming is done,
|
14
|
+
# Nanite::FileEnd packet is sent.
|
15
|
+
#
|
16
|
+
# 16K is a packet size because on certain UNIX-like operating systems, you cannot read/write
|
17
|
+
# more than that in one operation via socket.
|
18
|
+
#
|
19
|
+
# ==== Domains
|
20
|
+
#
|
21
|
+
# Streaming happens using a topic exchange called 'file broadcast', with keys
|
22
|
+
# formatted as "nanite.filepeer.DOMAIN". Domain variable in the key lets senders and
|
23
|
+
# receivers find each other in the cluster. Default domain is 'global'.
|
24
|
+
#
|
25
|
+
# Domains also serve as a way to register a callback Nanite agent executes once file
|
26
|
+
# streaming is completed. If a callback with name of domain is registered, it is called.
|
27
|
+
#
|
28
|
+
# Callbacks are registered by passing a block to subscribe_to_files method.
|
29
|
+
module FileStreaming
|
30
|
+
def broadcast_file(filename, options = {})
|
31
|
+
if File.exist?(filename)
|
32
|
+
File.open(filename, 'rb') do |file|
|
33
|
+
broadcast_data(filename, file, options)
|
34
|
+
end
|
35
|
+
else
|
36
|
+
return "file not found"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def broadcast_data(filename, io, options = {})
|
41
|
+
domain = options[:domain] || 'global'
|
42
|
+
filename = File.basename(filename)
|
43
|
+
dest = options[:destination] || filename
|
44
|
+
sent = 0
|
45
|
+
|
46
|
+
begin
|
47
|
+
file_push = Nanite::FileStart.new(filename, dest, Identity.generate)
|
48
|
+
amq.topic('file broadcast').publish(serializer.dump(file_push), :key => "nanite.filepeer.#{domain}")
|
49
|
+
res = Nanite::FileChunk.new(file_push.token)
|
50
|
+
while chunk = io.read(16384)
|
51
|
+
res.chunk = chunk
|
52
|
+
amq.topic('file broadcast').publish(serializer.dump(res), :key => "nanite.filepeer.#{domain}")
|
53
|
+
sent += chunk.length
|
54
|
+
end
|
55
|
+
fend = Nanite::FileEnd.new(file_push.token, options[:meta])
|
56
|
+
amq.topic('file broadcast').publish(serializer.dump(fend), :key => "nanite.filepeer.#{domain}")
|
57
|
+
""
|
58
|
+
ensure
|
59
|
+
io.close
|
60
|
+
end
|
61
|
+
|
62
|
+
sent
|
63
|
+
end
|
64
|
+
|
65
|
+
# FileState represents a file download in progress.
|
66
|
+
# It incapsulates the following information:
|
67
|
+
#
|
68
|
+
# * unique operation token
|
69
|
+
# * domain (namespace for file streaming operations)
|
70
|
+
# * file IO chunks are written to on receiver's side
|
71
|
+
class FileState
|
72
|
+
|
73
|
+
def initialize(token, dest, domain, write, blk)
|
74
|
+
@token = token
|
75
|
+
@cb = blk
|
76
|
+
@domain = domain
|
77
|
+
@write = write
|
78
|
+
|
79
|
+
if write
|
80
|
+
@filename = File.join(Nanite.agent.file_root, dest)
|
81
|
+
@dest = File.open(@filename, 'wb')
|
82
|
+
else
|
83
|
+
@dest = dest
|
84
|
+
end
|
85
|
+
|
86
|
+
@data = ""
|
87
|
+
end
|
88
|
+
|
89
|
+
def handle_packet(packet)
|
90
|
+
case packet
|
91
|
+
when Nanite::FileChunk
|
92
|
+
Nanite::Log.debug "written chunk to #{@dest.inspect}"
|
93
|
+
@data << packet.chunk
|
94
|
+
|
95
|
+
if @write
|
96
|
+
@dest.write(packet.chunk)
|
97
|
+
end
|
98
|
+
when Nanite::FileEnd
|
99
|
+
Nanite::Log.debug "#{@dest.inspect} receiving is completed"
|
100
|
+
if @write
|
101
|
+
@dest.close
|
102
|
+
end
|
103
|
+
|
104
|
+
@cb.call(@data, @dest, packet.meta)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
|
110
|
+
def subscribe_to_files(domain='global', write=false, &blk)
|
111
|
+
Nanite::Log.info "subscribing to file broadcasts for #{domain}"
|
112
|
+
@files ||= {}
|
113
|
+
amq.queue("files#{domain}").bind(amq.topic('file broadcast'), :key => "nanite.filepeer.#{domain}").subscribe do |packet|
|
114
|
+
case msg = serializer.load(packet)
|
115
|
+
when FileStart
|
116
|
+
@files[msg.token] = FileState.new(msg.token, msg.dest, domain, write, blk)
|
117
|
+
when FileChunk, FileEnd
|
118
|
+
if file = @files[msg.token]
|
119
|
+
file.handle_packet(msg)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|