Skip to content

Commit 4ac89ae

Browse files
authored
redisenterprise: add test-connection command to address a common supportability ask (#9490)
* add test-connection command * update version
1 parent 3db9b63 commit 4ac89ae

File tree

7 files changed

+438
-2
lines changed

7 files changed

+438
-2
lines changed

src/redisenterprise/HISTORY.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
33
Release History
44
===============
5+
1.4.0
6+
- add a new command `az redisenterprise test-connection` to test the connection to a cluster.
7+
58
1.3.1
69
- Fixed an issue where updating sku from Azure Cache for Enterprise to Azure Managed Redis SKU was not working as expected.
710

src/redisenterprise/azext_redisenterprise/_help.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,16 @@
3333
type: command
3434
short-summary: Get information about a RedisEnterprise cluster
3535
"""
36+
37+
helps['redisenterprise test-connection'] = """
38+
type: command
39+
short-summary: Test connection to a Redis Enterprise cluster
40+
long-summary: Test the connection to a Redis Enterprise cluster using the specified authentication method.
41+
examples:
42+
- name: Test connection using Entra authentication
43+
text: |-
44+
az redisenterprise test-connection --cluster-name "cache1" --resource-group "rg1" --auth entra
45+
- name: Test connection using access key authentication
46+
text: |-
47+
az redisenterprise test-connection --cluster-name "cache1" --resource-group "rg1" --auth access-key
48+
"""

src/redisenterprise/azext_redisenterprise/_params.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,17 @@ def load_arguments(self, _):
247247
c.argument('cluster_name', options_list=['--cluster-name', '--name', '-n'], type=str, help='The name of the '
248248
'RedisEnterprise cluster.', id_part='name')
249249

250+
with self.argument_context('redisenterprise test-connection') as c:
251+
c.argument('resource_group_name', resource_group_name_type, required=False)
252+
c.argument('cluster_name', options_list=['--cluster-name', '--name', '-n'], type=str, help='The name of the '
253+
'RedisEnterprise cluster.', id_part='name', required=False)
254+
c.argument('auth', arg_type=get_enum_type(['entra', 'access-key']), help='The authentication method to use '
255+
'for the connection test.')
256+
c.argument('host_name', options_list=['--host', '--host-name'], type=str, help='The Redis host name (FQDN) to connect to. '
257+
'If provided, --resource-group and --cluster-name are optional.')
258+
c.argument('access_key', options_list=['--access-key'], type=str, help='The access key for authentication. '
259+
'Required when using --host with access-key auth.')
260+
250261

251262
class AddPersistence(argparse.Action):
252263
def __call__(self, parser, namespace, values, option_string=None):

src/redisenterprise/azext_redisenterprise/commands.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def load_command_table(self, _): # pylint: disable=unused-argument
1818
g.custom_show_command('show', 'redisenterprise_show')
1919
from .custom import RedisEnterpriseUpdate
2020
self.command_table["redisenterprise update"] = RedisEnterpriseUpdate(loader=self)
21+
g.custom_command('test-connection', 'redisenterprise_test_connection')
2122
with self.command_group("redisenterprise database"):
2223
from .custom import DatabaseFlush, DatabaseCreate, DatabaseDelete, DatabaseExport, DatabaseForceUnlink
2324
from .custom import DatabaseImport, DatabaseListKey, DatabaseRegenerateKey

src/redisenterprise/azext_redisenterprise/custom.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from azure.cli.core.azclierror import (
2929
MutuallyExclusiveArgumentError,
3030
)
31+
from azure.cli.core.azclierror import ValidationError
3132

3233
logger = get_logger(__name__)
3334

@@ -69,6 +70,241 @@ def _handle_sku_change(self, current_sku, new_sku, instance):
6970
pass
7071

7172

73+
def _get_redis_connection(host_name, port, password, ssl=True, username=None):
74+
"""
75+
Create a Redis connection using the provided credentials.
76+
77+
:param host_name: The Redis host name.
78+
:param port: The Redis port.
79+
:param password: The password or token for authentication.
80+
:param ssl: Whether to use SSL connection.
81+
:param username: The username for authentication (required for Entra ID auth).
82+
:return: Redis client instance.
83+
"""
84+
import redis
85+
return redis.Redis(
86+
host=host_name,
87+
port=port,
88+
username=username,
89+
password=password,
90+
ssl=ssl,
91+
ssl_cert_reqs=None,
92+
decode_responses=True
93+
)
94+
95+
96+
def _test_redis_connection_with_ping(redis_client):
97+
"""
98+
Test Redis connection by sending a PING command.
99+
100+
:param redis_client: The Redis client instance.
101+
:return: Tuple of (success: bool, message: str).
102+
"""
103+
test_logger = get_logger(__name__)
104+
105+
try:
106+
test_logger.warning("Sending PING command to Redis server...")
107+
response = redis_client.ping()
108+
109+
if response:
110+
test_logger.warning("PING successful, received PONG response.")
111+
return True, "Successfully connected and verified with PING command."
112+
113+
test_logger.warning("PING command did not receive expected response.")
114+
return False, "PING command did not receive expected response."
115+
116+
except Exception as e: # pylint: disable=broad-except
117+
error_msg = f"Failed during PING operation: {str(e)}"
118+
test_logger.warning("Error: %s", error_msg)
119+
return False, error_msg
120+
121+
122+
def redisenterprise_test_connection(cmd,
123+
resource_group_name=None,
124+
cluster_name=None,
125+
auth=None,
126+
host_name=None,
127+
access_key=None):
128+
# pylint: disable=too-many-branches, line-too-long
129+
"""
130+
Test connection to a Redis Enterprise cluster using the specified authentication method.
131+
132+
:param cmd: The command instance.
133+
:param resource_group_name: The name of the resource group (optional if host is provided).
134+
:param cluster_name: The name of the Redis Enterprise cluster (optional if host is provided).
135+
:param auth: The authentication method to use ('entra' or 'access-key').
136+
:param host_name: The Redis host name (optional, will be retrieved from cluster if not provided).
137+
:param access_key: The access key for authentication (optional, only used with access-key auth).
138+
:return: Connection test result.
139+
"""
140+
# Default port for Redis Enterprise
141+
port = 10000
142+
database_name = 'default'
143+
144+
# Parse port from hostname if provided (e.g., "myhost.redis.cache.windows.net:10000")
145+
if host_name and ':' in host_name:
146+
host_parts = host_name.rsplit(':', 1)
147+
host_name = host_parts[0]
148+
try:
149+
port = int(host_parts[1])
150+
except ValueError:
151+
raise ValidationError(f"Invalid port number in hostname: {host_parts[1]}")
152+
153+
# Infer auth method from provided parameters
154+
if access_key and not auth:
155+
auth = 'access-key'
156+
157+
# Validate parameters - host and cluster-name are mutually exclusive
158+
if host_name and cluster_name:
159+
raise MutuallyExclusiveArgumentError(
160+
"--host and --cluster-name cannot be used together.",
161+
"Use either --host (with --access-key or --auth entra) or --resource-group and --cluster-name.")
162+
163+
# Validate parameters
164+
if host_name:
165+
# Direct connection mode - host is provided
166+
# Default to Entra auth if neither access_key nor auth is provided
167+
if not access_key and not auth:
168+
auth = 'entra'
169+
else:
170+
# Azure resource mode - need resource group and cluster name
171+
if not resource_group_name or not cluster_name:
172+
raise ValidationError("Either --host or both --resource-group and --cluster-name must be provided.")
173+
174+
# Default to Entra auth if auth is not specified
175+
if not auth:
176+
auth = 'entra'
177+
178+
# Get cluster information
179+
cluster = _ClusterShow(cli_ctx=cmd.cli_ctx)(command_args={
180+
"cluster_name": cluster_name,
181+
"resource_group": resource_group_name})
182+
183+
if not cluster:
184+
raise ValidationError(f"Cluster '{cluster_name}' not found in resource group '{resource_group_name}'.")
185+
186+
# Get the hostname from the cluster
187+
host_name = cluster.get('hostName')
188+
if not host_name:
189+
raise ValidationError(f"Unable to retrieve hostname for cluster '{cluster_name}'.")
190+
191+
result = {
192+
'clusterName': cluster_name,
193+
'resourceGroup': resource_group_name,
194+
'hostName': host_name,
195+
'port': port,
196+
'databaseName': database_name,
197+
'authMethod': auth,
198+
'connectionStatus': 'NotTested',
199+
'message': ''
200+
}
201+
202+
if auth == 'entra':
203+
# Get token from current Azure CLI credentials for Redis
204+
try:
205+
from azure.cli.core._profile import Profile
206+
207+
profile = Profile(cli_ctx=cmd.cli_ctx)
208+
# Use get_raw_token with the Redis resource
209+
creds, _, _ = profile.get_raw_token(resource="https://redis.azure.com")
210+
access_token = creds[1]
211+
212+
logger.debug("Successfully obtained Entra ID token for Redis.")
213+
214+
# Create Redis connection with the token
215+
# For Entra auth, username is the object ID (oid) from the token
216+
# The password is the access token itself
217+
import jwt
218+
decoded_token = jwt.decode(access_token, options={"verify_signature": False})
219+
entra_object_id = decoded_token.get('oid', decoded_token.get('sub', 'default'))
220+
221+
logger.warning("Connecting with Entra ID user (oid): %s", entra_object_id)
222+
223+
redis_client = _get_redis_connection(
224+
host_name=host_name,
225+
port=port,
226+
password=access_token,
227+
ssl=True,
228+
username=entra_object_id
229+
)
230+
231+
logger.warning("Successfully connected to Redis at %s:%s", host_name, port)
232+
233+
# Test the connection with a PING command
234+
success, message = _test_redis_connection_with_ping(redis_client)
235+
236+
if success:
237+
result['connectionStatus'] = 'Success'
238+
result['message'] = message
239+
else:
240+
result['connectionStatus'] = 'Failed'
241+
result['message'] = message
242+
243+
except ImportError as ie:
244+
result['connectionStatus'] = 'Failed'
245+
result['message'] = (f"Required package not installed: {str(ie)}. "
246+
"Please install 'redis' and 'PyJWT' packages.")
247+
except Exception as e: # pylint: disable=broad-except
248+
result['connectionStatus'] = 'Failed'
249+
result['message'] = f'Entra authentication failed: {str(e)}'
250+
251+
elif auth == 'access-key':
252+
# Get access keys for the database
253+
try:
254+
# Use provided access key or retrieve from Azure
255+
if not access_key:
256+
if not resource_group_name or not cluster_name:
257+
raise ValidationError("--access-key is required when using --host without --resource-group and --cluster-name.")
258+
259+
keys = _DatabaseListKey(cli_ctx=cmd.cli_ctx)(command_args={
260+
"cluster_name": cluster_name,
261+
"resource_group": resource_group_name,
262+
"database_name": database_name})
263+
264+
if keys:
265+
access_key = keys.get('primaryKey') or keys.get('secondaryKey')
266+
267+
if not access_key:
268+
result['connectionStatus'] = 'Failed'
269+
result['message'] = ('Access keys authentication may be disabled. '
270+
'Enable access keys authentication or use Entra authentication.')
271+
return result
272+
273+
# Create Redis connection with the access key
274+
redis_client = _get_redis_connection(
275+
host_name=host_name,
276+
port=port,
277+
password=access_key,
278+
ssl=True
279+
)
280+
281+
logger.warning("Successfully connected to Redis at %s:%s", host_name, port)
282+
283+
# Test the connection with a PING command
284+
success, message = _test_redis_connection_with_ping(redis_client)
285+
286+
if success:
287+
result['connectionStatus'] = 'Success'
288+
result['message'] = message
289+
else:
290+
result['connectionStatus'] = 'Failed'
291+
result['message'] = message
292+
293+
except ImportError as ie:
294+
result['connectionStatus'] = 'Failed'
295+
result['message'] = f"Required package not installed: {str(ie)}. Please install 'redis' package."
296+
except Exception as e: # pylint: disable=broad-except
297+
result['connectionStatus'] = 'Failed'
298+
result['message'] = f'Failed to connect with access key: {str(e)}'
299+
300+
# Raise error on connection failure to return non-zero exit code
301+
if result['connectionStatus'] == 'Failed':
302+
from azure.cli.core.azclierror import CLIError
303+
raise CLIError(result['message'])
304+
305+
return result
306+
307+
72308
class DatabaseFlush(_DatabaseFlush):
73309

74310
@classmethod

0 commit comments

Comments
 (0)