From 90ea1d60abc0e7fd4e83f3a8694558246b28a13e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 4 Sep 2026 09:38:34 -0400 Subject: [PATCH 1/6] Add Azure Managed Redis support alongside Azure Cache for Redis Azure Cache for Redis Basic, Standard, and Premium retire 2028-09-30, and Azure Managed Redis is the replacement. The two services listen on different TLS ports (10000 vs 6380), so the hardcoded 6380 in four call sites could only ever reach the retiring service. Application: - Add functions_redis_client.py as the single place that resolves service type, port, and credentials, and route session storage, the shared app cache, and the admin connection test through it. - Detect the service from the host name suffix, with optional redis_service_type and redis_port admin overrides for custom DNS and private endpoints. An unrecognized host keeps the previous port 6380 behavior, so existing Azure Cache for Redis deployments are unaffected. - Use the redis-entraid streaming credential provider so pooled connections re-AUTH before the Entra token expires, with one provider per long-lived client because the provider holds a single callback slot. Falls back to the in-repo provider when the package is absent so startup cannot break. - Align the admin connection test on the same token scope and factory the application uses; it previously used the legacy cacheinfra endpoint. - Report the resolved service and port in Redis Metrics. Deployer: - Provision Azure Managed Redis Balanced_B0 with high availability, the documented replacement for the Standard C0 previously deployed. - Set clusteringPolicy to NoCluster explicitly; the service default is OSSCluster, which requires a cluster-aware client SimpleChat does not use. - Keep a redisCacheKind switch for Azure Government and 21Vianet, where Azure Managed Redis is unavailable. - Grant data access through the redisEnterprise database access policy assignment, and retrieve keys with az redisenterprise. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/app.py | 41 +- application/single_app/app_settings_cache.py | 118 +--- application/single_app/config.py | 2 +- .../single_app/functions_redis_client.py | 366 ++++++++++++ .../single_app/functions_redis_monitoring.py | 11 + application/single_app/functions_settings.py | 2 + application/single_app/requirements.txt | 5 +- .../single_app/route_backend_settings.py | 60 +- .../route_frontend_admin_settings.py | 4 + .../static/js/admin/admin_settings.js | 25 +- .../admin/_panes/latest-features.html | 18 + .../templates/admin/_panes/redis-caching.html | 26 +- deployers/azure.yaml | 4 + deployers/bicep/README.md | 6 +- deployers/bicep/main.bicep | 43 +- deployers/bicep/main.json | 535 +++++++++++++----- deployers/bicep/main.parameters.json | 12 + deployers/bicep/modules/redisCache.bicep | 128 ++++- .../modules/setNativeWebAppPermissions.bicep | 36 +- deployers/bicep/modules/setPermissions.bicep | 38 +- deployers/bicep/postconfig.py | 87 ++- deployers/version.txt | 2 +- docs/admin/scale.md | 20 +- .../manual/provision-azure-resources.md | 26 +- .../features/AZURE_MANAGED_REDIS_SUPPORT.md | 186 ++++++ docs/explanation/release_notes.md | 33 ++ .../test_cosmos_wave1_cache_fallback.py | 14 +- .../test_cosmos_wave5a3_redis_monitoring.py | 3 + functional_tests/test_redis_client_factory.py | 257 +++++++++ .../test_redis_entra_token_auth.py | 192 +++++-- .../test_redis_service_type_detection.py | 213 +++++++ .../admin_settings_field_baseline.json | 3 + 32 files changed, 2136 insertions(+), 380 deletions(-) create mode 100644 application/single_app/functions_redis_client.py create mode 100644 docs/explanation/features/AZURE_MANAGED_REDIS_SUPPORT.md create mode 100644 functional_tests/test_redis_client_factory.py create mode 100644 functional_tests/test_redis_service_type_detection.py 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..bbeb8f238 100644 --- a/application/single_app/app_settings_cache.py +++ b/application/single_app/app_settings_cache.py @@ -7,14 +7,21 @@ 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. +# These names are re-exported for existing callers. +from functions_redis_client import ( # noqa: F401 + CREDENTIAL_PURPOSE_APP_CACHE, + REDIS_ENTRA_TOKEN_SCOPE, + REDIS_TOKEN_REFRESH_BUFFER_SECONDS, + RedisManagedIdentityCredentialProvider, + create_redis_client, + get_redis_entra_token_scope as _get_redis_entra_token_scope, +) # NOTE: functions_keyvault is imported locally inside configure_app_cache to avoid a circular # import (functions_keyvault -> app_settings_cache -> functions_keyvault). @@ -22,8 +29,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 +68,18 @@ _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): + """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. + """ + import functions_redis_client -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, + return functions_redis_client.create_redis_client( + settings=settings, + redis_url=redis_url, + auth_type=functions_redis_client.AUTH_TYPE_MANAGED_IDENTITY, **redis_kwargs ) @@ -707,39 +669,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..a97fabfc8 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.010" 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..cf71509ec 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,48 @@ 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 Exception as client_error: + log_event(f"[REDIS_TEST] Client construction failed: {str(client_error)}", level="error") + return jsonify({'error': f'Redis connection error: {str(client_error)}'}), 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. +
+
+
+ + +