diff --git a/application/single_app/app.py b/application/single_app/app.py index e91d04577..36afa5685 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -176,7 +176,7 @@ def register_route_blueprint(name, registrar, auth_guard=None): register_swagger_routes(app) from flask_session import Session -from redis import Redis +import functions_redis_client from functions_settings import get_settings from functions_authentication import get_current_user_id from functions_global_agents import ensure_default_global_agent_exists @@ -221,41 +221,18 @@ def configure_sessions(settings): try: if redis_auth_type == 'managed_identity': log_event("Redis enabled using Managed Identity", level=logging.INFO) - redis_client = app_settings_cache.create_redis_managed_identity_client( - redis_url, - settings=settings, - socket_connect_timeout=5, - socket_timeout=5 - ) elif redis_auth_type == 'key_vault': log_event("Redis enabled using Key Vault Secret", level=logging.INFO) - from functions_keyvault import retrieve_secret_direct - redis_key_secret_name = settings.get('redis_key', '').strip() - redis_password = retrieve_secret_direct(redis_key_secret_name) - if redis_password: - redis_password = redis_password.strip() - redis_client = Redis( - host=redis_url, - port=6380, - db=0, - password=redis_password, - ssl=True, - socket_connect_timeout=5, - socket_timeout=5 - ) else: - redis_key = settings.get('redis_key', '').strip() log_event("Redis enabled using Access Key", level=logging.INFO) - redis_client = Redis( - host=redis_url, - port=6380, - db=0, - password=redis_key, - ssl=True, - socket_connect_timeout=5, - socket_timeout=5 - ) - + + redis_client = functions_redis_client.create_redis_client( + settings=settings, + credential_purpose=functions_redis_client.CREDENTIAL_PURPOSE_SESSION, + socket_connect_timeout=5, + socket_timeout=5 + ) + # Test the connection redis_client.ping() log_event("✅ Redis connection successful", level=logging.INFO) diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py index 7067bfb37..f271690b9 100644 --- a/application/single_app/app_settings_cache.py +++ b/application/single_app/app_settings_cache.py @@ -7,14 +7,17 @@ import json import logging import copy -import base64 -import os import threading import time from datetime import datetime, timedelta -from redis import Redis -from redis.credentials import CredentialProvider -from azure.identity import DefaultAzureCredential + +# Redis client construction lives in functions_redis_client so session, cache, and admin +# diagnostics code paths share one place that resolves service type, port, and credentials. +from functions_redis_client import ( + AUTH_TYPE_MANAGED_IDENTITY, + CREDENTIAL_PURPOSE_APP_CACHE, + create_redis_client, +) # NOTE: functions_keyvault is imported locally inside configure_app_cache to avoid a circular # import (functions_keyvault -> app_settings_cache -> functions_keyvault). @@ -22,8 +25,6 @@ _settings = None _logger = logging.getLogger(__name__) -REDIS_ENTRA_TOKEN_SCOPE = 'https://redis.azure.com/.default' -REDIS_TOKEN_REFRESH_BUFFER_SECONDS = 300 APP_SETTINGS_CACHE = {} APP_USER_UI_SETTINGS_CACHE = {} APP_STREAM_SESSION_METADATA = {} @@ -63,61 +64,16 @@ _app_cache_lock = threading.Lock() -def _get_redis_entra_token_scope(settings=None): - configured_scope = (settings or {}).get('redis_entra_token_scope') or os.getenv('REDIS_ENTRA_TOKEN_SCOPE') - return (configured_scope or REDIS_ENTRA_TOKEN_SCOPE).strip() - - -def _decode_token_claims(access_token): - parts = access_token.split('.') - if len(parts) < 2: - raise ValueError('Redis Microsoft Entra token did not contain JWT claims.') - - payload = parts[1] - payload += '=' * (-len(payload) % 4) - decoded_payload = base64.urlsafe_b64decode(payload.encode('utf-8')).decode('utf-8') - return json.loads(decoded_payload) - - -def _get_redis_username_from_claims(access_token): - claims = _decode_token_claims(access_token) - username = claims.get('oid') or claims.get('appid') - if not username: - raise ValueError('Redis Microsoft Entra token did not include an object ID claim.') - return username - - -class RedisManagedIdentityCredentialProvider(CredentialProvider): - """Provides Redis ACL username and Microsoft Entra token credentials.""" - - def __init__(self, credential=None, scope=None): - self.credential = credential or DefaultAzureCredential() - self.scope = scope or REDIS_ENTRA_TOKEN_SCOPE - self._cached_credentials = None - self._expires_on = 0 - - def get_credentials(self): - now = time.time() - if self._cached_credentials and now < self._expires_on - REDIS_TOKEN_REFRESH_BUFFER_SECONDS: - return self._cached_credentials - - token = self.credential.get_token(self.scope) - username = _get_redis_username_from_claims(token.token) - self._cached_credentials = (username, token.token) - self._expires_on = token.expires_on - return self._cached_credentials - - def create_redis_managed_identity_client(redis_url, settings=None, **redis_kwargs): - credential_provider = RedisManagedIdentityCredentialProvider( - scope=_get_redis_entra_token_scope(settings) - ) - return Redis( - host=redis_url, - port=6380, - db=0, - credential_provider=credential_provider, - ssl=True, + """Build a managed identity Redis client for the configured Azure Redis service. + + Retained as a thin wrapper so existing callers keep working; the port, TLS, and + credential provider are resolved by functions_redis_client. + """ + return create_redis_client( + settings=settings, + redis_url=redis_url, + auth_type=AUTH_TYPE_MANAGED_IDENTITY, **redis_kwargs ) @@ -707,39 +663,17 @@ def configure_app_cache(settings, redis_cache_endpoint=None): raise ValueError('Redis cache is enabled but redis_url is empty.') if redis_auth_type == 'managed_identity': log_event("[ASC] Redis enabled using Managed Identity", level=logging.INFO) - redis_client = create_redis_managed_identity_client( - redis_url, - settings=settings - ) elif redis_auth_type == 'key_vault': log_event("[ASC] Redis enabled using Key Vault Secret", level=logging.INFO) - # Local import to avoid circular dependency: functions_keyvault imports app_settings_cache. - from functions_keyvault import retrieve_secret_direct - redis_key_secret_name = settings.get('redis_key', '').strip() - # Pass settings directly: get_settings_cache() is still None at this point - # because configure_app_cache has not finished initialising the cache yet. - redis_password = retrieve_secret_direct(redis_key_secret_name, settings=settings) - if redis_password: - redis_password = redis_password.strip() - log_event("[ASC] Redis key retrieved from Key Vault successfully", level=logging.INFO) - - redis_client = Redis( - host=redis_url, - port=6380, - db=0, - password=redis_password, - ssl=True - ) else: - redis_key = settings.get('redis_key', '').strip() log_event("[ASC] Redis enabled using Access Key", level=logging.INFO) - redis_client = Redis( - host=redis_url, - port=6380, - db=0, - password=redis_key, - ssl=True - ) + + # Pass settings directly: get_settings_cache() is still None at this point + # because configure_app_cache has not finished initialising the cache yet. + redis_client = create_redis_client( + settings=settings, + credential_purpose=CREDENTIAL_PURPOSE_APP_CACHE, + ) app_cache_is_using_redis = True APP_REDIS_CLIENT = redis_client except Exception as redis_init_error: diff --git a/application/single_app/config.py b/application/single_app/config.py index a6bb61a3a..64cd23643 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.009" +VERSION = "0.261.011" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_redis_client.py b/application/single_app/functions_redis_client.py new file mode 100644 index 000000000..644bea6af --- /dev/null +++ b/application/single_app/functions_redis_client.py @@ -0,0 +1,366 @@ +# functions_redis_client.py +"""Redis client construction shared by session, cache, and admin diagnostics code paths. + +SimpleChat supports two Azure Redis offerings side by side: + +* Azure Cache for Redis (``*.redis.cache.windows.net`` and sovereign equivalents), which + listens for TLS traffic on port 6380. +* Azure Managed Redis (``*..redis.azure.net``), which listens on port 10000. + +The service in use is detected from the configured host name so existing deployments keep +working untouched, and administrators can override the detection when a custom DNS name or +private endpoint hides the Azure suffix. + +NOTE: This module intentionally avoids importing ``config`` and ``functions_settings`` at +module scope. ``app_settings_cache`` imports this module during early application start up, +before those modules are ready, and ``functions_keyvault`` imports ``app_settings_cache``. +Both are imported locally inside the functions that need them. +""" + +import base64 +import json +import logging +import os +import threading +import time + +from redis import Redis +from redis.credentials import CredentialProvider +from azure.identity import DefaultAzureCredential + +SERVICE_TYPE_AUTO = 'auto' +SERVICE_TYPE_AZURE_CACHE_FOR_REDIS = 'azure_cache_for_redis' +SERVICE_TYPE_AZURE_MANAGED_REDIS = 'azure_managed_redis' + +SUPPORTED_SERVICE_TYPES = ( + SERVICE_TYPE_AUTO, + SERVICE_TYPE_AZURE_CACHE_FOR_REDIS, + SERVICE_TYPE_AZURE_MANAGED_REDIS, +) + +AZURE_CACHE_FOR_REDIS_PORT = 6380 +AZURE_MANAGED_REDIS_PORT = 10000 + +# Azure Managed Redis and the retiring Azure Cache for Redis Enterprise tiers both run the +# Redis Enterprise stack and both answer on port 10000. +AZURE_MANAGED_REDIS_HOST_SUFFIXES = ( + '.redis.azure.net', + '.redisenterprise.cache.azure.net', +) + +AZURE_CACHE_FOR_REDIS_HOST_SUFFIXES = ( + '.redis.cache.windows.net', + '.redis.cache.usgovcloudapi.net', + '.redis.cache.chinacloudapi.cn', +) + +REDIS_ENTRA_TOKEN_SCOPE = 'https://redis.azure.com/.default' +REDIS_TOKEN_REFRESH_BUFFER_SECONDS = 300 + +AUTH_TYPE_MANAGED_IDENTITY = 'managed_identity' +AUTH_TYPE_KEY_VAULT = 'key_vault' +AUTH_TYPE_KEY = 'key' + +# Long-lived clients that each need their own streaming credential provider. redis-entraid's +# provider keeps a single re-authentication callback slot, so two clients sharing one provider +# would leave the first client's pooled connections without proactive re-AUTH. +CREDENTIAL_PURPOSE_APP_CACHE = 'app_cache' +CREDENTIAL_PURPOSE_SESSION = 'session' +DEFAULT_CREDENTIAL_PURPOSE = CREDENTIAL_PURPOSE_APP_CACHE + +_logger = logging.getLogger(__name__) + +_streaming_credential_providers = {} +_streaming_credential_provider_lock = threading.Lock() + + +def normalize_redis_host(redis_url): + """Return a bare Redis host name, tolerating scheme and port decorations.""" + host = str(redis_url or '').strip() + if not host: + return '' + if '://' in host: + host = host.split('://', 1)[1] + host = host.split('/', 1)[0] + # Strip a trailing ":" but leave bracketed IPv6 literals alone. + if ']' not in host and host.count(':') == 1: + host = host.split(':', 1)[0] + return host.strip().rstrip('.').lower() + + +def detect_redis_service_type(redis_url): + """Infer the Azure Redis offering from a host name suffix. + + Returns ``SERVICE_TYPE_AUTO`` when the host name does not match a documented Azure + suffix, which lets callers fall back to the historical Azure Cache for Redis behavior. + """ + host = normalize_redis_host(redis_url) + if not host: + return SERVICE_TYPE_AUTO + if host.endswith(AZURE_MANAGED_REDIS_HOST_SUFFIXES): + return SERVICE_TYPE_AZURE_MANAGED_REDIS + if host.endswith(AZURE_CACHE_FOR_REDIS_HOST_SUFFIXES): + return SERVICE_TYPE_AZURE_CACHE_FOR_REDIS + return SERVICE_TYPE_AUTO + + +def resolve_redis_service_type(settings=None, redis_url=None): + """Resolve the effective Redis service type from settings, then host name detection.""" + source = settings or {} + configured = str(source.get('redis_service_type') or '').strip().lower() + if configured in (SERVICE_TYPE_AZURE_CACHE_FOR_REDIS, SERVICE_TYPE_AZURE_MANAGED_REDIS): + return configured + + host = redis_url if redis_url is not None else source.get('redis_url') + detected = detect_redis_service_type(host) + if detected != SERVICE_TYPE_AUTO: + return detected + + # Unrecognized host names keep the pre-Managed-Redis behavior so existing deployments + # that front Azure Cache for Redis with a custom DNS name are unaffected. + return SERVICE_TYPE_AZURE_CACHE_FOR_REDIS + + +def resolve_redis_port(settings=None, redis_url=None, service_type=None): + """Resolve the TLS port for the configured Redis service, honoring an admin override.""" + source = settings or {} + configured_port = str(source.get('redis_port') or '').strip() + if configured_port: + try: + port = int(configured_port) + except (TypeError, ValueError): + _logger.warning('Ignoring non-numeric redis_port override: %r', configured_port) + else: + if 1 <= port <= 65535: + return port + _logger.warning('Ignoring out-of-range redis_port override: %r', configured_port) + + effective_service_type = service_type or resolve_redis_service_type(source, redis_url=redis_url) + if effective_service_type == SERVICE_TYPE_AZURE_MANAGED_REDIS: + return AZURE_MANAGED_REDIS_PORT + return AZURE_CACHE_FOR_REDIS_PORT + + +def get_redis_entra_token_scope(settings=None): + """Return the Microsoft Entra scope used to authenticate against Redis.""" + configured_scope = (settings or {}).get('redis_entra_token_scope') or os.getenv('REDIS_ENTRA_TOKEN_SCOPE') + return (configured_scope or REDIS_ENTRA_TOKEN_SCOPE).strip() + + +def get_entra_authority(): + """Return the Microsoft Entra authority host for the active Azure environment.""" + try: + from config import authority + except Exception: + return None + normalized_authority = str(authority or '').strip() + return normalized_authority or None + + +def _build_streaming_credential_provider(scope, authority_host): + """Create a redis-entraid streaming provider that re-AUTHs pooled connections.""" + from redis_entraid.cred_provider import create_from_default_azure_credential + + return create_from_default_azure_credential( + (scope,), + authority=authority_host, + ) + + +def get_redis_credential_provider(settings=None, streaming=True, purpose=DEFAULT_CREDENTIAL_PURPOSE): + """Return a redis-py credential provider for Microsoft Entra authentication. + + ``streaming=True`` returns a ``redis-entraid`` provider that renews the Entra token in the + background and re-issues ``AUTH`` on live pooled connections. One provider is cached per + ``purpose`` because redis-entraid holds a single re-authentication callback slot: handing + the same provider to two clients would leave the first client's pool without proactive + re-AUTH, and creating one per call would leak a thread on every reconfiguration. + + ``streaming=False`` returns the connect-time-only provider, which starts no background + refresh thread. Ad-hoc diagnostic connections use it so an admin clicking "Test" cannot + accumulate threads, event loops, and recurring token requests for the life of the worker. + + Falls back to the in-repo credential provider when ``redis-entraid`` is unavailable, so + an application updated without reinstalling requirements still starts. + """ + scope = get_redis_entra_token_scope(settings) + authority_host = get_entra_authority() + + if not streaming: + return _build_fallback_credential_provider(scope) + + provider_key = (str(purpose or DEFAULT_CREDENTIAL_PURPOSE), scope, authority_host) + with _streaming_credential_provider_lock: + cached_provider = _streaming_credential_providers.get(provider_key) + if cached_provider is not None: + return cached_provider + + try: + provider = _build_streaming_credential_provider(scope, authority_host) + except Exception as provider_error: + _logger.warning( + 'redis-entraid credential provider unavailable, falling back: %s', + provider_error, + ) + provider = _build_fallback_credential_provider(scope) + + # A scope or authority change means the previous provider for this purpose is stale. + for stale_key in [key for key in _streaming_credential_providers if key[0] == provider_key[0]]: + _streaming_credential_providers.pop(stale_key, None) + _streaming_credential_providers[provider_key] = provider + return provider + + +def _decode_token_claims(access_token): + parts = access_token.split('.') + if len(parts) < 2: + raise ValueError('Redis Microsoft Entra token did not contain JWT claims.') + + payload = parts[1] + payload += '=' * (-len(payload) % 4) + decoded_payload = base64.urlsafe_b64decode(payload.encode('utf-8')).decode('utf-8') + return json.loads(decoded_payload) + + +def _get_redis_username_from_claims(access_token): + claims = _decode_token_claims(access_token) + username = claims.get('oid') or claims.get('appid') + if not username: + raise ValueError('Redis Microsoft Entra token did not include an object ID claim.') + return username + + +class RedisManagedIdentityCredentialProvider(CredentialProvider): + """Provides Redis ACL username and Microsoft Entra token credentials. + + Used only when ``redis-entraid`` is unavailable. Unlike the redis-entraid provider this + supplies credentials at connect time only, so a pooled connection is re-authenticated + when the server drops it rather than proactively before token expiry. + """ + + def __init__(self, credential=None, scope=None): + self.credential = credential or DefaultAzureCredential() + self.scope = scope or REDIS_ENTRA_TOKEN_SCOPE + self._cached_credentials = None + self._expires_on = 0 + + def get_credentials(self): + now = time.time() + if self._cached_credentials and now < self._expires_on - REDIS_TOKEN_REFRESH_BUFFER_SECONDS: + return self._cached_credentials + + token = self.credential.get_token(self.scope) + username = _get_redis_username_from_claims(token.token) + self._cached_credentials = (username, token.token) + self._expires_on = token.expires_on + return self._cached_credentials + + +def _build_fallback_credential_provider(scope): + """Return the in-repo credential provider used when redis-entraid is missing.""" + return RedisManagedIdentityCredentialProvider(scope=scope) + + +def reset_redis_credential_provider_cache(): + """Drop cached streaming credential providers so the next call rebuilds them.""" + with _streaming_credential_provider_lock: + _streaming_credential_providers.clear() + + +def resolve_redis_password(settings=None, auth_type=None, redis_key=None): + """Return the password for key or Key Vault authentication.""" + source = settings or {} + normalized_auth_type = str( + auth_type if auth_type is not None else source.get('redis_auth_type') or AUTH_TYPE_KEY + ).strip().lower() + secret_value = str(redis_key if redis_key is not None else source.get('redis_key') or '').strip() + + if normalized_auth_type == AUTH_TYPE_KEY_VAULT: + if not secret_value: + raise ValueError('Key Vault secret name is required for Key Vault authentication.') + # Local import to avoid a circular dependency at module load time. + from functions_keyvault import retrieve_secret_direct + + password = retrieve_secret_direct(secret_value, settings=source) + if not password: + raise ValueError('Key Vault returned an empty Redis access key.') + return password.strip() + + if not secret_value: + raise ValueError('Redis access key is required for key authentication.') + return secret_value + + +def create_redis_client( + settings=None, + redis_url=None, + auth_type=None, + redis_key=None, + streaming_credentials=True, + credential_purpose=DEFAULT_CREDENTIAL_PURPOSE, + **redis_kwargs +): + """Build a ``redis.Redis`` client for either Azure Redis offering. + + Host name, authentication type, and access key default to the values in ``settings`` but + can be overridden so the admin connection test can validate unsaved form input. + + ``credential_purpose`` identifies the long-lived client being built so each one receives + its own streaming credential provider; see ``get_redis_credential_provider``. + """ + source = settings or {} + host = normalize_redis_host(redis_url if redis_url is not None else source.get('redis_url')) + if not host: + raise ValueError('Redis host name is required.') + + service_type = resolve_redis_service_type(source, redis_url=host) + port = resolve_redis_port(source, redis_url=host, service_type=service_type) + normalized_auth_type = str( + auth_type if auth_type is not None else source.get('redis_auth_type') or AUTH_TYPE_KEY + ).strip().lower() + + client_kwargs = { + 'host': host, + 'port': port, + # Azure Managed Redis exposes a single database; redis-py only emits SELECT for a + # non-zero index, so db=0 is correct for both services. + 'db': 0, + 'ssl': True, + } + client_kwargs.update(redis_kwargs) + + if normalized_auth_type == AUTH_TYPE_MANAGED_IDENTITY: + client_kwargs['credential_provider'] = get_redis_credential_provider( + source, + streaming=streaming_credentials, + purpose=credential_purpose, + ) + else: + client_kwargs['password'] = resolve_redis_password( + source, + auth_type=normalized_auth_type, + redis_key=redis_key, + ) + + return Redis(**client_kwargs) + + +def describe_redis_connection(settings=None, redis_url=None): + """Return non-sensitive connection facts for diagnostics and admin monitoring.""" + source = settings or {} + host = normalize_redis_host(redis_url if redis_url is not None else source.get('redis_url')) + service_type = resolve_redis_service_type(source, redis_url=host) + return { + 'host': host, + 'service_type': service_type, + 'service_type_detected': detect_redis_service_type(host), + 'service_type_source': ( + 'setting' + if str(source.get('redis_service_type') or '').strip().lower() in ( + SERVICE_TYPE_AZURE_CACHE_FOR_REDIS, + SERVICE_TYPE_AZURE_MANAGED_REDIS, + ) + else 'detected' + ), + 'port': resolve_redis_port(source, redis_url=host, service_type=service_type), + } diff --git a/application/single_app/functions_redis_monitoring.py b/application/single_app/functions_redis_monitoring.py index e029fddff..79bd2bfd6 100644 --- a/application/single_app/functions_redis_monitoring.py +++ b/application/single_app/functions_redis_monitoring.py @@ -6,6 +6,7 @@ import time import app_settings_cache +import functions_redis_client REDIS_MONITORING_STATUS_DISABLED = "disabled" @@ -621,6 +622,13 @@ def get_redis_monitoring_status( enabled = bool(safe_settings.get("enable_redis_cache")) configured = bool(str(safe_settings.get("redis_url") or "").strip()) auth_type = str(safe_settings.get("redis_auth_type") or "key").strip().lower() or "key" + # Without a host name there is nothing to resolve, so report the service as unknown + # rather than showing the Azure Cache for Redis fallback used for connection attempts. + connection = ( + functions_redis_client.describe_redis_connection(safe_settings) + if configured + else {"service_type": None, "service_type_source": None, "port": None} + ) resolved_app_cache_client = ( app_cache_client if app_cache_client is not None @@ -640,6 +648,9 @@ def get_redis_monitoring_status( "enabled": enabled, "configured": configured, "auth_type": auth_type, + "service_type": connection["service_type"], + "service_type_source": connection["service_type_source"], + "port": connection["port"], }, "runtime": { "app_cache_using_redis": app_cache_using_redis, diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index d92b1e1b7..431e22a01 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1534,6 +1534,8 @@ def get_settings(use_cosmos=False, include_source=False): 'redis_url': '', 'redis_key': '', 'redis_auth_type': '', + 'redis_service_type': 'auto', + 'redis_port': '', # App Maintenance Settings 'enable_app_maintenance': True, diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index 2f7d57d84..7dc5de602 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -14,7 +14,7 @@ Markdown==3.8.1 bleach==6.4.0 defusedxml==0.7.1 azure-cosmos==4.9.0 -msal==1.31.0 +msal==1.33.0 Flask-Session==0.8.0 azure-ai-documentintelligence==1.0.2 numpy==2.1.1 @@ -30,7 +30,7 @@ azure-ai-agents==1.2.0b6 pyjwt==2.13.0 markdown2==2.5.5 azure-mgmt-cognitiveservices==13.6.0 -azure-identity==1.23.0 +azure-identity==1.24.0 azure-ai-contentsafety==1.0.0 azure-storage-blob==12.24.1 azure-storage-file-share==12.25.0 @@ -54,6 +54,7 @@ tableauserverclient==0.40 yamcs-client==2.1.0 protobuf==6.33.5 redis==5.3.1 +redis-entraid==1.2.1 smbprotocol==1.15.0 pyodbc==5.3.0 PyMySQL==1.1.2 diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index 9f582f7ec..1e6e4aa23 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -43,7 +43,6 @@ from azure.keyvault.secrets import SecretClient from swagger_wrapper import swagger_route, get_auth_security import logging -import redis import time import uuid @@ -1642,47 +1641,71 @@ def _test_gpt_connection(payload): def _test_redis_connection(payload): """ - Attempts to connect to Azure Redis using key or managed identity auth. - Performs a simple SET/GET round-trip test. + Attempts to connect to Azure Cache for Redis or Azure Managed Redis using the + credentials supplied by the admin form, then performs a SET/GET round trip. """ + import functions_redis_client + redis_host = payload.get('endpoint', '').strip() redis_key = payload.get('key', '').strip() redis_auth_type = payload.get('auth_type', 'key').strip() + redis_service_type = payload.get('service_type', '').strip() + redis_port = payload.get('port', '').strip() if not redis_host: return jsonify({'error': 'Redis host is required'}), 400 + if redis_auth_type == 'key_vault' and not redis_key: + return jsonify({'error': 'Key Vault secret name is required for Key Vault authentication'}), 400 + if redis_auth_type == 'key' and not redis_key: + return jsonify({'error': 'Redis key is required for key authentication'}), 400 + + settings = get_settings() + test_settings = dict(settings) + test_settings.update({ + 'redis_url': redis_host, + 'redis_auth_type': redis_auth_type, + 'redis_key': redis_key, + 'redis_service_type': redis_service_type or 'auto', + 'redis_port': redis_port, + }) + try: - if redis_auth_type == 'managed_identity': - # Acquire token from managed identity for Redis scope - from config import get_redis_cache_infrastructure_endpoint - credential = DefaultAzureCredential() - redis_hostname = redis_host.split('.')[0] - cache_endpoint = get_redis_cache_infrastructure_endpoint(redis_hostname) - token = credential.get_token(cache_endpoint) - redis_password = token.token - elif redis_auth_type == 'key_vault': - if not redis_key: - return jsonify({'error': 'Key Vault secret name is required for Key Vault authentication'}), 400 - try: - from functions_keyvault import retrieve_secret_direct - redis_password = retrieve_secret_direct(redis_key) - except Exception as kv_err: - log_event(f"[REDIS_TEST] Key Vault retrieval failed for secret '{redis_key}': {str(kv_err)}", level="error") - return jsonify({'error': 'Failed to retrieve Redis key from Key Vault. Check Application Insights using "[REDIS_TEST]" for details.'}), 500 - else: - if not redis_key: - return jsonify({'error': 'Redis key is required for key authentication'}), 400 - redis_password = redis_key - - r = redis.Redis( - host=redis_host, - port=6380, - password=redis_password, - ssl=True, + # streaming_credentials=False keeps this ad-hoc test from starting a background + # token refresh thread every time an admin clicks Test. + r = functions_redis_client.create_redis_client( + settings=test_settings, + streaming_credentials=False, socket_connect_timeout=5 ) + except ValueError as validation_error: + # The factory raises ValueError for missing host, key, or Key Vault secret name. The + # route already returns a specific 400 for each of those above, so anything reaching + # here is unexpected. Log the exception type and let the traceback carry the detail + # rather than interpolating a message that resolved credentials may have touched. + log_event( + f"[REDIS_TEST] Redis settings validation failed ({type(validation_error).__name__}).", + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({ + 'error': 'Redis settings are incomplete. Check the host name, service, port, and credential fields.' + }), 400 + except Exception as client_error: + # Client construction resolves credentials, so the message can carry Key Vault secret + # names, vault URIs, or token details. Record the type plus the traceback and keep + # both the log message and the response free of the resolved secret material. + log_event( + f"[REDIS_TEST] Redis client construction failed for auth type " + f"'{redis_auth_type}' ({type(client_error).__name__}).", + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({ + 'error': 'Failed to build the Redis connection. Check Application Insights using "[REDIS_TEST]" for details.' + }), 500 + try: test_key = "test_key_simplechat" test_value = "hello_redis" r.set(test_key, test_value, ex=10) diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 0c9be0b50..2417dcab1 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -1379,6 +1379,8 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul 'redis_url': form_data.get('redis_url', '').strip(), 'redis_key': admin_secret('redis_key'), 'redis_auth_type': form_data.get('redis_auth_type', '').strip(), + 'redis_service_type': form_data.get('redis_service_type', '').strip() or 'auto', + 'redis_port': form_data.get('redis_port', '').strip(), 'enable_file_sync': requested_enable_file_sync, 'enable_file_sync_personal': form_data.get('enable_file_sync_personal') == 'on', 'enable_file_sync_group': form_data.get('enable_file_sync_group') == 'on', @@ -2499,6 +2501,8 @@ def is_valid_url(url): 'redis_url': form_data.get('redis_url', '').strip(), 'redis_key': admin_secret('redis_key'), 'redis_auth_type': form_data.get('redis_auth_type', '').strip(), + 'redis_service_type': form_data.get('redis_service_type', '').strip() or 'auto', + 'redis_port': form_data.get('redis_port', '').strip(), 'enable_conversation_cache': form_data.get('enable_conversation_cache') == 'on', 'conversation_cache_ttl_seconds': conversation_cache_ttl_seconds, diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index a36417ca2..aa0f62c0d 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -1206,6 +1206,17 @@ function formatRedisMetric(value, unit) { return `${numericValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} ${unit}`; } +function formatRedisServiceType(value) { + const normalizedValue = String(value || '').trim(); + if (normalizedValue === 'azure_managed_redis') { + return 'Azure Managed Redis'; + } + if (normalizedValue === 'azure_cache_for_redis') { + return 'Azure Cache for Redis'; + } + return 'Not available'; +} + function formatRedisPercent(value) { if (value === null || value === undefined || value === '') { return 'Not available'; @@ -2362,6 +2373,13 @@ function renderRedisMonitoringStatus(statusPayload) { runtime.session_using_redis ? 'success' : 'secondary' ); + setElementText('redis-monitoring-service-type', formatRedisServiceType(configuration.service_type)); + setElementText( + 'redis-monitoring-service-port', + configuration.port + ? `Port ${configuration.port} (${configuration.service_type_source === 'setting' ? 'set by admin' : 'detected'})` + : 'Port: Not available' + ); setElementText('redis-monitoring-ping-latency', formatRedisMetric(health.ping_latency_ms, 'ms')); setElementText('redis-monitoring-memory-usage', formatRedisMemoryUsage(memory)); setElementText( @@ -8172,7 +8190,9 @@ function setupTestButtons() { test_type: 'redis', endpoint: document.getElementById('redis_url').value, key: document.getElementById('redis_key').value, - auth_type: document.getElementById('redis_auth_type').value + auth_type: document.getElementById('redis_auth_type').value, + service_type: document.getElementById('redis_service_type')?.value || 'auto', + port: document.getElementById('redis_port')?.value || '' }; try { @@ -8612,6 +8632,8 @@ function setupLatestFeaturesMirrors() { const mirroredRedisAuthType = document.getElementById('latest_features_redis_auth_type'); const canonicalRedisKey = document.getElementById('redis_key'); const mirroredRedisKey = document.getElementById('latest_features_redis_key'); + const canonicalRedisServiceType = document.getElementById('redis_service_type'); + const mirroredRedisServiceType = document.getElementById('latest_features_redis_service_type'); if (canonicalEnhancedCitations && mirroredEnhancedCitations) { mirroredEnhancedCitations.checked = canonicalEnhancedCitations.checked; @@ -8688,6 +8710,7 @@ function setupLatestFeaturesMirrors() { syncMirroredField(canonicalRedisUrl, mirroredRedisUrl); syncMirroredField(canonicalRedisKey, mirroredRedisKey); + syncMirroredField(canonicalRedisServiceType, mirroredRedisServiceType, 'change'); } function syncMirroredField(canonicalField, mirroredField, eventName = 'input') { diff --git a/application/single_app/templates/admin/_panes/latest-features.html b/application/single_app/templates/admin/_panes/latest-features.html index a5d8c5f5d..8d69b03a6 100644 --- a/application/single_app/templates/admin/_panes/latest-features.html +++ b/application/single_app/templates/admin/_panes/latest-features.html @@ -1005,6 +1005,24 @@
Redis Cache Settings
value="{{ settings.redis_url or '' }}" > +
+ + {% set redis_service_type_value = settings.redis_service_type | default('auto', true) %} + +
+
+ + {% set redis_service_type_value = settings.redis_service_type | default('auto', true) %} + +
+ Detection reads the host name suffix and selects port 10000 for Azure Managed Redis or port 6380 for Azure Cache for Redis. + Choose the service explicitly when a custom DNS name or private endpoint hides the Azure suffix. +
+
+
+ + +