clandestined 1.0.0a → 1.0.0b
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/CHANGES.md +16 -0
- data/LICENSE +21 -0
- data/README.md +15 -47
- data/clandestined.gemspec +2 -1
- data/lib/clandestined/cluster.rb +78 -87
- data/lib/clandestined/rendezvous_hash.rb +24 -24
- data/test/test_cluster.rb +5 -14
- data/test/test_rendezvous_hash.rb +5 -14
- metadata +6 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 03d6d29e1c5fe16b7457ae7ae9d851a55ad2c03e
|
4
|
+
data.tar.gz: 9ce134bb758d8f00442ac45a1b592ccbe143f04c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ccdcbbbd4d4d9524cbb61a21efa5d3389da3ad6b7b366c1d1d6273a7da265686a600ece76eb1a8d68e6955761fcb3ec82323747a8ee9c6c2ef76d2fb4d4e09f3
|
7
|
+
data.tar.gz: 04e1699336e8e5b08e0d75207d80308955e86986dafd79c1cd2d8b7ae3ec1b94de470e26cc6e88aea31c9bdf06606df2a06769737faa00ee6cb70383d8cc400d
|
data/CHANGES.md
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
|
2
|
+
v1.0.0b (2014-07-07)
|
3
|
+
====================
|
4
|
+
|
5
|
+
- `Cluster.remove_zone` now raises `ArgumentError` on attempt to remove a
|
6
|
+
non-existent zone.
|
7
|
+
- `Cluster.remove_node` and `RendezvousHash.remove_node` now raise
|
8
|
+
`ArgumentError` on attempt to remove a non-existent node.
|
9
|
+
- Support for custom hash functions retracted for 1.0.0 milestone.
|
10
|
+
- `murmur_seed` keyword argument renamed to `seed` for `Cluster` and
|
11
|
+
`RendezvousHash` `initialize` methods.
|
12
|
+
|
13
|
+
v1.0.0a (2014-07-06)
|
14
|
+
====================
|
15
|
+
|
16
|
+
- Initial Release.
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2014 Ernest W. Durbin III
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
CHANGED
@@ -36,7 +36,7 @@ Currently targetting for support:
|
|
36
36
|
'9' => Hash['name' => 'node9.example.com', 'zone' => 'us-east-1c'],
|
37
37
|
]
|
38
38
|
>>
|
39
|
-
>> cluster = Cluster.new(nodes)
|
39
|
+
>> cluster = Clandestined::Cluster.new(nodes)
|
40
40
|
>> cluster.find_nodes('mykey')
|
41
41
|
=> ["4", "8"]
|
42
42
|
```
|
@@ -64,8 +64,8 @@ set to 1
|
|
64
64
|
'9' => Hash['name' => 'node9.example.com'],
|
65
65
|
]
|
66
66
|
>>
|
67
|
-
>> cluster = Cluster.new(nodes, 1)
|
68
|
-
>> rendezvous = RendezvousHash.new(nodes.keys)
|
67
|
+
>> cluster = Clandestined::Cluster.new(nodes, 1)
|
68
|
+
>> rendezvous = Clandestined::RendezvousHash.new(nodes.keys)
|
69
69
|
>>
|
70
70
|
>> cluster.find_nodes('mykey')
|
71
71
|
=> ["4"]
|
@@ -77,47 +77,19 @@ set to 1
|
|
77
77
|
|
78
78
|
### murmur3 seeding
|
79
79
|
|
80
|
-
if you plan to use keys based on untrusted input (
|
81
|
-
|
82
|
-
|
83
|
-
|
80
|
+
if you plan to use keys based on untrusted input (supported, but go ahead),
|
81
|
+
it would be best to use a custom seed for hashing. although this technique is
|
82
|
+
by no means a way to fully mitigate a DoS attack using crafted keys, it may
|
83
|
+
help you sleep better at night.
|
84
84
|
|
85
|
-
|
86
|
-
>> require 'clandestined/cluster'
|
87
|
-
>> require 'clandestined/rendezvous_hash'
|
88
|
-
>>
|
89
|
-
>> nodes = Hash[
|
90
|
-
'1' => Hash['name' => 'node1.example.com'],
|
91
|
-
'2' => Hash['name' => 'node2.example.com'],
|
92
|
-
'3' => Hash['name' => 'node3.example.com'],
|
93
|
-
'4' => Hash['name' => 'node4.example.com'],
|
94
|
-
'5' => Hash['name' => 'node5.example.com'],
|
95
|
-
'6' => Hash['name' => 'node6.example.com'],
|
96
|
-
'7' => Hash['name' => 'node7.example.com'],
|
97
|
-
'8' => Hash['name' => 'node8.example.com'],
|
98
|
-
'9' => Hash['name' => 'node9.example.com'],
|
99
|
-
]
|
100
|
-
>>
|
101
|
-
>> cluster = Cluster.new(nodes, 1, 1337)
|
102
|
-
>> rendezvous = RendezvousHash.new(nodes.keys, 1337)
|
103
|
-
>>
|
104
|
-
>> cluster.find_nodes('mykey')
|
105
|
-
=> ["7"]
|
106
|
-
>> rendezvous.find_node('mykey')
|
107
|
-
=> "7"
|
108
|
-
```
|
109
|
-
|
110
|
-
### supplying your own hash function
|
85
|
+
**DISCLAIMER**
|
111
86
|
|
112
|
-
|
113
|
-
|
87
|
+
clandestined was not designed with consideration for untrusted input, please
|
88
|
+
see LICENSE.
|
114
89
|
|
115
|
-
|
116
|
-
or `Cluster` object as a callable which takes a byte string `key` and returns
|
117
|
-
an integer.
|
90
|
+
**END DISCLAIMER**
|
118
91
|
|
119
92
|
```ruby
|
120
|
-
>> require 'digest'
|
121
93
|
>> require 'clandestined/cluster'
|
122
94
|
>> require 'clandestined/rendezvous_hash'
|
123
95
|
>>
|
@@ -133,15 +105,11 @@ an integer.
|
|
133
105
|
'9' => Hash['name' => 'node9.example.com'],
|
134
106
|
]
|
135
107
|
>>
|
136
|
-
>>
|
137
|
-
|
138
|
-
end
|
139
|
-
>>
|
140
|
-
>> cluster = Cluster.new(nodes, 1, 0, method(:my_hash_function))
|
141
|
-
>> rendezvous = RendezvousHash.new(nodes.keys, 0, method(:my_hash_function))
|
108
|
+
>> cluster = Clandestined::Cluster.new(nodes, 1, 1337)
|
109
|
+
>> rendezvous = Clandestined::RendezvousHash.new(nodes.keys, 1337)
|
142
110
|
>>
|
143
111
|
>> cluster.find_nodes('mykey')
|
144
|
-
=> ["
|
112
|
+
=> ["7"]
|
145
113
|
>> rendezvous.find_node('mykey')
|
146
|
-
=> "
|
114
|
+
=> "7"
|
147
115
|
```
|
data/clandestined.gemspec
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = 'clandestined'
|
3
|
-
s.version = '1.0.
|
3
|
+
s.version = '1.0.0b'
|
4
|
+
s.licenses = ['MIT']
|
4
5
|
s.date = Time.now.strftime('%Y-%m-%d')
|
5
6
|
s.summary = 'rendezvous hashing implementation based on murmur3 hash'
|
6
7
|
s.author = "Ernest W. Durbin III"
|
data/lib/clandestined/cluster.rb
CHANGED
@@ -2,110 +2,101 @@
|
|
2
2
|
require 'clandestined/rendezvous_hash'
|
3
3
|
require 'murmur3'
|
4
4
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
@
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
@
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
if cluster_config
|
35
|
-
cluster_config.each do |node, node_data|
|
36
|
-
name = node_data['name']
|
37
|
-
zone = node_data['zone']
|
38
|
-
add_zone(zone)
|
39
|
-
add_node(node, zone, name)
|
5
|
+
module Clandestined
|
6
|
+
class Cluster
|
7
|
+
|
8
|
+
include Murmur3
|
9
|
+
|
10
|
+
attr_reader :hash_function
|
11
|
+
attr_reader :seed
|
12
|
+
attr_reader :replicas
|
13
|
+
attr_reader :nodes
|
14
|
+
attr_reader :zones
|
15
|
+
attr_reader :zone_members
|
16
|
+
attr_reader :rings
|
17
|
+
|
18
|
+
def initialize(cluster_config=nil, replicas=2, seed=0)
|
19
|
+
@seed = seed
|
20
|
+
|
21
|
+
@replicas = replicas
|
22
|
+
@nodes = Hash[]
|
23
|
+
@zones = []
|
24
|
+
@zone_members = Hash[]
|
25
|
+
@rings = Hash[]
|
26
|
+
|
27
|
+
if cluster_config
|
28
|
+
cluster_config.each do |node, node_data|
|
29
|
+
name = node_data['name']
|
30
|
+
zone = node_data['zone']
|
31
|
+
add_zone(zone)
|
32
|
+
add_node(node, zone, name)
|
33
|
+
end
|
40
34
|
end
|
41
35
|
end
|
42
|
-
end
|
43
36
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
@
|
37
|
+
def add_zone(zone)
|
38
|
+
@zones.push(zone) unless zones.include?(zone)
|
39
|
+
@zone_members[zone] ||= []
|
40
|
+
@zones.sort!
|
48
41
|
end
|
49
|
-
@zones.sort!
|
50
|
-
end
|
51
42
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
43
|
+
def remove_zone(zone)
|
44
|
+
if zones.include?(zone)
|
45
|
+
@zones.delete(zone)
|
46
|
+
for member in zone_members[zone]
|
47
|
+
@nodes.delete(member)
|
48
|
+
end
|
49
|
+
@zones.sort!
|
50
|
+
@rings.delete(zone)
|
51
|
+
@zone_members.delete(zone)
|
52
|
+
else
|
53
|
+
raise ArgumentError, "No such zone #{zone} to remove"
|
57
54
|
end
|
58
|
-
@zones.sort!
|
59
|
-
@rings.delete(zone)
|
60
|
-
@zone_members.delete(zone)
|
61
55
|
end
|
62
|
-
end
|
63
56
|
|
64
|
-
|
65
|
-
|
57
|
+
def add_node(node_id, node_zone=nil, node_name=nil)
|
58
|
+
if nodes.include?(node_id)
|
66
59
|
raise ArgumentError, 'Node with id #{node_id} already exists'
|
60
|
+
end
|
61
|
+
add_zone(node_zone)
|
62
|
+
unless rings.has_key?(node_zone)
|
63
|
+
@rings[node_zone] = RendezvousHash.new(nil, seed)
|
64
|
+
end
|
65
|
+
@rings[node_zone].add_node(node_id)
|
66
|
+
@nodes[node_id] = node_name
|
67
|
+
(@zone_members[node_zone] ||= []) << node_id
|
67
68
|
end
|
68
|
-
add_zone(node_zone)
|
69
|
-
unless rings.has_key?(node_zone)
|
70
|
-
@rings[node_zone] = RendezvousHash.new(nil, 0, self.hash_function)
|
71
|
-
end
|
72
|
-
@rings[node_zone].add_node(node_id)
|
73
|
-
@nodes[node_id] = node_name
|
74
|
-
unless zone_members.has_key?(node_zone)
|
75
|
-
@zone_members[node_zone] = []
|
76
|
-
end
|
77
|
-
@zone_members[node_zone].push(node_id)
|
78
|
-
end
|
79
69
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
70
|
+
def remove_node(node_id, node_zone=nil, node_name=nil)
|
71
|
+
@rings[node_zone].remove_node(node_id)
|
72
|
+
@nodes.delete(node_id)
|
73
|
+
@zone_members[node_zone].delete(node_id)
|
74
|
+
if zone_members[node_zone].length == 0
|
85
75
|
remove_zone(node_zone)
|
76
|
+
end
|
86
77
|
end
|
87
|
-
end
|
88
78
|
|
89
|
-
|
90
|
-
|
91
|
-
|
79
|
+
def node_name(node_id)
|
80
|
+
nodes[node_id]
|
81
|
+
end
|
92
82
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
83
|
+
def find_nodes(search_key, offset=nil)
|
84
|
+
nodes = []
|
85
|
+
unless offset
|
86
|
+
offset = search_key.split('').inject(0) { |s, c| s += c[0,1].unpack('c')[0] }
|
87
|
+
end
|
88
|
+
for i in (0...replicas)
|
89
|
+
zone = zones[(i + offset.to_i) % zones.length]
|
90
|
+
nodes << rings[zone].find_node(search_key)
|
91
|
+
end
|
92
|
+
nodes
|
97
93
|
end
|
98
|
-
|
99
|
-
|
100
|
-
|
94
|
+
|
95
|
+
def find_nodes_by_index(product_id, block_index)
|
96
|
+
offset = (product_id.to_i + block_index.to_i) % zones.length
|
97
|
+
search_key = "#{product_id}-#{block_index}"
|
98
|
+
find_nodes(search_key, offset)
|
101
99
|
end
|
102
|
-
nodes
|
103
|
-
end
|
104
100
|
|
105
|
-
def find_nodes_by_index(product_id, block_index)
|
106
|
-
offset = (product_id.to_i + block_index.to_i) % zones.length
|
107
|
-
search_key = "#{product_id}-#{block_index}"
|
108
|
-
find_nodes(search_key, offset)
|
109
101
|
end
|
110
|
-
|
111
102
|
end
|
@@ -1,37 +1,37 @@
|
|
1
1
|
require 'murmur3'
|
2
2
|
|
3
|
-
|
3
|
+
module Clandestined
|
4
|
+
class RendezvousHash
|
4
5
|
|
5
|
-
|
6
|
+
include Murmur3
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
|
8
|
+
attr_reader :nodes
|
9
|
+
attr_reader :seed
|
10
|
+
attr_reader :hash_function
|
10
11
|
|
11
|
-
|
12
|
-
|
13
|
-
|
12
|
+
def initialize(nodes=nil, seed=0)
|
13
|
+
@nodes = nodes || []
|
14
|
+
@seed = seed
|
15
|
+
|
16
|
+
@hash_function = lambda { |key| murmur3_32(key, seed) }
|
14
17
|
|
15
|
-
if hash_function == method(:murmur3_32)
|
16
|
-
@hash_function = lambda { |key| hash_function.call(key, murmur_seed) }
|
17
|
-
elsif murmur_seed != 0
|
18
|
-
raise ArgumentError, "Cannot apply seed to custom hash function #{hash_function}"
|
19
|
-
else
|
20
|
-
@hash_function = hash_function
|
21
18
|
end
|
22
19
|
|
23
|
-
|
20
|
+
def add_node(node)
|
21
|
+
@nodes.push(node) unless @nodes.include?(node)
|
22
|
+
end
|
24
23
|
|
25
|
-
|
26
|
-
|
27
|
-
|
24
|
+
def remove_node(node)
|
25
|
+
if @nodes.include?(node)
|
26
|
+
@nodes.delete(node)
|
27
|
+
else
|
28
|
+
raise ArgumentError, "No such node #{node} to remove"
|
29
|
+
end
|
30
|
+
end
|
28
31
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
+
def find_node(key)
|
33
|
+
nodes.max {|a,b| hash_function.call("#{a}-#{key}") <=> hash_function.call("#{b}-#{key}")}
|
34
|
+
end
|
32
35
|
|
33
|
-
def find_node(key)
|
34
|
-
nodes.max {|a,b| hash_function.call("#{a}-#{key}") <=> hash_function.call("#{b}-#{key}")}
|
35
36
|
end
|
36
|
-
|
37
37
|
end
|
data/test/test_cluster.rb
CHANGED
@@ -4,15 +4,14 @@ require 'set'
|
|
4
4
|
|
5
5
|
require 'clandestined/cluster'
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
end
|
7
|
+
include Clandestined
|
8
|
+
|
10
9
|
|
11
10
|
class ClusterTestCase < Test::Unit::TestCase
|
12
11
|
|
13
12
|
def test_init_no_options
|
14
13
|
cluster = Cluster.new()
|
15
|
-
assert_equal(
|
14
|
+
assert_equal(0, cluster.seed)
|
16
15
|
assert_equal(2, cluster.replicas)
|
17
16
|
assert_equal(Hash[], cluster.nodes)
|
18
17
|
assert_equal([], cluster.zones)
|
@@ -22,16 +21,7 @@ class ClusterTestCase < Test::Unit::TestCase
|
|
22
21
|
|
23
22
|
def test_murmur_seed
|
24
23
|
cluster = Cluster.new(nil, 2, 10)
|
25
|
-
assert_equal(
|
26
|
-
end
|
27
|
-
|
28
|
-
def test_custom_hash_function
|
29
|
-
cluster = Cluster.new(nil, 2, 0, method(:my_hash_function))
|
30
|
-
assert_equal(310130709337150341200260887719094037511, cluster.hash_function.call('6666'))
|
31
|
-
end
|
32
|
-
|
33
|
-
def test_seeded_custom_hash_function
|
34
|
-
assert_raises(ArgumentError) { Cluster.new(nil, 2, 10, method(:my_hash_function)) }
|
24
|
+
assert_equal(10, cluster.seed)
|
35
25
|
end
|
36
26
|
|
37
27
|
def test_init_single_zone
|
@@ -430,6 +420,7 @@ class ClusterIntegrationTestCase < Test::Unit::TestCase
|
|
430
420
|
cluster.remove_node('6', 'c', 'node6')
|
431
421
|
cluster.remove_node('11', 'c', 'node11')
|
432
422
|
cluster.remove_node('12', 'c', 'node12')
|
423
|
+
assert_raises(ArgumentError) {cluster.remove_zone('c')}
|
433
424
|
|
434
425
|
new_placements = Hash[]
|
435
426
|
for i in cluster.nodes.keys
|
@@ -4,9 +4,8 @@ require 'set'
|
|
4
4
|
|
5
5
|
require 'clandestined/rendezvous_hash'
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
end
|
7
|
+
include Clandestined
|
8
|
+
|
10
9
|
|
11
10
|
class RendezvousHashTestCase < Test::Unit::TestCase
|
12
11
|
|
@@ -23,20 +22,12 @@ class RendezvousHashTestCase < Test::Unit::TestCase
|
|
23
22
|
assert_equal(1361238019, rendezvous.hash_function.call('6666'))
|
24
23
|
end
|
25
24
|
|
26
|
-
def
|
25
|
+
def test_seed
|
27
26
|
rendezvous = RendezvousHash.new(nil, 10)
|
27
|
+
assert_equal(10, rendezvous.seed)
|
28
28
|
assert_equal(2981722772, rendezvous.hash_function.call('6666'))
|
29
29
|
end
|
30
30
|
|
31
|
-
def test_custom_hash_function
|
32
|
-
rendezvous = RendezvousHash.new(nil, 0, method(:my_hash_function))
|
33
|
-
assert_equal(310130709337150341200260887719094037511, rendezvous.hash_function.call('6666'))
|
34
|
-
end
|
35
|
-
|
36
|
-
def test_seeded_custom_hash_function
|
37
|
-
assert_raises(ArgumentError) { RendezvousHash.new(nil, 10, method(:my_hash_function)) }
|
38
|
-
end
|
39
|
-
|
40
31
|
def test_add_node
|
41
32
|
rendezvous = RendezvousHash.new()
|
42
33
|
rendezvous.add_node('1')
|
@@ -54,7 +45,7 @@ class RendezvousHashTestCase < Test::Unit::TestCase
|
|
54
45
|
rendezvous = RendezvousHash.new(nodes)
|
55
46
|
rendezvous.remove_node('2')
|
56
47
|
assert_equal(2, rendezvous.nodes.length)
|
57
|
-
rendezvous.remove_node(
|
48
|
+
assert_raises(ArgumentError) { rendezvous.remove_node(2, rendezvous.nodes.length) }
|
58
49
|
assert_equal(2, rendezvous.nodes.length)
|
59
50
|
rendezvous.remove_node('1')
|
60
51
|
assert_equal(1, rendezvous.nodes.length)
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: clandestined
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.0b
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ernest W. Durbin III
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-07-
|
11
|
+
date: 2014-07-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rake
|
@@ -62,8 +62,10 @@ extra_rdoc_files: []
|
|
62
62
|
files:
|
63
63
|
- .gitignore
|
64
64
|
- .travis.yml
|
65
|
+
- CHANGES.md
|
65
66
|
- Gemfile
|
66
67
|
- Gemfile.lock
|
68
|
+
- LICENSE
|
67
69
|
- README.md
|
68
70
|
- Rakefile
|
69
71
|
- clandestined.gemspec
|
@@ -76,7 +78,8 @@ files:
|
|
76
78
|
- test/test_cluster.rb
|
77
79
|
- test/test_rendezvous_hash.rb
|
78
80
|
homepage: https://github.com/ewdurbin/clandestined-ruby
|
79
|
-
licenses:
|
81
|
+
licenses:
|
82
|
+
- MIT
|
80
83
|
metadata: {}
|
81
84
|
post_install_message:
|
82
85
|
rdoc_options: []
|