Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## [v5.2.1] - 2026-10-14

### Fixed

- **Configuration label isolation.** Use the explicit null-label selector
instead of the wildcard so UI-specific secret references are not selected
by the orchestrator; existing service-label precedence is preserved.

### Added

- **Opt-in configuration TLS compatibility.** `APP_CONFIG_TLS_COMPATIBILITY=true`
enables verified P-256/TCP MSS 1200 transports only for the App Configuration
endpoint and the Key Vault supplied by `KEY_VAULT_URI`; the default SDK
transport, authentication and certificate validation remain unchanged.

## [v5.2.0] - 2026-10-06

### Removed
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.1.1
5.2.1
4 changes: 2 additions & 2 deletions azure.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: azure-gpt-rag
name: agent-landing-zone
metadata:
template: azure-gpt-rag-orchestrator
template: agent-app-orchestrator
infra:
provider: bicep
path: infra
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "gpt-rag-orchestrator-dashboard",
"name": "agent-app-orchestrator-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/OverviewTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
import { clampToToday, rangeLabel, type OverviewRange } from "./overview/range";
import { OVERVIEW_TOOLTIPS } from "./overview/copy";

const STORAGE_KEY = "gpt-rag-orchestrator.overview.range";
const STORAGE_KEY = "agent-app-orchestrator.overview.range";
const DEFAULT_RANGE: OverviewRange = { preset: "30d" };

function readStoredRange(): OverviewRange {
Expand Down
2 changes: 1 addition & 1 deletion src/api/hosted_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,7 @@ def create_app(

hosted_app = HostedResponsesAgentServerHost(
options=ResponsesServerOptions(
additional_server_version=f"gpt-rag-orchestrator/{_APP_VERSION}",
additional_server_version=f"agent-app-orchestrator/{_APP_VERSION}",
),
store=store,
routes=[
Expand Down
172 changes: 150 additions & 22 deletions src/connectors/appconfig.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,138 @@
import os
import logging
import socket
import ssl
from builtins import type as ValueType
from collections.abc import Callable, Mapping, Sequence
from typing import Any
from urllib.parse import urlsplit

import requests
from requests.adapters import HTTPAdapter
from urllib3.connection import HTTPConnection
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from azure.core.exceptions import ClientAuthenticationError
from azure.core.pipeline.transport import RequestsTransport
from azure.appconfiguration import (
AzureAppConfigurationClient,
ConfigurationSetting,
)
from azure.appconfiguration.provider import (
AzureAppConfigurationKeyVaultOptions,
AzureAppConfigurationProvider,
load,
SettingSelector,
)
from connectors.identity_manager import get_identity_manager

from tenacity import retry, wait_random_exponential, stop_after_attempt, RetryError
from tenacity import retry, wait_random_exponential, stop_after_attempt, RetryError, RetryCallState


def _with_legacy_app_label(extra_selector):
"""Return a provider loader that also reads the legacy app label after the current one."""
def _load(*args, selects, **kwargs):
def _tls_context(cafile: str | None = None) -> ssl.SSLContext:
context = (
ssl.create_default_context(cafile=cafile)
if cafile is not None
else ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.set_ecdh_curve("prime256v1")
return context


class _TLSCompatibilityAdapter(HTTPAdapter):
def __init__(self) -> None:
self.tls_context = _tls_context(requests.certs.where())
super().__init__()

def init_poolmanager(
self, connections: int, maxsize: int, block: bool = False, **kwargs: Any
) -> None:
kwargs["socket_options"] = [
*HTTPConnection.default_socket_options,
(socket.IPPROTO_TCP, socket.TCP_MAXSEG, 1200),
]
super().init_poolmanager(connections, maxsize, block, **kwargs)

def build_connection_pool_key_attributes(
self,
request: requests.PreparedRequest,
verify: bool | str,
cert: str | tuple[str, str] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
if verify is False:
raise ValueError("Configuration transport requires certificate verification.")
host, options = super().build_connection_pool_key_attributes(request, verify, cert)
# A custom CA must not inherit the default bundle or another caller's roots.
options["ssl_context"] = _tls_context() if isinstance(verify, str) else self.tls_context
options["socket_options"] = self.poolmanager.connection_pool_kw["socket_options"]
return host, options

def send(
self,
request: requests.PreparedRequest,
stream: bool = False,
timeout: float | tuple[float | None, float | None] | None = None,
verify: bool | str = True,
cert: str | tuple[str, str] | None = None,
proxies: dict[str, str] | None = None,
) -> requests.Response:
try:
return super().send(
request, stream=stream, timeout=timeout, verify=verify, cert=cert,
proxies=proxies,
)
except requests.RequestException as exc:
logging.getLogger(__name__).error(
"Configuration TLS compatibility request failed: error_type=%s",
ValueType(exc).__name__,
)
raise


def _create_appconfig_transport(endpoint: str) -> RequestsTransport:
parsed = urlsplit(endpoint)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in ("", "/")
or parsed.query
or parsed.fragment
):
raise ValueError("Configuration transport requires a plain HTTPS endpoint.")
session = requests.Session()
session.mount(f"https://{parsed.netloc}/", _TLSCompatibilityAdapter())
return RequestsTransport(session=session, session_owner=True)


def _with_legacy_app_label(
extra_selector: SettingSelector,
*,
transport: RequestsTransport | None = None,
key_vault_client_configs: Mapping[str, Mapping[str, Any]] | None = None,
) -> Callable[..., AzureAppConfigurationProvider]:
"""Adapt provider selectors and optional bootstrap transport without replacing credentials."""
def _load(
*args: Any, selects: Sequence[SettingSelector], **kwargs: Any
) -> AzureAppConfigurationProvider:
merged = list(selects)
merged.insert(min(2, len(merged)), extra_selector)
if transport is not None:
kwargs["transport"] = transport
if key_vault_client_configs is not None:
kwargs["key_vault_options"].client_configs = key_vault_client_configs
return load(*args, selects=merged, **kwargs)
return _load


class AppConfigClient:

credential = None
aiocredential = None
credential: TokenCredential | None = None
aiocredential: AsyncTokenCredential | None = None

def __init__(self):
def __init__(self) -> None:
"""
Bulk-loads all keys into an in-memory dict from the most common labels used by Agent Landing Zone:
- 'orchestrator' (legacy / shared deployments)
Expand All @@ -42,7 +145,7 @@ def __init__(self):
# Defaults
self.disabled = False
self.auth_failed = False
self.client = {}
self.client: Mapping[str, Any] = {}

# ==== Load all config parameters in one place ====
# NOTE: Do not default to "*" for client_id.
Expand Down Expand Up @@ -77,8 +180,30 @@ def __init__(self):
orchestrator_label_selector = SettingSelector(label_filter=APP_LABEL, key_filter='*')
legacy_app_label_selector = SettingSelector(label_filter=LEGACY_APP_LABEL, key_filter='*')
base_label_selector = SettingSelector(label_filter=AGENTLZ_LABEL, key_filter='*')
no_label_selector = SettingSelector(label_filter=None, key_filter='*')
load = _with_legacy_app_label(legacy_app_label_selector)
no_label_selector = SettingSelector(label_filter="\0", key_filter='*')

tls_compatibility = os.getenv("APP_CONFIG_TLS_COMPATIBILITY", "false").strip().lower()
if tls_compatibility not in ("true", "false"):
raise ValueError("APP_CONFIG_TLS_COMPATIBILITY must be true or false.")
transport = None
key_vault_client_configs = None
if tls_compatibility == "true":
vault_endpoint = os.getenv("KEY_VAULT_URI", "").strip()
if not vault_endpoint:
raise ValueError("APP_CONFIG_TLS_COMPATIBILITY requires KEY_VAULT_URI.")
vault_transport = _create_appconfig_transport(vault_endpoint)
transport = _create_appconfig_transport(endpoint)
key_vault_client_configs = {
vault_endpoint.rstrip("/") + "/": {"transport": vault_transport}
}
logging.info(
"Configuration TLS compatibility is enabled for App Configuration "
"and the configured Key Vault; certificate verification remains enabled."
)
load = _with_legacy_app_label(
legacy_app_label_selector, transport=transport,
key_vault_client_configs=key_vault_client_configs,
)

logging.info(
"Azure App Configuration init: endpoint_host=%s identity=%s allow_env_vars=%s labels=%s",
Expand Down Expand Up @@ -119,15 +244,15 @@ def __init__(self):
self.client = {}


def get(self, key: str, default: Any = None, type: type = str) -> Any:
def get(self, key: str, default: Any = None, type: ValueType[Any] | None = str) -> Any:
return self.get_value(key, default=default, allow_none=False, type=type)
def get_value(self, key: str, default: str = None, allow_none: bool = False, type: type = str) -> str:

def get_value(self, key: str, default: Any = None, allow_none: bool = False, type: ValueType[Any] | None = str) -> Any:

if key is None:
raise Exception('The key parameter is required for get_value().')

value = None
value: Any = None

allow_env_vars = False
if "allow_environment_variables" in os.environ:
Expand Down Expand Up @@ -162,16 +287,16 @@ def get_value(self, key: str, default: str = None, allow_none: bool = False, typ
else:
if default is not None or allow_none is True:
return default

raise Exception(f'The configuration variable {key} not found.')

@staticmethod
def retry_before_sleep(retry_state):
def retry_before_sleep(retry_state: RetryCallState) -> None:
# Log the outcome of each retry attempt.
message = f"""Retrying {retry_state.fn}:
attempt {retry_state.attempt_number}
ended with: {retry_state.outcome}"""
if retry_state.outcome.failed:
if retry_state.outcome is not None and retry_state.outcome.failed:
ex = retry_state.outcome.exception()
message += f"; Exception: {ex.__class__.__name__}: {ex}"
if retry_state.attempt_number < 1:
Expand All @@ -184,7 +309,7 @@ def retry_before_sleep(retry_state):
stop=stop_after_attempt(5),
before_sleep=retry_before_sleep
)
def get_config_with_retry(self, name):
def get_config_with_retry(self, name: str) -> Any:
if self.disabled or not self.client:
return None
try:
Expand All @@ -193,15 +318,15 @@ def get_config_with_retry(self, name):
return None

# Helper functions for reading environment variables
def read_env_variable(self, var_name, default=None):
def read_env_variable(self, var_name: str, default: str | None = None) -> str | None:
value = self.get_value(var_name, default)
return value.strip() if value else default

def read_env_list(self, var_name):
def read_env_list(self, var_name: str) -> list[str]:
value = self.get_value(var_name, "")
return [item.strip() for item in value.split(",") if item.strip()]

def read_env_boolean(self, var_name, default=False):
def read_env_boolean(self, var_name: str, default: bool = False) -> bool:
value = self.get_value(var_name, str(default)).strip().lower()
return value in ['true', '1', 'yes']

Expand Down Expand Up @@ -236,6 +361,9 @@ def set_value(self, key: str, value: Any, label: str = "agent-app-orchestrator")
if not endpoint:
raise RuntimeError("APP_CONFIG_ENDPOINT is not set.")

if self.credential is None:
raise RuntimeError("Azure App Configuration credentials are not initialized.")

if isinstance(value, bool):
str_value = "true" if value else "false"
else:
Expand Down
2 changes: 1 addition & 1 deletion src/constants.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
APPLICATION_INSIGHTS_CONNECTION_STRING = "APPLICATIONINSIGHTS_CONNECTION_STRING"
APP_NAME = "gpt-rag-orchestrator"
APP_NAME = "agent-app-orchestrator"
Loading
Loading