diff --git a/src/redisenterprise/HISTORY.rst b/src/redisenterprise/HISTORY.rst index 7ae0f2c3dab..f607920ca1e 100644 --- a/src/redisenterprise/HISTORY.rst +++ b/src/redisenterprise/HISTORY.rst @@ -2,6 +2,9 @@ Release History =============== +1.4.0 +- add a new command `az redisenterprise test-connection` to test the connection to a cluster. + 1.3.1 - Fixed an issue where updating sku from Azure Cache for Enterprise to Azure Managed Redis SKU was not working as expected. diff --git a/src/redisenterprise/azext_redisenterprise/_help.py b/src/redisenterprise/azext_redisenterprise/_help.py index fe7e6da80e5..8f2901d8a92 100644 --- a/src/redisenterprise/azext_redisenterprise/_help.py +++ b/src/redisenterprise/azext_redisenterprise/_help.py @@ -33,3 +33,16 @@ type: command short-summary: Get information about a RedisEnterprise cluster """ + +helps['redisenterprise test-connection'] = """ + type: command + short-summary: Test connection to a Redis Enterprise cluster + long-summary: Test the connection to a Redis Enterprise cluster using the specified authentication method. + examples: + - name: Test connection using Entra authentication + text: |- + az redisenterprise test-connection --cluster-name "cache1" --resource-group "rg1" --auth entra + - name: Test connection using access key authentication + text: |- + az redisenterprise test-connection --cluster-name "cache1" --resource-group "rg1" --auth access-key +""" diff --git a/src/redisenterprise/azext_redisenterprise/_params.py b/src/redisenterprise/azext_redisenterprise/_params.py index ba40d28a126..18e63e0dbd6 100644 --- a/src/redisenterprise/azext_redisenterprise/_params.py +++ b/src/redisenterprise/azext_redisenterprise/_params.py @@ -247,6 +247,17 @@ def load_arguments(self, _): c.argument('cluster_name', options_list=['--cluster-name', '--name', '-n'], type=str, help='The name of the ' 'RedisEnterprise cluster.', id_part='name') + with self.argument_context('redisenterprise test-connection') as c: + c.argument('resource_group_name', resource_group_name_type, required=False) + c.argument('cluster_name', options_list=['--cluster-name', '--name', '-n'], type=str, help='The name of the ' + 'RedisEnterprise cluster.', id_part='name', required=False) + c.argument('auth', arg_type=get_enum_type(['entra', 'access-key']), help='The authentication method to use ' + 'for the connection test.') + c.argument('host_name', options_list=['--host', '--host-name'], type=str, help='The Redis host name (FQDN) to connect to. ' + 'If provided, --resource-group and --cluster-name are optional.') + c.argument('access_key', options_list=['--access-key'], type=str, help='The access key for authentication. ' + 'Required when using --host with access-key auth.') + class AddPersistence(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): diff --git a/src/redisenterprise/azext_redisenterprise/commands.py b/src/redisenterprise/azext_redisenterprise/commands.py index 4b37dd27138..45b5964263c 100644 --- a/src/redisenterprise/azext_redisenterprise/commands.py +++ b/src/redisenterprise/azext_redisenterprise/commands.py @@ -18,6 +18,7 @@ def load_command_table(self, _): # pylint: disable=unused-argument g.custom_show_command('show', 'redisenterprise_show') from .custom import RedisEnterpriseUpdate self.command_table["redisenterprise update"] = RedisEnterpriseUpdate(loader=self) + g.custom_command('test-connection', 'redisenterprise_test_connection') with self.command_group("redisenterprise database"): from .custom import DatabaseFlush, DatabaseCreate, DatabaseDelete, DatabaseExport, DatabaseForceUnlink from .custom import DatabaseImport, DatabaseListKey, DatabaseRegenerateKey diff --git a/src/redisenterprise/azext_redisenterprise/custom.py b/src/redisenterprise/azext_redisenterprise/custom.py index 1a56a6f883b..2f0ff02513f 100644 --- a/src/redisenterprise/azext_redisenterprise/custom.py +++ b/src/redisenterprise/azext_redisenterprise/custom.py @@ -28,6 +28,7 @@ from azure.cli.core.azclierror import ( MutuallyExclusiveArgumentError, ) +from azure.cli.core.azclierror import ValidationError logger = get_logger(__name__) @@ -69,6 +70,241 @@ def _handle_sku_change(self, current_sku, new_sku, instance): pass +def _get_redis_connection(host_name, port, password, ssl=True, username=None): + """ + Create a Redis connection using the provided credentials. + + :param host_name: The Redis host name. + :param port: The Redis port. + :param password: The password or token for authentication. + :param ssl: Whether to use SSL connection. + :param username: The username for authentication (required for Entra ID auth). + :return: Redis client instance. + """ + import redis + return redis.Redis( + host=host_name, + port=port, + username=username, + password=password, + ssl=ssl, + ssl_cert_reqs=None, + decode_responses=True + ) + + +def _test_redis_connection_with_ping(redis_client): + """ + Test Redis connection by sending a PING command. + + :param redis_client: The Redis client instance. + :return: Tuple of (success: bool, message: str). + """ + test_logger = get_logger(__name__) + + try: + test_logger.warning("Sending PING command to Redis server...") + response = redis_client.ping() + + if response: + test_logger.warning("PING successful, received PONG response.") + return True, "Successfully connected and verified with PING command." + + test_logger.warning("PING command did not receive expected response.") + return False, "PING command did not receive expected response." + + except Exception as e: # pylint: disable=broad-except + error_msg = f"Failed during PING operation: {str(e)}" + test_logger.warning("Error: %s", error_msg) + return False, error_msg + + +def redisenterprise_test_connection(cmd, + resource_group_name=None, + cluster_name=None, + auth=None, + host_name=None, + access_key=None): + # pylint: disable=too-many-branches, line-too-long + """ + Test connection to a Redis Enterprise cluster using the specified authentication method. + + :param cmd: The command instance. + :param resource_group_name: The name of the resource group (optional if host is provided). + :param cluster_name: The name of the Redis Enterprise cluster (optional if host is provided). + :param auth: The authentication method to use ('entra' or 'access-key'). + :param host_name: The Redis host name (optional, will be retrieved from cluster if not provided). + :param access_key: The access key for authentication (optional, only used with access-key auth). + :return: Connection test result. + """ + # Default port for Redis Enterprise + port = 10000 + database_name = 'default' + + # Parse port from hostname if provided (e.g., "myhost.redis.cache.windows.net:10000") + if host_name and ':' in host_name: + host_parts = host_name.rsplit(':', 1) + host_name = host_parts[0] + try: + port = int(host_parts[1]) + except ValueError: + raise ValidationError(f"Invalid port number in hostname: {host_parts[1]}") + + # Infer auth method from provided parameters + if access_key and not auth: + auth = 'access-key' + + # Validate parameters - host and cluster-name are mutually exclusive + if host_name and cluster_name: + raise MutuallyExclusiveArgumentError( + "--host and --cluster-name cannot be used together.", + "Use either --host (with --access-key or --auth entra) or --resource-group and --cluster-name.") + + # Validate parameters + if host_name: + # Direct connection mode - host is provided + # Default to Entra auth if neither access_key nor auth is provided + if not access_key and not auth: + auth = 'entra' + else: + # Azure resource mode - need resource group and cluster name + if not resource_group_name or not cluster_name: + raise ValidationError("Either --host or both --resource-group and --cluster-name must be provided.") + + # Default to Entra auth if auth is not specified + if not auth: + auth = 'entra' + + # Get cluster information + cluster = _ClusterShow(cli_ctx=cmd.cli_ctx)(command_args={ + "cluster_name": cluster_name, + "resource_group": resource_group_name}) + + if not cluster: + raise ValidationError(f"Cluster '{cluster_name}' not found in resource group '{resource_group_name}'.") + + # Get the hostname from the cluster + host_name = cluster.get('hostName') + if not host_name: + raise ValidationError(f"Unable to retrieve hostname for cluster '{cluster_name}'.") + + result = { + 'clusterName': cluster_name, + 'resourceGroup': resource_group_name, + 'hostName': host_name, + 'port': port, + 'databaseName': database_name, + 'authMethod': auth, + 'connectionStatus': 'NotTested', + 'message': '' + } + + if auth == 'entra': + # Get token from current Azure CLI credentials for Redis + try: + from azure.cli.core._profile import Profile + + profile = Profile(cli_ctx=cmd.cli_ctx) + # Use get_raw_token with the Redis resource + creds, _, _ = profile.get_raw_token(resource="https://redis.azure.com") + access_token = creds[1] + + logger.debug("Successfully obtained Entra ID token for Redis.") + + # Create Redis connection with the token + # For Entra auth, username is the object ID (oid) from the token + # The password is the access token itself + import jwt + decoded_token = jwt.decode(access_token, options={"verify_signature": False}) + entra_object_id = decoded_token.get('oid', decoded_token.get('sub', 'default')) + + logger.warning("Connecting with Entra ID user (oid): %s", entra_object_id) + + redis_client = _get_redis_connection( + host_name=host_name, + port=port, + password=access_token, + ssl=True, + username=entra_object_id + ) + + logger.warning("Successfully connected to Redis at %s:%s", host_name, port) + + # Test the connection with a PING command + success, message = _test_redis_connection_with_ping(redis_client) + + if success: + result['connectionStatus'] = 'Success' + result['message'] = message + else: + result['connectionStatus'] = 'Failed' + result['message'] = message + + except ImportError as ie: + result['connectionStatus'] = 'Failed' + result['message'] = (f"Required package not installed: {str(ie)}. " + "Please install 'redis' and 'PyJWT' packages.") + except Exception as e: # pylint: disable=broad-except + result['connectionStatus'] = 'Failed' + result['message'] = f'Entra authentication failed: {str(e)}' + + elif auth == 'access-key': + # Get access keys for the database + try: + # Use provided access key or retrieve from Azure + if not access_key: + if not resource_group_name or not cluster_name: + raise ValidationError("--access-key is required when using --host without --resource-group and --cluster-name.") + + keys = _DatabaseListKey(cli_ctx=cmd.cli_ctx)(command_args={ + "cluster_name": cluster_name, + "resource_group": resource_group_name, + "database_name": database_name}) + + if keys: + access_key = keys.get('primaryKey') or keys.get('secondaryKey') + + if not access_key: + result['connectionStatus'] = 'Failed' + result['message'] = ('Access keys authentication may be disabled. ' + 'Enable access keys authentication or use Entra authentication.') + return result + + # Create Redis connection with the access key + redis_client = _get_redis_connection( + host_name=host_name, + port=port, + password=access_key, + ssl=True + ) + + logger.warning("Successfully connected to Redis at %s:%s", host_name, port) + + # Test the connection with a PING command + success, message = _test_redis_connection_with_ping(redis_client) + + if success: + result['connectionStatus'] = 'Success' + result['message'] = message + else: + result['connectionStatus'] = 'Failed' + result['message'] = message + + except ImportError as ie: + result['connectionStatus'] = 'Failed' + result['message'] = f"Required package not installed: {str(ie)}. Please install 'redis' package." + except Exception as e: # pylint: disable=broad-except + result['connectionStatus'] = 'Failed' + result['message'] = f'Failed to connect with access key: {str(e)}' + + # Raise error on connection failure to return non-zero exit code + if result['connectionStatus'] == 'Failed': + from azure.cli.core.azclierror import CLIError + raise CLIError(result['message']) + + return result + + class DatabaseFlush(_DatabaseFlush): @classmethod diff --git a/src/redisenterprise/azext_redisenterprise/tests/latest/test_test_connection.py b/src/redisenterprise/azext_redisenterprise/tests/latest/test_test_connection.py new file mode 100644 index 00000000000..c7f1803c9ad --- /dev/null +++ b/src/redisenterprise/azext_redisenterprise/tests/latest/test_test_connection.py @@ -0,0 +1,172 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os + +from azure.cli.testsdk import ScenarioTest, ResourceGroupPreparer +from azure.cli.testsdk.scenario_tests import AllowLargeResponse, live_only + +from .. import ( + try_manual, + raise_if, + calc_coverage +) + +TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) + + +# Env setup +@try_manual +def setup_test_connection_scenario(test): + pass + + +# Env cleanup +@try_manual +def cleanup_test_connection_scenario(test): + pass + + +# Step: Create Redis Enterprise cluster with B1 SKU, public network access, and access key auth +@try_manual +def step_create_cluster_with_b1_sku(test, checks=None): + if checks is None: + checks = [] + test.cmd('az redisenterprise create ' + '--cluster-name "{cluster}" ' + '--sku "Balanced_B1" ' + '--location "centraluseuap" ' + '--tags tag1="value1" ' + '--public-network-access "Enabled" ' + '--access-keys-auth Enabled ' + '--minimum-tls-version "1.2" ' + '--client-protocol "Encrypted" ' + '--clustering-policy "EnterpriseCluster" ' + '--eviction-policy "NoEviction" ' + '--port 10000 ' + '--resource-group "{rg}"', + checks=checks) + + +# Step: Show cluster details +@try_manual +def step_show_cluster(test, checks=None): + if checks is None: + checks = [] + test.cmd('az redisenterprise show ' + '--cluster-name "{cluster}" ' + '--resource-group "{rg}"', + checks=checks) + + +# Step: List database keys +@try_manual +def step_database_list_keys(test, checks=None): + if checks is None: + checks = [] + test.cmd('az redisenterprise database list-keys ' + '--cluster-name "{cluster}" ' + '--resource-group "{rg}"', + checks=checks) + + +# Step: Test connection with access-key authentication +@try_manual +def step_test_connection_access_key(test, checks=None): + if checks is None: + checks = [] + test.cmd('az redisenterprise test-connection ' + '--cluster-name "{cluster}" ' + '--resource-group "{rg}" ' + '--auth "access-key"', + checks=checks) + + +# Step: Delete cluster +@try_manual +def step_delete_cluster(test, checks=None): + if checks is None: + checks = [] + test.cmd('az redisenterprise delete -y ' + '--cluster-name "{cluster}" ' + '--resource-group "{rg}"', + checks=checks) + + +# Testcase: Test Connection Scenario +def call_test_connection_scenario(test, rg): + setup_test_connection_scenario(test) + + # Step 1: Create Redis Enterprise cluster with B1 SKU, public network access, and access keys enabled + step_create_cluster_with_b1_sku(test, checks=[ + test.check("name", "default"), + test.check("resourceGroup", "{rg}"), + test.check("clientProtocol", "Encrypted"), + test.check("clusteringPolicy", "EnterpriseCluster"), + test.check("evictionPolicy", "NoEviction"), + test.check("accessKeysAuthentication", "Enabled"), + test.check("port", 10000), + test.check("provisioningState", "Succeeded"), + test.check("resourceState", "Running"), + test.check("type", "Microsoft.Cache/redisEnterprise/databases") + ]) + + # Step 2: Verify the cluster is created with correct settings + step_show_cluster(test, checks=[ + test.check("name", "{cluster}"), + test.check("resourceGroup", "{rg}"), + test.check("location", "Central US EUAP"), + test.check("sku.name", "Balanced_B1"), + test.check("tags.tag1", "value1"), + test.check("minimumTlsVersion", "1.2"), + test.check("provisioningState", "Succeeded"), + test.check("resourceState", "Running"), + test.check("type", "Microsoft.Cache/redisEnterprise"), + test.check("databases[0].accessKeysAuthentication", "Enabled"), + test.check("databases[0].clientProtocol", "Encrypted") + ]) + + # Step 3: Verify database keys are available + step_database_list_keys(test, checks=[ + test.exists("primaryKey"), + test.exists("secondaryKey") + ]) + + # Step 4: Test connection using access-key authentication + step_test_connection_access_key(test, checks=[ + test.check("connectionStatus", "Success"), + test.check("authMethod", "access-key"), + test.check("clusterName", "{cluster}"), + test.check("resourceGroup", "{rg}"), + test.check("port", 10000), + test.check("databaseName", "default") + ]) + + # Step 5: Cleanup - delete the cluster + step_delete_cluster(test, checks=[]) + + cleanup_test_connection_scenario(test) + + +# Test class for test-connection scenario +class RedisEnterpriseTestConnectionScenarioTest(ScenarioTest): + + def __init__(self, *args, **kwargs): + super(RedisEnterpriseTestConnectionScenarioTest, self).__init__(*args, **kwargs) + + self.kwargs.update({ + 'cluster': self.create_random_name(prefix='clitest-tc-', length=21) + }) + + @AllowLargeResponse(size_kb=9999) + @ResourceGroupPreparer(name_prefix='clitest-redisenterprise-tc-', key='rg', parameter_name='rg', + location='eastasia', random_name_length=34) + @live_only() + def test_redisenterprise_test_connection(self, rg): + call_test_connection_scenario(self, rg) + calc_coverage(__file__) + raise_if() + + diff --git a/src/redisenterprise/setup.py b/src/redisenterprise/setup.py index e44019bc8ac..d3539637d25 100644 --- a/src/redisenterprise/setup.py +++ b/src/redisenterprise/setup.py @@ -10,7 +10,7 @@ # HISTORY.rst entry. -VERSION = '1.3.1' +VERSION = '1.4.0' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers @@ -26,7 +26,7 @@ 'License :: OSI Approved :: MIT License', ] -DEPENDENCIES = [] +DEPENDENCIES = ['redis>=7.1.0,<8.0.0', 'PyJWT>=2.1.0'] with open('README.md', 'r', encoding='utf-8') as f: README = f.read()