diff --git a/ReleaseNotes/version2.2.md b/ReleaseNotes/version2.2.md index b8466c40..d3679bc1 100644 --- a/ReleaseNotes/version2.2.md +++ b/ReleaseNotes/version2.2.md @@ -1,5 +1,33 @@ # Release Notes # + +2.2.10 + +Proxy: + +* Implement app configuration +* Added AppConfiguration reader +* Moved default values out of BackendHostConfigurationExtension and into BackendOptions +* Read WarmOptions every 30 seconds ( AZURE_APPCONFIG_REFRESH_SECONDS ) +* Markup BackendOptions as either: warm, cold or hidden +* Bug fix for AllUsage-2 processor +* Enable HealthProbe Sidecar as a Warm config parameter +* Enable Host settings to be activated via appconfig + +Deployment: +* Add AppConfiguration/appdeploy.sh to setup appconfiguration configure ACA to use it +* bug fix: Create multple entries at once rather than one at a time + +2.2.10-D1 + +Proxy: +* Implement partial matches for validated parameters +* Remove blocking operations during shutdown +* Convert HostHealthCollection to to HostCollectionSnapshot +* Added CompleteAllUsageProcessor for parsing the entire file +* Add StripPrefix to host config to optionally remove the match part of the path +* EVENT_LOGGERS can now be used to specify spcific loggers, including custom handlers + ## 2.2.9-D2 Deployment: diff --git a/SimpleL7Proxy.sln b/SimpleL7Proxy.sln index 8c100101..fd973ffb 100644 --- a/SimpleL7Proxy.sln +++ b/SimpleL7Proxy.sln @@ -15,9 +15,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{7FB4896E-B EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "test\ProxyWorkerTests\Tests.csproj", "{F562EE95-23FC-48A2-B5CD-21438BFE8926}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "nullserver", "nullserver", "{8911A389-8D4E-4268-90B7-9AC12C59861E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "nullserver", "test\nullserver\nullserver\nullserver.csproj", "{9F502DB0-81A5-4AAB-B915-AAABAA55B0A3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "nullserver", "test\nullserver\dotnet\nullserver.csproj", "{9F502DB0-81A5-4AAB-B915-AAABAA55B0A3}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "generator", "generator", "{5315AE06-D9C2-456F-BE0C-4B6996540CC9}" EndProject diff --git a/deployment/AppConfiguration/README.md b/deployment/AppConfiguration/README.md new file mode 100644 index 00000000..cb49d8cd --- /dev/null +++ b/deployment/AppConfiguration/README.md @@ -0,0 +1,155 @@ +# Azure App Configuration Deployment + +Provisions an Azure App Configuration store and populates it with the proxy's +**publishable** settings — both **Warm** (hot-reloaded) and **Cold** +(requires restart). The running proxy watches a single sentinel key and +hot-reloads all warm settings when it changes — no restart required. +Cold settings are published so they can be centrally managed, but changing +them requires a Container App restart. + +## Config Modes + +Every `BackendOptions` property can be decorated with +`[ConfigOption("Category:Name")]`. The `Mode` parameter controls how +the property is treated: + +| Mode | Published to App Config? | Hot-reloaded? | Notes | +|---|---|---|---| +| **Warm** (default) | ✅ | ✅ | Value changes take effect within ~30 s | +| **Cold** | ✅ | ❌ | Requires a Container App restart | +| **Hidden** | ❌ | ❌ | Composite / derived at runtime — skipped by deploy.sh | + +## Prerequisites + +| Requirement | Details | +|---|---| +| **Azure CLI** | `az` ≥ 2.50 with the `containerapp` extension | +| **jq** | Used to parse the Container App JSON | +| **Azure login** | `az login` (the script will prompt if needed) | +| **A running Container App** | The script reads its env vars as the source of truth for warm values | +| **Bash 4+** | Uses associative arrays (`declare -A`) | + +## Quick Start + +```bash +cd deployment/AppConfiguration + +# 1. Create your parameters file +cp deploy.parameters.example.sh deploy.parameters.sh + +# 2. Edit deploy.parameters.sh with your values +# (see Parameters section below) + +# 3. Run +./deploy.sh +``` + +## Parameters + +All parameters are set in `deploy.parameters.sh`. + +| Parameter | Description | +|---|---| +| `CONTAINER_APP_NAME` | Name of the Container App whose env vars are the source of warm values | +| `CONTAINER_APP_RESOURCE_GROUP` | Resource group where the Container App lives | +| `RESOURCE_GROUP` | Resource group for the App Configuration store (created if missing) | +| `LOCATION` | Azure region for the App Configuration store | +| `APPCONFIG_NAME` | Name of the App Configuration store (created if missing) | +| `APPCONFIG_SKU` | `free` or `standard` | +| `APPCONFIG_LABEL` | Label applied to all `Warm:*` keys (empty string = null / no label) | +| `AZURE_APPCONFIG_REFRESH_SECONDS` | Refresh interval written to `Warm:RefreshSeconds` | +| `UPDATE_CONTAINER_APP_ENV` | `true` to push `AZURE_APPCONFIG_ENDPOINT`, `AZURE_APPCONFIG_LABEL`, and `AZURE_APPCONFIG_REFRESH_SECONDS` env vars onto the Container App | + +> **Do not commit `deploy.parameters.sh`** — it contains environment-specific values. +> Only `deploy.parameters.example.sh` is checked in. + +## What the Script Does + +### 1. Read the live Container App + +Queries the Container App deployment and loads every env var from +`containers[0].env` into memory. Also discovers the container name +(needed for the optional env-var update at the end). + +### 2. Ensure the App Configuration store exists + +Creates the resource group and App Configuration store if they don't +already exist. + +### 3. Assign RBAC (first run only) + +Checks whether the signed-in Azure identity has the +**App Configuration Data Owner** role on the store. If not, assigns it +and waits 30 seconds for propagation. + +### 4. Discover config properties from source code + +Parses `BackendOptions.cs` with `awk`, scanning for the `[ConfigOption]` attribute: + +- **`[ConfigOption("Category:Name")]`** — marks a property as warm-reloadable + (the default mode) and defines the key path under the `Warm:` prefix. + The env var name defaults to the property name. +- **`[ConfigOption("Category:Name", ConfigName = "EnvVar")]`** — overrides + the env var name when it differs from the property name (e.g., + `CONTAINER_APP_NAME` for the `ContainerApp` property). +- **`[ConfigOption("Category:Name", Mode = ConfigMode.Cold)]`** — the + property is published to App Config but **not** hot-reloaded. Changing + the value requires a Container App restart. +- **`[ConfigOption("Category:Name", Mode = ConfigMode.Hidden)]`** — the + property is **skipped by deploy.sh**. Its runtime value is composite or + derived (e.g., `IDStr` is built from a prefix + replicaID at startup). +- **`[ParsedConfig("SourceConfig")]`** — marks a non-publishable property + whose default comes from a parsed composite config string (e.g., + `AsyncBlobStorageConfig`, `AsyncSBConfig`). + +Each discovered property (with Mode ≠ Hidden) produces a quad: +`PropertyName | KeyPath | ConfigName | Mode`. + +### 5. Resolve values and publish + +For each publishable property (Warm or Cold): + +1. **Container App env** — look up `ConfigName` in the env vars loaded in + step 1. +2. **Local shell env** — if not found on the Container App, fall back to a + local shell variable with the same name. +3. **Skip** — if neither has a value, the key is skipped. + +Found values are written to the App Config store as `Warm:`. +The output shows the source of each value (`container-app` or `local-env`). + +### 6. Bump the sentinel + +Writes `Warm:Sentinel` with the current UTC timestamp and +`Warm:RefreshSeconds` with the configured interval. The proxy SDK watches +only `Warm:Sentinel` — when it changes, all `Warm:*` keys are reloaded as +a batch. + +### 7. Update Container App env vars (optional) + +If `UPDATE_CONTAINER_APP_ENV=true`, pushes three env vars onto the +Container App so the proxy knows where to connect: + +- `AZURE_APPCONFIG_ENDPOINT` +- `AZURE_APPCONFIG_LABEL` +- `AZURE_APPCONFIG_REFRESH_SECONDS` + +## Re-running + +The script is idempotent. Run it again any time you want to sync the +Container App's current env var values into App Configuration. The +sentinel bump ensures the proxy picks up the new values on its next +refresh cycle. + +## How the Proxy Consumes These Settings + +The proxy's `AzureAppConfigurationRefreshService` (a `BackgroundService`): + +1. Connects to the App Configuration endpoint using managed identity. +2. Selects all keys matching `Warm:*`. +3. Registers `Warm:Sentinel` as the refresh trigger (`refreshAll: true`). +4. Every `RefreshSeconds`, checks if the sentinel changed. +5. On change, reloads all `Warm:*` keys and applies **only Warm-mode** + properties to `BackendOptions` via reflection using the `[ConfigOption]` + attribute metadata. Cold properties are present in the store but + are **not** applied at runtime — they require a restart to take effect. diff --git a/deployment/AppConfiguration/deploy.parameters.example.sh b/deployment/AppConfiguration/deploy.parameters.example.sh new file mode 100644 index 00000000..656b7a3f --- /dev/null +++ b/deployment/AppConfiguration/deploy.parameters.example.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Deployment Parameters for App Configuration warm settings sync +# +# 1) Copy this file to deploy.parameters.sh +# 2) Update values for your environment +# 3) Run ./deploy.sh +# +# The script reads the live Container App to discover env vars and +# container name. Values not found on the Container App fall back to +# local shell variables. +# +# Do not commit deploy.parameters.sh with real values. + +# ============================================================================= +# Container App (source of env var values) +# ============================================================================= +export CONTAINER_APP_NAME="myapp" +export CONTAINER_APP_RESOURCE_GROUP="rg-myapp-prod" + +# ============================================================================= +# App Configuration store +# ============================================================================= +export RESOURCE_GROUP="rg-myapp-appconfig" +export LOCATION="eastus" +export APPCONFIG_NAME="myapp-appcfg" +export APPCONFIG_SKU="standard" + +# Label applied to Warm:* keys. Use '\0' for null label. +export APPCONFIG_LABEL="" + +# Refresh interval (seconds) written to Warm:RefreshSeconds +export AZURE_APPCONFIG_REFRESH_SECONDS="30" + +# Set to "true" to push AZURE_APPCONFIG_ENDPOINT/LABEL/REFRESH_SECONDS +# env vars onto the Container App after publishing keys. +export UPDATE_CONTAINER_APP_ENV="true" diff --git a/deployment/AppConfiguration/deploy.sh b/deployment/AppConfiguration/deploy.sh new file mode 100644 index 00000000..cff22eff --- /dev/null +++ b/deployment/AppConfiguration/deploy.sh @@ -0,0 +1,360 @@ +#!/bin/bash + +# Deploy/Update Azure App Configuration for BackendOptions +# +# Goals: +# 1. Migration – seed App Configuration from a live Container App's +# env vars (with fallback to local shell variables). +# 2. Catalog – every publishable setting is always written so that +# operators can see the full list in the portal. +# When no env value exists, the C# default from +# BackendOptions.cs is used. If even that is empty, +# a "-" placeholder is written, meaning "use the +# built-in code default". +# +# Discovers publishable keys dynamically from [ConfigOption("...")] +# decorations in BackendOptions.cs. +# +# Three modes (ConfigMode enum): +# Warm – published under "Warm:" prefix, hot-reloaded (~30 s) +# Cold – published under "Cold:" prefix, requires Container App restart +# Hidden – not published (skipped by this script) +# +# Key prefix convention: +# Warm settings → Warm:
: (e.g. Warm:Logging:LogConsole) +# Cold settings → Cold:
: (e.g. Cold:Server:Workers) +# The prefix itself tells you the reload mode at a glance in the portal. +# +# Sources env var values from the live Container App deployment, falling +# back to local shell variables when not defined on the Container App. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${SCRIPT_DIR}/../.." + +if [ -f "${SCRIPT_DIR}/deploy.parameters.sh" ]; then + echo "Sourcing deploy.parameters.sh..." + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/deploy.parameters.sh" +elif [ -f "${SCRIPT_DIR}/deploy.parameters.example.sh" ]; then + echo "deploy.parameters.sh not found." + echo "Copy deploy.parameters.example.sh to deploy.parameters.sh and update values." + echo "Example: cp deploy.parameters.example.sh deploy.parameters.sh" +fi + +# ---------------------------------------------------------------------------- +# Required parameters +# ---------------------------------------------------------------------------- +CONTAINER_APP_NAME="${CONTAINER_APP_NAME:?'CONTAINER_APP_NAME must be set'}" +CONTAINER_APP_RESOURCE_GROUP="${CONTAINER_APP_RESOURCE_GROUP:?'CONTAINER_APP_RESOURCE_GROUP must be set'}" +RESOURCE_GROUP="${RESOURCE_GROUP:?'RESOURCE_GROUP must be set (App Configuration resource group)'}" +LOCATION="${LOCATION:?'LOCATION must be set (App Configuration location)'}" +APPCONFIG_NAME="${APPCONFIG_NAME:?'APPCONFIG_NAME must be set'}" + +# ---------------------------------------------------------------------------- +# Optional overrides +# ---------------------------------------------------------------------------- +APPCONFIG_SKU="${APPCONFIG_SKU:-standard}" +APPCONFIG_LABEL="${APPCONFIG_LABEL:-}" +AZURE_APPCONFIG_REFRESH_SECONDS="${AZURE_APPCONFIG_REFRESH_SECONDS:-30}" +UPDATE_CONTAINER_APP_ENV="${UPDATE_CONTAINER_APP_ENV:-true}" + +BACKEND_OPTIONS_FILE="${BACKEND_OPTIONS_FILE:-${REPO_ROOT}/src/SimpleL7Proxy/Config/BackendOptions.cs}" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +# ---------------------------------------------------------------------------- +# Preconditions +# ---------------------------------------------------------------------------- +if ! command -v az >/dev/null 2>&1; then + echo -e "${RED}Error: Azure CLI is not installed.${NC}" + exit 1 +fi + +if [ ! -f "${BACKEND_OPTIONS_FILE}" ]; then + echo -e "${RED}Error: BackendOptions file not found: ${BACKEND_OPTIONS_FILE}${NC}" + exit 1 +fi + +echo -e "${YELLOW}Checking Azure login status...${NC}" +az account show >/dev/null 2>&1 || az login >/dev/null + +SUBSCRIPTION_ID="$(az account show --query id -o tsv)" +echo -e "${GREEN}Using subscription: ${SUBSCRIPTION_ID}${NC}" + +# ---------------------------------------------------------------------------- +# Read the live Container App deployment +# ---------------------------------------------------------------------------- +echo -e "${YELLOW}Reading Container App '${CONTAINER_APP_NAME}' from '${CONTAINER_APP_RESOURCE_GROUP}'...${NC}" +CA_JSON="$(az containerapp show \ + --name "${CONTAINER_APP_NAME}" \ + --resource-group "${CONTAINER_APP_RESOURCE_GROUP}" \ + -o json)" || { echo -e "${RED}Error: Could not read Container App.${NC}"; exit 1; } + +# Derive the container name from the live deployment +CONTAINER_APP_CONTAINER_NAME="$(echo "${CA_JSON}" | jq -r '.properties.template.containers[0].name')" +echo -e "${GREEN}Container: ${CONTAINER_APP_CONTAINER_NAME}${NC}" + +# Build an associative array of the Container App's current env vars +declare -A CA_ENV_VARS +while IFS=$'\t' read -r ename evalue; do + [ -n "${ename}" ] && CA_ENV_VARS["${ename}"]="${evalue}" +done < <(echo "${CA_JSON}" | jq -r ' + .properties.template.containers[0].env[]? + | select(.value != null) + | [.name, .value] + | @tsv +') +echo -e "${GREEN}Loaded ${#CA_ENV_VARS[@]} env vars from Container App${NC}" + +# ---------------------------------------------------------------------------- +# Create or reuse App Configuration store +# ---------------------------------------------------------------------------- +echo -e "${YELLOW}Ensuring resource group '${RESOURCE_GROUP}' exists...${NC}" +az group create --name "${RESOURCE_GROUP}" --location "${LOCATION}" >/dev/null + +EXISTING_APP_CONFIG="$(az appconfig show --name "${APPCONFIG_NAME}" --resource-group "${RESOURCE_GROUP}" --query name -o tsv 2>/dev/null || true)" +if [ -z "${EXISTING_APP_CONFIG}" ]; then + echo -e "${YELLOW}Creating App Configuration store '${APPCONFIG_NAME}'...${NC}" + az appconfig create \ + --name "${APPCONFIG_NAME}" \ + --resource-group "${RESOURCE_GROUP}" \ + --location "${LOCATION}" \ + --sku "${APPCONFIG_SKU}" \ + >/dev/null +else + echo -e "${GREEN}Using existing App Configuration store: ${APPCONFIG_NAME}${NC}" +fi + +APPCONFIG_ENDPOINT="$(az appconfig show --name "${APPCONFIG_NAME}" --resource-group "${RESOURCE_GROUP}" --query endpoint -o tsv)" +APPCONFIG_RESOURCE_ID="$(az appconfig show --name "${APPCONFIG_NAME}" --resource-group "${RESOURCE_GROUP}" --query id -o tsv)" + +# ---------------------------------------------------------------------------- +# Ensure the logged-in identity has data-plane write access (RBAC) +# ---------------------------------------------------------------------------- +PRINCIPAL_ID="$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)" +if [ -n "${PRINCIPAL_ID}" ]; then + EXISTING_ROLE="$(az role assignment list \ + --assignee "${PRINCIPAL_ID}" \ + --role "App Configuration Data Owner" \ + --scope "${APPCONFIG_RESOURCE_ID}" \ + --query "[0].id" -o tsv 2>/dev/null || true)" + + if [ -z "${EXISTING_ROLE}" ]; then + echo -e "${YELLOW}Assigning 'App Configuration Data Owner' role to current user...${NC}" + az role assignment create \ + --assignee "${PRINCIPAL_ID}" \ + --role "App Configuration Data Owner" \ + --scope "${APPCONFIG_RESOURCE_ID}" \ + >/dev/null + echo -e "${YELLOW}Waiting for RBAC propagation (30s)...${NC}" + sleep 30 + else + echo -e "${GREEN}RBAC role already assigned.${NC}" + fi +else + echo -e "${YELLOW}Warning: Could not determine signed-in user principal. Ensure you have 'App Configuration Data Owner' role.${NC}" +fi + +# ---------------------------------------------------------------------------- +# Discover config options dynamically from [ConfigOption("...")] decorations. +# Handles: +# [ConfigOption("Key:Path")] → Mode = Warm (default), ConfigName = PropertyName +# [ConfigOption("Key:Path", ConfigName = "EnvVar")] → Mode = Warm, ConfigName = EnvVar +# [ConfigOption("Key:Path", Mode = ConfigMode.Cold)] → Mode = Cold, ConfigName = PropertyName +# [ConfigOption("Key:Path", Mode = ConfigMode.Hidden)] → Skipped (not published) +# Emit: Property|KeyPath|ConfigName|Mode +# ---------------------------------------------------------------------------- +mapfile -t CONFIG_ENTRIES < <( + awk ' + /\[ConfigOption\("/ { + # Skip entries marked Mode = ConfigMode.Hidden + if ($0 ~ /Mode[[:space:]]*=[[:space:]]*ConfigMode\.Hidden/) next; + + key = ""; + configName = ""; + mode = "Warm"; + prop = ""; + defVal = ""; + + # Extract KeyPath (first positional arg) + if (match($0, /\[ConfigOption\("([^"]+)"/, m)) { + key = m[1]; + } + + # Extract optional ConfigName = "..." on the same line + if (match($0, /ConfigName[[:space:]]*=[[:space:]]*"([^"]+)"/, c)) { + configName = c[1]; + } + + # Extract optional Mode = ConfigMode.Cold on the same line + if ($0 ~ /Mode[[:space:]]*=[[:space:]]*ConfigMode\.Cold/) { + mode = "Cold"; + } + + # Read ahead to find the property declaration + while (getline > 0) { + # Skip other attributes + if ($0 ~ /^[[:space:]]*\[/) continue; + + if ($0 ~ /^[[:space:]]*public[[:space:]]+/) { + if (match($0, /^[[:space:]]*public[[:space:]]+[^ ]+[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\{/, p)) { + prop = p[1]; + # Extract default value from "} = VALUE;" pattern + defVal = ""; + if (match($0, /\}[[:space:]]*=[[:space:]]*(.+);/, dv)) { + defVal = dv[1]; + sub(/[[:space:]]*\/\/.*$/, "", defVal); + sub(/^[[:space:]]+/, "", defVal); + sub(/[[:space:]]+$/, "", defVal); + if (match(defVal, /^"(.*)"$/, q)) { + defVal = q[1]; + } + } + break; + } + } + } + + if (key != "" && prop != "") { + # Default ConfigName to the property name + if (configName == "") configName = prop; + print prop "|" key "|" configName "|" mode "|" defVal; + } + } + ' "${BACKEND_OPTIONS_FILE}" +) + +if [ "${#CONFIG_ENTRIES[@]}" -eq 0 ]; then + echo -e "${RED}No [ConfigOption(...)] decorations found. Nothing to deploy.${NC}" + exit 1 +fi + +# Placeholder written when no env value AND no C# default exist. +# The proxy treats "-" as "use the built-in code default". +DEFAULT_PLACEHOLDER="-" + +echo -e "${YELLOW}Publishing config keys to App Configuration...${NC}" +SET_COUNT=0 +DEFAULT_COUNT=0 +WARM_COUNT=0 +COLD_COUNT=0 + +# Build a single JSON file for batch import (all keys, single label). +IMPORT_JSON_FILE="$(mktemp)" +trap 'rm -f "${IMPORT_JSON_FILE}"' EXIT + +echo "{" > "${IMPORT_JSON_FILE}" +JSON_FIRST=true + +for entry in "${CONFIG_ENTRIES[@]}"; do + PROP_NAME="$(echo "${entry}" | cut -d'|' -f1)" + KEY_PATH="$(echo "${entry}" | cut -d'|' -f2)" + CONFIG_NAME="$(echo "${entry}" | cut -d'|' -f3)" + MODE="$(echo "${entry}" | cut -d'|' -f4)" + CS_DEFAULT="$(echo "${entry}" | cut -d'|' -f5)" + # Prefix matches the mode: Warm:Section:Key or Cold:Section:Key + APP_CONFIG_KEY="${MODE}:${KEY_PATH}" + + ENV_NAME="${CONFIG_NAME:-${PROP_NAME}}" + VALUE="" + SOURCE="" + + # 1) Look up the value from the live Container App env vars + if [ -n "${CA_ENV_VARS[${ENV_NAME}]+x}" ]; then + VALUE="${CA_ENV_VARS[${ENV_NAME}]}" + SOURCE="container-app" + fi + + # 2) Fallback to local shell env vars + if [ -z "${VALUE}" ] && [[ "${ENV_NAME}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + VALUE="${!ENV_NAME-}" + [ -n "${VALUE}" ] && SOURCE="local-env" + fi + + # 3) Fallback to C# default from BackendOptions.cs + if [ -z "${VALUE}" ] && [ -n "${CS_DEFAULT}" ]; then + VALUE="${CS_DEFAULT}" + SOURCE="cs-default" + # Handle enum defaults like "TypeName.Value" → "Value" + # Only match Identifier.Identifier (e.g. IterationModeEnum.SinglePass) + # Avoid mangling URLs, file paths, or floats that also contain dots + if [[ "${VALUE}" =~ ^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$ ]]; then + VALUE="${VALUE##*.}" + fi + fi + + # 4) No value at all → write placeholder so the key is still visible + if [ -z "${VALUE}" ]; then + VALUE="${DEFAULT_PLACEHOLDER}" + SOURCE="placeholder" + fi + + # Escape for JSON (handle backslashes, quotes, newlines) + JSON_VALUE="$(printf '%s' "${VALUE}" | sed 's/\\/\\\\/g; s/"/\\"/g')" + + # Append to the JSON file + if [ "${JSON_FIRST}" = true ]; then JSON_FIRST=false; else echo "," >> "${IMPORT_JSON_FILE}"; fi + printf ' "%s": "%s"' "${APP_CONFIG_KEY}" "${JSON_VALUE}" >> "${IMPORT_JSON_FILE}" + + if [ "${MODE}" = "Cold" ]; then + COLD_COUNT=$((COLD_COUNT + 1)) + else + WARM_COUNT=$((WARM_COUNT + 1)) + fi + + echo -e "${GREEN} ${APP_CONFIG_KEY} = ${VALUE} (${SOURCE}) [${MODE}]${NC}" + SET_COUNT=$((SET_COUNT + 1)) + if [ "${SOURCE}" = "cs-default" ] || [ "${SOURCE}" = "placeholder" ]; then + DEFAULT_COUNT=$((DEFAULT_COUNT + 1)) + fi +done + +# Add Sentinel and RefreshSeconds to the import batch (always Warm) +echo "," >> "${IMPORT_JSON_FILE}" +printf ' "Warm:Sentinel": "%s",\n' "$(date -u +%s)" >> "${IMPORT_JSON_FILE}" +printf ' "Warm:RefreshSeconds": "%s"' "${AZURE_APPCONFIG_REFRESH_SECONDS}" >> "${IMPORT_JSON_FILE}" + +echo "" >> "${IMPORT_JSON_FILE}" +echo "}" >> "${IMPORT_JSON_FILE}" + +# Import all settings in a single batch call +echo -e "${YELLOW}Importing ${SET_COUNT} settings (Warm: ${WARM_COUNT}, Cold: ${COLD_COUNT}) + Sentinel, RefreshSeconds...${NC}" +az appconfig kv import \ + --name "${APPCONFIG_NAME}" \ + --source file \ + --path "${IMPORT_JSON_FILE}" \ + --format json \ + --label "${APPCONFIG_LABEL}" \ + --yes \ + --auth-mode login \ + >/dev/null +echo -e "${GREEN}✓ Import complete${NC}" + +# Optionally wire container app env vars +if [ "${UPDATE_CONTAINER_APP_ENV}" = "true" ]; then + echo -e "${YELLOW}Updating Container App env vars...${NC}" + az containerapp update \ + --name "${CONTAINER_APP_NAME}" \ + --resource-group "${CONTAINER_APP_RESOURCE_GROUP}" \ + --container-name "${CONTAINER_APP_CONTAINER_NAME}" \ + --set-env-vars \ + "AZURE_APPCONFIG_ENDPOINT=${APPCONFIG_ENDPOINT}" \ + "AZURE_APPCONFIG_LABEL=${APPCONFIG_LABEL}" \ + "AZURE_APPCONFIG_REFRESH_SECONDS=${AZURE_APPCONFIG_REFRESH_SECONDS}" \ + >/dev/null || echo -e "${YELLOW}Warning: Could not update Container App env vars (continuing).${NC}" +fi + +echo -e "${GREEN}======================================${NC}" +echo -e "${GREEN}App Configuration deployment complete${NC}" +echo -e "${GREEN}======================================${NC}" +echo -e "${GREEN}Store: ${APPCONFIG_NAME}${NC}" +echo -e "${GREEN}Endpoint: ${APPCONFIG_ENDPOINT}${NC}" +echo -e "${GREEN}Label: ${APPCONFIG_LABEL:-(none)}${NC}" +echo -e "${GREEN}Config keys published: ${SET_COUNT} (Warm: ${WARM_COUNT}, Cold: ${COLD_COUNT})${NC}" +echo -e "${GREEN} of which ${DEFAULT_COUNT} used C# default or '${DEFAULT_PLACEHOLDER}' placeholder${NC}" +echo -e "${GREEN}======================================${NC}" diff --git a/docs/ADVANCED_CONFIGURATION.md b/docs/ADVANCED_CONFIGURATION.md index 7d19c6bf..5435b563 100644 --- a/docs/ADVANCED_CONFIGURATION.md +++ b/docs/ADVANCED_CONFIGURATION.md @@ -70,23 +70,49 @@ DefaultPriority=3 ## Header Validation -You can enforce strict header presence and content validation using `ValidateHeaders`. +You can enforce that a request header's value appears in a comma-separated allow-list stored in another header using `ValidateHeaders`. This is typically combined with **User Profiles**, where the allow-list header is injected from the profile. ### Format -A comma-separated list of `HeaderName:RegexPattern` or `HeaderName:ExactValue` pairs. -* **HeaderName**: The case-insensitive name of the header. -* **Value**: The string or pattern that must match. +A comma-separated list of `SourceHeader:AllowedValuesHeader` pairs. -### Example +* **SourceHeader**: The header whose value is being validated (the "lookup"). +* **AllowedValuesHeader**: The header containing a comma-separated list of allowed values. -Require that `X-Tenant-ID` is `12345` and `X-Region` is `WestUS`. +The proxy checks that the value of `SourceHeader` matches at least one entry in `AllowedValuesHeader`. Both headers must be present on the request (they are automatically added to `RequiredHeaders` at startup). +### Matching Rules + +* **Exact match** (case-insensitive): The lookup value must equal one of the allowed values. +* **Wildcard prefix match**: If an allowed value ends with `*`, the lookup value only needs to *start with* the prefix. For example, `/echo*` matches `/echo`, `/echo/resource`, `/echo/resource?param1=sample1`, etc. + +### Example: Path-Based Access Control + +The proxy automatically copies the request path into the `S7Path` header before validation. Combined with an `AllowedPaths` header from the user profile, you can restrict which URL paths a user is permitted to call. + +**Environment variable:** ```bash -ValidateHeaders="X-Tenant-ID:12345,X-Region:WestUS" +ValidateHeaders="S7Path:AllowedPaths" ``` -If a request arrives without these headers, or with different values, it is rejected (usually with a 403 or 400). +**User profile** (e.g., in Cosmos DB): +```json +{ + "userId": "client-123", + "headers": { + "AllowedPaths": "/api/delay,/api/values,/echo*" + } +} +``` + +**Behavior:** +| Request Path | AllowedPaths | Result | +|---|---|---| +| `/api/delay` | `/api/delay,/api/values,/echo*` | ✅ Exact match | +| `/echo/resource?param1=x` | `/api/delay,/api/values,/echo*` | ✅ Prefix match on `/echo*` | +| `/api/other` | `/api/delay,/api/values,/echo*` | ❌ Rejected (417 Expectation Failed) | + +If validation fails, the request is rejected with HTTP **417 Expectation Failed** and the message `Validation check failed for header: `. --- diff --git a/docs/BACKEND_HOSTS.md b/docs/BACKEND_HOSTS.md index 474843a1..549b7ec0 100644 --- a/docs/BACKEND_HOSTS.md +++ b/docs/BACKEND_HOSTS.md @@ -23,6 +23,7 @@ This method allows you to define all properties for a single host within the `Ho | **processor** | Specifies a custom request processor if available. | (Empty) | | **useoauth** / **usemi** | If `true`, enables Managed Identity/OAuth authentication for this host. | `false` | | **audience** | The expected audience claim for OAuth tokens. | (Empty) | +| **stripprefix** / **strippathprefix** | If `true`, the matched path prefix is stripped when forwarding to the backend. If `false`, the original request path is preserved. | `true` | | **retryafter** | If `true`, respects the `Retry-After` header from the backend. | `true` | **Examples:** @@ -100,7 +101,7 @@ The `path` parameter in the connection string controls which requests are routed ### How Path Matching Works 1. **Specific paths take precedence**: Hosts with explicit paths (e.g., `/api/v1`) are matched before catch-all hosts. -2. **Path prefix is stripped**: When forwarding to a matched host, the matching prefix is removed from the request path. +2. **Path prefix is stripped by default**: When forwarding to a matched host, the matching prefix is removed from the request path. This behavior can be disabled per-host by setting `stripprefix=false` in the connection string. 3. **Catch-all fallback**: Hosts with `path=/` or no path specified handle requests that don't match any specific path. ### Path Matching Examples @@ -131,11 +132,37 @@ Host3="host=https://default-service.internal;path=/" | `/*` | Same as `/` | | (empty) | Same as `/` | +### Controlling Path Prefix Stripping + +By default, when a request matches a host's path prefix, that prefix is removed before forwarding (`stripprefix=true`). You can disable this per-host so the original request path is preserved. + +**Configuration:** +```bash +# Default: prefix is stripped +Host1="host=https://chat-service.internal;path=/chat" + +# Prefix preserved: backend receives the full original path +Host2="host=https://passthrough-service.internal;path=/api/v1;stripprefix=false" +``` + +**Routing comparison:** + +| Incoming Request | Host Config | `stripprefix` | Forwarded Path | +|-----------------|-------------|---------------|----------------| +| `GET /chat/completions` | `path=/chat` | `true` (default) | `GET /completions` | +| `GET /api/v1/users` | `path=/api/v1` | `true` (default) | `GET /users` | +| `GET /api/v1/users` | `path=/api/v1;stripprefix=false` | `false` | `GET /api/v1/users` | +| `GET /chat` | `path=/chat` | `true` (default) | `GET /` | +| `GET /chat` | `path=/chat;stripprefix=false` | `false` | `GET /chat` | + +This is useful when the backend expects the full path including the routing prefix — for example, when the backend application handles its own path-based routing. + ### Best Practices 1. **Use specific paths for service isolation**: Route different AI models or API versions to dedicated backends. 2. **Always have a catch-all**: Include at least one host with `path=/` to handle unexpected routes. 3. **Avoid overlapping paths**: If you have `/api` and `/api/v1`, the more specific path (`/api/v1`) should be tried first. +4. **Use `stripprefix=false` when backends own their routing**: If the backend expects the full original path (e.g., it has its own `/api/v1` routes), disable prefix stripping. See [LOAD_BALANCING.md](LOAD_BALANCING.md) for details on how hosts are selected after path filtering. diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 352400c6..6df20e94 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -78,23 +78,27 @@ For production deployments, consider also configuring: | **TTLHeader** | string | Name of the header used to specify time-to-live for requests. | S7PTTL | | **UniqueUserHeaders** | string | A list of header names that uniquely identify the caller or user. | X-UserID | | **UserProfileHeader** | string | Name of the header that contains user profile information when UseProfiles is enabled. | X-UserProfile | -| **ValidateHeaders** | string | Comma-separated list of key:value pairs for header validation. See [Advanced Configuration](ADVANCED_CONFIGURATION.md#header-validation) for examples. | (empty) | +| **ValidateHeaders** | string | Comma-separated `SourceHeader:AllowedValuesHeader` pairs. Validates that the source header value appears in the allow-list header. Supports trailing `*` for prefix matching. See [Advanced Configuration](ADVANCED_CONFIGURATION.md#header-validation). | (empty) | ## Logging & Monitoring Variables | Variable | Type | Description | Default | | ----------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| **APPINSIGHTS_CONNECTIONSTRING** | string | Specifies the connection string for Azure Application Insights. If set, the service sends logs to the configured Application Insights instance. | None | +| **APPINSIGHTS_CONNECTIONSTRING** | string | Specifies the connection string for Azure Application Insights. If set, the service sends structured telemetry (requests, dependencies, exceptions) to the configured Application Insights instance. App Insights is handled directly by ProxyEvent — not through the event logger pipeline. | None | | **CONTAINER_APP_NAME** | string | The name of the container application to be used in logs and telemetry. This is automatically defined by the ACA environment. | ContainerAppName | | **CONTAINER_APP_REPLICA_NAME** | string | Name/ID of the current container app replica (used for logging and request IDs). This is automatically defined by the ACA environment. | ContainerAppName | | **CONTAINER_APP_REVISION** | string | Revision identifier for the current container app deployment. This is automatically defined by the ACA environment. | ContainerAppName | -| **EVENTHUB_CONNECTIONSTRING** | string | The connection string for EventHub logging. Must also set **EVENTHUB_NAME**. | None | -| **EVENTHUB_NAME** | string | The EventHub namespace for logging. Must also set **EVENTHUB_CONNECTIONSTRING**. | None | +| **EVENT_LOGGERS** | string | Comma-separated list of event logger backends to enable. Built-in values: `file`, `eventhub`. You can also specify a fully-qualified class name within the assembly (e.g., `SimpleL7Proxy.Events.EventHubClient`). Multiple backends run simultaneously. When not set, falls back to legacy `LOGTOFILE` behaviour. | *(legacy fallback)* | +| **EVENTHUB_CONNECTIONSTRING** | string | The connection string for EventHub logging. Required when `eventhub` is in **EVENT_LOGGERS** (unless using **EVENTHUB_NAMESPACE** with managed identity). Must also set **EVENTHUB_NAME**. | None | +| **EVENTHUB_NAME** | string | The EventHub name for logging. Required when `eventhub` is in **EVENT_LOGGERS**. | None | +| **EVENTHUB_NAMESPACE** | string | The EventHub namespace (e.g., `mynamespace` or `mynamespace.servicebus.windows.net`). Used with `DefaultAzureCredential` when **EVENTHUB_CONNECTIONSTRING** is not set. Must also set **EVENTHUB_NAME**. | None | +| **EVENTHUB_STARTUP_SECONDS** | int | Timeout in seconds for the EventHub client to establish a connection during startup. If exceeded, EventHub logging is disabled gracefully (other loggers continue). | 10 | | **LogAllRequestHeaders** | bool | If true, logs all request headers for each proxied request. | false | | **LogAllRequestHeadersExcept** | string | Comma-separated list of request headers to exclude from logging, even if LogAllRequestHeaders is true. | Authorization | | **LogAllResponseHeaders** | bool | If true, logs all response headers for each proxied request. | false | | **LogAllResponseHeadersExcept** | string | Comma-separated list of response headers to exclude from logging, even if LogAllResponseHeaders is true. | Api-Key | -| **LOGFILE** | string | If set, logs events to the specified file instead of EventHub (for debugging/testing only; not for production use). | events.log (if enabled in code) | +| **LOGFILE_NAME** | string | Filename for the local log file when `file` is in **EVENT_LOGGERS** (or when **LOGTOFILE**=true in legacy mode). | eventslog.json | +| **LOGTOFILE** | bool | **Legacy.** When **EVENT_LOGGERS** is not set: `true` enables file logging, `false` enables EventHub logging. Prefer **EVENT_LOGGERS** for new deployments. | false | | **LogHeaders** | string | Comma-separated list of specific headers to log for debugging. | (empty) | | **LogProbes** | bool | If true, logs details about health probe requests to backends. | false | | **LogConsoleEvent** | bool | If true, logs events to the console output. | true | diff --git a/docs/LOAD_BALANCING.md b/docs/LOAD_BALANCING.md index a0576adb..7fd68b55 100644 --- a/docs/LOAD_BALANCING.md +++ b/docs/LOAD_BALANCING.md @@ -1,238 +1,243 @@ -# Load Balancing & Backend Selection - -SimpleL7Proxy uses a sophisticated multi-stage algorithm to select the optimal backend for each request. This document explains how backends are chosen, filtered, and iterated. - -## Algorithm Overview - -``` -REQUEST ARRIVES - │ - ▼ -┌──────────────────────────┐ -│ 1. Filter hosts by path │ → Specific path hosts OR catch-all hosts -└──────────────────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ 2. Create Iterator │ → RoundRobin / Latency / Random -│ (LoadBalanceMode) │ -└──────────────────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ 3. FOR EACH HOST: │ -│ ├─ Circuit breaker OK?│ → Skip if OPEN -│ ├─ TTL not expired? │ → 412 if expired -│ ├─ Send request │ -│ └─ Success? → RETURN │ -└──────────────────────────┘ - │ - ▼ (all hosts failed) -┌──────────────────────────┐ -│ 429s collected? → Requeue│ -│ Else → 503 Service │ -│ Unavailable │ -└──────────────────────────┘ -``` - ---- - -## Stage 1: Path-Based Host Filtering - -Before load balancing, hosts are filtered based on the request path. The proxy maintains two categories of hosts: - -| Category | Description | Example Path | -|----------|-------------|--------------| -| **Specific Path Hosts** | Hosts with explicit path prefixes | `/api/v1/*`, `/chat/*`, `/embeddings` | -| **Catch-All Hosts** | Hosts that handle any path | `/` or `/*` | - -### Matching Rules - -1. **Specific paths take precedence**: If any host's path matches the request, only those hosts are used. -2. **Path prefix is stripped**: When forwarding to a matched host, the matching prefix is removed from the request path. -3. **Catch-all fallback**: If no specific path matches, catch-all hosts are used with the original path. - -### Example - -``` -Configured Hosts: - Host1: path=/api/v1 → https://api-v1.internal - Host2: path=/api/v2 → https://api-v2.internal - Host3: path=/ → https://default.internal - -Request: GET /api/v1/users/123 - -Result: - - Matches Host1 (specific path /api/v1) - - Forwarded as: GET /users/123 to https://api-v1.internal - - Host2 and Host3 are NOT considered -``` - ---- - -## Stage 2: Load Balance Mode - -Once hosts are filtered, an iterator is created based on the configured `LoadBalanceMode`. - -### Available Modes - -| Mode | Environment Variable | Behavior | -|------|---------------------|----------| -| **Round Robin** | `LoadBalanceMode=roundrobin` | Uses a **global counter** shared across all workers. Each request gets the "next" host, ensuring fair distribution. | -| **Latency** | `LoadBalanceMode=latency` | Hosts are **sorted by average latency** (lowest first). Fastest hosts are tried first. | -| **Random** | `LoadBalanceMode=random` | Hosts are **shuffled randomly** for each request. All hosts are tried but in unpredictable order. | - -### Configuration - -```bash -# Default is random -LoadBalanceMode=latency -``` - -### When to Use Each Mode - -| Scenario | Recommended Mode | -|----------|------------------| -| All backends have equal capacity | `roundrobin` | -| Backends have different response times | `latency` | -| Want to avoid predictable patterns | `random` | -| Testing/debugging specific hosts | `roundrobin` with single host | - ---- - -## Stage 3: Iteration Mode - -The iteration mode controls how many times the proxy attempts to reach backends before giving up. - -| Mode | Environment Variable | Behavior | -|------|---------------------|----------| -| **SinglePass** | `IterationMode=SinglePass` | Try each matching host **once**. If all fail → error. | -| **MultiPass** | `IterationMode=MultiPass` | Retry across all hosts up to `MaxAttempts` total. Will cycle through hosts multiple times. | - -### Configuration - -```bash -IterationMode=SinglePass -MaxAttempts=30 # Only used in MultiPass mode -``` - -### Example: MultiPass with 3 Hosts - -``` -Hosts: [A, B, C] -MaxAttempts: 7 - -Attempt 1: Host A → 503 (fail) -Attempt 2: Host B → 503 (fail) -Attempt 3: Host C → 503 (fail) -Attempt 4: Host A → 503 (fail) # Second pass begins -Attempt 5: Host B → 503 (fail) -Attempt 6: Host C → 503 (fail) -Attempt 7: Host A → 200 (success!) ✓ -``` - ---- - -## Stage 4: Shared vs Per-Request Iterators - -Control whether concurrent requests share iterator state or each get their own. - -| Setting | Behavior | -|---------|----------| -| `UseSharedIterators=false` (default) | Each request gets its **own iterator**. Simple but may cause uneven distribution under high concurrency. | -| `UseSharedIterators=true` | Requests to the **same path** share an iterator. Ensures fair distribution across concurrent requests. | - -### When to Use Shared Iterators - -- **High concurrency**: Many simultaneous requests to the same path -- **Fair distribution required**: Need to ensure all backends get equal traffic -- **Round-robin mode**: Most beneficial when combined with `roundrobin` - -### Configuration - -```bash -UseSharedIterators=true -SharedIteratorTTLSeconds=300 # How long to keep unused iterators -SharedIteratorCleanupIntervalSeconds=60 # Cleanup frequency -``` - ---- - -## Stage 5: Per-Host Circuit Breaker Check - -Before sending a request to each host, the circuit breaker status is checked. - -``` -FOR EACH HOST in iterator: - └─ CheckFailedStatus() ──[OPEN]──► SKIP (continue to next host) - └─[CLOSED]──► Proceed with request -``` - -- **OPEN circuit**: Host is skipped immediately, no request sent -- **CLOSED circuit**: Request is attempted -- **All circuits OPEN**: Returns `503 Service Unavailable` - -See [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) for detailed circuit breaker configuration. - ---- - -## Response Handling - -After sending a request, the response determines the next action: - -| Response | Action | -|----------|--------| -| `2xx` (Success) | Return response to client ✓ | -| `3xx`, `404`, `5xx` | Try next host | -| `429` with `S7PREQUEUE` header | Collect for potential requeue, try next host | -| `412` (Precondition Failed) | Request TTL expired, stop iteration | - -### Requeue Behavior - -If all hosts return `429` with the `S7PREQUEUE` header, the request is requeued with a delay based on the shortest `retry-after` value. - ---- - -## Monitoring & Diagnostics - -### Logging - -Enable debug logging to see backend selection: - -```bash -LogHeaders=true -``` - -Log output includes: -- Which hosts matched the path -- Which host was selected -- Circuit breaker status for skipped hosts -- Attempt count and duration - -### Metrics - -Key metrics to monitor: -- `BackendAttempts`: Number of hosts tried per request -- `Backend-Host`: Which host ultimately served the request -- `Total-Latency`: End-to-end request duration - ---- - -## Configuration Summary - -| Variable | Default | Description | -|----------|---------|-------------| -| `LoadBalanceMode` | `random` | Algorithm: `roundrobin`, `latency`, or `random` | -| `IterationMode` | `SinglePass` | Retry strategy: `SinglePass` or `MultiPass` | -| `MaxAttempts` | `30` | Max total attempts (MultiPass only) | -| `UseSharedIterators` | `false` | Share iterators across concurrent requests | -| `SharedIteratorTTLSeconds` | `300` | TTL for unused shared iterators | -| `SharedIteratorCleanupIntervalSeconds` | `60` | Cleanup interval for expired iterators | - ---- - -## Related Documentation - -- [BACKEND_HOSTS.md](BACKEND_HOSTS.md) - Host configuration and connection strings -- [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) - Circuit breaker configuration -- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) - All configuration options +# Load Balancing & Backend Selection + +SimpleL7Proxy uses a sophisticated multi-stage algorithm to select the optimal backend for each request. This document explains how backends are chosen, filtered, and iterated. + +## Algorithm Overview + +``` +REQUEST ARRIVES + │ + ▼ +┌──────────────────────────┐ +│ 1. Filter hosts by path │ → Specific path hosts OR catch-all hosts +└──────────────────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ 2. Create Iterator │ → RoundRobin / Latency / Random +│ (LoadBalanceMode) │ +└──────────────────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ 3. FOR EACH HOST: │ +│ ├─ Circuit breaker OK?│ → Skip if OPEN +│ ├─ TTL not expired? │ → 412 if expired +│ ├─ Send request │ +│ └─ Success? → RETURN │ +└──────────────────────────┘ + │ + ▼ (all hosts failed) +┌──────────────────────────┐ +│ 429s collected? → Requeue│ +│ Else → 503 Service │ +│ Unavailable │ +└──────────────────────────┘ +``` + +--- + +## Stage 1: Path-Based Host Filtering + +Before load balancing, hosts are filtered based on the request path. The proxy maintains two categories of hosts: + +| Category | Description | Example Path | +|----------|-------------|--------------| +| **Specific Path Hosts** | Hosts with explicit path prefixes | `/api/v1/*`, `/chat/*`, `/embeddings` | +| **Catch-All Hosts** | Hosts that handle any path | `/` or `/*` | + +### Matching Rules + +1. **Specific paths take precedence**: If any host's path matches the request, only those hosts are used. +2. **Path prefix is stripped by default**: When forwarding to a matched host, the matching prefix is removed from the request path. This can be disabled per-host with `stripprefix=false` (see [BACKEND_HOSTS.md](BACKEND_HOSTS.md#controlling-path-prefix-stripping)). +3. **Catch-all fallback**: If no specific path matches, catch-all hosts are used with the original path. + +### Example + +``` +Configured Hosts: + Host1: path=/api/v1 → https://api-v1.internal + Host2: path=/api/v2 → https://api-v2.internal + Host3: path=/ → https://default.internal + +Request: GET /api/v1/users/123 + +Result (stripprefix=true, default): + - Matches Host1 (specific path /api/v1) + - Forwarded as: GET /users/123 to https://api-v1.internal + - Host2 and Host3 are NOT considered + +Result (if Host1 had stripprefix=false): + - Matches Host1 (specific path /api/v1) + - Forwarded as: GET /api/v1/users/123 to https://api-v1.internal + - Original path is preserved +``` + +--- + +## Stage 2: Load Balance Mode + +Once hosts are filtered, an iterator is created based on the configured `LoadBalanceMode`. + +### Available Modes + +| Mode | Environment Variable | Behavior | +|------|---------------------|----------| +| **Round Robin** | `LoadBalanceMode=roundrobin` | Uses a **global counter** shared across all workers. Each request gets the "next" host, ensuring fair distribution. | +| **Latency** | `LoadBalanceMode=latency` | Hosts are **sorted by average latency** (lowest first). Fastest hosts are tried first. | +| **Random** | `LoadBalanceMode=random` | Hosts are **shuffled randomly** for each request. All hosts are tried but in unpredictable order. | + +### Configuration + +```bash +# Default is random +LoadBalanceMode=latency +``` + +### When to Use Each Mode + +| Scenario | Recommended Mode | +|----------|------------------| +| All backends have equal capacity | `roundrobin` | +| Backends have different response times | `latency` | +| Want to avoid predictable patterns | `random` | +| Testing/debugging specific hosts | `roundrobin` with single host | + +--- + +## Stage 3: Iteration Mode + +The iteration mode controls how many times the proxy attempts to reach backends before giving up. + +| Mode | Environment Variable | Behavior | +|------|---------------------|----------| +| **SinglePass** | `IterationMode=SinglePass` | Try each matching host **once**. If all fail → error. | +| **MultiPass** | `IterationMode=MultiPass` | Retry across all hosts up to `MaxAttempts` total. Will cycle through hosts multiple times. | + +### Configuration + +```bash +IterationMode=SinglePass +MaxAttempts=30 # Only used in MultiPass mode +``` + +### Example: MultiPass with 3 Hosts + +``` +Hosts: [A, B, C] +MaxAttempts: 7 + +Attempt 1: Host A → 503 (fail) +Attempt 2: Host B → 503 (fail) +Attempt 3: Host C → 503 (fail) +Attempt 4: Host A → 503 (fail) # Second pass begins +Attempt 5: Host B → 503 (fail) +Attempt 6: Host C → 503 (fail) +Attempt 7: Host A → 200 (success!) ✓ +``` + +--- + +## Stage 4: Shared vs Per-Request Iterators + +Control whether concurrent requests share iterator state or each get their own. + +| Setting | Behavior | +|---------|----------| +| `UseSharedIterators=false` (default) | Each request gets its **own iterator**. Simple but may cause uneven distribution under high concurrency. | +| `UseSharedIterators=true` | Requests to the **same path** share an iterator. Ensures fair distribution across concurrent requests. | + +### When to Use Shared Iterators + +- **High concurrency**: Many simultaneous requests to the same path +- **Fair distribution required**: Need to ensure all backends get equal traffic +- **Round-robin mode**: Most beneficial when combined with `roundrobin` + +### Configuration + +```bash +UseSharedIterators=true +SharedIteratorTTLSeconds=300 # How long to keep unused iterators +SharedIteratorCleanupIntervalSeconds=60 # Cleanup frequency +``` + +--- + +## Stage 5: Per-Host Circuit Breaker Check + +Before sending a request to each host, the circuit breaker status is checked. + +``` +FOR EACH HOST in iterator: + └─ CheckFailedStatus() ──[OPEN]──► SKIP (continue to next host) + └─[CLOSED]──► Proceed with request +``` + +- **OPEN circuit**: Host is skipped immediately, no request sent +- **CLOSED circuit**: Request is attempted +- **All circuits OPEN**: Returns `503 Service Unavailable` + +See [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) for detailed circuit breaker configuration. + +--- + +## Response Handling + +After sending a request, the response determines the next action: + +| Response | Action | +|----------|--------| +| `2xx` (Success) | Return response to client ✓ | +| `3xx`, `404`, `5xx` | Try next host | +| `429` with `S7PREQUEUE` header | Collect for potential requeue, try next host | +| `412` (Precondition Failed) | Request TTL expired, stop iteration | + +### Requeue Behavior + +If all hosts return `429` with the `S7PREQUEUE` header, the request is requeued with a delay based on the shortest `retry-after` value. + +--- + +## Monitoring & Diagnostics + +### Logging + +Enable debug logging to see backend selection: + +```bash +LogHeaders=true +``` + +Log output includes: +- Which hosts matched the path +- Which host was selected +- Circuit breaker status for skipped hosts +- Attempt count and duration + +### Metrics + +Key metrics to monitor: +- `BackendAttempts`: Number of hosts tried per request +- `Backend-Host`: Which host ultimately served the request +- `Total-Latency`: End-to-end request duration + +--- + +## Configuration Summary + +| Variable | Default | Description | +|----------|---------|-------------| +| `LoadBalanceMode` | `random` | Algorithm: `roundrobin`, `latency`, or `random` | +| `IterationMode` | `SinglePass` | Retry strategy: `SinglePass` or `MultiPass` | +| `MaxAttempts` | `30` | Max total attempts (MultiPass only) | +| `UseSharedIterators` | `false` | Share iterators across concurrent requests | +| `SharedIteratorTTLSeconds` | `300` | TTL for unused shared iterators | +| `SharedIteratorCleanupIntervalSeconds` | `60` | Cleanup interval for expired iterators | + +--- + +## Related Documentation + +- [BACKEND_HOSTS.md](BACKEND_HOSTS.md) - Host configuration and connection strings +- [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) - Circuit breaker configuration +- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) - All configuration options diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index fc697b57..307f0981 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -4,9 +4,70 @@ SimpleL7Proxy is designed to provide deep visibility into AI workloads, solving ## Telemetry Channels Data is emitted to the following configured sinks: -1. **Azure Application Insights**: (Recommended for Production) Set `APPINSIGHTS_CONNECTIONSTRING`. -2. **Azure Event Hubs**: High-volume streaming ingestion. Set `EVENTHUB_CONNECTIONSTRING`. -3. **Console/Stdout**: For container logging and local debugging. +1. **Azure Application Insights**: (Recommended for Production) Set `APPINSIGHTS_CONNECTIONSTRING`. Handles structured telemetry (requests, dependencies, exceptions) directly via `TelemetryClient`. +2. **Azure Event Hubs**: High-volume streaming ingestion. Include `eventhub` in `EVENT_LOGGERS` and set `EVENTHUB_CONNECTIONSTRING` (or `EVENTHUB_NAMESPACE` for managed identity). +3. **Local Log File**: JSON event log for debugging/testing. Include `file` in `EVENT_LOGGERS` and optionally set `LOGFILE_NAME`. +4. **Console/Stdout**: For container logging and local debugging. + +Event Hubs and Local Log File are **sibling backends** managed by the `CompositeEventClient` — they can run simultaneously. Set `EVENT_LOGGERS=file,eventhub` to enable both. Each backend self-registers on successful startup; if one fails (e.g., EventHub timeout), the others continue unaffected. + +## Custom Event Loggers + +Besides the built-in `file` and `eventhub` backends, you can create your own logger by implementing `IEventClient` and `IHostedService` in the `SimpleL7Proxy` assembly. + +### Steps + +1. Create a class that implements both interfaces. +2. Accept `CompositeEventClient` and `ILogger` in the constructor (DI resolves them automatically). +3. In `StartAsync`, perform any setup, then call `_composite.Add(this)` to register. +4. Reference it by fully-qualified name in `EVENT_LOGGERS`. + +### Example + +```csharp +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SimpleL7Proxy.Events; + +public class ConsoleEventLogger : IEventClient, IHostedService +{ + private readonly CompositeEventClient _composite; + private readonly ILogger _logger; + + public ConsoleEventLogger(CompositeEventClient composite, ILogger logger) + { + _composite = composite; + _logger = logger; + } + + public int Count => 0; + public string ClientType => "Console"; + public Task StopTimerAsync() => Task.CompletedTask; + + public void SendData(string? value) + { + if (!string.IsNullOrEmpty(value)) + _logger.LogInformation("[EVENT] {Value}", value); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _composite.Add(this); + _logger.LogInformation("[SERVICE] ✓ ConsoleEventLogger started"); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => StopTimerAsync(); +} +``` + +**Usage:** +``` +EVENT_LOGGERS=file,SimpleL7Proxy.Events.ConsoleEventLogger +``` + +> **Note:** Only types within the `SimpleL7Proxy` assembly are resolved. External assemblies cannot be loaded via `EVENT_LOGGERS` for security. ## AI Token Metrics (Streaming) Standard gateways cannot count tokens in streaming responses (Server-Sent Events/SSE) because the "usage" field is often only sent in the final chunk, or requires aggregating chunks. diff --git a/src/SimpleL7Proxy/Backend/BackendHostHealthCollection.cs b/src/SimpleL7Proxy/Backend/BackendHostHealthCollection.cs deleted file mode 100644 index 766b50ff..00000000 --- a/src/SimpleL7Proxy/Backend/BackendHostHealthCollection.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -using SimpleL7Proxy.Config; - -namespace SimpleL7Proxy.Backend; - -public class HostHealthCollection : IHostHealthCollection -{ - public List Hosts { get; private set; } = []; - public List SpecificPathHosts { get; private set; } = []; - public List CatchAllHosts { get; private set; } = []; - - public HostHealthCollection(IOptions options, ILogger logger) - { - if (options == null) throw new ArgumentNullException(nameof(options)); - if (options.Value == null) throw new ArgumentNullException(nameof(options.Value)); - if (options.Value.Hosts == null) throw new ArgumentNullException(nameof(options.Value.Hosts)); - - foreach (var hostConfig in options.Value.Hosts) - { - BaseHostHealth host; - - // Determine if host supports probing based on DirectMode or ProbePath - if (hostConfig.DirectMode || string.IsNullOrEmpty(hostConfig.ProbePath) || hostConfig.ProbePath == "/") - { - // No probe path or root path - treat as non-probeable - host = new NonProbeableHostHealth(hostConfig, logger); - } - else - { - // Has a specific probe path - treat as probeable - host = new ProbeableHostHealth(hostConfig, logger); - } - - Hosts.Add(host); - - // Categorize by PartialPath - var hostPartialPath = hostConfig.PartialPath?.Trim(); - - if (string.IsNullOrEmpty(hostPartialPath) || - hostPartialPath == "/" || - hostPartialPath == "/*") - { - CatchAllHosts.Add(host); - } - else - { - SpecificPathHosts.Add(host); - } - } - - logger.LogCritical("[CONFIG] Host categorization complete: {SpecificCount} specific hosts, {CatchAllCount} catch-all hosts", - SpecificPathHosts.Count, CatchAllHosts.Count); - } -} \ No newline at end of file diff --git a/src/SimpleL7Proxy/Backend/Backends.cs b/src/SimpleL7Proxy/Backend/Backends.cs index 4fb2be83..0d2b9d81 100644 --- a/src/SimpleL7Proxy/Backend/Backends.cs +++ b/src/SimpleL7Proxy/Backend/Backends.cs @@ -22,10 +22,15 @@ namespace SimpleL7Proxy.Backend; // * Fetch the OAuth2 token and refresh it 100ms minutes before it expires public class Backends : IBackendService { - public List _backendHosts { get; set; } private List _activeHosts; private readonly IHostHealthCollection _backendHostCollection; + /// + /// All registered hosts from the current snapshot. + /// Always reads the latest snapshot — safe for concurrent access. + /// + private List _backendHosts => _backendHostCollection.Current.Hosts; + private readonly BackendOptions _options; private static readonly bool _debug = false; @@ -43,8 +48,8 @@ public class Backends : IBackendService private readonly ISharedIteratorRegistry? _sharedIteratorRegistry; // Reusable ProxyEvent instances for backend poller to reduce allocations - private readonly ProxyEvent _statusEvent = new ProxyEvent(8); - private readonly ProxyEvent _probeEvent = new ProxyEvent(8); + private readonly ProxyEvent _statusEvent = new ProxyEvent(25); // 4 fixed (Timestamp, LoadBalanceMode, ActiveHostsCount, SuccessRate) + 7*N per host (assumes ~3 hosts) + private readonly ProxyEvent _probeEvent = new ProxyEvent(6); // ProxyHost, Backend-Host, Port, Path, Code, Latency/Timeout CancellationTokenSource workerCancelTokenSource = new CancellationTokenSource(); @@ -80,7 +85,6 @@ public Backends( _circuitBreaker = circuitBreaker; _sharedIteratorRegistry = sharedIteratorRegistry; _backendHostCollection = backendHostCollection; - _backendHosts = backendHostCollection.Hosts; _options = options.Value; _logger = logger; @@ -93,10 +97,8 @@ public Backends( _options = bo; _activeHosts = []; _successRate = bo.SuccessRate / 100.0; - //_hosts = bo.Hosts; - // FailureThreshold = bo.CircuitBreakerErrorThreshold; - // FailureTimeFrame = bo.CircuitBreakerTimeslice; - // allowableCodes = bo.AcceptableStatusCodes; + + // Hosts are staged and activated by ConfigBootstrapper.RegisterBackends _logger.LogDebug("[INIT] Backends service starting"); @@ -133,12 +135,12 @@ public void Start() public List GetSpecificPathHosts() { - return _backendHostCollection.SpecificPathHosts; + return _backendHostCollection.Current.SpecificPathHosts; } public List GetCatchAllHosts() { - return _backendHostCollection.CatchAllHosts; + return _backendHostCollection.Current.CatchAllHosts; } public async Task WaitForStartup(int timeout) { diff --git a/src/SimpleL7Proxy/Backend/CircuitBreaker.cs b/src/SimpleL7Proxy/Backend/CircuitBreaker.cs index f24b3b40..ae396d2d 100644 --- a/src/SimpleL7Proxy/Backend/CircuitBreaker.cs +++ b/src/SimpleL7Proxy/Backend/CircuitBreaker.cs @@ -20,7 +20,7 @@ public class CircuitBreaker : ICircuitBreaker // Global counters using Interlocked operations private static int _totalCircuitBreakersCount = 0; private static int _blockedCircuitBreakersCount = 0; - private readonly ProxyEvent _circuitBreakerEvent = new ProxyEvent(6); + private readonly ProxyEvent _circuitBreakerEvent = new ProxyEvent(4); // Code, Time, Success, Count // Instance state tracking private bool _isCurrentlyBlocked = false; diff --git a/src/SimpleL7Proxy/Backend/HostCollectionManager.cs b/src/SimpleL7Proxy/Backend/HostCollectionManager.cs new file mode 100644 index 00000000..701fd49f --- /dev/null +++ b/src/SimpleL7Proxy/Backend/HostCollectionManager.cs @@ -0,0 +1,197 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using SimpleL7Proxy.Backend.Iterators; +using SimpleL7Proxy.Config; + +namespace SimpleL7Proxy.Backend; + +/// +/// Singleton manager that owns the authoritative host list. +/// Reads are lock-free (volatile snapshot reference). +/// Writes (CRUD) take a lock, build a new snapshot, and atomically swap. +/// Old snapshots remain valid for any in-flight workers holding a reference. +/// +/// Startup flow: +/// 1. Constructor starts with Empty snapshot +/// 2. StageHost() adds individual hosts to a pending list +/// 3. Activate() builds, freezes, and swaps the pending snapshot in as Current +/// After activation, CRUD operations modify Current directly. +/// +public sealed class HostCollectionManager : IHostHealthCollection +{ + private readonly object _writeLock = new(); + private volatile HostCollectionSnapshot _current; + private HostCollectionSnapshot? _pending; + private List? _stagedConfigs; + private int _version; + private readonly ILogger _logger; + + /// + public HostCollectionSnapshot Current => _current; + + public HostCollectionManager(ILogger logger) + { + ArgumentNullException.ThrowIfNull(logger, nameof(logger)); + + _logger = logger; + _version = 0; + + // Start empty — hosts are staged via StageHost() then Activate() + _current = HostCollectionSnapshot.Empty; + _logger.LogDebug("[HOST-MANAGER] Initialized with empty snapshot"); + } + + /// + public void StageHost(HostConfig config) + { + ArgumentNullException.ThrowIfNull(config, nameof(config)); + + lock (_writeLock) + { + _stagedConfigs ??= []; + _stagedConfigs.Add(config); + _logger.LogDebug("[HOST-MANAGER] Staged host: {Host} ({Count} staged)", + config.Host, _stagedConfigs.Count); + } + } + + /// + /// Builds a pending snapshot from the configured host list. + /// Does NOT activate it — call Activate() to make it Current. + /// + public void LoadFromConfig(IEnumerable hostConfigs) + { + ArgumentNullException.ThrowIfNull(hostConfigs, nameof(hostConfigs)); + + lock (_writeLock) + { + _version++; + _pending = HostCollectionSnapshot.Build(hostConfigs, _logger, _version); + _logger.LogInformation("[HOST-MANAGER] Pending snapshot built (v{Version}, {Count} hosts)", + _version, _pending.Hosts.Count); + } + } + + /// + /// Atomically swaps the pending snapshot in as Current. + /// If hosts were staged via , builds the snapshot from them. + /// If was used, uses the pre-built pending snapshot. + /// Freezes the snapshot before activation. + /// + public void Activate() + { + lock (_writeLock) + { + // Build from staged configs if present + if (_stagedConfigs != null && _stagedConfigs.Count > 0) + { + _version++; + _pending = HostCollectionSnapshot.Build(_stagedConfigs, _logger, _version); + _stagedConfigs = null; + } + + if (_pending == null) + { + _logger.LogWarning("[HOST-MANAGER] Activate() called with no pending snapshot or staged hosts"); + return; + } + + _pending.Freeze(); + + var oldVersion = _current.Version; + _current = _pending; + _pending = null; + + _logger.LogInformation("[HOST-MANAGER] ✓ Snapshot activated (v{OldVersion} → v{NewVersion}, {Count} hosts)", + oldVersion, _current.Version, _current.Hosts.Count); + + IteratorFactory.InvalidateCache(); + } + } + + /// + public BaseHostHealth AddHost(HostConfig config) + { + ArgumentNullException.ThrowIfNull(config, nameof(config)); + + lock (_writeLock) + { + // Create the new host health instance + BaseHostHealth host; + if (config.DirectMode || string.IsNullOrEmpty(config.ProbePath) || config.ProbePath == "/") + { + host = new NonProbeableHostHealth(config, _logger); + } + else + { + host = new ProbeableHostHealth(config, _logger); + } + + // Build new list including the new host + var newHosts = new List(_current.Hosts) { host }; + _version++; + _current = HostCollectionSnapshot.BuildFromHosts(newHosts, _version); + + _logger.LogInformation("[CRUD] ✓ Host added: {Host} (v{Version}, total: {Count})", + config.Host, _version, _current.Hosts.Count); + + IteratorFactory.InvalidateCache(); + return host; + } + } + + /// + public bool RemoveHost(Guid hostId) + { + lock (_writeLock) + { + var existing = _current.Hosts.FirstOrDefault(h => h.guid == hostId); + if (existing == null) + { + _logger.LogWarning("[CRUD] Host not found for removal: {HostId}", hostId); + return false; + } + + var newHosts = _current.Hosts.Where(h => h.guid != hostId).ToList(); + _version++; + _current = HostCollectionSnapshot.BuildFromHosts(newHosts, _version); + + _logger.LogInformation("[CRUD] ✓ Host removed: {Host} (v{Version}, total: {Count})", + existing.Host, _version, _current.Hosts.Count); + + IteratorFactory.InvalidateCache(); + return true; + } + } + + /// + public bool UpdateHost(Guid hostId, Action mutate) + { + ArgumentNullException.ThrowIfNull(mutate, nameof(mutate)); + + lock (_writeLock) + { + var existing = _current.Hosts.FirstOrDefault(h => h.guid == hostId); + if (existing == null) + { + _logger.LogWarning("[CRUD] Host not found for update: {HostId}", hostId); + return false; + } + + // Apply the mutation + mutate(existing.Config); + + // Re-categorize (host may have moved between specific-path and catch-all) + _version++; + _current = HostCollectionSnapshot.BuildFromHosts( + new List(_current.Hosts), _version); + + _logger.LogInformation("[CRUD] ✓ Host updated: {Host} (v{Version})", + existing.Host, _version); + + IteratorFactory.InvalidateCache(); + return true; + } + } +} diff --git a/src/SimpleL7Proxy/Backend/HostCollectionSnapshot.cs b/src/SimpleL7Proxy/Backend/HostCollectionSnapshot.cs new file mode 100644 index 00000000..a31c0c8f --- /dev/null +++ b/src/SimpleL7Proxy/Backend/HostCollectionSnapshot.cs @@ -0,0 +1,143 @@ +using System.Collections.Frozen; +using Microsoft.Extensions.Logging; + +namespace SimpleL7Proxy.Backend; + +/// +/// An immutable snapshot of all backend hosts, pre-categorized into specific-path and catch-all. +/// Once built, the lists are never mutated — readers grab a reference and iterate safely. +/// Old snapshots are kept alive by in-flight workers; GC reclaims them naturally. +/// +public sealed class HostCollectionSnapshot +{ + /// Every registered host (specific-path + catch-all). + public List Hosts { get; } + + /// Hosts whose PartialPath targets a specific route prefix. + public List SpecificPathHosts { get; } + + /// Hosts that match any request path (/, /*, or empty). + public List CatchAllHosts { get; } + + /// Monotonically increasing version for diagnostics / cache invalidation. + public int Version { get; } + + /// Frozen lookup of all hosts by their Guid. Populated by . + public FrozenDictionary? HostsByGuid { get; private set; } + + /// Frozen lookup of all hosts by their Host URL (e.g. "https://foo.openai.azure.com"). Populated by . + public FrozenDictionary? HostsByUrl { get; private set; } + + /// Whether has been called. + public bool IsFrozen { get; private set; } + + private HostCollectionSnapshot( + List hosts, + List specificPathHosts, + List catchAllHosts, + int version) + { + Hosts = hosts; + SpecificPathHosts = specificPathHosts; + CatchAllHosts = catchAllHosts; + Version = version; + } + + /// Empty snapshot for startup / error states. + public static HostCollectionSnapshot Empty { get; } = CreateEmpty(); + + private static HostCollectionSnapshot CreateEmpty() + { + var empty = new HostCollectionSnapshot([], [], [], 0); + empty.Freeze(); + return empty; + } + + /// + /// Freezes the snapshot by building + /// lookups for all instances contained in this snapshot. + /// After this call, is true and the dictionaries are available. + /// Calling Freeze more than once is a no-op. + /// + public void Freeze() + { + if (IsFrozen) return; + + HostsByGuid = Hosts.ToFrozenDictionary(h => h.guid, h => h.Config); + HostsByUrl = Hosts.ToFrozenDictionary(h => h.Host, h => h.Config, StringComparer.OrdinalIgnoreCase); + IsFrozen = true; + } + + /// + /// Builds a new snapshot from a list of HostConfigs, categorizing each host. + /// + public static HostCollectionSnapshot Build( + IEnumerable hostConfigs, + ILogger logger, + int version = 1) + { + var hosts = new List(); + var specificPathHosts = new List(); + var catchAllHosts = new List(); + + foreach (var hostConfig in hostConfigs) + { + BaseHostHealth host; + + // Determine if host supports probing based on DirectMode or ProbePath + if (hostConfig.DirectMode || string.IsNullOrEmpty(hostConfig.ProbePath) || hostConfig.ProbePath == "/") + { + host = new NonProbeableHostHealth(hostConfig, logger); + } + else + { + host = new ProbeableHostHealth(hostConfig, logger); + } + + hosts.Add(host); + CategorizeHost(host, specificPathHosts, catchAllHosts); + } + + logger.LogCritical("[CONFIG] Host categorization complete: {SpecificCount} specific hosts, {CatchAllCount} catch-all hosts", + specificPathHosts.Count, catchAllHosts.Count); + + return new HostCollectionSnapshot(hosts, specificPathHosts, catchAllHosts, version); + } + + /// + /// Builds a new snapshot from existing BaseHostHealth instances (used by CRUD to re-categorize). + /// + public static HostCollectionSnapshot BuildFromHosts( + List hosts, + int version) + { + var specificPathHosts = new List(); + var catchAllHosts = new List(); + + foreach (var host in hosts) + { + CategorizeHost(host, specificPathHosts, catchAllHosts); + } + + return new HostCollectionSnapshot(hosts, specificPathHosts, catchAllHosts, version); + } + + private static void CategorizeHost( + BaseHostHealth host, + List specificPathHosts, + List catchAllHosts) + { + var hostPartialPath = host.Config.PartialPath?.Trim(); + + if (string.IsNullOrEmpty(hostPartialPath) || + hostPartialPath == "/" || + hostPartialPath == "/*") + { + catchAllHosts.Add(host); + } + else + { + specificPathHosts.Add(host); + } + } +} \ No newline at end of file diff --git a/src/SimpleL7Proxy/Backend/HostConfig.cs b/src/SimpleL7Proxy/Backend/HostConfig.cs index 6b6b0a87..eae3059e 100644 --- a/src/SimpleL7Proxy/Backend/HostConfig.cs +++ b/src/SimpleL7Proxy/Backend/HostConfig.cs @@ -33,6 +33,7 @@ public class HostConfig public string PartialPath => ParsedConfig.PartialPath; public string ProbePath => ParsedConfig.ProbePath; public string Processor => ParsedConfig.Processor; + public bool StripPrefix => ParsedConfig.StripPrefix; public bool UseOAuth => ParsedConfig.UseOAuth; public bool UsesRetryAfter => ParsedConfig.UsesRetryAfter; public string Protocol { get; private set; } @@ -143,6 +144,7 @@ private static ParsedConfig TryParseConfig(string hostname, string? probepath, s DirectMode = false, IpAddr = ip ?? "", PartialPath = "/", + StripPrefix = true, UseOAuth = false, Audience = audience ?? "", UsesRetryAfter = true @@ -188,6 +190,10 @@ private static ParsedConfig TryParseConfig(string hostname, string? probepath, s case "processor": result.Processor = kvp.Value; break; + case "stripprefix": + case "strippathprefix": + result.StripPrefix = kvp.Value.Equals("true", StringComparison.OrdinalIgnoreCase); + break; case "useoauth": case "usemi": result.UseOAuth = kvp.Value.Equals("true", StringComparison.OrdinalIgnoreCase); @@ -306,6 +312,11 @@ public PathMatchResult SupportsPath(string requestPath) if (string.IsNullOrEmpty(_wildcardPrefix) || normalizedPath.StartsWith(_wildcardPrefix.AsSpan(), StringComparison.OrdinalIgnoreCase)) { + // When StripPrefix is false, match but keep the original path + if (!StripPrefix) + { + return PathMatchResult.Match(requestPath); + } // Strip the wildcard prefix if (!string.IsNullOrEmpty(_wildcardPrefix)) { @@ -320,6 +331,10 @@ public PathMatchResult SupportsPath(string requestPath) // Exact path match if (normalizedPath.Equals(_normalizedPartialPath.AsSpan(), StringComparison.OrdinalIgnoreCase)) { + if (!StripPrefix) + { + return PathMatchResult.Match(requestPath); + } return PathMatchResult.Match(query.IsEmpty ? "/" : string.Concat("/", query)); } @@ -333,6 +348,10 @@ public PathMatchResult SupportsPath(string requestPath) if (normalizedPath.Length == prefixSpan.Length || normalizedPath[prefixSpan.Length] == '/') { + if (!StripPrefix) + { + return PathMatchResult.Match(requestPath); + } var remaining = normalizedPath.Slice(prefixSpan.Length).TrimStart('/'); return PathMatchResult.Match(string.Concat("/", remaining, query)); } diff --git a/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs b/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs index 19de5ab4..7f47e431 100644 --- a/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs +++ b/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs @@ -1,16 +1,50 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace SimpleL7Proxy.Backend; +namespace SimpleL7Proxy.Backend; +/// +/// Manages the authoritative collection of backend hosts. +/// Exposes an immutable snapshot for lock-free reads and thread-safe CRUD for mutations. +/// public interface IHostHealthCollection { - List Hosts { get; } - List SpecificPathHosts { get; } - List CatchAllHosts { get; } + /// + /// Current immutable snapshot. Grab once per operation — no locking needed. + /// Old snapshots stay alive until in-flight workers finish, then GC reclaims them. + /// + HostCollectionSnapshot Current { get; } + + /// + /// Stages a single into the pending list. + /// Does NOT activate it — call after all hosts are staged. + /// + void StageHost(HostConfig config); + + /// + /// Builds a pending snapshot from a list of HostConfigs. + /// Does NOT activate it — call Activate() to swap it in. + /// + void LoadFromConfig(IEnumerable hostConfigs); + + /// + /// Atomically swaps the pending snapshot in as Current. + /// + void Activate(); + + /// + /// Creates a BaseHostHealth from a HostConfig, adds it to the collection, + /// rebuilds the snapshot, and invalidates iterator caches. + /// Returns the created host. + /// + BaseHostHealth AddHost(HostConfig config); + + /// + /// Removes a host by its unique GUID. + /// Returns true if found and removed. + /// + bool RemoveHost(Guid hostId); + + /// + /// Applies a mutation to an existing host's config, then re-categorizes. + /// Returns true if found and updated. + /// + bool UpdateHost(Guid hostId, Action mutate); } diff --git a/src/SimpleL7Proxy/Backend/Iterators/EmptyBackendHostIterator.cs b/src/SimpleL7Proxy/Backend/Iterators/EmptyBackendHostIterator.cs index 16b889bc..7a04ce67 100644 --- a/src/SimpleL7Proxy/Backend/Iterators/EmptyBackendHostIterator.cs +++ b/src/SimpleL7Proxy/Backend/Iterators/EmptyBackendHostIterator.cs @@ -34,6 +34,11 @@ public class EmptyBackendHostIterator : IHostIterator /// public IterationModeEnum Mode => IterationModeEnum.SinglePass; + /// + /// Gets the total number of hosts. Always returns 0 since there are no hosts. + /// + public int HostCount => 0; + /// /// Attempts to move to the next host. Always returns false since there are no hosts. /// diff --git a/src/SimpleL7Proxy/Backend/Iterators/HostIterator.cs b/src/SimpleL7Proxy/Backend/Iterators/HostIterator.cs index e65abe9d..fea78551 100644 --- a/src/SimpleL7Proxy/Backend/Iterators/HostIterator.cs +++ b/src/SimpleL7Proxy/Backend/Iterators/HostIterator.cs @@ -53,6 +53,11 @@ protected HostIterator(List hosts, IterationModeEnum mode, int m /// public IterationModeEnum Mode => _mode; + /// + /// Gets the total number of hosts in this iterator. + /// + public int HostCount => _hosts.Count; + /// /// Moves to the next host. Handles common pass completion logic. /// diff --git a/src/SimpleL7Proxy/Backend/Iterators/IHostIterator.cs b/src/SimpleL7Proxy/Backend/Iterators/IHostIterator.cs index adfe724b..5d149fe7 100644 --- a/src/SimpleL7Proxy/Backend/Iterators/IHostIterator.cs +++ b/src/SimpleL7Proxy/Backend/Iterators/IHostIterator.cs @@ -5,4 +5,8 @@ public interface IHostIterator : IEnumerator void RecordResult(BaseHostHealth host, bool success); bool HasMoreHosts { get; } IterationModeEnum Mode { get; } + /// + /// Gets the total number of hosts in this iterator. + /// + int HostCount { get; } } \ No newline at end of file diff --git a/src/SimpleL7Proxy/Backend/Iterators/ISharedHostIterator.cs b/src/SimpleL7Proxy/Backend/Iterators/ISharedHostIterator.cs index da0325a2..aa5768ab 100644 --- a/src/SimpleL7Proxy/Backend/Iterators/ISharedHostIterator.cs +++ b/src/SimpleL7Proxy/Backend/Iterators/ISharedHostIterator.cs @@ -28,6 +28,12 @@ public interface ISharedHostIterator /// string Path { get; } + /// + /// Gets the modified path (with matched prefix stripped) for this iterator. + /// This allows callers to retrieve the stripped path without re-filtering. + /// + string ModifiedPath { get; } + /// /// Gets the timestamp when this iterator was last used. /// diff --git a/src/SimpleL7Proxy/Backend/Iterators/ISharedIteratorRegistry.cs b/src/SimpleL7Proxy/Backend/Iterators/ISharedIteratorRegistry.cs index 6e66e941..398f0fdc 100644 --- a/src/SimpleL7Proxy/Backend/Iterators/ISharedIteratorRegistry.cs +++ b/src/SimpleL7Proxy/Backend/Iterators/ISharedIteratorRegistry.cs @@ -12,9 +12,9 @@ public interface ISharedIteratorRegistry /// Thread-safe: multiple concurrent requests to the same path will share the same iterator. /// /// The request path (normalized) to use as the key - /// Factory function to create a new iterator if one doesn't exist - /// A shared iterator for the path - ISharedHostIterator GetOrCreate(string path, Func factory); + /// Factory function to create a new iterator and its modified path if one doesn't exist + /// A shared iterator for the path (includes ModifiedPath for prefix-stripped path) + ISharedHostIterator GetOrCreate(string path, Func<(IHostIterator iterator, string modifiedPath)> factory); /// /// Invalidates all cached iterators. Call when backend configuration changes. diff --git a/src/SimpleL7Proxy/Backend/Iterators/IteratorFactory.cs b/src/SimpleL7Proxy/Backend/Iterators/IteratorFactory.cs index d8d1e58a..82ab2543 100644 --- a/src/SimpleL7Proxy/Backend/Iterators/IteratorFactory.cs +++ b/src/SimpleL7Proxy/Backend/Iterators/IteratorFactory.cs @@ -13,8 +13,6 @@ public static class IteratorFactory private static readonly object _lock = new object(); private static volatile int _roundRobinCounter = 0; private static volatile List? _cachedActiveHosts; - private static volatile List? _cachedSpecificPathHosts; - private static volatile List? _cachedCatchAllHosts; private static volatile int _cacheVersion = 0; // Incremented when cache is invalidated // Thread-safe random number generator @@ -26,15 +24,15 @@ public static class IteratorFactory /// /// The backend service to get active hosts from /// Load balancing strategy: "roundrobin", "latency", or "random" - /// The full URL for the request (without host part) to filter hosts by path + /// The normalized request path (e.g., /openai/v1/chat) to filter hosts by /// An iterator configured for single-pass iteration public static IHostIterator CreateSinglePassIterator( IBackendService backendService, string loadBalanceMode, - string fullURL, + string requestPath, out string modifiedPath) { - return CreateIteratorInternal(backendService, loadBalanceMode, IterationModeEnum.SinglePass, 1, fullURL, out modifiedPath); + return CreateIteratorInternal(backendService, loadBalanceMode, IterationModeEnum.SinglePass, 1, requestPath, out modifiedPath); } /// @@ -45,29 +43,29 @@ public static IHostIterator CreateSinglePassIterator( /// The backend service to get active hosts from /// Load balancing strategy: "roundrobin", "latency", or "random" /// Maximum total number of host attempts across all passes (e.g., 30) - /// The full URL for the request (without host part) to filter hosts by path + /// The normalized request path (e.g., /openai/v1/chat) to filter hosts by /// An iterator configured for multi-pass iteration with retry limit public static IHostIterator CreateMultiPassIterator( IBackendService backendService, string loadBalanceMode, int maxAttempts, - string fullURL, + string requestPath, out string modifiedPath) { - return CreateIteratorInternal(backendService, loadBalanceMode, IterationModeEnum.MultiPass, maxAttempts, fullURL, out modifiedPath); + return CreateIteratorInternal(backendService, loadBalanceMode, IterationModeEnum.MultiPass, maxAttempts, requestPath, out modifiedPath); } /// /// Internal method to create a thread-safe iterator for the specified load balance mode. /// This method is optimized for high concurrency with hundreds of proxy workers. - /// Filters hosts based on the request path extracted from the full URL. + /// Filters hosts based on the request path. /// private static IHostIterator CreateIteratorInternal( IBackendService backendService, string loadBalanceMode, IterationModeEnum mode, int maxAttempts, - string fullURL, + string requestPath, out string modifiedPath) { // Get pre-categorized hosts from backend service @@ -76,12 +74,11 @@ private static IHostIterator CreateIteratorInternal( if ((specificHosts?.Count ?? 0) == 0 && (catchAllHosts?.Count ?? 0) == 0) { - modifiedPath = fullURL; // No modification + modifiedPath = requestPath; // No modification return new EmptyBackendHostIterator(); } - // Extract path from fullURL to filter hosts - var requestPath = ExtractPathFromURL(fullURL); + // requestPath is already normalized by server.cs var (filteredHosts, mp) = FilterHostsByPath(specificHosts!, catchAllHosts!, requestPath); modifiedPath = mp; @@ -102,25 +99,6 @@ private static IHostIterator CreateIteratorInternal( }; } - /// - /// Extracts the path portion from a full URL (without host part). - /// Handles both absolute paths (/api/users) and relative paths (api/users). - /// - private static string ExtractPathFromURL(string fullURL) - { - if (string.IsNullOrEmpty(fullURL)) - return "/"; - - // Try to parse as absolute URI first - if (Uri.TryCreate(fullURL, UriKind.Absolute, out Uri? uri)) - { - return uri.PathAndQuery; - } - - // For relative paths, ensure they start with '/' - return fullURL.StartsWith('/') ? fullURL : "/" + fullURL; - } - /// /// Filters hosts by path and returns both the matching hosts and the path with matched prefix removed. /// This enables backend hosts to handle requests without needing to know their routing prefix. @@ -146,57 +124,7 @@ private static (List hosts, string modifiedPath) FilterHostsByPa return (catchAllHosts, requestPath); } - /// - /// Gets cached categorized hosts (specific vs catch-all) with thread-safe lazy initialization. - /// - private static (List specificHosts, List catchAllHosts) GetCategorizedHosts(IBackendService backendService) - { - // Fast path: read cached values without locking - var cachedSpecific = _cachedSpecificPathHosts; - var cachedCatchAll = _cachedCatchAllHosts; - - if (cachedSpecific != null && cachedCatchAll != null) - { - return (cachedSpecific, cachedCatchAll); - } - - // Slow path: need to categorize hosts - lock (_lock) - { - // Double-check: another thread may have populated the cache - if (_cachedSpecificPathHosts != null && _cachedCatchAllHosts != null) - { - return (_cachedSpecificPathHosts, _cachedCatchAllHosts); - } - - var activeHosts = backendService.GetActiveHosts(); - var specificHosts = new List(); - var catchAllHosts = new List(); - - // Categorize hosts once at startup - foreach (var host in activeHosts) - { - var hostPartialPath = host.Config.PartialPath?.Trim(); - - if (string.IsNullOrEmpty(hostPartialPath) || - hostPartialPath == "/" || - hostPartialPath == "/*") - { - catchAllHosts.Add(host); - } - else - { - specificHosts.Add(host); - } - } - _cachedSpecificPathHosts = specificHosts; - _cachedCatchAllHosts = catchAllHosts; - _cachedActiveHosts = activeHosts; // Also update the active hosts cache - - return (specificHosts, catchAllHosts); - } - } /// /// Gets cached active hosts. Cache is invalidated only when explicitly requested @@ -252,8 +180,6 @@ public static void InvalidateCache() lock (_lock) { _cachedActiveHosts = null; - _cachedSpecificPathHosts = null; - _cachedCatchAllHosts = null; Interlocked.Increment(ref _cacheVersion); // Track cache version for diagnostics } } @@ -269,13 +195,13 @@ public static void InvalidateCache() /// /// The backend service to get active hosts from /// Load balancing strategy (used for initial ordering) - /// The full URL for the request to filter hosts by path + /// The normalized request path to filter hosts by /// Output: the path with matched prefix removed /// A SharedHostIterator configured for circular iteration public static SharedHostIterator CreateSharedIterator( IBackendService backendService, string loadBalanceMode, - string fullURL, + string requestPath, out string modifiedPath) { // Get pre-categorized hosts from backend service @@ -284,12 +210,11 @@ public static SharedHostIterator CreateSharedIterator( if ((specificHosts?.Count ?? 0) == 0 && (catchAllHosts?.Count ?? 0) == 0) { - modifiedPath = fullURL; - return new SharedHostIterator(new List(), fullURL, IterationModeEnum.SinglePass); + modifiedPath = requestPath; + return new SharedHostIterator(new List(), requestPath, requestPath, IterationModeEnum.SinglePass); } - // Extract path from fullURL to filter hosts - var requestPath = ExtractPathFromURL(fullURL); + // requestPath is already normalized by server.cs var (filteredHosts, mp) = FilterHostsByPath(specificHosts!, catchAllHosts!, requestPath); modifiedPath = mp; @@ -301,7 +226,7 @@ public static SharedHostIterator CreateSharedIterator( _ => filteredHosts // Round-robin uses natural order }; - return new SharedHostIterator(orderedHosts, requestPath, IterationModeEnum.SinglePass); + return new SharedHostIterator(orderedHosts, requestPath, modifiedPath, IterationModeEnum.SinglePass); } /// @@ -310,13 +235,13 @@ public static SharedHostIterator CreateSharedIterator( /// /// The backend service to get active hosts from /// Load balancing strategy (used for initial ordering) - /// The full URL for the request to filter hosts by path + /// The normalized request path to filter hosts by /// Output: the path with matched prefix removed /// List of filtered and ordered hosts public static List GetFilteredHosts( IBackendService backendService, string loadBalanceMode, - string fullURL, + string requestPath, out string modifiedPath) { var specificHosts = backendService.GetSpecificPathHosts(); @@ -324,11 +249,11 @@ public static List GetFilteredHosts( if ((specificHosts?.Count ?? 0) == 0 && (catchAllHosts?.Count ?? 0) == 0) { - modifiedPath = fullURL; + modifiedPath = requestPath; return new List(); } - var requestPath = ExtractPathFromURL(fullURL); + // requestPath is already normalized by server.cs var (filteredHosts, mp) = FilterHostsByPath(specificHosts!, catchAllHosts!, requestPath); modifiedPath = mp; diff --git a/src/SimpleL7Proxy/Backend/Iterators/SharedHostIterator.cs b/src/SimpleL7Proxy/Backend/Iterators/SharedHostIterator.cs index e33a0379..2f90ae93 100644 --- a/src/SimpleL7Proxy/Backend/Iterators/SharedHostIterator.cs +++ b/src/SimpleL7Proxy/Backend/Iterators/SharedHostIterator.cs @@ -28,6 +28,7 @@ public sealed class SharedHostIterator : ISharedHostIterator, IDisposable { private readonly List _hosts; private readonly string _path; + private readonly string _modifiedPath; private readonly IterationModeEnum _mode; private readonly object _lock = new(); // Only used for Dispose and GetHostsSnapshot @@ -40,11 +41,13 @@ public sealed class SharedHostIterator : ISharedHostIterator, IDisposable /// /// The list of hosts to iterate over (a snapshot is taken) /// The path this iterator is associated with + /// The path with matched prefix stripped /// The iteration mode - public SharedHostIterator(List hosts, string path, IterationModeEnum mode) + public SharedHostIterator(List hosts, string path, string modifiedPath, IterationModeEnum mode) { _hosts = new List(hosts ?? throw new ArgumentNullException(nameof(hosts))); _path = path ?? throw new ArgumentNullException(nameof(path)); + _modifiedPath = modifiedPath ?? path; _mode = mode; _currentIndex = -1; _lastUsed = DateTime.UtcNow; @@ -53,6 +56,9 @@ public SharedHostIterator(List hosts, string path, IterationMode /// public string Path => _path; + /// + public string ModifiedPath => _modifiedPath; + /// public DateTime LastUsed => _lastUsed; diff --git a/src/SimpleL7Proxy/Backend/Iterators/SharedIteratorRegistry.cs b/src/SimpleL7Proxy/Backend/Iterators/SharedIteratorRegistry.cs index 28031548..c2caaf16 100644 --- a/src/SimpleL7Proxy/Backend/Iterators/SharedIteratorRegistry.cs +++ b/src/SimpleL7Proxy/Backend/Iterators/SharedIteratorRegistry.cs @@ -77,7 +77,7 @@ public int Count } /// - public ISharedHostIterator GetOrCreate(string path, Func factory) + public ISharedHostIterator GetOrCreate(string path, Func<(IHostIterator iterator, string modifiedPath)> factory) { if (_disposed) throw new ObjectDisposedException(nameof(SharedIteratorRegistry)); @@ -89,19 +89,19 @@ public ISharedHostIterator GetOrCreate(string path, Func factory) lock (_lock) { - // Fast path: iterator already exists + // Fast path: iterator already exists (modifiedPath is stored on the iterator) if (_iterators.TryGetValue(normalizedPath, out var existing)) return existing; // Slow path: create new iterator (factory called exactly once) - var baseIterator = factory(); + var (baseIterator, modifiedPath) = factory(); var hosts = ExtractHostsFromIterator(baseIterator); _logger.LogDebug( - "[SharedIteratorRegistry] Created new iterator for path '{Path}' with {HostCount} hosts", - normalizedPath, hosts.Count); + "[SharedIteratorRegistry] Created new iterator for path '{Path}' with {HostCount} hosts, modifiedPath='{ModifiedPath}'", + normalizedPath, hosts.Count, modifiedPath); - var iterator = new SharedHostIterator(hosts, normalizedPath, baseIterator.Mode); + var iterator = new SharedHostIterator(hosts, normalizedPath, modifiedPath, baseIterator.Mode); _iterators[normalizedPath] = iterator; return iterator; } diff --git a/src/SimpleL7Proxy/Backend/ParsedConfig.cs b/src/SimpleL7Proxy/Backend/ParsedConfig.cs index c2f5fe10..8fdcbbb9 100644 --- a/src/SimpleL7Proxy/Backend/ParsedConfig.cs +++ b/src/SimpleL7Proxy/Backend/ParsedConfig.cs @@ -27,6 +27,7 @@ public string Hostname public string PartialPath; public string ProbePath; public string Processor; + public bool StripPrefix; public bool UseOAuth; public bool UsesRetryAfter; } diff --git a/src/SimpleL7Proxy/BlobStorage/BlobWriteQueue.cs b/src/SimpleL7Proxy/BlobStorage/BlobWriteQueue.cs index 64a45d81..24a82e24 100644 --- a/src/SimpleL7Proxy/BlobStorage/BlobWriteQueue.cs +++ b/src/SimpleL7Proxy/BlobStorage/BlobWriteQueue.cs @@ -593,7 +593,8 @@ private async Task ExecuteBatchAsync( sw.Stop(); Interlocked.Increment(ref _batchesExecuted); - var successCount = deduplicatedOps.Count(op => op.GetResultAsync().Result.Success); + var results = await Task.WhenAll(deduplicatedOps.Select(op => op.GetResultAsync())).ConfigureAwait(false); + var successCount = results.Count(r => r.Success); _logger.LogDebug("[Worker-{WorkerId}] Batch completed - {Success}/{Total} unique blobs in {Duration}ms (original batch: {OriginalCount})", workerId, successCount, deduplicatedOps.Count, sw.ElapsedMilliseconds, batch.Count); diff --git a/src/SimpleL7Proxy/Config/AppConfigBootstrap.cs b/src/SimpleL7Proxy/Config/AppConfigBootstrap.cs new file mode 100644 index 00000000..f749e381 --- /dev/null +++ b/src/SimpleL7Proxy/Config/AppConfigBootstrap.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.Logging; +using Azure.Data.AppConfiguration; +using Azure.Identity; + +namespace SimpleL7Proxy.Config; + +/// +/// Bootstraps the Azure App Configuration download early in startup so that +/// values are available as environment variables before LoadBackendOptions runs. +/// Uses to do a one-shot fetch, then maps +/// each App Config key path to its environment variable name via +/// . +/// Call at the beginning of Main (before building the host), +/// then at the top of LoadBackendOptions. +/// +public class AppConfigBootstrap +{ + private Task?>? _downloadTask; + private readonly ILogger _logger; + private readonly string? _endpoint; + private readonly string? _connectionString; + private readonly string? _labelFilter; + + public AppConfigBootstrap(ILogger logger) + { + _logger = logger; + _endpoint = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_ENDPOINT"); + _connectionString = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_CONNECTION_STRING"); + + var labelFilter = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_LABEL"); + _labelFilter = string.IsNullOrEmpty(labelFilter) || labelFilter == "\\0" || labelFilter == "\0" + ? null + : labelFilter; + } + + /// + /// Kicks off an async download of Warm: and Cold: keys from App Configuration. + /// Returns immediately; the download runs on a thread-pool thread. + /// No-op when AZURE_APPCONFIG_ENDPOINT / AZURE_APPCONFIG_CONNECTION_STRING are not set. + /// + public void Start() + { + if (string.IsNullOrEmpty(_endpoint) && string.IsNullOrEmpty(_connectionString)) + { + _logger.LogInformation("[BOOTSTRAP] App Configuration not configured, skipping bootstrap download"); + _downloadTask = Task.FromResult?>(null); + return; + } + + _logger.LogInformation("[BOOTSTRAP] Starting App Configuration bootstrap download..."); + _downloadTask = Task.Run(DownloadConfig); + } + + /// + /// Blocks until the bootstrap download completes and returns the dictionary + /// keyed by environment variable name (ConfigName) with the App Configuration value. + /// Returns null if not configured or download failed. + /// Safe to call when Start was never called (no-op). + /// + public Dictionary? WaitForDownload() + { + if (_downloadTask == null) return null; + + Dictionary? settings; + try + { + settings = _downloadTask.GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[BOOTSTRAP] Failed awaiting App Configuration download"); + return null; + } + + if (settings == null || settings.Count == 0) + { + _logger.LogInformation("[BOOTSTRAP] No App Configuration settings downloaded"); + return null; + } + + _logger.LogInformation("[BOOTSTRAP] Retrieved {Count} App Configuration value(s)", settings.Count); + return settings; + } + + private Dictionary? DownloadConfig() + { + try + { + ConfigurationClient client = !string.IsNullOrEmpty(_endpoint) + ? new ConfigurationClient(new Uri(_endpoint), new DefaultAzureCredential()) + : new ConfigurationClient(_connectionString!); + + // Build a lookup from App Config key path → env var name using the descriptors. + // e.g. "Logging:LogConsole" → "LogConsole", "Async:Timeout" → "AsyncTimeout" + var keyPathToEnvVar = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var descriptor in ConfigOptions.Descriptors) + { + keyPathToEnvVar[descriptor.Attribute.KeyPath] = descriptor.ConfigName; + } + + var settings = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var prefix in new[] { "Warm:", "Cold:" }) + { + var selector = new SettingSelector { KeyFilter = $"{prefix}*", LabelFilter = _labelFilter }; + foreach (var setting in client.GetConfigurationSettings(selector)) + { + // Strip prefix: "Warm:Logging:LogConsole" → "Logging:LogConsole" + var keyPath = setting.Key.Substring(prefix.Length); + if (string.IsNullOrEmpty(keyPath) + || keyPath.Equals("Sentinel", StringComparison.OrdinalIgnoreCase)) + continue; + + // Map the key path to the env var name via the descriptors + if (keyPathToEnvVar.TryGetValue(keyPath, out var envVarName)) + { + settings[envVarName] = setting.Value ?? ""; + _logger.LogDebug("[BOOTSTRAP] {Key} → {EnvVar}", setting.Key, envVarName); + } + else if (ConfigParser.IsBackendHostConfigName(keyPath)) + { + // Host entries are not descriptor-backed (Host1..N, Probe_path1..N, IP1..N). + // Keep their key names so RegisterBackends can resolve them. + settings[keyPath] = setting.Value ?? ""; + _logger.LogDebug("[BOOTSTRAP] {Key} → {EnvVar}", setting.Key, keyPath); + } + else + { + _logger.LogDebug("[BOOTSTRAP] No descriptor for key {Key}, skipping", setting.Key); + } + } + } + + _logger.LogInformation("[BOOTSTRAP] ✓ Downloaded {Count} setting(s) from App Configuration", settings.Count); + return settings; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[BOOTSTRAP] ✗ App Configuration download failed — continuing with env vars only"); + return null; + } + } +} diff --git a/src/SimpleL7Proxy/Config/AppConfigurationSnapshot.cs b/src/SimpleL7Proxy/Config/AppConfigurationSnapshot.cs new file mode 100644 index 00000000..ea03ce0c --- /dev/null +++ b/src/SimpleL7Proxy/Config/AppConfigurationSnapshot.cs @@ -0,0 +1,23 @@ +namespace SimpleL7Proxy.Config; + +public class AppConfigurationSnapshot +{ + private readonly object _lock = new(); + private IReadOnlyDictionary _snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public void Replace(IDictionary values) + { + lock (_lock) + { + _snapshot = new Dictionary(values, StringComparer.OrdinalIgnoreCase); + } + } + + public IReadOnlyDictionary GetSnapshot() + { + lock (_lock) + { + return _snapshot; + } + } +} diff --git a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs new file mode 100644 index 00000000..6c671dcd --- /dev/null +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs @@ -0,0 +1,131 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Logging; +using Azure.Identity; + +using Microsoft.Extensions.DependencyInjection; + +namespace SimpleL7Proxy.Config; + +// ────────────────────────────────────────────────────────────────────── +// Extended: DI wiring helpers & middleware +// ────────────────────────────────────────────────────────────────────── + +/// +/// Extension methods for registering Azure App Configuration services in DI. +/// +public static class AzureAppConfigurationExtensions +{ + /// + /// Adds Azure App Configuration with automatic refresh for Warm settings only. + /// If AZURE_APPCONFIG_ENDPOINT or AZURE_APPCONFIG_CONNECTION_STRING are not set, + /// this method does nothing and all configuration comes from environment variables. + /// + public static IServiceCollection AddAzureAppConfigurationWithWarmRefresh( + this IServiceCollection services, + ILogger logger) + { + var endpoint = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_ENDPOINT"); + var connectionString = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_CONNECTION_STRING"); + + if (string.IsNullOrEmpty(endpoint) && string.IsNullOrEmpty(connectionString)) + { + logger.LogInformation("[CONFIG] Using environment variables for configuration (Azure App Configuration not configured)"); + return services; + } + + // Register the SDK's IConfigurationRefresherProvider so we can resolve refreshers at runtime. + // AddAzureAppConfiguration on IConfigurationBuilder sets up the config provider but does NOT + // register DI services — this call does. + services.AddAzureAppConfiguration(); + + services.AddSingleton(sp => + { + var refresher = sp.GetRequiredService().Refreshers.FirstOrDefault(); + return refresher ?? throw new InvalidOperationException("No configuration refresher available"); + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + + logger.LogInformation("[CONFIG] ✓ Azure App Configuration initialized with Warm settings refresh"); + + return services; + } + + /// + /// Configures the configuration builder to use Azure App Configuration. + /// If AZURE_APPCONFIG_ENDPOINT or AZURE_APPCONFIG_CONNECTION_STRING are not set, + /// this method does nothing and configuration comes from environment variables only. + /// Call this in Program.cs before building the host. + /// + public static IConfigurationBuilder AddAzureAppConfigurationWithWarmSupport( + this IConfigurationBuilder builder, + ILogger? logger = null) + { + var endpoint = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_ENDPOINT"); + var connectionString = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_CONNECTION_STRING"); + + if (string.IsNullOrEmpty(endpoint) && string.IsNullOrEmpty(connectionString)) + { + return builder; + } + + var labelFilter = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_LABEL"); + // Treat unset, empty, or the Azure CLI null-label convention "\0" as null label + if (string.IsNullOrEmpty(labelFilter) || labelFilter == "\\0" || labelFilter == "\0") + { + labelFilter = LabelFilter.Null; + } + logger?.LogInformation("[CONFIG] App Configuration label filter: {Label}", + labelFilter == LabelFilter.Null ? "(null / no label)" : labelFilter); + var refreshIntervalSeconds = int.TryParse( + Environment.GetEnvironmentVariable("AZURE_APPCONFIG_REFRESH_SECONDS"), + out var interval) ? interval : 30; + + builder.AddAzureAppConfiguration(options => + { + if (!string.IsNullOrEmpty(endpoint)) + { + options.Connect(new Uri(endpoint), new DefaultAzureCredential()); + logger?.LogInformation("[CONFIG] Connecting to Azure App Configuration via Managed Identity: {Endpoint}", endpoint); + } + else + { + options.Connect(connectionString); + logger?.LogInformation("[CONFIG] Connecting to Azure App Configuration via connection string"); + } + + // Disable replica discovery to prevent noisy DNS SRV lookup failures + // (_origin._tcp.*.azconfig.io) in environments where SRV records are + // unreachable (WSL, restricted networks, single-region deployments). + // Set AZURE_APPCONFIG_REPLICA_DISCOVERY=true to re-enable for geo-replicated stores. + var replicaDiscovery = string.Equals( + Environment.GetEnvironmentVariable("AZURE_APPCONFIG_REPLICA_DISCOVERY"), + "true", StringComparison.OrdinalIgnoreCase); + options.ReplicaDiscoveryEnabled = replicaDiscovery; + if (!replicaDiscovery) + logger?.LogInformation("[CONFIG] Replica discovery disabled (set AZURE_APPCONFIG_REPLICA_DISCOVERY=true to enable)"); + + // Load Warm settings (hot-reloadable, prefix = Warm:) + options.Select("Warm:*", labelFilter); + // Load Cold settings (require restart, prefix = Cold:) + options.Select("Cold:*", labelFilter); + + options.ConfigureRefresh(refresh => + { + // Sentinel is only on the Warm label — Cold settings aren't + // hot-reloaded so they don't need refresh triggers. + refresh.Register("Warm:Sentinel", labelFilter, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(refreshIntervalSeconds)); + }); + + logger?.LogInformation("[CONFIG] ✓ Azure App Configuration configured with {RefreshInterval}s refresh interval (prefixes: Warm:*, Cold:*)", + refreshIntervalSeconds); + }); + + return builder; + } +} \ No newline at end of file diff --git a/src/SimpleL7Proxy/Config/AzureAppConfigurationRefreshService.cs b/src/SimpleL7Proxy/Config/AzureAppConfigurationRefreshService.cs new file mode 100644 index 00000000..fe33e761 --- /dev/null +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationRefreshService.cs @@ -0,0 +1,338 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimpleL7Proxy.Backend; + +namespace SimpleL7Proxy.Config; + +/// +/// Background service that periodically triggers configuration refresh from Azure App Configuration. +/// Refresh interval is controlled by the AZURE_APPCONFIG_REFRESH_SECONDS environment variable (default: 30). +/// +public class AzureAppConfigurationRefreshService : BackgroundService +{ + private readonly IConfigurationRefresher _refresher; + private readonly IConfiguration _configuration; + private readonly AppConfigurationSnapshot _appConfigurationSnapshot; + private readonly IOptions _backendOptions; + private readonly IHostHealthCollection? _hostCollection; + private readonly ILogger _logger; + private readonly ConfigChangeNotifier _notifier; + private readonly TimeSpan _refreshInterval; + private readonly SemaphoreSlim _initialRefreshGate = new(1, 1); + private volatile bool _initialRefreshCompleted; + private readonly IReadOnlyList _warmDescriptors; + private readonly Dictionary _warmDescriptorByConfigName; + private readonly HashSet _warmConfigNames; + private readonly HashSet _warmSnapshotKeys; + private string? _lastSentinel; + + public AzureAppConfigurationRefreshService( + IConfigurationRefresher refresher, + IConfiguration configuration, + AppConfigurationSnapshot appConfigurationSnapshot, + IOptions backendOptions, + ILogger logger, + ConfigChangeNotifier notifier, + IHostHealthCollection? hostCollection = null) + { + _refresher = refresher; + _configuration = configuration; + _appConfigurationSnapshot = appConfigurationSnapshot; + _backendOptions = backendOptions; + _logger = logger; + _notifier = notifier; + _hostCollection = hostCollection; + + // Warm vs Cold is defined by code attributes (ConfigOption.Mode). + // A Cold option only becomes Warm after a code change and process restart. + _warmDescriptors = ConfigOptions.GetWarmDescriptors(); + _warmDescriptorByConfigName = _warmDescriptors + .ToDictionary(d => d.ConfigName, d => d, StringComparer.OrdinalIgnoreCase); + _warmConfigNames = _warmDescriptors + .Select(d => d.ConfigName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + _warmSnapshotKeys = _warmDescriptors + .Select(d => $"Warm:{d.Attribute.KeyPath}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var intervalSeconds = int.TryParse( + Environment.GetEnvironmentVariable("AZURE_APPCONFIG_REFRESH_SECONDS"), + out var interval) ? interval : 30; + _refreshInterval = TimeSpan.FromSeconds(intervalSeconds); + + _logger.LogInformation("[CONFIG] Discovered {Count} warm-decorated BackendOptions properties", _warmDescriptors.Count); + _logger.LogInformation("[CONFIG] Warm BackendOptions tracked for in-place update: {WarmConfigs}", + string.Join(", ", _warmConfigNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase))); + } + + /// + /// Performs the initial configuration download once. + /// Call this during startup when configuration is required before other initialization. + /// + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await EnsureInitialRefreshAsync(cancellationToken); + } + + public IReadOnlyDictionary GetCurrentConfigurationDictionary() + { + return _appConfigurationSnapshot.GetSnapshot(); + } + + /// + /// Reads the current Warm configuration section and stores a filtered snapshot + /// containing descriptor-backed warm keys and dynamic host-family keys + /// (Host*, Probe_path*, IP*). The snapshot is used later to detect changes + /// between refresh cycles. + /// + /// + /// When true, logs the snapshot size at Information level (used during + /// initial startup). Otherwise the capture is silent. + /// + private Dictionary CaptureWarmConfigurationSnapshot(bool alwaysLog = false) + { + // Capture Warm section into the snapshot — includes descriptor-backed + // warm keys plus dynamic host-family keys (Host*, Probe_path*, IP*). + // Keys arrive as "Warm:" since makePathsRelative is false. + var warmKvps = _configuration + .GetSection("Warm") + .AsEnumerable(makePathsRelative: false) + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) + && kvp.Value != null + && (_warmSnapshotKeys.Contains(kvp.Key) + || ConfigParser.IsBackendHostConfigName(kvp.Key["Warm:".Length..]))); + + var dictionary = warmKvps + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!, StringComparer.OrdinalIgnoreCase); + + _appConfigurationSnapshot.Replace(dictionary); + + if (alwaysLog) + { + _logger.LogInformation("[CONFIG] Configuration snapshot loaded ({Count} keys: Warm)", dictionary.Count); + } + + return dictionary; + } + + private List ApplyParsedWarmChanges(Dictionary parsedWarmValues, IReadOnlyList changesToApply) + { + var applied = new List(changesToApply.Count); + var changedProperties = new List(changesToApply.Count); + var target = _backendOptions.Value; + + foreach (var change in changesToApply) + { + if (!_warmDescriptorByConfigName.TryGetValue(change.PropertyName, out var descriptor)) + { + _logger.LogWarning("[CONFIG] Unknown warm config '{ConfigName}' in selected changes, skipping", change.PropertyName); + continue; + } + + if (!parsedWarmValues.TryGetValue(change.PropertyName, out var value)) + { + _logger.LogWarning("[CONFIG] Missing parsed value for warm config '{ConfigName}', skipping", change.PropertyName); + continue; + } + + descriptor.Property.SetValue(target, value); + changedProperties.Add(descriptor.Property); + applied.Add(change); + } + + ConfigParser.ApplyDerivedSettings(target, [.. changedProperties]); + + return applied; + } + + private List SetSubscribedConfigs( + Dictionary parsedWarmValues, + IReadOnlyList detectedWarmChanges) + { + if (detectedWarmChanges.Count == 0) + { + _logger.LogDebug("[CONFIG] Warm config refresh: no BackendOptions changes detected"); + return []; + } + + var subscribedChanges = SelectSubscribedChanges(detectedWarmChanges); + if (subscribedChanges.Count == 0) + { + _logger.LogInformation("[CONFIG] Warm changes detected ({Count}) but none selected for BackendOptions update", + detectedWarmChanges.Count); + return []; + } + + var appliedChanges = ApplyParsedWarmChanges(parsedWarmValues, subscribedChanges); + if (appliedChanges.Count == 0) + { + _logger.LogDebug("[CONFIG] Warm config refresh: no subscribed warm changes were applied"); + } + + return appliedChanges; + } + + private async Task NotifySubscribersAsync(List changes, CancellationToken cancellationToken) + { + if (_notifier is null ) + return; + + await _notifier.NotifyAsync(changes, _backendOptions.Value, cancellationToken); + } + + private List SelectSubscribedChanges(IReadOnlyList detectedWarmChanges) + { + var (hasWildcardSubscriber, subscribedFields) = _notifier.GetSubscribedFieldSet(); + + if (hasWildcardSubscriber) + { + return [.. detectedWarmChanges]; + } + + if (subscribedFields.Count == 0) + { + return []; + } + + return detectedWarmChanges + .Where(change => subscribedFields.Contains(change.PropertyName) + || ConfigParser.IsBackendHostConfigName(change.PropertyName)) + .ToList(); + } + + private async Task ProcessRefreshCycleAsync(CancellationToken stoppingToken) + { + // updates _configuration + var refreshed = await _refresher.TryRefreshAsync(stoppingToken); + + if (!refreshed || !HasSentinelChanged()) + { + return; + } + + // Read refreshed Warm configuration into snapshot first, then parse + // BackendOptions change candidates from the same refreshed view. + var snapshot = CaptureWarmConfigurationSnapshot(); + + _logger.LogInformation("[CONFIG] Sentinel change detected, processing configuration changes..."); + + // detect configs that changed + var (detectedWarmChanges, parsedWarmValues, hostChanges) = ConfigOptions.DetectWarmChanges(_backendOptions.Value, snapshot, _logger); + var appliedChanges = SetSubscribedConfigs(parsedWarmValues, detectedWarmChanges); + + // Notify subscribers for descriptor-backed (non-host) changes. + if (appliedChanges.Count > 0) + { + var changedProperties = string.Join(", ", appliedChanges.Select(c => c.PropertyName)); + + _logger.LogInformation("[CONFIG] Warm config changes detected: {Count} changed config(s) ({Configs})", + appliedChanges.Count, + changedProperties); + + _logger.LogDebug("[CONFIG] Updated BackendOptions fields: {Fields}", + changedProperties); + + await NotifySubscribersAsync(appliedChanges, stoppingToken); + } + + if (hostChanges.Count > 0) + { + // CALL HOST REPARSER + ConfigBootstrapper.RegisterBackends(_backendOptions.Value, null, hostChanges, _hostCollection); + } + } + + /// Reads the current Warm:Sentinel value from the configuration. + private string? ReadSentinel() => _configuration["Warm:Sentinel"]; + + /// + /// Returns true if the sentinel value has changed since the last check. + /// Updates the stored sentinel on change. + /// + private bool HasSentinelChanged() + { + var current = ReadSentinel(); + if (string.Equals(_lastSentinel, current, StringComparison.Ordinal)) + return false; + + _logger.LogDebug("[CONFIG] Sentinel changed: {Old} → {New}", _lastSentinel ?? "(none)", current ?? "(none)"); + _lastSentinel = current; + return true; + } + + private async Task EnsureInitialRefreshAsync(CancellationToken cancellationToken) + { + if (_initialRefreshCompleted) + { + return; + } + + await _initialRefreshGate.WaitAsync(cancellationToken); + try + { + if (_initialRefreshCompleted) + { + return; + } + + _logger.LogInformation("[CONFIG] Performing initial configuration download..."); + var initialRefresh = await _refresher.TryRefreshAsync(cancellationToken); + + if (initialRefresh) + { + _logger.LogInformation("[CONFIG] ✓ Initial configuration downloaded successfully"); + } + else + { + _logger.LogInformation("[CONFIG] Initial configuration is already up-to-date"); + } + + _lastSentinel = ReadSentinel(); + _logger.LogInformation("[CONFIG] Initial sentinel: {Sentinel}", _lastSentinel ?? "(none)"); + + // Only capture the snapshot for future change detection. + // Do NOT re-apply warm options here — the bootstrap path already + // loaded and parsed all values (including math expressions) via + // LoadBackendOptions. Re-applying would redundantly parse the same + // raw strings and fail on expressions like "30 * 60000". + CaptureWarmConfigurationSnapshot(alwaysLog: true); + + _initialRefreshCompleted = true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[CONFIG] ✗ Initial configuration download failed - will continue with defaults and retry"); + } + finally + { + _initialRefreshGate.Release(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("[CONFIG] Azure App Configuration refresh service started with {Interval}s interval", + _refreshInterval.TotalSeconds); + + await EnsureInitialRefreshAsync(stoppingToken); + + // ── Periodic polling loop ── + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(_refreshInterval, stoppingToken); + + try + { + await ProcessRefreshCycleAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[CONFIG] Configuration refresh failed - will retry"); + } + } + + _logger.LogInformation("[CONFIG] Azure App Configuration refresh service stopped"); + } +} diff --git a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs deleted file mode 100644 index 6ac86515..00000000 --- a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs +++ /dev/null @@ -1,837 +0,0 @@ -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.ApplicationInsights.WorkerService; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using OS = System; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Net; -using System.Net.Sockets; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading.Tasks; -using System.Reflection; - -using SimpleL7Proxy.Backend; -using SimpleL7Proxy.Backend.Iterators; -using SimpleL7Proxy.Events; - -namespace SimpleL7Proxy.Config; - -public static class BackendHostConfigurationExtensions -{ - private static ILogger? _logger; - static Dictionary EnvVars = new Dictionary(); - - - public static BackendOptions CreateBackendOptions(ILogger logger) - { - _logger = logger; - return LoadBackendOptions(); - } - - public static IServiceCollection AddBackendHostConfiguration(this IServiceCollection services, ILogger logger, BackendOptions backendOptions) - { - _logger = logger; - - services.AddSingleton(backendOptions); // Direct singleton - services.Configure(opt => - { - // Copy all properties from backendOptions to opt - foreach (var prop in typeof(BackendOptions).GetProperties()) - { - if (prop.CanWrite && prop.CanRead) - prop.SetValue(opt, prop.GetValue(backendOptions)); - } - }); - - return services; - } - - private static int ReadEnvironmentVariableOrDefault(string variableName, int defaultValue) - { - int value = _ReadEnvironmentVariableOrDefault(variableName, defaultValue); - EnvVars[variableName] = value.ToString(); - return value; - } - - private static int[] ReadEnvironmentVariableOrDefault(string variableName, int[] defaultValues) - { - int[] value = _ReadEnvironmentVariableOrDefault(variableName, defaultValues); - EnvVars[variableName] = string.Join(",", value); - return value; - } - private static float ReadEnvironmentVariableOrDefault(string variableName, float defaultValue) - { - float value = _ReadEnvironmentVariableOrDefault(variableName, defaultValue); - EnvVars[variableName] = value.ToString(); - return value; - } - private static string ReadEnvironmentVariableOrDefault(string variableName, string defaultValue) - { - string value = _ReadEnvironmentVariableOrDefault(variableName, defaultValue); - EnvVars[variableName] = value; - return value; - } - private static string ReadEnvironmentVariableOrDefault(string altVariableName, string variableName, string defaultValue) - { - // Try both variable names and use the first non-empty one - string? envValue = Environment.GetEnvironmentVariable(variableName)?.Trim() ?? - Environment.GetEnvironmentVariable(altVariableName)?.Trim(); - - // Use default if neither variable is defined - string result = !string.IsNullOrEmpty(envValue) ? envValue : defaultValue; - - // Record and return the value - EnvVars[variableName] = result; - return result; - } - - private static IterationModeEnum ReadEnvironmentVariableOrDefault(string variableName, IterationModeEnum defaultValue) - { - string? envValue = Environment.GetEnvironmentVariable(variableName)?.Trim(); - if (string.IsNullOrEmpty(envValue) || !Enum.TryParse(envValue, out IterationModeEnum value)) - { - EnvVars[variableName] = defaultValue.ToString(); - return defaultValue; - } - EnvVars[variableName] = value.ToString(); - return value; - } - - private static bool ReadEnvironmentVariableOrDefault(string variableName, bool defaultValue) - { - bool value = _ReadEnvironmentVariableOrDefault(variableName, defaultValue); - EnvVars[variableName] = value.ToString(); - return value; - } - - // Reads an environment variable and returns its value as an integer. - // If the environment variable is not set, it returns the provided default value. - private static int _ReadEnvironmentVariableOrDefault(string variableName, int defaultValue) - { - var envValue = Environment.GetEnvironmentVariable(variableName); - if (!int.TryParse(envValue, out var value)) - { - //_logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); - return defaultValue; - } - return value; - } - - // Reads an environment variable and returns its value as an integer[]. - // If the environment variable is not set, it returns the provided default value. - private static int[] _ReadEnvironmentVariableOrDefault(string variableName, int[] defaultValues) - { - var envValue = Environment.GetEnvironmentVariable(variableName); - if (string.IsNullOrEmpty(envValue)) - { - //_logger?.LogWarning($"Using default: {variableName}: {string.Join(",", defaultValues)}"); - return defaultValues; - } - try - { - return envValue.Split(',').Select(int.Parse).ToArray(); - } - catch (Exception) - { - _logger?.LogWarning($"Could not parse {variableName} as an integer array, using default: {string.Join(",", defaultValues)}"); - return defaultValues; - } - } - - // Reads an environment variable and returns its value as a float. - // If the environment variable is not set, it returns the provided default value. - private static float _ReadEnvironmentVariableOrDefault(string variableName, float defaultValue) - { - var envValue = Environment.GetEnvironmentVariable(variableName); - if (!float.TryParse(envValue, out var value)) - { - //_logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); - return defaultValue; - } - return value; - } - // Reads an environment variable and returns its value as a string. - // If the environment variable is not set, it returns the provided default value. - private static string _ReadEnvironmentVariableOrDefault(string variableName, string defaultValue) - { - var envValue = Environment.GetEnvironmentVariable(variableName); - if (string.IsNullOrEmpty(envValue)) - { - //_logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); - return defaultValue; - } - return envValue.Trim(); - } - - // Reads an environment variable and returns its value as a string. - // If the environment variable is not set, it returns the provided default value. - private static bool _ReadEnvironmentVariableOrDefault(string variableName, bool defaultValue) - { - var envValue = Environment.GetEnvironmentVariable(variableName); - if (string.IsNullOrEmpty(envValue)) - { - _logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); - return defaultValue; - } - return envValue.Trim().Equals("true", StringComparison.OrdinalIgnoreCase); - } - - // Converts a List to a dictionary of integers. - private static Dictionary KVIntPairs(List list) - { - Dictionary keyValuePairs = []; - - foreach (var item in list) - { - var kvp = item.Split(':'); - if (int.TryParse(kvp[0], out int key) && int.TryParse(kvp[1], out int value)) - { - keyValuePairs.Add(key, value); - } - else - { - Console.WriteLine($"Could not parse {item} as a key-value pair, ignoring"); - } - } - - return keyValuePairs; - } - - // Converts a List to a dictionary of strings. - private static Dictionary KVStringPairs(List list) - { - Dictionary keyValuePairs = []; - - foreach (var item in list) - { - var kvp = item.Split('='); - if (kvp.Length == 2) - { - keyValuePairs.Add(kvp[0].Trim(), kvp[1].Trim()); - } - else - { - Console.WriteLine($"Could not parse {item} as a key-value pair, ignoring"); - } - } - - return keyValuePairs; - } - - // Converts a comma-separated string to a list of strings. - private static List ToListOfString(string s) - { - if (String.IsNullOrEmpty(s)) - return []; - - return [.. s.Split(',').Select(p => p.Trim())]; - } - - // Converts a comma-separated string to a list of strings. - private static string[] ToArrayOfString(string s) - { - if (String.IsNullOrEmpty(s)) - return Array.Empty(); - - return s.Split(',').Select(p => p.Trim()).ToArray(); - } - - // Converts a comma-separated string to a list of integers. - private static List ToListOfInt(string s) - { - if (String.IsNullOrEmpty(s)) - return new List(); - - return s.Split(',').Select(p => int.Parse(p.Trim())).ToList(); - } - - // Generic configuration parser that supports both key=value pairs (order-independent) and legacy positional format - // Returns a dictionary of parsed values - private static Dictionary ParseConfigString(string config, Dictionary keyAliases, string configName) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (string.IsNullOrEmpty(config)) - return result; - - var parts = config.Split(',').Select(p => p.Trim()).ToArray(); - - // Check if it's the new key=value format - if (parts.Length > 0 && parts[0].Contains('=')) - { - // Parse as key=value pairs - foreach (var part in parts) - { - var kvp = part.Split('=', 2); // Split into max 2 parts to handle = in connection strings/URIs - if (kvp.Length == 2) - { - var key = kvp[0].Trim().ToLower(); - var value = kvp[1].Trim(); - - // Find the canonical key name from aliases - string? canonicalKey = null; - foreach (var (canonical, aliases) in keyAliases) - { - if (aliases.Any(alias => alias.Equals(key, StringComparison.OrdinalIgnoreCase))) - { - canonicalKey = canonical; - break; - } - } - - if (canonicalKey != null) - { - result[canonicalKey] = value; - } - else - { - _logger?.LogWarning($"Unknown {configName} key: {key}"); - } - } - else - { - _logger?.LogWarning($"Invalid {configName} key:value pair: {part}"); - } - } - } - - return result; - } - - // Parses a comma-separated Service Bus configuration string into individual components - // Format: "key1:value1,key2:value2,..." (order-independent) - // Keys: connectionString (or cs), namespace (or ns), queue (or q), useMI (or mi) - // Example: "cs:Endpoint=sb://...,ns:mysbnamespace,q:myqueue,mi:true" - // Legacy format also supported: "connectionString,namespace,queue,useMI" (positional, must be in order) - private static (string connectionString, string namespace_, string queue, bool useMI) ParseServiceBusConfig(string config) - { - // Define default values - string connectionString = "example-sb-connection-string"; - string namespace_ = ""; - string queue = "requeststatus"; - bool useMI = false; - - if (string.IsNullOrEmpty(config)) - return (connectionString, namespace_, queue, useMI); - - var parts = config.Split(',').Select(p => p.Trim()).ToArray(); - - // Check if it's the new key=value format - if (parts.Length > 0 && parts[0].Contains('=')) - { - // Use generic parser - var keyAliases = new Dictionary - { - { "connectionString", new[] { "connectionstring", "cs" } }, - { "namespace", new[] { "namespace", "ns" } }, - { "queue", new[] { "queue", "q" } }, - { "useMI", new[] { "usemi", "mi" } } - }; - - var parsed = ParseConfigString(config, keyAliases, "AsyncSBConfig"); - - if (parsed.TryGetValue("connectionString", out var cs)) connectionString = cs; - if (parsed.TryGetValue("namespace", out var ns)) namespace_ = ns; - if (parsed.TryGetValue("queue", out var q)) queue = q; - if (parsed.TryGetValue("useMI", out var mi)) useMI = mi.Equals("true", StringComparison.OrdinalIgnoreCase); - - return (connectionString, namespace_, queue, useMI); - } - else - { - // Legacy positional format: "connectionString,namespace,queue,useMI" - if (parts.Length != 4) - { - _logger?.LogWarning($"ServiceBusConfig must have exactly 4 comma-separated values (connectionString,namespace,queue,useMI). Found {parts.Length} values. Using defaults."); - return (connectionString, namespace_, queue, useMI); - } - - useMI = parts[3].Trim().Equals("true", StringComparison.OrdinalIgnoreCase); - return (parts[0], parts[1], parts[2], useMI); - } - } - - // Parses a comma-separated Blob Storage configuration string into individual components - // Format: "key1=value1,key2=value2,..." (order-independent) - // Keys: connectionString (or cs), accountUri (or uri), useMI (or mi) - // Example: "uri:https://mystorageaccount.blob.core.windows.net/,mi:true" - // Legacy format also supported: "connectionString,accountUri,useMI" (positional, must be in order) - private static (string connectionString, string accountUri, bool useMI) ParseBlobStorageConfig(string config) - { - // Define default values - string connectionString = ""; - string accountUri = "https://example.blob.core.windows.net/"; - bool useMI = false; - - if (string.IsNullOrEmpty(config)) - return (connectionString, accountUri, useMI); - - var parts = config.Split(',').Select(p => p.Trim()).ToArray(); - - // Check if it's the new key=value format - if (parts.Length > 0 && parts[0].Contains('=')) - { - // Use generic parser - var keyAliases = new Dictionary - { - { "connectionString", new[] { "connectionstring", "cs" } }, - { "accountUri", new[] { "accounturi", "uri" } }, - { "useMI", new[] { "usemi", "mi" } } - }; - - var parsed = ParseConfigString(config, keyAliases, "AsyncBlobStorageConfig"); - - if (parsed.TryGetValue("connectionString", out var cs)) connectionString = cs; - if (parsed.TryGetValue("accountUri", out var uri)) accountUri = uri; - if (parsed.TryGetValue("useMI", out var mi)) useMI = mi.Equals("true", StringComparison.OrdinalIgnoreCase); - - return (connectionString, accountUri, useMI); - } - else - { - // Legacy positional format: "connectionString,accountUri,useMI" - if (parts.Length != 3) - { - _logger?.LogWarning($"AsyncBlobStorageConfig must have exactly 3 comma-separated values (connectionString,accountUri,useMI). Found {parts.Length} values. Using defaults."); - return (connectionString, accountUri, useMI); - } - - useMI = parts[2].Trim().Equals("true", StringComparison.OrdinalIgnoreCase); - return (parts[0], parts[1], useMI); - } - } - - private static SocketsHttpHandler getHandler(int initialDelaySecs, int IntervalSecs, int linuxRetryCount) - { - SocketsHttpHandler handler = new SocketsHttpHandler(); - handler.ConnectCallback = async (ctx, ct) => - { - DnsEndPoint dnsEndPoint = ctx.DnsEndPoint; - IPAddress[] addresses = await Dns.GetHostAddressesAsync(dnsEndPoint.Host, dnsEndPoint.AddressFamily, ct).ConfigureAwait(false); - var s = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; - try - { - bool linuxKeepAliveConfigured = false; - - // Basic keep-alive setting - should work on all platforms - s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); - try - { - if (OperatingSystem.IsWindows()) - { - // Windows-specific approach using IOControl - byte[] keepAliveValues = new byte[12]; - BitConverter.GetBytes((uint)1).CopyTo(keepAliveValues, 0); // Turn keep-alive on - BitConverter.GetBytes((uint)60000).CopyTo(keepAliveValues, initialDelaySecs); // 60 seconds before first keep-alive - BitConverter.GetBytes((uint)30000).CopyTo(keepAliveValues, IntervalSecs); // 30 second interval - - s.IOControl(IOControlCode.KeepAliveValues, keepAliveValues, null); - //Console.WriteLine("TCP keep-alive settings applied using Windows-specific method"); - } - else if (OperatingSystem.IsLinux()) - { - - // Set keep-alive idle time in milliseconds - s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, initialDelaySecs); - - // Set keep-alive interval in milliseconds - s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, IntervalSecs); - - s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, linuxRetryCount); - linuxKeepAliveConfigured = true; - - //Console.WriteLine($"TCPKEEPALIVETIME set to {initialDelaySecs} seconds (connection idle time before sending probes)"); - //Console.WriteLine($"TCPKEEPALIVEINTERVAL set to {IntervalSecs} seconds (interval between probes)"); - // Console.WriteLine($"TCPKEEPALIVERETRYCOUNT set to {linuxRetryCount} probes (max failures before disconnect)"); - } - - } - catch (Exception ex) - { - ProxyEvent pe = new() - { - Type = EventType.Exception, - Exception = ex, - ["Message"] = "Failed to set TCP keep-alive parameters", - ["Host"] = dnsEndPoint.Host, - ["Port"] = dnsEndPoint.Port.ToString(), - ["InitialDelaySecs"] = initialDelaySecs.ToString(), - ["IntervalSecs"] = IntervalSecs.ToString(), - ["LinuxRetryCount"] = linuxRetryCount.ToString(), - ["linuxKeepAliveConfigured"] = linuxKeepAliveConfigured.ToString() - }; - pe.SendEvent(); - } - - // Connect to the endpoint - await s.ConnectAsync(addresses, dnsEndPoint.Port, ct).ConfigureAwait(false); - return new NetworkStream(s, ownsSocket: true); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Socket connection error: {ex.Message}"); - s.Dispose(); - throw; - } - }; - - return handler; - } - - // Loads backend options from environment variables or uses default values if the variables are not set. - // It also configures the DNS refresh timeout and sets up an HttpClient instance. - // If the IgnoreSSLCert environment variable is set to true, it configures the HttpClient to ignore SSL certificate errors. - // If the AppendHostsFile environment variable is set to true, it appends the IP addresses and hostnames to the /etc/hosts file. - private static BackendOptions LoadBackendOptions() - { - // Read and set the DNS refresh timeout from environment variables or use the default value - var DNSTimeout = ReadEnvironmentVariableOrDefault("DnsRefreshTimeout", 240000); - var KeepAliveInitialDelaySecs = ReadEnvironmentVariableOrDefault("KeepAliveInitialDelaySecs", 60); // 60 seconds - var KeepAlivePingIntervalSecs = ReadEnvironmentVariableOrDefault("KeepAlivePingIntervalSecs", 60); // 60 seconds - var keepAliveDurationSecs = ReadEnvironmentVariableOrDefault("KeepAliveIdleTimeoutSecs", 1200); // 20 minutes - - var EnableMultipleHttp2Connections = ReadEnvironmentVariableOrDefault("EnableMultipleHttp2Connections", false); - var MultiConnLifetimeSecs = ReadEnvironmentVariableOrDefault("MultiConnLifetimeSecs", 3600); // 1 hours - var MultiConnIdleTimeoutSecs = ReadEnvironmentVariableOrDefault("MultiConnIdleTimeoutSecs", 300); // 5 minutes - var MultiConnMaxConns = ReadEnvironmentVariableOrDefault("MultiConnMaxConns", 4000); // 4000 connections - - var retryCount = keepAliveDurationSecs / KeepAlivePingIntervalSecs; // Calculate retry count - var handler = getHandler(KeepAliveInitialDelaySecs, KeepAlivePingIntervalSecs, retryCount); - - if (EnableMultipleHttp2Connections) - { - handler.EnableMultipleHttp2Connections = true; - handler.PooledConnectionLifetime = TimeSpan.FromSeconds(MultiConnLifetimeSecs); - handler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(MultiConnIdleTimeoutSecs); - handler.MaxConnectionsPerServer = MultiConnMaxConns; - handler.ResponseDrainTimeout = TimeSpan.FromSeconds(keepAliveDurationSecs); - Console.WriteLine("Multiple HTTP/2 connections enabled."); - } - else - { - handler.EnableMultipleHttp2Connections = false; - Console.WriteLine("Multiple HTTP/2 connections disabled."); - } - // PooledConnectionIdleTimeout = TimeSpan.FromSeconds(KeepAliveIdleTimeoutSecs), - - - // Configure SSL handling - if (ReadEnvironmentVariableOrDefault("IgnoreSSLCert", false)) - { - handler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions - { - RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true - }; - Console.WriteLine("Ignoring SSL certificate validation errors."); - } - - HttpClient _client = new HttpClient(handler); - - // set timeout to large ti disable it at HttpClient level. Will use token cancellation for timeout instead. - _client.Timeout = Timeout.InfiniteTimeSpan; - - - string replicaID = ReadEnvironmentVariableOrDefault("CONTAINER_APP_REPLICA_NAME", "01"); -#if DEBUG - // Load appsettings.json only in Debug mode - var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: true) - .AddEnvironmentVariables() - .Build(); - - foreach (var setting in configuration.GetSection("Settings").GetChildren()) - { - Environment.SetEnvironmentVariable(setting.Key, setting.Value); - } -#endif - - // Parse Service Bus configuration - supports both single combined variable and individual variables - // ServiceBusConfig format: "connectionString,namespace,queue,useMI" - var sbConfigStr = ReadEnvironmentVariableOrDefault("AsyncSBConfig", ""); - var (sbConnStr, sbNamespace, sbQueue, sbUseMI) = ParseServiceBusConfig(sbConfigStr); - var (blobConnStr, blobAccountUri, blobUseMI) = ParseBlobStorageConfig(ReadEnvironmentVariableOrDefault("AsyncBlobStorageConfig", "")); - - // Create and return a BackendOptions object populated with values from environment variables or default values. - var backendOptions = new BackendOptions - { - AcceptableStatusCodes = ReadEnvironmentVariableOrDefault("AcceptableStatusCodes", new int[] { 200, 202, 401, 403, 404, 408, 410, 412, 417, 400 }), - // Individual env vars override AsyncBlobStorageConfig if specified - AsyncBlobStorageAccountUri = ReadEnvironmentVariableOrDefault("AsyncBlobStorageAccountUri", blobAccountUri), - AsyncBlobStorageConnectionString = ReadEnvironmentVariableOrDefault("AsyncBlobStorageConnectionString", blobConnStr), - AsyncBlobStorageUseMI = ReadEnvironmentVariableOrDefault("AsyncBlobStorageUseMI", blobUseMI), - AsyncBlobWorkerCount = ReadEnvironmentVariableOrDefault("AsyncBlobWorkerCount", 2), - AsyncClientRequestHeader = ReadEnvironmentVariableOrDefault("AsyncClientRequestHeader", "AsyncMode"), - AsyncClientConfigFieldName = ReadEnvironmentVariableOrDefault("AsyncClientConfigFieldName", "async-config"), - AsyncModeEnabled = ReadEnvironmentVariableOrDefault("AsyncModeEnabled", false), - // Individual env vars override ServiceBusConfig if specified - AsyncSBConnectionString = ReadEnvironmentVariableOrDefault("AsyncSBConnectionString", sbConnStr), - AsyncSBNamespace = ReadEnvironmentVariableOrDefault("AsyncSBNamespace", sbNamespace), - AsyncSBQueue = ReadEnvironmentVariableOrDefault("AsyncSBQueue", sbQueue), - AsyncSBUseMI = ReadEnvironmentVariableOrDefault("AsyncSBUseMI", sbUseMI), // Use managed identity for Service Bus - AsyncTimeout = ReadEnvironmentVariableOrDefault("AsyncTimeout", 30 * 60000), - AsyncTTLSecs = ReadEnvironmentVariableOrDefault("AsyncTTLSecs", 24 * 60 * 60), // 24 hours - AsyncTriggerTimeout = ReadEnvironmentVariableOrDefault("AsyncTriggerTimeout", 10000), - CircuitBreakerErrorThreshold = ReadEnvironmentVariableOrDefault("CBErrorThreshold", 50), - CircuitBreakerTimeslice = ReadEnvironmentVariableOrDefault("CBTimeslice", 60), - Client = _client, - ContainerApp = ReadEnvironmentVariableOrDefault("CONTAINER_APP_NAME", "ContainerAppName"), - DefaultPriority = ReadEnvironmentVariableOrDefault("DefaultPriority", 2), - DefaultTTLSecs = ReadEnvironmentVariableOrDefault("DefaultTTLSecs", 300), - DependancyHeaders = ToArrayOfString(ReadEnvironmentVariableOrDefault("DependancyHeaders", "Backend-Host, Host-URL, Status, Duration, Error, Message, Request-Date, backendLog")), - DisallowedHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("DisallowedHeaders", "")), - HealthProbeSidecar = ReadEnvironmentVariableOrDefault("HealthProbeSidecar", "Enabled=false;url=http://localhost:9000"), - HostName = ReadEnvironmentVariableOrDefault("Hostname", replicaID), - Hosts = new List(), - IDStr = $"{ReadEnvironmentVariableOrDefault("RequestIDPrefix", "S7P")}-{replicaID}-", - IterationMode = ReadEnvironmentVariableOrDefault("IterationMode", IterationModeEnum.SinglePass), - LoadBalanceMode = ReadEnvironmentVariableOrDefault("LoadBalanceMode", "latency"), // "latency", "roundrobin", "random" - LogAllRequestHeaders = ReadEnvironmentVariableOrDefault("LogAllRequestHeaders", false), - LogAllRequestHeadersExcept = ToListOfString(ReadEnvironmentVariableOrDefault("LogAllRequestHeadersExcept", "Authorization")), - LogAllResponseHeaders = ReadEnvironmentVariableOrDefault("LogAllResponseHeaders", false), - LogAllResponseHeadersExcept = ToListOfString(ReadEnvironmentVariableOrDefault("LogAllResponseHeadersExcept", "Api-Key")), - LogConsole = ReadEnvironmentVariableOrDefault("LogConsole", true), - LogConsoleEvent = ReadEnvironmentVariableOrDefault("LogConsoleEvent", false), - LogHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("LogHeaders", "")), - LogPoller = ReadEnvironmentVariableOrDefault("LogPoller", true), - LogProbes = ReadEnvironmentVariableOrDefault("LogProbes", true), - MaxQueueLength = ReadEnvironmentVariableOrDefault("MaxQueueLength", 1000), - MaxAttempts = ReadEnvironmentVariableOrDefault("MaxAttempts", 10), - OAuthAudience = ReadEnvironmentVariableOrDefault("OAuthAudience", ""), - PollInterval = ReadEnvironmentVariableOrDefault("PollInterval", 15000), - PollTimeout = ReadEnvironmentVariableOrDefault("PollTimeout", 3000), - Port = ReadEnvironmentVariableOrDefault("Port", 80), - PriorityKeyHeader = ReadEnvironmentVariableOrDefault("PriorityKeyHeader", "S7PPriorityKey"), - PriorityKeys = ToListOfString(ReadEnvironmentVariableOrDefault("PriorityKeys", "12345,234")), - PriorityValues = ToListOfInt(ReadEnvironmentVariableOrDefault("PriorityValues", "1,3")), - PriorityWorkers = KVIntPairs(ToListOfString(ReadEnvironmentVariableOrDefault("PriorityWorkers", "2:1,3:1"))), - RequiredHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("RequiredHeaders", "")), - Revision = ReadEnvironmentVariableOrDefault("CONTAINER_APP_REVISION", "revisionID"), - SuccessRate = ReadEnvironmentVariableOrDefault("SuccessRate", 80), - SuspendedUserConfigUrl = ReadEnvironmentVariableOrDefault("SuspendedUserConfigUrl", "file:config.json"), - StripResponseHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("StripResponseHeaders", "")), - StripRequestHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("StripRequestHeaders", "")), - StorageDbEnabled = ReadEnvironmentVariableOrDefault("StorageDbEnabled", false), - StorageDbContainerName = ReadEnvironmentVariableOrDefault("StorageDbContainerName", "Requests"), - TerminationGracePeriodSeconds = ReadEnvironmentVariableOrDefault("TERMINATION_GRACE_PERIOD_SECONDS", 30), - Timeout = ReadEnvironmentVariableOrDefault("Timeout", 1200000), // 20 minutes - TimeoutHeader = ReadEnvironmentVariableOrDefault("TimeoutHeader", "S7PTimeout"), - TTLHeader = ReadEnvironmentVariableOrDefault("TTLHeader", "S7PTTL"), - UniqueUserHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("UniqueUserHeaders", "X-UserID")), - UseOAuth = ReadEnvironmentVariableOrDefault("UseOAuth", false), - UseOAuthGov = ReadEnvironmentVariableOrDefault("UseOAuthGov", false), - UseProfiles = ReadEnvironmentVariableOrDefault("UseProfiles", false), - UserConfigRequired = ReadEnvironmentVariableOrDefault("UserConfigRequired", false), - UserConfigUrl = ReadEnvironmentVariableOrDefault("UserConfigUrl", "file:config.json"), - UserConfigRefreshIntervalSecs = ReadEnvironmentVariableOrDefault("UserConfigRefreshIntervalSecs", 3600), // 1 hour - UserIDFieldName = ReadEnvironmentVariableOrDefault("LookupHeaderName", "UserIDFieldName", "userId"), // migrate from LookupHeaderName - UserPriorityThreshold = ReadEnvironmentVariableOrDefault("UserPriorityThreshold", 0.1f), - UserProfileHeader = ReadEnvironmentVariableOrDefault("UserProfileHeader", "X-UserProfile"), - UserSoftDeleteTTLMinutes= ReadEnvironmentVariableOrDefault("UserSoftDeleteTTLMinutes", 6*60), // 6 hours - ValidateAuthAppFieldName = ReadEnvironmentVariableOrDefault("ValidateAuthAppFieldName", "authAppID"), - ValidateAuthAppID = ReadEnvironmentVariableOrDefault("ValidateAuthAppID", false), - ValidateAuthAppIDHeader = ReadEnvironmentVariableOrDefault("ValidateAuthAppIDHeader", "X-MS-CLIENT-PRINCIPAL-ID"), - ValidateAuthAppIDUrl = ReadEnvironmentVariableOrDefault("ValidateAuthAppIDUrl", "file:auth.json"), - ValidateHeaders = KVStringPairs(ToListOfString(ReadEnvironmentVariableOrDefault("ValidateHeaders", ""))), - Workers = ReadEnvironmentVariableOrDefault("Workers", 10), - }; - - // RegisterBackends will be called after DI container is built to avoid service provider dependency issues - - // confirm the number of priority keys and values match - if (backendOptions.PriorityKeys.Count != backendOptions.PriorityValues.Count) - { - Console.WriteLine("The number of PriorityKeys and PriorityValues do not match in length, defaulting all values to 5"); - backendOptions.PriorityValues = Enumerable.Repeat(5, backendOptions.PriorityKeys.Count).ToList(); - } - - // confirm that the PriorityWorkers Key's have a corresponding priority keys - int workerAllocation = 0; - foreach (var key in backendOptions.PriorityWorkers.Keys) - { - if (!(backendOptions.PriorityValues.Contains(key) || key == backendOptions.DefaultPriority)) - { - Console.WriteLine($"WARNING: PriorityWorkers Key {key} does not have a corresponding PriorityKey"); - } - workerAllocation += backendOptions.PriorityWorkers[key]; - } - - if (workerAllocation > backendOptions.Workers) - { - Console.WriteLine($"WARNING: Worker allocation exceeds total number of workers:{workerAllocation} > {backendOptions.Workers}"); - Console.WriteLine($"Adjusting total number of workers to {workerAllocation}. Fix PriorityWorkers if it isn't what you want."); - backendOptions.Workers = workerAllocation; - } - - // defined Healthprobe sidecar settings - var healthSettings = backendOptions.HealthProbeSidecar.Split(';', StringSplitOptions.RemoveEmptyEntries); - foreach (var setting in healthSettings) - { - var kvp = setting.Split('=', 2); - if (kvp.Length == 2) - { - var key = kvp[0].Trim().ToLower(); - var value = kvp[1].Trim().ToLower(); - if (key == "enabled") - { - backendOptions.HealthProbeSidecarEnabled = value == "true"; - } - else if (key == "url" && !string.IsNullOrEmpty(value)) - { - backendOptions.HealthProbeSidecarUrl = value; - } - } - } - - // if (backendOptions.UniqueUserHeaders.Count > 0) - // { - // // Make sure that uniqueUserHeaders are also in the required headers - // foreach (var header in backendOptions.UniqueUserHeaders) - // { - // if (!backendOptions.RequiredHeaders.Contains(header)) - // { - // Console.WriteLine($"Adding {header} to RequiredHeaders"); - // backendOptions.RequiredHeaders.Add(header); - // } - // } - // } - - // If validate headers are set, make sure they are also in the required headers and disallowed headers - if (backendOptions.ValidateHeaders.Count > 0) - { - foreach (var (key, value) in backendOptions.ValidateHeaders) - { - Console.WriteLine($"Validating {key} against {value}"); - if (!backendOptions.RequiredHeaders.Contains(key)) - { - Console.WriteLine($"Adding {key} to RequiredHeaders"); - backendOptions.RequiredHeaders.Add(key); - } - if (!backendOptions.RequiredHeaders.Contains(value)) - { - Console.WriteLine($"Adding {value} to RequiredHeaders"); - backendOptions.RequiredHeaders.Add(value); - } - if (!backendOptions.DisallowedHeaders.Contains(value)) - { - Console.WriteLine($"Adding {value} to DisallowedHeaders"); - backendOptions.DisallowedHeaders.Add(value); - } - } - } - - // Validate LoadBalanceMode case insensitively - backendOptions.LoadBalanceMode = backendOptions.LoadBalanceMode.Trim().ToLower(); - if (backendOptions.LoadBalanceMode != Constants.Latency && - backendOptions.LoadBalanceMode != Constants.RoundRobin && - backendOptions.LoadBalanceMode != Constants.Random) - { - Console.WriteLine($"Invalid LoadBalanceMode: {backendOptions.LoadBalanceMode}. Defaulting to '{Constants.Latency}'."); - backendOptions.LoadBalanceMode = Constants.Latency; - } - - OutputEnvVars(); - - return backendOptions; - } - - public static void RegisterBackends(BackendOptions backendOptions) - { - //backendOptions.Client.Timeout = TimeSpan.FromMilliseconds(backendOptions.Timeout); - int i = 1; - StringBuilder sb = new(); - while (true) - { - - var hostname = Environment.GetEnvironmentVariable($"Host{i}")?.Trim(); - if (string.IsNullOrEmpty(hostname)) break; - - var probePath = Environment.GetEnvironmentVariable($"Probe_path{i}")?.Trim(); - var ip = Environment.GetEnvironmentVariable($"IP{i}")?.Trim(); - - try - { - _logger?.LogDebug($"Found host {hostname} with probe path {probePath} and IP {ip}"); - - // Resolve HostConfig from DI using the factory - HostConfig bh = new HostConfig(hostname, probePath, ip, backendOptions.OAuthAudience); - backendOptions.Hosts.Add(bh); - - sb.AppendLine($"{ip} {bh.Host}"); - } - - catch (UriFormatException e) - { - _logger?.LogError($"Could not add Host{i} with {hostname} : {e.Message}"); - Console.WriteLine(e.StackTrace); - } - - i++; - } - - if (Environment.GetEnvironmentVariable("APPENDHOSTSFILE")?.Trim().Equals("true", StringComparison.OrdinalIgnoreCase) == true || - Environment.GetEnvironmentVariable("AppendHostsFile")?.Trim().Equals("true", StringComparison.OrdinalIgnoreCase) == true) - { - _logger?.LogInformation($"Appending {sb} to /etc/hosts"); - using StreamWriter sw = File.AppendText("/etc/hosts"); - sw.WriteLine(sb.ToString()); - } - } - - private static void OutputEnvVars() - { - const int keyWidth = 27; - const int valWidth = 30; - const int gutterWidth = 4; - int col = 0; - string? pendingEntry = null; - foreach (var kvp in EnvVars) - { - string key = kvp.Key; - string value = kvp.Value; - - // Prepare the entry for this pair - string entry = $"{(key.Length > keyWidth ? key.Substring(0, keyWidth - 3) + "..." : key),-keyWidth}:" + - $"{(value.Length > valWidth ? value.Substring(0, valWidth - 3) + "..." : value),-valWidth}"; - - if (col == 0) - { - // Store the first column entry and wait for the second - pendingEntry = entry; - col = 1; - } - else - { - // If the untrimmed key or value for the second column is too long, print it on its own line - if (key.Length > keyWidth || value.Length > valWidth) - { - // Print the pending first column entry alone - Console.WriteLine(pendingEntry); - // Print the long second column entry alone, but obey key/value widths - Console.WriteLine($"{(key.Length > keyWidth ? key.Substring(0, keyWidth - 3) + "..." : key),-keyWidth}: {value}"); - pendingEntry = null; - col = 0; - } - else - { - // Print both columns on the same line with gutter - Console.WriteLine($"{pendingEntry}{new string(' ', gutterWidth)}{entry}"); - pendingEntry = null; - col = 0; - } - } - } - if (col % 2 != 0) - { - Console.WriteLine(); - } - } - -} diff --git a/src/SimpleL7Proxy/Config/BackendOptions.cs b/src/SimpleL7Proxy/Config/BackendOptions.cs index d1b02b52..b1c0763b 100644 --- a/src/SimpleL7Proxy/Config/BackendOptions.cs +++ b/src/SimpleL7Proxy/Config/BackendOptions.cs @@ -5,105 +5,231 @@ namespace SimpleL7Proxy.Config; public class BackendOptions { - public int[] AcceptableStatusCodes { get; set; } = []; - public string AsyncBlobStorageConnectionString { get; set; } = "example-connection-string"; - public bool AsyncBlobStorageUseMI { get; set; } = true; - public string AsyncBlobStorageAccountUri { get; set; } = "https://mystorageaccount.blob.core.windows.net"; - public int AsyncBlobWorkerCount { get; set; } = 2; + // ════════════════════════════════════════════════════════════════════ + // Warm — published to App Configuration, hot-reloaded (~30 s) + // ════════════════════════════════════════════════════════════════════ + + // ── Async ── + [ConfigOption("Async:ClientRequestHeader", ConfigName = "AsyncClientRequestHeader")] public string AsyncClientRequestHeader { get; set; } = "AsyncMode"; + [ConfigOption("Async:ClientConfigFieldName", ConfigName = "AsyncClientConfigFieldName")] public string AsyncClientConfigFieldName { get; set; } = "async-config"; - public bool AsyncModeEnabled { get; set; } = false; - public string AsyncSBConnectionString { get; set; } = "example-sb-connection-string"; - public string AsyncSBQueue { get; set; } = "requeststatus"; - public bool AsyncSBUseMI { get; set; } = false; // Use managed identity for Service Bus - public string AsyncSBNamespace { get; set; } = "example-namespace"; + [ConfigOption("Async:Timeout", ConfigName = "AsyncTimeout")] public double AsyncTimeout { get; set; } = 30 * 60000; // 30 minutes + [ConfigOption("Async:TTLSecs", ConfigName = "AsyncTTLSecs")] public int AsyncTTLSecs { get; set; } = 24 * 60 * 60; // 24 hours - public int AsyncTriggerTimeout { get; set; } = 60000; // 1 minute - public HttpClient? Client { get; set; } - public string ContainerApp { get; set; } = ""; - public int CircuitBreakerErrorThreshold { get; set; } - public int CircuitBreakerTimeslice { get; set; } - public int DefaultPriority { get; set; } - public int DefaultTTLSecs { get; set; } - public string[] DependancyHeaders { get; set; } = []; - public List DisallowedHeaders { get; set; } = []; - public string HealthProbeSidecar { get; set; } = "Enabled=false; Url=http://localhost:9000"; - public bool HealthProbeSidecarEnabled { get; set; } = false; - public string HealthProbeSidecarUrl { get; set; } = "http://localhost/9000"; - public string HostName { get; set; } = ""; - public List Hosts { get; set; } = []; - public string IDStr { get; set; } = "S7P"; - public IterationModeEnum IterationMode { get; set; } + [ConfigOption("Async:TriggerTimeout", ConfigName = "AsyncTriggerTimeout")] + public int AsyncTriggerTimeout { get; set; } = 10000; // 10 seconds + + // ── Circuit Breaker ── + [ConfigOption("CircuitBreaker:ErrorThreshold", ConfigName = "CBErrorThreshold")] + public int CircuitBreakerErrorThreshold { get; set; } = 50; + [ConfigOption("CircuitBreaker:Timeslice", ConfigName = "CBTimeslice")] + public int CircuitBreakerTimeslice { get; set; } = 60; + + // ── Health Probe ── + [ConfigOption("HealthProbe:Sidecar", ConfigName = "HealthProbeSidecar")] + public string HealthProbeSidecar { get; set; } = "Enabled=false;url=http://localhost:9000"; + + // ── Load Balancing ── + [ConfigOption("LoadBalancing:Mode")] public string LoadBalanceMode { get; set; } = "latency"; // "latency", "roundrobin", "random" - public bool LogConsole { get; set; } - public bool LogConsoleEvent { get; set; } - public bool LogPoller { get; set; } = false; + + // ── Logging ── + [ConfigOption("Logging:LogConsole")] + public bool LogConsole { get; set; } = true; + [ConfigOption("Logging:LogConsoleEvent")] + public bool LogConsoleEvent { get; set; } = false; + [ConfigOption("Logging:LogPoller")] + public bool LogPoller { get; set; } = true; + [ConfigOption("Logging:LogHeaders")] public List LogHeaders { get; set; } = []; - public bool LogProbes { get; set; } + [ConfigOption("Logging:LogProbes")] + public bool LogProbes { get; set; } = true; + [ConfigOption("Logging:LogAllRequestHeaders")] public bool LogAllRequestHeaders { get; set; } = false; - public List LogAllRequestHeadersExcept { get; set; } = []; + [ConfigOption("Logging:LogAllRequestHeadersExcept")] + public List LogAllRequestHeadersExcept { get; set; } = ["Authorization"]; + [ConfigOption("Logging:LogAllResponseHeaders")] public bool LogAllResponseHeaders { get; set; } = false; - public List LogAllResponseHeadersExcept { get; set; } = []; - public string UserIDFieldName { get; set; } = ""; - public int MaxQueueLength { get; set; } - public int MaxAttempts { get ; set; } - public string OAuthAudience { get; set; } = ""; - public int Port { get; set; } - public int PollInterval { get; set; } - public int PollTimeout { get; set; } - public string PriorityKeyHeader { get; set; } = ""; - public List PriorityKeys { get; set; } = []; - public List PriorityValues { get; set; } = []; - public Dictionary PriorityWorkers { get; set; } = []; - public string Revision { get; set; } = ""; + [ConfigOption("Logging:LogAllResponseHeadersExcept")] + public List LogAllResponseHeadersExcept { get; set; } = ["Api-Key"]; + + // ── Priority ── + [ConfigOption("Priority:DefaultPriority")] + public int DefaultPriority { get; set; } = 2; + [ConfigOption("Priority:DefaultTTLSecs")] + public int DefaultTTLSecs { get; set; } = 300; + [ConfigOption("Priority:PriorityKeyHeader")] + public string PriorityKeyHeader { get; set; } = "S7PPriorityKey"; + [ConfigOption("Priority:PriorityKeys")] + public List PriorityKeys { get; set; } = ["12345", "234"]; + [ConfigOption("Priority:PriorityValues")] + public List PriorityValues { get; set; } = [1, 3]; + + // ── Request ── + [ConfigOption("Request:DependancyHeaders")] + public List DependancyHeaders { get; set; } = ["Backend-Host", "Host-URL", "Status", "Duration", "Error", "Message", "Request-Date", "backendLog"]; + [ConfigOption("Request:DisallowedHeaders")] + public List DisallowedHeaders { get; set; } = []; + [ConfigOption("Request:MaxAttempts")] + public int MaxAttempts { get; set; } = 10; + [ConfigOption("Request:RequiredHeaders")] public List RequiredHeaders { get; set; } = []; - public int SuccessRate { get; set; } - public string SuspendedUserConfigUrl { get; set; } = ""; - // Storage configuration - public bool StorageDbEnabled { get; set; } = false; - public string StorageDbContainerName { get; set; } = "Requests"; - public List StripResponseHeaders { get; set; } = []; + [ConfigOption("Request:StripRequestHeaders")] public List StripRequestHeaders { get; set; } = []; - public int Timeout { get; set; } - public string TimeoutHeader { get; set; } = ""; - public int TerminationGracePeriodSeconds { get; set; } - public bool TrackWorkers { get; set; } = false; - public string TTLHeader { get; set; } = ""; - public List UniqueUserHeaders { get; set; } = []; - public bool UseOAuth { get; set; } - public bool UseOAuthGov { get; set; } = false; - public bool UseProfiles { get; set; } = false; - public string UserProfileHeader { get; set; } = ""; - public string UserConfigUrl { get; set; } = ""; - public bool UserConfigRequired { get; set; } = false; - public int UserConfigRefreshIntervalSecs { get; set; } - public float UserPriorityThreshold { get; set; } - public int UserSoftDeleteTTLMinutes { get; set; } + [ConfigOption("Request:TimeoutHeader")] + public string TimeoutHeader { get; set; } = "S7PTimeout"; + [ConfigOption("Request:TTLHeader")] + public string TTLHeader { get; set; } = "S7PTTL"; + + // ── Response ── + [ConfigOption("Response:AcceptableStatusCodes")] + public int[] AcceptableStatusCodes { get; set; } = [200, 202, 401, 403, 404, 408, 410, 412, 417, 400]; + [ConfigOption("Response:StripResponseHeaders")] + public List StripResponseHeaders { get; set; } = []; + + // ── User ── + [ConfigOption("User:SuspendedUserConfigUrl")] + public string SuspendedUserConfigUrl { get; set; } = "file:config.json"; + [ConfigOption("User:UniqueUserHeaders")] + public List UniqueUserHeaders { get; set; } = ["X-UserID"]; + [ConfigOption("User:UserConfigUrl")] + public string UserConfigUrl { get; set; } = "file:config.json"; + [ConfigOption("User:UserIDFieldName")] + public string UserIDFieldName { get; set; } = "userId"; + [ConfigOption("User:UserPriorityThreshold")] + public float UserPriorityThreshold { get; set; } = 0.1f; + [ConfigOption("User:UserProfileHeader")] + public string UserProfileHeader { get; set; } = "X-UserProfile"; + + // ── Validation ── + [ConfigOption("Validation:ValidateHeaders")] public Dictionary ValidateHeaders { get; set; } = []; + [ConfigOption("Validation:ValidateAuthAppID")] public bool ValidateAuthAppID { get; set; } = false; - public string ValidateAuthAppIDUrl { get; set; } = ""; - public string ValidateAuthAppFieldName { get; set; } = ""; - public string ValidateAuthAppIDHeader { get; set; } = ""; - public int Workers { get; set; } - - // Shared Iterator Settings + [ConfigOption("Validation:ValidateAuthAppIDUrl")] + public string ValidateAuthAppIDUrl { get; set; } = "file:auth.json"; + [ConfigOption("Validation:ValidateAuthAppFieldName")] + public string ValidateAuthAppFieldName { get; set; } = "authAppID"; + [ConfigOption("Validation:ValidateAuthAppIDHeader")] + public string ValidateAuthAppIDHeader { get; set; } = "X-MS-CLIENT-PRINCIPAL-ID"; + + // ════════════════════════════════════════════════════════════════════ + // Cold — published to App Configuration, requires restart + // ════════════════════════════════════════════════════════════════════ + + // ── Async ── + [ConfigOption("Async:BlobStorageConfig", ConfigName = "AsyncBlobStorageConfig", Mode = ConfigMode.Cold)] + public string AsyncBlobStorageConfig { get; set; } = "uri=https://mystorageaccount.blob.core.windows.net,mi=true"; + [ConfigOption("Async:SBConfig", ConfigName = "AsyncSBConfig", Mode = ConfigMode.Cold)] + public string AsyncSBConfig { get; set; } = "cs=example-sb-connection-string,ns=example-namespace,q=requeststatus,mi=false"; + [ConfigOption("Async:BlobWorkerCount", ConfigName = "AsyncBlobWorkerCount", Mode = ConfigMode.Cold)] + public int AsyncBlobWorkerCount { get; set; } = 2; + [ConfigOption("Async:ModeEnabled", ConfigName = "AsyncModeEnabled", Mode = ConfigMode.Cold)] + public bool AsyncModeEnabled { get; set; } = false; + + // ── Security ── + [ConfigOption("Security:OAuthAudience", Mode = ConfigMode.Cold)] + public string OAuthAudience { get; set; } = ""; + [ConfigOption("Security:UseOAuth", Mode = ConfigMode.Cold)] + public bool UseOAuth { get; set; } = false; + [ConfigOption("Security:UseOAuthGov", Mode = ConfigMode.Cold)] + public bool UseOAuthGov { get; set; } = false; + + // ── Server ── + [ConfigOption("Server:IterationMode", Mode = ConfigMode.Cold)] + public IterationModeEnum IterationMode { get; set; } = IterationModeEnum.SinglePass; + [ConfigOption("Server:MaxQueueLength", Mode = ConfigMode.Cold)] + public int MaxQueueLength { get; set; } = 1000; + [ConfigOption("Server:PollInterval", Mode = ConfigMode.Cold)] + public int PollInterval { get; set; } = 15000; + [ConfigOption("Server:PollTimeout", Mode = ConfigMode.Cold)] + public int PollTimeout { get; set; } = 3000; + [ConfigOption("Server:Port", Mode = ConfigMode.Cold)] + public int Port { get; set; } = 80; + [ConfigOption("Server:SuccessRate", Mode = ConfigMode.Cold)] + public int SuccessRate { get; set; } = 80; + [ConfigOption("Server:TerminationGracePeriodSeconds", ConfigName = "TERMINATION_GRACE_PERIOD_SECONDS", Mode = ConfigMode.Cold)] + public int TerminationGracePeriodSeconds { get; set; } = 30; + [ConfigOption("Server:Timeout", Mode = ConfigMode.Cold)] + public int Timeout { get; set; } = 1200000; // 20 minutes + [ConfigOption("Server:Workers", Mode = ConfigMode.Cold)] + public int Workers { get; set; } = 10; + + // ── Shared Iterators ── /// /// When true, requests to the same path share the same host iterator, /// ensuring fair round-robin distribution across concurrent requests. - /// Default: false (each request gets its own iterator) /// + [ConfigOption("Server:UseSharedIterators", Mode = ConfigMode.Cold)] public bool UseSharedIterators { get; set; } = false; - - /// - /// How long (in seconds) an unused shared iterator lives before cleanup. - /// Default: 60 seconds - /// + /// How long (in seconds) an unused shared iterator lives before cleanup. + [ConfigOption("Server:SharedIteratorTTLSeconds", Mode = ConfigMode.Cold)] public int SharedIteratorTTLSeconds { get; set; } = 60; - - /// - /// How often (in seconds) to run cleanup of stale shared iterators. - /// Default: 30 seconds - /// + /// How often (in seconds) to run cleanup of stale shared iterators. + [ConfigOption("Server:SharedIteratorCleanupIntervalSeconds", Mode = ConfigMode.Cold)] public int SharedIteratorCleanupIntervalSeconds { get; set; } = 30; + + // ── Storage ── + [ConfigOption("Storage:Enabled", ConfigName = "StorageDbEnabled", Mode = ConfigMode.Cold)] + public bool StorageDbEnabled { get; set; } = false; + [ConfigOption("Storage:ContainerName", ConfigName = "StorageDbContainerName", Mode = ConfigMode.Cold)] + public string StorageDbContainerName { get; set; } = "Requests"; + + // ── User ── + [ConfigOption("User:UseProfiles", Mode = ConfigMode.Cold)] + public bool UseProfiles { get; set; } = false; + [ConfigOption("User:UserConfigRequired", Mode = ConfigMode.Cold)] + public bool UserConfigRequired { get; set; } = false; + [ConfigOption("User:UserConfigRefreshIntervalSecs", Mode = ConfigMode.Cold)] + public int UserConfigRefreshIntervalSecs { get; set; } = 3600; // 1 hour + [ConfigOption("User:UserSoftDeleteTTLMinutes", Mode = ConfigMode.Cold)] + public int UserSoftDeleteTTLMinutes { get; set; } = 360; // 6 hours + + // ════════════════════════════════════════════════════════════════════ + // Hidden — not published (runtime-derived / parsed / composite) + // ════════════════════════════════════════════════════════════════════ + + // ── Parsed from AsyncBlobStorageConfig ── + [ParsedConfig("AsyncBlobStorageConfig")] + [ConfigOption("Async:BlobStorageConnectionString", Mode = ConfigMode.Hidden)] + public string AsyncBlobStorageConnectionString { get; set; } = "example-connection-string"; + [ParsedConfig("AsyncBlobStorageConfig")] + [ConfigOption("Async:BlobStorageUseMI", Mode = ConfigMode.Hidden)] + public bool AsyncBlobStorageUseMI { get; set; } = true; + [ParsedConfig("AsyncBlobStorageConfig")] + [ConfigOption("Async:BlobStorageAccountUri", Mode = ConfigMode.Hidden)] + public string AsyncBlobStorageAccountUri { get; set; } = "https://mystorageaccount.blob.core.windows.net"; + + // ── Parsed from AsyncSBConfig ── + [ParsedConfig("AsyncSBConfig")] + [ConfigOption("Async:SBConnectionString", Mode = ConfigMode.Hidden)] + public string AsyncSBConnectionString { get; set; } = "example-sb-connection-string"; + [ParsedConfig("AsyncSBConfig")] + [ConfigOption("Async:SBQueue", Mode = ConfigMode.Hidden)] + public string AsyncSBQueue { get; set; } = "requeststatus"; + [ParsedConfig("AsyncSBConfig")] + [ConfigOption("Async:SBUseMI", Mode = ConfigMode.Hidden)] + public bool AsyncSBUseMI { get; set; } = false; + [ParsedConfig("AsyncSBConfig")] + [ConfigOption("Async:SBNamespace", Mode = ConfigMode.Hidden)] + public string AsyncSBNamespace { get; set; } = "example-namespace"; + + // ── Metadata ── + [ConfigOption("Metadata:ContainerApp", ConfigName = "CONTAINER_APP_NAME", Mode = ConfigMode.Hidden)] + public string ContainerApp { get; set; } = "ContainerAppName"; + [ConfigOption("Metadata:IDStr", ConfigName = "RequestIDPrefix", Mode = ConfigMode.Hidden)] + public string IDStr { get; set; } = "S7P"; + [ConfigOption("Metadata:Revision", ConfigName = "CONTAINER_APP_REVISION", Mode = ConfigMode.Hidden)] + public string Revision { get; set; } = "revisionID"; + + // ── Runtime-derived (no attribute) ── + public HttpClient? Client { get; set; } + public bool HealthProbeSidecarEnabled { get; set; } = false; + public string HealthProbeSidecarUrl { get; set; } = "http://localhost/9000"; + public string HostName { get; set; } = ""; + public List Hosts { get; set; } = []; + public Dictionary PriorityWorkers { get; set; } = new() { { 2, 1 }, { 3, 1 } }; + public bool TrackWorkers { get; set; } = false; } \ No newline at end of file diff --git a/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs new file mode 100644 index 00000000..9f012554 --- /dev/null +++ b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs @@ -0,0 +1,468 @@ +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.ApplicationInsights.WorkerService; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OS = System; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using System.Reflection; + +using SimpleL7Proxy.Backend; +using SimpleL7Proxy.Backend.Iterators; +using SimpleL7Proxy.Events; + +namespace SimpleL7Proxy.Config; + +public static class ConfigBootstrapper +{ + private static ILogger? _logger; + static Dictionary EnvVars = new Dictionary(); + private static readonly BackendOptions s_defaults = new(); + + + public static BackendOptions CreateBackendOptions(ILogger logger, AppConfigBootstrap appConfigBootstrap) + { + Dictionary effectiveEnvironment = new Dictionary(StringComparer.OrdinalIgnoreCase); + _logger = logger; + + foreach (DictionaryEntry de in Environment.GetEnvironmentVariables()) + { + var key = de.Key?.ToString(); + if (string.IsNullOrEmpty(key)) + continue; + effectiveEnvironment[key] = de.Value?.ToString() ?? string.Empty; + } + + // Wait for the bootstrap App Configuration download (started in Main) and + // add values into the effective environment dictionary so every + // ReadEnvironmentVariableOrDefault call below picks them up. + var appConfigSettings = appConfigBootstrap.WaitForDownload(); + if (appConfigSettings != null) + { + foreach (var kvp in appConfigSettings) + { + // strip [" and "] from keys and values if present to support both raw and JSON-style formats + string key = kvp.Key.Trim().TrimStart('[').TrimEnd(']').TrimStart('"').TrimEnd('"'); + string value = kvp.Value.Trim().TrimStart('[').TrimEnd(']').TrimStart('"').TrimEnd('"'); + effectiveEnvironment[key] = value; + } + _logger?.LogInformation("[BOOTSTRAP] Applied {Count} App Configuration value(s) to effective environment", appConfigSettings.Count); + } + + var backendOptions = ConfigParser.ParseOptions(effectiveEnvironment); + ConfigureHttpClientFromOptions(effectiveEnvironment, backendOptions); + + OutputEnvVars(); + + return backendOptions; + } + + private static void OutputEnvVars() + { + static string MaskValue(string key, string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + var lower = key.ToLowerInvariant(); + var isSensitive = lower.Contains("connectionstring") || + lower.Contains("password") || + lower.Contains("secret") || + lower.Contains("token") || + lower.Contains("apikey") || + lower.Contains("sas"); + + if (!isSensitive) + return value; + + if (value.Length <= 4) + return "****"; + + return $"{value.Substring(0, 2)}***{value.Substring(value.Length - 2)}"; + } + + const int keyWidth = 27; + const int valWidth = 30; + const int gutterWidth = 4; + int col = 0; + string? pendingEntry = null; + foreach (var kvp in ConfigParser.GetParsedEnvVars()) + { + string key = kvp.Key; + string value = MaskValue(key, kvp.Value); + + string entry = $"{(key.Length > keyWidth ? key.Substring(0, keyWidth - 3) + "..." : key),-keyWidth}:" + + $"{(value.Length > valWidth ? value.Substring(0, valWidth - 3) + "..." : value),-valWidth}"; + + if (col == 0) + { + pendingEntry = entry; + col = 1; + } + else + { + if (key.Length > keyWidth || value.Length > valWidth) + { + Console.WriteLine(pendingEntry); + Console.WriteLine($"{(key.Length > keyWidth ? key.Substring(0, keyWidth - 3) + "..." : key),-keyWidth}: {value}"); + pendingEntry = null; + col = 0; + } + else + { + Console.WriteLine($"{pendingEntry}{new string(' ', gutterWidth)}{entry}"); + pendingEntry = null; + col = 0; + } + } + } + if (col % 2 != 0) + { + Console.WriteLine(); + } + } + + public static IServiceCollection AddBackendHostConfiguration(this IServiceCollection services, ILogger logger, BackendOptions backendOptions) + { + _logger = logger; + + services.AddSingleton(backendOptions); // Direct singleton + services.Configure(opt => + { + // Copy all properties from backendOptions to opt + foreach (var prop in typeof(BackendOptions).GetProperties()) + { + if (prop.CanWrite && prop.CanRead) + prop.SetValue(opt, prop.GetValue(backendOptions)); + } + }); + + return services; + } + + private static int ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int defaultValue) + { + int value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); + EnvVars[variableName] = value.ToString(); + return value; + } + + private static bool ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, bool defaultValue) + { + bool value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); + EnvVars[variableName] = value.ToString(); + return value; + } + + // Reusable DataTable for evaluating simple arithmetic expressions (e.g. "60*10", "1200/2") + private static readonly System.Data.DataTable s_mathTable = new(); + + // Tries to evaluate a simple arithmetic expression (supports +, -, *, /). + // Returns false if the expression is not valid math. + private static bool TryEvaluateMathExpression(string expression, out double result) + { + result = 0; + if (string.IsNullOrWhiteSpace(expression)) return false; + try + { + var computed = s_mathTable.Compute(expression, null); + result = Convert.ToDouble(computed); + return true; + } + catch { return false; } + } + + // Reads an environment variable and returns its value as an integer. + // If the environment variable is not set, it returns the provided default value. + // Supports simple arithmetic expressions (e.g. "60*10"). + private static int _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int defaultValue) + { + var envValue = env.GetValueOrDefault(variableName); + if (envValue?.Trim() == ConfigOptions.DefaultPlaceholder) envValue = null; + if (!int.TryParse(envValue, out var value)) + { + // Try evaluating as a math expression (e.g. "60*10") + if (TryEvaluateMathExpression(envValue!, out var mathResult)) + return (int)mathResult; + //_logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); + return defaultValue; + } + return value; + } + + // Reads an environment variable and returns its value as a string. + // If the environment variable is not set, it returns the provided default value. + private static bool _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, bool defaultValue) + { + var envValue = env.GetValueOrDefault(variableName); + if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) + { + _logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); + return defaultValue; + } + return envValue.Trim().Equals("true", StringComparison.OrdinalIgnoreCase); + } + + // Converts a List to a dictionary of integers. + + private static SocketsHttpHandler getHandler(int initialDelaySecs, int IntervalSecs, int linuxRetryCount) + { + SocketsHttpHandler handler = new SocketsHttpHandler(); + handler.ConnectCallback = async (ctx, ct) => + { + DnsEndPoint dnsEndPoint = ctx.DnsEndPoint; + IPAddress[] addresses = await Dns.GetHostAddressesAsync(dnsEndPoint.Host, dnsEndPoint.AddressFamily, ct).ConfigureAwait(false); + var s = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + bool linuxKeepAliveConfigured = false; + + // Basic keep-alive setting - should work on all platforms + s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + try + { + if (OperatingSystem.IsWindows()) + { + // Windows-specific approach using IOControl + byte[] keepAliveValues = new byte[12]; + BitConverter.GetBytes((uint)1).CopyTo(keepAliveValues, 0); // Turn keep-alive on + BitConverter.GetBytes((uint)(initialDelaySecs * 1000)).CopyTo(keepAliveValues, 4); + BitConverter.GetBytes((uint)(IntervalSecs * 1000)).CopyTo(keepAliveValues, 8); + + s.IOControl(IOControlCode.KeepAliveValues, keepAliveValues, null); + //Console.WriteLine("TCP keep-alive settings applied using Windows-specific method"); + } + else if (OperatingSystem.IsLinux()) + { + + // Set keep-alive idle time in milliseconds + s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, initialDelaySecs); + + // Set keep-alive interval in milliseconds + s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, IntervalSecs); + + s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, linuxRetryCount); + linuxKeepAliveConfigured = true; + + //Console.WriteLine($"TCPKEEPALIVETIME set to {initialDelaySecs} seconds (connection idle time before sending probes)"); + //Console.WriteLine($"TCPKEEPALIVEINTERVAL set to {IntervalSecs} seconds (interval between probes)"); + // Console.WriteLine($"TCPKEEPALIVERETRYCOUNT set to {linuxRetryCount} probes (max failures before disconnect)"); + } + + } + catch (Exception ex) + { + ProxyEvent pe = new() + { + Type = EventType.Exception, + Exception = ex, + ["Message"] = "Failed to set TCP keep-alive parameters", + ["Host"] = dnsEndPoint.Host, + ["Port"] = dnsEndPoint.Port.ToString(), + ["InitialDelaySecs"] = initialDelaySecs.ToString(), + ["IntervalSecs"] = IntervalSecs.ToString(), + ["LinuxRetryCount"] = linuxRetryCount.ToString(), + ["linuxKeepAliveConfigured"] = linuxKeepAliveConfigured.ToString() + }; + pe.SendEvent(); + } + + // Connect to the endpoint + await s.ConnectAsync(addresses, dnsEndPoint.Port, ct).ConfigureAwait(false); + return new NetworkStream(s, ownsSocket: true); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Socket connection error: {ex.Message}"); + s.Dispose(); + throw; + } + }; + + return handler; + } + + // Activates runtime resources derived from config (HttpClient/transport) after parsing. + private static void ConfigureHttpClientFromOptions(Dictionary env, BackendOptions backendOptions) + { + // Read and set the DNS refresh timeout from environment variables or use the default value + // var DNSTimeout = ReadEnvironmentVariableOrDefault(env, "DnsRefreshTimeout", 240000); + var KeepAliveInitialDelaySecs = ReadEnvironmentVariableOrDefault(env, "KeepAliveInitialDelaySecs", 60); // 60 seconds + var KeepAlivePingIntervalSecs = ReadEnvironmentVariableOrDefault(env, "KeepAlivePingIntervalSecs", 60); // 60 seconds + var keepAliveDurationSecs = ReadEnvironmentVariableOrDefault(env, "KeepAliveIdleTimeoutSecs", 1200); // 20 minutes + var safeKeepAliveInitialDelaySecs = Math.Max(1, KeepAliveInitialDelaySecs); + var safeKeepAlivePingIntervalSecs = Math.Max(1, KeepAlivePingIntervalSecs); + + var EnableMultipleHttp2Connections = ReadEnvironmentVariableOrDefault(env, "EnableMultipleHttp2Connections", false); + var MultiConnLifetimeSecs = ReadEnvironmentVariableOrDefault(env, "MultiConnLifetimeSecs", 3600); // 1 hours + var MultiConnIdleTimeoutSecs = ReadEnvironmentVariableOrDefault(env, "MultiConnIdleTimeoutSecs", 300); // 5 minutes + var MultiConnMaxConns = ReadEnvironmentVariableOrDefault(env, "MultiConnMaxConns", 4000); // 4000 connections + + var retryCount = Math.Max(1, keepAliveDurationSecs / safeKeepAlivePingIntervalSecs); + var handler = getHandler(safeKeepAliveInitialDelaySecs, safeKeepAlivePingIntervalSecs, retryCount); + + if (EnableMultipleHttp2Connections) + { + handler.EnableMultipleHttp2Connections = true; + handler.PooledConnectionLifetime = TimeSpan.FromSeconds(MultiConnLifetimeSecs); + handler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(MultiConnIdleTimeoutSecs); + handler.MaxConnectionsPerServer = MultiConnMaxConns; + handler.ResponseDrainTimeout = TimeSpan.FromSeconds(keepAliveDurationSecs); + Console.WriteLine("Multiple HTTP/2 connections enabled."); + } + else + { + handler.EnableMultipleHttp2Connections = false; + Console.WriteLine("Multiple HTTP/2 connections disabled."); + } + + // Configure SSL handling + if (ReadEnvironmentVariableOrDefault(env, "IgnoreSSLCert", false)) + { + handler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true + }; + Console.WriteLine("Ignoring SSL certificate validation errors."); + } + + HttpClient client = new HttpClient(handler) + { + // set timeout to large to disable it at HttpClient level. Will use token cancellation for timeout instead. + Timeout = Timeout.InfiniteTimeSpan + }; + + backendOptions.Client = client; + } + + /// + /// Clears and re-populates by iterating + /// over Host1..N, Probe_path1..N, and IP1..N keys. + /// + /// Each parsed is staged into + /// (when provided). After all hosts are parsed, + /// is called to build, freeze, and swap the snapshot. + /// + /// + /// Values are resolved in priority order: + /// dictionary → Warm:/Cold:/bare-key + /// from → environment variable. + /// + /// + /// If APPENDHOSTSFILE is "true", the resolved host/IP pairs + /// are also appended to /etc/hosts (Linux container deployments). + /// + /// + /// The target options whose Hosts list will be rebuilt. + /// Optional for Warm/Cold/bare-key lookup. + /// + /// Optional flat dictionary of host-family settings (e.g. from a warm snapshot). + /// Takes precedence over when supplied. + /// + /// + /// Optional host collection manager. When provided, each parsed host is staged + /// and the collection is activated at the end. + /// + public static void RegisterBackends(BackendOptions backendOptions, IConfiguration? configuration = null, Dictionary? cfg = null, IHostHealthCollection? hostCollection = null) + { + //backendOptions.Client.Timeout = TimeSpan.FromMilliseconds(backendOptions.Timeout); + var hostSettingsSnapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + + string? ReadWithFallback(string key) + { + var configured = + (cfg != null && cfg.TryGetValue(key, out var cfgVal) ? cfgVal : null) + ?? configuration?[$"Warm:{key}"] + ?? configuration?[$"Cold:{key}"] + ?? configuration?[key]; + + if (!string.IsNullOrWhiteSpace(configured)) + { + return configured.Trim(); + } + + return Environment.GetEnvironmentVariable(key)?.Trim(); + } + + backendOptions.Hosts.Clear(); + + int i = 1; + StringBuilder sb = new(); + while (true) + { + + var hostKey = $"Host{i}"; + var probePathKey = $"Probe_path{i}"; + var ipKey = $"IP{i}"; + + + var hostname = ReadWithFallback(hostKey); + if (string.IsNullOrEmpty(hostname)) break; + + var probePath = ReadWithFallback(probePathKey); + var ip = ReadWithFallback(ipKey); + + _logger.LogInformation($"Found a Host: {hostKey}, Probe Path: {probePathKey}, HostName: {hostname}"); + hostSettingsSnapshot[hostKey] = hostname; + if (!string.IsNullOrEmpty(probePath)) + { + hostSettingsSnapshot[probePathKey] = probePath; + } + + if (!string.IsNullOrEmpty(ip)) + { + hostSettingsSnapshot[ipKey] = ip; + } + + try + { + _logger?.LogDebug($"Found host {hostname} with probe path {probePath} and IP {ip}"); + + // Resolve HostConfig from DI using the factory + HostConfig bh = new HostConfig(hostname, probePath, ip, backendOptions.OAuthAudience); + backendOptions.Hosts.Add(bh); + hostCollection?.StageHost(bh); + + sb.AppendLine($"{ip} {bh.Host}"); + } + + catch (UriFormatException e) + { + _logger?.LogError($"Could not add Host{i} with {hostname} : {e.Message}"); + Console.WriteLine(e.StackTrace); + } + + i++; + } + + var appendHostsFile = ReadWithFallback("APPENDHOSTSFILE") + ?? ReadWithFallback("AppendHostsFile"); + + if (!string.IsNullOrEmpty(appendHostsFile)) + { + hostSettingsSnapshot["APPENDHOSTSFILE"] = appendHostsFile; + } + + if (appendHostsFile?.Equals("true", StringComparison.OrdinalIgnoreCase) == true) + { + _logger?.LogInformation($"Appending {sb} to /etc/hosts"); + using StreamWriter sw = File.AppendText("/etc/hosts"); + sw.WriteLine(sb.ToString()); + } + + // Snapshot is updated only after all Host/Probe_path/IP entries are parsed and applied. + hostCollection?.Activate(); + } +} diff --git a/src/SimpleL7Proxy/Config/ConfigChange.cs b/src/SimpleL7Proxy/Config/ConfigChange.cs new file mode 100644 index 00000000..e88720fe --- /dev/null +++ b/src/SimpleL7Proxy/Config/ConfigChange.cs @@ -0,0 +1,34 @@ +namespace SimpleL7Proxy.Config; + +/// +/// Describes a single configuration setting that changed during a refresh cycle. +/// String representations of and +/// are computed lazily on first access to avoid unnecessary allocations. +/// +public readonly record struct ConfigChange +{ + private readonly object? _oldValue; + private readonly object? _newValue; + + /// Property name on (e.g. "LogConsole"). + public string PropertyName { get; init; } + + /// The App Configuration key path (e.g. "Logging:LogConsole"). + public string KeyPath { get; init; } + + /// + /// Raw previous value before the change, or null if unknown. + /// + public object? RawOldValue { get => _oldValue; init => _oldValue = value; } + + /// + /// Raw new value after the change. + /// + public object? RawNewValue { get => _newValue; init => _newValue = value; } + + /// Previous value (as string) before the change. Computed lazily from . + public string? OldValue => _oldValue?.ToString(); + + /// New value (as string) after the change. Computed lazily from . + public string? NewValue => _newValue?.ToString(); +} \ No newline at end of file diff --git a/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs b/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs new file mode 100644 index 00000000..6f1469ae --- /dev/null +++ b/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs @@ -0,0 +1,269 @@ +using Microsoft.Extensions.Logging; +using System.Linq.Expressions; + +namespace SimpleL7Proxy.Config; + +/// +/// Singleton service that manages config-change subscriptions. +/// Subscribers specify which fields they care about; the notifier filters +/// and only calls them when those fields change. +/// +/// Usage: +/// +/// var notifier = serviceProvider.GetRequiredService<ConfigChangeNotifier>(); +/// +/// // Subscribe to specific fields: +/// notifier.Subscribe(mySubscriber, "LogConsole", "Workers"); +/// +/// // Or with a lambda for specific fields: +/// notifier.Subscribe((changes, opts, ct) => +/// { +/// Console.WriteLine($"{changes.Count} setting(s) changed"); +/// return Task.CompletedTask; +/// }, "LogConsole", "Workers"); +/// +/// // Subscribe to ALL changes (no filter): +/// notifier.Subscribe(mySubscriber); +/// +/// // Unsubscribe when done: +/// notifier.Unsubscribe(mySubscriber); +/// +/// +/// +public class ConfigChangeNotifier +{ + private readonly List _subscriptions = []; + private readonly object _lock = new(); + private readonly ILogger _logger; + + public ConfigChangeNotifier(ILogger logger) + { + _logger = logger; + } + + /// + /// Register a subscriber for changes to specific fields. + /// Pass field names (ConfigName / env var names, e.g. "LogConsole", "Workers"). + /// If no fields are specified, the subscriber receives all changes. + /// + public void Subscribe(IConfigChangeSubscriber subscriber, params string[] fields) + { + var filter = fields.Length > 0 + ? new HashSet(fields, StringComparer.OrdinalIgnoreCase) + : null; // null = wildcard (all changes) + + lock (_lock) + { + _subscriptions.Add(new Subscription(subscriber, filter)); + } + + var fieldDesc = filter != null ? string.Join(", ", filter) : "*"; + _logger.LogInformation("[CONFIG] Subscriber registered: {Name} for fields: [{Fields}]", + subscriber.GetType().Name, fieldDesc); + } + + /// + /// Register a callback for changes to specific fields. + /// Returns a handle that can be passed to . + /// + public IConfigChangeSubscriber Subscribe( + Func, BackendOptions, CancellationToken, Task> callback, + params string[] fields) + { + var wrapper = new DelegateSubscriber(callback); + Subscribe(wrapper, fields); + return wrapper; + } + + /// + /// Register a subscriber for specific properties. + /// This avoids callers needing to know config/env field names. + /// + public void Subscribe( + IConfigChangeSubscriber subscriber, + params Expression>[] fields) + { + var configNames = ResolveConfigNames(fields); + Subscribe(subscriber, configNames); + } + + /// + /// Register a callback for specific properties. + /// Returns a handle that can be passed to . + /// + public IConfigChangeSubscriber Subscribe( + Func, BackendOptions, CancellationToken, Task> callback, + params Expression>[] fields) + { + var configNames = ResolveConfigNames(fields); + return Subscribe(callback, configNames); + } + + /// Remove a previously registered subscriber. + public void Unsubscribe(IConfigChangeSubscriber subscriber) + { + lock (_lock) + { + _subscriptions.RemoveAll(s => s.Subscriber == subscriber); + } + _logger.LogInformation("[CONFIG] Subscriber removed: {Name}", subscriber.GetType().Name); + } + + /// + /// Returns a precomputed view of subscribed fields. + /// If HasWildcardSubscriber is true, all fields are considered subscribed. + /// + public (bool HasWildcardSubscriber, HashSet SubscribedFields) GetSubscribedFieldSet() + { + Subscription[] snapshot; + lock (_lock) + { + if (_subscriptions.Count == 0) + { + return (false, new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + snapshot = [.. _subscriptions]; + } + + var subscribedFields = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var sub in snapshot) + { + if (sub.Filter == null) + { + return (true, subscribedFields); + } + + subscribedFields.UnionWith(sub.Filter); + } + + return (false, subscribedFields); + } + + /// + /// Called by the refresh service to fan out notifications. + /// Each subscriber only receives changes matching its field filter. + /// Failures are logged but don't stop other subscribers. + /// + internal async Task NotifyAsync( + IReadOnlyList changes, + BackendOptions backendOptions, + CancellationToken cancellationToken) + { + if (changes.Count == 0) return; + + Subscription[] snapshot; + lock (_lock) + { + if (_subscriptions.Count == 0) return; + snapshot = [.. _subscriptions]; + } + + // Multiple subscriptions can point to the same subscriber instance. + // Merge filters and notify each subscriber only once per refresh cycle. + var subscribers = snapshot + .GroupBy(s => s.Subscriber) + .Select(group => new + { + Subscriber = group.Key, + MergedFilter = MergeFilters(group.Select(s => s.Filter)) + }); + + foreach (var sub in subscribers) + { + // Filter changes to only those the subscriber cares about + var relevant = sub.MergedFilter != null + ? changes.Where(c => sub.MergedFilter.Contains(c.PropertyName)).ToList() + : (IReadOnlyList)changes; + + if (relevant.Count == 0) continue; + + try + { + _logger.LogDebug("[CONFIG] Notifying {Name} of {Count} change(s)", + sub.Subscriber.GetType().Name, relevant.Count); + await sub.Subscriber.OnConfigChangedAsync(relevant, backendOptions, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "[CONFIG] Subscriber {Name} failed", sub.Subscriber.GetType().Name); + } + } + } + + private static HashSet? MergeFilters(IEnumerable?> filters) + { + var merged = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var filter in filters) + { + // null means wildcard: subscriber wants all fields. + if (filter == null) + { + return null; + } + + merged.UnionWith(filter); + } + + return merged; + } + + private static string[] ResolveConfigNames(Expression>[] fields) + { + if (fields.Length == 0) + { + return []; + } + + var descriptorByPropertyName = ConfigOptions.GetDescriptors() + .ToDictionary(d => d.Property.Name, d => d.ConfigName, StringComparer.OrdinalIgnoreCase); + + var configNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var field in fields) + { + var propertyName = TryGetPropertyName(field.Body) + ?? throw new ArgumentException("Field selector must be a simple property access", nameof(fields)); + + if (!descriptorByPropertyName.TryGetValue(propertyName, out var configName)) + { + throw new ArgumentException($"Unsupported BackendOptions property '{propertyName}' for config subscriptions", nameof(fields)); + } + + configNames.Add(configName); + } + + return [.. configNames]; + } + + private static string? TryGetPropertyName(Expression body) + { + if (body is MemberExpression memberExpression) + { + return memberExpression.Member.Name; + } + + if (body is UnaryExpression unaryExpression + && unaryExpression.NodeType == ExpressionType.Convert + && unaryExpression.Operand is MemberExpression operandMemberExpression) + { + return operandMemberExpression.Member.Name; + } + + return null; + } + + /// Tracks a subscriber and its optional field filter. + private sealed record Subscription(IConfigChangeSubscriber Subscriber, HashSet? Filter); + + /// Wraps a lambda/delegate as an . + private sealed class DelegateSubscriber( + Func, BackendOptions, CancellationToken, Task> callback) + : IConfigChangeSubscriber + { + public Task OnConfigChangedAsync( + IReadOnlyList changes, + BackendOptions backendOptions, + CancellationToken cancellationToken) => callback(changes, backendOptions, cancellationToken); + } +} diff --git a/src/SimpleL7Proxy/Config/ConfigParser.cs b/src/SimpleL7Proxy/Config/ConfigParser.cs new file mode 100644 index 00000000..84c9ddb0 --- /dev/null +++ b/src/SimpleL7Proxy/Config/ConfigParser.cs @@ -0,0 +1,771 @@ +using Microsoft.Extensions.Configuration; +using SimpleL7Proxy.Backend.Iterators; +using System.Reflection; + +namespace SimpleL7Proxy.Config; + +public static class ConfigParser +{ + private static readonly Dictionary EnvVars = new(StringComparer.OrdinalIgnoreCase); + private static readonly BackendOptions s_defaults = new(); + private static readonly System.Data.DataTable s_mathTable = new(); + + private static readonly (string envVar, string property)[] SimpleFields = + new (string envVar, string property)[] + { + ("AsyncBlobWorkerCount", "AsyncBlobWorkerCount"), + ("AsyncTimeout", "AsyncTimeout"), + ("AsyncTTLSecs", "AsyncTTLSecs"), + ("AsyncTriggerTimeout", "AsyncTriggerTimeout"), + ("CBErrorThreshold", "CircuitBreakerErrorThreshold"), + ("CBTimeslice", "CircuitBreakerTimeslice"), + ("DefaultPriority", "DefaultPriority"), + ("DefaultTTLSecs", "DefaultTTLSecs"), + ("MaxQueueLength", "MaxQueueLength"), + ("MaxAttempts", "MaxAttempts"), + ("PollInterval", "PollInterval"), + ("PollTimeout", "PollTimeout"), + ("Port", "Port"), + ("TERMINATION_GRACE_PERIOD_SECONDS", "TerminationGracePeriodSeconds"), + ("Timeout", "Timeout"), + ("UserConfigRefreshIntervalSecs", "UserConfigRefreshIntervalSecs"), + ("UserSoftDeleteTTLMinutes", "UserSoftDeleteTTLMinutes"), + ("Workers", "Workers"), + + ("SuccessRate", "SuccessRate"), + ("UserPriorityThreshold", "UserPriorityThreshold"), + + ("AsyncClientRequestHeader", "AsyncClientRequestHeader"), + ("AsyncClientConfigFieldName", "AsyncClientConfigFieldName"), + ("CONTAINER_APP_NAME", "ContainerApp"), + ("HealthProbeSidecar", "HealthProbeSidecar"), + ("LoadBalanceMode", "LoadBalanceMode"), + ("OAuthAudience", "OAuthAudience"), + ("PriorityKeyHeader", "PriorityKeyHeader"), + ("CONTAINER_APP_REVISION", "Revision"), + ("StorageDbContainerName", "StorageDbContainerName"), + ("SuspendedUserConfigUrl", "SuspendedUserConfigUrl"), + ("TimeoutHeader", "TimeoutHeader"), + ("TTLHeader", "TTLHeader"), + ("UserConfigUrl", "UserConfigUrl"), + ("UserProfileHeader", "UserProfileHeader"), + ("ValidateAuthAppFieldName", "ValidateAuthAppFieldName"), + ("ValidateAuthAppID", "ValidateAuthAppID"), + ("ValidateAuthAppIDHeader", "ValidateAuthAppIDHeader"), + ("ValidateAuthAppIDUrl", "ValidateAuthAppIDUrl"), + + ("AsyncModeEnabled", "AsyncModeEnabled"), + ("LogAllRequestHeaders", "LogAllRequestHeaders"), + ("LogAllResponseHeaders", "LogAllResponseHeaders"), + ("LogConsole", "LogConsole"), + ("LogConsoleEvent", "LogConsoleEvent"), + ("LogPoller", "LogPoller"), + ("LogProbes", "LogProbes"), + ("StorageDbEnabled", "StorageDbEnabled"), + ("UseOAuth", "UseOAuth"), + ("UseOAuthGov", "UseOAuthGov"), + ("UseProfiles", "UseProfiles"), + ("UserConfigRequired", "UserConfigRequired"), + + ("DependancyHeaders", "DependancyHeaders"), + ("DisallowedHeaders", "DisallowedHeaders"), + ("LogAllRequestHeadersExcept", "LogAllRequestHeadersExcept"), + ("LogAllResponseHeadersExcept", "LogAllResponseHeadersExcept"), + ("LogHeaders", "LogHeaders"), + ("PriorityKeys", "PriorityKeys"), + ("RequiredHeaders", "RequiredHeaders"), + ("StripRequestHeaders", "StripRequestHeaders"), + ("StripResponseHeaders", "StripResponseHeaders"), + ("UniqueUserHeaders", "UniqueUserHeaders"), + }; + + public static BackendOptions ParseOptions(Dictionary env) + { + EnvVars.Clear(); + + var opts = new BackendOptions(); + var defaults = s_defaults; + + foreach (var (envVar, property) in SimpleFields) + { + ApplyFieldFromEnv(env, opts, defaults, envVar, property); + } + + opts.AcceptableStatusCodes = ReadEnvironmentVariableOrDefault(env, "AcceptableStatusCodes", defaults.AcceptableStatusCodes); + opts.IterationMode = ReadEnvironmentVariableOrDefault(env, "IterationMode", defaults.IterationMode); + + var defaultPriorityWorkers = string.Join(",", defaults.PriorityWorkers.Select(kvp => $"{kvp.Key}:{kvp.Value}")); + opts.PriorityWorkers = KVIntPairs(ToListOfString(ReadEnvironmentVariableOrDefault(env, "PriorityWorkers", defaultPriorityWorkers))); + + var defaultValidateHeaders = string.Join(",", defaults.ValidateHeaders.Select(kvp => $"{kvp.Key}={kvp.Value}")); + opts.ValidateHeaders = KVStringPairs(ToListOfString(ReadEnvironmentVariableOrDefault(env, "ValidateHeaders", defaultValidateHeaders))); + + ApplyAsyncServiceBusOverrides(env, opts, defaults); + ApplyAsyncBlobStorageOverrides(env, opts, defaults); + + var replicaId = ReadEnvironmentVariableOrDefault( + env, + "ReplicaID", + Environment.GetEnvironmentVariable("HOSTNAME") ?? Environment.MachineName); + ApplyReplicaIdentitySettings(env, opts, replicaId); + + ApplyDerivedSettingsFromConfigNames( + opts, + configuration: null, + nameof(BackendOptions.HealthProbeSidecar), + nameof(BackendOptions.LoadBalanceMode), + nameof(BackendOptions.PriorityKeys), + nameof(BackendOptions.PriorityValues), + nameof(BackendOptions.ValidateHeaders)); + + return opts; + } + + public static IReadOnlyDictionary GetParsedEnvVars() + { + return new Dictionary(EnvVars, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Applies a single configuration field from the environment dictionary to the target instance. + /// Uses reflection to set the named property, falling back to the corresponding default value when the + /// environment variable is absent or set to the default placeholder. Supports int, double, float, string, + /// bool, List<string>, List<int>, int[], Dictionary<string, string>, and enum property types. + /// + /// Dictionary of environment/configuration key-value pairs to read from. + /// The instance whose property will be set. + /// A default instance providing fallback values. + /// The environment variable (dictionary key) to look up. + /// The name of the property to set. + /// Thrown when does not exist on . + /// Thrown when the property type is not handled. + public static void ApplyFieldFromEnv(Dictionary env, BackendOptions target, BackendOptions defaults, string envVar, string property) + { + var pi = typeof(BackendOptions).GetProperty(property) ?? throw new InvalidOperationException($"Unknown BackendOptions property: {property}"); + var defVal = pi.GetValue(defaults); + var type = pi.PropertyType; + + if (type == typeof(int) || type == typeof(double)) + { + var val = ReadEnvironmentVariableOrDefault(env, envVar, Convert.ToInt32(defVal)); + pi.SetValue(target, Convert.ChangeType(val, type)); + } + else if (type == typeof(float)) + { + var val = ReadEnvironmentVariableOrDefault(env, envVar, Convert.ToSingle(defVal)); + pi.SetValue(target, Convert.ChangeType(val, type)); + } + else if (type == typeof(string)) + { + pi.SetValue(target, ReadEnvironmentVariableOrDefault(env, envVar, (string)defVal!)); + } + else if (type == typeof(bool)) + { + pi.SetValue(target, ReadEnvironmentVariableOrDefault(env, envVar, (bool)defVal!)); + } + else if (type == typeof(List)) + { + var value = ReadEnvironmentVariableOrDefault(env, envVar, string.Join(",", (List)defVal!)); + pi.SetValue(target, ToListOfString(value)); + } + else if (type == typeof(List)) + { + var value = ReadEnvironmentVariableOrDefault(env, envVar, string.Join(",", (List)defVal!)); + pi.SetValue(target, ToListOfInt(value)); + } + else if (type == typeof(int[])) + { + pi.SetValue(target, ReadEnvironmentVariableOrDefault(env, envVar, (int[])defVal!)); + } + else if (type == typeof(Dictionary)) + { + var defaultValue = string.Join(",", ((Dictionary)defVal!).Select(kvp => $"{kvp.Key}={kvp.Value}")); + var value = ReadEnvironmentVariableOrDefault(env, envVar, defaultValue); + pi.SetValue(target, KVStringPairs(ToListOfString(value))); + } + else if (type.IsEnum) + { + var value = ReadEnvironmentVariableOrDefault(env, envVar, defVal!.ToString()!); + if (Enum.TryParse(type, value, true, out var parsed)) + { + pi.SetValue(target, parsed); + } + else + { + pi.SetValue(target, defVal); + } + } + else + { + throw new NotSupportedException($"ApplyFieldFromEnv: unsupported property type {type.Name} for {property}"); + } + } + + public static void ApplyDerivedSettings(BackendOptions backendOptions, params PropertyInfo[] changedProperties) + { + if (changedProperties.Length == 0) + { + return; + } + + var changedPropertyNames = new HashSet( + changedProperties.Select(p => p.Name), + StringComparer.OrdinalIgnoreCase); + + if (changedPropertyNames.Contains(nameof(BackendOptions.HealthProbeSidecar))) + { + ParseHealthProbeSidecarSettings(backendOptions); + } + + if (changedPropertyNames.Contains(nameof(BackendOptions.LoadBalanceMode))) + { + ValidateLoadBalanceMode(backendOptions, s_defaults); + } + + if (changedPropertyNames.Contains(nameof(BackendOptions.PriorityKeys)) + || changedPropertyNames.Contains(nameof(BackendOptions.PriorityValues))) + { + ValidatePrioritySettings(backendOptions, s_defaults); + } + + if (changedPropertyNames.Contains(nameof(BackendOptions.ValidateHeaders))) + { + ValidateHeaderSettings(backendOptions); + } + } + + public static void ApplyDerivedSettingsFromConfigNames( + BackendOptions backendOptions, + IConfiguration? configuration, + params string[] changedConfigNames) + { + if (changedConfigNames.Length == 0) + { + return; + } + + var descriptorByConfigName = ConfigOptions.GetDescriptors() + .ToDictionary(d => d.ConfigName, d => d.Property, StringComparer.OrdinalIgnoreCase); + + var changedProperties = new List(changedConfigNames.Length); + var shouldRefreshBackends = false; + + foreach (var changedConfigName in changedConfigNames) + { + if (string.IsNullOrWhiteSpace(changedConfigName)) + { + continue; + } + + if (descriptorByConfigName.TryGetValue(changedConfigName, out var property)) + { + changedProperties.Add(property); + } + + if (IsBackendHostConfigName(changedConfigName)) + { + shouldRefreshBackends = true; + } + } + + if (changedProperties.Count > 0) + { + ApplyDerivedSettings(backendOptions, [.. changedProperties]); + } + + if (shouldRefreshBackends) + { + ConfigBootstrapper.RegisterBackends(backendOptions, configuration, null); + } + } + + public static bool IsBackendHostConfigName(string configName) + { + if (string.IsNullOrWhiteSpace(configName)) + { + return false; + } + + var normalized = configName; + if (normalized.StartsWith("Warm:", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized["Warm:".Length..]; + } + else if (normalized.StartsWith("Cold:", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized["Cold:".Length..]; + } + + static bool IsIndexedKey(string value, string prefix) + { + if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var suffix = value[prefix.Length..]; + return int.TryParse(suffix, out _); + } + + return IsIndexedKey(normalized, "Host") + || IsIndexedKey(normalized, "IP") + || IsIndexedKey(normalized, "Probe_path") + || normalized.Equals("APPENDHOSTSFILE", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("AppendHostsFile", StringComparison.OrdinalIgnoreCase); + } + + private static void ApplyAsyncServiceBusOverrides(Dictionary env, BackendOptions opts, BackendOptions defaults) + { + var configStr = ReadEnvironmentVariableOrDefault(env, "AsyncSBConfig", defaults.AsyncSBConfig); + var (connStr, ns, queue, useMi) = ParseServiceBusConfig(configStr); + opts.AsyncSBConfig = configStr; + opts.AsyncSBConnectionString = ReadEnvironmentVariableOrDefault(env, "AsyncSBConnectionString", connStr); + opts.AsyncSBNamespace = ReadEnvironmentVariableOrDefault(env, "AsyncSBNamespace", ns); + opts.AsyncSBQueue = ReadEnvironmentVariableOrDefault(env, "AsyncSBQueue", queue); + opts.AsyncSBUseMI = ReadEnvironmentVariableOrDefault(env, "AsyncSBUseMI", useMi); + } + + private static void ApplyAsyncBlobStorageOverrides(Dictionary env, BackendOptions opts, BackendOptions defaults) + { + var configStr = ReadEnvironmentVariableOrDefault(env, "AsyncBlobStorageConfig", defaults.AsyncBlobStorageConfig); + var (connStr, accountUri, useMi) = ParseBlobStorageConfig(configStr); + opts.AsyncBlobStorageConfig = configStr; + opts.AsyncBlobStorageAccountUri = ReadEnvironmentVariableOrDefault(env, "AsyncBlobStorageAccountUri", accountUri); + opts.AsyncBlobStorageConnectionString = ReadEnvironmentVariableOrDefault(env, "AsyncBlobStorageConnectionString", connStr); + opts.AsyncBlobStorageUseMI = ReadEnvironmentVariableOrDefault(env, "AsyncBlobStorageUseMI", useMi); + } + + private static void ApplyReplicaIdentitySettings(Dictionary env, BackendOptions opts, string replicaId) + { + opts.HostName = ReadEnvironmentVariableOrDefault(env, "Hostname", replicaId); + opts.IDStr = $"{ReadEnvironmentVariableOrDefault(env, "RequestIDPrefix", "S7P")}-{replicaId}-"; + } + + private static void ParseHealthProbeSidecarSettings(BackendOptions backendOptions) + { + var healthSettings = backendOptions.HealthProbeSidecar.Split(';', StringSplitOptions.RemoveEmptyEntries); + foreach (var setting in healthSettings) + { + var kvp = setting.Split('=', 2); + if (kvp.Length != 2) continue; + + var key = kvp[0].Trim().ToLowerInvariant(); + var value = kvp[1].Trim().ToLowerInvariant(); + if (key == "enabled") + { + backendOptions.HealthProbeSidecarEnabled = value == "true"; + } + else if (key == "url" && !string.IsNullOrEmpty(value)) + { + backendOptions.HealthProbeSidecarUrl = value; + } + } + } + + private static void ValidatePrioritySettings(BackendOptions backendOptions, BackendOptions defaults) + { + if (backendOptions.PriorityKeys.Count != backendOptions.PriorityValues.Count) + { + backendOptions.PriorityValues = Enumerable.Repeat(defaults.DefaultPriority, backendOptions.PriorityKeys.Count).ToList(); + } + + int workerAllocation = 0; + foreach (var key in backendOptions.PriorityWorkers.Keys) + { + workerAllocation += backendOptions.PriorityWorkers[key]; + } + + if (workerAllocation > backendOptions.Workers) + { + backendOptions.Workers = workerAllocation; + } + } + + private static void ValidateHeaderSettings(BackendOptions backendOptions) + { + if (backendOptions.ValidateHeaders.Count > 0) + { + foreach (var (key, value) in backendOptions.ValidateHeaders) + { + if (!backendOptions.RequiredHeaders.Contains(key)) + { + backendOptions.RequiredHeaders.Add(key); + } + if (!backendOptions.RequiredHeaders.Contains(value)) + { + backendOptions.RequiredHeaders.Add(value); + } + if (!backendOptions.DisallowedHeaders.Contains(value)) + { + backendOptions.DisallowedHeaders.Add(value); + } + } + } + } + + private static void ValidateLoadBalanceMode(BackendOptions backendOptions, BackendOptions defaults) + { + backendOptions.LoadBalanceMode = backendOptions.LoadBalanceMode.Trim().ToLowerInvariant(); + if (backendOptions.LoadBalanceMode != Constants.Latency && + backendOptions.LoadBalanceMode != Constants.RoundRobin && + backendOptions.LoadBalanceMode != Constants.Random) + { + backendOptions.LoadBalanceMode = defaults.LoadBalanceMode; + } + } + + private static bool TryEvaluateMathExpression(string expression, out double result) + { + result = 0; + if (string.IsNullOrWhiteSpace(expression)) return false; + + try + { + var computed = s_mathTable.Compute(expression, null); + result = Convert.ToDouble(computed); + return true; + } + catch + { + return false; + } + } + + private static int ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int defaultValue) + { + int value = ReadEnvironmentVariableOrDefaultCore(env, variableName, defaultValue); + EnvVars[variableName] = value.ToString(); + return value; + } + + private static int[] ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int[] defaultValues) + { + int[] value = ReadEnvironmentVariableOrDefaultCore(env, variableName, defaultValues); + EnvVars[variableName] = string.Join(",", value); + return value; + } + + private static float ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, float defaultValue) + { + float value = ReadEnvironmentVariableOrDefaultCore(env, variableName, defaultValue); + EnvVars[variableName] = value.ToString(); + return value; + } + + private static string ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, string defaultValue) + { + string value = ReadEnvironmentVariableOrDefaultCore(env, variableName, defaultValue); + EnvVars[variableName] = value; + return value; + } + + private static IterationModeEnum ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, IterationModeEnum defaultValue) + { + string? envValue = env.GetValueOrDefault(variableName)?.Trim(); + if (string.IsNullOrEmpty(envValue) || envValue == ConfigOptions.DefaultPlaceholder || !Enum.TryParse(envValue, true, out IterationModeEnum value)) + { + EnvVars[variableName] = defaultValue.ToString(); + return defaultValue; + } + + EnvVars[variableName] = value.ToString(); + return value; + } + + private static bool ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, bool defaultValue) + { + bool value = ReadEnvironmentVariableOrDefaultCore(env, variableName, defaultValue); + EnvVars[variableName] = value.ToString(); + return value; + } + + private static int ReadEnvironmentVariableOrDefaultCore(Dictionary env, string variableName, int defaultValue) + { + var envValue = env.GetValueOrDefault(variableName); + if (envValue?.Trim() == ConfigOptions.DefaultPlaceholder) envValue = null; + + if (!int.TryParse(envValue, out var value)) + { + if (TryEvaluateMathExpression(envValue ?? string.Empty, out var mathResult)) + { + return (int)mathResult; + } + return defaultValue; + } + + return value; + } + + private static int[] ReadEnvironmentVariableOrDefaultCore(Dictionary env, string variableName, int[] defaultValues) + { + var envValue = env.GetValueOrDefault(variableName); + if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) + { + return defaultValues; + } + + try + { + var trimmed = envValue.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + { + trimmed = trimmed[1..^1]; + } + + return trimmed.Split(',').Select(s => int.Parse(s.Trim())).ToArray(); + } + catch + { + return defaultValues; + } + } + + private static float ReadEnvironmentVariableOrDefaultCore(Dictionary env, string variableName, float defaultValue) + { + var envValue = env.GetValueOrDefault(variableName); + if (envValue?.Trim() == ConfigOptions.DefaultPlaceholder) envValue = null; + + if (!float.TryParse(envValue, out var value)) + { + if (TryEvaluateMathExpression(envValue ?? string.Empty, out var mathResult)) + { + return (float)mathResult; + } + return defaultValue; + } + + return value; + } + + private static string ReadEnvironmentVariableOrDefaultCore(Dictionary env, string variableName, string defaultValue) + { + var envValue = env.GetValueOrDefault(variableName); + if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) + { + return defaultValue; + } + + return envValue.Trim(); + } + + private static bool ReadEnvironmentVariableOrDefaultCore(Dictionary env, string variableName, bool defaultValue) + { + var envValue = env.GetValueOrDefault(variableName); + if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) + { + return defaultValue; + } + + return envValue.Trim().Equals("true", StringComparison.OrdinalIgnoreCase); + } + + private static Dictionary KVIntPairs(List list) + { + Dictionary keyValuePairs = []; + + foreach (var item in list) + { + var kvp = item.Split(':'); + if (kvp.Length == 2 && int.TryParse(kvp[0], out int key) && int.TryParse(kvp[1], out int value)) + { + keyValuePairs[key] = value; + } + } + + return keyValuePairs; + } + + private static Dictionary KVStringPairs(List list, char delimiter = '=') + { + char fallback = delimiter == '=' ? ':' : '='; + Dictionary keyValuePairs = []; + + foreach (var item in list) + { + var kvp = item.Split(delimiter, 2); + if (kvp.Length == 2) + { + keyValuePairs[kvp[0].Trim()] = kvp[1].Trim(); + continue; + } + + kvp = item.Split(fallback, 2); + if (kvp.Length == 2) + { + keyValuePairs[kvp[0].Trim()] = kvp[1].Trim(); + } + } + + return keyValuePairs; + } + + private static List ToListOfString(string s) + { + if (string.IsNullOrEmpty(s)) + { + return []; + } + + var trimmed = s.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + { + trimmed = trimmed[1..^1]; + } + + return [.. trimmed.Split(',').Select(p => p.Trim())]; + } + + private static List ToListOfInt(string s) + { + if (string.IsNullOrEmpty(s)) + { + return []; + } + + var trimmed = s.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + { + trimmed = trimmed[1..^1]; + } + + return trimmed.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + } + + private static Dictionary ParseConfigString(string config, Dictionary keyAliases) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(config)) + { + return result; + } + + var parts = config.Split(',').Select(p => p.Trim()).ToArray(); + if (parts.Length == 0 || !parts[0].Contains('=')) + { + return result; + } + + foreach (var part in parts) + { + var kvp = part.Split('=', 2); + if (kvp.Length != 2) + { + continue; + } + + var key = kvp[0].Trim().ToLowerInvariant(); + var value = kvp[1].Trim(); + + string? canonicalKey = null; + foreach (var (canonical, aliases) in keyAliases) + { + if (aliases.Any(alias => alias.Equals(key, StringComparison.OrdinalIgnoreCase))) + { + canonicalKey = canonical; + break; + } + } + + if (canonicalKey != null) + { + result[canonicalKey] = value; + } + } + + return result; + } + + private static bool IsNamedKeyValueFormat(string[] parts, Dictionary keyAliases) + { + if (parts.Length == 0) + { + return false; + } + + var kvp = parts[0].Split('=', 2); + if (kvp.Length != 2) + { + return false; + } + + var firstKey = kvp[0].Trim(); + return keyAliases.Values.SelectMany(v => v).Any(alias => alias.Equals(firstKey, StringComparison.OrdinalIgnoreCase)); + } + + private static (string connectionString, string namespace_, string queue, bool useMI) ParseServiceBusConfig(string config) + { + string connectionString = "example-sb-connection-string"; + string namespace_ = ""; + string queue = "requeststatus"; + bool useMI = false; + + if (string.IsNullOrEmpty(config)) + { + return (connectionString, namespace_, queue, useMI); + } + + var parts = config.Split(',').Select(p => p.Trim()).ToArray(); + var keyAliases = new Dictionary + { + { "connectionString", ["connectionstring", "cs"] }, + { "namespace", ["namespace", "ns"] }, + { "queue", ["queue", "q"] }, + { "useMI", ["usemi", "mi"] } + }; + + if (IsNamedKeyValueFormat(parts, keyAliases)) + { + var parsed = ParseConfigString(config, keyAliases); + if (parsed.TryGetValue("connectionString", out var cs)) connectionString = cs; + if (parsed.TryGetValue("namespace", out var ns)) namespace_ = ns; + if (parsed.TryGetValue("queue", out var q)) queue = q; + if (parsed.TryGetValue("useMI", out var mi)) useMI = mi.Equals("true", StringComparison.OrdinalIgnoreCase); + return (connectionString, namespace_, queue, useMI); + } + + if (parts.Length == 4) + { + useMI = parts[3].Trim().Equals("true", StringComparison.OrdinalIgnoreCase); + return (parts[0], parts[1], parts[2], useMI); + } + + return (connectionString, namespace_, queue, useMI); + } + + private static (string connectionString, string accountUri, bool useMI) ParseBlobStorageConfig(string config) + { + string connectionString = ""; + string accountUri = "https://mystorageaccount.blob.core.windows.net/"; + bool useMI = false; + + if (string.IsNullOrEmpty(config)) + { + return (connectionString, accountUri, useMI); + } + + var parts = config.Split(',').Select(p => p.Trim()).ToArray(); + var keyAliases = new Dictionary + { + { "connectionString", ["connectionstring", "cs"] }, + { "accountUri", ["accounturi", "uri"] }, + { "useMI", ["usemi", "mi"] } + }; + + if (IsNamedKeyValueFormat(parts, keyAliases)) + { + var parsed = ParseConfigString(config, keyAliases); + if (parsed.TryGetValue("connectionString", out var cs)) connectionString = cs; + if (parsed.TryGetValue("accountUri", out var uri)) accountUri = uri; + if (parsed.TryGetValue("useMI", out var mi)) useMI = mi.Equals("true", StringComparison.OrdinalIgnoreCase); + return (connectionString, accountUri, useMI); + } + + if (parts.Length == 3) + { + useMI = parts[2].Trim().Equals("true", StringComparison.OrdinalIgnoreCase); + return (parts[0], parts[1], useMI); + } + + return (connectionString, accountUri, useMI); + } +} diff --git a/src/SimpleL7Proxy/Config/IConfigChangeSubscriber.cs b/src/SimpleL7Proxy/Config/IConfigChangeSubscriber.cs new file mode 100644 index 00000000..f364319e --- /dev/null +++ b/src/SimpleL7Proxy/Config/IConfigChangeSubscriber.cs @@ -0,0 +1,19 @@ +namespace SimpleL7Proxy.Config; + +/// +/// Implement this interface to receive notifications when Azure App Configuration +/// settings change. Register via . +/// +public interface IConfigChangeSubscriber +{ + /// + /// Called when one or more warm configuration settings have changed. + /// + /// The list of settings that changed in this refresh cycle. + /// The current instance (already updated). + /// Cancellation token tied to the host lifetime. + Task OnConfigChangedAsync( + IReadOnlyList changes, + BackendOptions backendOptions, + CancellationToken cancellationToken); +} diff --git a/src/SimpleL7Proxy/Config/WarmOptions.cs b/src/SimpleL7Proxy/Config/WarmOptions.cs new file mode 100644 index 00000000..5631d049 --- /dev/null +++ b/src/SimpleL7Proxy/Config/WarmOptions.cs @@ -0,0 +1,299 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace SimpleL7Proxy.Config; + +/// +/// Controls how a property is published +/// and reloaded. +/// +public enum ConfigMode +{ + /// + /// Published to App Configuration. Changes are hot-reloaded + /// (typically within 30 seconds) — no restart required. + /// + Warm, + + /// + /// Published to App Configuration. Changes require an + /// application restart to take effect. + /// + Cold, + + /// + /// Not published. Used for runtime-derived, composite, or + /// sensitive properties that should never appear in App Configuration. + /// + Hidden +} + +/// +/// Marks a property as a managed config option. +/// +/// Warm (default): published to App Configuration and hot-reloaded.
+/// Cold: published to App Configuration but requires restart.
+/// Hidden: not published — for runtime or composite values. +///
+/// +/// keyPath defines the section path under a prefix that matches +/// the : Warm: or Cold: +/// (e.g. "Logging:LogConsole"Warm:Logging:LogConsole, +/// "Server:Workers"Cold:Server:Workers). +/// +/// +/// Use ConfigName when the source env var differs from the property name: +/// [ConfigOption("Metadata:ContainerApp", ConfigName = "CONTAINER_APP_NAME")] +/// +///
+[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class ConfigOptionAttribute : Attribute +{ + public ConfigOptionAttribute(string keyPath) + { + KeyPath = keyPath; + } + + /// Key path under the mode section, e.g. "Logging:LogConsole" → Warm:Logging:LogConsole or Cold:Server:Workers. + public string KeyPath { get; } + + /// + /// Source environment variable / config name used by deployment tooling. + /// Defaults to the property name when not specified. + /// + public string? ConfigName { get; set; } + + /// + /// How this property is published and reloaded. + /// Default: . + /// + public ConfigMode Mode { get; set; } = ConfigMode.Warm; +} + +/// +/// Marks a property whose default value is +/// parsed from a composite configuration string (e.g. AsyncBlobStorageConfig, +/// AsyncSBConfig) rather than read from a single environment variable. +/// These properties are typically marked . +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class ParsedConfigAttribute : Attribute +{ + public ParsedConfigAttribute(string sourceConfig) + { + SourceConfig = sourceConfig; + } + + /// Name of the composite config string this property is parsed from. + public string SourceConfig { get; } +} + +public sealed class ConfigOptionDescriptor +{ + public required PropertyInfo Property { get; init; } + public required ConfigOptionAttribute Attribute { get; init; } + + /// + /// Resolved config name: explicit ConfigName if set, otherwise the property name. + /// + public string ConfigName => Attribute.ConfigName ?? Property.Name; + + /// The reload mode for this config option. + public ConfigMode Mode => Attribute.Mode; + + /// Whether this option is published to App Configuration by deploy.sh. + public bool IsPublished => Mode != ConfigMode.Hidden; +} + +/// +/// Discovers and applies config options dynamically based on +/// decorations on +/// properties. +/// +public static class ConfigOptions +{ + private static readonly Lazy> _descriptors = new(DiscoverDescriptors); + private static readonly Lazy> _warmDescriptors = + new(() => Descriptors.Where(d => d.Mode == ConfigMode.Warm).ToList()); + private static readonly Lazy> _warmDescriptorsByConfigName = + new(() => _warmDescriptors.Value.ToDictionary(d => d.ConfigName, d => d, StringComparer.OrdinalIgnoreCase)); + private static readonly Lazy> _warmDescriptorsByKeyPath = + new(() => _warmDescriptors.Value.ToDictionary(d => d.Attribute.KeyPath, d => d, StringComparer.OrdinalIgnoreCase)); + private static readonly Lazy> _fieldsByConfigName = + new(() => Descriptors.ToDictionary(d => d.ConfigName, d => d.Property, StringComparer.OrdinalIgnoreCase)); + + /// All discovered config option descriptors. + public static IReadOnlyList Descriptors => _descriptors.Value; + + /// Returns all discovered config option descriptors. + public static IReadOnlyList GetDescriptors() => Descriptors; + + /// Returns only warm (hot-reloadable) descriptors. + public static IReadOnlyList GetWarmDescriptors() => + _warmDescriptors.Value; + + /// + /// Returns a reverse map from configuration name to field/property. + /// Computed once and cached for the process lifetime. + /// + public static IReadOnlyDictionary GetFieldsByConfigName() => + _fieldsByConfigName.Value; + + /// + /// Tries to resolve a field/property by configuration name. + /// + public static bool TryGetFieldByConfigName(string configName, out PropertyInfo? field) => + _fieldsByConfigName.Value.TryGetValue(configName, out field); + + /// Returns only publishable (Warm + Cold) descriptors. + public static IReadOnlyList GetPublishableDescriptors() => + Descriptors.Where(d => d.IsPublished).ToList(); + + /// + /// Placeholder value written by deploy.sh when no env value or C# default + /// exists. Treated as "use the built-in code default" — the property is + /// left unchanged. + /// + public const string DefaultPlaceholder = "-"; + + // /// + // /// Applies warm-mode config values from the given configuration section + // /// to the target instance. + // /// Only properties with are applied. + // /// Values equal to are ignored, + // /// leaving the built-in code default in place. + // /// + // public static List ApplyWarmTo(BackendOptions target, IConfiguration warmSection, ILogger? logger = null) + // { + // var (changes, parsedValues) = DetectWarmChanges(target, warmSection, logger); + + // foreach (var change in changes) + // { + // if (!parsedValues.TryGetValue(change.PropertyName, out var newValue)) + // continue; + + // if (!_warmDescriptorsByConfigName.Value.TryGetValue(change.PropertyName, out var descriptor)) + // continue; + + // descriptor.Property.SetValue(target, newValue); + // logger?.LogInformation("[CONFIG] Updated {Property}: {Old} → {New}", + // descriptor.ConfigName, change.OldValue, change.NewValue); + // } + + // return changes; + // } + + /// + /// Iterates over a Warm:-prefixed configuration snapshot and detects + /// values that differ from . + /// + /// Each snapshot key is resolved to a property + /// via the key-path or config-name descriptor maps. Host-family keys + /// (Host*, Probe_path*, IP*) are collected separately + /// in HostChanges since they are not backed by descriptors. + /// + /// Does not mutate . + /// + /// The current in-memory . + /// + /// Flat dictionary captured from the Warm: configuration section. + /// Keys are prefixed (e.g. Warm:Logging:LogConsole, Warm:Host1). + /// + /// Optional logger for diagnostics. + /// + /// A tuple of descriptor-backed changes with their parsed values, plus a + /// dictionary of host-family key changes keyed by bare name (e.g. Host1). + /// + public static (List Changes, Dictionary ParsedValues, Dictionary HostChanges) DetectWarmChanges( + BackendOptions liveOptions, + Dictionary snapshot, + ILogger? logger = null) + { + var changes = new List(); + var parsedValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + var hostChanges = new Dictionary(StringComparer.OrdinalIgnoreCase); + var defaultTarget = new BackendOptions(); + var env = new Dictionary(1, StringComparer.OrdinalIgnoreCase); + + foreach (var kvp in snapshot) + { + var snapshotKey = kvp.Key["Warm:".Length..]; + var rawValue = kvp.Value; + + if (string.IsNullOrEmpty(rawValue)) + continue; + + if (snapshotKey.StartsWith("Host") || snapshotKey.StartsWith("Probe") || snapshotKey.StartsWith("IP")) + { + // skip Host/Probe/IP entries which are used for dynamic host discovery and not mapped to BackendOptions properties + hostChanges[snapshotKey] = rawValue; + continue; + } + + if (!_warmDescriptorsByKeyPath.Value.TryGetValue(snapshotKey, out var descriptor) + && !_warmDescriptorsByConfigName.Value.TryGetValue(snapshotKey, out descriptor)) + continue; + + var configName = descriptor.ConfigName; + if (!TryGetFieldByConfigName(configName, out var field) || field == null) + continue; + + + var currentValue = field.GetValue(liveOptions); + + object? newValue; + if (rawValue == DefaultPlaceholder) + { + newValue = field.GetValue(defaultTarget); + } + else + { + env.Clear(); + env[configName] = rawValue; + + ConfigParser.ApplyFieldFromEnv( + env, + defaultTarget, + liveOptions, + configName, + field.Name); + + newValue = field.GetValue(defaultTarget); + } + + if (Equals(currentValue, newValue)) + continue; + + parsedValues[configName] = newValue; + changes.Add(new ConfigChange + { + PropertyName = configName, + KeyPath = descriptor.Attribute.KeyPath, + RawOldValue = currentValue, + RawNewValue = newValue + }); + } + + return (changes, parsedValues, hostChanges); + } + + private static IReadOnlyList DiscoverDescriptors() + { + return typeof(BackendOptions) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(prop => prop.CanRead && prop.CanWrite) + .Select(prop => new + { + Property = prop, + Attribute = prop.GetCustomAttribute() + }) + .Where(x => x.Attribute != null) + .Select(x => new ConfigOptionDescriptor + { + Property = x.Property, + Attribute = x.Attribute! + }) + .ToList(); + } +} diff --git a/src/SimpleL7Proxy/Constants.cs b/src/SimpleL7Proxy/Constants.cs index b0dee297..986d7027 100644 --- a/src/SimpleL7Proxy/Constants.cs +++ b/src/SimpleL7Proxy/Constants.cs @@ -11,7 +11,7 @@ public static class Constants public const string RoundRobin = "roundrobin"; public const string Random = "random"; public const string Server = "simplel7proxy"; - public const string VERSION = "2.2.9-d2"; + public const string VERSION = "2.2.10"; public const int AnyPriority = -1; diff --git a/src/SimpleL7Proxy/CoordinatedShutdownService.cs b/src/SimpleL7Proxy/CoordinatedShutdownService.cs index 98483e44..6c359c8a 100644 --- a/src/SimpleL7Proxy/CoordinatedShutdownService.cs +++ b/src/SimpleL7Proxy/CoordinatedShutdownService.cs @@ -155,13 +155,14 @@ public async Task StopAsync(CancellationToken cancellationToken) await _blobWriteQueue.StopAsync(CancellationToken.None).ConfigureAwait(false); } - _eventClient?.StopTimer(); + if (_eventClient != null) + await _eventClient.StopTimerAsync().ConfigureAwait(false); // Health probes are stopped at the VERY END so the container orchestrator // (e.g. Kubernetes, Container Apps) continues to see healthy probes while // other services drain. If probes fail early, the orchestrator may kill the pod. _logger.LogInformation("[SHUTDOWN] ⏹ Stopping health probes"); - await _probeServer.StopAsync().ConfigureAwait(false); + await _probeServer.StopAsync(CancellationToken.None).ConfigureAwait(false); await _server.StopProbes(CancellationToken.None).ConfigureAwait(false); } diff --git a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs deleted file mode 100644 index 9dd47bbd..00000000 --- a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Microsoft.ApplicationInsights; -using System.Collections.Concurrent; -using Microsoft.Extensions.Hosting; - -namespace SimpleL7Proxy.Events; - -public class AppInsightsEventClient(TelemetryClient telemetryClient) - : IEventClient, IHostedService -{ - - public Task StartTimer() - { - // No timer needed for App Insights - return Task.CompletedTask; - } - public void StopTimer() { } - public int Count => 0; - public string ClientType => "AppInsights"; - public void SendData(string? value) => telemetryClient.TrackEvent(value); - - public void SendData(ProxyEvent proxyEvent) - { - string Name = proxyEvent.TryGetValue("Type", out var type) - ? type : "ProxyEvent"; - - telemetryClient.TrackEvent(Name, proxyEvent); - } - - public Task StartAsync(CancellationToken cancellationToken) - { - // App Insights doesn't need initialization - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - // Flush any remaining telemetry - telemetryClient.Flush(); - return Task.CompletedTask; - } - - // public void SendData(Dictionary data) - // { - // telemetryClient.TrackEvent("ProxyEvent", data); - // } - - - // public void SendData(ConcurrentDictionary eventData, string? name = "ProxyEvent") - // { - // telemetryClient.TrackEvent(name, eventData); - // } -} diff --git a/src/SimpleL7Proxy/Events/CompositeEventClient.cs b/src/SimpleL7Proxy/Events/CompositeEventClient.cs index 4c1c4313..4c8c35db 100644 --- a/src/SimpleL7Proxy/Events/CompositeEventClient.cs +++ b/src/SimpleL7Proxy/Events/CompositeEventClient.cs @@ -1,72 +1,75 @@ -using System.Collections.Concurrent; +using System.Collections.Frozen; namespace SimpleL7Proxy.Events; -public class CompositeEventClient(IEnumerable eventClients) - : IEventClient +/// +/// Thread-safe composite that fans out SendData to every registered IEventClient. +/// Clients add themselves via once they have initialised successfully. +/// Clients are never removed — logging is critical and no events may be lost. +/// Each client's own drain / shutdown logic handles graceful teardown. +/// +/// The hot-path () reads from a +/// snapshot that is rebuilt on every Add, giving zero-overhead iteration with no locking. +/// +public class CompositeEventClient : IEventClient { + private readonly object _lock = new(); + private readonly Dictionary _mutable = new(); + private volatile FrozenDictionary _frozen = FrozenDictionary.Empty; - - public Task StartTimer() + /// + /// Register a client. Safe to call from any thread (e.g. inside StartAsync). + /// Re-freezes the snapshot so subsequent SendData calls include the new client. + /// + public void Add(IEventClient client) { - foreach (var client in eventClients) + ArgumentNullException.ThrowIfNull(client); + lock (_lock) { - Console.WriteLine($"starting timer for {client}"); - client.StopTimer(); + _mutable[client] = 0; + _frozen = _mutable.ToFrozenDictionary(); } - - return Task.CompletedTask; + Console.WriteLine($"[CompositeEventClient] Added {client.ClientType}"); } - public void StopTimer() + + public async Task StopTimerAsync() { - foreach (var client in eventClients) + foreach (var client in _frozen.Keys) { - Console.WriteLine($"Stopping timer for {client}"); - client.StopTimer(); + Console.WriteLine($"Stopping timer for {client.ClientType}"); + await client.StopTimerAsync().ConfigureAwait(false); } } + public int Count { get { var count = 0; - foreach (var client in eventClients) + foreach (var client in _frozen.Keys) { count += client.Count; } return count; } } - public string ClientType => string.Join(", ", eventClients.Select(c => c.ClientType)); - public void SendData(string? value) + + public string ClientType { - foreach (var client in eventClients) + get { - client.SendData(value); + var snapshot = _frozen; + return snapshot.Count == 0 + ? "Composite (empty)" + : string.Join(", ", snapshot.Keys.Select(c => c.ClientType)); } } - // public void SendData(Dictionary data) - // { - // foreach (var client in eventClients) - // { - // client.SendData(data); - // } - // } - - // public void SendData(ConcurrentDictionary eventData, string? name = null) - // { - // foreach (var client in eventClients) - // { - // client.SendData(eventData); - // } - // } - - public void SendData(ProxyEvent proxyEvent) + public void SendData(string? value) { - foreach (var client in eventClients) + foreach (var client in _frozen.Keys) { - client.SendData(proxyEvent); + client.SendData(value); } } } diff --git a/src/SimpleL7Proxy/Events/EventHubClient.cs b/src/SimpleL7Proxy/Events/EventHubClient.cs index d495dc16..cc57b8f5 100644 --- a/src/SimpleL7Proxy/Events/EventHubClient.cs +++ b/src/SimpleL7Proxy/Events/EventHubClient.cs @@ -9,13 +9,15 @@ namespace SimpleL7Proxy.Events; -public class EventHubClient : IEventClient, IHostedService +public class EventHubClient : IEventClient, IHostedService, IDisposable { + private bool _disposed = false; private readonly EventHubConfig? _config; private EventHubProducerClient? _producerClient; private EventDataBatch? _batchData; private readonly ILogger _logger; + private readonly CompositeEventClient _composite; private readonly CancellationTokenSource cancellationTokenSource = new(); private CancellationToken workerCancelToken; private bool isRunning = false; @@ -28,9 +30,17 @@ public class EventHubClient : IEventClient, IHostedService private static int entryCount = 0; //public EventHubClient(string connectionString, string eventHubName, ILogger? logger = null) - public EventHubClient(EventHubConfig? config, ILogger logger) + public EventHubClient(CompositeEventClient composite, ILogger logger) { - _config = config; + try { + _config = new EventHubConfig(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to initialize EventHubConfig. EventHubClient will be disabled."); + _config = null; + } + _composite = composite ?? throw new ArgumentNullException(nameof(composite)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // All initialization happens in StartAsync } @@ -39,7 +49,7 @@ public EventHubClient(EventHubConfig? config, ILogger logger) public string ClientType => isRunning ? "EventHub" : "EventHub (Disabled)"; public async Task StartAsync(CancellationToken cancellationToken) { - // Handle null or invalid configuration gracefully - just don't start the service + // If config failed to initialize (constructor threw), skip startup gracefully if (_config == null) { _logger.LogInformation("EventHubClient configuration is null. EventHub will not be started."); @@ -47,17 +57,6 @@ public async Task StartAsync(CancellationToken cancellationToken) { return; } - // Validate configuration has minimum required information - bool hasConnectionString = !string.IsNullOrEmpty(_config.ConnectionString) && !string.IsNullOrEmpty(_config.EventHubName); - bool hasNamespace = !string.IsNullOrEmpty(_config.EventHubNamespace) && !string.IsNullOrEmpty(_config.EventHubName); - - if (!hasConnectionString && !hasNamespace) - { - _logger.LogInformation("EventHubClient configuration is incomplete. EventHub will not be started."); - isRunning = false; - return; - } - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_config.StartupSeconds)); try { if (!string.IsNullOrEmpty(_config.ConnectionString)) @@ -66,6 +65,8 @@ public async Task StartAsync(CancellationToken cancellationToken) { } else if (!string.IsNullOrEmpty(_config.EventHubNamespace)) { + + // NOTE: this breaks in gov cloud because of the namespace suffix.. needs a better solution var fullyQualifiedNamespace = _config.EventHubNamespace; if (!fullyQualifiedNamespace.EndsWith(".servicebus.windows.net")) fullyQualifiedNamespace = $"{_config.EventHubNamespace}.servicebus.windows.net"; @@ -77,41 +78,41 @@ public async Task StartAsync(CancellationToken cancellationToken) { workerCancelToken = cancellationTokenSource.Token; isRunning = true; + _composite.Add(this); _logger.LogCritical("[SERVICE] ✓ EventHub Client started successfully"); writerTask = Task.Run(() => EventWriter(workerCancelToken), workerCancelToken); } catch (OperationCanceledException) { - _logger.LogError("EventHubClient setup timed out after {Seconds} seconds", _config.StartupSeconds); + _logger.LogError("EventHubClient setup timed out after {Seconds} seconds. EventHub logging will be disabled.", _config.StartupSeconds); isRunning = false; - throw new TimeoutException($"EventHubClient setup timed out after {_config.StartupSeconds} seconds. Check network connectivity to EventHub."); + // Don't throw — other event clients (e.g. LogFileEventClient) should continue running } catch (Exception ex) { - _logger.LogError(ex, "Failed to setup EventHubClient"); + _logger.LogError(ex, "Failed to setup EventHubClient. EventHub logging will be disabled."); isRunning = false; - // Include the inner exception message to make it visible in the main program catch block - throw new Exception($"Failed to setup EventHubClient: {ex.Message}", ex); + // Don't throw — other event clients (e.g. LogFileEventClient) should continue running } } - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { - StopTimer(); - return Task.CompletedTask; + await StopTimerAsync().ConfigureAwait(false); } TaskCompletionSource ShutdownTCS = new(); - public void StopTimer() + public async Task StopTimerAsync() { isShuttingDown = true; while (isRunning && _logBuffer.Count > 0) { - Task.Delay(100).Wait(); + await Task.Delay(100).ConfigureAwait(false); } cancellationTokenSource.Cancel(); isRunning = false; - writerTask?.Wait(); + if (writerTask != null) + await writerTask.ConfigureAwait(false); } public async Task EventWriter(CancellationToken token) @@ -218,14 +219,6 @@ public void SendData(string? value) // SendData(jsonData); // } - public void SendData(ProxyEvent proxyEvent) - { - if (!isRunning || isShuttingDown) return; - - string jsonData = JsonSerializer.Serialize(proxyEvent); - SendData(jsonData); - } - // public void SendData(ConcurrentDictionary eventData, string? name = null) // { // if (!isRunning || isShuttingDown) return; @@ -233,4 +226,22 @@ public void SendData(ProxyEvent proxyEvent) // string jsonData = JsonSerializer.Serialize(eventData); // SendData(jsonData); // } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + cancellationTokenSource.Dispose(); + } + _disposed = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } } diff --git a/src/SimpleL7Proxy/Events/EventHubConfig.cs b/src/SimpleL7Proxy/Events/EventHubConfig.cs index d4c599d3..a357a3bc 100644 --- a/src/SimpleL7Proxy/Events/EventHubConfig.cs +++ b/src/SimpleL7Proxy/Events/EventHubConfig.cs @@ -6,12 +6,26 @@ public class EventHubConfig { public string? EventHubNamespace { get; } public int StartupSeconds { get; } = 10; - public EventHubConfig(string? connectionString, string? eventHubName, string? eventHubNamespace, int startupSeconds) { - ConnectionString = connectionString; - EventHubName = eventHubName; - EventHubNamespace = eventHubNamespace; - StartupSeconds = startupSeconds; + public EventHubConfig() { - Console.WriteLine($"[CONFIG] EventHubConfig initialized. ConnectionString: {(string.IsNullOrEmpty(connectionString) ? "Not Set" : "Set")}, EventHubName: {eventHubName}, EventHubNamespace: {eventHubNamespace}"); + ConnectionString = Environment.GetEnvironmentVariable("EVENTHUB_CONNECTIONSTRING"); + EventHubName = Environment.GetEnvironmentVariable("EVENTHUB_NAME"); + EventHubNamespace = Environment.GetEnvironmentVariable("EVENTHUB_NAMESPACE"); + var startupSecondsStr = Environment.GetEnvironmentVariable("EVENTHUB_STARTUP_SECONDS"); + + if (int.TryParse(startupSecondsStr, out var parsed)) + StartupSeconds = parsed; + + // Valid config requires either (ConnectionString + EventHubName) or (EventHubNamespace + EventHubName) + bool hasConnectionString = !string.IsNullOrEmpty(ConnectionString) && !string.IsNullOrEmpty(EventHubName); + bool hasNamespace = !string.IsNullOrEmpty(EventHubNamespace) && !string.IsNullOrEmpty(EventHubName); + + if (!hasConnectionString && !hasNamespace) + { + Console.WriteLine("[CONFIG] EventHubConfig incomplete — need (EVENTHUB_CONNECTIONSTRING + EVENTHUB_NAME) or (EVENTHUB_NAMESPACE + EVENTHUB_NAME). EventHub logging will be disabled."); + throw new InvalidOperationException("Incomplete EventHub configuration. Check logs for details."); + } + + Console.WriteLine($"[CONFIG] EventHubConfig initialized. ConnectionString: {(string.IsNullOrEmpty(ConnectionString) ? "Not Set" : "Set")}, EventHubName: {EventHubName}, EventHubNamespace: {EventHubNamespace}, StartupSeconds: {StartupSeconds}"); } } \ No newline at end of file diff --git a/src/SimpleL7Proxy/Events/IEventClient.cs b/src/SimpleL7Proxy/Events/IEventClient.cs index 4fe71f5d..fb2adab5 100644 --- a/src/SimpleL7Proxy/Events/IEventClient.cs +++ b/src/SimpleL7Proxy/Events/IEventClient.cs @@ -6,11 +6,10 @@ public interface IEventClient { int Count { get; } string ClientType { get; } - //public Task StartTimer(); - public void StopTimer(); + public Task StopTimerAsync(); void SendData(string? value); // void SendData(Dictionary data); //void SendData( ConcurrentDictionary eventData, string? name=""); - void SendData(ProxyEvent eventData); + //void SendData(ProxyEvent eventData, IDictionary? extraProperties = null); } diff --git a/src/SimpleL7Proxy/Events/LogFileEventClient.cs b/src/SimpleL7Proxy/Events/LogFileEventClient.cs index 46e1676d..48be7957 100644 --- a/src/SimpleL7Proxy/Events/LogFileEventClient.cs +++ b/src/SimpleL7Proxy/Events/LogFileEventClient.cs @@ -21,10 +21,12 @@ public class LogFileEventClient : IEventClient, IHostedService public int GetEntryCount() => entryCount; private static int entryCount = 0; + private readonly CompositeEventClient _composite; private static Stream log = null!; private static StreamWriter writer = null!; - public LogFileEventClient(string filename) + public LogFileEventClient(string filename, CompositeEventClient composite) { + _composite = composite ?? throw new ArgumentNullException(nameof(composite)); // create file stream to a log file log = new FileStream(filename, FileMode.OpenOrCreate, FileAccess.Write); writer = new StreamWriter(log) @@ -49,15 +51,15 @@ public Task StartAsync(CancellationToken cancellationToken) workerCancelToken = cancellationTokenSource.Token; if (isRunning) { + _composite.Add(this); writerTask = Task.Run(() => EventWriter(workerCancelToken)); } return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { - StopTimer(); - return Task.CompletedTask; + await StopTimerAsync().ConfigureAwait(false); } @@ -120,21 +122,21 @@ private void LogNextBatch(int count) writer.Flush(); } - public void StopTimer() + public async Task StopTimerAsync() { if (writerTask == null) { - Console.WriteLine("LogFileEventClient: StopTimer called but writerTask is null"); + Console.WriteLine("LogFileEventClient: StopTimerAsync called but writerTask is null"); return; } isShuttingDown = true; while (isRunning && _logBuffer.Count > 0) { - Task.Delay(100).Wait(); + await Task.Delay(100).ConfigureAwait(false); } cancellationTokenSource.Cancel(); - writerTask?.Wait(); + await writerTask.ConfigureAwait(false); isRunning = false; } @@ -166,12 +168,4 @@ public void SendData(string? value) // SendData(JsonSerializer.Serialize(eventData)); // } - public void SendData(ProxyEvent proxyEvent) - { - if (!isRunning || isShuttingDown) return; - - string jsonData = JsonSerializer.Serialize(proxyEvent); - SendData(jsonData); - } - } \ No newline at end of file diff --git a/src/SimpleL7Proxy/Events/ProxyEvent.cs b/src/SimpleL7Proxy/Events/ProxyEvent.cs index 35b362c6..ed47434b 100644 --- a/src/SimpleL7Proxy/Events/ProxyEvent.cs +++ b/src/SimpleL7Proxy/Events/ProxyEvent.cs @@ -1,4 +1,8 @@ -using System.Collections.Concurrent; +using System.Buffers; +using System.Collections.Concurrent; +using System.Collections.Frozen; +using System.Text; +using System.Text.Json; using Microsoft.Extensions.Options; using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.DataContracts; @@ -34,7 +38,7 @@ public enum EventType public class ProxyEvent : ConcurrentDictionary { private static IOptions _options = null!; - private static IEventClient? _eventHubClient; + private static IEventClient? _eventClient; private static TelemetryClient? _telemetryClient; private static readonly Uri LOCALHOSTURI = new Uri("http://localhost"); @@ -46,26 +50,47 @@ public class ProxyEvent : ConcurrentDictionary public string? Method { get; set; } = "GET"; public TimeSpan Duration { get; set; } = TimeSpan.Zero; public Exception? Exception { get; set; } = null; + public static FrozenDictionary DefaultParams { get; private set; } = FrozenDictionary.Empty; public static void Initialize( IOptions backendOptions, - IEventClient? eventHubClient = null, + IEventClient? eventClient = null, TelemetryClient? telemetryClient = null) { _options = backendOptions ?? throw new ArgumentNullException(nameof(backendOptions)); - _eventHubClient = eventHubClient ?? throw new ArgumentNullException(nameof(eventHubClient)); - _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + _eventClient = eventClient ?? throw new ArgumentNullException(nameof(eventClient)); + _telemetryClient = telemetryClient; // null when APPINSIGHTS_CONNECTIONSTRING is not set + + // Set default parameters that should be included with every event (frozen = immutable + optimized reads) + DefaultParams = new Dictionary(3) + { + ["Ver"] = Constants.VERSION, + ["Revision"] = _options.Value.Revision, + ["ContainerApp"] = _options.Value.ContainerApp + }.ToFrozenDictionary(); } - public ProxyEvent() : base(1, 20, StringComparer.OrdinalIgnoreCase) + /// + /// Stamps Ver, Revision, ContainerApp, Status, Method into any properties dictionary. + /// + private void AddDefaultProperties(IDictionary properties) + { + foreach (var kvp in DefaultParams) + { + properties[kvp.Key] = kvp.Value; + } + + properties["Status"] = ((int)Status).ToString(); + properties["Method"] = Method ?? "GET"; + } + + public ProxyEvent() : base(1, 13, StringComparer.OrdinalIgnoreCase) { - // Ver, Revision, ContainerApp added at send time, not stored } public ProxyEvent(int capacity) : base(1, capacity, StringComparer.OrdinalIgnoreCase) { - // Ver, Revision, ContainerApp added at send time, not stored } public ProxyEvent(ProxyEvent other) : base(other) @@ -92,7 +117,7 @@ public void SendEvent() bool logDependency = false; bool logRequest = false; bool logException = false; - bool logToEventHub = false; + bool logToEventClient = false; // Console.WriteLine($"Sending event: {Type} with Status: {Status} and Duration: {Duration.TotalMilliseconds} ms"); @@ -105,56 +130,53 @@ public void SendEvent() if (_options?.Value.LogProbes == true) { logEvent = true; - logToEventHub = true; + logToEventClient = true; } break; case EventType.ServerError: case EventType.CircuitBreakerError: logEvent = true; - logToEventHub = true; + logToEventClient = true; break; case EventType.Console: if (_options?.Value.LogConsole == true) { logEvent = true; - logToEventHub = true; + logToEventClient = true; } break; case EventType.Poller: if (_options?.Value.LogPoller == true) { logEvent = true; - logToEventHub = true; + logToEventClient = true; } break; case EventType.BackendRequest: logDependency = true; - logToEventHub = true; + logToEventClient = true; break; case EventType.ProxyRequestEnqueued: case EventType.ProxyRequestRequeued: logEvent = true; - logToEventHub = true; + logToEventClient = true; break; case EventType.ProxyRequestExpired: case EventType.ProxyError: case EventType.ProxyRequest: logRequest = true; - logToEventHub = true; + logToEventClient = true; break; case EventType.Exception: logException = true; - logToEventHub = true; + logToEventClient = true; break; default: // For any other event type, we can log it as a custom event logEvent = true; - logToEventHub = true; + logToEventClient = true; break; } - - this["Status"] = ((int)Status).ToString(); - this["Method"] = Method ?? "GET"; // Default to GET if Method is null // Add replica-lifetime values at send time @@ -166,15 +188,14 @@ public void SendEvent() else if (logException) TrackException(); } - if (logToEventHub && _eventHubClient is not null) + if (logToEventClient && _eventClient is not null) { - this["Type"] = "S7P-" + Type.ToString(); - this["MID"] = MID ?? "N/A"; - this["Ver"] = Constants.VERSION; - this["Revision"] = _options!.Value.Revision; - this["ContainerApp"] = _options.Value.ContainerApp; - // Send the event to Event Hub - _eventHubClient.SendData(this); + Dictionary eventParams = new Dictionary(DefaultParams, StringComparer.OrdinalIgnoreCase); + eventParams["Type"] = "S7P-" + Type.ToString(); + eventParams["MID"] = MID ?? "N/A"; + AddDefaultProperties(eventParams); + // Send the event to all registered event clients (EventHub, LogFile, etc.) + _eventClient.SendData(ConvertToJson(this, eventParams)); } } catch (Exception ex) @@ -184,6 +205,35 @@ public void SendEvent() } } + public static string ConvertToJson(ProxyEvent proxyEvent, IDictionary? extraProperties = null) + { + // Use Utf8JsonWriter to merge proxyEvent + extraProperties into one JSON object + // without allocating an intermediate merged dictionary + var buffer = new ArrayBufferWriter(512); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + + foreach (var kvp in proxyEvent) + { + writer.WriteString(kvp.Key, kvp.Value); + } + + if (extraProperties is not null) + { + foreach (var kvp in extraProperties) + { + writer.WriteString(kvp.Key, kvp.Value); + } + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private void TrackEvent() { string eventName = "S7P-" + Type.ToString(); @@ -212,6 +262,9 @@ private void TrackEvent() } } + // Stamp defaults directly into telemetry (not into this ProxyEvent) + AddDefaultProperties(eventTelemetry.Properties); + _telemetryClient?.TrackEvent(eventTelemetry); } @@ -232,9 +285,7 @@ private void TrackDependancy() // Set the timestamp dependencyTelemetry.Timestamp = DateTimeOffset.UtcNow.Subtract(Duration); dependencyTelemetry.Id = MID; - dependencyTelemetry.Properties["Ver"] = Constants.VERSION; - dependencyTelemetry.Properties["Revision"] = _options.Value.Revision; - dependencyTelemetry.Properties["ContainerApp"] = _options.Value.ContainerApp; + AddDefaultProperties(dependencyTelemetry.Properties); // Add custom properties foreach (var kvp in this) @@ -276,9 +327,7 @@ private void TrackRequest() // Add a special flag to mark this as our custom telemetry requestTelemetry.Properties["CustomTracked"] = "true"; - requestTelemetry.Properties["Ver"] = Constants.VERSION; - requestTelemetry.Properties["Revision"] = _options.Value.Revision; - requestTelemetry.Properties["ContainerApp"] = _options.Value.ContainerApp; + AddDefaultProperties(requestTelemetry.Properties); foreach (var kvp in this) { @@ -292,9 +341,7 @@ private void TrackException() { this["ExceptionType"] = Exception?.GetType().ToString() ?? "Unknown"; this["Message"] = Exception?.Message ?? "No exception message"; - this["Ver"] = Constants.VERSION; - this["Revision"] = _options.Value.Revision; - this["ContainerApp"] = _options.Value.ContainerApp; + AddDefaultProperties(this); _telemetryClient?.TrackException(Exception, this.ToDictionary()); } @@ -348,7 +395,7 @@ public void WriteOutput(string data = "") if (_options.Value.LogConsoleEvent) { - _eventHubClient?.SendData(this); + _eventClient?.SendData(ConvertToJson(this)); } } catch (Exception ex) @@ -375,7 +422,7 @@ public void WriteErrorOutput(string data = "") this["Type"] = "S7P-Console-Error"; } - _eventHubClient?.SendData(this); + _eventClient?.SendData(ConvertToJson(this)); } catch (Exception ex) { @@ -384,7 +431,7 @@ public void WriteErrorOutput(string data = "") } } - public Dictionary ToDictionary(string[]? keys = null) + public Dictionary ToDictionary(List? keys = null) { // Create a new dictionary to hold the properties var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs b/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs deleted file mode 100644 index a82c8da2..00000000 --- a/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs +++ /dev/null @@ -1,96 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace SimpleL7Proxy.Events; - -/// -/// Extension methods for registering proxy event clients. -/// -public static class ProxyEventServiceCollectionExtensions -{ - /// - /// Registers EventHub and AppInsights event clients and their hosted services. - /// - public static IServiceCollection AddProxyEventClient( - this IServiceCollection services, - string? aiConnectionString) - { - AddAppInsightsClient(services, aiConnectionString); - - // EventHubClient checks EventHubConfig in constructor and decides whether to run - try - { - Console.WriteLine("Registering EventHubClient"); - services.AddSingleton(); - services.AddSingleton(svc => svc.GetRequiredService()); - services.AddSingleton(svc => (IHostedService)svc.GetRequiredService()); - } - catch (Exception ex) - { - Console.WriteLine("Failed to create EventHubClient: " + ex.Message); - } - - // Register the composite if you want to inject it, but do not overwrite IEventClient - services.AddSingleton(svc => - { - var clients = svc.GetServices().ToList(); - return new CompositeEventClient(clients); - }); - - return services; - } - - /// - /// Registers LogFile event client and its hosted service. - /// - public static IServiceCollection AddProxyEventLogFileClient( - this IServiceCollection services, - string? filename, - string? aiConnectionString) - { - AddAppInsightsClient(services, aiConnectionString); - - if (!string.IsNullOrEmpty(filename)) - { - try - { - services.AddSingleton(svc => new LogFileEventClient(filename)); - services.AddSingleton(svc => svc.GetRequiredService()); - services.AddSingleton(svc => (IHostedService)svc.GetRequiredService()); - } - catch (Exception ex) - { - Console.WriteLine("Failed to create LogFileEventClient: " + ex.Message); - } - } - - // Register the composite if you want to inject it, but do not overwrite IEventClient - services.AddSingleton(svc => - { - var clients = svc.GetServices().ToList(); - return new CompositeEventClient(clients); - }); - - return services; - } - - /// - /// Helper method to register AppInsights event client - /// - private static void AddAppInsightsClient(IServiceCollection services, string? aiConnectionString) - { - if (string.IsNullOrEmpty(aiConnectionString)) - return; - - try - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(svc => (IHostedService)svc.GetRequiredService()); - } - catch (Exception ex) - { - Console.WriteLine("Failed to create AppInsightsEventClient: " + ex.Message); - } - } -} \ No newline at end of file diff --git a/src/SimpleL7Proxy/Events/ServiceBus/ServiceBusRequestService.cs b/src/SimpleL7Proxy/Events/ServiceBus/ServiceBusRequestService.cs index d570ba55..82edd6b9 100644 --- a/src/SimpleL7Proxy/Events/ServiceBus/ServiceBusRequestService.cs +++ b/src/SimpleL7Proxy/Events/ServiceBus/ServiceBusRequestService.cs @@ -303,7 +303,7 @@ private async Task SendBatchesForTopicAsync(string topicName, List 0) { - Task.Delay(100).Wait(); + await Task.Delay(100).ConfigureAwait(false); } _cancellationTokenSource?.Cancel(); isRunning = false; - writerTask?.Wait(cancellationToken); + if (writerTask != null) + await writerTask.ConfigureAwait(false); } - - return Task.CompletedTask; } } } \ No newline at end of file diff --git a/src/SimpleL7Proxy/Feeder/AsyncFeeder.cs b/src/SimpleL7Proxy/Feeder/AsyncFeeder.cs index b47f0028..fd59ffce 100644 --- a/src/SimpleL7Proxy/Feeder/AsyncFeeder.cs +++ b/src/SimpleL7Proxy/Feeder/AsyncFeeder.cs @@ -41,6 +41,7 @@ public class AsyncFeeder : IHostedService, IAsyncFeeder private Task? readerTask; CancellationTokenSource? _cancellationTokenSource; private readonly ServiceBusFactory _senderFactory; + private readonly ConcurrentDictionary _activeHandlers = new(); private static long counter = 0; // REMOVE HARDCODED OPENAI CALL @@ -158,27 +159,18 @@ public async Task EventReader(CancellationToken token) "Check that the managed identity has 'Azure Service Bus Data Receiver' role assigned to the queue. " + "Service will continue but will not process messages."); - // Clean up processor and wait for shutdown signal instead of throwing + // Clean up processor and exit — nothing is running, no need to wait await processor.DisposeAsync().ConfigureAwait(false); processor = null; - - // Wait for shutdown signal - while (!isShuttingDown) - { - await Task.Delay(500, token).ConfigureAwait(false); - } - _logger.LogInformation("[SHUTDOWN] ✓ AsyncFeeder stopped (no processor was running)"); return; } - while (!isShuttingDown) - { - await Task.Delay(500, token).ConfigureAwait(false); - } + // Wait for shutdown signal (event-driven via cancellation token) + try { await Task.Delay(Timeout.Infinite, token).ConfigureAwait(false); } + catch (OperationCanceledException) { } - await processor.StopProcessingAsync().ConfigureAwait(false); - _logger.LogInformation("[SHUTDOWN] ✓ AsyncFeeder stopped processing messages"); + // Processor cleanup (StopProcessingAsync + Dispose) handled in finally block } catch (TaskCanceledException) @@ -190,13 +182,13 @@ public async Task EventReader(CancellationToken token) } else { - _logger.LogInformation($"[SHUTDOWN] AsyncFeeder service shutdown initiated."); + _logger.LogInformation("[SHUTDOWN] AsyncFeeder service shutdown initiated."); } } catch (OperationCanceledException) { // Operation was canceled, exit gracefully - _logger.LogInformation($"[SHUTDOWN] AsyncFeeder service shutdown initiated."); + _logger.LogInformation("[SHUTDOWN] AsyncFeeder service shutdown initiated."); } catch (InvalidOperationException ex) { @@ -208,16 +200,34 @@ public async Task EventReader(CancellationToken token) } finally { - // Ensure processor is disposed if (processor != null) { + // StopProcessingAsync sets _isRunning=false synchronously before its first await, + // which prevents new messages from being received. We don't need to await the + // AMQP link close — DisposeAsync handles that. + _ = processor.StopProcessingAsync(); + + // Wait for any in-flight handlers to complete (event-driven, no arbitrary timeout) + var pending = _activeHandlers.Values.ToArray(); + if (pending.Length > 0) + { + _logger.LogInformation("[SHUTDOWN] ⏳ Waiting for {Count} in-flight handler(s) to complete", pending.Length); + await Task.WhenAll(pending).ConfigureAwait(false); + } + try { - await processor.DisposeAsync().ConfigureAwait(false); + // DisposeAsync waits for the AMQP link close handshake which can take several seconds. + // Use a short timeout so shutdown isn't blocked by the network round-trip. + var disposeTask = processor.DisposeAsync().AsTask(); + if (await Task.WhenAny(disposeTask, Task.Delay(TimeSpan.FromSeconds(1))) != disposeTask) + { + _logger.LogDebug("[SHUTDOWN] Processor dispose timed out after 1s — AMQP link will close in background"); + } } catch (Exception ex) { - _logger.LogWarning(ex, "[SHUTDOWN] Error disposing processor"); + _logger.LogDebug(ex, "[SHUTDOWN] Processor dispose error"); } } } @@ -227,6 +237,23 @@ public async Task EventReader(CancellationToken token) private async Task MessageHandler(ProcessMessageEventArgs args) + { + var handlerId = Guid.NewGuid(); + _activeHandlers.TryAdd(handlerId, Task.CompletedTask); // placeholder + + try + { + var task = ProcessMessageCoreAsync(args); + _activeHandlers[handlerId] = task; + await task.ConfigureAwait(false); + } + finally + { + _activeHandlers.TryRemove(handlerId, out _); + } + } + + private async Task ProcessMessageCoreAsync(ProcessMessageEventArgs args) { var message = args.Message; var messageFromSB = message.Body.ToString(); @@ -234,7 +261,6 @@ private async Task MessageHandler(ProcessMessageEventArgs args) try { - // RequestAPIDocument comes from the status queue, only minimal fields populated if (requestData is RequestAPIDocument requestMsg) { @@ -246,7 +272,6 @@ private async Task MessageHandler(ProcessMessageEventArgs args) { rd.RecoveryProcessor = isBackground ? _openAIRequest : _normalRequest; } - } else if (requestData is RequestMessage msg) { @@ -258,21 +283,16 @@ private async Task MessageHandler(ProcessMessageEventArgs args) // Handle simple message _logger.LogInformation("AsyncFeeder: UserID: {UserID}, ID: {Id}", msg.UserID, msg.Id); - //_logger.LogInformation("AsyncFeeder: Message content: {MessageContent}", messageFromSB); } - else { _logger.LogWarning("AsyncFeeder: Unknown message type received from Service Bus."); _logger.LogInformation("AsyncFeeder: Message content: {MessageContent}", messageFromSB); } - // mark the request as completed await args.CompleteMessageAsync(message); } - - // message will be retried automatically on error catch (Exception ex) { _logger.LogError(ex, "AsyncFeeder: Error processing message from Service Bus: " + ex.Message); diff --git a/src/SimpleL7Proxy/Feeder/NormalRequest.cs b/src/SimpleL7Proxy/Feeder/NormalRequest.cs index 0c323fc1..7d15727e 100644 --- a/src/SimpleL7Proxy/Feeder/NormalRequest.cs +++ b/src/SimpleL7Proxy/Feeder/NormalRequest.cs @@ -67,7 +67,7 @@ private async Task DataFromBlob(RequestData request) _logger.LogDebug("Creating async worker for request {Guid} URL: {FullURL} UserId: {UserID} ", request.Guid, request.FullURL, request.UserID); - request.asyncWorker = _asyncWorkerFactory.CreateAsync(request, 0); + request.asyncWorker = await _asyncWorkerFactory.CreateAsync(request, 0).ConfigureAwait(false); // let asyncworker restore the blob streams await request.asyncWorker.PrepareResponseStreamsAsync(); diff --git a/src/SimpleL7Proxy/Feeder/OpenAIBackgroundRequest.cs b/src/SimpleL7Proxy/Feeder/OpenAIBackgroundRequest.cs index ea87f31b..9b9c6e95 100644 --- a/src/SimpleL7Proxy/Feeder/OpenAIBackgroundRequest.cs +++ b/src/SimpleL7Proxy/Feeder/OpenAIBackgroundRequest.cs @@ -67,7 +67,7 @@ public async Task HydrateRequestAsync(RequestData request) request.IsBackgroundCheck = true; request.runAsync = true; request.AsyncTriggered = true; - request.asyncWorker = _asyncWorkerFactory.CreateAsync(request, 0); + request.asyncWorker = await _asyncWorkerFactory.CreateAsync(request, 0).ConfigureAwait(false); // Initialize for background check - blobs will be created lazily when first written to await request.asyncWorker.InitializeForBackgroundCheck(); diff --git a/src/SimpleL7Proxy/ProbeServer.cs b/src/SimpleL7Proxy/ProbeServer.cs index 40e56b89..88a22cde 100644 --- a/src/SimpleL7Proxy/ProbeServer.cs +++ b/src/SimpleL7Proxy/ProbeServer.cs @@ -24,7 +24,7 @@ namespace SimpleL7Proxy; /// Standalone probe server using Kestrel on port 9000. /// Provides health check endpoints for Kubernetes/container orchestration. ///
-public class ProbeServer : BackgroundService +public class ProbeServer : BackgroundService, IConfigChangeSubscriber { private readonly IBackendService _backends; private readonly ILogger _logger; @@ -38,6 +38,7 @@ public class ProbeServer : BackgroundService private Timer? _probeTimer; private readonly BackendOptions _backendOptions; + private HttpClient? _selfCheckClient; static readonly byte[] s_okBytes = Encoding.UTF8.GetBytes("OK\n"); static readonly int s_okLength = s_okBytes.Length; @@ -49,13 +50,15 @@ public class ProbeServer : BackgroundService public static HealthStatusEnum StartupStatus = HealthStatusEnum.StartupZeroHosts; private static int FailedAttempts = 0; - public ProbeServer(IBackendService backends, HealthCheckService healthService, ILogger logger, IOptions backendOptions) + public ProbeServer(IBackendService backends, HealthCheckService healthService, ILogger logger, IOptions backendOptions, ConfigChangeNotifier configChangeNotifier) { _backends = backends ?? throw new ArgumentNullException(nameof(backends)); _healthService = healthService ?? throw new ArgumentNullException(nameof(healthService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _backendOptions = backendOptions?.Value ?? throw new ArgumentNullException(nameof(backendOptions)); - //_port = _backendOptions.ProbeServerPort; // Default probe server port + + // Subscribe for HealthProbeSidecar changes (HealthProbeSidecarEnabled & Url are parsed from it) + configChangeNotifier.Subscribe(this, options => options.HealthProbeSidecar); } /// @@ -63,12 +66,20 @@ public ProbeServer(IBackendService backends, HealthCheckService healthService, I /// protected override Task ExecuteAsync(CancellationToken cancellationToken) { - HttpClient? selfCheckClient = null; - + StartProbeServer(); + return Task.CompletedTask; + } + + /// + /// (Re)initializes the sidecar client and probe timer. + /// Safe to call multiple times — tears down the previous instance first. + /// + private void StartProbeServer() + { if (_backendOptions.HealthProbeSidecarEnabled) { _logger.LogInformation("[INIT] ✓ Health probe sidecar enabled at {Url}", _backendOptions.HealthProbeSidecarUrl); - selfCheckClient = CreateSelfCheckClient(); + _selfCheckClient = CreateSelfCheckClient(); } else { @@ -80,38 +91,56 @@ protected override Task ExecuteAsync(CancellationToken cancellationToken) { _startupStatus = _readinessStatus = _healthService.GetStatus(); - // Push to sidecar if enabled - if (selfCheckClient != null) + // Push to sidecar if enabled (fire-and-forget async to avoid blocking threadpool) + var client = _selfCheckClient; + if (client != null) { - try - { - var url = $"{_backendOptions.HealthProbeSidecarUrl}/internal/update-status?readiness={_readinessStatus}&startup={_startupStatus}"; - var response = selfCheckClient.GetAsync(url).Result; - if (!response.IsSuccessStatusCode) - { - FailedAttempts++; - _logger.LogWarning("[FAIL] Probe server updated failed. {attempts} attempts. HTTP {StatusCode}", FailedAttempts, response.StatusCode); - } - else - { - if (FailedAttempts > 0) - { - _logger.LogInformation("[RECOVER] Probe server update succeeded after {attempts} failed attempts", FailedAttempts); - FailedAttempts = 0; - } - } - } - catch (Exception ex) - { - FailedAttempts++; - _logger.LogWarning("[FAIL] Probe server updated failed. {attempts} attempts. Exception: {Message}", FailedAttempts, ex.Message); - } + _ = PushStatusToSidecarAsync(client); } }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); - - - return Task.CompletedTask; + + FailedAttempts = 0; + } + + /// + /// Stops the timer and disposes the sidecar client. + /// + private void StopProbeServer() + { + _probeTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _probeTimer?.Dispose(); + _probeTimer = null; + + _selfCheckClient?.Dispose(); + _selfCheckClient = null; + } + + private async Task PushStatusToSidecarAsync(HttpClient selfCheckClient) + { + try + { + var url = $"{_backendOptions.HealthProbeSidecarUrl}/internal/update-status?readiness={_readinessStatus}&startup={_startupStatus}"; + var response = await selfCheckClient.GetAsync(url).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + FailedAttempts++; + _logger.LogWarning("[FAIL] Probe server updated failed. {attempts} attempts. HTTP {StatusCode}", FailedAttempts, response.StatusCode); + } + else + { + if (FailedAttempts > 0) + { + _logger.LogInformation("[RECOVER] Probe server update succeeded after {attempts} failed attempts", FailedAttempts); + FailedAttempts = 0; + } + } + } + catch (Exception ex) + { + FailedAttempts++; + _logger.LogWarning("[FAIL] Probe server updated failed. {attempts} attempts. Exception: {Message}", FailedAttempts, ex.Message); + } } public async Task LivenessResponseAsync(HttpListenerContext lc) @@ -220,16 +249,31 @@ public async Task StartupResponseAsync(HttpListenerContext lc) } /// - /// Stops the probe server gracefully. + /// Called by the host on shutdown — stops the probe server gracefully. /// - public async Task StopAsync() + public override Task StopAsync(CancellationToken cancellationToken) { - // cancel the timer - _probeTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _probeTimer?.Dispose(); + StopProbeServer(); + return base.StopAsync(cancellationToken); } + /// + /// Called when HealthProbeSidecar config changes at runtime. + /// Does a clean restart: stops timer, disposes client, re-initializes everything. + /// + public Task OnConfigChangedAsync( + IReadOnlyList changes, + BackendOptions backendOptions, + CancellationToken cancellationToken) + { + _logger.LogInformation("[CONFIG] HealthProbeSidecar changed — restarting probe server"); + // apply the changes + StopProbeServer(); + StartProbeServer(); + return Task.CompletedTask; + } + private static HttpClient CreateSelfCheckClient() { var handler = new SocketsHttpHandler diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index 614a6f21..927635ed 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -60,8 +60,17 @@ public static async Task Main(string[] args) }); var startupLogger = startupLoggerFactory.CreateLogger(); + var appConfigBootstrap = new AppConfigBootstrap(startupLoggerFactory.CreateLogger()); + + // Kick off App Configuration download early so values are ready + // by the time LoadBackendOptions reads environment variables. + appConfigBootstrap.Start(); var hostBuilder = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((hostContext, config) => + { + config.AddAzureAppConfigurationWithWarmSupport(startupLogger); + }) .ConfigureLogging(logging => { logging.ClearProviders(); @@ -74,7 +83,7 @@ public static async Task Main(string[] args) .ConfigureServices((hostContext, services) => { ConfigureApplicationInsights(services); - ConfigureDependencyInjection(services, startupLogger); + ConfigureDependencyInjection(services, startupLogger, appConfigBootstrap); }); @@ -83,9 +92,20 @@ public static async Task Main(string[] args) // var serviceProvider = frameworkHost.Services; // Perform static initialization after building the host to ensure correct singleton usage var serviceProvider = frameworkHost.Services; + + // If Azure App Configuration refresh service is available, force initial download before other startup work. + var appConfigRefreshService = serviceProvider.GetService(); + if (appConfigRefreshService != null) + { + await appConfigRefreshService.InitializeAsync(CancellationToken.None); + var appConfigDictionary = appConfigRefreshService.GetCurrentConfigurationDictionary(); + startupLogger.LogInformation("[INIT] Azure App Configuration dictionary loaded with {Count} keys", appConfigDictionary.Count); + startupLogger.LogInformation("[INIT] ✓ Azure App Configuration initial fetch completed"); + } + var options = serviceProvider.GetRequiredService>(); - var eventHubClient = serviceProvider.GetService(); - var telemetryClient = serviceProvider.GetRequiredService(); + var eventClient = serviceProvider.GetService(); + var telemetryClient = serviceProvider.GetService(); var backendTokenProvider = serviceProvider.GetRequiredService(); // Initialize static logger for all stream processors @@ -96,13 +116,15 @@ public static async Task Main(string[] args) // Initialize ProxyEvent with BackendOptions - ProxyEvent.Initialize(options, eventHubClient, telemetryClient); + ProxyEvent.Initialize(options, eventClient, telemetryClient); // Initialize HostConfig with all required dependencies including service provider for circuit breaker DI HostConfig.Initialize(backendTokenProvider, startupLogger, serviceProvider); // Register backends after DI container is built and HostConfig is initialized - BackendHostConfigurationExtensions.RegisterBackends(options.Value); + var configuration = serviceProvider.GetService(); + var hostCollection = serviceProvider.GetRequiredService(); + ConfigBootstrapper.RegisterBackends(options.Value, configuration, null, hostCollection); try { @@ -192,39 +214,88 @@ private static void ConfigureApplicationInsights(IServiceCollection services) } } - private static void ConfigureDependencyInjection(IServiceCollection services, ILogger startupLogger) + private static void ConfigureDependencyInjection(IServiceCollection services, ILogger startupLogger, AppConfigBootstrap appConfigBootstrap) { + services.AddSingleton(appConfigBootstrap); + // EVENT_LOGGERS is a comma-separated list of event logger backends to enable. + // Supported values: "file", "eventhub" + // Example: EVENT_LOGGERS="file,eventhub" enables both simultaneously. + // Falls back to legacy LOGTOFILE behaviour when EVENT_LOGGERS is not set. + var eventLoggersRaw = Environment.GetEnvironmentVariable("EVENT_LOGGERS"); + HashSet enabledLoggers; - // Register TelemetryClient - services.AddSingleton(); - bool.TryParse(Environment.GetEnvironmentVariable("LOGTOFILE"), out var log_to_file); - - if (log_to_file) + if (!string.IsNullOrWhiteSpace(eventLoggersRaw)) { - var logFileName = Environment.GetEnvironmentVariable("LOGFILE_NAME") ?? "eventslog.json"; - services.AddProxyEventLogFileClient(logFileName, Environment.GetEnvironmentVariable("APPINSIGHTS_CONNECTIONSTRING")); - + enabledLoggers = new HashSet( + eventLoggersRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + StringComparer.OrdinalIgnoreCase); + Console.WriteLine($"[CONFIG] EVENT_LOGGERS: {string.Join(", ", enabledLoggers)}"); } else { - var eventHubConnectionString = Environment.GetEnvironmentVariable("EVENTHUB_CONNECTIONSTRING"); - var eventHubName = Environment.GetEnvironmentVariable("EVENTHUB_NAME"); - var eventHubNamespace = Environment.GetEnvironmentVariable("EVENTHUB_NAMESPACE"); - var eventHubStartupSecondsStr = Environment.GetEnvironmentVariable("EVENTHUB_STARTUP_SECONDS"); - - // default to 10 if it's not set or invalid - if (!int.TryParse(eventHubStartupSecondsStr, out _)) - eventHubStartupSecondsStr = "10"; - _ = int.TryParse(eventHubStartupSecondsStr, out var eventHubStartupSeconds); - - services.AddSingleton(new EventHubConfig(eventHubConnectionString!, eventHubName!, eventHubNamespace!, eventHubStartupSeconds)); - services.AddProxyEventClient(Environment.GetEnvironmentVariable("APPINSIGHTS_CONNECTIONSTRING")); + // Legacy fallback: LOGTOFILE=true → file, otherwise → eventhub + bool.TryParse(Environment.GetEnvironmentVariable("LOGTOFILE"), out var log_to_file); + enabledLoggers = new HashSet(StringComparer.OrdinalIgnoreCase) + { + log_to_file ? "file" : "eventhub" + }; + Console.WriteLine($"[CONFIG] EVENT_LOGGERS not set, falling back to legacy: {string.Join(", ", enabledLoggers)}"); + } + + // Ensure CompositeEventClient is registered before any individual clients + TryAddCompositeEventClient(services); + + foreach ( var loggername in enabledLoggers) + { + if (loggername == "file") + { + var logFileName = Environment.GetEnvironmentVariable("LOGFILE_NAME") ?? "eventslog.json"; + services.AddSingleton(svc => + new LogFileEventClient(logFileName, svc.GetRequiredService())); + services.AddSingleton(svc => (IHostedService)svc.GetRequiredService()); + } + else if ( loggername == "eventhub") + { + // EventHubClient reads its own config from env vars (EVENTHUB_CONNECTIONSTRING, EVENTHUB_NAME, etc.) + services.AddSingleton(); + services.AddSingleton(svc => svc.GetRequiredService()); + } + else { + // Reflection fallback: resolve type name within this assembly only (prevents cross-assembly loading) + var loggerType = typeof(Program).Assembly.GetType(loggername, throwOnError: false); + if (loggerType == null) + { + startupLogger.LogWarning("[CONFIG] Event logger type '{LoggerType}' not found. Skipping.", loggername); + continue; + } + + if (!typeof(IEventClient).IsAssignableFrom(loggerType)) + { + startupLogger.LogWarning("[CONFIG] Event logger type '{LoggerType}' does not implement IEventClient. Skipping.", loggername); + continue; + } + + // Register as concrete singleton so DI can resolve constructor dependencies + services.AddSingleton(loggerType); + + // If it implements IHostedService, register it so the host calls StartAsync + if (typeof(IHostedService).IsAssignableFrom(loggerType)) + { + services.AddSingleton(svc => (IHostedService)svc.GetRequiredService(loggerType)); + } + + startupLogger.LogInformation("[CONFIG] Registered event logger: {LoggerType}", loggername); + } + } - var backendOptions = BackendHostConfigurationExtensions.CreateBackendOptions(startupLogger); + var backendOptions = ConfigBootstrapper.CreateBackendOptions(startupLogger, appConfigBootstrap); services.AddBackendHostConfiguration(startupLogger, backendOptions); + // Wire up Azure App Configuration warm-refresh service (no-op if AZURE_APPCONFIG_ENDPOINT is not set) + services.AddAzureAppConfigurationWithWarmRefresh(startupLogger); + if (backendOptions.AsyncModeEnabled) { services.AddSingleton(); @@ -293,12 +364,13 @@ private static void ConfigureDependencyInjection(IServiceCollection services, IL services.AddSingleton(); services.AddTransient(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton>(); services.AddSingleton, ConcurrentPriQueue>(); //services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -358,4 +430,16 @@ private static void ConfigureDependencyInjection(IServiceCollection services, IL services.AddHostedService(); services.AddHostedService(provider => provider.GetRequiredService()); } + + /// + /// Ensures CompositeEventClient is registered exactly once. + /// + private static void TryAddCompositeEventClient(IServiceCollection services) + { + if (services.Any(sd => sd.ServiceType == typeof(CompositeEventClient))) + return; + + services.AddSingleton(); + services.AddSingleton(svc => svc.GetRequiredService()); + } } diff --git a/src/SimpleL7Proxy/Proxy/AsyncWorkerFactory.cs b/src/SimpleL7Proxy/Proxy/AsyncWorkerFactory.cs index d3e4cadd..ffaf209e 100644 --- a/src/SimpleL7Proxy/Proxy/AsyncWorkerFactory.cs +++ b/src/SimpleL7Proxy/Proxy/AsyncWorkerFactory.cs @@ -16,6 +16,8 @@ public class AsyncWorkerFactory : IAsyncWorkerFactory private readonly IBackupAPIService _backupAPIService; private readonly BackendOptions _backendOptions; + private readonly SemaphoreSlim _initLock = new(1, 1); + private bool _initialized; public AsyncWorkerFactory(IBlobWriter blobWriter, ILogger logger, @@ -28,21 +30,35 @@ public AsyncWorkerFactory(IBlobWriter blobWriter, _requestBackupService = requestBackupService; _backendOptions = backendOptions.Value; _backupAPIService = backupAPIService; + } + + private async Task EnsureInitializedAsync() + { + if (_initialized) return; + await _initLock.WaitAsync().ConfigureAwait(false); try { - _blobWriter.InitClientAsync(Constants.Server, Constants.Server).GetAwaiter().GetResult(); + if (_initialized) return; + await _blobWriter.InitClientAsync(Constants.Server, Constants.Server).ConfigureAwait(false); + _initialized = true; } catch (BlobWriterException ex) { _backendOptions.AsyncModeEnabled = false; _logger.LogError(ex, "Failed to initialize BlobWriter in AsyncWorkerFactory, disabling Async mode"); - return; + } + finally + { + _initLock.Release(); } } - public AsyncWorker CreateAsync(RequestData requestData, int AsyncTriggerTimeout) + public async Task CreateAsync(RequestData requestData, int AsyncTriggerTimeout) { + // Ensure blob client is initialized (lazy, thread-safe, one-time) + await EnsureInitializedAsync().ConfigureAwait(false); + _logger.LogDebug("[AsyncWorkerFactory] Creating AsyncWorker for request {Guid} with timeout {Timeout}s", requestData.Guid, AsyncTriggerTimeout); diff --git a/src/SimpleL7Proxy/Proxy/IAsyncWorkerFactory.cs b/src/SimpleL7Proxy/Proxy/IAsyncWorkerFactory.cs index 0bc591d1..5b402d06 100644 --- a/src/SimpleL7Proxy/Proxy/IAsyncWorkerFactory.cs +++ b/src/SimpleL7Proxy/Proxy/IAsyncWorkerFactory.cs @@ -1,5 +1,5 @@ namespace SimpleL7Proxy.Proxy; public interface IAsyncWorkerFactory { - AsyncWorker CreateAsync(RequestData requestData, int AsyncTriggerTimeout); + Task CreateAsync(RequestData requestData, int AsyncTriggerTimeout); } \ No newline at end of file diff --git a/src/SimpleL7Proxy/Proxy/NullAsyncWorkerFactory.cs b/src/SimpleL7Proxy/Proxy/NullAsyncWorkerFactory.cs index 03184839..491d4e80 100644 --- a/src/SimpleL7Proxy/Proxy/NullAsyncWorkerFactory.cs +++ b/src/SimpleL7Proxy/Proxy/NullAsyncWorkerFactory.cs @@ -2,9 +2,9 @@ namespace SimpleL7Proxy.Proxy; public class NullAsyncWorkerFactory: IAsyncWorkerFactory { - public AsyncWorker CreateAsync(RequestData requestData, int AsyncTriggerTimeout) + public Task CreateAsync(RequestData requestData, int AsyncTriggerTimeout) { //NOP - return null!; + return Task.FromResult(null!); } } \ No newline at end of file diff --git a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs index e8bb8fdf..35ee6791 100644 --- a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs +++ b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs @@ -54,12 +54,12 @@ public class ProxyWorker private bool _isEvictingAsyncRequest; private readonly HealthCheckService _healthCheckService; - private static string[] s_backendKeys = Array.Empty(); + private static List s_backendKeys = []; private readonly ISharedIteratorRegistry? _sharedIteratorRegistry; // Static pre-allocated ProxyEvent objects for error scenarios to avoid expensive copy constructor - private static readonly ProxyEvent s_finallyBlockErrorEvent = new ProxyEvent(30); // Base eventData (~20) + error fields (6) + buffer - private static readonly ProxyEvent s_backendRequestAttemptEvent = new ProxyEvent(25); // Base eventData (~20) + attempt fields (7) + // private static readonly ProxyEvent s_backendRequestAttemptEvent = new ProxyEvent(25); // Base eventData (~20) + attempt fields (7) + private static readonly ProxyEvent s_finallyBlockErrorEvent = new ProxyEvent(18); public ProxyWorker( int id, @@ -240,6 +240,7 @@ public async Task TaskRunnerAsync() } var eventData = incomingRequest.EventData; + ProxyData pr = null!; try { if (Constants.probes.Contains(incomingRequest.Path)) @@ -262,8 +263,6 @@ public async Task TaskRunnerAsync() workerState = "Read Proxy"; // Do THE WORK: FIND A BACKEND AND SEND THE REQUEST - ProxyData pr = null!; - try { pr = await ProxyToBackEndAsync(incomingRequest).ConfigureAwait(false); @@ -376,8 +375,6 @@ public async Task TaskRunnerAsync() incomingRequest.asyncWorker?.UpdateBackup(); } - // Dispose ProxyData to release memory immediately (headers, body byte arrays) - pr?.Dispose(); } catch (S7PRequeueException e) { @@ -396,28 +393,16 @@ public async Task TaskRunnerAsync() eventData.Type = EventType.Exception; eventData.Exception = e; - var errorMessage = Encoding.UTF8.GetBytes(e.Message); - if (lcontext == null) { _logger.LogError("Context is null in ProxyErrorException"); continue; } - try + if (await WriteErrorToClientAsync(lcontext, e.StatusCode, e.Message, eventData, incomingRequest?.Guid)) { - lcontext.Response.StatusCode = (int)e.StatusCode; - await lcontext.Response.OutputStream.WriteAsync(errorMessage).ConfigureAwait(false); _logger.LogWarning("Proxy error: {Message}", e.Message); } - catch (Exception writeEx) - { - _logger.LogError(writeEx, "Failed to write error message for request {Guid}", incomingRequest?.Guid); - - eventData["ErrorDetail"] = "Network Error sending error response"; - eventData.Type = EventType.Exception; - eventData.Exception = writeEx; - } } catch (IOException ioEx) @@ -441,19 +426,11 @@ public async Task TaskRunnerAsync() _logger.LogError("Context is null in IOException"); continue; } - try + + if (await WriteErrorToClientAsync(lcontext, HttpStatusCode.RequestTimeout, errorMessage, eventData, incomingRequest?.Guid)) { - lcontext.Response.StatusCode = (int)eventData.Status; - var errorBytes = Encoding.UTF8.GetBytes(errorMessage); - await lcontext.Response.OutputStream.WriteAsync(errorBytes).ConfigureAwait(false); _logger.LogError(ioEx, "An IO exception occurred for request {Guid}", incomingRequest?.Guid); } - catch (Exception writeEx) - { - _logger.LogError(writeEx, "Failed to write error message for request {Guid}", incomingRequest?.Guid); - eventData["InnerErrorDetail"] = "Network Error"; - eventData["InnerErrorStack"] = writeEx.StackTrace?.ToString() ?? "No Stack Trace"; - } } } catch (TaskCanceledException) @@ -504,17 +481,7 @@ public async Task TaskRunnerAsync() continue; } - try - { - lcontext.Response.StatusCode = 500; - var errorBytes = Encoding.UTF8.GetBytes(errorMessage); - await lcontext.Response.OutputStream.WriteAsync(errorBytes).ConfigureAwait(false); - } - catch (Exception writeEx) - { - eventData["InnerErrorDetail"] = "Network Error"; - eventData["InnerErrorStack"] = writeEx.StackTrace?.ToString() ?? "No Stack Trace"; - } + await WriteErrorToClientAsync(lcontext, HttpStatusCode.InternalServerError, errorMessage, eventData, incomingRequest?.Guid); } } } @@ -522,6 +489,10 @@ public async Task TaskRunnerAsync() { try { + // Dispose ProxyData to release HttpResponseMessage and body byte arrays. + // Must be in finally — exception paths were previously leaking this. + pr?.Dispose(); + if (abortTask) { if (incomingRequest.asyncWorker != null) @@ -809,61 +780,13 @@ public async Task ProxyToBackEndAsync(RequestData request) //byte[] bodyBytes = await request.CachBodyAsync().ConfigureAwait(false); List retryAfter = new(); - string modifiedPath = ""; - - // Get an iterator for the active hosts based on configuration: - // - UseSharedIterators=true: Share iterator by path for fair distribution across concurrent requests - // - UseSharedIterators=false: Each request gets its own iterator (default) - IHostIterator? hostIterator = null; - ISharedHostIterator? sharedIterator = null; - - if (_options.UseSharedIterators && _sharedIteratorRegistry != null) - { - // Use shared iterator - multiple requests to same path share the same iterator - sharedIterator = _sharedIteratorRegistry.GetOrCreate( - request.Path, - () => IteratorFactory.CreateSinglePassIterator( - _backends, - _options.LoadBalanceMode, - request.Path, - out modifiedPath)); - - // Get modified path from factory for shared iterator case - _ = IteratorFactory.GetFilteredHosts(_backends, _options.LoadBalanceMode, request.Path, out modifiedPath); - - _logger.LogDebug( - "[ProxyToBackEnd:{Guid}] Using SHARED iterator for path '{Path}' with {HostCount} hosts", - request.Guid, request.Path, sharedIterator.HostCount); - } - else - { - // Use per-request iterator (original behavior) - hostIterator = _options.IterationMode switch - { - IterationModeEnum.SinglePass => IteratorFactory.CreateSinglePassIterator( - _backends, - _options.LoadBalanceMode, - request.Path, - out modifiedPath), - - IterationModeEnum.MultiPass => IteratorFactory.CreateMultiPassIterator( - _backends, - _options.LoadBalanceMode, - _options.MaxAttempts, - request.Path, - out modifiedPath), + var (hostIterator, sharedIterator, modifiedPath) = CreateHostIterator(request); - _ => IteratorFactory.CreateSinglePassIterator( - _backends, - _options.LoadBalanceMode, - request.Path, - out modifiedPath) - }; - } request.Path = modifiedPath; - var matchingHostCount = _backends.GetActiveHosts() - .Count(h => h.Config.PartialPath == request.Path || h.Config.PartialPath == "/"); + // Use the host count from the already-created iterator (avoids redundant GetActiveHosts call + // and fixes a bug where the old code compared stripped path against configured PartialPath) + var matchingHostCount = sharedIterator?.HostCount ?? hostIterator?.HostCount ?? 0; _logger.LogDebug("[ProxyToBackEnd:{Guid}] Found {HostCount} backend hosts for path {Path}", request.Guid, matchingHostCount, request.Path); @@ -873,9 +796,9 @@ public async Task ProxyToBackEndAsync(RequestData request) request.Guid, request.Path); // Log all available hosts and their paths for debugging - var allHosts = _backends.GetActiveHosts(); + var activeHosts = _backends.GetActiveHosts(); _logger.LogCritical("[ProxyToBackEnd:{Guid}] Available hosts and their paths:", request.Guid); - foreach (var h in allHosts) + foreach (var h in activeHosts) { var cbStatus = h.Config.GetCircuitBreakerStatusString(); _logger.LogCritical("[ProxyToBackEnd:{Guid}] - Host: {Host}, Path: {PartialPath}, CB-Status: {CBStatus}", @@ -1005,7 +928,7 @@ public async Task ProxyToBackEndAsync(RequestData request) // Create ASYNC Worker if needed, and setup the timeout // SEND THE REQUEST TO THE BACKEND USING THE APROPRIATE TIMEOUT. // TO DO: reuse the cts instead of creating a new one each time. - var (requestCts, rTimeout) = SetupAsyncWorkerAndTimeout(request); + var (requestCts, rTimeout) = await SetupAsyncWorkerAndTimeout(request).ConfigureAwait(false); DateTime responseDate; using (requestCts) { @@ -1103,38 +1026,17 @@ public async Task ProxyToBackEndAsync(RequestData request) } } + var (shouldRequeue, retryMs) = CheckRequeueResponse(proxyResponse, intCode, requestAttempt, ref requestState); - if (intCode == 429 && proxyResponse.Headers.TryGetValues("S7PREQUEUE", out var values)) + if (shouldRequeue) { - requestState = "Process 429"; - - foreach (var header in proxyResponse.Headers.ToList()) - { - if (s_excludedHeaders.Contains(header.Key)) continue; - requestAttempt[header.Key] = string.Join(", ", header.Value); - // Console.WriteLine($" {header.Key}: {requestAttempt[header.Key]}"); - } - - // Requeue the request if the response is a 429 and the S7PREQUEUE header is set - // It's possible that the next host processes this request successfully, in which case these will get ignored - if (!string.Equals(values.FirstOrDefault(), "true", StringComparison.OrdinalIgnoreCase)) - continue; - - // Try retry-after-ms (milliseconds), then retry-after (seconds), default to 1000ms - int retryMs = 1000; - if (proxyResponse.Headers.TryGetValues("retry-after-ms", out var retryAfterValuesMS) && - int.TryParse(retryAfterValuesMS.FirstOrDefault(), out var retryAfterValueMS)) - { - retryMs = retryAfterValueMS; - } - else if (proxyResponse.Headers.TryGetValues("retry-after", out var retryAfterValues) && - int.TryParse(retryAfterValues.FirstOrDefault(), out var retryAfterValue)) - { - retryMs = retryAfterValue * 1000; - } - throw new S7PRequeueException("Requeue request", pr, retryMs); } + else if (intCode == 429) + { + // S7PREQUEUE was not "true" — try next host + continue; + } else { // request was successful, so we can disable the skip @@ -1227,45 +1129,7 @@ public async Task ProxyToBackEndAsync(RequestData request) } catch (HttpRequestException e) { - HttpStatusCode statusCode = e.StatusCode ?? HttpStatusCode.BadGateway; // Default to 502 if no status code - - // If no status code from the exception, try to infer from inner exception or message - if (e.StatusCode == null) - { - if (e.InnerException is SocketException socketEx) - { - switch (socketEx.SocketErrorCode) - { - case SocketError.HostNotFound: - case SocketError.TryAgain: - case SocketError.NoData: - statusCode = HttpStatusCode.ServiceUnavailable; // 503 - break; - case SocketError.TimedOut: - statusCode = HttpStatusCode.RequestTimeout; // 408 - break; - case SocketError.ConnectionRefused: - statusCode = HttpStatusCode.BadGateway; // 502 - break; - } - } - - // Fallback to message parsing if still default - if (statusCode == HttpStatusCode.BadGateway) - { - if (e.Message.Contains("name or service not known", StringComparison.OrdinalIgnoreCase) || - e.Message.Contains("No such host is known", StringComparison.OrdinalIgnoreCase) || - e.Message.Contains("Temporary failure in name resolution", StringComparison.OrdinalIgnoreCase) || - e.Message.Contains("Name resolution failed", StringComparison.OrdinalIgnoreCase)) - { - statusCode = HttpStatusCode.ServiceUnavailable; // 503 - } - else if (e.Message.Contains("timed out", StringComparison.OrdinalIgnoreCase)) - { - statusCode = HttpStatusCode.RequestTimeout; // 408 - } - } - } + HttpStatusCode statusCode = ResolveHttpRequestErrorStatus(e); intCode = (int)statusCode; PopulateRequestAttemptError(requestAttempt, statusCode, @@ -1370,70 +1234,8 @@ public async Task ProxyToBackEndAsync(RequestData request) } } - // STREAM SERVER ERROR RESPONSE. Must respond because the request was not successful - try - { - // For async requests that triggered, write error to blob via AsyncWorker - if (request.AsyncTriggered && request.asyncWorker != null) - { - _logger.LogInformation("Writing error response to AsyncWorker blob for request {Guid} - Status: {StatusCode}", - request.Guid, lastStatusCode); - - // Write error headers to blob - var errorHeaders = new WebHeaderCollection - { - ["x-Request-Queue-Duration"] = (request.DequeueTime - request.EnqueueTime).TotalMilliseconds.ToString("F3") + " ms", - ["x-Total-Latency"] = (DateTime.UtcNow - request.EnqueueTime).TotalMilliseconds.ToString("F3") + " ms", - ["x-ProxyHost"] = _options.HostName, - ["x-MID"] = request.MID, - ["Attempts"] = request.BackendAttempts.ToString() - }; - - await request.asyncWorker.WriteHeaders(lastStatusCode, errorHeaders); - - // Write error body to blob - use lazy stream creation for background checks - if (request.IsBackgroundCheck) - { - var outputStream = await request.asyncWorker.GetOrCreateDataStreamAsync(); - await outputStream.WriteAsync(Encoding.UTF8.GetBytes(sb.ToString())).ConfigureAwait(false); - await outputStream.FlushAsync().ConfigureAwait(false); - } - else if (request.OutputStream != null) - { - await request.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(sb.ToString())).ConfigureAwait(false); - await request.OutputStream.FlushAsync().ConfigureAwait(false); - } - } - // For synchronous requests or async that hasn't triggered, write to HTTP context - else if (!request.AsyncTriggered && request.Context != null) - { - _logger.LogInformation("Response Status Code: {StatusCode} for request {Guid}", - lastStatusCode, request.Guid); - request.Context.Response.StatusCode = (int)lastStatusCode; - request.Context.Response.KeepAlive = false; - - request.Context.Response.Headers["x-Request-Queue-Duration"] = (request.DequeueTime - request.EnqueueTime).TotalMilliseconds.ToString("F3") + " ms"; - request.Context.Response.Headers["x-Total-Latency"] = (DateTime.UtcNow - request.EnqueueTime).TotalMilliseconds.ToString("F3") + " ms"; - request.Context.Response.Headers["x-ProxyHost"] = _options.HostName; - request.Context.Response.Headers["x-MID"] = request.MID; - request.Context.Response.Headers["Attempts"] = request.BackendAttempts.ToString(); - - await request.Context.Response.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(sb.ToString())).ConfigureAwait(false); - await request.Context.Response.OutputStream.FlushAsync().ConfigureAwait(false); - } - else - { - _logger.LogWarning("Cannot write error response for request {Guid} - Context: {HasContext}, AsyncTriggered: {AsyncTriggered}, AsyncWorker: {HasAsyncWorker}", - request.Guid, request.Context != null, request.AsyncTriggered, request.asyncWorker != null); - } - - } - catch (Exception e) - { - // If we can't write the response, we can only log it - _logger.LogError(e, "Error writing error response for request {Guid} - AsyncTriggered: {AsyncTriggered}", - request.Guid, request.AsyncTriggered); - } + // Write error response to client (sync HTTP) or blob (async) + await WriteExhaustedHostsErrorAsync(request, lastStatusCode, sb.ToString()).ConfigureAwait(false); return new ProxyData @@ -1499,6 +1301,253 @@ private void PopulateRequestAttemptError( requestAttempt["Message"] = message; } + /// + /// Creates a host iterator for routing requests to backend hosts. + /// Uses shared iterators (fair distribution across concurrent requests) or per-request iterators + /// based on configuration. + /// + /// A tuple of (per-request iterator, shared iterator, modified path). Exactly one iterator will be non-null. + private (IHostIterator? hostIterator, ISharedHostIterator? sharedIterator, string modifiedPath) CreateHostIterator(RequestData request) + { + string modifiedPath = ""; + IHostIterator? hostIterator = null; + ISharedHostIterator? sharedIterator = null; + + if (_options.UseSharedIterators && _sharedIteratorRegistry != null) + { + // Use shared iterator - multiple requests to same path share the same iterator + // The modifiedPath is stored on the iterator itself, so we don't need a second filtering call + sharedIterator = _sharedIteratorRegistry.GetOrCreate( + request.Path, + () => + { + var iterator = IteratorFactory.CreateSinglePassIterator( + _backends, + _options.LoadBalanceMode, + request.Path, + out var mp); + return (iterator, mp); + }); + + // Read modifiedPath from the shared iterator (computed once, cached) + modifiedPath = sharedIterator.ModifiedPath; + + _logger.LogDebug( + "[ProxyToBackEnd:{Guid}] Using SHARED iterator for path '{Path}' with {HostCount} hosts", + request.Guid, request.Path, sharedIterator.HostCount); + } + else + { + // Use per-request iterator (original behavior) + hostIterator = _options.IterationMode switch + { + IterationModeEnum.SinglePass => IteratorFactory.CreateSinglePassIterator( + _backends, + _options.LoadBalanceMode, + request.Path, + out modifiedPath), + + IterationModeEnum.MultiPass => IteratorFactory.CreateMultiPassIterator( + _backends, + _options.LoadBalanceMode, + _options.MaxAttempts, + request.Path, + out modifiedPath), + + _ => IteratorFactory.CreateSinglePassIterator( + _backends, + _options.LoadBalanceMode, + request.Path, + out modifiedPath) + }; + } + + return (hostIterator, sharedIterator, modifiedPath); + } + + /// + /// Maps an HttpRequestException to the most appropriate HTTP status code by inspecting + /// the exception's StatusCode, inner SocketException error codes, and error message text. + /// + private static HttpStatusCode ResolveHttpRequestErrorStatus(HttpRequestException e) + { + HttpStatusCode statusCode = e.StatusCode ?? HttpStatusCode.BadGateway; + + if (e.StatusCode != null) + return statusCode; + + // Infer from inner SocketException + if (e.InnerException is SocketException socketEx) + { + switch (socketEx.SocketErrorCode) + { + case SocketError.HostNotFound: + case SocketError.TryAgain: + case SocketError.NoData: + return HttpStatusCode.ServiceUnavailable; // 503 + case SocketError.TimedOut: + return HttpStatusCode.RequestTimeout; // 408 + case SocketError.ConnectionRefused: + return HttpStatusCode.BadGateway; // 502 + } + } + + // Fallback to message parsing + if (statusCode == HttpStatusCode.BadGateway) + { + if (e.Message.Contains("name or service not known", StringComparison.OrdinalIgnoreCase) || + e.Message.Contains("No such host is known", StringComparison.OrdinalIgnoreCase) || + e.Message.Contains("Temporary failure in name resolution", StringComparison.OrdinalIgnoreCase) || + e.Message.Contains("Name resolution failed", StringComparison.OrdinalIgnoreCase)) + { + return HttpStatusCode.ServiceUnavailable; // 503 + } + else if (e.Message.Contains("timed out", StringComparison.OrdinalIgnoreCase)) + { + return HttpStatusCode.RequestTimeout; // 408 + } + } + + return statusCode; + } + + /// + /// Checks whether a 429 response with the S7PREQUEUE header should trigger a requeue. + /// Copies response headers into the request attempt event and parses retry-after timing. + /// + /// (shouldRequeue: true if S7PREQUEUE="true", retryMs: delay before requeue) + private (bool shouldRequeue, int retryMs) CheckRequeueResponse( + HttpResponseMessage proxyResponse, + int intCode, + ProxyEvent requestAttempt, + ref string requestState) + { + if (intCode != 429 || !proxyResponse.Headers.TryGetValues("S7PREQUEUE", out var values)) + return (false, 0); + + requestState = "Process 429"; + + foreach (var header in proxyResponse.Headers.ToList()) + { + if (s_excludedHeaders.Contains(header.Key)) continue; + requestAttempt[header.Key] = string.Join(", ", header.Value); + } + + if (!string.Equals(values.FirstOrDefault(), "true", StringComparison.OrdinalIgnoreCase)) + return (false, 0); + + // Try retry-after-ms (milliseconds), then retry-after (seconds), default to 1000ms + int retryMs = 1000; + if (proxyResponse.Headers.TryGetValues("retry-after-ms", out var retryAfterValuesMS) && + int.TryParse(retryAfterValuesMS.FirstOrDefault(), out var retryAfterValueMS)) + { + retryMs = retryAfterValueMS; + } + else if (proxyResponse.Headers.TryGetValues("retry-after", out var retryAfterValues) && + int.TryParse(retryAfterValues.FirstOrDefault(), out var retryAfterValue)) + { + retryMs = retryAfterValue * 1000; + } + + return (true, retryMs); + } + + /// + /// Writes the error response when all backend hosts have been exhausted. + /// Routes to blob storage (for async requests) or HTTP context (for sync requests). + /// + private async Task WriteExhaustedHostsErrorAsync(RequestData request, HttpStatusCode statusCode, string errorBody) + { + try + { + if (request.AsyncTriggered && request.asyncWorker != null) + { + _logger.LogInformation("Writing error response to AsyncWorker blob for request {Guid} - Status: {StatusCode}", + request.Guid, statusCode); + + var errorHeaders = new WebHeaderCollection + { + ["x-Request-Queue-Duration"] = (request.DequeueTime - request.EnqueueTime).TotalMilliseconds.ToString("F3") + " ms", + ["x-Total-Latency"] = (DateTime.UtcNow - request.EnqueueTime).TotalMilliseconds.ToString("F3") + " ms", + ["x-ProxyHost"] = _options.HostName, + ["x-MID"] = request.MID, + ["Attempts"] = request.BackendAttempts.ToString() + }; + + await request.asyncWorker.WriteHeaders(statusCode, errorHeaders); + + var errorBytes = Encoding.UTF8.GetBytes(errorBody); + if (request.IsBackgroundCheck) + { + var outputStream = await request.asyncWorker.GetOrCreateDataStreamAsync(); + await outputStream.WriteAsync(errorBytes).ConfigureAwait(false); + await outputStream.FlushAsync().ConfigureAwait(false); + } + else if (request.OutputStream != null) + { + await request.OutputStream.WriteAsync(errorBytes).ConfigureAwait(false); + await request.OutputStream.FlushAsync().ConfigureAwait(false); + } + } + else if (!request.AsyncTriggered && request.Context != null) + { + _logger.LogInformation("Response Status Code: {StatusCode} for request {Guid}", + statusCode, request.Guid); + request.Context.Response.StatusCode = (int)statusCode; + request.Context.Response.KeepAlive = false; + + request.Context.Response.Headers["x-Request-Queue-Duration"] = (request.DequeueTime - request.EnqueueTime).TotalMilliseconds.ToString("F3") + " ms"; + request.Context.Response.Headers["x-Total-Latency"] = (DateTime.UtcNow - request.EnqueueTime).TotalMilliseconds.ToString("F3") + " ms"; + request.Context.Response.Headers["x-ProxyHost"] = _options.HostName; + request.Context.Response.Headers["x-MID"] = request.MID; + request.Context.Response.Headers["Attempts"] = request.BackendAttempts.ToString(); + + await request.Context.Response.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(errorBody)).ConfigureAwait(false); + await request.Context.Response.OutputStream.FlushAsync().ConfigureAwait(false); + } + else + { + _logger.LogWarning("Cannot write error response for request {Guid} - Context: {HasContext}, AsyncTriggered: {AsyncTriggered}, AsyncWorker: {HasAsyncWorker}", + request.Guid, request.Context != null, request.AsyncTriggered, request.asyncWorker != null); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error writing error response for request {Guid} - AsyncTriggered: {AsyncTriggered}", + request.Guid, request.AsyncTriggered); + } + } + + /// + /// Writes an error response (status code + message body) to the client's HTTP connection. + /// Consolidates the duplicated try/catch pattern used across catch blocks in TaskRunnerAsync. + /// + /// True if the response was written successfully, false if the write failed. + private async Task WriteErrorToClientAsync( + HttpListenerContext lcontext, + HttpStatusCode statusCode, + string errorMessage, + ProxyEvent eventData, + Guid? requestGuid) + { + try + { + lcontext.Response.StatusCode = (int)statusCode; + var errorBytes = Encoding.UTF8.GetBytes(errorMessage); + await lcontext.Response.OutputStream.WriteAsync(errorBytes).ConfigureAwait(false); + return true; + } + catch (Exception writeEx) + { + _logger.LogError(writeEx, "Failed to write error response for request {Guid}", requestGuid); + eventData["InnerErrorDetail"] = "Network Error sending error response"; + eventData["InnerErrorStack"] = writeEx.StackTrace?.ToString() ?? "No Stack Trace"; + eventData.Type = EventType.Exception; + eventData.Exception = writeEx; + return false; + } + } + private void PopulateTimeoutError( ProxyEvent requestAttempt, RequestData request, @@ -1684,7 +1733,7 @@ private async Task HandleBackgroundCheckResultAsync( // cts is returned to the caller who disposes of it - private (CancellationTokenSource, double) SetupAsyncWorkerAndTimeout(RequestData request) + private async Task<(CancellationTokenSource, double)> SetupAsyncWorkerAndTimeout(RequestData request) { double timeout = request.Timeout; CancellationTokenSource cts; @@ -1700,7 +1749,7 @@ private async Task HandleBackgroundCheckResultAsync( { var timeLeft = _options.AsyncTriggerTimeout - (int)(DateTime.UtcNow - request.EnqueueTime).TotalMilliseconds; timeLeft = Math.Max(1, timeLeft); - request.asyncWorker = _asyncWorkerFactory.CreateAsync(request, timeLeft); + request.asyncWorker = await _asyncWorkerFactory.CreateAsync(request, timeLeft).ConfigureAwait(false); _ = request.asyncWorker.StartAsync(); } diff --git a/src/SimpleL7Proxy/Proxy/StreamProcessor/CompleteAllUsageProcessor.cs b/src/SimpleL7Proxy/Proxy/StreamProcessor/CompleteAllUsageProcessor.cs new file mode 100644 index 00000000..572ebd58 --- /dev/null +++ b/src/SimpleL7Proxy/Proxy/StreamProcessor/CompleteAllUsageProcessor.cs @@ -0,0 +1,83 @@ + +using System.Text.Json.Nodes; +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; +using SimpleL7Proxy.Events; +using System.Text.RegularExpressions; + +namespace SimpleL7Proxy.StreamProcessor +{ + /// + /// Stream processor implementation that extracts comprehensive usage statistics + /// from JSON streaming responses, capturing all fields in the response. + /// + public class CompleteAllUsageProcessor : JsonStreamProcessor + { + + protected override int MaxLines => 100; + protected override int MinLineLength => 1; + protected override bool CaptureAllLines => true; // Capture all lines for Anthropic responses + + /// + /// Processes the last lines to extract comprehensive statistics from the JSON response. + /// Recursively extracts all fields using dot notation for nested objects. + /// + /// Array of the last significant lines from the stream. + /// The primary line to process. + /// + /// Processes the last lines to extract comprehensive statistics from the JSON response. + /// Extracts all fields using dot notation for nested objects. + /// + /// Array of the last significant lines from the stream. + /// The primary line to process. + protected override void ProcessLastLines(string[] lastLines, string primaryLine) + { + + // the usage JSON is spread over multiple lines. We need to know where it starts and end. + int startIndex = Array.IndexOf(lastLines, primaryLine); + var input = string.Join(" ", lastLines[startIndex..]); + + // Use a regex to extract the json for either usage or usageMetadata. + var jsonPattern = @"""(?:[uU]sage|[uU]sage[mM]etadata)"":\s*(\{(?:[^{}]|(?\{)|(?<-open>\}))*\}(?(open)(?!)))"; + var matches = Regex.Matches(input, jsonPattern, RegexOptions.Singleline); + int count=0; + + if (matches.Count > 0) + { + foreach (Match match in matches) + { + var jsonBlock = @"{""usage"": " + match.Groups[1].Value + @"}"; + + try + { + var jsonNode = ParseJsonLine(jsonBlock); + if (jsonNode != null) + { + count++; + ExtractAllFields(jsonNode, "Usage"); + } + } + catch (Exception ex) + { + data["ParseError"] = ex.Message; + } + } + } + } + + /// + /// Populates event data with comprehensive statistics and provides backward compatibility. + /// + protected override void PopulateEventData(ProxyEvent eventData, HttpResponseHeaders headers) + { + // Copy all captured data to the event data + foreach (var kvp in data) + { + // Convert key to PascalCase: usage.foo_bar => Usage.Foo_Bar + var convertedKey = ConvertToPascalCase(kvp.Key); + eventData[convertedKey] = kvp.Value; + } + } + + } +} \ No newline at end of file diff --git a/src/SimpleL7Proxy/Proxy/StreamProcessor/JsonStreamProcessor.cs b/src/SimpleL7Proxy/Proxy/StreamProcessor/JsonStreamProcessor.cs index 49e53d7f..e9cc4bac 100644 --- a/src/SimpleL7Proxy/Proxy/StreamProcessor/JsonStreamProcessor.cs +++ b/src/SimpleL7Proxy/Proxy/StreamProcessor/JsonStreamProcessor.cs @@ -45,6 +45,7 @@ public abstract class JsonStreamProcessor : BaseStreamProcessor protected Dictionary data = new(); protected virtual int MaxLines { get; } = 10; protected virtual int MinLineLength { get; } = 20; + protected virtual bool CaptureAllLines { get; } = false; // If true, captures all lines instead of just the last /// /// Implements the common streaming pattern used by JSON-based processors. @@ -52,8 +53,9 @@ public abstract class JsonStreamProcessor : BaseStreamProcessor public override async Task CopyToAsync(System.Net.Http.HttpContent sourceContent, Stream outputStream) { _logger?.LogDebug("Starting JSON stream processing"); - var lastLines = new string[MaxLines]; // Fixed array for last 6 lines - int currentIndex = 0; // Current write position + var allLines = CaptureAllLines ? new List() : null; // Unbounded list for full capture + var lastLines = CaptureAllLines ? null : new string[MaxLines]; // Fixed circular buffer for bounded capture + int currentIndex = 0; // Current write position (circular buffer only) int lineCount = 0; // Total lines written try @@ -74,12 +76,17 @@ public override async Task CopyToAsync(System.Net.Http.HttpContent sourceContent Task t = writer.WriteLineAsync(currentLine); // Only process through lines that could have usage in them - if (currentLine.Length > MinLineLength ) + if (CaptureAllLines) { - lastLines[currentIndex] = currentLine; + allLines!.Add(currentLine); + lineCount++; + } + else if (currentLine.Length > MinLineLength) + { + lastLines![currentIndex] = currentLine; currentIndex = (currentIndex + 1) % MaxLines; // Wrap around lineCount++; - } + } await t.ConfigureAwait(false); } @@ -88,7 +95,7 @@ public override async Task CopyToAsync(System.Net.Http.HttpContent sourceContent } catch (IOException e) { - _logger?.LogDebug("IOException during stream processing: {Message}", e.Message); + _logger?.LogError("IOException during stream processing: {Message}", e.Message); if (!ShouldIgnoreException(e)) { data["LastError"] = e.Message; @@ -118,18 +125,23 @@ public override async Task CopyToAsync(System.Net.Http.HttpContent sourceContent try { - // Walk through lines to find the one with usage data - // copy from currentIndex to the end into the buffer - var validLines = new string[Math.Min(lineCount, MaxLines)]; + // Build validLines from either the unbounded list or the circular buffer + string[] validLines; - if (lineCount >= MaxLines) + if (CaptureAllLines) + { + validLines = allLines!.ToArray(); + } + else if (lineCount >= MaxLines) { - Array.Copy(lastLines, currentIndex, validLines, 0, MaxLines - currentIndex); - Array.Copy(lastLines, 0, validLines, MaxLines - currentIndex, currentIndex); + validLines = new string[MaxLines]; + Array.Copy(lastLines!, currentIndex, validLines, 0, MaxLines - currentIndex); + Array.Copy(lastLines!, 0, validLines, MaxLines - currentIndex, currentIndex); } else { - Array.Copy(lastLines, 0, validLines, 0, lineCount); + validLines = new string[lineCount]; + Array.Copy(lastLines!, 0, validLines, 0, lineCount); } string? usageLine = null; diff --git a/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs b/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs index d1faef96..7a353705 100644 --- a/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs +++ b/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs @@ -22,13 +22,14 @@ public sealed class StreamProcessorFactory ["OpenAI"] = static () => new OpenAIProcessor(), ["AllUsage"] = static () => new AllUsageProcessor(), ["DefaultStream"] = static () => DefaultStreamProcessorInstance, // Reuse singleton - ["MultiLineAllUsage"] = static () => new MultiLineAllUsageProcessor() + ["MultiLineAllUsage"] = static () => new MultiLineAllUsageProcessor(), + ["AllUsage-2"] = static () => new CompleteAllUsageProcessor() }; // Constants for processor selection logic private const string DEFAULT_PROCESSOR = "Default"; private const string STREAM_PROCESSOR = "DefaultStream"; - private const string PROCESSOR_SUFFIX = "Processor"; + private static readonly string[] PROCESSOR_SUFFIXES = ["Processor", "Parser"]; private const string TOKEN_PROCESSOR_HEADER = "TOKENPROCESSOR"; private const string EVENT_STREAM_MEDIA = "text/event-stream"; @@ -63,7 +64,7 @@ public static string DetermineStreamProcessor(HttpResponseMessage proxyResponse, var processor = proxyResponse.StatusCode == HttpStatusCode.OK && proxyResponse.Headers.TryGetValues(TOKEN_PROCESSOR_HEADER, out var values) && values.FirstOrDefault()?.Trim() is { Length: > 0 } headerValue - ? StripProcessorSuffix(headerValue, PROCESSOR_SUFFIX) + ? StripProcessorSuffix(headerValue) : DEFAULT_PROCESSOR; // Use pattern matching for cleaner logic @@ -87,7 +88,7 @@ public IStreamProcessor GetStreamProcessor(string processorName, out string reso { if (!ProcessorFactories.TryGetValue(processorName, out var factory)) { - _logger.LogDebug("Unknown processor requested: {Requested}. Falling back to default.", processorName); + _logger.LogError("Unknown processor requested: {Requested}. Falling back to default.", processorName); factory = ProcessorFactories[STREAM_PROCESSOR]; resolvedProcessorName = STREAM_PROCESSOR; } @@ -110,11 +111,19 @@ public IStreamProcessor GetStreamProcessor(string processorName, out string reso } /// - /// Strips the "Processor" suffix from a processor name if present. - /// Example: "OpenAIProcessor" -> "OpenAI" + /// Strips known suffixes from a processor name if present. + /// Examples: "OpenAIProcessor" -> "OpenAI", "OpenAIParser" -> "OpenAI" /// - private static string StripProcessorSuffix(string value, string suffix) - => value.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) - ? value[..^suffix.Length] - : value; + private static string StripProcessorSuffix(string value) + { + foreach (var suffix in PROCESSOR_SUFFIXES) + { + if (value.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return value[..^suffix.Length]; + } + } + + return value; + } } diff --git a/src/SimpleL7Proxy/SimpleL7Proxy.csproj b/src/SimpleL7Proxy/SimpleL7Proxy.csproj index 6f4b0209..f409485e 100644 --- a/src/SimpleL7Proxy/SimpleL7Proxy.csproj +++ b/src/SimpleL7Proxy/SimpleL7Proxy.csproj @@ -6,6 +6,7 @@ enable enable Debug;Release;test + $(DefineConstants);AZURE_APPCONFIG_FULL @@ -19,6 +20,7 @@ + diff --git a/src/SimpleL7Proxy/User/UserProfile.cs b/src/SimpleL7Proxy/User/UserProfile.cs index 5ea24b18..f44d0463 100644 --- a/src/SimpleL7Proxy/User/UserProfile.cs +++ b/src/SimpleL7Proxy/User/UserProfile.cs @@ -28,7 +28,7 @@ public class UserProfile : BackgroundService, IUserProfileService private static readonly HttpClient httpClient = new HttpClient(); // Reusable ProxyEvent for profile error logging to reduce allocations - private readonly ProxyEvent _profileErrorEvent = new ProxyEvent(8); + private readonly ProxyEvent _profileErrorEvent = new ProxyEvent(4); // Message, EntityId/ConfigUrl, EntityType, Timestamp private readonly object _profileErrorEventLock = new object(); // Special keys used to mark deleted profiles in-place diff --git a/src/SimpleL7Proxy/config.json b/src/SimpleL7Proxy/config.json index d0f7cdf9..f7e83535 100644 --- a/src/SimpleL7Proxy/config.json +++ b/src/SimpleL7Proxy/config.json @@ -16,9 +16,10 @@ "async-config": "enabled=true, containername=user123455, topic=status-12355" }, { - "userId": "123457", + "userId": "123456", "Header1": "Value1", "Header2": "Value2", - "async-config": "enabled=false" + "async-config": "enabled=false", + "AllowedPaths": "/echo*, /file/*" } ] \ No newline at end of file diff --git a/src/SimpleL7Proxy/server.cs b/src/SimpleL7Proxy/server.cs index 6eb17f8c..08e541d7 100644 --- a/src/SimpleL7Proxy/server.cs +++ b/src/SimpleL7Proxy/server.cs @@ -25,7 +25,7 @@ namespace SimpleL7Proxy; // This class represents a server that listens for HTTP requests and processes them. // It uses a priority queue to manage incoming requests and supports telemetry for monitoring. // If the incoming request has the S7PPriorityKey header, it will be assigned a priority based the S7PPriority header. -public class Server : BackgroundService +public class Server : BackgroundService, IConfigChangeSubscriber { // private readonly IBackendOptions? _options; private readonly BackendOptions _options; @@ -50,6 +50,10 @@ public class Server : BackgroundService private readonly ProbeServer _probeServer; + // Precomputed validation rules to avoid dictionary iteration and string ops per request + private readonly record struct ValidateHeaderRule(string SourceHeader, string AllowedValuesHeader, string DisplayName); + private readonly ValidateHeaderRule[] _validateHeaderRules; + // Constructor to initialize the server with backend options and telemetry client. public Server( IConcurrentPriQueue requestsQueue, @@ -63,6 +67,7 @@ public Server( IBlobWriter blobWriter, HealthCheckService healthService, ProbeServer probeServer, + ConfigChangeNotifier configChangeNotifier, ILogger logger) { ArgumentNullException.ThrowIfNull(backendOptions, nameof(backendOptions)); @@ -89,6 +94,40 @@ public Server( _priorityHeaderName = _options.PriorityKeyHeader; _probeServer = probeServer; + configChangeNotifier.Subscribe(this, + [options => options.PriorityKeyHeader, + options => options.ValidateHeaders, + // options => options.Port, COLD option, requires full restart to take effect + options => options.Timeout, + options => options.PriorityValues, + // options => options.UseProfiles, + options => options.AsyncModeEnabled, + options => options.DefaultPriority, + // options => options.IDStr, + options => options.ValidateAuthAppID, + options => options.ValidateAuthAppIDHeader, + options => options.DisallowedHeaders, + options => options.UserProfileHeader, + options => options.RequiredHeaders, + options => options.UniqueUserHeaders, + options => options.AsyncClientRequestHeader, + options => options.PriorityKeys, + options => options.TimeoutHeader, + options => options.DefaultTTLSecs, + options => options.TTLHeader, + // options => options.CircuitBreakerTimeslice, display only + options => options.MaxQueueLength, + options => options.PollInterval + ]); + + // Precompute validation header rules once at startup + _validateHeaderRules = _options.ValidateHeaders + .Select(kvp => new ValidateHeaderRule( + kvp.Key, + kvp.Value, + kvp.Key.StartsWith("S7", StringComparison.Ordinal) ? kvp.Key[2..] : kvp.Key)) + .ToArray(); + var _listeningUrl = $"http://+:{_options.Port}/"; _httpListener = new HttpListener(); @@ -105,6 +144,16 @@ public Server( _logger.LogInformation($"[CONFIG] Server configuration - Port: {_options.Port} | Timeout: {timeoutTime} | Workers: {_options.Workers}"); } + public Task OnConfigChangedAsync( + IReadOnlyList changes, + BackendOptions backendOptions, + CancellationToken cancellationToken) + { + _logger.LogInformation("[CONFIG] Server changed — Settings live updated without restart"); + // apply the changes + return Task.CompletedTask; + } + public void BeginShutdown() { _isShuttingDown = true; @@ -319,6 +368,12 @@ public async Task Run(CancellationToken cancellationToken) } rd.UserID = ""; + // Normalize path once: ensure non-empty and starts with '/' + if (string.IsNullOrEmpty(rd.Path)) + rd.Path = "/"; + else if (!rd.Path.StartsWith('/')) + rd.Path = "/" + rd.Path; + rd.Headers["S7Path"] = rd.Path; // Copy path // Lookup the user profile and add the headers to the request if (doUserProfile) { @@ -371,22 +426,41 @@ public async Task Run(CancellationToken cancellationToken) } } - // Check for any validate headers ( both fields have been checked for existance ) - if (_options.ValidateHeaders.Count > 0) + // Validate headers using precomputed rules and zero-alloc span tokenization + if (_validateHeaderRules.Length > 0) { - foreach (var header in _options.ValidateHeaders) + foreach (ref readonly var rule in _validateHeaderRules.AsSpan()) { - // Check that the header exists in the destination header - var lookup = rd.Headers[header.Key]!.Trim(); - List values = [.. rd.Headers[header.Value]!.Split(',')]; - if (!values.Contains(lookup)) + var lookup = rd.Headers[rule.SourceHeader]!.AsSpan().Trim(); + var allowedSpan = rd.Headers[rule.AllowedValuesHeader]!.AsSpan(); + bool matched = false; + + foreach (var range in allowedSpan.Split(',')) + { + var pattern = allowedSpan[range].Trim(); + if (pattern.Length > 0 && pattern[^1] == '*') + { + if (lookup.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase)) + { + matched = true; + break; + } + } + else if (lookup.Equals(pattern, StringComparison.OrdinalIgnoreCase)) + { + matched = true; + break; + } + } + + if (!matched) { if (rd.Debug) - Console.WriteLine($"Validation check failed for header: {header.Key} = {lookup}"); + Console.WriteLine($"Validation check failed for {rule.DisplayName}: {lookup}"); throw new ProxyErrorException( ProxyErrorException.ErrorType.InvalidHeader, HttpStatusCode.ExpectationFailed, - "Validation check failed for header: " + header.Key + "\n" + $"Validation check failed for {rule.DisplayName}: {lookup}\n" ); } } diff --git a/test/ProxyWorkerTests/Helpers/TestHostFactory.cs b/test/ProxyWorkerTests/Helpers/TestHostFactory.cs new file mode 100644 index 00000000..190791af --- /dev/null +++ b/test/ProxyWorkerTests/Helpers/TestHostFactory.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SimpleL7Proxy.Backend; +using SimpleL7Proxy.Config; + +namespace Tests.Helpers; + +/// +/// Creates instances with the minimum DI wiring +/// needed by . Call once +/// (typically in [ClassInitialize] or [AssemblyInitialize]) before creating hosts. +/// +public static class TestHostFactory +{ + private static bool _initialized; + private static readonly object _lock = new(); + private static IServiceProvider? _serviceProvider; + + /// + /// Bootstraps the static call that the + /// production code performs at startup. Safe to call multiple times. + /// + public static void EnsureInitialized() + { + if (_initialized) return; + lock (_lock) + { + if (_initialized) return; + + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); + services.Configure(opts => + { + opts.CircuitBreakerErrorThreshold = 100; // high threshold so CB never trips in tests + opts.CircuitBreakerTimeslice = 60; + opts.AcceptableStatusCodes = [200, 401, 403, 408, 410, 412, 417, 400]; + }); + services.AddTransient(); + + _serviceProvider = services.BuildServiceProvider(); + var logger = _serviceProvider.GetRequiredService().CreateLogger("TestHostFactory"); + HostConfig.Initialize(null!, logger, _serviceProvider); + _initialized = true; + } + } + + /// + /// Creates a (always-healthy) backed by a + /// direct-mode . + /// + /// + /// Fully-qualified host, e.g. "https://host-a.example.com", + /// or extended config format: "host=https://host-a.example.com;mode=direct;path=/openai/*". + /// + public static NonProbeableHostHealth CreateHost(string hostname) + { + EnsureInitialized(); + var logger = _serviceProvider!.GetRequiredService().CreateLogger("TestHost"); + // Use direct-mode by default so no probe URL is needed + var configStr = hostname.Contains(';') ? hostname : $"host={hostname};mode=direct"; + var config = new HostConfig(configStr); + return new NonProbeableHostHealth(config, logger); + } + + /// + /// Convenience: creates a list of N direct-mode catch-all hosts named host-0 … host-(n-1). + /// + public static List CreateHosts(int count, string? pathOverride = null) + { + EnsureInitialized(); + var hosts = new List(count); + for (int i = 0; i < count; i++) + { + var pathPart = pathOverride != null ? $";path={pathOverride}" : ""; + hosts.Add(CreateHost($"host=https://host-{i}.example.com;mode=direct{pathPart}")); + } + return hosts; + } +} diff --git a/test/ProxyWorkerTests/Iterators/RoundRobinIteratorTests.cs b/test/ProxyWorkerTests/Iterators/RoundRobinIteratorTests.cs new file mode 100644 index 00000000..ba9166aa --- /dev/null +++ b/test/ProxyWorkerTests/Iterators/RoundRobinIteratorTests.cs @@ -0,0 +1,267 @@ +using SimpleL7Proxy.Backend; +using SimpleL7Proxy.Backend.Iterators; +using Tests.Helpers; + +namespace Tests.Iterators; + +[TestClass] +public class RoundRobinIteratorTests +{ + [ClassInitialize] + public static void ClassInit(TestContext _) => TestHostFactory.EnsureInitialized(); + + // ────────────────────────────────────────────────────────────── + // Basic Distribution + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void SinglePass_VisitsEveryHostExactlyOnce() + { + // Arrange + var hosts = TestHostFactory.CreateHosts(3); + var iterator = new RoundRobinHostIterator(hosts, IterationModeEnum.SinglePass, maxAttempts: 1); + + // Act + var visited = Drain(iterator); + + // Assert — all 3 hosts visited, no duplicates + Assert.AreEqual(3, visited.Count, "Should visit every host exactly once in SinglePass."); + CollectionAssert.AreEquivalent( + hosts.Select(h => h.Host).ToList(), + visited.Select(h => h.Host).ToList(), + "Every host should be visited."); + } + + [TestMethod] + public void EvenDistribution_AcrossMultipleIterators() + { + // Arrange — 3 hosts, 30 sequential iterators each doing SinglePass + var hosts = TestHostFactory.CreateHosts(3); + var hitCounts = new Dictionary(); + foreach (var h in hosts) hitCounts[h.Host] = 0; + + // Act — each iterator gets one host via the global counter + for (int i = 0; i < 30; i++) + { + var iterator = new RoundRobinHostIterator(hosts, IterationModeEnum.SinglePass, maxAttempts: 1); + if (iterator.MoveNext()) + { + hitCounts[iterator.Current.Host]++; + } + } + + // Assert — each host should be hit 10 times (30 / 3) + foreach (var kvp in hitCounts) + { + Assert.AreEqual(10, kvp.Value, + $"Host {kvp.Key} should receive exactly 10 of 30 requests. Got {kvp.Value}."); + } + } + + [TestMethod] + public void GlobalCounter_DistributesAcrossIndependentIterators() + { + // Arrange + var hosts = TestHostFactory.CreateHosts(4); + var selectedHosts = new List(); + + // Act — create 8 separate iterators, take first host from each + for (int i = 0; i < 8; i++) + { + var it = new RoundRobinHostIterator(hosts, IterationModeEnum.SinglePass, maxAttempts: 1); + Assert.IsTrue(it.MoveNext()); + selectedHosts.Add(it.Current.Host); + } + + // Assert — should cycle through all 4 hosts twice: 0,1,2,3,0,1,2,3 + for (int i = 0; i < selectedHosts.Count; i++) + { + Assert.AreEqual(hosts[((i + 1) % 4)].Host, selectedHosts[i], + $"Request {i} should hit host index {(i + 1) % 4} but hit {selectedHosts[i]}."); + } + } + + // ────────────────────────────────────────────────────────────── + // Edge Cases + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void EmptyHostList_MoveNextReturnsFalse() + { + var iterator = new RoundRobinHostIterator( + new List(), IterationModeEnum.SinglePass, maxAttempts: 1); + + Assert.IsFalse(iterator.MoveNext(), "MoveNext on empty list should return false."); + } + + [TestMethod] + public void SingleHost_AlwaysReturnsSameHost() + { + var hosts = TestHostFactory.CreateHosts(1); + var iterator = new RoundRobinHostIterator(hosts, IterationModeEnum.SinglePass, maxAttempts: 1); + + Assert.IsTrue(iterator.MoveNext()); + Assert.AreEqual(hosts[0].Host, iterator.Current.Host); + // SinglePass with 1 host: second MoveNext should return false + Assert.IsFalse(iterator.MoveNext(), "Should stop after visiting the only host."); + } + + [TestMethod] + public void SinglePass_DoesNotExceedHostCount() + { + var hosts = TestHostFactory.CreateHosts(3); + var iterator = new RoundRobinHostIterator(hosts, IterationModeEnum.SinglePass, maxAttempts: 1); + + int count = 0; + while (iterator.MoveNext()) count++; + + Assert.AreEqual(3, count, "SinglePass should yield exactly hostCount elements."); + } + + // ────────────────────────────────────────────────────────────── + // MultiPass Mode + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void MultiPass_RespectsMaxAttempts() + { + var hosts = TestHostFactory.CreateHosts(3); + int maxAttempts = 7; + var iterator = new RoundRobinHostIterator(hosts, IterationModeEnum.MultiPass, maxAttempts); + + int count = 0; + while (iterator.MoveNext()) count++; + + Assert.IsTrue(count <= maxAttempts, + $"MultiPass should not exceed maxAttempts ({maxAttempts}). Got {count}."); + Assert.IsTrue(count >= hosts.Count, + $"MultiPass should visit at least all hosts once ({hosts.Count}). Got {count}."); + } + + [TestMethod] + public void MultiPass_CyclesThroughHostsMultipleTimes() + { + var hosts = TestHostFactory.CreateHosts(2); + int maxAttempts = 6; + var iterator = new RoundRobinHostIterator(hosts, IterationModeEnum.MultiPass, maxAttempts); + + var visited = Drain(iterator); + + // With 2 hosts and 6 attempts, both hosts should appear multiple times + Assert.IsTrue(visited.Count > 2, + "MultiPass with maxAttempts=6 and 2 hosts should visit more than 2 hosts total."); + } + + // ────────────────────────────────────────────────────────────── + // HostCount Property + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void HostCount_ReflectsActualHostListSize() + { + var hosts = TestHostFactory.CreateHosts(5); + var iterator = new RoundRobinHostIterator(hosts, IterationModeEnum.SinglePass, maxAttempts: 1); + + Assert.AreEqual(5, iterator.HostCount); + } + + [TestMethod] + public void HostCount_ZeroForEmptyList() + { + var iterator = new RoundRobinHostIterator( + new List(), IterationModeEnum.SinglePass, maxAttempts: 1); + + Assert.AreEqual(0, iterator.HostCount); + } + + // ────────────────────────────────────────────────────────────── + // Concurrency + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void ConcurrentIterators_NoHostMissedOrDuplicated() + { + // Arrange — 4 hosts, 100 parallel iterators each taking 1 host + var hosts = TestHostFactory.CreateHosts(4); + var bag = new System.Collections.Concurrent.ConcurrentBag(); + int totalRequests = 100; + + // Act + Parallel.For(0, totalRequests, _ => + { + var it = new RoundRobinHostIterator(hosts, IterationModeEnum.SinglePass, maxAttempts: 1); + if (it.MoveNext()) + { + bag.Add(it.Current.Host); + } + }); + + // Assert — every host should be selected, distribution should be roughly even + Assert.AreEqual(totalRequests, bag.Count, "Every request should select a host."); + var grouped = bag.GroupBy(h => h).ToDictionary(g => g.Key, g => g.Count()); + Assert.AreEqual(4, grouped.Count, "All 4 hosts should appear."); + foreach (var kvp in grouped) + { + Assert.AreEqual(25, kvp.Value, + $"Host {kvp.Key} expected 25 hits out of 100, got {kvp.Value}."); + } + } + + [TestMethod] + public void ConcurrentDrain_AllHostsVisitedInEachIterator() + { + // Arrange — multiple concurrent iterators, each fully drained + var hosts = TestHostFactory.CreateHosts(3); + int parallelism = 50; + var errors = new System.Collections.Concurrent.ConcurrentBag(); + + // Act + Parallel.For(0, parallelism, i => + { + var it = new RoundRobinHostIterator(hosts, IterationModeEnum.SinglePass, maxAttempts: 1); + var visited = Drain(it); + if (visited.Count != 3) + { + errors.Add($"Iterator {i}: expected 3 hosts, got {visited.Count}"); + } + }); + + // Assert + Assert.AreEqual(0, errors.Count, + $"Concurrent drain failures:\n{string.Join("\n", errors)}"); + } + + // ────────────────────────────────────────────────────────────── + // Reset + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void Reset_AllowsReIteration() + { + var hosts = TestHostFactory.CreateHosts(3); + var iterator = new RoundRobinHostIterator(hosts, IterationModeEnum.SinglePass, maxAttempts: 1); + + // Drain fully + while (iterator.MoveNext()) { } + + // Reset and drain again + iterator.Reset(); + var visited = Drain(iterator); + + Assert.AreEqual(3, visited.Count, "After Reset, should visit all hosts again."); + } + + // ────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────── + + private static List Drain(RoundRobinHostIterator iterator) + { + var result = new List(); + while (iterator.MoveNext()) + { + result.Add(iterator.Current); + } + return result; + } +} diff --git a/test/generator/generator_one/Server.cs b/test/generator/generator_one/Server.cs index 11847107..779e7d84 100644 --- a/test/generator/generator_one/Server.cs +++ b/test/generator/generator_one/Server.cs @@ -336,7 +336,7 @@ private async Task RunTest(CancellationToken cancellationToken, string test_endp foreach (var test in allTests) { var m = new HttpRequestMessage(test.Method, test_endpoint + test.Path); - CloneHttpRequestMessage(m, test.request); + await CloneHttpRequestMessageAsync(m, test.request); lock (_lock) { @@ -600,12 +600,12 @@ private void prepareTests(string test_endpoint) } - private void CloneHttpRequestMessage(HttpRequestMessage clone, HttpRequestMessage request) + private async Task CloneHttpRequestMessageAsync(HttpRequestMessage clone, HttpRequestMessage request) { // Copy the content if (request.Content != null) { - clone.Content = new ByteArrayContent(request.Content.ReadAsByteArrayAsync().Result); + clone.Content = new ByteArrayContent(await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false)); foreach (var header in request.Content.Headers) { clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); diff --git a/test/nullserver/Python/anthropoc-claude-sonnet-4.txt b/test/nullserver/Python/anthropoc-claude-sonnet-4.txt new file mode 100644 index 00000000..4a7e5c7f --- /dev/null +++ b/test/nullserver/Python/anthropoc-claude-sonnet-4.txt @@ -0,0 +1,51 @@ +event: message_start +data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_vrtx_01RBJnwkNyyAeeTuhWHfnqRq","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1}}} + +event: ping +data: {"type": "ping"} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Here"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s a haiku about databases:"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\n\nSilent"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" rows"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" of"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" data\nWaiting"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" for the perfect"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" query—"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\nKnowledge"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" awak"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ens"} } + +event: content_block_stop +data: {"type":"content_block_stop","index":0 } + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":28} } + +event: message_stop +data: {"type":"message_stop" } + diff --git a/test/nullserver/Python/aoai2.txt b/test/nullserver/Python/aoai2.txt new file mode 100644 index 00000000..4d28ba89 --- /dev/null +++ b/test/nullserver/Python/aoai2.txt @@ -0,0 +1,57 @@ +data: {"choices":[],"created":0,"id":"","model":"","object":"","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{}}]} + +data: {"choices":[{"content_filter_results":{},"delta":{"content":"","refusal":null,"role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"79GGFQ","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":"Why"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"0xIvB","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" did"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"Nkem","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" the"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"tsN1","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" AI"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"rfEqC","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" go"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"3S3CF","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" to"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"2UFT7","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" school"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"U","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":"?\n\n"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"A4q","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":"To"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"W3qBXT","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" upgrade"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" its"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"bBmN","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" neural"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"I","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" network"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" and"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"ltYd","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" finally"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" get"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"oiwO","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" some"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"9W8","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" \""},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"bCtKE","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":"byte"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"7bR6","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":"-sized"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"89","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":"\""},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"O4gCrF","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":" education"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"Cs6uyzmRFMpZIj","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{"protected_material_code":{"detected":false,"filtered":false}},"delta":{"content":"!"},"finish_reason":null,"index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"D8jf6Oh","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[{"content_filter_results":{},"delta":{},"finish_reason":"stop","index":0,"logprobs":null}],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"mt","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":null} + +data: {"choices":[],"created":1772722442,"id":"chatcmpl-DG4NOH7Y4MCmyiBN9hdAyzX4H4QTr","model":"gpt-4.1-2025-04-14","obfuscation":"rLEaUJD","object":"chat.completion.chunk","system_fingerprint":"fp_e05894342c","usage":{"completion_tokens":24,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens":26,"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"total_tokens":50}} + +data: [DONE] + + \ No newline at end of file diff --git a/test/nullserver/Python/gemini-2.5-flash-lite.txt b/test/nullserver/Python/gemini-2.5-flash-lite.txt new file mode 100644 index 00000000..981fb8d6 --- /dev/null +++ b/test/nullserver/Python/gemini-2.5-flash-lite.txt @@ -0,0 +1,667 @@ +[{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": "The" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " story of Earth" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " is a tale of unimaginable length, a symphony played out over billions of years," + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " marked by cataclysms and quiet evolutions, by the birth of life and the" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " rise and fall of empires. It begins not with a bang, but with a slow, fiery genesis.\n\n**The Primordial Ooze: A Violent Birth" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": "**\n\nBillions of years ago, in the nascent solar system, a swirling cloud of gas and dust coalesced. Gravity, the silent conductor, pulled these particles together," + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " forming a molten sphere. This was proto-Earth, a hellish landscape bathed in the harsh radiation of a young, volatile Sun. Asteroids and comets, the cosmic debris of creation, rained down incessantly, shaping its surface with impact" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " craters and delivering volatile compounds – the very ingredients for life.\n\nVolcanic activity was rampant, spewing gases that would eventually form an atmosphere, thin and toxic at first, composed of methane, ammonia, and water vapor. The surface was" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " a chaotic dance of molten rock and searing heat. There was no water, no air as we know it. It was a world of fire and fury.\n\nThen, a profound change began. As the Earth cooled, water vapor, released by countless volcanic eruptions, began to condense. Gigantic, planet-spanning storms raged" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": ", and over eons, oceans formed. These primordial oceans were warm, perhaps even scalding in places, rich in dissolved minerals leached from the young crust.\n\n**The Spark of Life: A Whisper in the Deep**\n\nWithin these vast, mineral-laden oceans, something miraculous stirred. In the hydrothermal vents that dotted" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " the ocean floor, where superheated water mixed with dissolved chemicals, the building blocks of life – amino acids and nucleotides – began to self-assemble. It was a slow, painstaking process, guided by the fundamental laws of chemistry. Over hundreds of millions of years, these complex molecules organized themselves, forming self-replicating entities" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": ", the very first primitive cells.\n\nThese were not the complex organisms we see today. They were single-celled, microscopic, and seemingly insignificant. They were bacteria and archaea, the pioneers of life. For an immeasurable stretch of time, they were the only inhabitants of Earth, a vast, silent kingdom beneath" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " the waves. They fed on the chemical energy available in their environment, multiplying and adapting.\n\n**The Oxygen Revolution: A Breath of Fresh Air**\n\nA pivotal moment in Earth’s history arrived with the evolution of cyanobacteria. These remarkable organisms discovered a new energy source: sunlight. Through photosynthesis, they began to convert carbon dioxide" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " and water into energy, releasing a waste product that would fundamentally alter the planet: oxygen.\n\nInitially, this oxygen was a poison to the anaerobic life that dominated Earth. But life, ever resilient, adapted. Some organisms evolved to tolerate oxygen, and others even learned to use it for respiration, a far more efficient way" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " to generate energy. Over millions of years, the oceans became saturated with oxygen, and eventually, it began to escape into the atmosphere.\n\nThe Great Oxygenation Event, as it's known, was a planet-altering transformation. It wiped out vast swathes of existing life but paved the way for a new era" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " of complexity. The atmosphere slowly changed, becoming the breathable envelope we know today.\n\n**The Cambrian Explosion: A Burst of Biodiversity**\n\nWith a more stable atmosphere and a richer oxygen supply, life began to diversify at an astonishing rate. The Cambrian Explosion, a period of rapid evolutionary innovation, saw the emergence of most" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " of the major animal phyla that exist today. Creatures with shells, skeletons, and complex sensory organs appeared. The oceans teemed with life: trilobites scuttled across the seafloor, strange jellyfish pulsed in the water, and early ancestors of fish began to swim.\n\nLife was no longer confined to single cells" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": ". It was becoming multicellular, complex, and diverse. This was a period of intense competition and adaptation, with new species evolving and existing ones disappearing, a constant cycle of birth and extinction.\n\n**The Land Beckons: A New Frontier**\n\nFor hundreds of millions of years, life remained predominantly aquatic. But the" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " land, exposed to sunlight and rich in minerals, was a tempting frontier. Around 400 million years ago, plants, the descendants of ancient algae, began to venture onto dry land. They developed roots to anchor themselves and absorb water, and vascular systems to transport nutrients.\n\nThe arrival of plants on land was" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " a game-changer. They stabilized the soil, prevented erosion, and began to release even more oxygen into the atmosphere. This, in turn, created new niches for animals. Insects were among the first to colonize the land, followed by amphibians, which evolved to breathe air and lay eggs on land.\n\n**The Reign" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " of Giants: Dinosaurs and Mammals**\n\nThe Mesozoic Era, often called the Age of Reptiles, was dominated by the dinosaurs. These magnificent creatures evolved into an incredible array of forms, from the colossal sauropods to the swift, predatory theropods. They ruled the land, the air, and even" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " the shallow seas for over 150 million years.\n\nBut evolution rarely stands still. As dinosaurs thrived, small, furry mammals scurried in the shadows, adapting to the same environments. They were nocturnal, opportunists, waiting for their chance.\n\nThat chance came in the form of a catastrophic asteroid impact. Around" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " 66 million years ago, a celestial body, miles wide, slammed into the Yucatan Peninsula. The impact triggered massive earthquakes, tsunamis, and widespread volcanic activity. A thick cloud of dust and debris choked the atmosphere, blocking out the sun and plunging the Earth into a prolonged period of darkness and cold.\n\n" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": "This K-Pg extinction event was devastating. It wiped out approximately 75% of all species on Earth, including all non-avian dinosaurs. But for the mammals, it was a golden opportunity. With the apex predators gone, they were free to diversify and occupy the vacated ecological niches.\n\n**The Rise of" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " Mammalian Dominance and the Emergence of Us**\n\nThe Cenozoic Era, the Age of Mammals, saw the rapid evolution and diversification of mammalian species. Horses, whales, elephants, and primates all emerged and adapted to the changing landscapes.\n\nWithin the primate lineage, a remarkable group began to evolve in" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " Africa. These were our ancestors, the hominins. They walked upright, freeing their hands for tool use, and their brains began to grow larger and more complex. They learned to control fire, to communicate through language, and to cooperate in increasingly sophisticated ways.\n\nThe story of Homo sapiens is a relatively recent chapter in Earth’" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": "s long history. We emerged from Africa and, through our intelligence, adaptability, and social structures, spread across the globe. We learned to cultivate crops, domesticate animals, build cities, and develop complex societies. We harnessed the power of the atom and ventured into space.\n\n**The Present and the Future: A Delicate" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " Balance**\n\nToday, Earth is a planet shaped by billions of years of geological, chemical, and biological processes, and now, profoundly by the actions of a single species. We have achieved feats unimaginable to our ancestors, but we also face unprecedented challenges. Climate change, biodiversity loss, and resource depletion are stark reminders of our impact" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": " on the planet.\n\nThe story of Earth is far from over. It is a continuous narrative, a tapestry woven with the threads of life and the enduring forces of nature. Whether the next chapter will be one of continued progress and harmonious coexistence or one of ecological decline remains to be written.\n\nThe Earth, in its vast" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": "ness and resilience, has witnessed the rise and fall of continents, the ebb and flow of ice ages, and the evolution of life in its myriad forms. It is a testament to the power of time, the tenacity of life, and the intricate, interconnected web of existence. Our story, as a species on this planet" + } + ] + } + } + ], + "usageMetadata": { + "trafficType": "ON_DEMAND" + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +, +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "text": ", is just a fleeting moment in its grand, unfolding saga. And as we look out at the stars, we must remember the precious, unique world that gave us life, a world whose story is still being written, and whose future, in many ways, rests in our hands." + } + ] + }, + "finishReason": "STOP" + } + ], + "usageMetadata": { + "promptTokenCount": 7, + "candidatesTokenCount": 1684, + "totalTokenCount": 1691, + "trafficType": "ON_DEMAND", + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 7 + } + ], + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 1684 + } + ] + }, + "modelVersion": "gemini-2.5-flash-lite", + "createTime": "2026-03-05T15:05:59.845467Z", + "responseId": "15upaZvNM8XWodAP7LPtgAg" +} +] diff --git a/test/nullserver/Python/stream_server.py b/test/nullserver/Python/stream_server.py index 0d94c614..c0df794b 100644 --- a/test/nullserver/Python/stream_server.py +++ b/test/nullserver/Python/stream_server.py @@ -48,6 +48,14 @@ def do_GET(self): for header, value in self.headers.items(): if header == "Authorization": self.gotAuth = (len(value) > 1) and "yes" or "no" + + # Example: /status-0123456789abcdef endpoint + if parsed_path.path == '/status-0123456789abcdef': + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"OK") + return # Example: /health endpoint if parsed_path.path == '/health': @@ -172,9 +180,10 @@ def do_GET(self): self.wfile.write(b"File not found") return + processor = self.headers.get('X-TokenProcessor', 'MultiLineAllUsage') sleep_time = random.uniform(.4, .7) # Random sleep time time.sleep(sleep_time) - self.send_streaming_response(filename, "MultiLineAllUsage") + self.send_streaming_response(filename, processor) return # Default response @@ -194,11 +203,14 @@ def do_GET(self): time.sleep(1) # Stream file contents line by line with a 1-second delay - self.stream_file_contents("stream_data.txt") + try: + self.stream_file_contents("stream_data.txt") - # Send the zero-length chunk to indicate the end of the response - self.wfile.write(b"0\r\n\r\n") - self.wfile.flush() + # Send the zero-length chunk to indicate the end of the response + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + except BrokenPipeError: + print(f"Client disconnected during streaming for {parsed_path.path}") def extract_request_headers(self): request_sequence = self.headers.get('x-Request-Sequence', 'N/A') diff --git a/test/openai/call-proxy.sh b/test/openai/call-proxy.sh index 96d5cf50..a7f7fd5f 100644 --- a/test/openai/call-proxy.sh +++ b/test/openai/call-proxy.sh @@ -23,15 +23,17 @@ HOSTMAP["nvm2"]="nvm2.openai.azure.com|openai|" HOSTMAP["lopenai"]="localhost:8000|openai|" HOSTMAP["foundry"]="localhost:8000|aif2/openai|" HOSTMAP["null"]="localhost:3000||" -HOSTMAP["aca"]="simplel7dev.agreeableisland-74a4ba5f.eastus.azurecontainerapps.io|" -HOSTMAP["aca-resp"]="simplel7dev.agreeableisland-74a4ba5f.eastus.azurecontainerapps.io|resp" +HOSTMAP["aca"]="nvm2-tc26.purpledesert-d46de6cb.eastus.azurecontainerapps.io|aif3/openai|" +HOSTMAP["aca-resp"]="nvm2-tc26.purpledesert-d46de6cb.eastus.azurecontainerapps.io|resp" +HOSTMAP["local-demo1"]="localhost:8000|aif3/openai|" +HOSTMAP["local-demo2"]="localhost:8000|resp|" # Add more host aliases as needed: # HOSTMAP["nvmtr3"]="nvmtr3apim.azure-api.net|somefolder|custom-api-key" # Map request types to HTTP method and partial URLs (format: "METHOD /url") declare -A URLS -URLS["4.0chat"]="POST /deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview" +URLS["4.0chat"]="POST /v1/chat/completions?api-version=2024-05-01-preview" URLS["4.1chat"]="POST /deployments/gpt-4.1/chat/completions?api-version=2025-01-01-preview" URLS["4.1request"]="POST /v1/responses" URLS["4.1response"]="GET /v1/responses" @@ -76,7 +78,7 @@ debugmode="false" expiredelta="900" response_id="" custom_apikey="" -show_timestamps="true" +show_timestamps="false" args=() # Process arguments to extract flags and positional parameters @@ -211,7 +213,7 @@ echo "----------------------------------------" # Execute curl with appropriate method curl_cmd=( - curl -X "$http_method" "$fullurl" + curl -i -X "$http_method" "$fullurl" -H "Content-Type: application/json; charset=UTF-8" -H "Ocp-Apim-Subscription-Key: $APIMKEY" -H "S7PTTL: $expiredelta" @@ -253,4 +255,4 @@ else else "${curl_cmd[@]}" fi -fi< \ No newline at end of file +fi \ No newline at end of file diff --git a/test/openai/demo1.sh b/test/openai/demo1.sh new file mode 100644 index 00000000..4c7e2ef4 --- /dev/null +++ b/test/openai/demo1.sh @@ -0,0 +1 @@ +./call-proxy.sh aca-resp request openai_call-long.json diff --git a/test/openai/demo2.sh b/test/openai/demo2.sh new file mode 100644 index 00000000..c5a17a01 --- /dev/null +++ b/test/openai/demo2.sh @@ -0,0 +1 @@ +./call-proxy.sh aca-resp request openai_call-bg-long.json -a diff --git a/test/openai/demo3.sh b/test/openai/demo3.sh new file mode 100644 index 00000000..91715622 --- /dev/null +++ b/test/openai/demo3.sh @@ -0,0 +1 @@ +./call-proxy.sh local-demo2 request openai_call-long.json diff --git a/test/openai/demo4.sh b/test/openai/demo4.sh new file mode 100644 index 00000000..1de06e03 --- /dev/null +++ b/test/openai/demo4.sh @@ -0,0 +1 @@ +./echo-2.sh diff --git a/test/openai/echo-2.sh b/test/openai/echo-2.sh new file mode 100644 index 00000000..5fe92fdd --- /dev/null +++ b/test/openai/echo-2.sh @@ -0,0 +1 @@ +curl -H "test: x" -H "xx: Value1" -H "x-userprofile: 123456" http://localhost:8000/echo/resource?param1=sample1 -H "AsyncMode: true" diff --git a/test/openai/echo-looper.sh b/test/openai/echo-looper.sh index 8db1974b..41bdc93e 100644 --- a/test/openai/echo-looper.sh +++ b/test/openai/echo-looper.sh @@ -1 +1 @@ -for i in {1..100}; do ./call-echo.sh ; done +for i in {1..100}; do ./echo-2.sh ; done diff --git a/test/openai/echo2.sh b/test/openai/echo2.sh new file mode 100644 index 00000000..5fe92fdd --- /dev/null +++ b/test/openai/echo2.sh @@ -0,0 +1 @@ +curl -H "test: x" -H "xx: Value1" -H "x-userprofile: 123456" http://localhost:8000/echo/resource?param1=sample1 -H "AsyncMode: true" diff --git a/test/openai/openai_call-long.json b/test/openai/openai_call-long.json new file mode 100644 index 00000000..317f3b0f --- /dev/null +++ b/test/openai/openai_call-long.json @@ -0,0 +1,10 @@ +{ + "input": [ + { + "role": "user", + "content": "I am going to delhi; what should I see? tell me about all the landmarks and why they are important historically. With each story, also tell me a timeline of all the main characters and how they each died. Make sure to include the any details help to unsderstand the backstory. Conclude with a detailed time estimate the amount of time I would spend doing these activities, taking into account the the bus schedules for the trip. Include the necessary details for the trip changes for each day of the week. Also give a recommendation to stay at a hotel for my starting point considering that I am a vegetarian. given response should be filled with no less than 20 details per location. Do not summarize. instead expond each fact so that it is easy to understand and plan with. Make four responses, the second response should be in markup while the first is in text. the third and fourth responses should redo the entire process for visiting a beach city in France. finally, dont reverse every sentence but ensure readability. " + } + ], + "background": false, + "model": "gpt-4.1" +} diff --git a/test/openai/openai_call2.json b/test/openai/openai_call2.json index f893825f..d1f1151f 100644 --- a/test/openai/openai_call2.json +++ b/test/openai/openai_call2.json @@ -1,5 +1,5 @@ { "messages": [{"role": "system", "content": "You are an helpful assistant."}, {"role": "user", "content": "What are 3 things to visit in Seattle?"}], "max_tokens": 1000, - "model": "d1" + "model": "gpt-4o" }