soar-registry-staff 0.0.3 → 0.0.4
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.
- checksums.yaml +4 -4
- data/README.md +30 -37
- data/lib/soar/registry/staff/directory/dynamo_db.rb +151 -0
- data/lib/soar/registry/staff/directory/stub.rb +107 -0
- data/lib/soar/registry/staff/identity/base.rb +84 -0
- data/lib/soar/registry/staff/identity/email.rb +22 -0
- data/lib/soar/registry/staff/identity/id.rb +22 -0
- data/lib/soar/registry/staff/test/directory/orchestrator.rb +81 -0
- data/lib/soar/registry/staff/test/directory/provider/base.rb +50 -0
- data/lib/soar/registry/staff/test/directory/provider/dynamo_db.rb +84 -0
- data/lib/soar/registry/staff/test/directory/provider/stub.rb +68 -0
- data/lib/soar/registry/staff/test/fixtures/identity_data.json +60 -0
- data/lib/soar/registry/staff/test/fixtures/identity_table.json +63 -0
- metadata +40 -7
- data/lib/soar/registry/staff/directory/dynamo_db/base.rb +0 -90
- data/lib/soar/registry/staff/directory/dynamo_db/identity.rb +0 -83
- data/lib/soar/registry/staff/identifier/factory.rb +0 -40
- data/lib/soar/registry/staff/identifier/hash.rb +0 -24
- data/lib/soar/registry/staff/identifier/id.rb +0 -18
- data/lib/soar/registry/staff/identity.rb +0 -95
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7f6b422c48fd608fa4d16edd4393b0a8113eed41
|
4
|
+
data.tar.gz: 9b63627ed150163f5862a36308068766d3e8c992
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3cb61fbda6f6f42a5b3a3341ff6b9bd639cd7e4e37dc6cc1de1e8a3b997ecae65ed122b60dcf359caa6b1e0767469e25ace0cec7925cf45d871e87527c656d2a
|
7
|
+
data.tar.gz: 6aa66da6cd94c508c16552f36bab9c179e69ef32ba5bf74479d66f6ee69ab0302d52763f8d46873a560d4aa448e928d5a65924ea4c16b34c5227b9ed9f6b1b8e
|
data/README.md
CHANGED
@@ -1,7 +1,5 @@
|
|
1
1
|
# Registry of staff identities
|
2
|
-
Allows you to
|
3
|
-
|
4
|
-
*Please note:* Querying staff entities requires a different idr just for entities.
|
2
|
+
Allows you to find staff identities by id using *Soar::Registry::Staff::Identity::Id* or email *Soar::Registry::Staff::Identity::Email*
|
5
3
|
|
6
4
|
## Quickstart
|
7
5
|
|
@@ -21,26 +19,31 @@ provider:
|
|
21
19
|
password: 'secret'
|
22
20
|
```
|
23
21
|
|
24
|
-
### Create an instance of the staff identity registry
|
22
|
+
### Create an instance of the chosen staff identity registry
|
23
|
+
|
24
|
+
#### Find by id
|
25
|
+
```ruby
|
26
|
+
require 'soar/registry/staff/identity/id'
|
27
|
+
@id_idr = Soar::Registry::Staff::Identity::Id.new(configuration)
|
28
|
+
```
|
29
|
+
#### Find by email
|
25
30
|
```ruby
|
26
|
-
require 'soar/registry/staff/identity'
|
27
|
-
@
|
31
|
+
require 'soar/registry/staff/identity/email'
|
32
|
+
@email_idr = Soar::Registry::Staff::Identity::Email.new(configuration)
|
28
33
|
```
|
29
34
|
|
30
35
|
### Getting a list of identifiers
|
31
36
|
```ruby
|
32
|
-
> identifiers = @
|
33
|
-
> identifiers = @
|
34
|
-
> identifiers = @idr.get_identifiers({"identity_id" => "identity-820d5660-2204-4f7d-8c04-746313439b81"}.to_json)
|
37
|
+
> identifiers = @id_idr.get_identifiers("identity-820d5660-2204-4f7d-8c04-746313439b81")
|
38
|
+
> identifiers = @email_idr.get_identifiers("admin@hetzner.co.za")
|
35
39
|
> puts identifiers.inspect
|
36
40
|
["admin@hetzner.co.za", "identity-820d5660-2204-4f7d-8c04-746313439b81"]
|
37
41
|
```
|
38
42
|
|
39
43
|
### Getting a list of roles
|
40
44
|
```ruby
|
41
|
-
> roles = @
|
42
|
-
> roles = @
|
43
|
-
> roles = @idr.get_roles({"identity_id" => "identity-820d5660-2204-4f7d-8c04-746313439b81"}.to_json)
|
45
|
+
> roles = @id_idr.get_roles("identity-820d5660-2204-4f7d-8c04-746313439b81")
|
46
|
+
> roles = @email_idr.get_roles("admin@hetzner.co.za")
|
44
47
|
> puts roles.inspect
|
45
48
|
["staff", "configuration_publisher", "configuration_consumer"]
|
46
49
|
```
|
@@ -48,9 +51,8 @@ require 'soar/registry/staff/identity'
|
|
48
51
|
### Getting a hash of attributes for a role
|
49
52
|
```ruby
|
50
53
|
> role = 'staff'
|
51
|
-
> attributes = @
|
52
|
-
> attributes = @
|
53
|
-
> attributes = @idr.get_attributes({"identity_id" => "identity-820d5660-2204-4f7d-8c04-746313439b81"}.to_json, role)
|
54
|
+
> attributes = @id_idr.get_attributes("identity-820d5660-2204-4f7d-8c04-746313439b81", role)
|
55
|
+
> attributes = @email_idr.get_attributes("admin@hetzner.co.za", role)
|
54
56
|
> puts attributes.inspect
|
55
57
|
{
|
56
58
|
"staff": {
|
@@ -62,9 +64,8 @@ require 'soar/registry/staff/identity'
|
|
62
64
|
|
63
65
|
### Getting a hash of all attributes
|
64
66
|
```ruby
|
65
|
-
> attributes = @
|
66
|
-
> attributes = @
|
67
|
-
> attributes = @idr.get_attributes({"identity_id" => "identity-820d5660-2204-4f7d-8c04-746313439b81"}.to_json)
|
67
|
+
> attributes = @id_idr.get_attributes("identity-820d5660-2204-4f7d-8c04-746313439b81")
|
68
|
+
> attributes = @email_idr.get_attributes("admin@hetzner.co.za")
|
68
69
|
> puts attributes.inspect
|
69
70
|
{
|
70
71
|
"identity_id" => "identity-820d5660-2204-4f7d-8c04-746313439b81",
|
@@ -89,38 +90,30 @@ require 'soar/registry/staff/identity'
|
|
89
90
|
}
|
90
91
|
```
|
91
92
|
|
92
|
-
## Tests
|
93
93
|
|
94
|
-
|
94
|
+
## Quicktest
|
95
|
+
|
96
|
+
### Local Stub
|
97
|
+
```bash
|
98
|
+
TEST_ORCHESTRATION_PROVIDER=Stub rspec
|
99
|
+
```
|
100
|
+
|
101
|
+
### DynamoDb
|
95
102
|
```
|
96
|
-
$ docker-compose --file docker-compose.test.yml up --abort-on-container-exit --remove-orphans
|
103
|
+
$ TEST_ORCHESTRATION_PROVIDER=DynamoDb docker-compose --file docker-compose.test.yml up --abort-on-container-exit --remove-orphans
|
97
104
|
```
|
98
105
|
|
99
106
|
### CI
|
100
107
|
*NB:* container_name in docker-compose.ci.yml corresponds with name parameter of docker ps command in below bash script.
|
101
108
|
```bash
|
102
109
|
#!/bin/bash
|
103
|
-
docker-compose --file docker-compose.test.yml up --build --abort-on-container-exit --remove-orphans --force-recreate;
|
110
|
+
TEST_ORCHESTRATION_PROVIDER=DynamoDb docker-compose --file docker-compose.test.yml up --build --abort-on-container-exit --remove-orphans --force-recreate;
|
104
111
|
EXIT_CODE=$(docker ps -a -f "name=soar-registry-staff" -q | xargs docker inspect -f "{{ .State.ExitCode }}");
|
105
112
|
exit $EXIT_CODE;
|
106
113
|
```
|
107
114
|
|
108
|
-
### Unit tests
|
109
|
-
|
110
|
-
#### Run dynamodb
|
111
|
-
```
|
112
|
-
$ docker-compose up
|
113
|
-
```
|
114
|
-
|
115
|
-
#### Open another terminal and run:
|
116
|
-
```
|
117
|
-
$ sudo -H pip install awscli
|
118
|
-
$ aws configure
|
119
|
-
$ bundle
|
120
|
-
$ rspec
|
121
|
-
```
|
122
115
|
|
123
|
-
|
116
|
+
## Useful commands:
|
124
117
|
```
|
125
118
|
$ aws dynamodb list-tables --endpoint-url http://localhost:8000
|
126
119
|
$ aws dynamodb delete-table --table-name identities --endpoint-url http://localhost:8000
|
@@ -0,0 +1,151 @@
|
|
1
|
+
require 'soar_idm/directory_provider'
|
2
|
+
require 'aws-sdk'
|
3
|
+
require 'hashie'
|
4
|
+
|
5
|
+
module Soar
|
6
|
+
module Registry
|
7
|
+
module Staff
|
8
|
+
module Directory
|
9
|
+
class DynamoDb < SoarIdm::DirectoryProvider
|
10
|
+
|
11
|
+
LIMIT = 10
|
12
|
+
INDEXED_ATTRIBUTES = ['email', 'entity_id']
|
13
|
+
|
14
|
+
attr_reader :configuration, :credentials, :client
|
15
|
+
|
16
|
+
##
|
17
|
+
# @param [Hash] configuration for the directory provider
|
18
|
+
# @return [Nil]
|
19
|
+
# @raise [Soar::Registry::Staff::Directory::Error::BootstrapError] if required configuration is missing
|
20
|
+
##
|
21
|
+
def bootstrap(configuration)
|
22
|
+
@configuration = Hashie.stringify_keys(configuration)
|
23
|
+
raise Soar::Registry::Staff::Directory::Error::BootstrapError, 'Missing region' if not @configuration.has_key?('region')
|
24
|
+
raise Soar::Registry::Staff::Directory::Error::BootstrapError, 'Missing endpoint' if not @configuration.has_key?('endpoint')
|
25
|
+
@table_name = @configuration.delete('table')
|
26
|
+
raise Soar::Registry::Staff::Directory::Error::BootstrapError, 'Missing table name' if not @table_name
|
27
|
+
end
|
28
|
+
|
29
|
+
##
|
30
|
+
# @return [Bool] whether directory provider received minimum required configuration
|
31
|
+
##
|
32
|
+
def bootstrapped?
|
33
|
+
@configuration.has_key?('region') and @configuration.has_key?('endpoint')
|
34
|
+
end
|
35
|
+
|
36
|
+
def uri
|
37
|
+
@configuration['endpoint']
|
38
|
+
end
|
39
|
+
|
40
|
+
def authenticate(credentials)
|
41
|
+
raise Soar::Registry::Staff::Directory::Error::AuthenticationError, 'missing username' if not credentials.key?('username')
|
42
|
+
raise Soar::Registry::Staff::Directory::Error::AuthenticationError, 'missing password' if not credentials.key?('password')
|
43
|
+
@credentials = {
|
44
|
+
"access_key_id" => credentials['username'],
|
45
|
+
"secret_access_key" => credentials['password']
|
46
|
+
}
|
47
|
+
end
|
48
|
+
|
49
|
+
def connect
|
50
|
+
begin
|
51
|
+
options = @configuration
|
52
|
+
options['credentials'] = Aws::Credentials.new(@credentials['access_key_id'], @credentials['secret_access_key'])
|
53
|
+
@client = Aws::DynamoDB::Client.new(Hashie.symbolize_keys(options))
|
54
|
+
true
|
55
|
+
rescue ArgumentError => e
|
56
|
+
raise Soar::Registry::Staff::Directory::Error::ConnectionError, e.message
|
57
|
+
rescue
|
58
|
+
raise Soar::Registry::Staff::Directory::Error::ConnectionError, 'error creating client'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def connected?
|
63
|
+
return @client.class.name == 'Aws::DynamoDB::Client'
|
64
|
+
end
|
65
|
+
|
66
|
+
def ready?
|
67
|
+
begin
|
68
|
+
#return @client.list_tables.table_names.class.name == 'Array'
|
69
|
+
resp = @client.describe_table({
|
70
|
+
table_name: @table_name
|
71
|
+
})
|
72
|
+
return resp.table.table_name == @table_name
|
73
|
+
rescue Aws::DynamoDB::Errors::ResourceNotFoundException, Seahorse::Client::NetworkingError
|
74
|
+
return false
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def put_identity(entity)
|
79
|
+
@client.put_item({
|
80
|
+
table_name: @table_name,
|
81
|
+
item: Hashie.symbolize_keys(entity)
|
82
|
+
})
|
83
|
+
end
|
84
|
+
|
85
|
+
##
|
86
|
+
# @param [String] identity_id primary key of the identity
|
87
|
+
# @return [Hash] the identity
|
88
|
+
# @raise [Soar::Registry::Staff::Directory::DynamoDb::Error::UniqueIdentifierNotFoundError] if primary key not found
|
89
|
+
##
|
90
|
+
def fetch_identity(identity_id)
|
91
|
+
options = {
|
92
|
+
table_name: @table_name,
|
93
|
+
key: {
|
94
|
+
identity_id: identity_id
|
95
|
+
}
|
96
|
+
}
|
97
|
+
identity = @client.get_item(options)
|
98
|
+
raise Soar::Registry::Staff::Directory::Error::UniqueIdentifierNotFoundError, 'Unable to find identity' if identity.item.nil?
|
99
|
+
identity.item
|
100
|
+
end
|
101
|
+
|
102
|
+
##
|
103
|
+
# @param [String] identifier_attribute
|
104
|
+
# @param [String] identifier_value
|
105
|
+
# @return [Array] list of identities
|
106
|
+
# @raise [ArgumentError] if query or index is not specified
|
107
|
+
##
|
108
|
+
def search_identities(identifier_attribute, identifier_value)
|
109
|
+
raise ArgumentError, "Attribute is required" if identifier_attribute.nil?
|
110
|
+
raise ArgumentError, 'Value is required' if identifier_value.nil?
|
111
|
+
options = {
|
112
|
+
table_name: @table_name,
|
113
|
+
select: 'ALL_ATTRIBUTES',
|
114
|
+
limit: LIMIT,
|
115
|
+
key_condition_expression: "#{identifier_attribute} = :value",
|
116
|
+
expression_attribute_values: {
|
117
|
+
":value": identifier_value
|
118
|
+
}
|
119
|
+
}
|
120
|
+
|
121
|
+
options.merge!({index_name: "#{identifier_attribute}_index"}) if INDEXED_ATTRIBUTES.include?(identifier_attribute)
|
122
|
+
identity = @client.query(options)
|
123
|
+
identity.items.map { |item|
|
124
|
+
Hashie.stringify_keys(item)
|
125
|
+
}
|
126
|
+
end
|
127
|
+
|
128
|
+
##
|
129
|
+
# @return [Array] a list of primary keys and global secondary indexes
|
130
|
+
##
|
131
|
+
def indexed_attributes
|
132
|
+
resp = @client.describe_table({
|
133
|
+
table_name: @table_name
|
134
|
+
})
|
135
|
+
indexed_attributes = []
|
136
|
+
resp['table']['key_schema'].each { |key_schema|
|
137
|
+
indexed_attributes << key_schema['attribute_name']
|
138
|
+
}
|
139
|
+
resp['table']['global_secondary_indexes'].each { |index|
|
140
|
+
index['key_schema'].each { |key_schema|
|
141
|
+
indexed_attributes << key_schema['attribute_name']
|
142
|
+
}
|
143
|
+
}
|
144
|
+
indexed_attributes
|
145
|
+
end
|
146
|
+
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require 'soar_idm/directory_provider'
|
2
|
+
require 'soar/registry/staff/directory/error'
|
3
|
+
require 'aws-sdk'
|
4
|
+
require 'hashie'
|
5
|
+
require 'mince'
|
6
|
+
require 'hashy_db'
|
7
|
+
|
8
|
+
module Soar
|
9
|
+
module Registry
|
10
|
+
module Staff
|
11
|
+
module Directory
|
12
|
+
class Stub < SoarIdm::DirectoryProvider
|
13
|
+
|
14
|
+
attr_accessor :interface
|
15
|
+
attr_writer :network
|
16
|
+
attr_writer :connection
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@interface = Mince::HashyDb::Interface
|
20
|
+
@network = true
|
21
|
+
@connection = true
|
22
|
+
end
|
23
|
+
|
24
|
+
##
|
25
|
+
# @param [Hash] configuration for the directory provider
|
26
|
+
# @return [Nil]
|
27
|
+
# @raise [Soar::Registry::Staff::Directory::Error::BootstrapError] if required configuration is missing
|
28
|
+
##
|
29
|
+
def bootstrap(configuration)
|
30
|
+
@configuration = configuration
|
31
|
+
raise Soar::Registry::Staff::Directory::Error::BootstrapError if configuration.empty?
|
32
|
+
end
|
33
|
+
|
34
|
+
##
|
35
|
+
# @return [Bool] whether directory provider received minimum required configuration
|
36
|
+
##
|
37
|
+
def bootstrapped?
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
def uri
|
42
|
+
@configuration['endpoint']
|
43
|
+
end
|
44
|
+
|
45
|
+
def authenticate(credentials)
|
46
|
+
raise Soar::Registry::Staff::Directory::Error::AuthenticationError, 'missing username' if not credentials.key?('username')
|
47
|
+
raise Soar::Registry::Staff::Directory::Error::AuthenticationError, 'missing password' if not credentials.key?('password')
|
48
|
+
end
|
49
|
+
|
50
|
+
def connect
|
51
|
+
raise Soar::Registry::Staff::Directory::Error::ConnectionError if not @connection
|
52
|
+
true
|
53
|
+
end
|
54
|
+
|
55
|
+
def connected?
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
def ready?
|
60
|
+
@network
|
61
|
+
end
|
62
|
+
|
63
|
+
def put_identity(entity)
|
64
|
+
@interface.add('identities', entity)
|
65
|
+
end
|
66
|
+
|
67
|
+
##
|
68
|
+
# @param [String] identity_id primary key of the identity
|
69
|
+
# @return [Hash] the identity
|
70
|
+
# @raise [Soar::Registry::Staff::Directory::DynamoDb::Error::UniqueIdentifierNotFoundError] if primary key not found
|
71
|
+
##
|
72
|
+
def fetch_identity(identity_id)
|
73
|
+
identity = @interface.get_for_key_with_value('identities', "identity_id", identity_id)
|
74
|
+
raise Soar::Registry::Staff::Directory::Error::UniqueIdentifierNotFoundError if identity.nil?
|
75
|
+
identity
|
76
|
+
end
|
77
|
+
|
78
|
+
##
|
79
|
+
# @param [String] identifier_attribute
|
80
|
+
# @param [String] identifier_value
|
81
|
+
# @return [Array] list of identities
|
82
|
+
# @raise [ArgumentError] if query or index is not specified
|
83
|
+
##
|
84
|
+
def search_identities(identifier_attribute, identifier_value)
|
85
|
+
identities = []
|
86
|
+
@interface.find_all('identities').each { |identity|
|
87
|
+
if identity[identifier_attribute] == identifier_value
|
88
|
+
identities << identity
|
89
|
+
else
|
90
|
+
next
|
91
|
+
end
|
92
|
+
}
|
93
|
+
identities
|
94
|
+
end
|
95
|
+
|
96
|
+
##
|
97
|
+
# @return [Array] a list of primary keys and global secondary indexes
|
98
|
+
##
|
99
|
+
def indexed_attributes
|
100
|
+
["identity_id", "email", "entity_id"]
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'soar_idm/soar_idm'
|
2
|
+
require 'soar/registry/staff/translator/dynamo_db'
|
3
|
+
require 'soar/registry/staff/directory/dynamo_db'
|
4
|
+
|
5
|
+
module Soar
|
6
|
+
module Registry
|
7
|
+
module Staff
|
8
|
+
module Identity
|
9
|
+
class Base < SoarIdm::IdmApi
|
10
|
+
|
11
|
+
attr_reader :directory
|
12
|
+
attr_reader :translator
|
13
|
+
attr_reader :client
|
14
|
+
|
15
|
+
PRIMARY_KEY = 'identity_id'
|
16
|
+
|
17
|
+
##
|
18
|
+
# @param [Hash] configuration
|
19
|
+
# for example see config/config.yml
|
20
|
+
##
|
21
|
+
def initialize(configuration)
|
22
|
+
@translator = Object.const_get(configuration['rule_set']['adaptor']).new
|
23
|
+
@directory = Object::const_get(configuration['provider']['adaptor']).new()
|
24
|
+
@directory.bootstrap(configuration['provider']['config'])
|
25
|
+
@directory.authenticate(configuration['provider']['credentials'])
|
26
|
+
@client = @directory.connect
|
27
|
+
raise Soar::Registry::Staff::Directory::Error::BootstrapError if not @directory.bootstrapped?
|
28
|
+
raise Soar::Registry::Staff::Directory::Error::ConnectionError if not @directory.connected?
|
29
|
+
raise Soar::Registry::Staff::Directory::Error::NotReadyError if not @directory.ready?
|
30
|
+
end
|
31
|
+
|
32
|
+
##
|
33
|
+
# @param [Hash] identity
|
34
|
+
# @return [Array] list of roles
|
35
|
+
def calculate_roles(identity)
|
36
|
+
entry = @directory.fetch_identity(identity[PRIMARY_KEY])
|
37
|
+
return nil if not entry
|
38
|
+
identity = @translator.get_identity(entry)
|
39
|
+
roles = []
|
40
|
+
identity['roles'].each do |role, attributes|
|
41
|
+
roles << role
|
42
|
+
end
|
43
|
+
roles
|
44
|
+
end
|
45
|
+
|
46
|
+
##
|
47
|
+
# @param [Hash] identity
|
48
|
+
# @return [Array] list of identifiers
|
49
|
+
##
|
50
|
+
def calculate_identifiers(identity)
|
51
|
+
indexes = @directory.indexed_attributes
|
52
|
+
entry = @directory.fetch_identity(identity[PRIMARY_KEY])
|
53
|
+
identity = @translator.get_identity(entry)
|
54
|
+
identifiers = []
|
55
|
+
indexes.each { |index|
|
56
|
+
identifiers << identity[index]
|
57
|
+
}
|
58
|
+
identifiers
|
59
|
+
end
|
60
|
+
|
61
|
+
##
|
62
|
+
# @param [Hash] identity
|
63
|
+
# @param [String] role
|
64
|
+
# @return [Hash] A hash of attributes
|
65
|
+
def calculate_attributes(identity, role)
|
66
|
+
entry = @directory.fetch_identity(identity[PRIMARY_KEY])
|
67
|
+
return nil if not entry
|
68
|
+
identity = @translator.get_identity(entry)
|
69
|
+
{ role => identity['roles'][role] }
|
70
|
+
end
|
71
|
+
|
72
|
+
##
|
73
|
+
# @param [Hash] identity
|
74
|
+
# @return [Hash] Hash of attributes keyed by role
|
75
|
+
def calculate_all_attributes(identity)
|
76
|
+
entry = @directory.fetch_identity(identity[PRIMARY_KEY])
|
77
|
+
@translator.get_identity(entry)
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|