From f14e467392b2a7e6f87af3d4cfaca7bfcd2496dc Mon Sep 17 00:00:00 2001 From: are-ces <195810094+are-ces@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:06:02 +0200 Subject: [PATCH] LCORE-2923: Add missing configuration fields to telemetry configuration snapshot Extend the LIGHTSPEED_STACK_FIELDS allowlist in the telemetry snapshot module so the configuration snapshot covers all serializable config sections, classifying each field as PASSTHROUGH or SENSITIVE. Sections added / extended: - Service (base_url, root_path) - Llama Stack (timeout, max_retries, retry_delay, allow_degraded_mode, config) - Inference (context_windows, max_infer_iters, max_tool_calls, providers) - Authentication (skip_for_health_probes/metrics, api_key_config, rh_identity_config, trusted_proxy_config) - Azure Entra ID (tenant_id, client_id, client_secret, scope) - Customization (profile_path, disable_shield_ids_override, agent_card_path) - Conversation Cache, Conversation Compaction, Quota Handlers - A2A State (sqlite, postgres) - Splunk (enabled, url, token_path, index, source, timeout, verify_ssl) - RAG unified schema: rag.byok.stores, rag.okp, rag.retrieval.{inline,tool} (sources, max_chunks, inline reranker) - Approvals, rlsapi v1, Saved Prompts, Skills, Deployment Environment - Vector Store (default_provider, providers[] with faiss/pgvector config) - Shields (name, provider_id) - config_format_version Aligned all field paths and tests with the unified 'rag' configuration section (rag.byok / rag.okp / rag.retrieval) that landed on main, and covered vector_store, shields, and config_format_version which arrived in later PRs. Consolidated telemetry snapshot tests into the existing test_configuration_snapshot.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/telemetry/configuration_snapshot.py | 262 +++++++- tests/unit/telemetry/conftest.py | 420 ++++++++++++- .../telemetry/test_configuration_snapshot.py | 567 +++++++++++++++++- 3 files changed, 1214 insertions(+), 35 deletions(-) diff --git a/src/telemetry/configuration_snapshot.py b/src/telemetry/configuration_snapshot.py index e0e9a9fe2..f4d53abe7 100644 --- a/src/telemetry/configuration_snapshot.py +++ b/src/telemetry/configuration_snapshot.py @@ -18,6 +18,7 @@ import yaml from pydantic import SecretStr +import constants from log import get_logger from models.config import Configuration @@ -36,10 +37,16 @@ class MaskingType(Enum): PASSTHROUGH: Value is returned as-is (booleans, numbers, identifiers). SENSITIVE: Value is replaced with 'configured' or 'not_configured' (credentials, URLs, file paths, hostnames). + RAG_SOURCES: A list of RAG source ids is summarized as + {'count': int, 'okp_enabled': bool}. The individual ids are + user-chosen rag_ids (potential PII), so only the count is emitted; + the fixed OKP sentinel is surfaced as a boolean so telemetry can + tell whether the OKP knowledge source is in use. """ PASSTHROUGH = "passthrough" SENSITIVE = "sensitive" + RAG_SOURCES = "rag_sources" @dataclass(frozen=True) @@ -75,13 +82,16 @@ class ListFieldSpec: LIGHTSPEED_STACK_FIELDS: tuple[FieldSpec | ListFieldSpec, ...] = ( # Operational FieldSpec("name", MaskingType.PASSTHROUGH), + FieldSpec("config_format_version", MaskingType.PASSTHROUGH), # Core Service Configuration FieldSpec("service.workers", MaskingType.PASSTHROUGH), FieldSpec("service.host", MaskingType.SENSITIVE), FieldSpec("service.port", MaskingType.PASSTHROUGH), + FieldSpec("service.base_url", MaskingType.SENSITIVE), FieldSpec("service.auth_enabled", MaskingType.PASSTHROUGH), FieldSpec("service.color_log", MaskingType.PASSTHROUGH), FieldSpec("service.access_log", MaskingType.PASSTHROUGH), + FieldSpec("service.root_path", MaskingType.SENSITIVE), FieldSpec("service.tls_config.tls_certificate_path", MaskingType.SENSITIVE), FieldSpec("service.tls_config.tls_key_path", MaskingType.SENSITIVE), FieldSpec("service.tls_config.tls_key_password", MaskingType.SENSITIVE), @@ -94,11 +104,32 @@ class ListFieldSpec: FieldSpec("llama_stack.url", MaskingType.SENSITIVE), FieldSpec("llama_stack.api_key", MaskingType.SENSITIVE), FieldSpec("llama_stack.library_client_config_path", MaskingType.SENSITIVE), + FieldSpec("llama_stack.timeout", MaskingType.PASSTHROUGH), + FieldSpec("llama_stack.max_retries", MaskingType.PASSTHROUGH), + FieldSpec("llama_stack.retry_delay", MaskingType.PASSTHROUGH), + FieldSpec("llama_stack.allow_degraded_mode", MaskingType.PASSTHROUGH), + FieldSpec("llama_stack.config.baseline", MaskingType.PASSTHROUGH), + FieldSpec("llama_stack.config.profile", MaskingType.SENSITIVE), + FieldSpec("llama_stack.config.native_override", MaskingType.SENSITIVE), FieldSpec("inference.default_model", MaskingType.PASSTHROUGH), FieldSpec("inference.default_provider", MaskingType.PASSTHROUGH), + FieldSpec("inference.context_windows", MaskingType.PASSTHROUGH), + FieldSpec("inference.max_infer_iters", MaskingType.PASSTHROUGH), + FieldSpec("inference.max_tool_calls", MaskingType.PASSTHROUGH), + ListFieldSpec( + "inference.providers", + item_fields=( + FieldSpec("type", MaskingType.PASSTHROUGH), + FieldSpec("id", MaskingType.PASSTHROUGH), + FieldSpec("api_key_env", MaskingType.SENSITIVE), + FieldSpec("allowed_models", MaskingType.PASSTHROUGH), + ), + ), # Authentication & Authorization FieldSpec("authentication.module", MaskingType.PASSTHROUGH), FieldSpec("authentication.skip_tls_verification", MaskingType.PASSTHROUGH), + FieldSpec("authentication.skip_for_health_probes", MaskingType.PASSTHROUGH), + FieldSpec("authentication.skip_for_metrics", MaskingType.PASSTHROUGH), FieldSpec("authentication.k8s_cluster_api", MaskingType.SENSITIVE), FieldSpec("authentication.k8s_ca_cert_path", MaskingType.SENSITIVE), FieldSpec("authentication.jwk_config.url", MaskingType.SENSITIVE), @@ -120,6 +151,26 @@ class ListFieldSpec: FieldSpec("negate", MaskingType.PASSTHROUGH), ), ), + FieldSpec("authentication.api_key_config.api_key", MaskingType.SENSITIVE), + FieldSpec( + "authentication.rh_identity_config.required_entitlements", + MaskingType.SENSITIVE, + ), + FieldSpec( + "authentication.rh_identity_config.max_header_size", + MaskingType.PASSTHROUGH, + ), + FieldSpec( + "authentication.trusted_proxy_config.user_header", + MaskingType.PASSTHROUGH, + ), + ListFieldSpec( + "authentication.trusted_proxy_config.allowed_service_accounts", + item_fields=( + FieldSpec("namespace", MaskingType.SENSITIVE), + FieldSpec("name", MaskingType.SENSITIVE), + ), + ), ListFieldSpec( "authorization.access_rules", item_fields=( @@ -127,6 +178,11 @@ class ListFieldSpec: FieldSpec("actions", MaskingType.PASSTHROUGH), ), ), + # Azure Entra ID + FieldSpec("azure_entra_id.tenant_id", MaskingType.SENSITIVE), + FieldSpec("azure_entra_id.client_id", MaskingType.SENSITIVE), + FieldSpec("azure_entra_id.client_secret", MaskingType.SENSITIVE), + FieldSpec("azure_entra_id.scope", MaskingType.PASSTHROUGH), # User Data Collection Features FieldSpec("user_data_collection.feedback_enabled", MaskingType.PASSTHROUGH), FieldSpec("user_data_collection.feedback_storage", MaskingType.SENSITIVE), @@ -135,7 +191,10 @@ class ListFieldSpec: # AI/ML Capabilities Configuration FieldSpec("customization.system_prompt", MaskingType.SENSITIVE), FieldSpec("customization.system_prompt_path", MaskingType.SENSITIVE), + FieldSpec("customization.profile_path", MaskingType.SENSITIVE), FieldSpec("customization.disable_query_system_prompt", MaskingType.PASSTHROUGH), + FieldSpec("customization.disable_shield_ids_override", MaskingType.PASSTHROUGH), + FieldSpec("customization.agent_card_path", MaskingType.SENSITIVE), # Database & Storage Configuration FieldSpec("database.sqlite.db_path", MaskingType.SENSITIVE), FieldSpec("database.postgres.host", MaskingType.SENSITIVE), @@ -147,6 +206,152 @@ class ListFieldSpec: FieldSpec("database.postgres.ssl_mode", MaskingType.PASSTHROUGH), FieldSpec("database.postgres.gss_encmode", MaskingType.PASSTHROUGH), FieldSpec("database.postgres.ca_cert_path", MaskingType.SENSITIVE), + # Conversation Cache + FieldSpec("conversation_cache.type", MaskingType.PASSTHROUGH), + FieldSpec("conversation_cache.memory.max_entries", MaskingType.PASSTHROUGH), + FieldSpec("conversation_cache.sqlite.db_path", MaskingType.SENSITIVE), + FieldSpec("conversation_cache.postgres.host", MaskingType.SENSITIVE), + FieldSpec("conversation_cache.postgres.port", MaskingType.PASSTHROUGH), + FieldSpec("conversation_cache.postgres.db", MaskingType.SENSITIVE), + FieldSpec("conversation_cache.postgres.user", MaskingType.SENSITIVE), + FieldSpec("conversation_cache.postgres.password", MaskingType.SENSITIVE), + FieldSpec("conversation_cache.postgres.namespace", MaskingType.SENSITIVE), + FieldSpec("conversation_cache.postgres.ssl_mode", MaskingType.PASSTHROUGH), + FieldSpec("conversation_cache.postgres.gss_encmode", MaskingType.PASSTHROUGH), + FieldSpec("conversation_cache.postgres.ca_cert_path", MaskingType.SENSITIVE), + # Conversation Compaction + FieldSpec("compaction.enabled", MaskingType.PASSTHROUGH), + FieldSpec("compaction.threshold_ratio", MaskingType.PASSTHROUGH), + FieldSpec("compaction.token_floor", MaskingType.PASSTHROUGH), + FieldSpec("compaction.buffer_turns", MaskingType.PASSTHROUGH), + FieldSpec("compaction.buffer_max_ratio", MaskingType.PASSTHROUGH), + # Quota Handlers + FieldSpec("quota_handlers.sqlite.db_path", MaskingType.SENSITIVE), + FieldSpec("quota_handlers.postgres.host", MaskingType.SENSITIVE), + FieldSpec("quota_handlers.postgres.port", MaskingType.PASSTHROUGH), + FieldSpec("quota_handlers.postgres.db", MaskingType.SENSITIVE), + FieldSpec("quota_handlers.postgres.user", MaskingType.SENSITIVE), + FieldSpec("quota_handlers.postgres.password", MaskingType.SENSITIVE), + FieldSpec("quota_handlers.postgres.namespace", MaskingType.SENSITIVE), + FieldSpec("quota_handlers.postgres.ssl_mode", MaskingType.PASSTHROUGH), + FieldSpec("quota_handlers.postgres.gss_encmode", MaskingType.PASSTHROUGH), + FieldSpec("quota_handlers.postgres.ca_cert_path", MaskingType.SENSITIVE), + ListFieldSpec( + "quota_handlers.limiters", + item_fields=( + FieldSpec("type", MaskingType.PASSTHROUGH), + FieldSpec("name", MaskingType.PASSTHROUGH), + FieldSpec("initial_quota", MaskingType.PASSTHROUGH), + FieldSpec("quota_increase", MaskingType.PASSTHROUGH), + FieldSpec("period", MaskingType.PASSTHROUGH), + ), + ), + FieldSpec("quota_handlers.scheduler.period", MaskingType.PASSTHROUGH), + FieldSpec( + "quota_handlers.scheduler.database_reconnection_count", + MaskingType.PASSTHROUGH, + ), + FieldSpec( + "quota_handlers.scheduler.database_reconnection_delay", + MaskingType.PASSTHROUGH, + ), + FieldSpec("quota_handlers.enable_token_history", MaskingType.PASSTHROUGH), + # BYOK RAG + FieldSpec("rag.byok.max_chunks", MaskingType.PASSTHROUGH), + ListFieldSpec( + "rag.byok.stores", + item_fields=( + # rag_id / vector_db_id are user-chosen names (potential PII) + FieldSpec("rag_id", MaskingType.SENSITIVE), + FieldSpec("backend", MaskingType.PASSTHROUGH), + FieldSpec("embedding_model", MaskingType.PASSTHROUGH), + FieldSpec("embedding_dimension", MaskingType.PASSTHROUGH), + FieldSpec("vector_db_id", MaskingType.SENSITIVE), + FieldSpec("db_path", MaskingType.SENSITIVE), + FieldSpec("score_multiplier", MaskingType.PASSTHROUGH), + FieldSpec("relevance_cutoff_score", MaskingType.PASSTHROUGH), + FieldSpec("host", MaskingType.SENSITIVE), + FieldSpec("port", MaskingType.PASSTHROUGH), + FieldSpec("db", MaskingType.SENSITIVE), + FieldSpec("user", MaskingType.SENSITIVE), + FieldSpec("password", MaskingType.SENSITIVE), + ), + ), + # A2A State + FieldSpec("a2a_state.sqlite.db_path", MaskingType.SENSITIVE), + FieldSpec("a2a_state.postgres.host", MaskingType.SENSITIVE), + FieldSpec("a2a_state.postgres.port", MaskingType.PASSTHROUGH), + FieldSpec("a2a_state.postgres.db", MaskingType.SENSITIVE), + FieldSpec("a2a_state.postgres.user", MaskingType.SENSITIVE), + FieldSpec("a2a_state.postgres.password", MaskingType.SENSITIVE), + FieldSpec("a2a_state.postgres.namespace", MaskingType.SENSITIVE), + FieldSpec("a2a_state.postgres.ssl_mode", MaskingType.PASSTHROUGH), + FieldSpec("a2a_state.postgres.gss_encmode", MaskingType.PASSTHROUGH), + FieldSpec("a2a_state.postgres.ca_cert_path", MaskingType.SENSITIVE), + # Splunk + FieldSpec("splunk.enabled", MaskingType.PASSTHROUGH), + FieldSpec("splunk.url", MaskingType.SENSITIVE), + FieldSpec("splunk.token_path", MaskingType.SENSITIVE), + FieldSpec("splunk.index", MaskingType.SENSITIVE), + FieldSpec("splunk.source", MaskingType.PASSTHROUGH), + FieldSpec("splunk.timeout", MaskingType.PASSTHROUGH), + FieldSpec("splunk.verify_ssl", MaskingType.PASSTHROUGH), + # RAG Retrieval Strategy + # sources are user-chosen rag_ids (potential PII) -> summarized as + # {count, okp_enabled} rather than emitted verbatim. + FieldSpec("rag.retrieval.inline.sources", MaskingType.RAG_SOURCES), + FieldSpec("rag.retrieval.inline.max_chunks", MaskingType.PASSTHROUGH), + FieldSpec("rag.retrieval.tool.sources", MaskingType.RAG_SOURCES), + FieldSpec("rag.retrieval.tool.max_chunks", MaskingType.PASSTHROUGH), + # OKP + FieldSpec("rag.okp.rhokp_url", MaskingType.SENSITIVE), + FieldSpec("rag.okp.offline", MaskingType.PASSTHROUGH), + FieldSpec("rag.okp.chunk_filter_query", MaskingType.PASSTHROUGH), + FieldSpec("rag.okp.search_mode", MaskingType.PASSTHROUGH), + FieldSpec("rag.okp.max_chunks", MaskingType.PASSTHROUGH), + # Reranker (inline retrieval) + FieldSpec("rag.retrieval.inline.reranker.enabled", MaskingType.PASSTHROUGH), + FieldSpec("rag.retrieval.inline.reranker.model", MaskingType.PASSTHROUGH), + # Vector Store (dynamic provider capacity) + # default_provider / providers[].id are user-chosen names (potential PII) + FieldSpec("vector_store.default_provider", MaskingType.SENSITIVE), + ListFieldSpec( + "vector_store.providers", + item_fields=( + FieldSpec("id", MaskingType.SENSITIVE), + FieldSpec("type", MaskingType.PASSTHROUGH), + FieldSpec("embedding_model", MaskingType.PASSTHROUGH), + FieldSpec("embedding_dimension", MaskingType.PASSTHROUGH), + FieldSpec("config.path", MaskingType.SENSITIVE), + FieldSpec("config.host", MaskingType.SENSITIVE), + FieldSpec("config.port", MaskingType.PASSTHROUGH), + FieldSpec("config.db", MaskingType.SENSITIVE), + FieldSpec("config.user", MaskingType.SENSITIVE), + FieldSpec("config.password", MaskingType.SENSITIVE), + ), + ), + # Shields (pydantic-ai agent guardrails) + ListFieldSpec( + "shields", + item_fields=( + FieldSpec("name", MaskingType.PASSTHROUGH), + FieldSpec("provider_id", MaskingType.PASSTHROUGH), + ), + ), + # Approvals + FieldSpec("approvals.approval_timeout_seconds", MaskingType.PASSTHROUGH), + FieldSpec("approvals.approval_retention_days", MaskingType.PASSTHROUGH), + # rlsapi v1 + FieldSpec("rlsapi_v1.allow_verbose_infer", MaskingType.PASSTHROUGH), + FieldSpec("rlsapi_v1.quota_subject", MaskingType.PASSTHROUGH), + # Saved Prompts + FieldSpec("saved_prompts.max_prompts_per_user", MaskingType.PASSTHROUGH), + FieldSpec("saved_prompts.max_display_name_length", MaskingType.PASSTHROUGH), + FieldSpec("saved_prompts.max_content_length", MaskingType.PASSTHROUGH), + # Skills + FieldSpec("skills.paths", MaskingType.SENSITIVE), + # Deployment Environment + FieldSpec("deployment_environment", MaskingType.PASSTHROUGH), # Integration & Connectivity ListFieldSpec( "mcp_servers", @@ -154,6 +359,10 @@ class ListFieldSpec: FieldSpec("name", MaskingType.PASSTHROUGH), FieldSpec("provider_id", MaskingType.PASSTHROUGH), FieldSpec("url", MaskingType.SENSITIVE), + FieldSpec("authorization_headers", MaskingType.SENSITIVE), + FieldSpec("headers", MaskingType.SENSITIVE), + FieldSpec("require_approval", MaskingType.PASSTHROUGH), + FieldSpec("timeout", MaskingType.PASSTHROUGH), ), ), ) @@ -290,6 +499,30 @@ def _serialize_passthrough(value: Any) -> Any: return CONFIGURED +def _summarize_rag_sources(value: Any) -> dict[str, Any]: + """Summarize a list of RAG source ids without leaking the ids themselves. + + RAG source ids are user-chosen rag_ids that may be identifying (PII), so + only their count is reported. The fixed OKP sentinel (constants.OKP_RAG_ID) + is a well-known, non-identifying value, so its presence is surfaced as a + boolean to indicate whether the OKP knowledge source is enabled. + + Parameters: + ---------- + value: The raw sources value (expected to be a list/tuple of str). + + Returns: + ------- + A dict {'count': int, 'okp_enabled': bool}. + """ + if not isinstance(value, (list, tuple)): + return {"count": 0, "okp_enabled": False} + return { + "count": len(value), + "okp_enabled": constants.OKP_RAG_ID in value, + } + + def mask_value(value: Any, masking: MaskingType) -> Any: """Apply masking to a configuration value. @@ -303,9 +536,11 @@ def mask_value(value: Any, masking: MaskingType) -> Any: The masked or serialized value. """ if masking == MaskingType.SENSITIVE: - if value is None: + if value is None or value == "": return NOT_CONFIGURED return CONFIGURED + if masking == MaskingType.RAG_SOURCES: + return _summarize_rag_sources(value) return _serialize_passthrough(value) @@ -363,16 +598,23 @@ def _extract_list_field( return NOT_CONFIGURED if not isinstance(items, (list, tuple)): return NOT_CONFIGURED - return [ - { - field_spec.path: mask_value( - get_nested_value(item, field_spec.path), - field_spec.masking, + result: list[dict[str, Any]] = [] + for item in items: + item_dict: dict[str, Any] = {} + for field_spec in spec.item_fields: + # Use _set_nested_value so dotted item paths (e.g. "config.path") + # nest into sub-objects instead of producing literal dotted keys, + # matching the nesting used for top-level fields. + _set_nested_value( + item_dict, + field_spec.path, + mask_value( + get_nested_value(item, field_spec.path), + field_spec.masking, + ), ) - for field_spec in spec.item_fields - } - for item in items - ] + result.append(item_dict) + return result def _extract_snapshot_fields( diff --git a/tests/unit/telemetry/conftest.py b/tests/unit/telemetry/conftest.py index f17cc5a28..f03fa84f5 100644 --- a/tests/unit/telemetry/conftest.py +++ b/tests/unit/telemetry/conftest.py @@ -7,17 +7,27 @@ import yaml from pydantic import SecretStr +import constants from models.config import ( + A2AStateConfiguration, AccessRule, Action, + APIKeyTokenConfiguration, + ApprovalsConfiguration, AuthenticationConfiguration, AuthorizationConfiguration, + AzureEntraIdConfiguration, ByokConfiguration, + CompactionConfiguration, Configuration, + ConversationHistoryConfiguration, CORSConfiguration, Customization, DatabaseConfiguration, + FaissVectorStoreProvider, + FaissVectorStoreProviderConfig, InferenceConfiguration, + InMemoryCacheConfig, JsonPathOperator, JwkConfiguration, JwtConfiguration, @@ -25,14 +35,33 @@ LlamaStackConfiguration, ModelContextProtocolServer, OkpConfiguration, + PgvectorVectorStoreProvider, + PgvectorVectorStoreProviderConfig, PostgreSQLDatabaseConfiguration, + QuestionValidityShieldConfiguration, + QuotaHandlersConfiguration, + QuotaLimiterConfiguration, + QuotaSchedulerConfiguration, RagConfiguration, + RagStore, + RedactionShieldConfiguration, + RerankerConfiguration, RetrievalConfiguration, RetrievalStrategyConfiguration, + RHIdentityConfiguration, + RlsapiV1Configuration, + SavedPromptsConfiguration, ServiceConfiguration, + SkillsConfiguration, + SplunkConfiguration, SQLiteDatabaseConfiguration, TLSConfiguration, + TrustedProxyConfiguration, + TrustedProxyServiceAccount, + UnifiedInferenceProvider, + UnifiedLlamaStackConfig, UserDataCollection, + VectorStoreConfiguration, ) # ============================================================================= @@ -63,6 +92,60 @@ PII_PG_NAMESPACE = "production_ns" PII_PG_CA_CERT = "/etc/ssl/postgres/ca.crt" PII_MCP_URL = "https://mcp.internal.corp.com:9090" +PII_MCP_AUTH_HEADER_VALUE = "/etc/secrets/mcp-token.txt" +PII_BASE_URL = "https://lightspeed.internal.corp.com" +PII_ROOT_PATH = "/api/v1/lightspeed" +PII_PROFILE_PATH = "/opt/lightspeed/custom_profile.py" +PII_AGENT_CARD_PATH = "/opt/lightspeed/agent_card.yaml" +PII_CACHE_SQLITE_PATH = "/var/lib/lightspeed/cache.sqlite" +PII_CACHE_PG_HOST = "cache-db.internal.corp.com" +PII_CACHE_PG_DB = "lightspeed_cache" +PII_CACHE_PG_USER = "cache_admin" +PII_CACHE_PG_PASS = "CacheP@ss!Secret" +PII_CACHE_PG_NAMESPACE = "cache_ns" +PII_CACHE_PG_CA_CERT = "/etc/ssl/cache/ca.crt" +PII_QUOTA_SQLITE_PATH = "/var/lib/lightspeed/quota.sqlite" +PII_QUOTA_PG_HOST = "quota-db.internal.corp.com" +PII_QUOTA_PG_DB = "lightspeed_quota" +PII_QUOTA_PG_USER = "quota_admin" +PII_QUOTA_PG_PASS = "QuotaP@ss!Secret" +PII_QUOTA_PG_NAMESPACE = "quota_ns" +PII_QUOTA_PG_CA_CERT = "/etc/ssl/quota/ca.crt" +PII_BYOK_DB_PATH = "/var/lib/lightspeed/byok_rag.db" +PII_BYOK_HOST = "byok-db.internal.corp.com" +# port is passthrough (not treated as PII), so this is a plain value +BYOK_PORT = "5433" +PII_BYOK_DB = "byok_vectors" +PII_BYOK_USER = "byok_admin" +PII_BYOK_PASS = "ByokP@ss!Secret" +PII_VS_FAISS_PATH = "/var/lib/lightspeed/vector_store_faiss.db" +PII_VS_PG_HOST = "vs-db.internal.corp.com" +PII_VS_PG_DB = "lightspeed_vector_store" +PII_VS_PG_USER = "vs_admin" +PII_VS_PG_PASS = "VsP@ss!Secret" +PII_A2A_SQLITE_PATH = "/var/lib/lightspeed/a2a.sqlite" +PII_A2A_PG_HOST = "a2a-db.internal.corp.com" +PII_A2A_PG_DB = "lightspeed_a2a" +PII_A2A_PG_USER = "a2a_admin" +PII_A2A_PG_PASS = "A2aP@ss!Secret" +PII_A2A_PG_NAMESPACE = "a2a_ns" +PII_A2A_PG_CA_CERT = "/etc/ssl/a2a/ca.crt" +PII_SPLUNK_URL = "https://splunk-hec.internal.corp.com:8088" +PII_SPLUNK_TOKEN_PATH = "/etc/secrets/splunk-token.txt" +PII_SPLUNK_INDEX = "lightspeed_prod_index" +PII_OKP_URL = "https://okp.internal.corp.com:9443" +# chunk_filter_query is passthrough (not treated as PII), so this is a plain value +OKP_CHUNK_FILTER = "product:ansible AND product:*openshift*" +PII_AZURE_TENANT_ID = "azure-tenant-id-secret-12345" +PII_AZURE_CLIENT_ID = "azure-client-id-secret-67890" +PII_AZURE_CLIENT_SECRET = "azure-client-secret-abcdef" +PII_RH_IDENTITY_ENTITLEMENTS = "insights,openshift" +PII_TRUSTED_PROXY_SA_NS = "proxy-namespace-secret" +PII_TRUSTED_PROXY_SA_NAME = "proxy-sa-secret-name" +PII_SKILLS_PATH = "/opt/lightspeed/skills" +PII_LS_PROFILE = "/opt/llama-stack/custom-profile.yaml" +PII_LS_NATIVE_OVERRIDE = "override-secret-value" +PII_PROVIDER_API_KEY_ENV = "OPENAI_API_KEY" ALL_PII_VALUES = [ PII_HOST, @@ -89,6 +172,56 @@ PII_PG_NAMESPACE, PII_PG_CA_CERT, PII_MCP_URL, + PII_MCP_AUTH_HEADER_VALUE, + PII_BASE_URL, + PII_ROOT_PATH, + PII_PROFILE_PATH, + PII_AGENT_CARD_PATH, + PII_CACHE_SQLITE_PATH, + PII_CACHE_PG_HOST, + PII_CACHE_PG_DB, + PII_CACHE_PG_USER, + PII_CACHE_PG_PASS, + PII_CACHE_PG_NAMESPACE, + PII_CACHE_PG_CA_CERT, + PII_QUOTA_SQLITE_PATH, + PII_QUOTA_PG_HOST, + PII_QUOTA_PG_DB, + PII_QUOTA_PG_USER, + PII_QUOTA_PG_PASS, + PII_QUOTA_PG_NAMESPACE, + PII_QUOTA_PG_CA_CERT, + PII_BYOK_DB_PATH, + PII_BYOK_HOST, + PII_BYOK_DB, + PII_BYOK_USER, + PII_BYOK_PASS, + PII_VS_FAISS_PATH, + PII_VS_PG_HOST, + PII_VS_PG_DB, + PII_VS_PG_USER, + PII_VS_PG_PASS, + PII_A2A_SQLITE_PATH, + PII_A2A_PG_HOST, + PII_A2A_PG_DB, + PII_A2A_PG_USER, + PII_A2A_PG_PASS, + PII_A2A_PG_NAMESPACE, + PII_A2A_PG_CA_CERT, + PII_SPLUNK_URL, + PII_SPLUNK_TOKEN_PATH, + PII_SPLUNK_INDEX, + PII_OKP_URL, + PII_AZURE_TENANT_ID, + PII_AZURE_CLIENT_ID, + PII_AZURE_CLIENT_SECRET, + PII_RH_IDENTITY_ENTITLEMENTS, + PII_TRUSTED_PROXY_SA_NS, + PII_TRUSTED_PROXY_SA_NAME, + PII_SKILLS_PATH, + PII_LS_PROFILE, + PII_LS_NATIVE_OVERRIDE, + PII_PROVIDER_API_KEY_ENV, ] SAMPLE_LLAMA_STACK_CONFIG: dict[str, Any] = { @@ -181,15 +314,16 @@ def build_fully_populated_config() -> Configuration: """ return Configuration.model_construct( name="test-service", + config_format_version="unified", service=ServiceConfiguration.model_construct( host=PII_HOST, port=8080, - base_url=None, + base_url=PII_BASE_URL, workers=4, auth_enabled=True, color_log=True, access_log=False, - root_path="", + root_path=PII_ROOT_PATH, tls_config=TLSConfiguration.model_construct( tls_certificate_path=Path(PII_TLS_CERT), tls_key_path=Path(PII_TLS_KEY), @@ -208,15 +342,36 @@ def build_fully_populated_config() -> Configuration: use_as_library_client=False, library_client_config_path=PII_LIB_CONFIG, timeout=180, + max_retries=5, + retry_delay=2, + allow_degraded_mode=True, + config=UnifiedLlamaStackConfig.model_construct( + baseline="default", + profile=PII_LS_PROFILE, + native_override={"key": PII_LS_NATIVE_OVERRIDE}, + ), ), inference=InferenceConfiguration.model_construct( default_model="gpt-4o-mini", default_provider="openai", + context_windows={"openai/gpt-4o-mini": 128000}, + max_infer_iters=10, + max_tool_calls=30, + providers=[ + UnifiedInferenceProvider.model_construct( + type="openai", + id="openai-provider", + api_key_env=PII_PROVIDER_API_KEY_ENV, + allowed_models=["gpt-4o-mini", "gpt-4o"], + extra={}, + ), + ], ), authentication=AuthenticationConfiguration.model_construct( module="jwk_token", skip_tls_verification=False, - skip_for_health_probes=False, + skip_for_health_probes=True, + skip_for_metrics=True, k8s_cluster_api=PII_K8S_API, k8s_ca_cert_path=Path(PII_K8S_CERT), jwk_config=JwkConfiguration.model_construct( @@ -236,8 +391,22 @@ def build_fully_populated_config() -> Configuration: ], ), ), - api_key_config=None, - rh_identity_config=None, + api_key_config=APIKeyTokenConfiguration.model_construct( + api_key=SecretStr(PII_API_KEY), + ), + rh_identity_config=RHIdentityConfiguration.model_construct( + required_entitlements=[PII_RH_IDENTITY_ENTITLEMENTS], + max_header_size=16384, + ), + trusted_proxy_config=TrustedProxyConfiguration.model_construct( + user_header="X-Forwarded-User", + allowed_service_accounts=[ + TrustedProxyServiceAccount.model_construct( + namespace=PII_TRUSTED_PROXY_SA_NS, + name=PII_TRUSTED_PROXY_SA_NAME, + ), + ], + ), ), authorization=AuthorizationConfiguration.model_construct( access_rules=[ @@ -260,10 +429,11 @@ def build_fully_populated_config() -> Configuration: customization=Customization.model_construct( system_prompt=PII_SYSTEM_PROMPT, system_prompt_path=Path(PII_PROMPT_PATH), + profile_path=PII_PROFILE_PATH, disable_query_system_prompt=False, - profile_path=None, + disable_shield_ids_override=True, custom_profile=None, - agent_card_path=None, + agent_card_path=Path(PII_AGENT_CARD_PATH), agent_card_config=None, ), database=DatabaseConfiguration.model_construct( @@ -282,42 +452,206 @@ def build_fully_populated_config() -> Configuration: ca_cert_path=Path(PII_PG_CA_CERT), ), ), + # NOTE: deliberately sets type="postgres" together with memory and + # sqlite. ConversationHistoryConfiguration.check_cache_configuration + # would reject this combination, but model_construct() bypasses the + # validator on purpose so a single fixture exercises snapshot + # extraction for all three cache backends at once. This shape is not + # a config the loader can ever produce. + conversation_cache=ConversationHistoryConfiguration.model_construct( + type="postgres", + memory=InMemoryCacheConfig.model_construct(max_entries=1000), + sqlite=SQLiteDatabaseConfiguration.model_construct( + db_path=PII_CACHE_SQLITE_PATH, + ), + postgres=PostgreSQLDatabaseConfiguration.model_construct( + host=PII_CACHE_PG_HOST, + port=5432, + db=PII_CACHE_PG_DB, + user=PII_CACHE_PG_USER, + password=SecretStr(PII_CACHE_PG_PASS), + namespace=PII_CACHE_PG_NAMESPACE, + ssl_mode="verify-full", + gss_encmode="prefer", + ca_cert_path=Path(PII_CACHE_PG_CA_CERT), + ), + ), + compaction=CompactionConfiguration.model_construct( + enabled=True, + threshold_ratio=0.8, + token_floor=8192, + buffer_turns=6, + buffer_max_ratio=0.4, + ), + quota_handlers=QuotaHandlersConfiguration.model_construct( + sqlite=SQLiteDatabaseConfiguration.model_construct( + db_path=PII_QUOTA_SQLITE_PATH, + ), + postgres=PostgreSQLDatabaseConfiguration.model_construct( + host=PII_QUOTA_PG_HOST, + port=5432, + db=PII_QUOTA_PG_DB, + user=PII_QUOTA_PG_USER, + password=SecretStr(PII_QUOTA_PG_PASS), + namespace=PII_QUOTA_PG_NAMESPACE, + ssl_mode="verify-full", + gss_encmode="prefer", + ca_cert_path=Path(PII_QUOTA_PG_CA_CERT), + ), + limiters=[ + QuotaLimiterConfiguration.model_construct( + type="user_limiter", + name="daily-user-limit", + initial_quota=10000, + quota_increase=0, + period="1 day", + ), + ], + scheduler=QuotaSchedulerConfiguration.model_construct( + period=5, + database_reconnection_count=10, + database_reconnection_delay=2, + ), + enable_token_history=True, + ), + a2a_state=A2AStateConfiguration.model_construct( + sqlite=SQLiteDatabaseConfiguration.model_construct( + db_path=PII_A2A_SQLITE_PATH, + ), + postgres=PostgreSQLDatabaseConfiguration.model_construct( + host=PII_A2A_PG_HOST, + port=5432, + db=PII_A2A_PG_DB, + user=PII_A2A_PG_USER, + password=SecretStr(PII_A2A_PG_PASS), + namespace=PII_A2A_PG_NAMESPACE, + ssl_mode="verify-full", + gss_encmode="prefer", + ca_cert_path=Path(PII_A2A_PG_CA_CERT), + ), + ), mcp_servers=[ ModelContextProtocolServer.model_construct( name="my-mcp-server", provider_id="model-context-protocol", url=PII_MCP_URL, - authorization_headers={}, - timeout=None, + authorization_headers={"Authorization": PII_MCP_AUTH_HEADER_VALUE}, + headers=["x-rh-identity"], + require_approval="always", + timeout=60, ), ], - conversation_cache=None, + azure_entra_id=AzureEntraIdConfiguration.model_construct( + tenant_id=SecretStr(PII_AZURE_TENANT_ID), + client_id=SecretStr(PII_AZURE_CLIENT_ID), + client_secret=SecretStr(PII_AZURE_CLIENT_SECRET), + scope="https://cognitiveservices.azure.com/.default", + ), + splunk=SplunkConfiguration.model_construct( + enabled=True, + url=PII_SPLUNK_URL, + token_path=Path(PII_SPLUNK_TOKEN_PATH), + index=PII_SPLUNK_INDEX, + source="lightspeed-stack", + timeout=5, + verify_ssl=True, + ), rag=RagConfiguration.model_construct( byok=ByokConfiguration.model_construct( max_chunks=10, - stores=[], + stores=[ + RagStore.model_construct( + rag_id="my-rag", + backend="faiss", + embedding_model="all-MiniLM-L6-v2", + embedding_dimension=384, + vector_db_id="my-vector-db", + db_path=PII_BYOK_DB_PATH, + score_multiplier=1.5, + relevance_cutoff_score=0.42, + host=PII_BYOK_HOST, + port=BYOK_PORT, + db=PII_BYOK_DB, + user=PII_BYOK_USER, + password=SecretStr(PII_BYOK_PASS), + ), + ], ), okp=OkpConfiguration.model_construct( - rhokp_url=None, + rhokp_url=PII_OKP_URL, offline=True, - chunk_filter_query=None, + chunk_filter_query=OKP_CHUNK_FILTER, + search_mode="hybrid", max_chunks=5, ), retrieval=RetrievalConfiguration.model_construct( inline=RetrievalStrategyConfiguration.model_construct( - sources=[], + sources=[constants.OKP_RAG_ID, "my-rag"], max_chunks=10, + reranker=RerankerConfiguration.model_construct( + enabled=True, + model="cross-encoder/ms-marco-MiniLM-L6-v2", + ), ), tool=RetrievalStrategyConfiguration.model_construct( - sources=[], + sources=["my-rag"], max_chunks=10, ), ), ), - a2a_state=None, - quota_handlers=None, - azure_entra_id=None, - splunk=None, + approvals=ApprovalsConfiguration.model_construct( + approval_timeout_seconds=600, + approval_retention_days=90, + ), + rlsapi_v1=RlsapiV1Configuration.model_construct( + allow_verbose_infer=True, + quota_subject="user_id", + ), + saved_prompts=SavedPromptsConfiguration.model_construct( + max_prompts_per_user=100, + max_display_name_length=200, + max_content_length=5000, + ), + skills=SkillsConfiguration.model_construct( + paths=[Path(PII_SKILLS_PATH)], + ), + vector_store=VectorStoreConfiguration.model_construct( + default_provider="faiss-provider", + providers=[ + FaissVectorStoreProvider.model_construct( + id="faiss-provider", + type="faiss", + embedding_model="all-MiniLM-L6-v2", + embedding_dimension=384, + config=FaissVectorStoreProviderConfig.model_construct( + path=PII_VS_FAISS_PATH, + ), + ), + PgvectorVectorStoreProvider.model_construct( + id="pgvector-provider", + type="pgvector", + embedding_model="all-MiniLM-L6-v2", + embedding_dimension=384, + config=PgvectorVectorStoreProviderConfig.model_construct( + host=PII_VS_PG_HOST, + port=5432, + db=PII_VS_PG_DB, + user=PII_VS_PG_USER, + password=SecretStr(PII_VS_PG_PASS), + ), + ), + ], + ), + shields=[ + QuestionValidityShieldConfiguration.model_construct( + name="question-validity", + provider_id="question_validity", + ), + RedactionShieldConfiguration.model_construct( + name="pii-redaction", + provider_id="redaction", + ), + ], deployment_environment="production", ) @@ -357,20 +691,30 @@ def build_minimal_config() -> Configuration: use_as_library_client=True, library_client_config_path=None, timeout=180, + max_retries=5, + retry_delay=2, + allow_degraded_mode=False, + config=None, ), inference=InferenceConfiguration.model_construct( default_model=None, default_provider=None, + context_windows={}, + max_infer_iters=10, + max_tool_calls=30, + providers=[], ), authentication=AuthenticationConfiguration.model_construct( module="noop", skip_tls_verification=False, skip_for_health_probes=False, + skip_for_metrics=False, k8s_cluster_api=None, k8s_ca_cert_path=None, jwk_config=None, api_key_config=None, rh_identity_config=None, + trusted_proxy_config=None, ), authorization=None, user_data_collection=UserDataCollection.model_construct( @@ -388,6 +732,17 @@ def build_minimal_config() -> Configuration: ), mcp_servers=[], conversation_cache=None, + compaction=CompactionConfiguration.model_construct( + enabled=False, + threshold_ratio=0.7, + token_floor=4096, + buffer_turns=4, + buffer_max_ratio=0.3, + ), + a2a_state=None, + quota_handlers=None, + azure_entra_id=None, + splunk=None, rag=RagConfiguration.model_construct( byok=ByokConfiguration.model_construct( max_chunks=10, @@ -403,6 +758,10 @@ def build_minimal_config() -> Configuration: inline=RetrievalStrategyConfiguration.model_construct( sources=[], max_chunks=10, + reranker=RerankerConfiguration.model_construct( + enabled=False, + model="cross-encoder/ms-marco-MiniLM-L6-v2", + ), ), tool=RetrievalStrategyConfiguration.model_construct( sources=[], @@ -410,10 +769,25 @@ def build_minimal_config() -> Configuration: ), ), ), - a2a_state=None, - quota_handlers=None, - azure_entra_id=None, - splunk=None, + approvals=ApprovalsConfiguration.model_construct( + approval_timeout_seconds=300, + approval_retention_days=30, + ), + rlsapi_v1=RlsapiV1Configuration.model_construct( + allow_verbose_infer=False, + quota_subject=None, + ), + saved_prompts=SavedPromptsConfiguration.model_construct( + max_prompts_per_user=50, + max_display_name_length=255, + max_content_length=10000, + ), + skills=None, + vector_store=VectorStoreConfiguration.model_construct( + default_provider=None, + providers=[], + ), + shields=[], deployment_environment="development", ) diff --git a/tests/unit/telemetry/test_configuration_snapshot.py b/tests/unit/telemetry/test_configuration_snapshot.py index 85dac1d64..f7fd20704 100644 --- a/tests/unit/telemetry/test_configuration_snapshot.py +++ b/tests/unit/telemetry/test_configuration_snapshot.py @@ -1,5 +1,7 @@ """Tests for configuration snapshot with PII masking.""" +# pylint: disable=too-many-lines,too-many-public-methods + import json from enum import Enum from pathlib import Path, PurePosixPath @@ -9,6 +11,7 @@ import yaml from pydantic import SecretStr +import constants from models.config import Action, JsonPathOperator from telemetry.configuration_snapshot import ( CONFIGURED, @@ -32,7 +35,9 @@ ) from tests.unit.telemetry.conftest import ( ALL_PII_VALUES, + BYOK_PORT, LLAMA_STACK_PII_VALUES, + OKP_CHUNK_FILTER, SAMPLE_LLAMA_STACK_CONFIG, build_fully_populated_config, build_minimal_config, @@ -188,8 +193,8 @@ def test_sensitive_with_path(self) -> None: ) def test_sensitive_with_empty_string(self) -> None: - """Test sensitive masking with empty string returns 'configured'.""" - assert mask_value("", MaskingType.SENSITIVE) == CONFIGURED + """Test sensitive masking with empty string returns 'not_configured'.""" + assert mask_value("", MaskingType.SENSITIVE) == NOT_CONFIGURED def test_passthrough_bool(self) -> None: """Test passthrough returns bool as-is.""" @@ -211,6 +216,45 @@ def test_passthrough_list(self) -> None: """Test passthrough with list returns list.""" assert mask_value(["GET", "POST"], MaskingType.PASSTHROUGH) == ["GET", "POST"] + def test_rag_sources_with_okp(self) -> None: + """Test RAG_SOURCES summarizes ids as count + okp_enabled flag.""" + assert mask_value( + [constants.OKP_RAG_ID, "my-rag"], MaskingType.RAG_SOURCES + ) == { + "count": 2, + "okp_enabled": True, + } + + def test_rag_sources_without_okp(self) -> None: + """Test RAG_SOURCES reports okp_enabled False when sentinel absent.""" + assert mask_value(["a", "b", "c"], MaskingType.RAG_SOURCES) == { + "count": 3, + "okp_enabled": False, + } + + def test_rag_sources_empty(self) -> None: + """Test RAG_SOURCES with empty list reports zero count.""" + assert mask_value([], MaskingType.RAG_SOURCES) == { + "count": 0, + "okp_enabled": False, + } + + def test_rag_sources_none(self) -> None: + """Test RAG_SOURCES with None reports zero count.""" + assert mask_value(None, MaskingType.RAG_SOURCES) == { + "count": 0, + "okp_enabled": False, + } + + def test_rag_sources_never_leaks_ids(self) -> None: + """Test RAG_SOURCES never emits the raw (potentially PII) source ids.""" + sensitive_id = "sensitive-private-rag-id" + result = mask_value( + [sensitive_id, constants.OKP_RAG_ID], MaskingType.RAG_SOURCES + ) + assert sensitive_id not in str(result) + assert result == {"count": 2, "okp_enabled": True} + # ============================================================================= # Tests: _set_nested_value @@ -448,6 +492,10 @@ def test_list_field_mcp_servers(self) -> None: assert mcp[0]["name"] == "my-mcp-server" assert mcp[0]["provider_id"] == "model-context-protocol" assert mcp[0]["url"] == CONFIGURED + assert mcp[0]["authorization_headers"] == CONFIGURED + assert mcp[0]["headers"] == CONFIGURED + assert mcp[0]["require_approval"] == "always" + assert mcp[0]["timeout"] == 60 def test_empty_mcp_servers(self) -> None: """Test empty MCP servers list.""" @@ -490,6 +538,521 @@ def test_database_ssl_mode_passthrough(self) -> None: assert snapshot["database"]["postgres"]["ssl_mode"] == "verify-full" assert snapshot["database"]["postgres"]["gss_encmode"] == "prefer" + def test_service_base_url_masked(self) -> None: + """Test service base_url is masked as sensitive.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["service"]["base_url"] == CONFIGURED + + def test_service_base_url_none(self) -> None: + """Test service base_url when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["service"]["base_url"] == NOT_CONFIGURED + + def test_service_root_path_masked(self) -> None: + """Test service root_path is masked as sensitive.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["service"]["root_path"] == CONFIGURED + + def test_llama_stack_timeout_passthrough(self) -> None: + """Test llama_stack timeout passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["llama_stack"]["timeout"] == 180 + + def test_llama_stack_max_retries_passthrough(self) -> None: + """Test llama_stack max_retries passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["llama_stack"]["max_retries"] == 5 + + def test_llama_stack_retry_delay_passthrough(self) -> None: + """Test llama_stack retry_delay passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["llama_stack"]["retry_delay"] == 2 + + def test_llama_stack_allow_degraded_mode_passthrough(self) -> None: + """Test llama_stack allow_degraded_mode passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["llama_stack"]["allow_degraded_mode"] is True + + def test_llama_stack_config_baseline_passthrough(self) -> None: + """Test llama_stack config baseline passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["llama_stack"]["config"]["baseline"] == "default" + + def test_llama_stack_config_profile_masked(self) -> None: + """Test llama_stack config profile is masked as sensitive.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["llama_stack"]["config"]["profile"] == CONFIGURED + + def test_llama_stack_config_native_override_masked(self) -> None: + """Test llama_stack config native_override is masked as sensitive.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["llama_stack"]["config"]["native_override"] == CONFIGURED + + def test_llama_stack_config_none(self) -> None: + """Test llama_stack config fields when config is None.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["llama_stack"]["config"]["baseline"] is None + assert snapshot["llama_stack"]["config"]["profile"] == NOT_CONFIGURED + assert snapshot["llama_stack"]["config"]["native_override"] == NOT_CONFIGURED + + def test_inference_context_windows_passthrough(self) -> None: + """Test inference context_windows passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["inference"]["context_windows"] == { + "openai/gpt-4o-mini": 128000 + } + + def test_inference_max_infer_iters_passthrough(self) -> None: + """Test inference max_infer_iters passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["inference"]["max_infer_iters"] == 10 + + def test_inference_max_tool_calls_passthrough(self) -> None: + """Test inference max_tool_calls passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["inference"]["max_tool_calls"] == 30 + + def test_inference_providers_extraction(self) -> None: + """Test inference providers list extraction with masking.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + providers = snapshot["inference"]["providers"] + assert isinstance(providers, list) + assert len(providers) == 1 + assert providers[0]["type"] == "openai" + assert providers[0]["id"] == "openai-provider" + assert providers[0]["api_key_env"] == CONFIGURED + assert providers[0]["allowed_models"] == ["gpt-4o-mini", "gpt-4o"] + + def test_inference_providers_empty(self) -> None: + """Test inference providers when empty.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["inference"]["providers"] == [] + + def test_authentication_skip_for_health_probes(self) -> None: + """Test authentication skip_for_health_probes passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["authentication"]["skip_for_health_probes"] is True + + def test_authentication_skip_for_metrics(self) -> None: + """Test authentication skip_for_metrics passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["authentication"]["skip_for_metrics"] is True + + def test_authentication_api_key_config_masked(self) -> None: + """Test authentication api_key_config.api_key is masked.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["authentication"]["api_key_config"]["api_key"] == CONFIGURED + + def test_authentication_api_key_config_none(self) -> None: + """Test authentication api_key_config when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["authentication"]["api_key_config"]["api_key"] == NOT_CONFIGURED + + def test_authentication_rh_identity_config(self) -> None: + """Test authentication rh_identity_config fields.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert ( + snapshot["authentication"]["rh_identity_config"]["required_entitlements"] + == CONFIGURED + ) + assert ( + snapshot["authentication"]["rh_identity_config"]["max_header_size"] == 16384 + ) + + def test_authentication_rh_identity_config_none(self) -> None: + """Test authentication rh_identity_config when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert ( + snapshot["authentication"]["rh_identity_config"]["required_entitlements"] + == NOT_CONFIGURED + ) + assert ( + snapshot["authentication"]["rh_identity_config"]["max_header_size"] is None + ) + + def test_authentication_trusted_proxy_config(self) -> None: + """Test authentication trusted_proxy_config fields.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert ( + snapshot["authentication"]["trusted_proxy_config"]["user_header"] + == "X-Forwarded-User" + ) + accounts = snapshot["authentication"]["trusted_proxy_config"][ + "allowed_service_accounts" + ] + assert isinstance(accounts, list) + assert len(accounts) == 1 + assert accounts[0]["namespace"] == CONFIGURED + assert accounts[0]["name"] == CONFIGURED + + def test_authentication_trusted_proxy_config_none(self) -> None: + """Test authentication trusted_proxy_config when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["authentication"]["trusted_proxy_config"]["user_header"] is None + assert ( + snapshot["authentication"]["trusted_proxy_config"][ + "allowed_service_accounts" + ] + == NOT_CONFIGURED + ) + + def test_azure_entra_id_fields(self) -> None: + """Test azure_entra_id fields are properly masked.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["azure_entra_id"]["tenant_id"] == CONFIGURED + assert snapshot["azure_entra_id"]["client_id"] == CONFIGURED + assert snapshot["azure_entra_id"]["client_secret"] == CONFIGURED + assert ( + snapshot["azure_entra_id"]["scope"] + == "https://cognitiveservices.azure.com/.default" + ) + + def test_azure_entra_id_none(self) -> None: + """Test azure_entra_id when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["azure_entra_id"]["tenant_id"] == NOT_CONFIGURED + assert snapshot["azure_entra_id"]["client_id"] == NOT_CONFIGURED + assert snapshot["azure_entra_id"]["client_secret"] == NOT_CONFIGURED + assert snapshot["azure_entra_id"]["scope"] is None + + def test_customization_profile_path_masked(self) -> None: + """Test customization profile_path is masked as sensitive.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["customization"]["profile_path"] == CONFIGURED + + def test_customization_disable_shield_ids_override(self) -> None: + """Test customization disable_shield_ids_override passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["customization"]["disable_shield_ids_override"] is True + + def test_customization_agent_card_path_masked(self) -> None: + """Test customization agent_card_path is masked as sensitive.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["customization"]["agent_card_path"] == CONFIGURED + + def test_conversation_cache_fields(self) -> None: + """Test conversation_cache fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + cache = snapshot["conversation_cache"] + assert cache["type"] == "postgres" + assert cache["memory"]["max_entries"] == 1000 + assert cache["sqlite"]["db_path"] == CONFIGURED + assert cache["postgres"]["host"] == CONFIGURED + assert cache["postgres"]["port"] == 5432 + assert cache["postgres"]["db"] == CONFIGURED + assert cache["postgres"]["user"] == CONFIGURED + assert cache["postgres"]["password"] == CONFIGURED + assert cache["postgres"]["namespace"] == CONFIGURED + assert cache["postgres"]["ssl_mode"] == "verify-full" + assert cache["postgres"]["gss_encmode"] == "prefer" + assert cache["postgres"]["ca_cert_path"] == CONFIGURED + + def test_conversation_cache_none(self) -> None: + """Test conversation_cache when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["conversation_cache"]["type"] is None + assert snapshot["conversation_cache"]["memory"]["max_entries"] is None + assert snapshot["conversation_cache"]["sqlite"]["db_path"] == NOT_CONFIGURED + assert snapshot["conversation_cache"]["postgres"]["host"] == NOT_CONFIGURED + + def test_compaction_fields(self) -> None: + """Test compaction fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + compaction = snapshot["compaction"] + assert compaction["enabled"] is True + assert compaction["threshold_ratio"] == 0.8 + assert compaction["token_floor"] == 8192 + assert compaction["buffer_turns"] == 6 + assert compaction["buffer_max_ratio"] == 0.4 + + def test_compaction_defaults(self) -> None: + """Test compaction fields with default values.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + compaction = snapshot["compaction"] + assert compaction["enabled"] is False + assert compaction["threshold_ratio"] == 0.7 + assert compaction["token_floor"] == 4096 + assert compaction["buffer_turns"] == 4 + assert compaction["buffer_max_ratio"] == 0.3 + + def test_quota_handlers_fields(self) -> None: + """Test quota_handlers fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + qh = snapshot["quota_handlers"] + assert qh["sqlite"]["db_path"] == CONFIGURED + assert qh["postgres"]["host"] == CONFIGURED + assert qh["postgres"]["port"] == 5432 + assert qh["postgres"]["db"] == CONFIGURED + assert qh["postgres"]["user"] == CONFIGURED + assert qh["postgres"]["password"] == CONFIGURED + assert qh["postgres"]["namespace"] == CONFIGURED + assert qh["postgres"]["ssl_mode"] == "verify-full" + assert qh["postgres"]["gss_encmode"] == "prefer" + assert qh["postgres"]["ca_cert_path"] == CONFIGURED + assert qh["enable_token_history"] is True + + def test_quota_handlers_limiters(self) -> None: + """Test quota_handlers limiters list extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + limiters = snapshot["quota_handlers"]["limiters"] + assert isinstance(limiters, list) + assert len(limiters) == 1 + assert limiters[0]["type"] == "user_limiter" + assert limiters[0]["name"] == "daily-user-limit" + assert limiters[0]["initial_quota"] == 10000 + assert limiters[0]["quota_increase"] == 0 + assert limiters[0]["period"] == "1 day" + + def test_quota_handlers_scheduler(self) -> None: + """Test quota_handlers scheduler fields.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + scheduler = snapshot["quota_handlers"]["scheduler"] + assert scheduler["period"] == 5 + assert scheduler["database_reconnection_count"] == 10 + assert scheduler["database_reconnection_delay"] == 2 + + def test_quota_handlers_none(self) -> None: + """Test quota_handlers when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["quota_handlers"]["sqlite"]["db_path"] == NOT_CONFIGURED + assert snapshot["quota_handlers"]["postgres"]["host"] == NOT_CONFIGURED + + def test_byok_rag_extraction(self) -> None: + """Test rag.byok.stores list extraction with masking.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + byok = snapshot["rag"]["byok"]["stores"] + assert isinstance(byok, list) + assert len(byok) == 1 + # rag_id / vector_db_id are user-chosen names -> masked as sensitive + assert byok[0]["rag_id"] == CONFIGURED + assert byok[0]["backend"] == "faiss" + assert byok[0]["embedding_model"] == "all-MiniLM-L6-v2" + assert byok[0]["embedding_dimension"] == 384 + assert byok[0]["vector_db_id"] == CONFIGURED + assert byok[0]["db_path"] == CONFIGURED + assert byok[0]["score_multiplier"] == 1.5 + assert byok[0]["relevance_cutoff_score"] == 0.42 + assert byok[0]["host"] == CONFIGURED + assert byok[0]["port"] == BYOK_PORT + assert byok[0]["db"] == CONFIGURED + assert byok[0]["user"] == CONFIGURED + assert byok[0]["password"] == CONFIGURED + + def test_byok_rag_empty(self) -> None: + """Test rag.byok.stores when empty.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["rag"]["byok"]["stores"] == [] + + def test_a2a_state_fields(self) -> None: + """Test a2a_state fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + a2a = snapshot["a2a_state"] + assert a2a["sqlite"]["db_path"] == CONFIGURED + assert a2a["postgres"]["host"] == CONFIGURED + assert a2a["postgres"]["port"] == 5432 + assert a2a["postgres"]["db"] == CONFIGURED + assert a2a["postgres"]["user"] == CONFIGURED + assert a2a["postgres"]["password"] == CONFIGURED + assert a2a["postgres"]["namespace"] == CONFIGURED + assert a2a["postgres"]["ssl_mode"] == "verify-full" + assert a2a["postgres"]["gss_encmode"] == "prefer" + assert a2a["postgres"]["ca_cert_path"] == CONFIGURED + + def test_a2a_state_none(self) -> None: + """Test a2a_state when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["a2a_state"]["sqlite"]["db_path"] == NOT_CONFIGURED + assert snapshot["a2a_state"]["postgres"]["host"] == NOT_CONFIGURED + + def test_splunk_fields(self) -> None: + """Test splunk fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + splunk = snapshot["splunk"] + assert splunk["enabled"] is True + assert splunk["url"] == CONFIGURED + assert splunk["token_path"] == CONFIGURED + assert splunk["index"] == CONFIGURED + assert splunk["source"] == "lightspeed-stack" + assert splunk["timeout"] == 5 + assert splunk["verify_ssl"] is True + + def test_splunk_none(self) -> None: + """Test splunk when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["splunk"]["enabled"] is None + assert snapshot["splunk"]["url"] == NOT_CONFIGURED + assert snapshot["splunk"]["token_path"] == NOT_CONFIGURED + assert snapshot["splunk"]["index"] == NOT_CONFIGURED + assert snapshot["splunk"]["source"] is None + + def test_rag_fields(self) -> None: + """Test rag retrieval strategy fields extraction. + + sources are summarized as {count, okp_enabled}: the user-chosen rag_ids + are not emitted, but the OKP sentinel is surfaced as a boolean. + """ + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + retrieval = snapshot["rag"]["retrieval"] + # inline sources = ["okp", "my-rag"] -> 2 sources, OKP enabled + assert retrieval["inline"]["sources"] == {"count": 2, "okp_enabled": True} + # tool sources = ["my-rag"] -> 1 source, OKP not enabled + assert retrieval["tool"]["sources"] == {"count": 1, "okp_enabled": False} + + def test_rag_defaults(self) -> None: + """Test rag retrieval strategy fields with defaults.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + retrieval = snapshot["rag"]["retrieval"] + assert retrieval["inline"]["sources"] == {"count": 0, "okp_enabled": False} + assert retrieval["tool"]["sources"] == {"count": 0, "okp_enabled": False} + + def test_okp_fields(self) -> None: + """Test okp fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + okp = snapshot["rag"]["okp"] + assert okp["rhokp_url"] == CONFIGURED + assert okp["offline"] is True + # chunk_filter_query is passthrough (not treated as PII) + assert okp["chunk_filter_query"] == OKP_CHUNK_FILTER + assert okp["search_mode"] == "hybrid" + assert okp["max_chunks"] == 5 + + def test_okp_defaults(self) -> None: + """Test okp fields with defaults.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + okp = snapshot["rag"]["okp"] + assert okp["rhokp_url"] == NOT_CONFIGURED + assert okp["offline"] is True + assert okp["chunk_filter_query"] is None + assert okp["search_mode"] is None + + def test_reranker_fields(self) -> None: + """Test reranker fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + reranker = snapshot["rag"]["retrieval"]["inline"]["reranker"] + assert reranker["enabled"] is True + assert reranker["model"] == "cross-encoder/ms-marco-MiniLM-L6-v2" + + def test_reranker_defaults(self) -> None: + """Test reranker fields with defaults.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + reranker = snapshot["rag"]["retrieval"]["inline"]["reranker"] + assert reranker["enabled"] is False + assert reranker["model"] == "cross-encoder/ms-marco-MiniLM-L6-v2" + + def test_approvals_fields(self) -> None: + """Test approvals fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["approvals"]["approval_timeout_seconds"] == 600 + assert snapshot["approvals"]["approval_retention_days"] == 90 + + def test_approvals_defaults(self) -> None: + """Test approvals fields with defaults.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["approvals"]["approval_timeout_seconds"] == 300 + assert snapshot["approvals"]["approval_retention_days"] == 30 + + def test_rlsapi_v1_fields(self) -> None: + """Test rlsapi_v1 fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["rlsapi_v1"]["allow_verbose_infer"] is True + assert snapshot["rlsapi_v1"]["quota_subject"] == "user_id" + + def test_rlsapi_v1_defaults(self) -> None: + """Test rlsapi_v1 fields with defaults.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["rlsapi_v1"]["allow_verbose_infer"] is False + assert snapshot["rlsapi_v1"]["quota_subject"] is None + + def test_saved_prompts_fields(self) -> None: + """Test saved_prompts fields extraction.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["saved_prompts"]["max_prompts_per_user"] == 100 + assert snapshot["saved_prompts"]["max_display_name_length"] == 200 + assert snapshot["saved_prompts"]["max_content_length"] == 5000 + + def test_saved_prompts_defaults(self) -> None: + """Test saved_prompts fields with defaults.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["saved_prompts"]["max_prompts_per_user"] == 50 + assert snapshot["saved_prompts"]["max_display_name_length"] == 255 + assert snapshot["saved_prompts"]["max_content_length"] == 10000 + + def test_skills_paths_masked(self) -> None: + """Test skills paths is masked as sensitive.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["skills"]["paths"] == CONFIGURED + + def test_skills_none(self) -> None: + """Test skills when not configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["skills"]["paths"] == NOT_CONFIGURED + + def test_deployment_environment_passthrough(self) -> None: + """Test deployment_environment passes through.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["deployment_environment"] == "production" + + def test_deployment_environment_default(self) -> None: + """Test deployment_environment with default value.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["deployment_environment"] == "development" + + def test_config_format_version_passthrough(self) -> None: + """Test config_format_version passes through as its actual value.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + assert snapshot["config_format_version"] == "unified" + + def test_config_format_version_none(self) -> None: + """Test config_format_version passes through as None when unset.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["config_format_version"] is None + + def test_vector_store_fields(self) -> None: + """Test vector_store.providers extraction with masking.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + # default_provider / provider ids are user-chosen names -> masked + assert snapshot["vector_store"]["default_provider"] == CONFIGURED + providers = snapshot["vector_store"]["providers"] + assert isinstance(providers, list) + assert len(providers) == 2 + # faiss provider: id masked, type passthrough, config.path masked + # (dotted item paths nest into a "config" sub-object) + assert providers[0]["id"] == CONFIGURED + assert providers[0]["type"] == "faiss" + assert providers[0]["embedding_model"] == "all-MiniLM-L6-v2" + assert providers[0]["embedding_dimension"] == 384 + assert providers[0]["config"]["path"] == CONFIGURED + # pgvector provider: connection fields masked + assert providers[1]["id"] == CONFIGURED + assert providers[1]["type"] == "pgvector" + assert providers[1]["config"]["host"] == CONFIGURED + assert providers[1]["config"]["port"] == 5432 + assert providers[1]["config"]["db"] == CONFIGURED + assert providers[1]["config"]["user"] == CONFIGURED + assert providers[1]["config"]["password"] == CONFIGURED + + def test_vector_store_empty(self) -> None: + """Test vector_store with no providers configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["vector_store"]["default_provider"] == NOT_CONFIGURED + assert snapshot["vector_store"]["providers"] == [] + + def test_shields_extraction(self) -> None: + """Test shields list extraction with masking.""" + snapshot = build_lightspeed_stack_snapshot(build_fully_populated_config()) + shields = snapshot["shields"] + assert isinstance(shields, list) + assert len(shields) == 2 + assert shields[0]["name"] == "question-validity" + assert shields[0]["provider_id"] == "question_validity" + assert shields[1]["name"] == "pii-redaction" + assert shields[1]["provider_id"] == "redaction" + + def test_shields_empty(self) -> None: + """Test shields when none configured.""" + snapshot = build_lightspeed_stack_snapshot(build_minimal_config()) + assert snapshot["shields"] == [] + # ============================================================================= # Tests: build_llama_stack_snapshot