Skip to content
This repository was archived by the owner on Jan 9, 2024. It is now read-only.

Commit d69d290

Browse files
Appurv JainGrokzen
authored andcommitted
[Bug Fix] Ensure port mapping is not done in isolation of host mapping
The existing remapping logic looks at host remapping and port remapping in isolation, which leads to failure in some cases where all from_ports are same like the following example. ``` startup_nodes = [ {"host": "127.0.0.1", "port": "17000"}, {"host": "127.0.0.1", "port": "17001"}, {"host": "127.0.0.1", "port": "17002"}, {"host": "127.0.0.1", "port": "17003"}, {"host": "127.0.0.1", "port": "17004"}, {"host": "127.0.0.1", "port": "17005"} ] host_port_remap=[ {'from_host': '41.1.3.1', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17000}, {'from_host': '41.1.3.5', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17001}, {'from_host': '41.1.4.2', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17002}, {'from_host': '50.0.1.7', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17003}, {'from_host': '50.0.7.3', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17004}, {'from_host': '32.0.1.1', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17005} ] ``` This is becasue when just ports get remapped, it ends up producing overlapping node names and ends up corrupting both the `nodes_cache` and the `slots` dictionaries in nodemanager Changes: - This change modifies the remapping logic to use both host and port info(if avaiable) to decide if remapping should happen - Adds more cases in the host_port_remap list validation logic so that no unknown keys are allowed and only valid ips are allowed - Added a more explicit ip comparison logic(comparing octets indiviidually) - Also fixed an unrelated failing testcase `test_blocked_commands` in `test_cluster_obj.py` - Updated the docs/clients.rst with details about the new mapping logic and added an example Test Plan: - Updated Unit tests to catch this is issue - Tested the changes a real AWS Elasticache Redis cluster fronted by stunnel and tested both get, mget and set command with differrent keys
1 parent 6da568e commit d69d290

File tree

6 files changed

+190
-14
lines changed

6 files changed

+190
-14
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ build
1313
.cache
1414
docs/_build
1515
docs/_build_html
16+
.idea

docs/authors.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ Authors who contributed code or testing:
2727
- astrohsy - https://github.com/astrohsy
2828
- Artur Stawiarski - https://github.com/astawiarski
2929
- Matthew Anderson - https://github.com/mc3ander
30+
- Appurv Jain - https://github.com/appurvj

docs/client.rst

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,45 @@ host_port_remap
1212

1313
This option exists to enable the client to fix a problem where the redis-server internally tracks a different ip:port compared to what your clients would like to connect to.
1414

15-
The simples example to describe this problem is if you start a redis cluster through docker on your local machine. If we assume that you start the docker image grokzen/redis-cluster, when the redis cluster is initialized it will track the docker network IP for each node in the cluster.
15+
A simple example to describe this problem is if you start a redis cluster through docker on your local machine. If we assume that you start the docker image grokzen/redis-cluster,
16+
when the redis cluster is initialized it will track the docker network IP for each node in the cluster.
1617

17-
For example this could be 172.18.0.2. The problem is that a client that runs outside on your local machine will recieve from the redis cluster that each node is reachable on the ip 172.18.0.2. But in some cases this IP is not available on your host system and to solve this we need a remapping table where we can tell this client that if you get back from your cluster 172.18.0.2 then your should remap it to localhost instead. When the client does this it can now connect and reach all nodes in your cluster.
18+
For example this could be 172.18.0.2. The problem is that a client that runs outside on your local machine will receive from the redis cluster that each node is reachable on the ip 172.18.0.2.
19+
But in some cases this IP is not available on your host system.To solve this we need a remapping table where we can tell this client that if you get back from your cluster 172.18.0.2 then your should remap it to localhost instead.
20+
When the client does this it can now connect and reach all nodes in your cluster.
1821

19-
It is also possible to remap the port for each node as well.
2022

21-
Example script
23+
Remapping works off a rules list. Each rule is a dictionary of the form shown below
24+
25+
.. code-block::
26+
27+
{
28+
'from_host': <Host name on redis server side>, # String
29+
'from_port': <Port on the redis server side>, # Integer
30+
'to_host': <Host name that the client needs to map to>, # String
31+
'to_port': <Port that the client needs to map to> # Integer
32+
}
33+
34+
35+
Remapping properties:
36+
37+
- This host_port_remap feature will not work on the startup_nodes so you still need to put in a valid and reachable set of startup nodes.
38+
- The remapping logic treats host_port_remap list as a "rules list" and only the first matching remapping entry will be applied
39+
- A remapping rule may contain just host or just port mapping, but both sides of the maping( i.e. from_host and to_host or from_port and to_port) are required for either
40+
- If both from_host and from_port are specified, then both will be used to decide if a remapping rule applies
41+
42+
Examples of valid rules:
43+
44+
.. code-block:: python
45+
46+
{'from_host': "1.2.3.4", 'from_port': 1000, 'to_host': "2.2.2.2", 'to_port': 2000}
47+
48+
{'from_host': "1.1.1.1", 'to_host': "127.0.0.1"}
49+
50+
{'from_port': 1000, 'to_port': 2000}
51+
52+
53+
Example scripts:
2254

2355
.. code-block:: python
2456
@@ -52,5 +84,43 @@ Example script
5284
## Test the client that it can still send and recieve data from the nodes after the remap has been done
5385
print(rc.set('foo', 'bar'))
5486
87+
This feature is also useful in cases such as when one is trying to access AWS ElastiCache cluster secured by Stunnel (https://www.stunnel.org/)
88+
89+
.. code-block:: python
90+
91+
from rediscluster import RedisCluster
92+
93+
startup_nodes = [
94+
{"host": "127.0.0.1", "port": "17000"},
95+
{"host": "127.0.0.1", "port": "17001"},
96+
{"host": "127.0.0.1", "port": "17002"},
97+
{"host": "127.0.0.1", "port": "17003"},
98+
{"host": "127.0.0.1", "port": "17004"},
99+
{"host": "127.0.0.1", "port": "17005"}
100+
]
101+
102+
host_port_remap=[
103+
{'from_host': '41.1.3.1', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17000},
104+
{'from_host': '41.1.3.5', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17001},
105+
{'from_host': '41.1.4.2', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17002},
106+
{'from_host': '50.0.1.7', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17003},
107+
{'from_host': '50.0.7.3', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17004},
108+
{'from_host': '32.0.1.1', 'from_port': 6379, 'to_host': '127.0.0.1', 'to_port': 17005}
109+
]
110+
111+
112+
# Note: decode_responses must be set to True when used with python3
113+
rc = RedisCluster(
114+
startup_nodes=startup_nodes,
115+
host_port_remap=host_port_remap,
116+
decode_responses=True,
117+
ssl=True,
118+
ssl_cert_reqs=None,
119+
# Needed for Elasticache Clusters
120+
skip_full_coverage_check=True)
55121
56-
Pleaes note that this host_port_remap feature will not work on the startup_nodes so you still need to put in a valid and reachable set of startup nodes.
122+
123+
print(rc.connection_pool.nodes.nodes)
124+
print(rc.ping())
125+
print(rc.set('foo', 'bar'))
126+
print(rc.get('foo'))

docs/release-notes.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Release Notes
2626
* Implement new connection pool ClusterBlockingConnectionPool (#347)
2727
* Nodemanager initiailize should now handle usernames properly (#365)
2828
* PubSub tests has been all been disabled
29-
* New feature, host_port_remap. Send in a remapping configuration to RedisCluster instance where the nodes configuration recieved from the redis cluster can be altered to allow for connection in certain circumstances. See new section in clients.rst in docs/ for usage example.
29+
* New feature, host_port_remap. Send in a remapping configuration to RedisCluster instance where the nodes configuration recieved from the redis cluster can be altered to allow for connection in certain circumstances. See new section in client.rst in docs/ for usage example.
3030
* When a slot is not covered by the cluster, it will not raise SlotNotCoveredError instead of the old generic RedisClusterException. The client will not attempt to rebuild the cluster layout a few times before giving up and raising that exception to the user. (#350)
3131
* CLIENT SETNAME is now possible to use from the client instance. For setting the name for all connections from the client by default, see issue #802 in redis-py repo for the change that was implemented in redis-py 3.4.0.
3232

rediscluster/nodemanager.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
import json
55
import logging
66
import random
7+
import socket
78

89
# rediscluster imports
910
from .crc import crc16
1011
from .exceptions import RedisClusterException, RedisClusterConfigError
1112

1213
# 3rd party imports
1314
from redis import Redis
14-
from redis._compat import unicode, long, basestring
1515
from redis.connection import Encoder
1616
from redis import ConnectionError, TimeoutError, ResponseError
1717

@@ -71,12 +71,30 @@ def _validate_host_port_remap(self, host_port_remap):
7171
if not isinstance(item, dict):
7272
raise RedisClusterConfigError("items inside host_port_remap list must be of dict type")
7373

74+
if len(set(item.keys()) - {'from_host', 'from_port', 'to_host', 'to_port'}) != 0:
75+
raise RedisClusterConfigError("Invalid keys provided in host_port_remap rule")
76+
7477
# If we have from_host, we must have a to_host option to allow for translation to work
7578
if ('from_host' in item and 'to_host' not in item) or ('from_host' not in item and 'to_host' in item):
76-
raise RedisClusterConfigError("Both from_host and to_host must be present in remap item if either is defined")
79+
raise RedisClusterConfigError("Both from_host and to_host must be present in host_port_remap rule if either is defined")
80+
81+
if ('from_port' in item and 'to_port' not in item) or ('from_port' not in item and 'to_port' in item):
82+
raise RedisClusterConfigError("Both from_port and to_port must be present in host_port_remap rule if either is defined")
83+
84+
try:
85+
socket.inet_aton(item.get('from_host', '0.0.0.0').strip())
86+
socket.inet_aton(item.get('to_host', '0.0.0.0').strip())
87+
except socket.error:
88+
raise RedisClusterConfigError("Both from_host and to_host in host_port_remap rule must be a valid ip address")
89+
if len(item.get('from_host', '0.0.0.0').split('.')) < 4 or len(item.get('to_host', '0.0.0.0').split('.')) < 4 :
90+
raise RedisClusterConfigError(
91+
"Both from_host and to_host in host_port_remap rule must must have all octets specified")
7792

78-
if ('from_port' in item and 'to_port' not in item) or ('from_port' not in item and 'to_port' in item):
79-
raise RedisClusterConfigError("Both from_port and to_port must be present in remap item")
93+
try:
94+
int(item.get('from_port', 0))
95+
int(item.get('to_port', 0))
96+
except ValueError:
97+
raise RedisClusterConfigError("Both from_port and to_port in host_port_remap rule must be integers")
8098

8199
def keyslot(self, key):
82100
"""
@@ -305,9 +323,30 @@ def remap_internal_node_object(self, node_obj):
305323
if 'from_port' in remap_rule and 'to_port' in remap_rule:
306324
if remap_rule['from_port'] == node_obj[1]:
307325
node_obj[1] = remap_rule['to_port']
326+
# At this point remapping has occurred, so no further rules should be processed
327+
break
308328

309329
return node_obj
310330

331+
def _remap_rule_applies(self, remap_rule, node_obj):
332+
# Double check to make sure that the relevant host and/or port fields are present
333+
if not (('from_host' in remap_rule and 'to_host' in remap_rule) or ('from_port' in remap_rule and 'to_port' in remap_rule)):
334+
return False
335+
if 'from_host' in remap_rule and not self._ips_equal(remap_rule['from_host'], node_obj[0]):
336+
return False
337+
if 'from_port' in remap_rule and remap_rule['from_port'] != node_obj[1]:
338+
return False
339+
# If the previous conditions are not met then this is a valid match.
340+
return True
341+
342+
def _ips_equal(self, ip1, ip2):
343+
split_ip1 = ip1.strip().split(".")
344+
split_ip2 = ip2.strip().split(".")
345+
for i, octet in enumerate(split_ip1):
346+
if int(octet) != int(split_ip2[i]):
347+
return False
348+
return True
349+
311350
def increment_reinitialize_counter(self, ct=1, count=1):
312351
for i in range(min(ct, self.reinitialize_steps)):
313352
self.reinitialize_counter += count

tests/test_cluster_node_manager.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -495,13 +495,32 @@ def test_host_port_remap():
495495
startup_nodes=[{"host": "127.0.0.1", "port": 7000}],
496496
host_port_remap=[{'to_port': ''}],
497497
)
498+
# Invalid keys in the rules should also raise exception
499+
with pytest.raises(RedisClusterConfigError) as excp:
500+
n = NodeManager(
501+
startup_nodes=[{"host": "127.0.0.1", "port": 7000}],
502+
host_port_remap=[{'invalid_key': ''}],
503+
)
504+
505+
# Invalid ips in the rules should raise exception
506+
with pytest.raises(RedisClusterConfigError) as excp:
507+
n = NodeManager(
508+
startup_nodes=[{"host": "127.0.0.1", "port": 7000}],
509+
host_port_remap=[{'from_host': '127.2.x.w', 'to_host': '127.0.0.1'}],
510+
)
511+
# Incomplete ips in the rules should raise exception
512+
with pytest.raises(RedisClusterConfigError) as excp:
513+
n = NodeManager(
514+
startup_nodes=[{"host": "127.0.0.1", "port": 7000}],
515+
host_port_remap=[{'from_host': '127.2', 'to_host': '127.0.0.1'}],
516+
)
498517

499518
# Creating a valid config with multiple entries
500519
n = NodeManager(
501520
startup_nodes=[{"host": "127.0.0.1", "port": 7000}],
502521
host_port_remap=[
503-
{'from_host': '127.0.0.1', 'to_host': 'localhost', 'from_port': 7000, 'to_port': 70001},
504-
{'from_host': '172.1.0.1', 'to_host': 'localhost', 'from_port': 7000, 'to_port': 70001},
522+
{'from_host': '127.0.0.1', 'to_host': '127.0.0.1', 'from_port': 7000, 'to_port': 70001},
523+
{'from_host': '172.1.0.1', 'to_host': '127.0.0.1', 'from_port': 7000, 'to_port': 70001},
505524
],
506525
)
507526

@@ -516,10 +535,56 @@ def test_host_port_remap():
516535

517536
# Test that modifying both host and port works
518537
n = NodeManager(
519-
host_port_remap=[{'from_host': '127.0.0.1', 'to_host': 'localhost', 'from_port': 7000, 'to_port': 7001}],
538+
host_port_remap=[{'from_host': '127.1.1.1', 'to_host': '128.0.0.1', 'from_port': 7000, 'to_port': 7001},
539+
{'from_host': '127.2.2.2', 'to_host': '128.0.0.1', 'from_port': 7000, 'to_port': 7005}],
540+
startup_nodes=[{"host": "128.0.0.1", "port": 7000}]
541+
)
542+
initial_node_obj = ['127.1.1.1', 7000, 'xyz']
543+
remapped_obj = n.remap_internal_node_object(initial_node_obj)
544+
assert remapped_obj[0] == '128.0.0.1'
545+
assert remapped_obj[1] == 7001
546+
547+
# Validate that ports are NOT remapped in isolation if hosts are also present
548+
n = NodeManager(
549+
host_port_remap=[{'from_host': '127.2.2.2', 'to_host': '127.0.0.1', 'from_port': 7000, 'to_port': 7001},
550+
{'from_host': '127.3.3.3', 'to_host': '127.0.0.1', 'from_port': 7000, 'to_port': 7005}],
520551
startup_nodes=[{"host": "127.0.0.1", "port": 7000}]
521552
)
522553
initial_node_obj = ['127.0.0.1', 7000, 'xyz']
523554
remapped_obj = n.remap_internal_node_object(initial_node_obj)
524-
assert remapped_obj[0] == 'localhost'
555+
assert remapped_obj[0] == '127.0.0.1'
556+
assert remapped_obj[1] == 7000
557+
558+
# Validate that first applicable rule is applied
559+
n = NodeManager(
560+
host_port_remap=[{'from_host': '127.2.2.2', 'to_host': '127.0.0.1', 'from_port': 7000, 'to_port': 7001},
561+
{'from_host': '127.3.3.3', 'to_host': '127.0.0.1', 'from_port': 7000, 'to_port': 7005},
562+
{'from_host': '127.2.2.2', 'to_host': '127.0.0.1', 'from_port': 7000, 'to_port': 7006}],
563+
startup_nodes=[{"host": "127.0.0.1", "port": 7000}]
564+
)
565+
initial_node_obj = ['127.2.2.2', 7000, 'xyz']
566+
remapped_obj = n.remap_internal_node_object(initial_node_obj)
567+
assert remapped_obj[0] == '127.0.0.1'
525568
assert remapped_obj[1] == 7001
569+
570+
# Validate just port mapping works
571+
n = NodeManager(
572+
host_port_remap=[{'from_port': 7000, 'to_port': 7001},
573+
{'from_port': 7002, 'to_port': 7005}],
574+
startup_nodes=[{"host": "127.0.0.1", "port": 7000}]
575+
)
576+
initial_node_obj = ['127.0.0.1', 7000, 'xyz']
577+
remapped_obj = n.remap_internal_node_object(initial_node_obj)
578+
assert remapped_obj[0] == '127.0.0.1'
579+
assert remapped_obj[1] == 7001
580+
581+
# Validate just host mapping works
582+
n = NodeManager(
583+
host_port_remap=[{'from_host': '127.2.2.2', 'to_host': '127.0.0.1'},
584+
{'from_host': '127.3.3.3', 'to_host': '127.0.0.2'}],
585+
startup_nodes=[{"host": "127.0.0.1", "port": 7000}]
586+
)
587+
initial_node_obj = ['127.3.3.3', 7000, 'xyz']
588+
remapped_obj = n.remap_internal_node_object(initial_node_obj)
589+
assert remapped_obj[0] == '127.0.0.2'
590+
assert remapped_obj[1] == 7000

0 commit comments

Comments
 (0)