Skip to content

Commit 9f58e7b

Browse files
jshimkus-rhjshimkusjohn-westcott-iv
authored
[AAP-45287] Fix IPv6 handling for Redis (#724)
## Description <!-- Mandatory: Provide a clear, concise description of the changes and their purpose --> - What is being changed? * Extending the class of addresses supported for Redis clients to include IPv6 values. - Why is this change needed? * Current Redis client rejects IPv6 address as invalid. - How does this change address the issue? Accepts IPv6 addresses in the common forms of <addr>:<port> and [<addr>]:<port>. ## Type of Change <!-- Mandatory: Check one or more boxes that apply --> - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Test update - [ ] Refactoring (no functional changes) - [ ] Development environment change - [ ] Configuration change ## Self-Review Checklist <!-- These items help ensure quality - they complement our automated CI checks --> - [x] I have performed a self-review of my code - [x] I have added relevant comments to complex code sections - [ ] I have updated documentation where needed - [ ] I have considered the security impact of these changes - [ ] I have considered performance implications - [x] I have thought about error handling and edge cases - [x] I have tested the changes in my local environment ## Testing Instructions ### Steps to Test 1. Deploy AAP environment in an IPv6-only environment *not utilizing host names for Redis client. Installer operates as such. ### Expected Results Redis client connections successfully instantiated. --------- Co-authored-by: Joe Shimkus <[email protected]> Co-authored-by: John Westcott IV <[email protected]>
1 parent a46ebe5 commit 9f58e7b

File tree

4 files changed

+216
-6
lines changed

4 files changed

+216
-6
lines changed

ansible_base/lib/redis/client.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from redis.exceptions import NoPermissionError, RedisClusterException
1313

1414
from ansible_base.lib.constants import STATUS_DEGRADED, STATUS_FAILED, STATUS_GOOD
15+
from ansible_base.lib.utils import address
1516

1617
logger = logging.getLogger('ansible_base.lib.redis.client')
1718

@@ -160,22 +161,27 @@ def _get_hosts(self) -> None:
160161
had_host_errors = False
161162
host_ports = self.redis_hosts.split(',')
162163
for host_port in host_ports:
163-
try:
164-
node, port_string = host_port.split(':')
165-
except ValueError:
164+
response = address.classify_address(host_port)
165+
if response.type == address.AddressType.UNKNOWN:
166+
logger.error(
167+
f"Specified cluster_host {host_port} is not valid; "
168+
"it is of an unknown address type and must be one of <hostname>:<port>, <ipv4>:<port> or [<ipv6>]:<port>"
169+
)
170+
had_host_errors = True
171+
continue
172+
if not response.port:
166173
logger.error(f"Specified cluster_host {host_port} is not valid; it needs to be in the format <host>:<port>")
167174
had_host_errors = True
168175
continue
169-
170176
# Make sure we have an int for the port
171177
try:
172-
port = int(port_string)
178+
port = int(response.port)
173179
except ValueError:
174180
logger.error(f'Specified port on {host_port} is not an int')
175181
had_host_errors = True
176182
continue
177183

178-
self.connection_settings['startup_nodes'].append(ClusterNode(node, port))
184+
self.connection_settings['startup_nodes'].append(ClusterNode(response.address, port))
179185

180186
if had_host_errors:
181187
raise ValueError()

ansible_base/lib/utils/address.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import dataclasses
2+
import enum
3+
import ipaddress
4+
import re
5+
import typing
6+
7+
8+
class AddressType(enum.Enum):
9+
"""
10+
AddressType provides an abstracted identification of the determined kind of
11+
an address. The abstraction eliminates cohesion between the provided
12+
address identification functionality and its clients.
13+
14+
The type allows a client which may be configured with any of the supported
15+
types to identify which type was specified and perform necessary runtime
16+
handling.
17+
18+
An example would be a client which uses the values from its configuration
19+
to construct URLs and which is configured using raw IPv6 addreesses. The
20+
client needs to be able to determine the address type to know what
21+
processing (such as enclosing IPv6 addresses in []s) is needed to
22+
successfully utilize it.
23+
"""
24+
25+
HOSTNAME = "hostname"
26+
IPv4 = "ipv4"
27+
IPv6 = "ipv6"
28+
UNKNOWN = "unknown"
29+
30+
31+
@dataclasses.dataclass(frozen=True)
32+
class AddressTypeResponse(object):
33+
"""
34+
AddressTypeResponse is returned from the classify address method describing
35+
the detected address including splitting it into address and port parts as
36+
appicable.
37+
38+
Strings are used for the address and port so as to minimize changes to
39+
existing code to facilitate use of the classification functionality
40+
provided.
41+
"""
42+
43+
type: AddressType
44+
address: str
45+
port: typing.Optional[str] = None
46+
47+
@property
48+
def ipv6_bracketed(self):
49+
"""
50+
IPv6 addresses are stored without brackets in the address field.
51+
If the type of this instance is AddressType.IPv6 the method will return
52+
the address enclosed in brackets.
53+
If the type is not AddressType.IPv6 it will return None.
54+
"""
55+
if self.type != AddressType.IPv6:
56+
return None
57+
return f"[{self.address}]"
58+
59+
60+
def _classify_base_address(address: str) -> AddressTypeResponse:
61+
"""
62+
Categorizes a given string as IPv4, IPv6, hostname, or unknown.
63+
64+
Args:
65+
address: The string to categorize.
66+
67+
Returns:
68+
A value of AddressTypeResponse indicating the classified address.
69+
"""
70+
try:
71+
ipaddress.IPv4Address(address)
72+
return AddressTypeResponse(AddressType.IPv4, address)
73+
except ipaddress.AddressValueError:
74+
pass
75+
76+
try:
77+
ipaddress.IPv6Address(address)
78+
return AddressTypeResponse(AddressType.IPv6, address)
79+
except ipaddress.AddressValueError:
80+
pass
81+
82+
# Basic hostname check (can be expanded for more rigorous validation)
83+
# The original regex was generated via Gemini AI.
84+
# It was modified to require the first character be alphabetic to eliminate
85+
# a string composed of nothing but digits be recognized as a hostname.
86+
#
87+
# The regex may appear more complicated than it actually is.
88+
#
89+
# First you can ignore the "?:" which simply makes the group in which it
90+
# appears non-capturing.
91+
#
92+
# Second you can conceptually collapse the portions delineated by "{" and
93+
# "}" to "*". The bracketed portions simply put a limit on minimum and
94+
# maximum lengths of the preceding construct.
95+
#
96+
# With the two above changes you'll see that there's effectively only one
97+
# construct in the regex:
98+
#
99+
# [a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?
100+
#
101+
# It's second usage simply includes the requirement for a leading "." and
102+
# allows it to be specified zero or more times.
103+
if re.match(r"^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", address):
104+
return AddressTypeResponse(AddressType.HOSTNAME, address)
105+
106+
return AddressTypeResponse(AddressType.UNKNOWN, address)
107+
108+
109+
def _classify_address(address: str) -> AddressTypeResponse:
110+
"""
111+
Categorizes a given string as IPv4, IPv6, hostname, or unknown.
112+
113+
Args:
114+
address: The string to categorize.
115+
116+
Returns:
117+
A value of AddressTypeResponse indicating the classified address.
118+
"""
119+
# We could be dealing with an IPv6 address wrapped in [].
120+
# If that is the case we want to classify the address itself.
121+
122+
# Check that the address is at least two characters long.
123+
if (len(address) >= 2) and (address[0] == "[") and (address[-1] == "]"):
124+
response = _classify_base_address(address[1:-1])
125+
126+
# We only recognize an IPv6 address wrapped in []s as valid.
127+
# Regardless of the contents being identified if it is not an IPv6
128+
# address we treat it as unknown.
129+
if response.type == AddressType.IPv6:
130+
return response
131+
return AddressTypeResponse(AddressType.UNKNOWN, address)
132+
133+
return _classify_base_address(address)
134+
135+
136+
def classify_address(address: str) -> AddressTypeResponse:
137+
"""
138+
Categorizes a given string with optional ":<port>" suffix as IPv4, IPv6,
139+
hostname, or unknown.
140+
141+
Args:
142+
address: The string to categorize.
143+
144+
Returns:
145+
A value of AddressTypeResponse indicating the classified address.
146+
"""
147+
response = _classify_address(address)
148+
if response.type != AddressType.UNKNOWN:
149+
# A known type with no port.
150+
return response
151+
152+
# Split into potential address and port and classify the address.
153+
(split_address, _, port) = address.rpartition(":")
154+
response = _classify_address(split_address)
155+
if response.type != AddressType.UNKNOWN:
156+
# A known type with a port.
157+
return AddressTypeResponse(response.type, response.address, port)
158+
159+
# An unknown address type.
160+
return AddressTypeResponse(AddressType.UNKNOWN, address)

test_app/tests/lib/redis/test_client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,13 @@ def test_redis_cluster_mget_raises_expected_exception():
209209
('a:1,b:1', False, 2),
210210
('a:b', True, None),
211211
('a,b,c', True, None),
212+
('[::1]:1', False, 1),
213+
('2600:1f18:218b:5902:e5d4:54de:fdc1:24b8:1', False, 1),
214+
('[2600:1f18:218b:5902:e5d4:54de:fdc1:24b8]:1', False, 1),
215+
('[::1]:1,2600:1f18:218b:5902:e5d4:54de:fdc1:24b8:1', False, 2),
216+
('[::1]:1,[2600:1f18:218b:5902:e5d4:54de:fdc1:24b8]:1', False, 2),
217+
('[::1],[2600:1f18:218b:5902:e5d4:54de:fdc1:24b8]:1', True, None),
218+
('[::1]:1,//////////:1', True, None),
212219
],
213220
)
214221
def test_redis_client_cluster_hosts_parsing(redis_hosts, raises, expected_length):
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import pytest
2+
3+
from ansible_base.lib.utils.address import AddressType, AddressTypeResponse, classify_address
4+
5+
6+
@pytest.mark.parametrize(
7+
"address,expected",
8+
[
9+
("127.0.0.1", AddressTypeResponse(AddressType.IPv4, "127.0.0.1")),
10+
("[127.0.0.1]", AddressTypeResponse(AddressType.UNKNOWN, "[127.0.0.1]")),
11+
("127.0.0.1:1234", AddressTypeResponse(AddressType.IPv4, "127.0.0.1", "1234")),
12+
("10.0.1.1:1234", AddressTypeResponse(AddressType.IPv4, "10.0.1.1", "1234")),
13+
("10.0.1.1", AddressTypeResponse(AddressType.IPv4, "10.0.1.1")),
14+
("[10.0.1.1]", AddressTypeResponse(AddressType.UNKNOWN, "[10.0.1.1]")),
15+
("::1", AddressTypeResponse(AddressType.IPv6, "::1")),
16+
("[::1]", AddressTypeResponse(AddressType.IPv6, "::1")),
17+
("[::1]:1234", AddressTypeResponse(AddressType.IPv6, "::1", "1234")),
18+
("2600:1f18:218b:5902:e5d4:54de:fdc1:24b8", AddressTypeResponse(AddressType.IPv6, "2600:1f18:218b:5902:e5d4:54de:fdc1:24b8")),
19+
("[2600:1f18:218b:5902:e5d4:54de:fdc1:24b8]", AddressTypeResponse(AddressType.IPv6, "2600:1f18:218b:5902:e5d4:54de:fdc1:24b8")),
20+
("2600:1f18:218b:5902:e5d4:54de:fdc1:24b8:1234", AddressTypeResponse(AddressType.IPv6, "2600:1f18:218b:5902:e5d4:54de:fdc1:24b8", "1234")),
21+
("[2600:1f18:218b:5902:e5d4:54de:fdc1:24b8]:1234", AddressTypeResponse(AddressType.IPv6, "2600:1f18:218b:5902:e5d4:54de:fdc1:24b8", "1234")),
22+
("localhost", AddressTypeResponse(AddressType.HOSTNAME, "localhost")),
23+
("[localhost]", AddressTypeResponse(AddressType.UNKNOWN, "[localhost]")),
24+
("localhost:1234", AddressTypeResponse(AddressType.HOSTNAME, "localhost", "1234")),
25+
("a-host-name", AddressTypeResponse(AddressType.HOSTNAME, "a-host-name")),
26+
("a-host-name:1234", AddressTypeResponse(AddressType.HOSTNAME, "a-host-name", "1234")),
27+
("////////////", AddressTypeResponse(AddressType.UNKNOWN, "////////////")),
28+
("123123123123", AddressTypeResponse(AddressType.UNKNOWN, "123123123123")),
29+
],
30+
)
31+
def test_classify_address(address, expected):
32+
response = classify_address(address)
33+
assert response == expected
34+
if response.type == AddressType.IPv6:
35+
assert response.ipv6_bracketed == f"[{response.address}]"
36+
else:
37+
assert response.ipv6_bracketed is None

0 commit comments

Comments
 (0)