Skip to content

Commit 44cff24

Browse files
committed
add test-connection command
1 parent 5c28c3b commit 44cff24

File tree

5 files changed

+255
-1
lines changed

5 files changed

+255
-1
lines changed

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,13 @@ 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)
252+
c.argument('cluster_name', options_list=['--cluster-name', '--name', '-n'], type=str, help='The name of the '
253+
'RedisEnterprise cluster.', id_part='name')
254+
c.argument('auth', arg_type=get_enum_type(['entra', 'access-key']), help='The authentication method to use '
255+
'for the connection test. Allowed values: entra, access-key.')
256+
250257

251258
class AddPersistence(argparse.Action):
252259
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
@@ -16,6 +16,7 @@ def load_command_table(self, _): # pylint: disable=unused-argument
1616
g.custom_command('create', 'redisenterprise_create', supports_no_wait=True)
1717
g.custom_command('list', 'redisenterprise_list')
1818
g.custom_show_command('show', 'redisenterprise_show')
19+
g.custom_command('test-connection', 'redisenterprise_test_connection')
1920
with self.command_group("redisenterprise database"):
2021
from .custom import DatabaseFlush, DatabaseCreate, DatabaseDelete, DatabaseExport, DatabaseForceUnlink
2122
from .custom import DatabaseImport, DatabaseListKey, DatabaseRegenerateKey

src/redisenterprise/azext_redisenterprise/custom.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,243 @@
2727
from azure.cli.core.azclierror import (
2828
MutuallyExclusiveArgumentError,
2929
)
30+
from azure.cli.core.azclierror import ValidationError
3031

3132
logger = get_logger(__name__)
3233

3334

35+
def _get_redis_connection(host_name, port, password, ssl=True, username=None):
36+
"""
37+
Create a Redis connection using the provided credentials.
38+
39+
:param host_name: The Redis host name.
40+
:param port: The Redis port.
41+
:param password: The password or token for authentication.
42+
:param ssl: Whether to use SSL connection.
43+
:param username: The username for authentication (required for Entra ID auth).
44+
:return: Redis client instance.
45+
"""
46+
import redis
47+
return redis.Redis(
48+
host=host_name,
49+
port=port,
50+
username=username,
51+
password=password,
52+
ssl=ssl,
53+
ssl_cert_reqs=None,
54+
decode_responses=True
55+
)
56+
57+
58+
def _test_redis_connection_with_write(redis_client, test_key_prefix="az_cli_test_connection"):
59+
"""
60+
Test Redis connection by writing and reading a test key.
61+
62+
:param redis_client: The Redis client instance.
63+
:param test_key_prefix: Prefix for the test key.
64+
:return: Tuple of (success: bool, message: str, steps: list).
65+
"""
66+
import uuid
67+
import time
68+
69+
test_logger = get_logger(__name__)
70+
test_key = f"{test_key_prefix}:{uuid.uuid4()}"
71+
test_value = f"test_value_{int(time.time())}"
72+
steps = []
73+
74+
try:
75+
# Step 1: Write a test key
76+
test_logger.warning("Step 1: Writing test key '%s' with value '%s'...", test_key, test_value)
77+
redis_client.set(test_key, test_value, ex=60) # Expire in 60 seconds
78+
steps.append({'step': 1, 'action': 'write', 'status': 'success', 'key': test_key, 'value': test_value,
79+
'message': f"Successfully wrote key '{test_key}'"})
80+
test_logger.warning("Step 1: Successfully wrote test key")
81+
82+
# Step 2: Read the test key back
83+
test_logger.warning("Step 2: Reading test key '%s'...", test_key)
84+
retrieved_value = redis_client.get(test_key)
85+
steps.append({'step': 2, 'action': 'read', 'status': 'success', 'key': test_key, 'value': retrieved_value,
86+
'message': f"Successfully read key '{test_key}', value: '{retrieved_value}'"})
87+
test_logger.warning("Step 2: Successfully read test key, value: '%s'", retrieved_value)
88+
89+
# Step 3: Verify the value
90+
test_logger.warning("Step 3: Verifying value matches...")
91+
if retrieved_value == test_value:
92+
steps.append({'step': 3, 'action': 'verify', 'status': 'success',
93+
'message': 'Value verification passed'})
94+
test_logger.warning("Step 3: Value verification passed")
95+
else:
96+
steps.append({'step': 3, 'action': 'verify', 'status': 'failed',
97+
'message': f"Value mismatch: expected '{test_value}', got '{retrieved_value}'"})
98+
test_logger.warning("Step 3: Value verification failed")
99+
return False, f"Value mismatch: expected '{test_value}', got '{retrieved_value}'.", steps
100+
101+
# Step 4: Delete the test key
102+
test_logger.warning("Step 4: Deleting test key '%s'...", test_key)
103+
redis_client.delete(test_key)
104+
steps.append({'step': 4, 'action': 'delete', 'status': 'success', 'key': test_key,
105+
'message': f"Successfully deleted key '{test_key}'"})
106+
test_logger.warning("Step 4: Successfully deleted test key")
107+
108+
return True, "Successfully connected and verified write/read/delete operations.", steps
109+
110+
except Exception as e: # pylint: disable=broad-except
111+
error_msg = f"Failed during test operation: {str(e)}"
112+
test_logger.warning("Error: %s", error_msg)
113+
steps.append({'step': len(steps) + 1, 'action': 'error', 'status': 'failed', 'message': error_msg})
114+
return False, error_msg, steps
115+
116+
117+
def redisenterprise_test_connection(cmd,
118+
resource_group_name,
119+
cluster_name,
120+
auth):
121+
"""
122+
Test connection to a Redis Enterprise cluster using the specified authentication method.
123+
124+
:param cmd: The command instance.
125+
:param resource_group_name: The name of the resource group.
126+
:param cluster_name: The name of the Redis Enterprise cluster.
127+
:param auth: The authentication method to use ('entra' or 'access-key').
128+
:return: Connection test result.
129+
"""
130+
# Get cluster information
131+
cluster = _ClusterShow(cli_ctx=cmd.cli_ctx)(command_args={
132+
"cluster_name": cluster_name,
133+
"resource_group": resource_group_name})
134+
135+
if not cluster:
136+
raise ValidationError(f"Cluster '{cluster_name}' not found in resource group '{resource_group_name}'.")
137+
138+
# Get the hostname from the cluster
139+
host_name = cluster.get('hostName')
140+
if not host_name:
141+
raise ValidationError(f"Unable to retrieve hostname for cluster '{cluster_name}'.")
142+
143+
# Get the list of databases in the cluster
144+
databases = list(_DatabaseList(cli_ctx=cmd.cli_ctx)(command_args={
145+
"cluster_name": cluster_name,
146+
"resource_group": resource_group_name}))
147+
148+
if not databases:
149+
raise ValidationError(f"No databases found in cluster '{cluster_name}'. "
150+
"Please create a database before testing the connection.")
151+
152+
# Use the first database
153+
first_database = databases[0]
154+
database_name = first_database.get('name', 'default')
155+
port = first_database.get('port', 10000)
156+
157+
logger.warning("Using database: %s", database_name)
158+
159+
result = {
160+
'clusterName': cluster_name,
161+
'resourceGroup': resource_group_name,
162+
'hostName': host_name,
163+
'port': port,
164+
'databaseName': database_name,
165+
'authMethod': auth,
166+
'connectionStatus': 'NotTested',
167+
'message': ''
168+
}
169+
170+
if auth == 'entra':
171+
# Get token from current Azure CLI credentials for Redis
172+
try:
173+
from azure.cli.core._profile import Profile
174+
175+
profile = Profile(cli_ctx=cmd.cli_ctx)
176+
# Use get_raw_token with the Redis resource
177+
creds, _, _ = profile.get_raw_token(resource="https://redis.azure.com")
178+
access_token = creds[1]
179+
180+
logger.debug("Successfully obtained Entra ID token for Redis.")
181+
182+
# Create Redis connection with the token
183+
# For Entra auth, username is the object ID (oid) from the token
184+
# The password is the access token itself
185+
import jwt
186+
decoded_token = jwt.decode(access_token, options={"verify_signature": False})
187+
user_name = decoded_token.get('oid', decoded_token.get('sub', 'default'))
188+
189+
logger.warning("Connecting with Entra ID user (oid): %s", user_name)
190+
191+
redis_client = _get_redis_connection(
192+
host_name=host_name,
193+
port=port,
194+
password=access_token,
195+
ssl=True,
196+
username=user_name
197+
)
198+
199+
logger.warning("Successfully connected to Redis at %s:%s", host_name, port)
200+
201+
# Test the connection with a write operation
202+
success, message, _ = _test_redis_connection_with_write(redis_client)
203+
204+
if success:
205+
result['connectionStatus'] = 'Success'
206+
result['message'] = message
207+
else:
208+
result['connectionStatus'] = 'Failed'
209+
result['message'] = message
210+
211+
except ImportError as ie:
212+
result['connectionStatus'] = 'Failed'
213+
result['message'] = (f"Required package not installed: {str(ie)}. "
214+
"Please install 'redis' and 'PyJWT' packages.")
215+
except Exception as e: # pylint: disable=broad-except
216+
result['connectionStatus'] = 'Failed'
217+
result['message'] = f'Entra authentication failed: {str(e)}'
218+
219+
elif auth == 'access-key':
220+
# Get access keys for the database
221+
try:
222+
keys = _DatabaseListKey(cli_ctx=cmd.cli_ctx)(command_args={
223+
"cluster_name": cluster_name,
224+
"resource_group": resource_group_name,
225+
"database_name": database_name})
226+
227+
access_key = None
228+
if keys:
229+
access_key = keys.get('primaryKey') or keys.get('secondaryKey')
230+
231+
if not access_key:
232+
result['connectionStatus'] = 'Failed'
233+
result['message'] = ('Access keys authentication may be disabled. '
234+
'Enable access keys authentication or use Entra authentication.')
235+
return result
236+
237+
# Create Redis connection with the access key
238+
redis_client = _get_redis_connection(
239+
host_name=host_name,
240+
port=port,
241+
password=access_key,
242+
ssl=True
243+
)
244+
245+
logger.warning("Successfully connected to Redis at %s:%s", host_name, port)
246+
247+
# Test the connection with a write operation
248+
success, message, _ = _test_redis_connection_with_write(redis_client)
249+
250+
if success:
251+
result['connectionStatus'] = 'Success'
252+
result['message'] = message
253+
else:
254+
result['connectionStatus'] = 'Failed'
255+
result['message'] = message
256+
257+
except ImportError as ie:
258+
result['connectionStatus'] = 'Failed'
259+
result['message'] = f"Required package not installed: {str(ie)}. Please install 'redis' package."
260+
except Exception as e: # pylint: disable=broad-except
261+
result['connectionStatus'] = 'Failed'
262+
result['message'] = f'Failed to connect with access key: {str(e)}'
263+
264+
return result
265+
266+
34267
class DatabaseFlush(_DatabaseFlush):
35268

36269
@classmethod

src/redisenterprise/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
'License :: OSI Approved :: MIT License',
2727
]
2828

29-
DEPENDENCIES = []
29+
DEPENDENCIES = ['redis~=7.1.0']
3030

3131
with open('README.md', 'r', encoding='utf-8') as f:
3232
README = f.read()

0 commit comments

Comments
 (0)