From f4a83267f0c15ed6504950015dbc360755939db9 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 17:40:28 -0800 Subject: [PATCH 01/46] make the delimiter configurable for validate headers --- .../Config/BackendHostConfigurationExtensions.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs index 6ac86515..29ec904d 100644 --- a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs @@ -206,20 +206,20 @@ private static Dictionary KVIntPairs(List list) } // Converts a List to a dictionary of strings. - private static Dictionary KVStringPairs(List list) + private static Dictionary KVStringPairs(List list, char delimiter = '=') { Dictionary keyValuePairs = []; foreach (var item in list) { - var kvp = item.Split('='); + var kvp = item.Split(delimiter); 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"); + Console.WriteLine($"Could not parse {item} as a key-value pair (delimiter='{delimiter}'), ignoring"); } } @@ -639,7 +639,7 @@ private static BackendOptions LoadBackendOptions() ValidateAuthAppID = ReadEnvironmentVariableOrDefault("ValidateAuthAppID", false), ValidateAuthAppIDHeader = ReadEnvironmentVariableOrDefault("ValidateAuthAppIDHeader", "X-MS-CLIENT-PRINCIPAL-ID"), ValidateAuthAppIDUrl = ReadEnvironmentVariableOrDefault("ValidateAuthAppIDUrl", "file:auth.json"), - ValidateHeaders = KVStringPairs(ToListOfString(ReadEnvironmentVariableOrDefault("ValidateHeaders", ""))), + ValidateHeaders = KVStringPairs(ToListOfString(ReadEnvironmentVariableOrDefault("ValidateHeaders", "")), ':'), Workers = ReadEnvironmentVariableOrDefault("Workers", 10), }; From e3e4d7939a2815d69a8dcfc5e4b5f576dca4d7f0 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 17:42:37 -0800 Subject: [PATCH 02/46] remove unused starttimer code --- src/SimpleL7Proxy/Events/AppInsightsEventClient.cs | 6 ------ src/SimpleL7Proxy/Events/CompositeEventClient.cs | 12 ------------ src/SimpleL7Proxy/Events/IEventClient.cs | 1 - 3 files changed, 19 deletions(-) diff --git a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs index 9dd47bbd..93a2b8e0 100644 --- a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs +++ b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs @@ -7,12 +7,6 @@ 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"; diff --git a/src/SimpleL7Proxy/Events/CompositeEventClient.cs b/src/SimpleL7Proxy/Events/CompositeEventClient.cs index 4c1c4313..f290f0e3 100644 --- a/src/SimpleL7Proxy/Events/CompositeEventClient.cs +++ b/src/SimpleL7Proxy/Events/CompositeEventClient.cs @@ -5,18 +5,6 @@ namespace SimpleL7Proxy.Events; public class CompositeEventClient(IEnumerable eventClients) : IEventClient { - - - public Task StartTimer() - { - foreach (var client in eventClients) - { - Console.WriteLine($"starting timer for {client}"); - client.StopTimer(); - } - - return Task.CompletedTask; - } public void StopTimer() { foreach (var client in eventClients) diff --git a/src/SimpleL7Proxy/Events/IEventClient.cs b/src/SimpleL7Proxy/Events/IEventClient.cs index 4fe71f5d..e2f6f8e9 100644 --- a/src/SimpleL7Proxy/Events/IEventClient.cs +++ b/src/SimpleL7Proxy/Events/IEventClient.cs @@ -6,7 +6,6 @@ public interface IEventClient { int Count { get; } string ClientType { get; } - //public Task StartTimer(); public void StopTimer(); void SendData(string? value); From 738917a2925e0ec736ebc59917ab82b6c7e43586 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 18:00:38 -0800 Subject: [PATCH 03/46] remove blocking wait, replace with async await --- src/SimpleL7Proxy/CoordinatedShutdownService.cs | 3 ++- src/SimpleL7Proxy/Events/AppInsightsEventClient.cs | 2 +- src/SimpleL7Proxy/Events/CompositeEventClient.cs | 4 ++-- src/SimpleL7Proxy/Events/EventHubClient.cs | 12 ++++++------ src/SimpleL7Proxy/Events/IEventClient.cs | 2 +- src/SimpleL7Proxy/Events/LogFileEventClient.cs | 13 ++++++------- .../Events/ServiceBus/ServiceBusRequestService.cs | 9 ++++----- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/SimpleL7Proxy/CoordinatedShutdownService.cs b/src/SimpleL7Proxy/CoordinatedShutdownService.cs index 98483e44..be8b8f8e 100644 --- a/src/SimpleL7Proxy/CoordinatedShutdownService.cs +++ b/src/SimpleL7Proxy/CoordinatedShutdownService.cs @@ -155,7 +155,8 @@ 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 diff --git a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs index 93a2b8e0..74024e87 100644 --- a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs +++ b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs @@ -7,7 +7,7 @@ namespace SimpleL7Proxy.Events; public class AppInsightsEventClient(TelemetryClient telemetryClient) : IEventClient, IHostedService { - public void StopTimer() { } + public Task StopTimerAsync() => Task.CompletedTask; public int Count => 0; public string ClientType => "AppInsights"; public void SendData(string? value) => telemetryClient.TrackEvent(value); diff --git a/src/SimpleL7Proxy/Events/CompositeEventClient.cs b/src/SimpleL7Proxy/Events/CompositeEventClient.cs index f290f0e3..ec7b2fd2 100644 --- a/src/SimpleL7Proxy/Events/CompositeEventClient.cs +++ b/src/SimpleL7Proxy/Events/CompositeEventClient.cs @@ -5,12 +5,12 @@ namespace SimpleL7Proxy.Events; public class CompositeEventClient(IEnumerable eventClients) : IEventClient { - public void StopTimer() + public async Task StopTimerAsync() { foreach (var client in eventClients) { Console.WriteLine($"Stopping timer for {client}"); - client.StopTimer(); + await client.StopTimerAsync().ConfigureAwait(false); } } public int Count diff --git a/src/SimpleL7Proxy/Events/EventHubClient.cs b/src/SimpleL7Proxy/Events/EventHubClient.cs index d495dc16..c6667438 100644 --- a/src/SimpleL7Proxy/Events/EventHubClient.cs +++ b/src/SimpleL7Proxy/Events/EventHubClient.cs @@ -93,25 +93,25 @@ public async Task StartAsync(CancellationToken cancellationToken) { } } - 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) diff --git a/src/SimpleL7Proxy/Events/IEventClient.cs b/src/SimpleL7Proxy/Events/IEventClient.cs index e2f6f8e9..3094baa1 100644 --- a/src/SimpleL7Proxy/Events/IEventClient.cs +++ b/src/SimpleL7Proxy/Events/IEventClient.cs @@ -6,7 +6,7 @@ public interface IEventClient { int Count { get; } string ClientType { get; } - public void StopTimer(); + public Task StopTimerAsync(); void SendData(string? value); // void SendData(Dictionary data); diff --git a/src/SimpleL7Proxy/Events/LogFileEventClient.cs b/src/SimpleL7Proxy/Events/LogFileEventClient.cs index 46e1676d..f88b4d03 100644 --- a/src/SimpleL7Proxy/Events/LogFileEventClient.cs +++ b/src/SimpleL7Proxy/Events/LogFileEventClient.cs @@ -54,10 +54,9 @@ public Task StartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { - StopTimer(); - return Task.CompletedTask; + await StopTimerAsync().ConfigureAwait(false); } @@ -120,21 +119,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; } 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 From bcad7642a1ccc0e3b16c1c101658516dbfac9195 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 18:01:25 -0800 Subject: [PATCH 04/46] cache validate headers for perf --- src/SimpleL7Proxy/server.cs | 50 ++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/SimpleL7Proxy/server.cs b/src/SimpleL7Proxy/server.cs index 6eb17f8c..e58c1009 100644 --- a/src/SimpleL7Proxy/server.cs +++ b/src/SimpleL7Proxy/server.cs @@ -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, @@ -89,6 +93,14 @@ public Server( _priorityHeaderName = _options.PriorityKeyHeader; _probeServer = probeServer; + // 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(); @@ -319,6 +331,7 @@ public async Task Run(CancellationToken cancellationToken) } rd.UserID = ""; + rd.Headers["S7Path"] = rd.Path; // Copy path // Lookup the user profile and add the headers to the request if (doUserProfile) { @@ -371,22 +384,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" ); } } From c9484b8b78a7314c534c9dfe817c15b817c2cb30 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 19:12:27 -0800 Subject: [PATCH 05/46] replace blocking calls with await --- .../BlobStorage/BlobWriteQueue.cs | 3 +- src/SimpleL7Proxy/Feeder/AsyncFeeder.cs | 72 ++++++++++++------- src/SimpleL7Proxy/Feeder/NormalRequest.cs | 2 +- .../Feeder/OpenAIBackgroundRequest.cs | 2 +- src/SimpleL7Proxy/ProbeServer.cs | 53 +++++++------- src/SimpleL7Proxy/Proxy/AsyncWorkerFactory.cs | 22 +++++- .../Proxy/IAsyncWorkerFactory.cs | 2 +- .../Proxy/NullAsyncWorkerFactory.cs | 4 +- src/SimpleL7Proxy/Proxy/ProxyWorker.cs | 6 +- 9 files changed, 104 insertions(+), 62 deletions(-) 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/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..a7d41959 100644 --- a/src/SimpleL7Proxy/ProbeServer.cs +++ b/src/SimpleL7Proxy/ProbeServer.cs @@ -80,32 +80,10 @@ protected override Task ExecuteAsync(CancellationToken cancellationToken) { _startupStatus = _readinessStatus = _healthService.GetStatus(); - // Push to sidecar if enabled + // Push to sidecar if enabled (fire-and-forget async to avoid blocking threadpool) if (selfCheckClient != 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(selfCheckClient); } }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); @@ -114,6 +92,33 @@ protected override Task ExecuteAsync(CancellationToken cancellationToken) return Task.CompletedTask; } + 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) { // Liveness probe check - use pre-allocated objects 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..56331a62 100644 --- a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs +++ b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs @@ -1005,7 +1005,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) { @@ -1684,7 +1684,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 +1700,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(); } From 26d0710b3b1fd656cdd598d457eb4246106b86aa Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 19:12:58 -0800 Subject: [PATCH 06/46] update docs for validate wildcard matching --- docs/ADVANCED_CONFIGURATION.md | 42 +++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) 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: `. --- From b1b34492f0294f079372e352b8fabbc1a32271ce Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 19:13:18 -0800 Subject: [PATCH 07/46] update docs for validate wildcard matching --- docs/ENVIRONMENT_VARIABLES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 352400c6..5098c701 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -78,7 +78,7 @@ 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 From 61de08c9f4ee6aa7b87326fe68a3c6e9181581d9 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 19:21:49 -0800 Subject: [PATCH 08/46] potential memory leax of response stream --- src/SimpleL7Proxy/Proxy/ProxyWorker.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs index 56331a62..0744400f 100644 --- a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs +++ b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs @@ -376,8 +376,6 @@ public async Task TaskRunnerAsync() incomingRequest.asyncWorker?.UpdateBackup(); } - // Dispose ProxyData to release memory immediately (headers, body byte arrays) - pr?.Dispose(); } catch (S7PRequeueException e) { @@ -522,6 +520,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) From 43bed1fa1f7fe2a6bd18c66395c84e512fe7a338 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 19:24:03 -0800 Subject: [PATCH 09/46] check in demo scripts --- test/openai/call-proxy.sh | 14 ++++++++------ test/openai/demo1.sh | 1 + test/openai/demo2.sh | 1 + test/openai/demo3.sh | 1 + test/openai/demo4.sh | 1 + test/openai/echo-2.sh | 1 + test/openai/echo-looper.sh | 2 +- test/openai/echo2.sh | 1 + test/openai/openai_call-long.json | 10 ++++++++++ test/openai/openai_call2.json | 2 +- 10 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 test/openai/demo1.sh create mode 100644 test/openai/demo2.sh create mode 100644 test/openai/demo3.sh create mode 100644 test/openai/demo4.sh create mode 100644 test/openai/echo-2.sh create mode 100644 test/openai/echo2.sh create mode 100644 test/openai/openai_call-long.json 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" } From bf7a4e4cd93c7a0ed57a1df5cea5b77486e7fe4c Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 19:42:17 -0800 Subject: [PATCH 10/46] pull out duplicate code into WriteErrorToClientAsync --- src/SimpleL7Proxy/Proxy/ProxyWorker.cs | 77 +++++++++++++------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs index 0744400f..768c29c3 100644 --- a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs +++ b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs @@ -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); @@ -394,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) @@ -439,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) @@ -502,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); } } } @@ -864,7 +833,8 @@ public async Task ProxyToBackEndAsync(RequestData request) } request.Path = modifiedPath; - var matchingHostCount = _backends.GetActiveHosts() + var activeHosts = _backends.GetActiveHosts(); + var matchingHostCount = activeHosts .Count(h => h.Config.PartialPath == request.Path || h.Config.PartialPath == "/"); _logger.LogDebug("[ProxyToBackEnd:{Guid}] Found {HostCount} backend hosts for path {Path}", request.Guid, matchingHostCount, request.Path); @@ -875,9 +845,8 @@ public async Task ProxyToBackEndAsync(RequestData request) request.Guid, request.Path); // Log all available hosts and their paths for debugging - var allHosts = _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}", @@ -1501,6 +1470,36 @@ private void PopulateRequestAttemptError( requestAttempt["Message"] = message; } + /// + /// 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, From 2da322a53104a9959122fefb91b63ffe1c054832 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 20:00:48 -0800 Subject: [PATCH 11/46] extract methods CreateHostIterator, ResolveHttpRequestErrorStatus from god method ProxyToBackend() --- src/SimpleL7Proxy/Proxy/ProxyWorker.cs | 404 ++++++++++++++----------- 1 file changed, 223 insertions(+), 181 deletions(-) diff --git a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs index 768c29c3..da97663a 100644 --- a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs +++ b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs @@ -780,57 +780,8 @@ 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 activeHosts = _backends.GetActiveHosts(); @@ -1075,37 +1026,16 @@ public async Task ProxyToBackEndAsync(RequestData request) } - if (intCode == 429 && proxyResponse.Headers.TryGetValues("S7PREQUEUE", out var values)) + var (shouldRequeue, retryMs) = CheckRequeueResponse(proxyResponse, intCode, requestAttempt, ref requestState); + 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 @@ -1198,45 +1128,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, @@ -1341,70 +1233,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 @@ -1470,6 +1300,218 @@ 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 + 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), + + _ => 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. From aef2cdd1033e69934ba1a99ef914907b1e127952 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 13 Feb 2026 20:40:49 -0800 Subject: [PATCH 12/46] convert to host collection Snapshots --- .../Backend/BackendHostHealthCollection.cs | 56 ------ src/SimpleL7Proxy/Backend/Backends.cs | 20 +- .../Backend/HostCollectionManager.cs | 171 ++++++++++++++++++ .../Backend/HostCollectionSnapshot.cs | 111 ++++++++++++ .../Backend/IHostHealthCollection.cs | 52 ++++-- .../Backend/Iterators/IteratorFactory.cs | 54 ------ src/SimpleL7Proxy/Program.cs | 2 +- 7 files changed, 335 insertions(+), 131 deletions(-) delete mode 100644 src/SimpleL7Proxy/Backend/BackendHostHealthCollection.cs create mode 100644 src/SimpleL7Proxy/Backend/HostCollectionManager.cs create mode 100644 src/SimpleL7Proxy/Backend/HostCollectionSnapshot.cs 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..46b3ca20 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; @@ -80,7 +85,6 @@ public Backends( _circuitBreaker = circuitBreaker; _sharedIteratorRegistry = sharedIteratorRegistry; _backendHostCollection = backendHostCollection; - _backendHosts = backendHostCollection.Hosts; _options = options.Value; _logger = logger; @@ -93,10 +97,10 @@ public Backends( _options = bo; _activeHosts = []; _successRate = bo.SuccessRate / 100.0; - //_hosts = bo.Hosts; - // FailureThreshold = bo.CircuitBreakerErrorThreshold; - // FailureTimeFrame = bo.CircuitBreakerTimeslice; - // allowableCodes = bo.AcceptableStatusCodes; + + // Stage hosts from config into a pending snapshot, then activate + _backendHostCollection.LoadFromConfig(bo.Hosts); + _backendHostCollection.Activate(); _logger.LogDebug("[INIT] Backends service starting"); @@ -133,12 +137,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/HostCollectionManager.cs b/src/SimpleL7Proxy/Backend/HostCollectionManager.cs new file mode 100644 index 00000000..673cf764 --- /dev/null +++ b/src/SimpleL7Proxy/Backend/HostCollectionManager.cs @@ -0,0 +1,171 @@ +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. LoadFromConfig() builds hosts from BackendOptions into a pending snapshot +/// 3. Activate() 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 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 loaded via LoadFromConfig() then Activate() + _current = HostCollectionSnapshot.Empty; + _logger.LogDebug("[HOST-MANAGER] Initialized with empty snapshot"); + } + + /// + /// 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. + /// After this, readers see the new hosts immediately. + /// Old snapshots remain valid for in-flight workers until GC reclaims them. + /// + public void Activate() + { + lock (_writeLock) + { + if (_pending == null) + { + _logger.LogWarning("[HOST-MANAGER] Activate() called with no pending snapshot"); + return; + } + + 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..c8a95128 --- /dev/null +++ b/src/SimpleL7Proxy/Backend/HostCollectionSnapshot.cs @@ -0,0 +1,111 @@ +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; } + + 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; } = new([], [], [], 0); + + /// + /// 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/IHostHealthCollection.cs b/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs index 19de5ab4..1c07e674 100644 --- a/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs +++ b/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs @@ -1,16 +1,44 @@ -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; } + + /// + /// 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/IteratorFactory.cs b/src/SimpleL7Proxy/Backend/Iterators/IteratorFactory.cs index d8d1e58a..6f6ba749 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 @@ -146,57 +144,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 +200,6 @@ public static void InvalidateCache() lock (_lock) { _cachedActiveHosts = null; - _cachedSpecificPathHosts = null; - _cachedCatchAllHosts = null; Interlocked.Increment(ref _cacheVersion); // Track cache version for diagnostics } } diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index 614a6f21..387afb33 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -298,7 +298,7 @@ private static void ConfigureDependencyInjection(IServiceCollection services, IL services.AddSingleton>(); services.AddSingleton, ConcurrentPriQueue>(); //services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From e9fff129c8d9ab04bd34450dde7dfd79f337fa6c Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Thu, 26 Feb 2026 13:28:27 -0500 Subject: [PATCH 13/46] implement CompleteAllUsageProcessor and update tests --- ReleaseNotes/version2.2.md | 6 ++ src/SimpleL7Proxy/Constants.cs | 2 +- src/SimpleL7Proxy/Proxy/ProxyWorker.cs | 2 +- .../CompleteAllUsageProcessor.cs | 83 +++++++++++++++++++ .../StreamProcessor/JsonStreamProcessor.cs | 36 +++++--- .../StreamProcessor/StreamProcessorFactory.cs | 3 +- test/generator/generator_one/Server.cs | 6 +- test/nullserver/Python/stream_server.py | 3 +- 8 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 src/SimpleL7Proxy/Proxy/StreamProcessor/CompleteAllUsageProcessor.cs diff --git a/ReleaseNotes/version2.2.md b/ReleaseNotes/version2.2.md index b8466c40..a200281c 100644 --- a/ReleaseNotes/version2.2.md +++ b/ReleaseNotes/version2.2.md @@ -1,5 +1,11 @@ # Release Notes # +Proxy: +* Implement partial matches for validated parameters +* Remove blocking operations during shutdown +* Convert HostHealthCollection to to HostCollectionSnapshot +* Added CompleteAllUsageProcessor for parsing the entire file + ## 2.2.9-D2 Deployment: diff --git a/src/SimpleL7Proxy/Constants.cs b/src/SimpleL7Proxy/Constants.cs index b0dee297..b860663e 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-d1"; public const int AnyPriority = -1; diff --git a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs index da97663a..c66971e0 100644 --- a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs +++ b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs @@ -1025,8 +1025,8 @@ public async Task ProxyToBackEndAsync(RequestData request) } } - var (shouldRequeue, retryMs) = CheckRequeueResponse(proxyResponse, intCode, requestAttempt, ref requestState); + if (shouldRequeue) { throw new S7PRequeueException("Requeue request", pr, retryMs); 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..ef30869e 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); } @@ -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..02abdd6a 100644 --- a/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs +++ b/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs @@ -22,7 +22,8 @@ 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(), + ["CompleteAllUsageProcessor"] = static () => new CompleteAllUsageProcessor() }; // Constants for processor selection logic 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/stream_server.py b/test/nullserver/Python/stream_server.py index 0d94c614..0d2257e6 100644 --- a/test/nullserver/Python/stream_server.py +++ b/test/nullserver/Python/stream_server.py @@ -172,9 +172,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 From 67a12642b60441f6083b4884804f2220d892da87 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 10:20:12 -0500 Subject: [PATCH 14/46] rename fullURL=>requestPath, streamline path patocessing, add optional stripping, pull modifiedPath calculation into server --- src/SimpleL7Proxy/Backend/HostConfig.cs | 19 ++++++ .../Iterators/EmptyBackendHostIterator.cs | 5 ++ .../Backend/Iterators/HostIterator.cs | 5 ++ .../Backend/Iterators/IHostIterator.cs | 4 ++ .../Backend/Iterators/ISharedHostIterator.cs | 6 ++ .../Iterators/ISharedIteratorRegistry.cs | 6 +- .../Backend/Iterators/IteratorFactory.cs | 61 ++++++------------- .../Backend/Iterators/SharedHostIterator.cs | 8 ++- .../Iterators/SharedIteratorRegistry.cs | 12 ++-- src/SimpleL7Proxy/Backend/ParsedConfig.cs | 1 + src/SimpleL7Proxy/Proxy/ProxyWorker.cs | 28 +++++---- src/SimpleL7Proxy/config.json | 5 +- src/SimpleL7Proxy/server.cs | 5 ++ 13 files changed, 101 insertions(+), 64 deletions(-) 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/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 6f6ba749..82ab2543 100644 --- a/src/SimpleL7Proxy/Backend/Iterators/IteratorFactory.cs +++ b/src/SimpleL7Proxy/Backend/Iterators/IteratorFactory.cs @@ -24,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); } /// @@ -43,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 @@ -74,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; @@ -100,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. @@ -215,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 @@ -230,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; @@ -247,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); } /// @@ -256,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(); @@ -270,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/Proxy/ProxyWorker.cs b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs index c66971e0..0325184f 100644 --- a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs +++ b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs @@ -784,9 +784,9 @@ public async Task ProxyToBackEndAsync(RequestData request) request.Path = modifiedPath; - var activeHosts = _backends.GetActiveHosts(); - var matchingHostCount = activeHosts - .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); @@ -796,6 +796,7 @@ public async Task ProxyToBackEndAsync(RequestData request) request.Guid, request.Path); // Log all available hosts and their paths for debugging + var activeHosts = _backends.GetActiveHosts(); _logger.LogCritical("[ProxyToBackEnd:{Guid}] Available hosts and their paths:", request.Guid); foreach (var h in activeHosts) { @@ -1315,16 +1316,21 @@ private void PopulateRequestAttemptError( 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, - () => 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); + () => + { + 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", 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 e58c1009..b6a3402d 100644 --- a/src/SimpleL7Proxy/server.cs +++ b/src/SimpleL7Proxy/server.cs @@ -331,6 +331,11 @@ 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) From 46084f681d117eff815c59169437bf9a0dbb0276 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 10:21:49 -0500 Subject: [PATCH 15/46] respond to default probe path, add try-catch for broken pipe --- test/nullserver/Python/stream_server.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/test/nullserver/Python/stream_server.py b/test/nullserver/Python/stream_server.py index 0d2257e6..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': @@ -195,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') From 91595177219d685d2a698928acd5f7758820d810 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 10:22:32 -0500 Subject: [PATCH 16/46] add test cases for load balancer testing --- .../Helpers/TestHostFactory.cs | 81 ++++++ .../Iterators/RoundRobinIteratorTests.cs | 267 ++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 test/ProxyWorkerTests/Helpers/TestHostFactory.cs create mode 100644 test/ProxyWorkerTests/Iterators/RoundRobinIteratorTests.cs 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..a0d0e363 --- /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; + } +} From 12081abcc6f109f2308e573b04c6734c0735212b Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 10:38:58 -0500 Subject: [PATCH 17/46] update docs, release notes --- ReleaseNotes/version2.2.md | 1 + docs/BACKEND_HOSTS.md | 29 ++++++++++++++++++++++++++++- docs/LOAD_BALANCING.md | 9 +++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/ReleaseNotes/version2.2.md b/ReleaseNotes/version2.2.md index a200281c..a3ce193d 100644 --- a/ReleaseNotes/version2.2.md +++ b/ReleaseNotes/version2.2.md @@ -5,6 +5,7 @@ Proxy: * 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 ## 2.2.9-D2 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/LOAD_BALANCING.md b/docs/LOAD_BALANCING.md index a0576adb..f1b702a5 100644 --- a/docs/LOAD_BALANCING.md +++ b/docs/LOAD_BALANCING.md @@ -49,7 +49,7 @@ Before load balancing, hosts are filtered based on the request path. The proxy m ### 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. +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 @@ -62,10 +62,15 @@ Configured Hosts: Request: GET /api/v1/users/123 -Result: +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 ``` --- From 71ec486d34eccd70c402793a5b8089a229524456 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 12:43:41 -0500 Subject: [PATCH 18/46] streamline event creation and logging --- src/SimpleL7Proxy/Backend/Backends.cs | 4 +- src/SimpleL7Proxy/Backend/CircuitBreaker.cs | 2 +- .../Events/AppInsightsEventClient.cs | 19 ---- .../Events/CompositeEventClient.cs | 24 ----- src/SimpleL7Proxy/Events/EventHubClient.cs | 8 -- src/SimpleL7Proxy/Events/IEventClient.cs | 2 +- .../Events/LogFileEventClient.cs | 8 -- src/SimpleL7Proxy/Events/ProxyEvent.cs | 95 ++++++++++++++----- src/SimpleL7Proxy/Proxy/ProxyWorker.cs | 4 +- src/SimpleL7Proxy/User/UserProfile.cs | 2 +- 10 files changed, 78 insertions(+), 90 deletions(-) diff --git a/src/SimpleL7Proxy/Backend/Backends.cs b/src/SimpleL7Proxy/Backend/Backends.cs index 46b3ca20..3622a965 100644 --- a/src/SimpleL7Proxy/Backend/Backends.cs +++ b/src/SimpleL7Proxy/Backend/Backends.cs @@ -48,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(); 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/Events/AppInsightsEventClient.cs b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs index 74024e87..0540b6cc 100644 --- a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs +++ b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs @@ -12,14 +12,6 @@ public class AppInsightsEventClient(TelemetryClient telemetryClient) 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 @@ -32,15 +24,4 @@ public Task StopAsync(CancellationToken cancellationToken) 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 ec7b2fd2..024d9b77 100644 --- a/src/SimpleL7Proxy/Events/CompositeEventClient.cs +++ b/src/SimpleL7Proxy/Events/CompositeEventClient.cs @@ -33,28 +33,4 @@ public void SendData(string? value) client.SendData(value); } } - - // 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) - { - foreach (var client in eventClients) - { - client.SendData(proxyEvent); - } - } } diff --git a/src/SimpleL7Proxy/Events/EventHubClient.cs b/src/SimpleL7Proxy/Events/EventHubClient.cs index c6667438..6d98fced 100644 --- a/src/SimpleL7Proxy/Events/EventHubClient.cs +++ b/src/SimpleL7Proxy/Events/EventHubClient.cs @@ -218,14 +218,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; diff --git a/src/SimpleL7Proxy/Events/IEventClient.cs b/src/SimpleL7Proxy/Events/IEventClient.cs index 3094baa1..fb2adab5 100644 --- a/src/SimpleL7Proxy/Events/IEventClient.cs +++ b/src/SimpleL7Proxy/Events/IEventClient.cs @@ -11,5 +11,5 @@ public interface IEventClient 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 f88b4d03..d4c1e602 100644 --- a/src/SimpleL7Proxy/Events/LogFileEventClient.cs +++ b/src/SimpleL7Proxy/Events/LogFileEventClient.cs @@ -165,12 +165,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..cc94e18b 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; @@ -46,6 +50,7 @@ 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, @@ -56,16 +61,36 @@ public static void Initialize( _eventHubClient = eventHubClient ?? throw new ArgumentNullException(nameof(eventHubClient)); _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + // 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) @@ -152,9 +177,6 @@ public void SendEvent() logToEventHub = 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 @@ -168,13 +190,12 @@ public void SendEvent() if (logToEventHub && _eventHubClient 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; + Dictionary eventParams = new Dictionary(DefaultParams, StringComparer.OrdinalIgnoreCase); + eventParams["Type"] = "S7P-" + Type.ToString(); + eventParams["MID"] = MID ?? "N/A"; + AddDefaultProperties(eventParams); // Send the event to Event Hub - _eventHubClient.SendData(this); + _eventHubClient.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); + _eventHubClient?.SendData(ConvertToJson(this)); } } catch (Exception ex) @@ -375,7 +422,7 @@ public void WriteErrorOutput(string data = "") this["Type"] = "S7P-Console-Error"; } - _eventHubClient?.SendData(this); + _eventHubClient?.SendData(ConvertToJson(this)); } catch (Exception ex) { diff --git a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs index 0325184f..6f9cba6d 100644 --- a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs +++ b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs @@ -58,8 +58,8 @@ public class ProxyWorker 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, 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 From 4581f9771b316077ba913e8b428c7ef62b9e4852 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 13:17:03 -0500 Subject: [PATCH 19/46] have each event client register with CompositeEventClient --- .../Events/AppInsightsEventClient.cs | 21 +++++-- .../Events/CompositeEventClient.cs | 55 ++++++++++++++++--- src/SimpleL7Proxy/Events/EventHubClient.cs | 5 +- .../Events/LogFileEventClient.cs | 5 +- .../ProxyEventServiceCollectionExtensions.cs | 47 +++++++++------- 5 files changed, 96 insertions(+), 37 deletions(-) diff --git a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs index 0540b6cc..957fcca4 100644 --- a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs +++ b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs @@ -4,24 +4,33 @@ namespace SimpleL7Proxy.Events; -public class AppInsightsEventClient(TelemetryClient telemetryClient) - : IEventClient, IHostedService +public class AppInsightsEventClient : IEventClient, IHostedService { + private readonly TelemetryClient? _telemetryClient; + private readonly CompositeEventClient _composite; + + public AppInsightsEventClient(TelemetryClient? telemetryClient, CompositeEventClient composite) + { + _telemetryClient = telemetryClient; + _composite = composite ?? throw new ArgumentNullException(nameof(composite)); + } + public Task StopTimerAsync() => Task.CompletedTask; public int Count => 0; - public string ClientType => "AppInsights"; - public void SendData(string? value) => telemetryClient.TrackEvent(value); + public string ClientType => _telemetryClient is not null ? "AppInsights" : "AppInsights (Disabled)"; + public void SendData(string? value) => _telemetryClient?.TrackEvent(value); public Task StartAsync(CancellationToken cancellationToken) { - // App Insights doesn't need initialization + if (_telemetryClient is not null) + _composite.Add(this); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { // Flush any remaining telemetry - telemetryClient.Flush(); + _telemetryClient?.Flush(); return Task.CompletedTask; } } diff --git a/src/SimpleL7Proxy/Events/CompositeEventClient.cs b/src/SimpleL7Proxy/Events/CompositeEventClient.cs index 024d9b77..4c8c35db 100644 --- a/src/SimpleL7Proxy/Events/CompositeEventClient.cs +++ b/src/SimpleL7Proxy/Events/CompositeEventClient.cs @@ -1,34 +1,73 @@ -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; + + /// + /// 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) + { + ArgumentNullException.ThrowIfNull(client); + lock (_lock) + { + _mutable[client] = 0; + _frozen = _mutable.ToFrozenDictionary(); + } + Console.WriteLine($"[CompositeEventClient] Added {client.ClientType}"); + } + public async Task StopTimerAsync() { - foreach (var client in eventClients) + foreach (var client in _frozen.Keys) { - Console.WriteLine($"Stopping timer for {client}"); + 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 string ClientType + { + get + { + var snapshot = _frozen; + return snapshot.Count == 0 + ? "Composite (empty)" + : string.Join(", ", snapshot.Keys.Select(c => c.ClientType)); + } + } + public void SendData(string? value) { - foreach (var client in eventClients) + foreach (var client in _frozen.Keys) { client.SendData(value); } diff --git a/src/SimpleL7Proxy/Events/EventHubClient.cs b/src/SimpleL7Proxy/Events/EventHubClient.cs index 6d98fced..475db4f1 100644 --- a/src/SimpleL7Proxy/Events/EventHubClient.cs +++ b/src/SimpleL7Proxy/Events/EventHubClient.cs @@ -16,6 +16,7 @@ public class EventHubClient : IEventClient, IHostedService 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 +29,10 @@ 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(EventHubConfig? config, CompositeEventClient composite, ILogger logger) { _config = config; + _composite = composite ?? throw new ArgumentNullException(nameof(composite)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // All initialization happens in StartAsync } @@ -77,6 +79,7 @@ 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); } diff --git a/src/SimpleL7Proxy/Events/LogFileEventClient.cs b/src/SimpleL7Proxy/Events/LogFileEventClient.cs index d4c1e602..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,6 +51,7 @@ public Task StartAsync(CancellationToken cancellationToken) workerCancelToken = cancellationTokenSource.Token; if (isRunning) { + _composite.Add(this); writerTask = Task.Run(() => EventWriter(workerCancelToken)); } return Task.CompletedTask; diff --git a/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs b/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs index a82c8da2..9b23596f 100644 --- a/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs +++ b/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ namespace SimpleL7Proxy.Events; /// /// Extension methods for registering proxy event clients. +/// Each IEventClient adds itself to CompositeEventClient during its own StartAsync. /// public static class ProxyEventServiceCollectionExtensions { @@ -15,28 +16,23 @@ public static IServiceCollection AddProxyEventClient( this IServiceCollection services, string? aiConnectionString) { + // CompositeEventClient is the single fan-out point; clients self-register via Add(this) + services.TryAddCompositeEventClient(); + AddAppInsightsClient(services, aiConnectionString); - // EventHubClient checks EventHubConfig in constructor and decides whether to run + // EventHubClient checks EventHubConfig in StartAsync and decides whether to run try { Console.WriteLine("Registering EventHubClient"); services.AddSingleton(); - services.AddSingleton(svc => svc.GetRequiredService()); - services.AddSingleton(svc => (IHostedService)svc.GetRequiredService()); + services.AddSingleton(svc => 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; } @@ -48,14 +44,17 @@ public static IServiceCollection AddProxyEventLogFileClient( string? filename, string? aiConnectionString) { + // CompositeEventClient is the single fan-out point; clients self-register via Add(this) + services.TryAddCompositeEventClient(); + AddAppInsightsClient(services, aiConnectionString); if (!string.IsNullOrEmpty(filename)) { try { - services.AddSingleton(svc => new LogFileEventClient(filename)); - services.AddSingleton(svc => svc.GetRequiredService()); + services.AddSingleton(svc => + new LogFileEventClient(filename, svc.GetRequiredService())); services.AddSingleton(svc => (IHostedService)svc.GetRequiredService()); } catch (Exception ex) @@ -64,18 +63,11 @@ public static IServiceCollection AddProxyEventLogFileClient( } } - // 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 + /// Helper method to register AppInsights event client. /// private static void AddAppInsightsClient(IServiceCollection services, string? aiConnectionString) { @@ -85,7 +77,6 @@ private static void AddAppInsightsClient(IServiceCollection services, string? ai try { services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(svc => (IHostedService)svc.GetRequiredService()); } catch (Exception ex) @@ -93,4 +84,18 @@ private static void AddAppInsightsClient(IServiceCollection services, string? ai Console.WriteLine("Failed to create AppInsightsEventClient: " + ex.Message); } } + + /// + /// Ensures CompositeEventClient is registered exactly once regardless of which Add* method is called. + /// + private static void TryAddCompositeEventClient(this IServiceCollection services) + { + // Avoid duplicate registrations when multiple Add* methods are chained + if (services.Any(sd => sd.ServiceType == typeof(CompositeEventClient))) + return; + + services.AddSingleton(); + // Expose the composite as the IEventClient so ProxyEvent.Initialize can resolve it + services.AddSingleton(svc => svc.GetRequiredService()); + } } \ No newline at end of file From aa8a5b45753a4cc6583cc05532e08aca49a16f2b Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 13:28:10 -0500 Subject: [PATCH 20/46] cleanup how application insights is registered ( only once ) --- .../Events/AppInsightsEventClient.cs | 36 ------------------- src/SimpleL7Proxy/Events/ProxyEvent.cs | 2 +- .../ProxyEventServiceCollectionExtensions.cs | 33 +++-------------- src/SimpleL7Proxy/Program.cs | 10 ++---- 4 files changed, 9 insertions(+), 72 deletions(-) delete mode 100644 src/SimpleL7Proxy/Events/AppInsightsEventClient.cs diff --git a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs b/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs deleted file mode 100644 index 957fcca4..00000000 --- a/src/SimpleL7Proxy/Events/AppInsightsEventClient.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.ApplicationInsights; -using System.Collections.Concurrent; -using Microsoft.Extensions.Hosting; - -namespace SimpleL7Proxy.Events; - -public class AppInsightsEventClient : IEventClient, IHostedService -{ - private readonly TelemetryClient? _telemetryClient; - private readonly CompositeEventClient _composite; - - public AppInsightsEventClient(TelemetryClient? telemetryClient, CompositeEventClient composite) - { - _telemetryClient = telemetryClient; - _composite = composite ?? throw new ArgumentNullException(nameof(composite)); - } - - public Task StopTimerAsync() => Task.CompletedTask; - public int Count => 0; - public string ClientType => _telemetryClient is not null ? "AppInsights" : "AppInsights (Disabled)"; - public void SendData(string? value) => _telemetryClient?.TrackEvent(value); - - public Task StartAsync(CancellationToken cancellationToken) - { - if (_telemetryClient is not null) - _composite.Add(this); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - // Flush any remaining telemetry - _telemetryClient?.Flush(); - return Task.CompletedTask; - } -} diff --git a/src/SimpleL7Proxy/Events/ProxyEvent.cs b/src/SimpleL7Proxy/Events/ProxyEvent.cs index cc94e18b..a710fee5 100644 --- a/src/SimpleL7Proxy/Events/ProxyEvent.cs +++ b/src/SimpleL7Proxy/Events/ProxyEvent.cs @@ -59,7 +59,7 @@ public static void Initialize( { _options = backendOptions ?? throw new ArgumentNullException(nameof(backendOptions)); _eventHubClient = eventHubClient ?? throw new ArgumentNullException(nameof(eventHubClient)); - _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + _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) diff --git a/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs b/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs index 9b23596f..28c3c2b9 100644 --- a/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs +++ b/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs @@ -10,17 +10,15 @@ namespace SimpleL7Proxy.Events; public static class ProxyEventServiceCollectionExtensions { /// - /// Registers EventHub and AppInsights event clients and their hosted services. + /// Registers EventHub event client and its hosted service. + /// App Insights is handled directly by ProxyEvent via TelemetryClient — not through the composite. /// public static IServiceCollection AddProxyEventClient( - this IServiceCollection services, - string? aiConnectionString) + this IServiceCollection services) { // CompositeEventClient is the single fan-out point; clients self-register via Add(this) services.TryAddCompositeEventClient(); - AddAppInsightsClient(services, aiConnectionString); - // EventHubClient checks EventHubConfig in StartAsync and decides whether to run try { @@ -38,17 +36,15 @@ public static IServiceCollection AddProxyEventClient( /// /// Registers LogFile event client and its hosted service. + /// App Insights is handled directly by ProxyEvent via TelemetryClient — not through the composite. /// public static IServiceCollection AddProxyEventLogFileClient( this IServiceCollection services, - string? filename, - string? aiConnectionString) + string? filename) { // CompositeEventClient is the single fan-out point; clients self-register via Add(this) services.TryAddCompositeEventClient(); - AddAppInsightsClient(services, aiConnectionString); - if (!string.IsNullOrEmpty(filename)) { try @@ -66,25 +62,6 @@ public static IServiceCollection AddProxyEventLogFileClient( 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(svc => (IHostedService)svc.GetRequiredService()); - } - catch (Exception ex) - { - Console.WriteLine("Failed to create AppInsightsEventClient: " + ex.Message); - } - } - /// /// Ensures CompositeEventClient is registered exactly once regardless of which Add* method is called. /// diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index 387afb33..7cfc110a 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -85,7 +85,7 @@ public static async Task Main(string[] args) var serviceProvider = frameworkHost.Services; var options = serviceProvider.GetRequiredService>(); var eventHubClient = serviceProvider.GetService(); - var telemetryClient = serviceProvider.GetRequiredService(); + var telemetryClient = serviceProvider.GetService(); var backendTokenProvider = serviceProvider.GetRequiredService(); // Initialize static logger for all stream processors @@ -194,16 +194,12 @@ private static void ConfigureApplicationInsights(IServiceCollection services) private static void ConfigureDependencyInjection(IServiceCollection services, ILogger startupLogger) { - - - // Register TelemetryClient - services.AddSingleton(); bool.TryParse(Environment.GetEnvironmentVariable("LOGTOFILE"), out var log_to_file); if (log_to_file) { var logFileName = Environment.GetEnvironmentVariable("LOGFILE_NAME") ?? "eventslog.json"; - services.AddProxyEventLogFileClient(logFileName, Environment.GetEnvironmentVariable("APPINSIGHTS_CONNECTIONSTRING")); + services.AddProxyEventLogFileClient(logFileName); } else @@ -219,7 +215,7 @@ private static void ConfigureDependencyInjection(IServiceCollection services, IL _ = int.TryParse(eventHubStartupSecondsStr, out var eventHubStartupSeconds); services.AddSingleton(new EventHubConfig(eventHubConnectionString!, eventHubName!, eventHubNamespace!, eventHubStartupSeconds)); - services.AddProxyEventClient(Environment.GetEnvironmentVariable("APPINSIGHTS_CONNECTIONSTRING")); + services.AddProxyEventClient(); } var backendOptions = BackendHostConfigurationExtensions.CreateBackendOptions(startupLogger); From 83df0acc7ebfc77e78e75da2b9ddc3cc849caaf9 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 13:41:34 -0500 Subject: [PATCH 21/46] allow EVENT_LOGGERS to define what is instantiated --- src/SimpleL7Proxy/Program.cs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index 7cfc110a..bc31449f 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -194,15 +194,38 @@ private static void ConfigureApplicationInsights(IServiceCollection services) private static void ConfigureDependencyInjection(IServiceCollection services, ILogger startupLogger) { - bool.TryParse(Environment.GetEnvironmentVariable("LOGTOFILE"), out var log_to_file); + // 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; + + if (!string.IsNullOrWhiteSpace(eventLoggersRaw)) + { + enabledLoggers = new HashSet( + eventLoggersRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + StringComparer.OrdinalIgnoreCase); + Console.WriteLine($"[CONFIG] EVENT_LOGGERS: {string.Join(", ", enabledLoggers)}"); + } + else + { + // 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)}"); + } - if (log_to_file) + if (enabledLoggers.Contains("file")) { var logFileName = Environment.GetEnvironmentVariable("LOGFILE_NAME") ?? "eventslog.json"; services.AddProxyEventLogFileClient(logFileName); - } - else + + if (enabledLoggers.Contains("eventhub")) { var eventHubConnectionString = Environment.GetEnvironmentVariable("EVENTHUB_CONNECTIONSTRING"); var eventHubName = Environment.GetEnvironmentVariable("EVENTHUB_NAME"); From acbb30a1443a686deaa97465a27085ca78461f66 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 13:52:49 -0500 Subject: [PATCH 22/46] rename eventhubclient to eventclient, dont die if event hub client doesnt start --- src/SimpleL7Proxy/Events/ProxyEvent.cs | 36 +++++++++++++------------- src/SimpleL7Proxy/Program.cs | 4 +-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/SimpleL7Proxy/Events/ProxyEvent.cs b/src/SimpleL7Proxy/Events/ProxyEvent.cs index a710fee5..d739b58d 100644 --- a/src/SimpleL7Proxy/Events/ProxyEvent.cs +++ b/src/SimpleL7Proxy/Events/ProxyEvent.cs @@ -38,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"); @@ -54,11 +54,11 @@ public class ProxyEvent : ConcurrentDictionary 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)); + _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) @@ -117,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"); @@ -130,51 +130,51 @@ 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; } @@ -188,14 +188,14 @@ public void SendEvent() else if (logException) TrackException(); } - if (logToEventHub && _eventHubClient is not null) + if (logToEventClient && _eventClient is not null) { Dictionary eventParams = new Dictionary(DefaultParams, StringComparer.OrdinalIgnoreCase); eventParams["Type"] = "S7P-" + Type.ToString(); eventParams["MID"] = MID ?? "N/A"; AddDefaultProperties(eventParams); - // Send the event to Event Hub - _eventHubClient.SendData(ConvertToJson(this, eventParams)); + // Send the event to all registered event clients (EventHub, LogFile, etc.) + _eventClient.SendData(ConvertToJson(this, eventParams)); } } catch (Exception ex) @@ -395,7 +395,7 @@ public void WriteOutput(string data = "") if (_options.Value.LogConsoleEvent) { - _eventHubClient?.SendData(ConvertToJson(this)); + _eventClient?.SendData(ConvertToJson(this)); } } catch (Exception ex) @@ -422,7 +422,7 @@ public void WriteErrorOutput(string data = "") this["Type"] = "S7P-Console-Error"; } - _eventHubClient?.SendData(ConvertToJson(this)); + _eventClient?.SendData(ConvertToJson(this)); } catch (Exception ex) { diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index bc31449f..dcf1b6aa 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -84,7 +84,7 @@ public static async Task Main(string[] args) // Perform static initialization after building the host to ensure correct singleton usage var serviceProvider = frameworkHost.Services; var options = serviceProvider.GetRequiredService>(); - var eventHubClient = serviceProvider.GetService(); + var eventClient = serviceProvider.GetService(); var telemetryClient = serviceProvider.GetService(); var backendTokenProvider = serviceProvider.GetRequiredService(); @@ -96,7 +96,7 @@ 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); From 35aa74dd827190dd75e8caf26c6425e438f8b448 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 13:57:09 -0500 Subject: [PATCH 23/46] update docs to reflect EVENT_LOGGERS, doc fixes --- docs/ENVIRONMENT_VARIABLES.md | 12 ++++++++---- docs/OBSERVABILITY.md | 9 ++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 5098c701..e5dbd1d2 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -84,17 +84,21 @@ For production deployments, consider also configuring: | 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. Supported values: `file`, `eventhub`. Multiple backends can run simultaneously (e.g., `file,eventhub`). 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/OBSERVABILITY.md b/docs/OBSERVABILITY.md index fc697b57..28ce362e 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -4,9 +4,12 @@ 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. ## 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. From 3fbf2eeb80ad77d952bf147886127717eead83e7 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 16:55:09 -0500 Subject: [PATCH 24/46] update docs for loggers --- docs/ENVIRONMENT_VARIABLES.md | 2 +- docs/OBSERVABILITY.md | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index e5dbd1d2..6df20e94 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -88,7 +88,7 @@ For production deployments, consider also configuring: | **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 | -| **EVENT_LOGGERS** | string | Comma-separated list of event logger backends to enable. Supported values: `file`, `eventhub`. Multiple backends can run simultaneously (e.g., `file,eventhub`). When not set, falls back to legacy `LOGTOFILE` behaviour. | *(legacy fallback)* | +| **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 | diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index 28ce362e..307f0981 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -11,6 +11,64 @@ Data is emitted to the following configured sinks: 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. From 88229190351c6eb3a1d610607d319d05c588517a Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 17:00:16 -0500 Subject: [PATCH 25/46] pulled read env vars for event hub into EventHubConfig --- src/SimpleL7Proxy/Events/EventHubConfig.cs | 26 +++++-- .../ProxyEventServiceCollectionExtensions.cs | 78 ------------------- 2 files changed, 20 insertions(+), 84 deletions(-) delete mode 100644 src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs 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/ProxyEventServiceCollectionExtensions.cs b/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs deleted file mode 100644 index 28c3c2b9..00000000 --- a/src/SimpleL7Proxy/Events/ProxyEventServiceCollectionExtensions.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace SimpleL7Proxy.Events; - -/// -/// Extension methods for registering proxy event clients. -/// Each IEventClient adds itself to CompositeEventClient during its own StartAsync. -/// -public static class ProxyEventServiceCollectionExtensions -{ - /// - /// Registers EventHub event client and its hosted service. - /// App Insights is handled directly by ProxyEvent via TelemetryClient — not through the composite. - /// - public static IServiceCollection AddProxyEventClient( - this IServiceCollection services) - { - // CompositeEventClient is the single fan-out point; clients self-register via Add(this) - services.TryAddCompositeEventClient(); - - // EventHubClient checks EventHubConfig in StartAsync and decides whether to run - try - { - Console.WriteLine("Registering EventHubClient"); - services.AddSingleton(); - services.AddSingleton(svc => svc.GetRequiredService()); - } - catch (Exception ex) - { - Console.WriteLine("Failed to create EventHubClient: " + ex.Message); - } - - return services; - } - - /// - /// Registers LogFile event client and its hosted service. - /// App Insights is handled directly by ProxyEvent via TelemetryClient — not through the composite. - /// - public static IServiceCollection AddProxyEventLogFileClient( - this IServiceCollection services, - string? filename) - { - // CompositeEventClient is the single fan-out point; clients self-register via Add(this) - services.TryAddCompositeEventClient(); - - if (!string.IsNullOrEmpty(filename)) - { - try - { - services.AddSingleton(svc => - new LogFileEventClient(filename, svc.GetRequiredService())); - services.AddSingleton(svc => (IHostedService)svc.GetRequiredService()); - } - catch (Exception ex) - { - Console.WriteLine("Failed to create LogFileEventClient: " + ex.Message); - } - } - - return services; - } - - /// - /// Ensures CompositeEventClient is registered exactly once regardless of which Add* method is called. - /// - private static void TryAddCompositeEventClient(this IServiceCollection services) - { - // Avoid duplicate registrations when multiple Add* methods are chained - if (services.Any(sd => sd.ServiceType == typeof(CompositeEventClient))) - return; - - services.AddSingleton(); - // Expose the composite as the IEventClient so ProxyEvent.Initialize can resolve it - services.AddSingleton(svc => svc.GetRequiredService()); - } -} \ No newline at end of file From f1d5e481844d2a1582a2a0f47b823a2300d82b1e Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 17:02:15 -0500 Subject: [PATCH 26/46] EVENT_LOGGERS can now be extended with custom logging code --- src/SimpleL7Proxy/Program.cs | 73 +++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index dcf1b6aa..7ab9961e 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -219,26 +219,51 @@ private static void ConfigureDependencyInjection(IServiceCollection services, IL Console.WriteLine($"[CONFIG] EVENT_LOGGERS not set, falling back to legacy: {string.Join(", ", enabledLoggers)}"); } - if (enabledLoggers.Contains("file")) - { - var logFileName = Environment.GetEnvironmentVariable("LOGFILE_NAME") ?? "eventslog.json"; - services.AddProxyEventLogFileClient(logFileName); - } + // Ensure CompositeEventClient is registered before any individual clients + TryAddCompositeEventClient(services); - if (enabledLoggers.Contains("eventhub")) + foreach ( var loggername in enabledLoggers) { - 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(); + 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); @@ -377,4 +402,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()); + } } From b0f35ef5a363ef5bd79a08f9856314c01bbc357f Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 27 Feb 2026 17:04:57 -0500 Subject: [PATCH 27/46] EVENT_LOGGERS can now be extended with custom logging code --- ReleaseNotes/version2.2.md | 1 + src/SimpleL7Proxy/Events/EventHubClient.cs | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/ReleaseNotes/version2.2.md b/ReleaseNotes/version2.2.md index a3ce193d..1649cbfa 100644 --- a/ReleaseNotes/version2.2.md +++ b/ReleaseNotes/version2.2.md @@ -6,6 +6,7 @@ Proxy: * 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 diff --git a/src/SimpleL7Proxy/Events/EventHubClient.cs b/src/SimpleL7Proxy/Events/EventHubClient.cs index 475db4f1..ef833a9f 100644 --- a/src/SimpleL7Proxy/Events/EventHubClient.cs +++ b/src/SimpleL7Proxy/Events/EventHubClient.cs @@ -29,9 +29,16 @@ public class EventHubClient : IEventClient, IHostedService private static int entryCount = 0; //public EventHubClient(string connectionString, string eventHubName, ILogger? logger = null) - public EventHubClient(EventHubConfig? config, CompositeEventClient composite, 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 @@ -84,15 +91,14 @@ public async Task StartAsync(CancellationToken cancellationToken) { 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 } } From fcf69926b6340f71b78b073c5337bcb1396ac0db Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Mon, 2 Mar 2026 17:18:06 -0500 Subject: [PATCH 28/46] integrate app configuration --- ReleaseNotes/version2.2.md | 13 + deployment/AppConfiguration/README.md | 155 ++++++++ .../deploy.parameters.example.sh | 37 ++ deployment/AppConfiguration/deploy.sh | 303 ++++++++++++++++ .../Config/AzureAppConfigurationExtensions.cs | 342 ++++++++++++++++++ .../BackendHostConfigurationExtensions.cs | 172 +++++---- src/SimpleL7Proxy/Config/BackendOptions.cs | 162 ++++++--- src/SimpleL7Proxy/Config/WarmOptions.cs | 178 +++++++++ src/SimpleL7Proxy/Program.cs | 18 + src/SimpleL7Proxy/SimpleL7Proxy.csproj | 2 + 10 files changed, 1261 insertions(+), 121 deletions(-) create mode 100644 deployment/AppConfiguration/README.md create mode 100644 deployment/AppConfiguration/deploy.parameters.example.sh create mode 100644 deployment/AppConfiguration/deploy.sh create mode 100644 src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs create mode 100644 src/SimpleL7Proxy/Config/WarmOptions.cs diff --git a/ReleaseNotes/version2.2.md b/ReleaseNotes/version2.2.md index 1649cbfa..251fcec5 100644 --- a/ReleaseNotes/version2.2.md +++ b/ReleaseNotes/version2.2.md @@ -1,5 +1,18 @@ # Release Notes # +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 + +Deployment: +* Add AppConfiguration/appdeploy.sh to setup appconfiguration configure ACA to use it + +2.2.10-D1 + Proxy: * Implement partial matches for validated parameters * Remove blocking operations during shutdown diff --git a/deployment/AppConfiguration/README.md b/deployment/AppConfiguration/README.md new file mode 100644 index 00000000..b325bc3b --- /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..4a96f645 --- /dev/null +++ b/deployment/AppConfiguration/deploy.sh @@ -0,0 +1,303 @@ +#!/bin/bash + +# Deploy/Update Azure App Configuration for BackendOptions +# Discovers publishable keys dynamically from [ConfigOption("...")] +# decorations in BackendOptions.cs. +# +# Three modes (ConfigMode enum): +# Warm – published & hot-reloaded (no restart required) +# Cold – published but requires a Container App restart +# Hidden – not published (skipped by this script) +# +# 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 = ""; + + # 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]; + break; + } + } + } + + if (key != "" && prop != "") { + # Default ConfigName to the property name + if (configName == "") configName = prop; + print prop "|" key "|" configName "|" mode; + } + } + ' "${BACKEND_OPTIONS_FILE}" +) + +if [ "${#CONFIG_ENTRIES[@]}" -eq 0 ]; then + echo -e "${RED}No [ConfigOption(...)] decorations found. Nothing to deploy.${NC}" + exit 1 +fi + +echo -e "${YELLOW}Publishing config keys to App Configuration...${NC}" +SET_COUNT=0 +SKIP_COUNT=0 +WARM_COUNT=0 +COLD_COUNT=0 + +for entry in "${CONFIG_ENTRIES[@]}"; do + PROP_NAME="$(echo "${entry}" | cut -d'|' -f1)" + WARM_KEY_PATH="$(echo "${entry}" | cut -d'|' -f2)" + CONFIG_NAME="$(echo "${entry}" | cut -d'|' -f3)" + MODE="$(echo "${entry}" | cut -d'|' -f4)" + APP_CONFIG_KEY="Warm:${WARM_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 + + if [ -z "${VALUE}" ]; then + echo -e "${YELLOW}Skipping ${APP_CONFIG_KEY} (no env value for ${ENV_NAME})${NC}" + SKIP_COUNT=$((SKIP_COUNT + 1)) + continue + fi + + az appconfig kv set \ + --name "${APPCONFIG_NAME}" \ + --key "${APP_CONFIG_KEY}" \ + --value "${VALUE}" \ + --label "${APPCONFIG_LABEL}" \ + --yes \ + --auth-mode login \ + >/dev/null + + echo -e "${GREEN}Set ${APP_CONFIG_KEY} from ${ENV_NAME} (${SOURCE}) [${MODE}]${NC}" + SET_COUNT=$((SET_COUNT + 1)) + if [ "${MODE}" = "Warm" ]; then + WARM_COUNT=$((WARM_COUNT + 1)) + else + COLD_COUNT=$((COLD_COUNT + 1)) + fi +done + +# Ensure refresh controls exist +az appconfig kv set \ + --name "${APPCONFIG_NAME}" \ + --key "Warm:Sentinel" \ + --value "$(date -u +%s)" \ + --label "${APPCONFIG_LABEL}" \ + --yes \ + --auth-mode login \ + >/dev/null + +az appconfig kv set \ + --name "${APPCONFIG_NAME}" \ + --key "Warm:RefreshSeconds" \ + --value "${AZURE_APPCONFIG_REFRESH_SECONDS}" \ + --label "${APPCONFIG_LABEL}" \ + --yes \ + --auth-mode login \ + >/dev/null + +# 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}${NC}" +echo -e "${GREEN}Config keys published: ${SET_COUNT} (Warm: ${WARM_COUNT}, Cold: ${COLD_COUNT})${NC}" +echo -e "${YELLOW}Config keys skipped (no source env): ${SKIP_COUNT}${NC}" +echo -e "${GREEN}======================================${NC}" diff --git a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs new file mode 100644 index 00000000..ef78277e --- /dev/null +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs @@ -0,0 +1,342 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +#if AZURE_APPCONFIG_FULL +using Azure.Identity; +using Microsoft.Extensions.DependencyInjection; +#endif + +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; + } + } +} + +/// +/// Azure App Configuration integration for hot-reloading [Warm] settings. +/// +/// Core behaviour (always compiled): +/// - AzureAppConfigurationRefreshService: a BackgroundService that polls +/// the App Configuration sentinel key every N seconds (configurable via +/// AZURE_APPCONFIG_REFRESH_SECONDS, default 30) and applies changes to +/// BackendOptions. +/// +/// Extended wiring helpers and ASP.NET-style middleware are available when +/// the compile-time constant AZURE_APPCONFIG_FULL is defined. +/// csproj: <DefineConstants>$(DefineConstants);AZURE_APPCONFIG_FULL</DefineConstants> +/// + +// ────────────────────────────────────────────────────────────────────── +// Core: Background polling service (always compiled) +// ────────────────────────────────────────────────────────────────────── + +/// +/// 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 ILogger _logger; + private readonly TimeSpan _refreshInterval; + private readonly SemaphoreSlim _initialRefreshGate = new(1, 1); + private volatile bool _initialRefreshCompleted; + private readonly IReadOnlyList _warmDescriptors; + + public AzureAppConfigurationRefreshService( + IConfigurationRefresher refresher, + IConfiguration configuration, + AppConfigurationSnapshot appConfigurationSnapshot, + IOptions backendOptions, + ILogger logger) + { + _refresher = refresher; + _configuration = configuration; + _appConfigurationSnapshot = appConfigurationSnapshot; + _backendOptions = backendOptions; + _logger = logger; + _warmDescriptors = ConfigOptions.GetWarmDescriptors(); + + 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); + } + + /// + /// 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(); + } + + private void CaptureWarmSettingsDictionary() + { + var dictionary = _configuration + .GetSection("Warm") + .AsEnumerable(makePathsRelative: false) + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) && kvp.Value != null) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!, StringComparer.OrdinalIgnoreCase); + + _appConfigurationSnapshot.Replace(dictionary); + _logger.LogInformation("[CONFIG] Warm configuration snapshot updated ({Count} keys)", dictionary.Count); + } + + private void ApplyDecoratedWarmOptions() + { + try + { + var appliedCount = ConfigOptions.ApplyWarmTo(_backendOptions.Value, _configuration.GetSection("Warm"), _logger); + _logger.LogInformation("[CONFIG] ✓ Applied {Count} warm options to BackendOptions", appliedCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "[CONFIG] ✗ Failed to apply warm settings to BackendOptions"); + } + } + + 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"); + } + + CaptureWarmSettingsDictionary(); + ApplyDecoratedWarmOptions(); + + _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 + { + var refreshed = await _refresher.TryRefreshAsync(stoppingToken); + + if (refreshed) + { + ApplyDecoratedWarmOptions(); + CaptureWarmSettingsDictionary(); + _logger.LogDebug("[CONFIG] Configuration refresh check completed - changes detected"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[CONFIG] Configuration refresh failed - will retry"); + } + } + + _logger.LogInformation("[CONFIG] Azure App Configuration refresh service stopped"); + } +} + +// ────────────────────────────────────────────────────────────────────── +// Extended: DI wiring helpers & middleware +// Compile with: AZURE_APPCONFIG_FULL +// ────────────────────────────────────────────────────────────────────── +#if AZURE_APPCONFIG_FULL + +/// +/// Extension methods for registering Azure App Configuration services in DI. +/// Only compiled when AZURE_APPCONFIG_FULL is defined. +/// +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.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"); + } + + options.Select("Warm:*", labelFilter); + + options.ConfigureRefresh(refresh => + { + refresh.Register("Warm:Sentinel", labelFilter, refreshAll: true) + .SetRefreshInterval(TimeSpan.FromSeconds(refreshIntervalSeconds)); + }); + + logger?.LogInformation("[CONFIG] ✓ Azure App Configuration configured with {RefreshInterval}s refresh interval", + refreshIntervalSeconds); + }); + + return builder; + } +} + +/// +/// Middleware to trigger configuration refresh on each HTTP request. +/// Only compiled when AZURE_APPCONFIG_FULL is defined. +/// Note: This project uses the Worker SDK, not ASP.NET Core, +/// so this middleware is provided for reference only. +/// +public class AzureAppConfigurationRefreshMiddleware +{ + private readonly RequestDelegate _next; + private readonly IConfigurationRefresher _refresher; + + public AzureAppConfigurationRefreshMiddleware(RequestDelegate next, IConfigurationRefresher refresher) + { + _next = next; + _refresher = refresher; + } + + public async Task InvokeAsync(HttpContext context) + { + _ = _refresher.TryRefreshAsync(); + await _next(context); + } +} + +// Placeholder types - this app uses Worker SDK, not ASP.NET Core +public class HttpContext { } +public delegate Task RequestDelegate(HttpContext context); + +#endif // AZURE_APPCONFIG_FULL diff --git a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs index 29ec904d..3aa97957 100644 --- a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs @@ -208,18 +208,30 @@ private static Dictionary KVIntPairs(List list) // Converts a List to a dictionary of strings. private static Dictionary KVStringPairs(List list, char delimiter = '=') { + // Alternate delimiter to try when the primary doesn't produce a valid split + char fallback = delimiter == '=' ? ':' : '='; Dictionary keyValuePairs = []; foreach (var item in list) { - var kvp = item.Split(delimiter); + // Split into max 2 parts so values containing the delimiter are preserved + var kvp = item.Split(delimiter, 2); if (kvp.Length == 2) { keyValuePairs.Add(kvp[0].Trim(), kvp[1].Trim()); } else { - Console.WriteLine($"Could not parse {item} as a key-value pair (delimiter='{delimiter}'), ignoring"); + // Try the fallback delimiter (supports both '=' and ':' formats) + kvp = item.Split(fallback, 2); + if (kvp.Length == 2) + { + keyValuePairs.Add(kvp[0].Trim(), kvp[1].Trim()); + } + else + { + Console.WriteLine($"Could not parse {item} as a key-value pair (delimiter='{delimiter}' or '{fallback}'), ignoring"); + } } } @@ -313,7 +325,7 @@ private static Dictionary ParseConfigString(string config, Dicti // 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 + // Fallback defaults — should match BackendOptions property initializers string connectionString = "example-sb-connection-string"; string namespace_ = ""; string queue = "requeststatus"; @@ -366,9 +378,9 @@ private static (string connectionString, string namespace_, string queue, bool u // 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 + // Fallback defaults — should match BackendOptions property initializers string connectionString = ""; - string accountUri = "https://example.blob.core.windows.net/"; + string accountUri = "https://mystorageaccount.blob.core.windows.net/"; bool useMI = false; if (string.IsNullOrEmpty(config)) @@ -553,94 +565,100 @@ private static BackendOptions LoadBackendOptions() } #endif - // Parse Service Bus configuration - supports both single combined variable and individual variables - // ServiceBusConfig format: "connectionString,namespace,queue,useMI" - var sbConfigStr = ReadEnvironmentVariableOrDefault("AsyncSBConfig", ""); + var defOpts = new BackendOptions(); // Create a default options object to get default values for individual settings + + // Parse composite config strings — defaults come from BackendOptions property initializers. + // When no env var is set, the default composite string is parsed, keeping all defaults in one place. + var sbConfigStr = ReadEnvironmentVariableOrDefault("AsyncSBConfig", defOpts.AsyncSBConfig); var (sbConnStr, sbNamespace, sbQueue, sbUseMI) = ParseServiceBusConfig(sbConfigStr); - var (blobConnStr, blobAccountUri, blobUseMI) = ParseBlobStorageConfig(ReadEnvironmentVariableOrDefault("AsyncBlobStorageConfig", "")); + var blobConfigStr = ReadEnvironmentVariableOrDefault("AsyncBlobStorageConfig", defOpts.AsyncBlobStorageConfig); + var (blobConnStr, blobAccountUri, blobUseMI) = ParseBlobStorageConfig(blobConfigStr); // Create and return a BackendOptions object populated with values from environment variables or default values. + // defOpts provides the single-source-of-truth defaults from BackendOptions property initializers. 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 + AcceptableStatusCodes = ReadEnvironmentVariableOrDefault("AcceptableStatusCodes", defOpts.AcceptableStatusCodes), + // Composite config strings — published to App Configuration; individual env vars override below + AsyncBlobStorageConfig = blobConfigStr, 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 + AsyncBlobWorkerCount = ReadEnvironmentVariableOrDefault("AsyncBlobWorkerCount", defOpts.AsyncBlobWorkerCount), + AsyncClientRequestHeader = ReadEnvironmentVariableOrDefault("AsyncClientRequestHeader", defOpts.AsyncClientRequestHeader), + AsyncClientConfigFieldName = ReadEnvironmentVariableOrDefault("AsyncClientConfigFieldName", defOpts.AsyncClientConfigFieldName), + AsyncModeEnabled = ReadEnvironmentVariableOrDefault("AsyncModeEnabled", defOpts.AsyncModeEnabled), + // Composite config string — published to App Configuration; individual env vars override below + AsyncSBConfig = sbConfigStr, 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), + AsyncTimeout = ReadEnvironmentVariableOrDefault("AsyncTimeout", (int)defOpts.AsyncTimeout), // cast: BackendOptions stores as double + AsyncTTLSecs = ReadEnvironmentVariableOrDefault("AsyncTTLSecs", defOpts.AsyncTTLSecs), // 24 hours + AsyncTriggerTimeout = ReadEnvironmentVariableOrDefault("AsyncTriggerTimeout", defOpts.AsyncTriggerTimeout), + CircuitBreakerErrorThreshold = ReadEnvironmentVariableOrDefault("CBErrorThreshold", defOpts.CircuitBreakerErrorThreshold), + CircuitBreakerTimeslice = ReadEnvironmentVariableOrDefault("CBTimeslice", defOpts.CircuitBreakerTimeslice), 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"), + ContainerApp = ReadEnvironmentVariableOrDefault("CONTAINER_APP_NAME", defOpts.ContainerApp), + DefaultPriority = ReadEnvironmentVariableOrDefault("DefaultPriority", defOpts.DefaultPriority), + DefaultTTLSecs = ReadEnvironmentVariableOrDefault("DefaultTTLSecs", defOpts.DefaultTTLSecs), + DependancyHeaders = ToArrayOfString(ReadEnvironmentVariableOrDefault("DependancyHeaders", string.Join(", ", defOpts.DependancyHeaders))), + DisallowedHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("DisallowedHeaders", string.Join(",", defOpts.DisallowedHeaders))), + HealthProbeSidecar = ReadEnvironmentVariableOrDefault("HealthProbeSidecar", defOpts.HealthProbeSidecar), 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), + IterationMode = ReadEnvironmentVariableOrDefault("IterationMode", defOpts.IterationMode), + LoadBalanceMode = ReadEnvironmentVariableOrDefault("LoadBalanceMode", defOpts.LoadBalanceMode), // "latency", "roundrobin", "random" + LogAllRequestHeaders = ReadEnvironmentVariableOrDefault("LogAllRequestHeaders", defOpts.LogAllRequestHeaders), + LogAllRequestHeadersExcept = ToListOfString(ReadEnvironmentVariableOrDefault("LogAllRequestHeadersExcept", string.Join(",", defOpts.LogAllRequestHeadersExcept))), + LogAllResponseHeaders = ReadEnvironmentVariableOrDefault("LogAllResponseHeaders", defOpts.LogAllResponseHeaders), + LogAllResponseHeadersExcept = ToListOfString(ReadEnvironmentVariableOrDefault("LogAllResponseHeadersExcept", string.Join(",", defOpts.LogAllResponseHeadersExcept))), + LogConsole = ReadEnvironmentVariableOrDefault("LogConsole", defOpts.LogConsole), + LogConsoleEvent = ReadEnvironmentVariableOrDefault("LogConsoleEvent", defOpts.LogConsoleEvent), + LogHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("LogHeaders", string.Join(",", defOpts.LogHeaders))), + LogPoller = ReadEnvironmentVariableOrDefault("LogPoller", defOpts.LogPoller), + LogProbes = ReadEnvironmentVariableOrDefault("LogProbes", defOpts.LogProbes), + MaxQueueLength = ReadEnvironmentVariableOrDefault("MaxQueueLength", defOpts.MaxQueueLength), + MaxAttempts = ReadEnvironmentVariableOrDefault("MaxAttempts", defOpts.MaxAttempts), + OAuthAudience = ReadEnvironmentVariableOrDefault("OAuthAudience", defOpts.OAuthAudience), + PollInterval = ReadEnvironmentVariableOrDefault("PollInterval", defOpts.PollInterval), + PollTimeout = ReadEnvironmentVariableOrDefault("PollTimeout", defOpts.PollTimeout), + Port = ReadEnvironmentVariableOrDefault("Port", defOpts.Port), + PriorityKeyHeader = ReadEnvironmentVariableOrDefault("PriorityKeyHeader", defOpts.PriorityKeyHeader), + PriorityKeys = ToListOfString(ReadEnvironmentVariableOrDefault("PriorityKeys", string.Join(",", defOpts.PriorityKeys))), + PriorityValues = ToListOfInt(ReadEnvironmentVariableOrDefault("PriorityValues", string.Join(",", defOpts.PriorityValues))), + PriorityWorkers = KVIntPairs(ToListOfString(ReadEnvironmentVariableOrDefault("PriorityWorkers", string.Join(",", defOpts.PriorityWorkers.Select(kv => $"{kv.Key}:{kv.Value}"))))), + RequiredHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("RequiredHeaders", string.Join(",", defOpts.RequiredHeaders))), + Revision = ReadEnvironmentVariableOrDefault("CONTAINER_APP_REVISION", defOpts.Revision), + SuccessRate = ReadEnvironmentVariableOrDefault("SuccessRate", defOpts.SuccessRate), + SuspendedUserConfigUrl = ReadEnvironmentVariableOrDefault("SuspendedUserConfigUrl", defOpts.SuspendedUserConfigUrl), + StripResponseHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("StripResponseHeaders", string.Join(",", defOpts.StripResponseHeaders))), + StripRequestHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("StripRequestHeaders", string.Join(",", defOpts.StripRequestHeaders))), + StorageDbEnabled = ReadEnvironmentVariableOrDefault("StorageDbEnabled", defOpts.StorageDbEnabled), + StorageDbContainerName = ReadEnvironmentVariableOrDefault("StorageDbContainerName", defOpts.StorageDbContainerName), + TerminationGracePeriodSeconds = ReadEnvironmentVariableOrDefault("TERMINATION_GRACE_PERIOD_SECONDS", defOpts.TerminationGracePeriodSeconds), + Timeout = ReadEnvironmentVariableOrDefault("Timeout", defOpts.Timeout), // 20 minutes + TimeoutHeader = ReadEnvironmentVariableOrDefault("TimeoutHeader", defOpts.TimeoutHeader), + TTLHeader = ReadEnvironmentVariableOrDefault("TTLHeader", defOpts.TTLHeader), + UniqueUserHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("UniqueUserHeaders", string.Join(",", defOpts.UniqueUserHeaders))), + UseOAuth = ReadEnvironmentVariableOrDefault("UseOAuth", defOpts.UseOAuth), + UseOAuthGov = ReadEnvironmentVariableOrDefault("UseOAuthGov", defOpts.UseOAuthGov), + UseProfiles = ReadEnvironmentVariableOrDefault("UseProfiles", defOpts.UseProfiles), + UserConfigRequired = ReadEnvironmentVariableOrDefault("UserConfigRequired", defOpts.UserConfigRequired), + UserConfigUrl = ReadEnvironmentVariableOrDefault("UserConfigUrl", defOpts.UserConfigUrl), + UserConfigRefreshIntervalSecs = ReadEnvironmentVariableOrDefault("UserConfigRefreshIntervalSecs", defOpts.UserConfigRefreshIntervalSecs), // 1 hour + UserIDFieldName = ReadEnvironmentVariableOrDefault("LookupHeaderName", "UserIDFieldName", defOpts.UserIDFieldName), // migrate from LookupHeaderName + UserPriorityThreshold = ReadEnvironmentVariableOrDefault("UserPriorityThreshold", defOpts.UserPriorityThreshold), + UserProfileHeader = ReadEnvironmentVariableOrDefault("UserProfileHeader", defOpts.UserProfileHeader), + UserSoftDeleteTTLMinutes = ReadEnvironmentVariableOrDefault("UserSoftDeleteTTLMinutes", defOpts.UserSoftDeleteTTLMinutes), // 6 hours + ValidateAuthAppFieldName = ReadEnvironmentVariableOrDefault("ValidateAuthAppFieldName", defOpts.ValidateAuthAppFieldName), + ValidateAuthAppID = ReadEnvironmentVariableOrDefault("ValidateAuthAppID", defOpts.ValidateAuthAppID), + ValidateAuthAppIDHeader = ReadEnvironmentVariableOrDefault("ValidateAuthAppIDHeader", defOpts.ValidateAuthAppIDHeader), + ValidateAuthAppIDUrl = ReadEnvironmentVariableOrDefault("ValidateAuthAppIDUrl", defOpts.ValidateAuthAppIDUrl), + ValidateHeaders = KVStringPairs(ToListOfString(ReadEnvironmentVariableOrDefault("ValidateHeaders", string.Join(",", defOpts.ValidateHeaders.Select(kv => $"{kv.Key}={kv.Value}"))))), + Workers = ReadEnvironmentVariableOrDefault("Workers", defOpts.Workers), }; // RegisterBackends will be called after DI container is built to avoid service provider dependency issues diff --git a/src/SimpleL7Proxy/Config/BackendOptions.cs b/src/SimpleL7Proxy/Config/BackendOptions.cs index d1b02b52..7e0f278e 100644 --- a/src/SimpleL7Proxy/Config/BackendOptions.cs +++ b/src/SimpleL7Proxy/Config/BackendOptions.cs @@ -5,87 +5,161 @@ namespace SimpleL7Proxy.Config; public class BackendOptions { - public int[] AcceptableStatusCodes { get; set; } = []; + [ConfigOption("Response:AcceptableStatusCodes")] + public int[] AcceptableStatusCodes { get; set; } = [200, 202, 401, 403, 404, 408, 410, 412, 417, 400]; + [ConfigOption("Async:BlobStorageConfig", ConfigName = "AsyncBlobStorageConfig")] + public string AsyncBlobStorageConfig { get; set; } = "uri=https://mystorageaccount.blob.core.windows.net,mi=true"; + [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"; public int AsyncBlobWorkerCount { get; set; } = 2; + [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; + [ConfigOption("Async:SBConfig", ConfigName = "AsyncSBConfig")] + public string AsyncSBConfig { get; set; } = "cs=example-sb-connection-string,ns=example-namespace,q=requeststatus,mi=false"; + [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; // Use managed identity for Service Bus + [ParsedConfig("AsyncSBConfig")] + [ConfigOption("Async:SBNamespace", Mode = ConfigMode.Hidden)] 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 + [ConfigOption("Async:TriggerTimeout", ConfigName = "AsyncTriggerTimeout")] + public int AsyncTriggerTimeout { get; set; } = 10000; // 10 seconds + 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; } = []; + + [ConfigOption("Metadata:ContainerApp", ConfigName = "CONTAINER_APP_NAME")] + public string ContainerApp { get; set; } = "ContainerAppName"; + + [ConfigOption("CircuitBreaker:ErrorThreshold", ConfigName = "CBErrorThreshold")] + public int CircuitBreakerErrorThreshold { get; set; } = 50; + [ConfigOption("CircuitBreaker:Timeslice", ConfigName = "CBTimeslice")] + public int CircuitBreakerTimeslice { get; set; } = 60; + [ConfigOption("Priority:DefaultPriority")] + public int DefaultPriority { get; set; } = 2; + [ConfigOption("Priority:DefaultTTLSecs")] + public int DefaultTTLSecs { get; set; } = 300; + [ConfigOption("Request:DependancyHeaders")] + public string[] DependancyHeaders { get; set; } = ["Backend-Host", "Host-URL", "Status", "Duration", "Error", "Message", "Request-Date", "backendLog"]; + [ConfigOption("Request:DisallowedHeaders")] public List DisallowedHeaders { get; set; } = []; - public string HealthProbeSidecar { get; set; } = "Enabled=false; Url=http://localhost:9000"; + + [ConfigOption("HealthProbe:Sidecar", ConfigName = "HealthProbeSidecar")] + 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; } = []; + [ConfigOption("Metadata:IDStr", ConfigName = "RequestIDPrefix", Mode = ConfigMode.Hidden)] public string IDStr { get; set; } = "S7P"; - public IterationModeEnum IterationMode { get; set; } + + public IterationModeEnum IterationMode { get; set; } = IterationModeEnum.SinglePass; + + [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; + [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; } + [ConfigOption("Logging:LogAllResponseHeadersExcept")] + public List LogAllResponseHeadersExcept { get; set; } = ["Api-Key"]; + [ConfigOption("User:UserIDFieldName")] + public string UserIDFieldName { get; set; } = "userId"; + public int MaxQueueLength { get; set; } = 1000; + [ConfigOption("Request:MaxAttempts")] + public int MaxAttempts { get ; set; } = 10; 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; } = ""; + public int Port { get; set; } = 80; + public int PollInterval { get; set; } = 15000; + public int PollTimeout { get; set; } = 3000; + [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]; + public Dictionary PriorityWorkers { get; set; } = new() { { 2, 1 }, { 3, 1 } }; + [ConfigOption("Metadata:Revision", ConfigName = "CONTAINER_APP_REVISION")] + public string Revision { get; set; } = "revisionID"; + [ConfigOption("Request:RequiredHeaders")] public List RequiredHeaders { get; set; } = []; - public int SuccessRate { get; set; } - public string SuspendedUserConfigUrl { get; set; } = ""; + public int SuccessRate { get; set; } = 80; + [ConfigOption("User:SuspendedUserConfigUrl")] + public string SuspendedUserConfigUrl { get; set; } = "file:config.json"; // Storage configuration public bool StorageDbEnabled { get; set; } = false; public string StorageDbContainerName { get; set; } = "Requests"; + [ConfigOption("Response:StripResponseHeaders")] 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 int Timeout { get; set; } = 1200000; // 20 minutes + [ConfigOption("Request:TimeoutHeader")] + public string TimeoutHeader { get; set; } = "S7PTimeout"; + public int TerminationGracePeriodSeconds { get; set; } = 30; public bool TrackWorkers { get; set; } = false; - public string TTLHeader { get; set; } = ""; - public List UniqueUserHeaders { get; set; } = []; - public bool UseOAuth { get; set; } + [ConfigOption("Request:TTLHeader")] + public string TTLHeader { get; set; } = "S7PTTL"; + [ConfigOption("User:UniqueUserHeaders")] + public List UniqueUserHeaders { get; set; } = ["X-UserID"]; + public bool UseOAuth { get; set; } = false; public bool UseOAuthGov { get; set; } = false; public bool UseProfiles { get; set; } = false; - public string UserProfileHeader { get; set; } = ""; - public string UserConfigUrl { get; set; } = ""; + [ConfigOption("User:UserProfileHeader")] + public string UserProfileHeader { get; set; } = "X-UserProfile"; + [ConfigOption("User:UserConfigUrl")] + public string UserConfigUrl { get; set; } = "file:config.json"; public bool UserConfigRequired { get; set; } = false; - public int UserConfigRefreshIntervalSecs { get; set; } - public float UserPriorityThreshold { get; set; } - public int UserSoftDeleteTTLMinutes { get; set; } + public int UserConfigRefreshIntervalSecs { get; set; } = 3600; // 1 hour + [ConfigOption("User:UserPriorityThreshold")] + public float UserPriorityThreshold { get; set; } = 0.1f; + public int UserSoftDeleteTTLMinutes { get; set; } = 360; // 6 hours + [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; } + [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"; + public int Workers { get; set; } = 10; // Shared Iterator Settings /// diff --git a/src/SimpleL7Proxy/Config/WarmOptions.cs b/src/SimpleL7Proxy/Config/WarmOptions.cs new file mode 100644 index 00000000..d98707ec --- /dev/null +++ b/src/SimpleL7Proxy/Config/WarmOptions.cs @@ -0,0 +1,178 @@ +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 the Warm: prefix +/// (e.g. "Logging:LogConsole"Warm:Logging:LogConsole). +/// +/// +/// 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 Warm section (e.g. "Logging:LogConsole"). + 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); + + /// 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() => + Descriptors.Where(d => d.Mode == ConfigMode.Warm).ToList(); + + /// Returns only publishable (Warm + Cold) descriptors. + public static IReadOnlyList GetPublishableDescriptors() => + Descriptors.Where(d => d.IsPublished).ToList(); + + /// + /// Applies warm-mode config values from the given configuration section + /// to the target instance. + /// Only properties with are applied. + /// + public static int ApplyWarmTo(BackendOptions target, IConfiguration warmSection, ILogger? logger = null) + { + var applied = 0; + + foreach (var descriptor in Descriptors) + { + if (descriptor.Mode != ConfigMode.Warm) + continue; + + var section = warmSection.GetSection(descriptor.Attribute.KeyPath); + if (!section.Exists()) + continue; + + var converted = section.Get(descriptor.Property.PropertyType); + if (converted == null) + continue; + + descriptor.Property.SetValue(target, converted); + applied++; + } + + logger?.LogDebug("[CONFIG] Applied {Count} warm config options", applied); + return applied; + } + + 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/Program.cs b/src/SimpleL7Proxy/Program.cs index 7ab9961e..3536b139 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -62,6 +62,10 @@ public static async Task Main(string[] args) var startupLogger = startupLoggerFactory.CreateLogger(); var hostBuilder = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((hostContext, config) => + { + config.AddAzureAppConfigurationWithWarmSupport(startupLogger); + }) .ConfigureLogging(logging => { logging.ClearProviders(); @@ -83,6 +87,17 @@ 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 eventClient = serviceProvider.GetService(); var telemetryClient = serviceProvider.GetService(); @@ -269,6 +284,9 @@ private static void ConfigureDependencyInjection(IServiceCollection services, IL var backendOptions = BackendHostConfigurationExtensions.CreateBackendOptions(startupLogger); 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(); 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 @@ + From de8202354979ca38a953311a931357893912fc2b Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Tue, 3 Mar 2026 12:49:23 -0500 Subject: [PATCH 29/46] update to deploy settings at once, prefix with warm: and cold:, include default values from code --- deployment/AppConfiguration/deploy.sh | 127 ++++++++++++++++++-------- 1 file changed, 91 insertions(+), 36 deletions(-) diff --git a/deployment/AppConfiguration/deploy.sh b/deployment/AppConfiguration/deploy.sh index 4a96f645..b73370bf 100644 --- a/deployment/AppConfiguration/deploy.sh +++ b/deployment/AppConfiguration/deploy.sh @@ -1,14 +1,30 @@ #!/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 & hot-reloaded (no restart required) -# Cold – published but requires a Container App restart +# 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. @@ -163,6 +179,7 @@ mapfile -t CONFIG_ENTRIES < <( configName = ""; mode = "Warm"; prop = ""; + defVal = ""; # Extract KeyPath (first positional arg) if (match($0, /\[ConfigOption\("([^"]+)"/, m)) { @@ -187,6 +204,17 @@ mapfile -t CONFIG_ENTRIES < <( 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; } } @@ -195,7 +223,7 @@ mapfile -t CONFIG_ENTRIES < <( if (key != "" && prop != "") { # Default ConfigName to the property name if (configName == "") configName = prop; - print prop "|" key "|" configName "|" mode; + print prop "|" key "|" configName "|" mode "|" defVal; } } ' "${BACKEND_OPTIONS_FILE}" @@ -206,18 +234,31 @@ if [ "${#CONFIG_ENTRIES[@]}" -eq 0 ]; then 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 -SKIP_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)" - WARM_KEY_PATH="$(echo "${entry}" | cut -d'|' -f2)" + KEY_PATH="$(echo "${entry}" | cut -d'|' -f2)" CONFIG_NAME="$(echo "${entry}" | cut -d'|' -f3)" MODE="$(echo "${entry}" | cut -d'|' -f4)" - APP_CONFIG_KEY="Warm:${WARM_KEY_PATH}" + 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="" @@ -235,48 +276,62 @@ for entry in "${CONFIG_ENTRIES[@]}"; do [ -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" + if [[ "${VALUE}" == *.* ]]; then + VALUE="${VALUE##*.}" + fi + fi + + # 4) No value at all → write placeholder so the key is still visible if [ -z "${VALUE}" ]; then - echo -e "${YELLOW}Skipping ${APP_CONFIG_KEY} (no env value for ${ENV_NAME})${NC}" - SKIP_COUNT=$((SKIP_COUNT + 1)) - continue + VALUE="${DEFAULT_PLACEHOLDER}" + SOURCE="placeholder" fi - az appconfig kv set \ - --name "${APPCONFIG_NAME}" \ - --key "${APP_CONFIG_KEY}" \ - --value "${VALUE}" \ - --label "${APPCONFIG_LABEL}" \ - --yes \ - --auth-mode login \ - >/dev/null + # Escape for JSON (handle backslashes, quotes, newlines) + JSON_VALUE="$(printf '%s' "${VALUE}" | sed 's/\\/\\\\/g; s/"/\\"/g')" - echo -e "${GREEN}Set ${APP_CONFIG_KEY} from ${ENV_NAME} (${SOURCE}) [${MODE}]${NC}" - SET_COUNT=$((SET_COUNT + 1)) - if [ "${MODE}" = "Warm" ]; then - WARM_COUNT=$((WARM_COUNT + 1)) - else + # 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 -# Ensure refresh controls exist -az appconfig kv set \ - --name "${APPCONFIG_NAME}" \ - --key "Warm:Sentinel" \ - --value "$(date -u +%s)" \ - --label "${APPCONFIG_LABEL}" \ - --yes \ - --auth-mode login \ - >/dev/null +# 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}" -az appconfig kv set \ +# 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}" \ - --key "Warm:RefreshSeconds" \ - --value "${AZURE_APPCONFIG_REFRESH_SECONDS}" \ + --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 @@ -297,7 +352,7 @@ 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}${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 "${YELLOW}Config keys skipped (no source env): ${SKIP_COUNT}${NC}" +echo -e "${GREEN} of which ${DEFAULT_COUNT} used C# default or '${DEFAULT_PLACEHOLDER}' placeholder${NC}" echo -e "${GREEN}======================================${NC}" From 597057d2eda51c0611f782f634ee1d6306410127 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Tue, 3 Mar 2026 12:57:07 -0500 Subject: [PATCH 30/46] reorg config params into hot, cold and hidden --- .../Config/AzureAppConfigurationExtensions.cs | 87 +++++- .../BackendHostConfigurationExtensions.cs | 14 +- src/SimpleL7Proxy/Config/BackendOptions.cs | 248 +++++++++++------- src/SimpleL7Proxy/Config/WarmOptions.cs | 42 ++- 4 files changed, 266 insertions(+), 125 deletions(-) diff --git a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs index ef78277e..ec5e9336 100644 --- a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs @@ -65,6 +65,7 @@ public class AzureAppConfigurationRefreshService : BackgroundService private readonly SemaphoreSlim _initialRefreshGate = new(1, 1); private volatile bool _initialRefreshCompleted; private readonly IReadOnlyList _warmDescriptors; + private string? _lastSentinel; public AzureAppConfigurationRefreshService( IConfigurationRefresher refresher, @@ -102,31 +103,68 @@ public IReadOnlyDictionary GetCurrentConfigurationDictionary() return _appConfigurationSnapshot.GetSnapshot(); } - private void CaptureWarmSettingsDictionary() + private void CaptureWarmSettingsDictionary(bool alwaysLog = false) { - var dictionary = _configuration + // Capture both Warm: and Cold: sections into the snapshot + var warmKvps = _configuration .GetSection("Warm") .AsEnumerable(makePathsRelative: false) - .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) && kvp.Value != null) + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) && kvp.Value != null); + + var coldKvps = _configuration + .GetSection("Cold") + .AsEnumerable(makePathsRelative: false) + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) && kvp.Value != null); + + var dictionary = warmKvps.Concat(coldKvps) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!, StringComparer.OrdinalIgnoreCase); _appConfigurationSnapshot.Replace(dictionary); - _logger.LogInformation("[CONFIG] Warm configuration snapshot updated ({Count} keys)", dictionary.Count); + + if (alwaysLog) + { + _logger.LogInformation("[CONFIG] Configuration snapshot loaded ({Count} keys: Warm + Cold)", dictionary.Count); + } } - private void ApplyDecoratedWarmOptions() + private int ApplyDecoratedWarmOptions(bool alwaysLog = false) { try { - var appliedCount = ConfigOptions.ApplyWarmTo(_backendOptions.Value, _configuration.GetSection("Warm"), _logger); - _logger.LogInformation("[CONFIG] ✓ Applied {Count} warm options to BackendOptions", appliedCount); + var changedCount = ConfigOptions.ApplyWarmTo(_backendOptions.Value, _configuration.GetSection("Warm"), _logger); + + if (alwaysLog || changedCount > 0) + { + _logger.LogInformation("[CONFIG] ✓ Applied {Count} warm option change(s) to BackendOptions", changedCount); + } + + return changedCount; } catch (Exception ex) { _logger.LogError(ex, "[CONFIG] ✗ Failed to apply warm settings to BackendOptions"); + return 0; } } + /// 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.LogInformation("[CONFIG] Sentinel changed: {Old} → {New}", _lastSentinel ?? "(none)", current ?? "(none)"); + _lastSentinel = current; + return true; + } + private async Task EnsureInitialRefreshAsync(CancellationToken cancellationToken) { if (_initialRefreshCompleted) @@ -154,8 +192,11 @@ private async Task EnsureInitialRefreshAsync(CancellationToken cancellationToken _logger.LogInformation("[CONFIG] Initial configuration is already up-to-date"); } - CaptureWarmSettingsDictionary(); - ApplyDecoratedWarmOptions(); + _lastSentinel = ReadSentinel(); + _logger.LogInformation("[CONFIG] Initial sentinel: {Sentinel}", _lastSentinel ?? "(none)"); + + CaptureWarmSettingsDictionary(alwaysLog: true); + ApplyDecoratedWarmOptions(alwaysLog: true); _initialRefreshCompleted = true; } @@ -185,11 +226,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var refreshed = await _refresher.TryRefreshAsync(stoppingToken); - if (refreshed) + if (refreshed && HasSentinelChanged()) { - ApplyDecoratedWarmOptions(); + var changedCount = ApplyDecoratedWarmOptions(); CaptureWarmSettingsDictionary(); - _logger.LogDebug("[CONFIG] Configuration refresh check completed - changes detected"); + + if (changedCount > 0) + { + _logger.LogInformation("[CONFIG] Configuration refresh: {Count} value(s) changed", changedCount); + } } } catch (Exception ex) @@ -295,15 +340,31 @@ public static IConfigurationBuilder AddAzureAppConfigurationWithWarmSupport( 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", + logger?.LogInformation("[CONFIG] ✓ Azure App Configuration configured with {RefreshInterval}s refresh interval (prefixes: Warm:*, Cold:*)", refreshIntervalSeconds); }); diff --git a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs index 3aa97957..f185c1f2 100644 --- a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs @@ -85,6 +85,9 @@ private static string ReadEnvironmentVariableOrDefault(string altVariableName, s string? envValue = Environment.GetEnvironmentVariable(variableName)?.Trim() ?? Environment.GetEnvironmentVariable(altVariableName)?.Trim(); + // Treat placeholder as unset + if (envValue == ConfigOptions.DefaultPlaceholder) envValue = null; + // Use default if neither variable is defined string result = !string.IsNullOrEmpty(envValue) ? envValue : defaultValue; @@ -96,7 +99,8 @@ private static string ReadEnvironmentVariableOrDefault(string altVariableName, s private static IterationModeEnum ReadEnvironmentVariableOrDefault(string variableName, IterationModeEnum defaultValue) { string? envValue = Environment.GetEnvironmentVariable(variableName)?.Trim(); - if (string.IsNullOrEmpty(envValue) || !Enum.TryParse(envValue, out IterationModeEnum value)) + if (string.IsNullOrEmpty(envValue) || envValue == ConfigOptions.DefaultPlaceholder + || !Enum.TryParse(envValue, out IterationModeEnum value)) { EnvVars[variableName] = defaultValue.ToString(); return defaultValue; @@ -117,6 +121,7 @@ private static bool ReadEnvironmentVariableOrDefault(string variableName, bool d private static int _ReadEnvironmentVariableOrDefault(string variableName, int defaultValue) { var envValue = Environment.GetEnvironmentVariable(variableName); + if (envValue?.Trim() == ConfigOptions.DefaultPlaceholder) envValue = null; if (!int.TryParse(envValue, out var value)) { //_logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); @@ -130,7 +135,7 @@ private static int _ReadEnvironmentVariableOrDefault(string variableName, int de private static int[] _ReadEnvironmentVariableOrDefault(string variableName, int[] defaultValues) { var envValue = Environment.GetEnvironmentVariable(variableName); - if (string.IsNullOrEmpty(envValue)) + if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) { //_logger?.LogWarning($"Using default: {variableName}: {string.Join(",", defaultValues)}"); return defaultValues; @@ -151,6 +156,7 @@ private static int[] _ReadEnvironmentVariableOrDefault(string variableName, int[ private static float _ReadEnvironmentVariableOrDefault(string variableName, float defaultValue) { var envValue = Environment.GetEnvironmentVariable(variableName); + if (envValue?.Trim() == ConfigOptions.DefaultPlaceholder) envValue = null; if (!float.TryParse(envValue, out var value)) { //_logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); @@ -163,7 +169,7 @@ private static float _ReadEnvironmentVariableOrDefault(string variableName, floa private static string _ReadEnvironmentVariableOrDefault(string variableName, string defaultValue) { var envValue = Environment.GetEnvironmentVariable(variableName); - if (string.IsNullOrEmpty(envValue)) + if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) { //_logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); return defaultValue; @@ -176,7 +182,7 @@ private static string _ReadEnvironmentVariableOrDefault(string variableName, str private static bool _ReadEnvironmentVariableOrDefault(string variableName, bool defaultValue) { var envValue = Environment.GetEnvironmentVariable(variableName); - if (string.IsNullOrEmpty(envValue)) + if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) { _logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); return defaultValue; diff --git a/src/SimpleL7Proxy/Config/BackendOptions.cs b/src/SimpleL7Proxy/Config/BackendOptions.cs index 7e0f278e..00ddb366 100644 --- a/src/SimpleL7Proxy/Config/BackendOptions.cs +++ b/src/SimpleL7Proxy/Config/BackendOptions.cs @@ -5,39 +5,15 @@ namespace SimpleL7Proxy.Config; public class BackendOptions { - [ConfigOption("Response:AcceptableStatusCodes")] - public int[] AcceptableStatusCodes { get; set; } = [200, 202, 401, 403, 404, 408, 410, 412, 417, 400]; - [ConfigOption("Async:BlobStorageConfig", ConfigName = "AsyncBlobStorageConfig")] - public string AsyncBlobStorageConfig { get; set; } = "uri=https://mystorageaccount.blob.core.windows.net,mi=true"; - [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"; - 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; - [ConfigOption("Async:SBConfig", ConfigName = "AsyncSBConfig")] - public string AsyncSBConfig { get; set; } = "cs=example-sb-connection-string,ns=example-namespace,q=requeststatus,mi=false"; - [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; // Use managed identity for Service Bus - [ParsedConfig("AsyncSBConfig")] - [ConfigOption("Async:SBNamespace", Mode = ConfigMode.Hidden)] - 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")] @@ -45,41 +21,21 @@ public class BackendOptions [ConfigOption("Async:TriggerTimeout", ConfigName = "AsyncTriggerTimeout")] public int AsyncTriggerTimeout { get; set; } = 10000; // 10 seconds - public HttpClient? Client { get; set; } - - [ConfigOption("Metadata:ContainerApp", ConfigName = "CONTAINER_APP_NAME")] - public string ContainerApp { get; set; } = "ContainerAppName"; - + // ── Circuit Breaker ── [ConfigOption("CircuitBreaker:ErrorThreshold", ConfigName = "CBErrorThreshold")] public int CircuitBreakerErrorThreshold { get; set; } = 50; [ConfigOption("CircuitBreaker:Timeslice", ConfigName = "CBTimeslice")] public int CircuitBreakerTimeslice { get; set; } = 60; - [ConfigOption("Priority:DefaultPriority")] - public int DefaultPriority { get; set; } = 2; - [ConfigOption("Priority:DefaultTTLSecs")] - public int DefaultTTLSecs { get; set; } = 300; - [ConfigOption("Request:DependancyHeaders")] - public string[] DependancyHeaders { get; set; } = ["Backend-Host", "Host-URL", "Status", "Duration", "Error", "Message", "Request-Date", "backendLog"]; - [ConfigOption("Request:DisallowedHeaders")] - public List DisallowedHeaders { get; set; } = []; + // ── Health Probe ── [ConfigOption("HealthProbe:Sidecar", ConfigName = "HealthProbeSidecar")] 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; } = []; - [ConfigOption("Metadata:IDStr", ConfigName = "RequestIDPrefix", Mode = ConfigMode.Hidden)] - public string IDStr { get; set; } = "S7P"; - - public IterationModeEnum IterationMode { get; set; } = IterationModeEnum.SinglePass; - + // ── Load Balancing ── [ConfigOption("LoadBalancing:Mode")] public string LoadBalanceMode { get; set; } = "latency"; // "latency", "roundrobin", "random" + + // ── Logging ── [ConfigOption("Logging:LogConsole")] public bool LogConsole { get; set; } = true; [ConfigOption("Logging:LogConsoleEvent")] @@ -98,57 +54,56 @@ public class BackendOptions public bool LogAllResponseHeaders { get; set; } = false; [ConfigOption("Logging:LogAllResponseHeadersExcept")] public List LogAllResponseHeadersExcept { get; set; } = ["Api-Key"]; - [ConfigOption("User:UserIDFieldName")] - public string UserIDFieldName { get; set; } = "userId"; - public int MaxQueueLength { get; set; } = 1000; - [ConfigOption("Request:MaxAttempts")] - public int MaxAttempts { get ; set; } = 10; - public string OAuthAudience { get; set; } = ""; - public int Port { get; set; } = 80; - public int PollInterval { get; set; } = 15000; - public int PollTimeout { get; set; } = 3000; + + // ── 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]; - public Dictionary PriorityWorkers { get; set; } = new() { { 2, 1 }, { 3, 1 } }; - [ConfigOption("Metadata:Revision", ConfigName = "CONTAINER_APP_REVISION")] - public string Revision { get; set; } = "revisionID"; + + // ── Request ── + [ConfigOption("Request:DependancyHeaders")] + public string[] 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; } = 80; - [ConfigOption("User:SuspendedUserConfigUrl")] - public string SuspendedUserConfigUrl { get; set; } = "file:config.json"; - // Storage configuration - public bool StorageDbEnabled { get; set; } = false; - public string StorageDbContainerName { get; set; } = "Requests"; - [ConfigOption("Response:StripResponseHeaders")] - public List StripResponseHeaders { get; set; } = []; [ConfigOption("Request:StripRequestHeaders")] public List StripRequestHeaders { get; set; } = []; - public int Timeout { get; set; } = 1200000; // 20 minutes [ConfigOption("Request:TimeoutHeader")] public string TimeoutHeader { get; set; } = "S7PTimeout"; - public int TerminationGracePeriodSeconds { get; set; } = 30; - public bool TrackWorkers { get; set; } = false; [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"]; - public bool UseOAuth { get; set; } = false; - public bool UseOAuthGov { get; set; } = false; - public bool UseProfiles { get; set; } = false; - [ConfigOption("User:UserProfileHeader")] - public string UserProfileHeader { get; set; } = "X-UserProfile"; [ConfigOption("User:UserConfigUrl")] public string UserConfigUrl { get; set; } = "file:config.json"; - public bool UserConfigRequired { get; set; } = false; - public int UserConfigRefreshIntervalSecs { get; set; } = 3600; // 1 hour + [ConfigOption("User:UserIDFieldName")] + public string UserIDFieldName { get; set; } = "userId"; [ConfigOption("User:UserPriorityThreshold")] public float UserPriorityThreshold { get; set; } = 0.1f; - public int UserSoftDeleteTTLMinutes { get; set; } = 360; // 6 hours + [ConfigOption("User:UserProfileHeader")] + public string UserProfileHeader { get; set; } = "X-UserProfile"; + + // ── Validation ── [ConfigOption("Validation:ValidateHeaders")] public Dictionary ValidateHeaders { get; set; } = []; [ConfigOption("Validation:ValidateAuthAppID")] @@ -159,25 +114,122 @@ public class BackendOptions 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 Iterator Settings + + // ── 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/WarmOptions.cs b/src/SimpleL7Proxy/Config/WarmOptions.cs index d98707ec..3f198ca0 100644 --- a/src/SimpleL7Proxy/Config/WarmOptions.cs +++ b/src/SimpleL7Proxy/Config/WarmOptions.cs @@ -37,8 +37,10 @@ public enum ConfigMode /// Hidden: not published — for runtime or composite values. /// /// -/// keyPath defines the section path under the Warm: prefix -/// (e.g. "Logging:LogConsole"Warm:Logging:LogConsole). +/// 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: @@ -53,7 +55,7 @@ public ConfigOptionAttribute(string keyPath) KeyPath = keyPath; } - /// Key path under the Warm section (e.g. "Logging:LogConsole"). + /// Key path under the mode section, e.g. "Logging:LogConsole" → Warm:Logging:LogConsole or Cold:Server:Workers. public string KeyPath { get; } /// @@ -127,14 +129,23 @@ public static IReadOnlyList GetWarmDescriptors() => 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 int ApplyWarmTo(BackendOptions target, IConfiguration warmSection, ILogger? logger = null) { - var applied = 0; + var changed = 0; foreach (var descriptor in Descriptors) { @@ -145,16 +156,27 @@ public static int ApplyWarmTo(BackendOptions target, IConfiguration warmSection, if (!section.Exists()) continue; - var converted = section.Get(descriptor.Property.PropertyType); - if (converted == null) + // "-" means "use built-in default" — skip this key + if (section.Value == DefaultPlaceholder) + continue; + + var newValue = section.Get(descriptor.Property.PropertyType); + if (newValue == null) + continue; + + var currentValue = descriptor.Property.GetValue(target); + + // Skip if the value hasn't actually changed + if (Equals(currentValue, newValue)) continue; - descriptor.Property.SetValue(target, converted); - applied++; + descriptor.Property.SetValue(target, newValue); + logger?.LogInformation("[CONFIG] Updated {Property}: {Old} → {New}", + descriptor.Property.Name, currentValue, newValue); + changed++; } - logger?.LogDebug("[CONFIG] Applied {Count} warm config options", applied); - return applied; + return changed; } private static IReadOnlyList DiscoverDescriptors() From 3acd0c615c2a61790097cae1adf064c67cd6c512 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Tue, 3 Mar 2026 14:36:47 -0500 Subject: [PATCH 31/46] bug fix for incorrectly parsing values with special characters --- deployment/AppConfiguration/deploy.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deployment/AppConfiguration/deploy.sh b/deployment/AppConfiguration/deploy.sh index b73370bf..cff22eff 100644 --- a/deployment/AppConfiguration/deploy.sh +++ b/deployment/AppConfiguration/deploy.sh @@ -281,7 +281,9 @@ for entry in "${CONFIG_ENTRIES[@]}"; do VALUE="${CS_DEFAULT}" SOURCE="cs-default" # Handle enum defaults like "TypeName.Value" → "Value" - if [[ "${VALUE}" == *.* ]]; then + # 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 From a1b86425c45a8ffaa27e4f19039df86960e57652 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Tue, 3 Mar 2026 14:40:05 -0500 Subject: [PATCH 32/46] read the app config settings if they exist on startup --- .../Config/AzureAppConfigurationExtensions.cs | 128 +++++++++++++++++- .../BackendHostConfigurationExtensions.cs | 45 +++++- src/SimpleL7Proxy/Program.cs | 4 + 3 files changed, 172 insertions(+), 5 deletions(-) diff --git a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs index ec5e9336..687151b8 100644 --- a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs @@ -2,9 +2,10 @@ using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Azure.Data.AppConfiguration; +using Azure.Identity; #if AZURE_APPCONFIG_FULL -using Azure.Identity; using Microsoft.Extensions.DependencyInjection; #endif @@ -32,6 +33,131 @@ public IReadOnlyDictionary GetSnapshot() } } +/// +/// 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 static class AppConfigBootstrap +{ + private static Task?>? _downloadTask; + private static ILogger? _logger; + + /// + /// 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 static void Start(ILogger logger) + { + _logger = 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("[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(endpoint, connectionString, logger)); + } + + /// + /// 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 static 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 static Dictionary? DownloadConfig(string? endpoint, string? connectionString, ILogger logger) + { + try + { + var labelFilter = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_LABEL"); + if (string.IsNullOrEmpty(labelFilter) || labelFilter == "\\0" || labelFilter == "\0") + labelFilter = null; // null = no label filter + + 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 + { + 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; + } + } +} + /// /// Azure App Configuration integration for hot-reloading [Warm] settings. /// diff --git a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs index f185c1f2..742b08fa 100644 --- a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs @@ -142,7 +142,12 @@ private static int[] _ReadEnvironmentVariableOrDefault(string variableName, int[ } try { - return envValue.Split(',').Select(int.Parse).ToArray(); + // Strip JSON-style square brackets (e.g. "[200, 202, 401]" → "200, 202, 401") + var trimmed = envValue.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + trimmed = trimmed[1..^1]; + + return trimmed.Split(',').Select(s => int.Parse(s.Trim())).ToArray(); } catch (Exception) { @@ -250,7 +255,12 @@ private static List ToListOfString(string s) if (String.IsNullOrEmpty(s)) return []; - return [.. s.Split(',').Select(p => p.Trim())]; + // Strip JSON-style square brackets (e.g. "[a, b, c]" → "a, b, c") + var trimmed = s.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + trimmed = trimmed[1..^1]; + + return [.. trimmed.Split(',').Select(p => p.Trim())]; } // Converts a comma-separated string to a list of strings. @@ -259,7 +269,12 @@ private static string[] ToArrayOfString(string s) if (String.IsNullOrEmpty(s)) return Array.Empty(); - return s.Split(',').Select(p => p.Trim()).ToArray(); + // Strip JSON-style square brackets (e.g. "[a, b, c]" → "a, b, c") + var trimmed = s.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + trimmed = trimmed[1..^1]; + + return trimmed.Split(',').Select(p => p.Trim()).ToArray(); } // Converts a comma-separated string to a list of integers. @@ -268,7 +283,12 @@ private static List ToListOfInt(string s) if (String.IsNullOrEmpty(s)) return new List(); - return s.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + // Strip JSON-style square brackets (e.g. "[1, 2, 3]" → "1, 2, 3") + var trimmed = s.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + trimmed = trimmed[1..^1]; + + return trimmed.Split(',').Select(p => int.Parse(p.Trim())).ToList(); } // Generic configuration parser that supports both key=value pairs (order-independent) and legacy positional format @@ -510,6 +530,23 @@ private static SocketsHttpHandler getHandler(int initialDelaySecs, int IntervalS // 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() { + // Wait for the bootstrap App Configuration download (started in Main) and + // push values into environment variables 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('"'); + Environment.SetEnvironmentVariable(key, value); + Console.WriteLine($"[BOOTSTRAP] Set environment variable from App Configuration: {key}={value}"); + } + _logger?.LogInformation("[BOOTSTRAP] Applied {Count} App Configuration value(s) as environment variables", appConfigSettings.Count); + } + // 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 diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index 3536b139..1d9fbddf 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -61,6 +61,10 @@ public static async Task Main(string[] args) var startupLogger = startupLoggerFactory.CreateLogger(); + // Kick off App Configuration download early so values are ready + // by the time LoadBackendOptions reads environment variables. + AppConfigBootstrap.Start(startupLogger); + var hostBuilder = Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration((hostContext, config) => { From b3d11de70ca54f7908efb07f93923c012b9bb157 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 4 Mar 2026 10:40:23 -0500 Subject: [PATCH 33/46] update project path for the null server --- SimpleL7Proxy.sln | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 From 8dcafafa049789834bc810e1fe1c95765b993f20 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 4 Mar 2026 10:46:54 -0500 Subject: [PATCH 34/46] nop --- deployment/AppConfiguration/README.md | 310 +++++----- docs/LOAD_BALANCING.md | 486 ++++++++-------- .../Backend/HostCollectionManager.cs | 342 +++++------ .../Iterators/RoundRobinIteratorTests.cs | 534 +++++++++--------- 4 files changed, 836 insertions(+), 836 deletions(-) diff --git a/deployment/AppConfiguration/README.md b/deployment/AppConfiguration/README.md index b325bc3b..cb49d8cd 100644 --- a/deployment/AppConfiguration/README.md +++ b/deployment/AppConfiguration/README.md @@ -1,155 +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. +# 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/docs/LOAD_BALANCING.md b/docs/LOAD_BALANCING.md index f1b702a5..7fd68b55 100644 --- a/docs/LOAD_BALANCING.md +++ b/docs/LOAD_BALANCING.md @@ -1,243 +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 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 +# 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/src/SimpleL7Proxy/Backend/HostCollectionManager.cs b/src/SimpleL7Proxy/Backend/HostCollectionManager.cs index 673cf764..cbe84df2 100644 --- a/src/SimpleL7Proxy/Backend/HostCollectionManager.cs +++ b/src/SimpleL7Proxy/Backend/HostCollectionManager.cs @@ -1,171 +1,171 @@ -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. LoadFromConfig() builds hosts from BackendOptions into a pending snapshot -/// 3. Activate() 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 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 loaded via LoadFromConfig() then Activate() - _current = HostCollectionSnapshot.Empty; - _logger.LogDebug("[HOST-MANAGER] Initialized with empty snapshot"); - } - - /// - /// 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. - /// After this, readers see the new hosts immediately. - /// Old snapshots remain valid for in-flight workers until GC reclaims them. - /// - public void Activate() - { - lock (_writeLock) - { - if (_pending == null) - { - _logger.LogWarning("[HOST-MANAGER] Activate() called with no pending snapshot"); - return; - } - - 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; - } - } -} +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. LoadFromConfig() builds hosts from BackendOptions into a pending snapshot +/// 3. Activate() 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 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 loaded via LoadFromConfig() then Activate() + _current = HostCollectionSnapshot.Empty; + _logger.LogDebug("[HOST-MANAGER] Initialized with empty snapshot"); + } + + /// + /// 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. + /// After this, readers see the new hosts immediately. + /// Old snapshots remain valid for in-flight workers until GC reclaims them. + /// + public void Activate() + { + lock (_writeLock) + { + if (_pending == null) + { + _logger.LogWarning("[HOST-MANAGER] Activate() called with no pending snapshot"); + return; + } + + 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/test/ProxyWorkerTests/Iterators/RoundRobinIteratorTests.cs b/test/ProxyWorkerTests/Iterators/RoundRobinIteratorTests.cs index a0d0e363..ba9166aa 100644 --- a/test/ProxyWorkerTests/Iterators/RoundRobinIteratorTests.cs +++ b/test/ProxyWorkerTests/Iterators/RoundRobinIteratorTests.cs @@ -1,267 +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; - } -} +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; + } +} From 8cae303bdb2b5859214e78df86fa27bcaa078b3e Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 4 Mar 2026 13:00:06 -0500 Subject: [PATCH 35/46] use reflection to populate the settings --- .../BackendHostConfigurationExtensions.cs | 370 +++++++++++------- src/SimpleL7Proxy/Config/BackendOptions.cs | 2 +- src/SimpleL7Proxy/Events/ProxyEvent.cs | 2 +- src/SimpleL7Proxy/Proxy/ProxyWorker.cs | 2 +- 4 files changed, 237 insertions(+), 139 deletions(-) diff --git a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs index 742b08fa..1d79db15 100644 --- a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Extensibility; using Microsoft.ApplicationInsights.WorkerService; using Microsoft.Extensions.Configuration; @@ -116,14 +116,36 @@ private static bool ReadEnvironmentVariableOrDefault(string variableName, bool d 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(string variableName, int defaultValue) { var envValue = Environment.GetEnvironmentVariable(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; } @@ -164,6 +186,9 @@ private static float _ReadEnvironmentVariableOrDefault(string variableName, floa if (envValue?.Trim() == ConfigOptions.DefaultPlaceholder) envValue = null; if (!float.TryParse(envValue, out var value)) { + // Try evaluating as a math expression (e.g. "0.5*2") + if (TryEvaluateMathExpression(envValue!, out var mathResult)) + return (float)mathResult; //_logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); return defaultValue; } @@ -524,6 +549,104 @@ private static SocketsHttpHandler getHandler(int initialDelaySecs, int IntervalS return handler; } + // Sets a single BackendOptions property from an environment variable, dispatching + // to the correct ReadEnvironmentVariableOrDefault overload based on the property type. + private static void ApplyFieldFromEnv(BackendOptions target, BackendOptions defaults, string envVar, string property) + { + var pi = typeof(BackendOptions).GetProperty(property)!; + var defVal = pi.GetValue(defaults); + var type = pi.PropertyType; + + if (type == typeof(int) || type == typeof(double)) + { + var val = ReadEnvironmentVariableOrDefault(envVar, Convert.ToInt32(defVal)); + pi.SetValue(target, Convert.ChangeType(val, type)); + } + else if (type == typeof(float)) + { + var val = ReadEnvironmentVariableOrDefault(envVar, Convert.ToSingle(defVal)); + pi.SetValue(target, Convert.ChangeType(val, type)); + } + else if (type == typeof(string)) + pi.SetValue(target, ReadEnvironmentVariableOrDefault(envVar, (string)defVal!)); + else if (type == typeof(bool)) + pi.SetValue(target, ReadEnvironmentVariableOrDefault(envVar, (bool)defVal!)); + else if (type == typeof(List)) + pi.SetValue(target, ToListOfString(ReadEnvironmentVariableOrDefault(envVar, string.Join(",", (List)defVal!)))); + else + throw new NotSupportedException($"ApplyFieldFromEnv: unsupported property type {type.Name} for {property}"); + } + + // Environment variable → BackendOptions property mappings for simple typed fields. + // ApplyFieldFromEnv dispatches to the correct reader based on the property's runtime type. + private static readonly (string envVar, string property)[] SimpleFields = [ + // int / double + ("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"), + // float + ("SuccessRate", "SuccessRate"), + ("UserPriorityThreshold", "UserPriorityThreshold"), + // string + ("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"), + // bool + ("AsyncModeEnabled", "AsyncModeEnabled"), + ("LogAllRequestHeaders", "LogAllRequestHeaders"), + ("LogAllResponseHeaders", "LogAllResponseHeaders"), + ("LogConsole", "LogConsole"), + ("LogConsoleEvent", "LogConsoleEvent"), + ("LogPoller", "LogPoller"), + ("LogProbes", "LogProbes"), + ("StorageDbEnabled", "StorageDbEnabled"), + ("UseOAuth", "UseOAuth"), + ("UseOAuthGov", "UseOAuthGov"), + ("UseProfiles", "UseProfiles"), + ("UserConfigRequired", "UserConfigRequired"), + // List + ("DependancyHeaders", "DependancyHeaders"), + ("DisallowedHeaders", "DisallowedHeaders"), + ("LogAllRequestHeadersExcept", "LogAllRequestHeadersExcept"), + ("LogAllResponseHeadersExcept", "LogAllResponseHeadersExcept"), + ("LogHeaders", "LogHeaders"), + ("PriorityKeys", "PriorityKeys"), + ("RequiredHeaders", "RequiredHeaders"), + ("StripRequestHeaders", "StripRequestHeaders"), + ("StripResponseHeaders", "StripResponseHeaders"), + ("UniqueUserHeaders", "UniqueUserHeaders"), + ]; + // 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. @@ -590,7 +713,7 @@ private static BackendOptions LoadBackendOptions() HttpClient _client = new HttpClient(handler); - // set timeout to large ti disable it at HttpClient level. Will use token cancellation for timeout instead. + // set timeout to large to disable it at HttpClient level. Will use token cancellation for timeout instead. _client.Timeout = Timeout.InfiniteTimeSpan; @@ -610,107 +733,122 @@ private static BackendOptions LoadBackendOptions() var defOpts = new BackendOptions(); // Create a default options object to get default values for individual settings - // Parse composite config strings — defaults come from BackendOptions property initializers. - // When no env var is set, the default composite string is parsed, keeping all defaults in one place. - var sbConfigStr = ReadEnvironmentVariableOrDefault("AsyncSBConfig", defOpts.AsyncSBConfig); - var (sbConnStr, sbNamespace, sbQueue, sbUseMI) = ParseServiceBusConfig(sbConfigStr); - var blobConfigStr = ReadEnvironmentVariableOrDefault("AsyncBlobStorageConfig", defOpts.AsyncBlobStorageConfig); - var (blobConnStr, blobAccountUri, blobUseMI) = ParseBlobStorageConfig(blobConfigStr); - // Create and return a BackendOptions object populated with values from environment variables or default values. // defOpts provides the single-source-of-truth defaults from BackendOptions property initializers. var backendOptions = new BackendOptions { - AcceptableStatusCodes = ReadEnvironmentVariableOrDefault("AcceptableStatusCodes", defOpts.AcceptableStatusCodes), - // Composite config strings — published to App Configuration; individual env vars override below - AsyncBlobStorageConfig = blobConfigStr, - AsyncBlobStorageAccountUri = ReadEnvironmentVariableOrDefault("AsyncBlobStorageAccountUri", blobAccountUri), - AsyncBlobStorageConnectionString = ReadEnvironmentVariableOrDefault("AsyncBlobStorageConnectionString", blobConnStr), - AsyncBlobStorageUseMI = ReadEnvironmentVariableOrDefault("AsyncBlobStorageUseMI", blobUseMI), - AsyncBlobWorkerCount = ReadEnvironmentVariableOrDefault("AsyncBlobWorkerCount", defOpts.AsyncBlobWorkerCount), - AsyncClientRequestHeader = ReadEnvironmentVariableOrDefault("AsyncClientRequestHeader", defOpts.AsyncClientRequestHeader), - AsyncClientConfigFieldName = ReadEnvironmentVariableOrDefault("AsyncClientConfigFieldName", defOpts.AsyncClientConfigFieldName), - AsyncModeEnabled = ReadEnvironmentVariableOrDefault("AsyncModeEnabled", defOpts.AsyncModeEnabled), - // Composite config string — published to App Configuration; individual env vars override below - AsyncSBConfig = sbConfigStr, - 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", (int)defOpts.AsyncTimeout), // cast: BackendOptions stores as double - AsyncTTLSecs = ReadEnvironmentVariableOrDefault("AsyncTTLSecs", defOpts.AsyncTTLSecs), // 24 hours - AsyncTriggerTimeout = ReadEnvironmentVariableOrDefault("AsyncTriggerTimeout", defOpts.AsyncTriggerTimeout), - CircuitBreakerErrorThreshold = ReadEnvironmentVariableOrDefault("CBErrorThreshold", defOpts.CircuitBreakerErrorThreshold), - CircuitBreakerTimeslice = ReadEnvironmentVariableOrDefault("CBTimeslice", defOpts.CircuitBreakerTimeslice), Client = _client, - ContainerApp = ReadEnvironmentVariableOrDefault("CONTAINER_APP_NAME", defOpts.ContainerApp), - DefaultPriority = ReadEnvironmentVariableOrDefault("DefaultPriority", defOpts.DefaultPriority), - DefaultTTLSecs = ReadEnvironmentVariableOrDefault("DefaultTTLSecs", defOpts.DefaultTTLSecs), - DependancyHeaders = ToArrayOfString(ReadEnvironmentVariableOrDefault("DependancyHeaders", string.Join(", ", defOpts.DependancyHeaders))), - DisallowedHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("DisallowedHeaders", string.Join(",", defOpts.DisallowedHeaders))), - HealthProbeSidecar = ReadEnvironmentVariableOrDefault("HealthProbeSidecar", defOpts.HealthProbeSidecar), - HostName = ReadEnvironmentVariableOrDefault("Hostname", replicaID), Hosts = new List(), - IDStr = $"{ReadEnvironmentVariableOrDefault("RequestIDPrefix", "S7P")}-{replicaID}-", + AcceptableStatusCodes = ReadEnvironmentVariableOrDefault("AcceptableStatusCodes", defOpts.AcceptableStatusCodes), IterationMode = ReadEnvironmentVariableOrDefault("IterationMode", defOpts.IterationMode), - LoadBalanceMode = ReadEnvironmentVariableOrDefault("LoadBalanceMode", defOpts.LoadBalanceMode), // "latency", "roundrobin", "random" - LogAllRequestHeaders = ReadEnvironmentVariableOrDefault("LogAllRequestHeaders", defOpts.LogAllRequestHeaders), - LogAllRequestHeadersExcept = ToListOfString(ReadEnvironmentVariableOrDefault("LogAllRequestHeadersExcept", string.Join(",", defOpts.LogAllRequestHeadersExcept))), - LogAllResponseHeaders = ReadEnvironmentVariableOrDefault("LogAllResponseHeaders", defOpts.LogAllResponseHeaders), - LogAllResponseHeadersExcept = ToListOfString(ReadEnvironmentVariableOrDefault("LogAllResponseHeadersExcept", string.Join(",", defOpts.LogAllResponseHeadersExcept))), - LogConsole = ReadEnvironmentVariableOrDefault("LogConsole", defOpts.LogConsole), - LogConsoleEvent = ReadEnvironmentVariableOrDefault("LogConsoleEvent", defOpts.LogConsoleEvent), - LogHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("LogHeaders", string.Join(",", defOpts.LogHeaders))), - LogPoller = ReadEnvironmentVariableOrDefault("LogPoller", defOpts.LogPoller), - LogProbes = ReadEnvironmentVariableOrDefault("LogProbes", defOpts.LogProbes), - MaxQueueLength = ReadEnvironmentVariableOrDefault("MaxQueueLength", defOpts.MaxQueueLength), - MaxAttempts = ReadEnvironmentVariableOrDefault("MaxAttempts", defOpts.MaxAttempts), - OAuthAudience = ReadEnvironmentVariableOrDefault("OAuthAudience", defOpts.OAuthAudience), - PollInterval = ReadEnvironmentVariableOrDefault("PollInterval", defOpts.PollInterval), - PollTimeout = ReadEnvironmentVariableOrDefault("PollTimeout", defOpts.PollTimeout), - Port = ReadEnvironmentVariableOrDefault("Port", defOpts.Port), - PriorityKeyHeader = ReadEnvironmentVariableOrDefault("PriorityKeyHeader", defOpts.PriorityKeyHeader), - PriorityKeys = ToListOfString(ReadEnvironmentVariableOrDefault("PriorityKeys", string.Join(",", defOpts.PriorityKeys))), PriorityValues = ToListOfInt(ReadEnvironmentVariableOrDefault("PriorityValues", string.Join(",", defOpts.PriorityValues))), PriorityWorkers = KVIntPairs(ToListOfString(ReadEnvironmentVariableOrDefault("PriorityWorkers", string.Join(",", defOpts.PriorityWorkers.Select(kv => $"{kv.Key}:{kv.Value}"))))), - RequiredHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("RequiredHeaders", string.Join(",", defOpts.RequiredHeaders))), - Revision = ReadEnvironmentVariableOrDefault("CONTAINER_APP_REVISION", defOpts.Revision), - SuccessRate = ReadEnvironmentVariableOrDefault("SuccessRate", defOpts.SuccessRate), - SuspendedUserConfigUrl = ReadEnvironmentVariableOrDefault("SuspendedUserConfigUrl", defOpts.SuspendedUserConfigUrl), - StripResponseHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("StripResponseHeaders", string.Join(",", defOpts.StripResponseHeaders))), - StripRequestHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("StripRequestHeaders", string.Join(",", defOpts.StripRequestHeaders))), - StorageDbEnabled = ReadEnvironmentVariableOrDefault("StorageDbEnabled", defOpts.StorageDbEnabled), - StorageDbContainerName = ReadEnvironmentVariableOrDefault("StorageDbContainerName", defOpts.StorageDbContainerName), - TerminationGracePeriodSeconds = ReadEnvironmentVariableOrDefault("TERMINATION_GRACE_PERIOD_SECONDS", defOpts.TerminationGracePeriodSeconds), - Timeout = ReadEnvironmentVariableOrDefault("Timeout", defOpts.Timeout), // 20 minutes - TimeoutHeader = ReadEnvironmentVariableOrDefault("TimeoutHeader", defOpts.TimeoutHeader), - TTLHeader = ReadEnvironmentVariableOrDefault("TTLHeader", defOpts.TTLHeader), - UniqueUserHeaders = ToListOfString(ReadEnvironmentVariableOrDefault("UniqueUserHeaders", string.Join(",", defOpts.UniqueUserHeaders))), - UseOAuth = ReadEnvironmentVariableOrDefault("UseOAuth", defOpts.UseOAuth), - UseOAuthGov = ReadEnvironmentVariableOrDefault("UseOAuthGov", defOpts.UseOAuthGov), - UseProfiles = ReadEnvironmentVariableOrDefault("UseProfiles", defOpts.UseProfiles), - UserConfigRequired = ReadEnvironmentVariableOrDefault("UserConfigRequired", defOpts.UserConfigRequired), - UserConfigUrl = ReadEnvironmentVariableOrDefault("UserConfigUrl", defOpts.UserConfigUrl), - UserConfigRefreshIntervalSecs = ReadEnvironmentVariableOrDefault("UserConfigRefreshIntervalSecs", defOpts.UserConfigRefreshIntervalSecs), // 1 hour UserIDFieldName = ReadEnvironmentVariableOrDefault("LookupHeaderName", "UserIDFieldName", defOpts.UserIDFieldName), // migrate from LookupHeaderName - UserPriorityThreshold = ReadEnvironmentVariableOrDefault("UserPriorityThreshold", defOpts.UserPriorityThreshold), - UserProfileHeader = ReadEnvironmentVariableOrDefault("UserProfileHeader", defOpts.UserProfileHeader), - UserSoftDeleteTTLMinutes = ReadEnvironmentVariableOrDefault("UserSoftDeleteTTLMinutes", defOpts.UserSoftDeleteTTLMinutes), // 6 hours - ValidateAuthAppFieldName = ReadEnvironmentVariableOrDefault("ValidateAuthAppFieldName", defOpts.ValidateAuthAppFieldName), - ValidateAuthAppID = ReadEnvironmentVariableOrDefault("ValidateAuthAppID", defOpts.ValidateAuthAppID), - ValidateAuthAppIDHeader = ReadEnvironmentVariableOrDefault("ValidateAuthAppIDHeader", defOpts.ValidateAuthAppIDHeader), - ValidateAuthAppIDUrl = ReadEnvironmentVariableOrDefault("ValidateAuthAppIDUrl", defOpts.ValidateAuthAppIDUrl), ValidateHeaders = KVStringPairs(ToListOfString(ReadEnvironmentVariableOrDefault("ValidateHeaders", string.Join(",", defOpts.ValidateHeaders.Select(kv => $"{kv.Key}={kv.Value}"))))), - Workers = ReadEnvironmentVariableOrDefault("Workers", defOpts.Workers), }; // RegisterBackends will be called after DI container is built to avoid service provider dependency issues + // Apply all simple typed fields from environment variables via reflection + foreach (var (envVar, property) in SimpleFields) + { + ApplyFieldFromEnv(backendOptions, defOpts, envVar, property); + } + + // Apply settings with unique patterns (composite config overrides, computed identity) + ApplyAsyncServiceBusOverrides(EnvVars, backendOptions, defOpts); + ApplyAsyncBlobStorageOverrides(EnvVars, backendOptions, defOpts); + ApplyReplicaIdentitySettings(EnvVars, backendOptions, replicaID); + + ValidatePrioritySettings(EnvVars, backendOptions, defOpts); + ParseHealthProbeSidecarSettings(EnvVars, backendOptions, defOpts); + ValidateHeaderSettings(EnvVars, backendOptions, defOpts); + ValidateLoadBalanceMode(EnvVars, backendOptions, defOpts); + + 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 ApplyAsyncServiceBusOverrides(Dictionary envVars, BackendOptions opts, BackendOptions defOpts) + { + // Parse composite config string, then allow individual env vars to override + var configStr = ReadEnvironmentVariableOrDefault("AsyncSBConfig", defOpts.AsyncSBConfig); + var (connStr, namespace_, queue, useMI) = ParseServiceBusConfig(configStr); + opts.AsyncSBConfig = configStr; + opts.AsyncSBConnectionString = ReadEnvironmentVariableOrDefault("AsyncSBConnectionString", connStr); + opts.AsyncSBNamespace = ReadEnvironmentVariableOrDefault("AsyncSBNamespace", namespace_); + opts.AsyncSBQueue = ReadEnvironmentVariableOrDefault("AsyncSBQueue", queue); + opts.AsyncSBUseMI = ReadEnvironmentVariableOrDefault("AsyncSBUseMI", useMI); + } + + private static void ApplyAsyncBlobStorageOverrides(Dictionary envVars, BackendOptions opts, BackendOptions defOpts) + { + // Parse composite config string, then allow individual env vars to override + var configStr = ReadEnvironmentVariableOrDefault("AsyncBlobStorageConfig", defOpts.AsyncBlobStorageConfig); + var (connStr, accountUri, useMI) = ParseBlobStorageConfig(configStr); + opts.AsyncBlobStorageConfig = configStr; + opts.AsyncBlobStorageAccountUri = ReadEnvironmentVariableOrDefault("AsyncBlobStorageAccountUri", accountUri); + opts.AsyncBlobStorageConnectionString = ReadEnvironmentVariableOrDefault("AsyncBlobStorageConnectionString", connStr); + opts.AsyncBlobStorageUseMI = ReadEnvironmentVariableOrDefault("AsyncBlobStorageUseMI", useMI); + } + + private static void ApplyReplicaIdentitySettings(Dictionary envVars, BackendOptions opts, string replicaID) + { + opts.HostName = ReadEnvironmentVariableOrDefault("Hostname", replicaID); + opts.IDStr = $"{ReadEnvironmentVariableOrDefault("RequestIDPrefix", "S7P")}-{replicaID}-"; + } + + private static void ValidatePrioritySettings(Dictionary envVars, BackendOptions backendOptions, BackendOptions defOpts) + { // 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(); + Console.WriteLine($"The number of PriorityKeys and PriorityValues do not match in length, defaulting all values to {defOpts.DefaultPriority}"); + backendOptions.PriorityValues = Enumerable.Repeat(defOpts.DefaultPriority, backendOptions.PriorityKeys.Count).ToList(); } // confirm that the PriorityWorkers Key's have a corresponding priority keys @@ -730,8 +868,10 @@ private static BackendOptions LoadBackendOptions() 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 + private static void ParseHealthProbeSidecarSettings(Dictionary envVars, BackendOptions backendOptions, BackendOptions defOpts) + { var healthSettings = backendOptions.HealthProbeSidecar.Split(';', StringSplitOptions.RemoveEmptyEntries); foreach (var setting in healthSettings) { @@ -750,7 +890,10 @@ private static BackendOptions LoadBackendOptions() } } } + } + private static void ValidateHeaderSettings(Dictionary envVars, BackendOptions backendOptions, BackendOptions defOpts) + { // if (backendOptions.UniqueUserHeaders.Count > 0) // { // // Make sure that uniqueUserHeaders are also in the required headers @@ -787,62 +930,17 @@ private static BackendOptions LoadBackendOptions() } } } + } - // Validate LoadBalanceMode case insensitively + private static void ValidateLoadBalanceMode(Dictionary envVars, BackendOptions backendOptions, BackendOptions defOpts) + { 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()); + Console.WriteLine($"Invalid LoadBalanceMode: {backendOptions.LoadBalanceMode}. Defaulting to '{defOpts.LoadBalanceMode}'."); + backendOptions.LoadBalanceMode = defOpts.LoadBalanceMode; } } diff --git a/src/SimpleL7Proxy/Config/BackendOptions.cs b/src/SimpleL7Proxy/Config/BackendOptions.cs index 00ddb366..b1c0763b 100644 --- a/src/SimpleL7Proxy/Config/BackendOptions.cs +++ b/src/SimpleL7Proxy/Config/BackendOptions.cs @@ -69,7 +69,7 @@ public class BackendOptions // ── Request ── [ConfigOption("Request:DependancyHeaders")] - public string[] DependancyHeaders { get; set; } = ["Backend-Host", "Host-URL", "Status", "Duration", "Error", "Message", "Request-Date", "backendLog"]; + 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")] diff --git a/src/SimpleL7Proxy/Events/ProxyEvent.cs b/src/SimpleL7Proxy/Events/ProxyEvent.cs index d739b58d..ed47434b 100644 --- a/src/SimpleL7Proxy/Events/ProxyEvent.cs +++ b/src/SimpleL7Proxy/Events/ProxyEvent.cs @@ -431,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/Proxy/ProxyWorker.cs b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs index 6f9cba6d..35ee6791 100644 --- a/src/SimpleL7Proxy/Proxy/ProxyWorker.cs +++ b/src/SimpleL7Proxy/Proxy/ProxyWorker.cs @@ -54,7 +54,7 @@ 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 From fd4f791ef87f384834083ba2a793fdadc415bf4d Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 4 Mar 2026 13:03:12 -0500 Subject: [PATCH 36/46] implement pub sub pattern for config changes --- .../Config/AzureAppConfigurationExtensions.cs | 91 ++++++----- src/SimpleL7Proxy/Config/ConfigChange.cs | 19 +++ .../Config/ConfigChangeNotifer.cs | 144 ++++++++++++++++++ .../Config/IConfigChangeSubscriber.cs | 19 +++ src/SimpleL7Proxy/Config/WarmOptions.cs | 93 +++++++++-- src/SimpleL7Proxy/ProbeServer.cs | 70 +++++++-- 6 files changed, 373 insertions(+), 63 deletions(-) create mode 100644 src/SimpleL7Proxy/Config/ConfigChange.cs create mode 100644 src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs create mode 100644 src/SimpleL7Proxy/Config/IConfigChangeSubscriber.cs diff --git a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs index 687151b8..907b47b8 100644 --- a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs @@ -187,6 +187,7 @@ public class AzureAppConfigurationRefreshService : BackgroundService private readonly AppConfigurationSnapshot _appConfigurationSnapshot; private readonly IOptions _backendOptions; private readonly ILogger _logger; + private readonly ConfigChangeNotifier _notifier; private readonly TimeSpan _refreshInterval; private readonly SemaphoreSlim _initialRefreshGate = new(1, 1); private volatile bool _initialRefreshCompleted; @@ -198,13 +199,15 @@ public AzureAppConfigurationRefreshService( IConfiguration configuration, AppConfigurationSnapshot appConfigurationSnapshot, IOptions backendOptions, - ILogger logger) + ILogger logger, + ConfigChangeNotifier notifier) { _refresher = refresher; _configuration = configuration; _appConfigurationSnapshot = appConfigurationSnapshot; _backendOptions = backendOptions; _logger = logger; + _notifier = notifier; _warmDescriptors = ConfigOptions.GetWarmDescriptors(); var intervalSeconds = int.TryParse( @@ -253,24 +256,30 @@ private void CaptureWarmSettingsDictionary(bool alwaysLog = false) } } - private int ApplyDecoratedWarmOptions(bool alwaysLog = false) + // private List ApplyDecoratedWarmOptions(bool alwaysLog = false) + // { + // try + // { + // var changes = ConfigOptions.ApplyWarmTo(_backendOptions.Value, _configuration.GetSection("Warm"), _logger); + + // if (alwaysLog || changes.Count > 0) + // { + // _logger.LogInformation("[CONFIG] ✓ Applied {Count} warm option change(s) to BackendOptions", changes.Count); + // } + + // return changes; + // } + // catch (Exception ex) + // { + // Console.WriteLine(ex.StackTrace); + // _logger.LogError(ex, "[CONFIG] ✗ Failed to apply warm settings to BackendOptions"); + // return []; + // } + // } + + private async Task NotifySubscribersAsync(List changes, CancellationToken cancellationToken) { - try - { - var changedCount = ConfigOptions.ApplyWarmTo(_backendOptions.Value, _configuration.GetSection("Warm"), _logger); - - if (alwaysLog || changedCount > 0) - { - _logger.LogInformation("[CONFIG] ✓ Applied {Count} warm option change(s) to BackendOptions", changedCount); - } - - return changedCount; - } - catch (Exception ex) - { - _logger.LogError(ex, "[CONFIG] ✗ Failed to apply warm settings to BackendOptions"); - return 0; - } + await _notifier.NotifyAsync(changes, _backendOptions.Value, cancellationToken); } /// Reads the current Warm:Sentinel value from the configuration. @@ -321,8 +330,12 @@ private async Task EnsureInitialRefreshAsync(CancellationToken cancellationToken _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". CaptureWarmSettingsDictionary(alwaysLog: true); - ApplyDecoratedWarmOptions(alwaysLog: true); _initialRefreshCompleted = true; } @@ -348,25 +361,26 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(_refreshInterval, stoppingToken); - try - { - var refreshed = await _refresher.TryRefreshAsync(stoppingToken); - - if (refreshed && HasSentinelChanged()) - { - var changedCount = ApplyDecoratedWarmOptions(); - CaptureWarmSettingsDictionary(); - - if (changedCount > 0) - { - _logger.LogInformation("[CONFIG] Configuration refresh: {Count} value(s) changed", changedCount); - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[CONFIG] Configuration refresh failed - will retry"); - } + // try + // { + // var refreshed = await _refresher.TryRefreshAsync(stoppingToken); + + // if (refreshed && HasSentinelChanged()) + // { + // var changes = ApplyDecoratedWarmOptions(); + // CaptureWarmSettingsDictionary(); + + // if (changes.Count > 0) + // { + // _logger.LogInformation("[CONFIG] Configuration refresh: {Count} value(s) changed", changes.Count); + // await NotifySubscribersAsync(changes, stoppingToken); + // } + // } + // } + // catch (Exception ex) + // { + // _logger.LogWarning(ex, "[CONFIG] Configuration refresh failed - will retry"); + // } } _logger.LogInformation("[CONFIG] Azure App Configuration refresh service stopped"); @@ -415,6 +429,7 @@ public static IServiceCollection AddAzureAppConfigurationWithWarmRefresh( }); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/src/SimpleL7Proxy/Config/ConfigChange.cs b/src/SimpleL7Proxy/Config/ConfigChange.cs new file mode 100644 index 00000000..7943f876 --- /dev/null +++ b/src/SimpleL7Proxy/Config/ConfigChange.cs @@ -0,0 +1,19 @@ +namespace SimpleL7Proxy.Config; + +/// +/// Describes a single configuration setting that changed during a refresh cycle. +/// +public readonly record struct ConfigChange +{ + /// 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; } + + /// Previous value (as string) before the change, or null if unknown. + public string? OldValue { get; init; } + + /// New value (as string) after the change. + public string? NewValue { get; init; } +} \ 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..057f7714 --- /dev/null +++ b/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.Logging; + +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; + } + + /// 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); + } + + /// Number of active subscriptions. + public int Count { get { lock (_lock) { return _subscriptions.Count; } } } + + /// + /// 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]; + } + + foreach (var sub in snapshot) + { + // Filter changes to only those the subscriber cares about + var relevant = sub.Filter != null + ? changes.Where(c => sub.Filter.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); + } + } + } + + /// 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/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 index 3f198ca0..358b7c54 100644 --- a/src/SimpleL7Proxy/Config/WarmOptions.cs +++ b/src/SimpleL7Proxy/Config/WarmOptions.cs @@ -143,9 +143,9 @@ public static IReadOnlyList GetPublishableDescriptors() /// Values equal to are ignored, /// leaving the built-in code default in place. /// - public static int ApplyWarmTo(BackendOptions target, IConfiguration warmSection, ILogger? logger = null) + public static List ApplyWarmTo(BackendOptions target, IConfiguration warmSection, ILogger? logger = null) { - var changed = 0; + var changes = new List(); foreach (var descriptor in Descriptors) { @@ -156,27 +156,102 @@ public static int ApplyWarmTo(BackendOptions target, IConfiguration warmSection, if (!section.Exists()) continue; + var rawValue = section.Value; + // "-" means "use built-in default" — skip this key - if (section.Value == DefaultPlaceholder) + if (rawValue == DefaultPlaceholder) + continue; + + if (string.IsNullOrEmpty(rawValue)) continue; - var newValue = section.Get(descriptor.Property.PropertyType); + var newValue = ParseValue(rawValue, descriptor.Property.PropertyType); if (newValue == null) + { + logger?.LogWarning("[CONFIG] Could not parse {Property} value '{Raw}' as {Type}", + descriptor.Property.Name, rawValue, descriptor.Property.PropertyType.Name); continue; + } var currentValue = descriptor.Property.GetValue(target); - // Skip if the value hasn't actually changed - if (Equals(currentValue, newValue)) + // Skip if the value hasn't actually changed (compare string representations for collections) + var oldStr = currentValue?.ToString(); + var newStr = newValue.ToString(); + if (oldStr == newStr) continue; descriptor.Property.SetValue(target, newValue); logger?.LogInformation("[CONFIG] Updated {Property}: {Old} → {New}", - descriptor.Property.Name, currentValue, newValue); - changed++; + descriptor.ConfigName, oldStr, newStr); + + changes.Add(new ConfigChange + { + PropertyName = descriptor.ConfigName, + KeyPath = descriptor.Attribute.KeyPath, + OldValue = oldStr, + NewValue = newStr + }); + } + + return changes; + } + + /// + /// Parses a raw string value from App Configuration into the target CLR type. + /// Handles the same types used in BackendOptions: string, bool, int, float, + /// int[], string[], List<string>, List<int>, Dictionary<string,string>. + /// Strips JSON-style brackets from collection values. + /// + private static object? ParseValue(string raw, Type targetType) + { + if (targetType == typeof(string)) + return raw; + + if (targetType == typeof(bool)) + return raw.Equals("true", StringComparison.OrdinalIgnoreCase) ? true + : raw.Equals("false", StringComparison.OrdinalIgnoreCase) ? false + : null; + + if (targetType == typeof(int)) + return int.TryParse(raw, out var i) ? i : null; + + if (targetType == typeof(float)) + return float.TryParse(raw, out var f) ? f : null; + + if (targetType == typeof(double)) + return double.TryParse(raw, out var d) ? d : null; + + // Strip JSON brackets for collection types + var trimmed = raw.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + trimmed = trimmed[1..^1]; + + if (targetType == typeof(int[])) + return trimmed.Split(',').Select(s => int.Parse(s.Trim())).ToArray(); + + if (targetType == typeof(string[])) + return trimmed.Split(',').Select(s => s.Trim()).ToArray(); + + if (targetType == typeof(List)) + return trimmed.Split(',').Select(s => s.Trim()).ToList(); + + if (targetType == typeof(List)) + return trimmed.Split(',').Select(s => int.Parse(s.Trim())).ToList(); + + if (targetType == typeof(Dictionary)) + { + var dict = new Dictionary(); + foreach (var pair in trimmed.Split(',')) + { + var kvp = pair.Split('=', 2); + if (kvp.Length == 2) + dict[kvp[0].Trim()] = kvp[1].Trim(); + } + return dict; } - return changed; + return null; } private static IReadOnlyList DiscoverDescriptors() diff --git a/src/SimpleL7Proxy/ProbeServer.cs b/src/SimpleL7Proxy/ProbeServer.cs index a7d41959..55035f05 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, "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 { @@ -81,15 +92,28 @@ protected override Task ExecuteAsync(CancellationToken cancellationToken) _startupStatus = _readinessStatus = _healthService.GetStatus(); // Push to sidecar if enabled (fire-and-forget async to avoid blocking threadpool) - if (selfCheckClient != null) + var client = _selfCheckClient; + if (client != null) { - _ = PushStatusToSidecarAsync(selfCheckClient); + _ = 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) @@ -225,16 +249,30 @@ 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"); + StopProbeServer(); + StartProbeServer(); + return Task.CompletedTask; + } + private static HttpClient CreateSelfCheckClient() { var handler = new SocketsHttpHandler From c818e0f06f8bba16fc7b77a2056ac1ccbda9e656 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 4 Mar 2026 15:24:41 -0500 Subject: [PATCH 37/46] migrate env to Dictionary --- .../BackendHostConfigurationExtensions.cs | 275 ++++++++++-------- 1 file changed, 152 insertions(+), 123 deletions(-) diff --git a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs index 1d79db15..f7a172c8 100644 --- a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Options; using OS = System; using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -32,8 +33,34 @@ public static class BackendHostConfigurationExtensions public static BackendOptions CreateBackendOptions(ILogger logger) { + Dictionary effectiveEnvironment = new Dictionary(StringComparer.OrdinalIgnoreCase); _logger = logger; - return LoadBackendOptions(); + + 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); + } + + return LoadBackendOptions(effectiveEnvironment); } public static IServiceCollection AddBackendHostConfiguration(this IServiceCollection services, ILogger logger, BackendOptions backendOptions) @@ -54,36 +81,35 @@ public static IServiceCollection AddBackendHostConfiguration(this IServiceCollec return services; } - private static int ReadEnvironmentVariableOrDefault(string variableName, int defaultValue) + private static int ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int defaultValue) { - int value = _ReadEnvironmentVariableOrDefault(variableName, defaultValue); + int value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); EnvVars[variableName] = value.ToString(); return value; } - private static int[] ReadEnvironmentVariableOrDefault(string variableName, int[] defaultValues) + private static int[] ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int[] defaultValues) { - int[] value = _ReadEnvironmentVariableOrDefault(variableName, defaultValues); + int[] value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValues); EnvVars[variableName] = string.Join(",", value); return value; } - private static float ReadEnvironmentVariableOrDefault(string variableName, float defaultValue) + private static float ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, float defaultValue) { - float value = _ReadEnvironmentVariableOrDefault(variableName, defaultValue); + float value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); EnvVars[variableName] = value.ToString(); return value; } - private static string ReadEnvironmentVariableOrDefault(string variableName, string defaultValue) + private static string ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, string defaultValue) { - string value = _ReadEnvironmentVariableOrDefault(variableName, defaultValue); + string value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); EnvVars[variableName] = value; return value; } - private static string ReadEnvironmentVariableOrDefault(string altVariableName, string variableName, string defaultValue) + private static string ReadEnvironmentVariableOrDefault(Dictionary env, 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(); + string? envValue = env.GetValueOrDefault(variableName)?.Trim() ?? env.GetValueOrDefault(altVariableName)?.Trim(); // Treat placeholder as unset if (envValue == ConfigOptions.DefaultPlaceholder) envValue = null; @@ -96,11 +122,11 @@ private static string ReadEnvironmentVariableOrDefault(string altVariableName, s return result; } - private static IterationModeEnum ReadEnvironmentVariableOrDefault(string variableName, IterationModeEnum defaultValue) + private static IterationModeEnum ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, IterationModeEnum defaultValue) { - string? envValue = Environment.GetEnvironmentVariable(variableName)?.Trim(); + string? envValue = env.GetValueOrDefault(variableName)?.Trim(); if (string.IsNullOrEmpty(envValue) || envValue == ConfigOptions.DefaultPlaceholder - || !Enum.TryParse(envValue, out IterationModeEnum value)) + || !Enum.TryParse(envValue, ignoreCase: true, out IterationModeEnum value)) { EnvVars[variableName] = defaultValue.ToString(); return defaultValue; @@ -109,9 +135,9 @@ private static IterationModeEnum ReadEnvironmentVariableOrDefault(string variabl return value; } - private static bool ReadEnvironmentVariableOrDefault(string variableName, bool defaultValue) + private static bool ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, bool defaultValue) { - bool value = _ReadEnvironmentVariableOrDefault(variableName, defaultValue); + bool value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); EnvVars[variableName] = value.ToString(); return value; } @@ -137,9 +163,9 @@ private static bool TryEvaluateMathExpression(string expression, out double resu // 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(string variableName, int defaultValue) + private static int _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int defaultValue) { - var envValue = Environment.GetEnvironmentVariable(variableName); + var envValue = env.GetValueOrDefault(variableName); if (envValue?.Trim() == ConfigOptions.DefaultPlaceholder) envValue = null; if (!int.TryParse(envValue, out var value)) { @@ -154,9 +180,9 @@ private static int _ReadEnvironmentVariableOrDefault(string variableName, int de // 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) + private static int[] _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int[] defaultValues) { - var envValue = Environment.GetEnvironmentVariable(variableName); + var envValue = env.GetValueOrDefault(variableName); if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) { //_logger?.LogWarning($"Using default: {variableName}: {string.Join(",", defaultValues)}"); @@ -180,9 +206,9 @@ private static int[] _ReadEnvironmentVariableOrDefault(string variableName, int[ // 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) + private static float _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, float defaultValue) { - var envValue = Environment.GetEnvironmentVariable(variableName); + var envValue = env.GetValueOrDefault(variableName); if (envValue?.Trim() == ConfigOptions.DefaultPlaceholder) envValue = null; if (!float.TryParse(envValue, out var value)) { @@ -196,9 +222,9 @@ private static float _ReadEnvironmentVariableOrDefault(string variableName, floa } // 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) + private static string _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, string defaultValue) { - var envValue = Environment.GetEnvironmentVariable(variableName); + var envValue = env.GetValueOrDefault(variableName); if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) { //_logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); @@ -209,9 +235,9 @@ private static string _ReadEnvironmentVariableOrDefault(string variableName, str // 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) + private static bool _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, bool defaultValue) { - var envValue = Environment.GetEnvironmentVariable(variableName); + var envValue = env.GetValueOrDefault(variableName); if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) { _logger?.LogWarning($"Using default: {variableName}: {defaultValue}"); @@ -369,6 +395,19 @@ private static Dictionary ParseConfigString(string config, Dicti 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)); + } + // 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) @@ -386,12 +425,7 @@ private static (string connectionString, string namespace_, string queue, bool u 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 + var keyAliases = new Dictionary { { "connectionString", new[] { "connectionstring", "cs" } }, { "namespace", new[] { "namespace", "ns" } }, @@ -399,6 +433,9 @@ private static (string connectionString, string namespace_, string queue, bool u { "useMI", new[] { "usemi", "mi" } } }; + // Check if it's the new key=value format + if (IsNamedKeyValueFormat(parts, keyAliases)) + { var parsed = ParseConfigString(config, keyAliases, "AsyncSBConfig"); if (parsed.TryGetValue("connectionString", out var cs)) connectionString = cs; @@ -438,18 +475,16 @@ private static (string connectionString, string accountUri, bool useMI) ParseBlo 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 + var keyAliases = new Dictionary { { "connectionString", new[] { "connectionstring", "cs" } }, { "accountUri", new[] { "accounturi", "uri" } }, { "useMI", new[] { "usemi", "mi" } } }; + // Check if it's the new key=value format + if (IsNamedKeyValueFormat(parts, keyAliases)) + { var parsed = ParseConfigString(config, keyAliases, "AsyncBlobStorageConfig"); if (parsed.TryGetValue("connectionString", out var cs)) connectionString = cs; @@ -493,8 +528,8 @@ private static SocketsHttpHandler getHandler(int initialDelaySecs, int IntervalS // 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 + 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"); @@ -551,7 +586,7 @@ private static SocketsHttpHandler getHandler(int initialDelaySecs, int IntervalS // Sets a single BackendOptions property from an environment variable, dispatching // to the correct ReadEnvironmentVariableOrDefault overload based on the property type. - private static void ApplyFieldFromEnv(BackendOptions target, BackendOptions defaults, string envVar, string property) + private static void ApplyFieldFromEnv(Dictionary env, BackendOptions target, BackendOptions defaults, string envVar, string property) { var pi = typeof(BackendOptions).GetProperty(property)!; var defVal = pi.GetValue(defaults); @@ -559,20 +594,20 @@ private static void ApplyFieldFromEnv(BackendOptions target, BackendOptions defa if (type == typeof(int) || type == typeof(double)) { - var val = ReadEnvironmentVariableOrDefault(envVar, Convert.ToInt32(defVal)); + var val = ReadEnvironmentVariableOrDefault(env, envVar, Convert.ToInt32(defVal)); pi.SetValue(target, Convert.ChangeType(val, type)); } else if (type == typeof(float)) { - var val = ReadEnvironmentVariableOrDefault(envVar, Convert.ToSingle(defVal)); + var val = ReadEnvironmentVariableOrDefault(env, envVar, Convert.ToSingle(defVal)); pi.SetValue(target, Convert.ChangeType(val, type)); } else if (type == typeof(string)) - pi.SetValue(target, ReadEnvironmentVariableOrDefault(envVar, (string)defVal!)); + pi.SetValue(target, ReadEnvironmentVariableOrDefault(env, envVar, (string)defVal!)); else if (type == typeof(bool)) - pi.SetValue(target, ReadEnvironmentVariableOrDefault(envVar, (bool)defVal!)); + pi.SetValue(target, ReadEnvironmentVariableOrDefault(env, envVar, (bool)defVal!)); else if (type == typeof(List)) - pi.SetValue(target, ToListOfString(ReadEnvironmentVariableOrDefault(envVar, string.Join(",", (List)defVal!)))); + pi.SetValue(target, ToListOfString(ReadEnvironmentVariableOrDefault(env, envVar, string.Join(",", (List)defVal!)))); else throw new NotSupportedException($"ApplyFieldFromEnv: unsupported property type {type.Name} for {property}"); } @@ -651,38 +686,23 @@ private static readonly (string envVar, string property)[] SimpleFields = [ // 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() + private static BackendOptions LoadBackendOptions(Dictionary env) { - // Wait for the bootstrap App Configuration download (started in Main) and - // push values into environment variables 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('"'); - Environment.SetEnvironmentVariable(key, value); - Console.WriteLine($"[BOOTSTRAP] Set environment variable from App Configuration: {key}={value}"); - } - _logger?.LogInformation("[BOOTSTRAP] Applied {Count} App Configuration value(s) as environment variables", appConfigSettings.Count); - } - - // 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); + // 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) { @@ -702,7 +722,7 @@ private static BackendOptions LoadBackendOptions() // Configure SSL handling - if (ReadEnvironmentVariableOrDefault("IgnoreSSLCert", false)) + if (ReadEnvironmentVariableOrDefault(env, "IgnoreSSLCert", false)) { handler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions { @@ -717,20 +737,7 @@ private static BackendOptions LoadBackendOptions() _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 - + string replicaID = ReadEnvironmentVariableOrDefault(env, "CONTAINER_APP_REPLICA_NAME", "01"); var defOpts = new BackendOptions(); // Create a default options object to get default values for individual settings // Create and return a BackendOptions object populated with values from environment variables or default values. @@ -739,12 +746,12 @@ private static BackendOptions LoadBackendOptions() { Client = _client, Hosts = new List(), - AcceptableStatusCodes = ReadEnvironmentVariableOrDefault("AcceptableStatusCodes", defOpts.AcceptableStatusCodes), - IterationMode = ReadEnvironmentVariableOrDefault("IterationMode", defOpts.IterationMode), - PriorityValues = ToListOfInt(ReadEnvironmentVariableOrDefault("PriorityValues", string.Join(",", defOpts.PriorityValues))), - PriorityWorkers = KVIntPairs(ToListOfString(ReadEnvironmentVariableOrDefault("PriorityWorkers", string.Join(",", defOpts.PriorityWorkers.Select(kv => $"{kv.Key}:{kv.Value}"))))), - UserIDFieldName = ReadEnvironmentVariableOrDefault("LookupHeaderName", "UserIDFieldName", defOpts.UserIDFieldName), // migrate from LookupHeaderName - ValidateHeaders = KVStringPairs(ToListOfString(ReadEnvironmentVariableOrDefault("ValidateHeaders", string.Join(",", defOpts.ValidateHeaders.Select(kv => $"{kv.Key}={kv.Value}"))))), + AcceptableStatusCodes = ReadEnvironmentVariableOrDefault(env, "AcceptableStatusCodes", defOpts.AcceptableStatusCodes), + IterationMode = ReadEnvironmentVariableOrDefault(env, "IterationMode", defOpts.IterationMode), + PriorityValues = ToListOfInt(ReadEnvironmentVariableOrDefault(env, "PriorityValues", string.Join(",", defOpts.PriorityValues))), + PriorityWorkers = KVIntPairs(ToListOfString(ReadEnvironmentVariableOrDefault(env, "PriorityWorkers", string.Join(",", defOpts.PriorityWorkers.Select(kv => $"{kv.Key}:{kv.Value}"))))), + UserIDFieldName = ReadEnvironmentVariableOrDefault(env, "LookupHeaderName", "UserIDFieldName", defOpts.UserIDFieldName), // migrate from LookupHeaderName + ValidateHeaders = KVStringPairs(ToListOfString(ReadEnvironmentVariableOrDefault(env, "ValidateHeaders", string.Join(",", defOpts.ValidateHeaders.Select(kv => $"{kv.Key}={kv.Value}"))))), }; // RegisterBackends will be called after DI container is built to avoid service provider dependency issues @@ -752,18 +759,18 @@ private static BackendOptions LoadBackendOptions() // Apply all simple typed fields from environment variables via reflection foreach (var (envVar, property) in SimpleFields) { - ApplyFieldFromEnv(backendOptions, defOpts, envVar, property); + ApplyFieldFromEnv(env, backendOptions, defOpts, envVar, property); } // Apply settings with unique patterns (composite config overrides, computed identity) - ApplyAsyncServiceBusOverrides(EnvVars, backendOptions, defOpts); - ApplyAsyncBlobStorageOverrides(EnvVars, backendOptions, defOpts); - ApplyReplicaIdentitySettings(EnvVars, backendOptions, replicaID); + ApplyAsyncServiceBusOverrides(env, backendOptions, defOpts); + ApplyAsyncBlobStorageOverrides(env, backendOptions, defOpts); + ApplyReplicaIdentitySettings(env, backendOptions, replicaID); - ValidatePrioritySettings(EnvVars, backendOptions, defOpts); - ParseHealthProbeSidecarSettings(EnvVars, backendOptions, defOpts); - ValidateHeaderSettings(EnvVars, backendOptions, defOpts); - ValidateLoadBalanceMode(EnvVars, backendOptions, defOpts); + ValidatePrioritySettings(backendOptions, defOpts); + ParseHealthProbeSidecarSettings(env, backendOptions, defOpts); + ValidateHeaderSettings(env, backendOptions, defOpts); + ValidateLoadBalanceMode(env, backendOptions, defOpts); OutputEnvVars(); @@ -813,36 +820,36 @@ public static void RegisterBackends(BackendOptions backendOptions) } } - private static void ApplyAsyncServiceBusOverrides(Dictionary envVars, BackendOptions opts, BackendOptions defOpts) + private static void ApplyAsyncServiceBusOverrides(Dictionary env, BackendOptions opts, BackendOptions defOpts) { // Parse composite config string, then allow individual env vars to override - var configStr = ReadEnvironmentVariableOrDefault("AsyncSBConfig", defOpts.AsyncSBConfig); + var configStr = ReadEnvironmentVariableOrDefault(env, "AsyncSBConfig", defOpts.AsyncSBConfig); var (connStr, namespace_, queue, useMI) = ParseServiceBusConfig(configStr); opts.AsyncSBConfig = configStr; - opts.AsyncSBConnectionString = ReadEnvironmentVariableOrDefault("AsyncSBConnectionString", connStr); - opts.AsyncSBNamespace = ReadEnvironmentVariableOrDefault("AsyncSBNamespace", namespace_); - opts.AsyncSBQueue = ReadEnvironmentVariableOrDefault("AsyncSBQueue", queue); - opts.AsyncSBUseMI = ReadEnvironmentVariableOrDefault("AsyncSBUseMI", useMI); + opts.AsyncSBConnectionString = ReadEnvironmentVariableOrDefault(env, "AsyncSBConnectionString", connStr); + opts.AsyncSBNamespace = ReadEnvironmentVariableOrDefault(env, "AsyncSBNamespace", namespace_); + opts.AsyncSBQueue = ReadEnvironmentVariableOrDefault(env, "AsyncSBQueue", queue); + opts.AsyncSBUseMI = ReadEnvironmentVariableOrDefault(env, "AsyncSBUseMI", useMI); } - private static void ApplyAsyncBlobStorageOverrides(Dictionary envVars, BackendOptions opts, BackendOptions defOpts) + private static void ApplyAsyncBlobStorageOverrides(Dictionary env, BackendOptions opts, BackendOptions defOpts) { // Parse composite config string, then allow individual env vars to override - var configStr = ReadEnvironmentVariableOrDefault("AsyncBlobStorageConfig", defOpts.AsyncBlobStorageConfig); + var configStr = ReadEnvironmentVariableOrDefault(env, "AsyncBlobStorageConfig", defOpts.AsyncBlobStorageConfig); var (connStr, accountUri, useMI) = ParseBlobStorageConfig(configStr); opts.AsyncBlobStorageConfig = configStr; - opts.AsyncBlobStorageAccountUri = ReadEnvironmentVariableOrDefault("AsyncBlobStorageAccountUri", accountUri); - opts.AsyncBlobStorageConnectionString = ReadEnvironmentVariableOrDefault("AsyncBlobStorageConnectionString", connStr); - opts.AsyncBlobStorageUseMI = ReadEnvironmentVariableOrDefault("AsyncBlobStorageUseMI", useMI); + opts.AsyncBlobStorageAccountUri = ReadEnvironmentVariableOrDefault(env, "AsyncBlobStorageAccountUri", accountUri); + opts.AsyncBlobStorageConnectionString = ReadEnvironmentVariableOrDefault(env, "AsyncBlobStorageConnectionString", connStr); + opts.AsyncBlobStorageUseMI = ReadEnvironmentVariableOrDefault(env, "AsyncBlobStorageUseMI", useMI); } - private static void ApplyReplicaIdentitySettings(Dictionary envVars, BackendOptions opts, string replicaID) + private static void ApplyReplicaIdentitySettings(Dictionary env, BackendOptions opts, string replicaID) { - opts.HostName = ReadEnvironmentVariableOrDefault("Hostname", replicaID); - opts.IDStr = $"{ReadEnvironmentVariableOrDefault("RequestIDPrefix", "S7P")}-{replicaID}-"; + opts.HostName = ReadEnvironmentVariableOrDefault(env, "Hostname", replicaID); + opts.IDStr = $"{ReadEnvironmentVariableOrDefault(env, "RequestIDPrefix", "S7P")}-{replicaID}-"; } - private static void ValidatePrioritySettings(Dictionary envVars, BackendOptions backendOptions, BackendOptions defOpts) + private static void ValidatePrioritySettings( BackendOptions backendOptions, BackendOptions defOpts) { // confirm the number of priority keys and values match if (backendOptions.PriorityKeys.Count != backendOptions.PriorityValues.Count) @@ -946,6 +953,28 @@ private static void ValidateLoadBalanceMode(Dictionary envVars, 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; @@ -954,7 +983,7 @@ private static void OutputEnvVars() foreach (var kvp in EnvVars) { string key = kvp.Key; - string value = kvp.Value; + string value = MaskValue(key, kvp.Value); // Prepare the entry for this pair string entry = $"{(key.Length > keyWidth ? key.Substring(0, keyWidth - 3) + "..." : key),-keyWidth}:" + From b8e349c930575c16e3af3d737b90058743d9c8a1 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Thu, 5 Mar 2026 14:49:01 -0500 Subject: [PATCH 38/46] fixes for AllUsage-2 parser --- ...ionExtensions.cs => ConfigBootstrapper.cs} | 0 .../StreamProcessor/JsonStreamProcessor.cs | 2 +- .../StreamProcessor/StreamProcessorFactory.cs | 28 +- .../Python/anthropoc-claude-sonnet-4.txt | 51 ++ test/nullserver/Python/aoai2.txt | 57 ++ .../Python/gemini-2.5-flash-lite.txt | 667 ++++++++++++++++++ 6 files changed, 794 insertions(+), 11 deletions(-) rename src/SimpleL7Proxy/Config/{BackendHostConfigurationExtensions.cs => ConfigBootstrapper.cs} (100%) create mode 100644 test/nullserver/Python/anthropoc-claude-sonnet-4.txt create mode 100644 test/nullserver/Python/aoai2.txt create mode 100644 test/nullserver/Python/gemini-2.5-flash-lite.txt diff --git a/src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs similarity index 100% rename from src/SimpleL7Proxy/Config/BackendHostConfigurationExtensions.cs rename to src/SimpleL7Proxy/Config/ConfigBootstrapper.cs diff --git a/src/SimpleL7Proxy/Proxy/StreamProcessor/JsonStreamProcessor.cs b/src/SimpleL7Proxy/Proxy/StreamProcessor/JsonStreamProcessor.cs index ef30869e..e9cc4bac 100644 --- a/src/SimpleL7Proxy/Proxy/StreamProcessor/JsonStreamProcessor.cs +++ b/src/SimpleL7Proxy/Proxy/StreamProcessor/JsonStreamProcessor.cs @@ -95,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; diff --git a/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs b/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs index 02abdd6a..7a353705 100644 --- a/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs +++ b/src/SimpleL7Proxy/Proxy/StreamProcessor/StreamProcessorFactory.cs @@ -23,13 +23,13 @@ public sealed class StreamProcessorFactory ["AllUsage"] = static () => new AllUsageProcessor(), ["DefaultStream"] = static () => DefaultStreamProcessorInstance, // Reuse singleton ["MultiLineAllUsage"] = static () => new MultiLineAllUsageProcessor(), - ["CompleteAllUsageProcessor"] = static () => new CompleteAllUsageProcessor() + ["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"; @@ -64,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 @@ -88,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; } @@ -111,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/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" +} +] From dc78296a327776b3c1e460c17344834fdee0733f Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Thu, 5 Mar 2026 15:34:07 -0500 Subject: [PATCH 39/46] reworked config code for reading from appConfig --- .../Config/AzureAppConfigurationExtensions.cs | 207 +++-- .../Config/ConfigBootstrapper.cs | 819 +++--------------- .../Config/ConfigChangeNotifer.cs | 32 +- src/SimpleL7Proxy/Config/ConfigComparer.cs | 89 ++ src/SimpleL7Proxy/Config/ConfigParser.cs | 642 ++++++++++++++ src/SimpleL7Proxy/Config/WarmOptions.cs | 127 ++- src/SimpleL7Proxy/Program.cs | 4 +- 7 files changed, 1108 insertions(+), 812 deletions(-) create mode 100644 src/SimpleL7Proxy/Config/ConfigComparer.cs create mode 100644 src/SimpleL7Proxy/Config/ConfigParser.cs diff --git a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs index 907b47b8..2698765c 100644 --- a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs @@ -192,6 +192,9 @@ public class AzureAppConfigurationRefreshService : BackgroundService 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( @@ -208,7 +211,17 @@ public AzureAppConfigurationRefreshService( _backendOptions = backendOptions; _logger = logger; _notifier = notifier; + // 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"), @@ -216,6 +229,8 @@ public AzureAppConfigurationRefreshService( _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))); } /// @@ -232,56 +247,156 @@ public IReadOnlyDictionary GetCurrentConfigurationDictionary() return _appConfigurationSnapshot.GetSnapshot(); } - private void CaptureWarmSettingsDictionary(bool alwaysLog = false) + private void CaptureWarmConfigurationSnapshot(bool alwaysLog = false) { - // Capture both Warm: and Cold: sections into the snapshot + // Capture Warm section into the snapshot. var warmKvps = _configuration .GetSection("Warm") .AsEnumerable(makePathsRelative: false) - .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) && kvp.Value != null); + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) + && kvp.Value != null + && _warmSnapshotKeys.Contains(kvp.Key)); - var coldKvps = _configuration - .GetSection("Cold") - .AsEnumerable(makePathsRelative: false) - .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) && kvp.Value != null); - - var dictionary = warmKvps.Concat(coldKvps) + 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 + Cold)", dictionary.Count); + _logger.LogInformation("[CONFIG] Configuration snapshot loaded ({Count} keys: Warm)", dictionary.Count); + } + } + + private (Dictionary ParsedWarmValues, List DetectedWarmChanges) ParseWarmRefreshCandidates(bool alwaysLog = false) + { + try + { + var currentOptions = _backendOptions.Value; + var (changes, parsedValues) = ConfigOptions.ParseWarmChanges(currentOptions, _configuration.GetSection("Warm"), _logger); + + if (alwaysLog || changes.Count > 0) + { + _logger.LogDebug("[CONFIG] ✓ Parsed {Count} warm option change candidate(s)", changes.Count); + } + + return (parsedValues, changes); + } + catch (Exception ex) + { + _logger.LogError(ex, "[CONFIG] ✗ Failed to parse warm settings"); + return (new Dictionary(StringComparer.OrdinalIgnoreCase), []); + } + } + + private List ApplyParsedWarmChanges(Dictionary parsedWarmValues, IReadOnlyList changesToApply) + { + var applied = 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); + applied.Add(change); } + + return applied; } - // private List ApplyDecoratedWarmOptions(bool alwaysLog = false) - // { - // try - // { - // var changes = ConfigOptions.ApplyWarmTo(_backendOptions.Value, _configuration.GetSection("Warm"), _logger); - - // if (alwaysLog || changes.Count > 0) - // { - // _logger.LogInformation("[CONFIG] ✓ Applied {Count} warm option change(s) to BackendOptions", changes.Count); - // } - - // return changes; - // } - // catch (Exception ex) - // { - // Console.WriteLine(ex.StackTrace); - // _logger.LogError(ex, "[CONFIG] ✗ Failed to apply warm settings to BackendOptions"); - // return []; - // } - // } + 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) { 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)) + .ToList(); + } + + private async Task ProcessRefreshCycleAsync(CancellationToken stoppingToken) + { + 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. + CaptureWarmConfigurationSnapshot(); + var (parsedWarmValues, detectedWarmChanges) = ParseWarmRefreshCandidates(); + + var appliedChanges = SetSubscribedConfigs(parsedWarmValues, detectedWarmChanges); + if (appliedChanges.Count == 0) + { + return; + } + + 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); + } + /// Reads the current Warm:Sentinel value from the configuration. private string? ReadSentinel() => _configuration["Warm:Sentinel"]; @@ -295,7 +410,7 @@ private bool HasSentinelChanged() if (string.Equals(_lastSentinel, current, StringComparison.Ordinal)) return false; - _logger.LogInformation("[CONFIG] Sentinel changed: {Old} → {New}", _lastSentinel ?? "(none)", current ?? "(none)"); + _logger.LogDebug("[CONFIG] Sentinel changed: {Old} → {New}", _lastSentinel ?? "(none)", current ?? "(none)"); _lastSentinel = current; return true; } @@ -335,7 +450,7 @@ private async Task EnsureInitialRefreshAsync(CancellationToken cancellationToken // 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". - CaptureWarmSettingsDictionary(alwaysLog: true); + CaptureWarmConfigurationSnapshot(alwaysLog: true); _initialRefreshCompleted = true; } @@ -361,26 +476,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(_refreshInterval, stoppingToken); - // try - // { - // var refreshed = await _refresher.TryRefreshAsync(stoppingToken); - - // if (refreshed && HasSentinelChanged()) - // { - // var changes = ApplyDecoratedWarmOptions(); - // CaptureWarmSettingsDictionary(); - - // if (changes.Count > 0) - // { - // _logger.LogInformation("[CONFIG] Configuration refresh: {Count} value(s) changed", changes.Count); - // await NotifySubscribersAsync(changes, stoppingToken); - // } - // } - // } - // catch (Exception ex) - // { - // _logger.LogWarning(ex, "[CONFIG] Configuration refresh failed - will retry"); - // } + 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/ConfigBootstrapper.cs b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs index f7a172c8..bd8a4fad 100644 --- a/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs +++ b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs @@ -25,10 +25,11 @@ namespace SimpleL7Proxy.Config; -public static class BackendHostConfigurationExtensions +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) @@ -60,7 +61,77 @@ public static BackendOptions CreateBackendOptions(ILogger logger) _logger?.LogInformation("[BOOTSTRAP] Applied {Count} App Configuration value(s) to effective environment", appConfigSettings.Count); } - return LoadBackendOptions(effectiveEnvironment); + var backendOptions = ConfigParser.ParseOptions(effectiveEnvironment, logger); + 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) @@ -88,53 +159,6 @@ private static int ReadEnvironmentVariableOrDefault(Dictionary e return value; } - private static int[] ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int[] defaultValues) - { - int[] value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValues); - EnvVars[variableName] = string.Join(",", value); - return value; - } - private static float ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, float defaultValue) - { - float value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); - EnvVars[variableName] = value.ToString(); - return value; - } - private static string ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, string defaultValue) - { - string value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); - EnvVars[variableName] = value; - return value; - } - private static string ReadEnvironmentVariableOrDefault(Dictionary env, string altVariableName, string variableName, string defaultValue) - { - // Try both variable names and use the first non-empty one - string? envValue = env.GetValueOrDefault(variableName)?.Trim() ?? env.GetValueOrDefault(altVariableName)?.Trim(); - - // Treat placeholder as unset - if (envValue == ConfigOptions.DefaultPlaceholder) envValue = null; - - // 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(Dictionary env, string variableName, IterationModeEnum defaultValue) - { - string? envValue = env.GetValueOrDefault(variableName)?.Trim(); - if (string.IsNullOrEmpty(envValue) || envValue == ConfigOptions.DefaultPlaceholder - || !Enum.TryParse(envValue, ignoreCase: 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 = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); @@ -178,61 +202,6 @@ private static int _ReadEnvironmentVariableOrDefault(Dictionary 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(Dictionary env, string variableName, int[] defaultValues) - { - var envValue = env.GetValueOrDefault(variableName); - if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) - { - //_logger?.LogWarning($"Using default: {variableName}: {string.Join(",", defaultValues)}"); - return defaultValues; - } - try - { - // Strip JSON-style square brackets (e.g. "[200, 202, 401]" → "200, 202, 401") - var trimmed = envValue.Trim(); - if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) - trimmed = trimmed[1..^1]; - - return trimmed.Split(',').Select(s => int.Parse(s.Trim())).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(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)) - { - // Try evaluating as a math expression (e.g. "0.5*2") - if (TryEvaluateMathExpression(envValue!, out var mathResult)) - return (float)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 string _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, string 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(); - } - // 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) @@ -247,265 +216,6 @@ private static bool _ReadEnvironmentVariableOrDefault(Dictionary } // 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, char delimiter = '=') - { - // Alternate delimiter to try when the primary doesn't produce a valid split - char fallback = delimiter == '=' ? ':' : '='; - Dictionary keyValuePairs = []; - - foreach (var item in list) - { - // Split into max 2 parts so values containing the delimiter are preserved - var kvp = item.Split(delimiter, 2); - if (kvp.Length == 2) - { - keyValuePairs.Add(kvp[0].Trim(), kvp[1].Trim()); - } - else - { - // Try the fallback delimiter (supports both '=' and ':' formats) - kvp = item.Split(fallback, 2); - if (kvp.Length == 2) - { - keyValuePairs.Add(kvp[0].Trim(), kvp[1].Trim()); - } - else - { - Console.WriteLine($"Could not parse {item} as a key-value pair (delimiter='{delimiter}' or '{fallback}'), ignoring"); - } - } - } - - return keyValuePairs; - } - - // Converts a comma-separated string to a list of strings. - private static List ToListOfString(string s) - { - if (String.IsNullOrEmpty(s)) - return []; - - // Strip JSON-style square brackets (e.g. "[a, b, c]" → "a, b, c") - var trimmed = s.Trim(); - if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) - trimmed = trimmed[1..^1]; - - return [.. trimmed.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(); - - // Strip JSON-style square brackets (e.g. "[a, b, c]" → "a, b, c") - var trimmed = s.Trim(); - if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) - trimmed = trimmed[1..^1]; - - return trimmed.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(); - - // Strip JSON-style square brackets (e.g. "[1, 2, 3]" → "1, 2, 3") - var trimmed = s.Trim(); - if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) - trimmed = trimmed[1..^1]; - - return trimmed.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; - } - - 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)); - } - - // 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) - { - // Fallback defaults — should match BackendOptions property initializers - 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", new[] { "connectionstring", "cs" } }, - { "namespace", new[] { "namespace", "ns" } }, - { "queue", new[] { "queue", "q" } }, - { "useMI", new[] { "usemi", "mi" } } - }; - - // Check if it's the new key=value format - if (IsNamedKeyValueFormat(parts, keyAliases)) - { - 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) - { - // Fallback defaults — should match BackendOptions property initializers - 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", new[] { "connectionstring", "cs" } }, - { "accountUri", new[] { "accounturi", "uri" } }, - { "useMI", new[] { "usemi", "mi" } } - }; - - // Check if it's the new key=value format - if (IsNamedKeyValueFormat(parts, keyAliases)) - { - 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) { @@ -584,111 +294,10 @@ private static SocketsHttpHandler getHandler(int initialDelaySecs, int IntervalS return handler; } - // Sets a single BackendOptions property from an environment variable, dispatching - // to the correct ReadEnvironmentVariableOrDefault overload based on the property type. - private static void ApplyFieldFromEnv(Dictionary env, BackendOptions target, BackendOptions defaults, string envVar, string property) + // Activates runtime resources derived from config (HttpClient/transport) after parsing. + private static void ConfigureHttpClientFromOptions(Dictionary env, BackendOptions backendOptions) { - var pi = typeof(BackendOptions).GetProperty(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)) - pi.SetValue(target, ToListOfString(ReadEnvironmentVariableOrDefault(env, envVar, string.Join(",", (List)defVal!)))); - else - throw new NotSupportedException($"ApplyFieldFromEnv: unsupported property type {type.Name} for {property}"); - } - - // Environment variable → BackendOptions property mappings for simple typed fields. - // ApplyFieldFromEnv dispatches to the correct reader based on the property's runtime type. - private static readonly (string envVar, string property)[] SimpleFields = [ - // int / double - ("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"), - // float - ("SuccessRate", "SuccessRate"), - ("UserPriorityThreshold", "UserPriorityThreshold"), - // string - ("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"), - // bool - ("AsyncModeEnabled", "AsyncModeEnabled"), - ("LogAllRequestHeaders", "LogAllRequestHeaders"), - ("LogAllResponseHeaders", "LogAllResponseHeaders"), - ("LogConsole", "LogConsole"), - ("LogConsoleEvent", "LogConsoleEvent"), - ("LogPoller", "LogPoller"), - ("LogProbes", "LogProbes"), - ("StorageDbEnabled", "StorageDbEnabled"), - ("UseOAuth", "UseOAuth"), - ("UseOAuthGov", "UseOAuthGov"), - ("UseProfiles", "UseProfiles"), - ("UserConfigRequired", "UserConfigRequired"), - // List - ("DependancyHeaders", "DependancyHeaders"), - ("DisallowedHeaders", "DisallowedHeaders"), - ("LogAllRequestHeadersExcept", "LogAllRequestHeadersExcept"), - ("LogAllResponseHeadersExcept", "LogAllResponseHeadersExcept"), - ("LogHeaders", "LogHeaders"), - ("PriorityKeys", "PriorityKeys"), - ("RequiredHeaders", "RequiredHeaders"), - ("StripRequestHeaders", "StripRequestHeaders"), - ("StripResponseHeaders", "StripResponseHeaders"), - ("UniqueUserHeaders", "UniqueUserHeaders"), - ]; - - // 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(Dictionary env) - { - // Read and set the DNS refresh timeout from environment variables or use the default value + // 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 @@ -718,8 +327,6 @@ private static BackendOptions LoadBackendOptions(Dictionary env) handler.EnableMultipleHttp2Connections = false; Console.WriteLine("Multiple HTTP/2 connections disabled."); } - // PooledConnectionIdleTimeout = TimeSpan.FromSeconds(KeepAliveIdleTimeoutSecs), - // Configure SSL handling if (ReadEnvironmentVariableOrDefault(env, "IgnoreSSLCert", false)) @@ -731,50 +338,13 @@ private static BackendOptions LoadBackendOptions(Dictionary env) 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. - _client.Timeout = Timeout.InfiniteTimeSpan; - - - string replicaID = ReadEnvironmentVariableOrDefault(env, "CONTAINER_APP_REPLICA_NAME", "01"); - var defOpts = new BackendOptions(); // Create a default options object to get default values for individual settings - - // Create and return a BackendOptions object populated with values from environment variables or default values. - // defOpts provides the single-source-of-truth defaults from BackendOptions property initializers. - var backendOptions = new BackendOptions + HttpClient client = new HttpClient(handler) { - Client = _client, - Hosts = new List(), - AcceptableStatusCodes = ReadEnvironmentVariableOrDefault(env, "AcceptableStatusCodes", defOpts.AcceptableStatusCodes), - IterationMode = ReadEnvironmentVariableOrDefault(env, "IterationMode", defOpts.IterationMode), - PriorityValues = ToListOfInt(ReadEnvironmentVariableOrDefault(env, "PriorityValues", string.Join(",", defOpts.PriorityValues))), - PriorityWorkers = KVIntPairs(ToListOfString(ReadEnvironmentVariableOrDefault(env, "PriorityWorkers", string.Join(",", defOpts.PriorityWorkers.Select(kv => $"{kv.Key}:{kv.Value}"))))), - UserIDFieldName = ReadEnvironmentVariableOrDefault(env, "LookupHeaderName", "UserIDFieldName", defOpts.UserIDFieldName), // migrate from LookupHeaderName - ValidateHeaders = KVStringPairs(ToListOfString(ReadEnvironmentVariableOrDefault(env, "ValidateHeaders", string.Join(",", defOpts.ValidateHeaders.Select(kv => $"{kv.Key}={kv.Value}"))))), + // set timeout to large to disable it at HttpClient level. Will use token cancellation for timeout instead. + Timeout = Timeout.InfiniteTimeSpan }; - // RegisterBackends will be called after DI container is built to avoid service provider dependency issues - - // Apply all simple typed fields from environment variables via reflection - foreach (var (envVar, property) in SimpleFields) - { - ApplyFieldFromEnv(env, backendOptions, defOpts, envVar, property); - } - - // Apply settings with unique patterns (composite config overrides, computed identity) - ApplyAsyncServiceBusOverrides(env, backendOptions, defOpts); - ApplyAsyncBlobStorageOverrides(env, backendOptions, defOpts); - ApplyReplicaIdentitySettings(env, backendOptions, replicaID); - - ValidatePrioritySettings(backendOptions, defOpts); - ParseHealthProbeSidecarSettings(env, backendOptions, defOpts); - ValidateHeaderSettings(env, backendOptions, defOpts); - ValidateLoadBalanceMode(env, backendOptions, defOpts); - - OutputEnvVars(); - - return backendOptions; + backendOptions.Client = client; } public static void RegisterBackends(BackendOptions backendOptions) @@ -820,206 +390,99 @@ public static void RegisterBackends(BackendOptions backendOptions) } } - private static void ApplyAsyncServiceBusOverrides(Dictionary env, BackendOptions opts, BackendOptions defOpts) + // ────────────────────────────────────────────────────────────────── + // Legacy parsing overloads (currently unused after ConfigParser extraction) + // ────────────────────────────────────────────────────────────────── + + private static int[] ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int[] defaultValues) { - // Parse composite config string, then allow individual env vars to override - var configStr = ReadEnvironmentVariableOrDefault(env, "AsyncSBConfig", defOpts.AsyncSBConfig); - var (connStr, namespace_, queue, useMI) = ParseServiceBusConfig(configStr); - opts.AsyncSBConfig = configStr; - opts.AsyncSBConnectionString = ReadEnvironmentVariableOrDefault(env, "AsyncSBConnectionString", connStr); - opts.AsyncSBNamespace = ReadEnvironmentVariableOrDefault(env, "AsyncSBNamespace", namespace_); - opts.AsyncSBQueue = ReadEnvironmentVariableOrDefault(env, "AsyncSBQueue", queue); - opts.AsyncSBUseMI = ReadEnvironmentVariableOrDefault(env, "AsyncSBUseMI", useMI); + int[] value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValues); + EnvVars[variableName] = string.Join(",", value); + return value; } - private static void ApplyAsyncBlobStorageOverrides(Dictionary env, BackendOptions opts, BackendOptions defOpts) + private static float ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, float defaultValue) { - // Parse composite config string, then allow individual env vars to override - var configStr = ReadEnvironmentVariableOrDefault(env, "AsyncBlobStorageConfig", defOpts.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); + float value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); + EnvVars[variableName] = value.ToString(); + return value; } - private static void ApplyReplicaIdentitySettings(Dictionary env, BackendOptions opts, string replicaID) + private static string ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, string defaultValue) { - opts.HostName = ReadEnvironmentVariableOrDefault(env, "Hostname", replicaID); - opts.IDStr = $"{ReadEnvironmentVariableOrDefault(env, "RequestIDPrefix", "S7P")}-{replicaID}-"; + string value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); + EnvVars[variableName] = value; + return value; } - private static void ValidatePrioritySettings( BackendOptions backendOptions, BackendOptions defOpts) + private static string ReadEnvironmentVariableOrDefault(Dictionary env, string altVariableName, string variableName, string defaultValue) { - // 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 {defOpts.DefaultPriority}"); - backendOptions.PriorityValues = Enumerable.Repeat(defOpts.DefaultPriority, 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]; - } + string? envValue = env.GetValueOrDefault(variableName)?.Trim() ?? env.GetValueOrDefault(altVariableName)?.Trim(); + if (envValue == ConfigOptions.DefaultPlaceholder) envValue = null; - 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; - } + string result = !string.IsNullOrEmpty(envValue) ? envValue : defaultValue; + EnvVars[variableName] = result; + return result; } - private static void ParseHealthProbeSidecarSettings(Dictionary envVars, BackendOptions backendOptions, BackendOptions defOpts) + private static IterationModeEnum ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, IterationModeEnum defaultValue) { - var healthSettings = backendOptions.HealthProbeSidecar.Split(';', StringSplitOptions.RemoveEmptyEntries); - foreach (var setting in healthSettings) + string? envValue = env.GetValueOrDefault(variableName)?.Trim(); + if (string.IsNullOrEmpty(envValue) || envValue == ConfigOptions.DefaultPlaceholder + || !Enum.TryParse(envValue, ignoreCase: true, out IterationModeEnum value)) { - 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; - } - } + EnvVars[variableName] = defaultValue.ToString(); + return defaultValue; } + EnvVars[variableName] = value.ToString(); + return value; } - private static void ValidateHeaderSettings(Dictionary envVars, BackendOptions backendOptions, BackendOptions defOpts) + private static int[] _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int[] defaultValues) { - // 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) + var envValue = env.GetValueOrDefault(variableName); + if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) { - 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); - } - } + return defaultValues; } - } - private static void ValidateLoadBalanceMode(Dictionary envVars, BackendOptions backendOptions, BackendOptions defOpts) - { - backendOptions.LoadBalanceMode = backendOptions.LoadBalanceMode.Trim().ToLower(); - if (backendOptions.LoadBalanceMode != Constants.Latency && - backendOptions.LoadBalanceMode != Constants.RoundRobin && - backendOptions.LoadBalanceMode != Constants.Random) + 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 (Exception) { - Console.WriteLine($"Invalid LoadBalanceMode: {backendOptions.LoadBalanceMode}. Defaulting to '{defOpts.LoadBalanceMode}'."); - backendOptions.LoadBalanceMode = defOpts.LoadBalanceMode; + _logger?.LogWarning($"Could not parse {variableName} as an integer array, using default: {string.Join(",", defaultValues)}"); + return defaultValues; } } - private static void OutputEnvVars() + private static float _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, float defaultValue) { - static string MaskValue(string key, string value) + var envValue = env.GetValueOrDefault(variableName); + if (envValue?.Trim() == ConfigOptions.DefaultPlaceholder) envValue = null; + if (!float.TryParse(envValue, out var 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)}"; + if (TryEvaluateMathExpression(envValue!, out var mathResult)) + return (float)mathResult; + return defaultValue; } + return value; + } - 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 = MaskValue(key, 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) + private static string _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, string defaultValue) + { + var envValue = env.GetValueOrDefault(variableName); + if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) { - Console.WriteLine(); + return defaultValue; } + return envValue.Trim(); } + } diff --git a/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs b/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs index 057f7714..544e9a61 100644 --- a/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs +++ b/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs @@ -84,8 +84,36 @@ public void Unsubscribe(IConfigChangeSubscriber subscriber) _logger.LogInformation("[CONFIG] Subscriber removed: {Name}", subscriber.GetType().Name); } - /// Number of active subscriptions. - public int Count { get { lock (_lock) { return _subscriptions.Count; } } } + /// + /// 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. diff --git a/src/SimpleL7Proxy/Config/ConfigComparer.cs b/src/SimpleL7Proxy/Config/ConfigComparer.cs new file mode 100644 index 00000000..935ac799 --- /dev/null +++ b/src/SimpleL7Proxy/Config/ConfigComparer.cs @@ -0,0 +1,89 @@ +using System.Collections; +using System.Reflection; + +namespace SimpleL7Proxy.Config; + +public static class ConfigComparer +{ + public static IReadOnlyDictionary GetChangedOptions(BackendOptions previousOptions, BackendOptions currentOptions) + { + var changes = new SortedDictionary(StringComparer.Ordinal); + + foreach (var prop in typeof(BackendOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.CanRead) + continue; + + if (prop.Name == nameof(BackendOptions.Client) || prop.Name == nameof(BackendOptions.Hosts)) + continue; + + var oldValue = prop.GetValue(previousOptions); + var newValue = prop.GetValue(currentOptions); + + if (!AreOptionValuesEqual(oldValue, newValue)) + changes[prop.Name] = (oldValue, newValue); + } + + return changes; + } + + private static bool AreOptionValuesEqual(object? left, object? right) + { + if (ReferenceEquals(left, right)) + return true; + + if (left is null || right is null) + return false; + + if (left is IDictionary leftDict && right is IDictionary rightDict) + return AreDictionariesEqual(leftDict, rightDict); + + if (left is string || right is string) + return Equals(left, right); + + if (left is IEnumerable leftEnumerable && right is IEnumerable rightEnumerable) + return AreEnumerablesEqual(leftEnumerable, rightEnumerable); + + return Equals(left, right); + } + + private static bool AreDictionariesEqual(IDictionary left, IDictionary right) + { + if (left.Count != right.Count) + return false; + + foreach (DictionaryEntry entry in left) + { + if (!right.Contains(entry.Key)) + return false; + + if (!AreOptionValuesEqual(entry.Value, right[entry.Key])) + return false; + } + + return true; + } + + private static bool AreEnumerablesEqual(IEnumerable left, IEnumerable right) + { + var leftItems = new List(); + var rightItems = new List(); + + foreach (var item in left) + leftItems.Add(item); + + foreach (var item in right) + rightItems.Add(item); + + if (leftItems.Count != rightItems.Count) + return false; + + for (int i = 0; i < leftItems.Count; i++) + { + if (!AreOptionValuesEqual(leftItems[i], rightItems[i])) + return false; + } + + return true; + } +} diff --git a/src/SimpleL7Proxy/Config/ConfigParser.cs b/src/SimpleL7Proxy/Config/ConfigParser.cs new file mode 100644 index 00000000..600d8bc3 --- /dev/null +++ b/src/SimpleL7Proxy/Config/ConfigParser.cs @@ -0,0 +1,642 @@ +using Microsoft.Extensions.Logging; +using SimpleL7Proxy.Backend.Iterators; + +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 ILogger? _logger; + + 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, ILogger? logger) + { + _logger = logger; + 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); + + ParseHealthProbeSidecarSettings(opts); + ValidatePrioritySettings(opts, defaults); + ValidateHeaderSettings(opts); + ValidateLoadBalanceMode(opts, defaults); + + return opts; + } + + public static IReadOnlyDictionary GetParsedEnvVars() + { + return new Dictionary(EnvVars, StringComparer.OrdinalIgnoreCase); + } + + 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}"); + } + } + + 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/WarmOptions.cs b/src/SimpleL7Proxy/Config/WarmOptions.cs index 358b7c54..e9a44c25 100644 --- a/src/SimpleL7Proxy/Config/WarmOptions.cs +++ b/src/SimpleL7Proxy/Config/WarmOptions.cs @@ -114,6 +114,10 @@ public sealed class ConfigOptionDescriptor 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)); /// All discovered config option descriptors. public static IReadOnlyList Descriptors => _descriptors.Value; @@ -123,7 +127,7 @@ public static class ConfigOptions /// Returns only warm (hot-reloadable) descriptors. public static IReadOnlyList GetWarmDescriptors() => - Descriptors.Where(d => d.Mode == ConfigMode.Warm).ToList(); + _warmDescriptors.Value; /// Returns only publishable (Warm + Cold) descriptors. public static IReadOnlyList GetPublishableDescriptors() => @@ -145,46 +149,70 @@ public static IReadOnlyList GetPublishableDescriptors() /// public static List ApplyWarmTo(BackendOptions target, IConfiguration warmSection, ILogger? logger = null) { - var changes = new List(); + var (changes, parsedValues) = ParseWarmChanges(target, warmSection, logger); - foreach (var descriptor in Descriptors) + foreach (var change in changes) { - if (descriptor.Mode != ConfigMode.Warm) + 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; + } + + /// + /// Parses warm-mode values and returns only changed options plus parsed new values. + /// Does not mutate . + /// + public static (List Changes, Dictionary ParsedValues) ParseWarmChanges( + BackendOptions current, + IConfiguration warmSection, + ILogger? logger = null) + { + var changes = new List(); + var parsedValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var descriptor in _warmDescriptors.Value) + { var section = warmSection.GetSection(descriptor.Attribute.KeyPath); if (!section.Exists()) continue; var rawValue = section.Value; - // "-" means "use built-in default" — skip this key - if (rawValue == DefaultPlaceholder) + if (string.IsNullOrEmpty(rawValue) || rawValue == DefaultPlaceholder) continue; - if (string.IsNullOrEmpty(rawValue)) - continue; + var currentValue = descriptor.Property.GetValue(current); - var newValue = ParseValue(rawValue, descriptor.Property.PropertyType); - if (newValue == null) + var env = new Dictionary(StringComparer.OrdinalIgnoreCase) { - logger?.LogWarning("[CONFIG] Could not parse {Property} value '{Raw}' as {Type}", - descriptor.Property.Name, rawValue, descriptor.Property.PropertyType.Name); - continue; - } + [descriptor.ConfigName] = rawValue + }; + + var parsedTarget = new BackendOptions(); + ConfigParser.ApplyFieldFromEnv( + env, + parsedTarget, + current, + descriptor.ConfigName, + descriptor.Property.Name); - var currentValue = descriptor.Property.GetValue(target); + var newValue = descriptor.Property.GetValue(parsedTarget); - // Skip if the value hasn't actually changed (compare string representations for collections) var oldStr = currentValue?.ToString(); var newStr = newValue.ToString(); if (oldStr == newStr) continue; - descriptor.Property.SetValue(target, newValue); - logger?.LogInformation("[CONFIG] Updated {Property}: {Old} → {New}", - descriptor.ConfigName, oldStr, newStr); - + parsedValues[descriptor.ConfigName] = newValue; changes.Add(new ConfigChange { PropertyName = descriptor.ConfigName, @@ -194,64 +222,7 @@ public static List ApplyWarmTo(BackendOptions target, IConfigurati }); } - return changes; - } - - /// - /// Parses a raw string value from App Configuration into the target CLR type. - /// Handles the same types used in BackendOptions: string, bool, int, float, - /// int[], string[], List<string>, List<int>, Dictionary<string,string>. - /// Strips JSON-style brackets from collection values. - /// - private static object? ParseValue(string raw, Type targetType) - { - if (targetType == typeof(string)) - return raw; - - if (targetType == typeof(bool)) - return raw.Equals("true", StringComparison.OrdinalIgnoreCase) ? true - : raw.Equals("false", StringComparison.OrdinalIgnoreCase) ? false - : null; - - if (targetType == typeof(int)) - return int.TryParse(raw, out var i) ? i : null; - - if (targetType == typeof(float)) - return float.TryParse(raw, out var f) ? f : null; - - if (targetType == typeof(double)) - return double.TryParse(raw, out var d) ? d : null; - - // Strip JSON brackets for collection types - var trimmed = raw.Trim(); - if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) - trimmed = trimmed[1..^1]; - - if (targetType == typeof(int[])) - return trimmed.Split(',').Select(s => int.Parse(s.Trim())).ToArray(); - - if (targetType == typeof(string[])) - return trimmed.Split(',').Select(s => s.Trim()).ToArray(); - - if (targetType == typeof(List)) - return trimmed.Split(',').Select(s => s.Trim()).ToList(); - - if (targetType == typeof(List)) - return trimmed.Split(',').Select(s => int.Parse(s.Trim())).ToList(); - - if (targetType == typeof(Dictionary)) - { - var dict = new Dictionary(); - foreach (var pair in trimmed.Split(',')) - { - var kvp = pair.Split('=', 2); - if (kvp.Length == 2) - dict[kvp[0].Trim()] = kvp[1].Trim(); - } - return dict; - } - - return null; + return (changes, parsedValues); } private static IReadOnlyList DiscoverDescriptors() diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index 1d9fbddf..7174fc0d 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -121,7 +121,7 @@ public static async Task Main(string[] args) HostConfig.Initialize(backendTokenProvider, startupLogger, serviceProvider); // Register backends after DI container is built and HostConfig is initialized - BackendHostConfigurationExtensions.RegisterBackends(options.Value); + ConfigBootstrapper.RegisterBackends(options.Value); try { @@ -285,7 +285,7 @@ private static void ConfigureDependencyInjection(IServiceCollection services, IL } - var backendOptions = BackendHostConfigurationExtensions.CreateBackendOptions(startupLogger); + var backendOptions = ConfigBootstrapper.CreateBackendOptions(startupLogger); services.AddBackendHostConfiguration(startupLogger, backendOptions); // Wire up Azure App Configuration warm-refresh service (no-op if AZURE_APPCONFIG_ENDPOINT is not set) From b01f870271b5cfeb47fe4e913cc3537d340a7b21 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Thu, 5 Mar 2026 15:38:29 -0500 Subject: [PATCH 40/46] remove unused file --- src/SimpleL7Proxy/Config/ConfigComparer.cs | 89 ---------------------- 1 file changed, 89 deletions(-) delete mode 100644 src/SimpleL7Proxy/Config/ConfigComparer.cs diff --git a/src/SimpleL7Proxy/Config/ConfigComparer.cs b/src/SimpleL7Proxy/Config/ConfigComparer.cs deleted file mode 100644 index 935ac799..00000000 --- a/src/SimpleL7Proxy/Config/ConfigComparer.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Collections; -using System.Reflection; - -namespace SimpleL7Proxy.Config; - -public static class ConfigComparer -{ - public static IReadOnlyDictionary GetChangedOptions(BackendOptions previousOptions, BackendOptions currentOptions) - { - var changes = new SortedDictionary(StringComparer.Ordinal); - - foreach (var prop in typeof(BackendOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (!prop.CanRead) - continue; - - if (prop.Name == nameof(BackendOptions.Client) || prop.Name == nameof(BackendOptions.Hosts)) - continue; - - var oldValue = prop.GetValue(previousOptions); - var newValue = prop.GetValue(currentOptions); - - if (!AreOptionValuesEqual(oldValue, newValue)) - changes[prop.Name] = (oldValue, newValue); - } - - return changes; - } - - private static bool AreOptionValuesEqual(object? left, object? right) - { - if (ReferenceEquals(left, right)) - return true; - - if (left is null || right is null) - return false; - - if (left is IDictionary leftDict && right is IDictionary rightDict) - return AreDictionariesEqual(leftDict, rightDict); - - if (left is string || right is string) - return Equals(left, right); - - if (left is IEnumerable leftEnumerable && right is IEnumerable rightEnumerable) - return AreEnumerablesEqual(leftEnumerable, rightEnumerable); - - return Equals(left, right); - } - - private static bool AreDictionariesEqual(IDictionary left, IDictionary right) - { - if (left.Count != right.Count) - return false; - - foreach (DictionaryEntry entry in left) - { - if (!right.Contains(entry.Key)) - return false; - - if (!AreOptionValuesEqual(entry.Value, right[entry.Key])) - return false; - } - - return true; - } - - private static bool AreEnumerablesEqual(IEnumerable left, IEnumerable right) - { - var leftItems = new List(); - var rightItems = new List(); - - foreach (var item in left) - leftItems.Add(item); - - foreach (var item in right) - rightItems.Add(item); - - if (leftItems.Count != rightItems.Count) - return false; - - for (int i = 0; i < leftItems.Count; i++) - { - if (!AreOptionValuesEqual(leftItems[i], rightItems[i])) - return false; - } - - return true; - } -} From 770db54c62a80f4387a7823265d698a4f047e9f6 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Thu, 5 Mar 2026 15:45:44 -0500 Subject: [PATCH 41/46] call subscribers once when multiple configs change --- .../Config/ConfigBootstrapper.cs | 95 ------------------- .../Config/ConfigChangeNotifer.cs | 34 ++++++- src/SimpleL7Proxy/Config/WarmOptions.cs | 2 +- 3 files changed, 32 insertions(+), 99 deletions(-) diff --git a/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs index bd8a4fad..60b5a329 100644 --- a/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs +++ b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs @@ -390,99 +390,4 @@ public static void RegisterBackends(BackendOptions backendOptions) } } - // ────────────────────────────────────────────────────────────────── - // Legacy parsing overloads (currently unused after ConfigParser extraction) - // ────────────────────────────────────────────────────────────────── - - private static int[] ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, int[] defaultValues) - { - int[] value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValues); - EnvVars[variableName] = string.Join(",", value); - return value; - } - - private static float ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, float defaultValue) - { - float value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); - EnvVars[variableName] = value.ToString(); - return value; - } - - private static string ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, string defaultValue) - { - string value = _ReadEnvironmentVariableOrDefault(env, variableName, defaultValue); - EnvVars[variableName] = value; - return value; - } - - private static string ReadEnvironmentVariableOrDefault(Dictionary env, string altVariableName, string variableName, string defaultValue) - { - string? envValue = env.GetValueOrDefault(variableName)?.Trim() ?? env.GetValueOrDefault(altVariableName)?.Trim(); - if (envValue == ConfigOptions.DefaultPlaceholder) envValue = null; - - string result = !string.IsNullOrEmpty(envValue) ? envValue : defaultValue; - EnvVars[variableName] = result; - return result; - } - - 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, ignoreCase: true, out IterationModeEnum value)) - { - EnvVars[variableName] = defaultValue.ToString(); - return defaultValue; - } - EnvVars[variableName] = value.ToString(); - return value; - } - - private static int[] _ReadEnvironmentVariableOrDefault(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 (Exception) - { - _logger?.LogWarning($"Could not parse {variableName} as an integer array, using default: {string.Join(",", defaultValues)}"); - return defaultValues; - } - } - - private static float _ReadEnvironmentVariableOrDefault(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!, out var mathResult)) - return (float)mathResult; - return defaultValue; - } - return value; - } - - private static string _ReadEnvironmentVariableOrDefault(Dictionary env, string variableName, string defaultValue) - { - var envValue = env.GetValueOrDefault(variableName); - if (string.IsNullOrEmpty(envValue) || envValue.Trim() == ConfigOptions.DefaultPlaceholder) - { - return defaultValue; - } - return envValue.Trim(); - } - - } diff --git a/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs b/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs index 544e9a61..6605f3d8 100644 --- a/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs +++ b/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs @@ -134,11 +134,21 @@ internal async Task NotifyAsync( snapshot = [.. _subscriptions]; } - foreach (var sub in snapshot) + // 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.Filter != null - ? changes.Where(c => sub.Filter.Contains(c.PropertyName)).ToList() + var relevant = sub.MergedFilter != null + ? changes.Where(c => sub.MergedFilter.Contains(c.PropertyName)).ToList() : (IReadOnlyList)changes; if (relevant.Count == 0) continue; @@ -156,6 +166,24 @@ internal async Task NotifyAsync( } } + 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; + } + /// Tracks a subscriber and its optional field filter. private sealed record Subscription(IConfigChangeSubscriber Subscriber, HashSet? Filter); diff --git a/src/SimpleL7Proxy/Config/WarmOptions.cs b/src/SimpleL7Proxy/Config/WarmOptions.cs index e9a44c25..49b92b5d 100644 --- a/src/SimpleL7Proxy/Config/WarmOptions.cs +++ b/src/SimpleL7Proxy/Config/WarmOptions.cs @@ -208,7 +208,7 @@ public static (List Changes, Dictionary ParsedVal var newValue = descriptor.Property.GetValue(parsedTarget); var oldStr = currentValue?.ToString(); - var newStr = newValue.ToString(); + var newStr = newValue?.ToString(); if (oldStr == newStr) continue; From 7483ba1b5003e8d2b5f6dc10c8c8e5cefdad47e2 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 6 Mar 2026 12:45:04 -0500 Subject: [PATCH 42/46] refactor and improve efficiency --- src/SimpleL7Proxy/Config/WarmOptions.cs | 82 +++++++++++----------- src/SimpleL7Proxy/Events/EventHubClient.cs | 36 ++++++---- 2 files changed, 63 insertions(+), 55 deletions(-) diff --git a/src/SimpleL7Proxy/Config/WarmOptions.cs b/src/SimpleL7Proxy/Config/WarmOptions.cs index 49b92b5d..39dafe9b 100644 --- a/src/SimpleL7Proxy/Config/WarmOptions.cs +++ b/src/SimpleL7Proxy/Config/WarmOptions.cs @@ -140,44 +140,47 @@ public static IReadOnlyList GetPublishableDescriptors() /// 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) = ParseWarmChanges(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; - } + // /// + // /// 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; + // } /// - /// Parses warm-mode values and returns only changed options plus parsed new values. - /// Does not mutate . + /// Detects warm-mode values that differ from the live options and returns + /// the changes plus their parsed new values. + /// Does not mutate . /// - public static (List Changes, Dictionary ParsedValues) ParseWarmChanges( - BackendOptions current, + public static (List Changes, Dictionary ParsedValues) DetectWarmChanges( + BackendOptions liveOptions, IConfiguration warmSection, ILogger? logger = null) { var changes = new List(); var parsedValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + var parsedTarget = new BackendOptions(); + var env = new Dictionary(1, StringComparer.OrdinalIgnoreCase); foreach (var descriptor in _warmDescriptors.Value) { @@ -190,26 +193,21 @@ public static (List Changes, Dictionary ParsedVal if (string.IsNullOrEmpty(rawValue) || rawValue == DefaultPlaceholder) continue; - var currentValue = descriptor.Property.GetValue(current); + var currentValue = descriptor.Property.GetValue(liveOptions); - var env = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [descriptor.ConfigName] = rawValue - }; + env.Clear(); + env[descriptor.ConfigName] = rawValue; - var parsedTarget = new BackendOptions(); ConfigParser.ApplyFieldFromEnv( env, parsedTarget, - current, + liveOptions, descriptor.ConfigName, descriptor.Property.Name); var newValue = descriptor.Property.GetValue(parsedTarget); - var oldStr = currentValue?.ToString(); - var newStr = newValue?.ToString(); - if (oldStr == newStr) + if (Equals(currentValue, newValue)) continue; parsedValues[descriptor.ConfigName] = newValue; @@ -217,8 +215,8 @@ public static (List Changes, Dictionary ParsedVal { PropertyName = descriptor.ConfigName, KeyPath = descriptor.Attribute.KeyPath, - OldValue = oldStr, - NewValue = newStr + RawOldValue = currentValue, + RawNewValue = newValue }); } diff --git a/src/SimpleL7Proxy/Events/EventHubClient.cs b/src/SimpleL7Proxy/Events/EventHubClient.cs index ef833a9f..cc57b8f5 100644 --- a/src/SimpleL7Proxy/Events/EventHubClient.cs +++ b/src/SimpleL7Proxy/Events/EventHubClient.cs @@ -9,8 +9,9 @@ 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; @@ -48,7 +49,7 @@ public EventHubClient(CompositeEventClient composite, ILogger lo 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."); @@ -56,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)) @@ -75,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"; @@ -234,4 +226,22 @@ public void SendData(string? value) // 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); + } } From 75dc7bd5f32ba1d404e5cb9cc3e688d4e66b6e7f Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 6 Mar 2026 12:46:05 -0500 Subject: [PATCH 43/46] remove references to strings that may not be used but get refershed every 30 seconds --- src/SimpleL7Proxy/Config/ConfigChange.cs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/SimpleL7Proxy/Config/ConfigChange.cs b/src/SimpleL7Proxy/Config/ConfigChange.cs index 7943f876..e88720fe 100644 --- a/src/SimpleL7Proxy/Config/ConfigChange.cs +++ b/src/SimpleL7Proxy/Config/ConfigChange.cs @@ -2,18 +2,33 @@ 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; } - /// Previous value (as string) before the change, or null if unknown. - public string? OldValue { 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. - public string? NewValue { get; init; } + /// New value (as string) after the change. Computed lazily from . + public string? NewValue => _newValue?.ToString(); } \ No newline at end of file From a9fd5febd94ba88c100ab8994f47a8b2101aa2f6 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 6 Mar 2026 14:50:12 -0500 Subject: [PATCH 44/46] get host parsing to read changes --- .../Config/AzureAppConfigurationExtensions.cs | 157 ++++++++++-------- .../Config/ConfigBootstrapper.cs | 84 ++++++++-- .../Config/ConfigChangeNotifer.cs | 69 ++++++++ src/SimpleL7Proxy/Config/ConfigParser.cs | 145 +++++++++++++++- src/SimpleL7Proxy/Config/WarmOptions.cs | 103 +++++++++--- 5 files changed, 450 insertions(+), 108 deletions(-) diff --git a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs index 2698765c..38f60dfe 100644 --- a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs @@ -42,32 +42,42 @@ public IReadOnlyDictionary GetSnapshot() /// Call at the beginning of Main (before building the host), /// then at the top of LoadBackendOptions. ///
-public static class AppConfigBootstrap +public class AppConfigBootstrap { - private static Task?>? _downloadTask; - private static ILogger? _logger; + 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 static void Start(ILogger logger) + public void Start() { - _logger = logger; - - var endpoint = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_ENDPOINT"); - var connectionString = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_CONNECTION_STRING"); - - if (string.IsNullOrEmpty(endpoint) && string.IsNullOrEmpty(connectionString)) + if (string.IsNullOrEmpty(_endpoint) && string.IsNullOrEmpty(_connectionString)) { - logger.LogInformation("[BOOTSTRAP] App Configuration not configured, skipping bootstrap download"); + _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(endpoint, connectionString, logger)); + _logger.LogInformation("[BOOTSTRAP] Starting App Configuration bootstrap download..."); + _downloadTask = Task.Run(DownloadConfig); } /// @@ -76,7 +86,7 @@ public static void Start(ILogger logger) /// Returns null if not configured or download failed. /// Safe to call when Start was never called (no-op). /// - public static Dictionary? WaitForDownload() + public Dictionary? WaitForDownload() { if (_downloadTask == null) return null; @@ -87,31 +97,27 @@ public static void Start(ILogger logger) } catch (Exception ex) { - _logger?.LogWarning(ex, "[BOOTSTRAP] Failed awaiting App Configuration download"); + _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"); + _logger.LogInformation("[BOOTSTRAP] No App Configuration settings downloaded"); return null; } - _logger?.LogInformation("[BOOTSTRAP] Retrieved {Count} App Configuration value(s)", settings.Count); + _logger.LogInformation("[BOOTSTRAP] Retrieved {Count} App Configuration value(s)", settings.Count); return settings; } - private static Dictionary? DownloadConfig(string? endpoint, string? connectionString, ILogger logger) + private Dictionary? DownloadConfig() { try { - var labelFilter = Environment.GetEnvironmentVariable("AZURE_APPCONFIG_LABEL"); - if (string.IsNullOrEmpty(labelFilter) || labelFilter == "\\0" || labelFilter == "\0") - labelFilter = null; // null = no label filter - - ConfigurationClient client = !string.IsNullOrEmpty(endpoint) - ? new ConfigurationClient(new Uri(endpoint), new DefaultAzureCredential()) - : new ConfigurationClient(connectionString); + 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" @@ -125,7 +131,7 @@ public static void Start(ILogger logger) foreach (var prefix in new[] { "Warm:", "Cold:" }) { - var selector = new SettingSelector { KeyFilter = $"{prefix}*", LabelFilter = labelFilter }; + var selector = new SettingSelector { KeyFilter = $"{prefix}*", LabelFilter = _labelFilter }; foreach (var setting in client.GetConfigurationSettings(selector)) { // Strip prefix: "Warm:Logging:LogConsole" → "Logging:LogConsole" @@ -138,24 +144,32 @@ public static void Start(ILogger logger) if (keyPathToEnvVar.TryGetValue(keyPath, out var envVarName)) { settings[envVarName] = setting.Value ?? ""; - logger.LogDebug("[BOOTSTRAP] {Key} → {EnvVar}", setting.Key, envVarName); + _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.LogDebug("[BOOTSTRAP] No descriptor for key {Key}, skipping", setting.Key); } } } - logger.LogInformation("[BOOTSTRAP] ✓ Downloaded {Count} setting(s) from App Configuration", settings.Count); + _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"); + _logger.LogWarning(ex, "[BOOTSTRAP] ✗ App Configuration download failed — continuing with env vars only"); return null; } } + } /// @@ -211,6 +225,7 @@ public AzureAppConfigurationRefreshService( _backendOptions = backendOptions; _logger = logger; _notifier = notifier; + // 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(); @@ -247,15 +262,28 @@ public IReadOnlyDictionary GetCurrentConfigurationDictionary() return _appConfigurationSnapshot.GetSnapshot(); } - private void CaptureWarmConfigurationSnapshot(bool alwaysLog = false) + /// + /// 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. + // 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)); + && (_warmSnapshotKeys.Contains(kvp.Key) + || ConfigParser.IsBackendHostConfigName(kvp.Key["Warm:".Length..]))); var dictionary = warmKvps .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!, StringComparer.OrdinalIgnoreCase); @@ -266,32 +294,14 @@ private void CaptureWarmConfigurationSnapshot(bool alwaysLog = false) { _logger.LogInformation("[CONFIG] Configuration snapshot loaded ({Count} keys: Warm)", dictionary.Count); } - } - private (Dictionary ParsedWarmValues, List DetectedWarmChanges) ParseWarmRefreshCandidates(bool alwaysLog = false) - { - try - { - var currentOptions = _backendOptions.Value; - var (changes, parsedValues) = ConfigOptions.ParseWarmChanges(currentOptions, _configuration.GetSection("Warm"), _logger); - - if (alwaysLog || changes.Count > 0) - { - _logger.LogDebug("[CONFIG] ✓ Parsed {Count} warm option change candidate(s)", changes.Count); - } - - return (parsedValues, changes); - } - catch (Exception ex) - { - _logger.LogError(ex, "[CONFIG] ✗ Failed to parse warm settings"); - return (new Dictionary(StringComparer.OrdinalIgnoreCase), []); - } + 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) @@ -309,9 +319,12 @@ private List ApplyParsedWarmChanges(Dictionary pa } descriptor.Property.SetValue(target, value); + changedProperties.Add(descriptor.Property); applied.Add(change); } + ConfigParser.ApplyDerivedSettings(target, [.. changedProperties]); + return applied; } @@ -362,12 +375,14 @@ private List SelectSubscribedChanges(IReadOnlyList d } return detectedWarmChanges - .Where(change => subscribedFields.Contains(change.PropertyName)) + .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()) @@ -377,24 +392,34 @@ private async Task ProcessRefreshCycleAsync(CancellationToken stoppingToken) // Read refreshed Warm configuration into snapshot first, then parse // BackendOptions change candidates from the same refreshed view. - CaptureWarmConfigurationSnapshot(); - var (parsedWarmValues, detectedWarmChanges) = ParseWarmRefreshCandidates(); + 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); - if (appliedChanges.Count == 0) + + // Notify subscribers for descriptor-backed (non-host) changes. + if (appliedChanges.Count > 0) { - return; - } + var changedProperties = string.Join(", ", appliedChanges.Select(c => c.PropertyName)); + + _logger.LogInformation("[CONFIG] Warm config changes detected: {Count} changed config(s) ({Configs})", + appliedChanges.Count, + changedProperties); - var changedProperties = string.Join(", ", appliedChanges.Select(c => c.PropertyName)); + _logger.LogDebug("[CONFIG] Updated BackendOptions fields: {Fields}", + changedProperties); - _logger.LogInformation("[CONFIG] Warm config changes detected: {Count} changed config(s) ({Configs})", - appliedChanges.Count, - changedProperties); + await NotifySubscribersAsync(appliedChanges, stoppingToken); + } - _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); + } } /// Reads the current Warm:Sentinel value from the configuration. diff --git a/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs index 60b5a329..f89c6d14 100644 --- a/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs +++ b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs @@ -32,7 +32,7 @@ public static class ConfigBootstrapper private static readonly BackendOptions s_defaults = new(); - public static BackendOptions CreateBackendOptions(ILogger logger) + public static BackendOptions CreateBackendOptions(ILogger logger, AppConfigBootstrap appConfigBootstrap) { Dictionary effectiveEnvironment = new Dictionary(StringComparer.OrdinalIgnoreCase); _logger = logger; @@ -48,7 +48,7 @@ public static BackendOptions CreateBackendOptions(ILogger logger) // 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(); + var appConfigSettings = appConfigBootstrap.WaitForDownload(); if (appConfigSettings != null) { foreach (var kvp in appConfigSettings) @@ -61,7 +61,7 @@ public static BackendOptions CreateBackendOptions(ILogger logger) _logger?.LogInformation("[BOOTSTRAP] Applied {Count} App Configuration value(s) to effective environment", appConfigSettings.Count); } - var backendOptions = ConfigParser.ParseOptions(effectiveEnvironment, logger); + var backendOptions = ConfigParser.ParseOptions(effectiveEnvironment); ConfigureHttpClientFromOptions(effectiveEnvironment, backendOptions); OutputEnvVars(); @@ -347,19 +347,75 @@ private static void ConfigureHttpClientFromOptions(Dictionary en backendOptions.Client = client; } - public static void RegisterBackends(BackendOptions backendOptions) + /// + /// Clears and re-populates by iterating + /// over Host1..N, Probe_path1..N, and IP1..N keys. + /// + /// 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. + /// + public static void RegisterBackends(BackendOptions backendOptions, IConfiguration? configuration = null, Dictionary? cfg = 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 hostname = Environment.GetEnvironmentVariable($"Host{i}")?.Trim(); + var hostKey = $"Host{i}"; + var probePathKey = $"Probe_path{i}"; + var ipKey = $"IP{i}"; + + + var hostname = ReadWithFallback(hostKey); if (string.IsNullOrEmpty(hostname)) break; - var probePath = Environment.GetEnvironmentVariable($"Probe_path{i}")?.Trim(); - var ip = Environment.GetEnvironmentVariable($"IP{i}")?.Trim(); + 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 { @@ -381,13 +437,21 @@ public static void RegisterBackends(BackendOptions backendOptions) i++; } - if (Environment.GetEnvironmentVariable("APPENDHOSTSFILE")?.Trim().Equals("true", StringComparison.OrdinalIgnoreCase) == true || - Environment.GetEnvironmentVariable("AppendHostsFile")?.Trim().Equals("true", StringComparison.OrdinalIgnoreCase) == true) + 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. + } } diff --git a/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs b/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs index 6605f3d8..6f1469ae 100644 --- a/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs +++ b/src/SimpleL7Proxy/Config/ConfigChangeNotifer.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using System.Linq.Expressions; namespace SimpleL7Proxy.Config; @@ -74,6 +75,30 @@ public IConfigChangeSubscriber Subscribe( 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) { @@ -184,6 +209,50 @@ internal async Task NotifyAsync( 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); diff --git a/src/SimpleL7Proxy/Config/ConfigParser.cs b/src/SimpleL7Proxy/Config/ConfigParser.cs index 600d8bc3..84c9ddb0 100644 --- a/src/SimpleL7Proxy/Config/ConfigParser.cs +++ b/src/SimpleL7Proxy/Config/ConfigParser.cs @@ -1,5 +1,6 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; using SimpleL7Proxy.Backend.Iterators; +using System.Reflection; namespace SimpleL7Proxy.Config; @@ -8,7 +9,6 @@ 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 ILogger? _logger; private static readonly (string envVar, string property)[] SimpleFields = new (string envVar, string property)[] @@ -79,9 +79,8 @@ private static readonly (string envVar, string property)[] SimpleFields = ("UniqueUserHeaders", "UniqueUserHeaders"), }; - public static BackendOptions ParseOptions(Dictionary env, ILogger? logger) + public static BackendOptions ParseOptions(Dictionary env) { - _logger = logger; EnvVars.Clear(); var opts = new BackendOptions(); @@ -110,10 +109,14 @@ public static BackendOptions ParseOptions(Dictionary env, ILogge Environment.GetEnvironmentVariable("HOSTNAME") ?? Environment.MachineName); ApplyReplicaIdentitySettings(env, opts, replicaId); - ParseHealthProbeSidecarSettings(opts); - ValidatePrioritySettings(opts, defaults); - ValidateHeaderSettings(opts); - ValidateLoadBalanceMode(opts, defaults); + ApplyDerivedSettingsFromConfigNames( + opts, + configuration: null, + nameof(BackendOptions.HealthProbeSidecar), + nameof(BackendOptions.LoadBalanceMode), + nameof(BackendOptions.PriorityKeys), + nameof(BackendOptions.PriorityValues), + nameof(BackendOptions.ValidateHeaders)); return opts; } @@ -123,6 +126,19 @@ 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}"); @@ -185,6 +201,119 @@ public static void ApplyFieldFromEnv(Dictionary env, BackendOpti } } + 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); diff --git a/src/SimpleL7Proxy/Config/WarmOptions.cs b/src/SimpleL7Proxy/Config/WarmOptions.cs index 39dafe9b..5631d049 100644 --- a/src/SimpleL7Proxy/Config/WarmOptions.cs +++ b/src/SimpleL7Proxy/Config/WarmOptions.cs @@ -118,6 +118,10 @@ public static class ConfigOptions 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; @@ -129,6 +133,19 @@ public static class ConfigOptions 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(); @@ -168,59 +185,97 @@ public static IReadOnlyList GetPublishableDescriptors() // } /// - /// Detects warm-mode values that differ from the live options and returns - /// the changes plus their parsed new values. - /// Does not mutate . + /// 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 . /// - public static (List Changes, Dictionary ParsedValues) DetectWarmChanges( + /// 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, - IConfiguration warmSection, + Dictionary snapshot, ILogger? logger = null) { var changes = new List(); var parsedValues = new Dictionary(StringComparer.OrdinalIgnoreCase); - var parsedTarget = new BackendOptions(); + var hostChanges = new Dictionary(StringComparer.OrdinalIgnoreCase); + var defaultTarget = new BackendOptions(); var env = new Dictionary(1, StringComparer.OrdinalIgnoreCase); - foreach (var descriptor in _warmDescriptors.Value) + foreach (var kvp in snapshot) { - var section = warmSection.GetSection(descriptor.Attribute.KeyPath); - if (!section.Exists()) + var snapshotKey = kvp.Key["Warm:".Length..]; + var rawValue = kvp.Value; + + if (string.IsNullOrEmpty(rawValue)) continue; - var rawValue = section.Value; + 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; - if (string.IsNullOrEmpty(rawValue) || rawValue == DefaultPlaceholder) + var configName = descriptor.ConfigName; + if (!TryGetFieldByConfigName(configName, out var field) || field == null) continue; - var currentValue = descriptor.Property.GetValue(liveOptions); - env.Clear(); - env[descriptor.ConfigName] = rawValue; + var currentValue = field.GetValue(liveOptions); + + object? newValue; + if (rawValue == DefaultPlaceholder) + { + newValue = field.GetValue(defaultTarget); + } + else + { + env.Clear(); + env[configName] = rawValue; - ConfigParser.ApplyFieldFromEnv( - env, - parsedTarget, - liveOptions, - descriptor.ConfigName, - descriptor.Property.Name); + ConfigParser.ApplyFieldFromEnv( + env, + defaultTarget, + liveOptions, + configName, + field.Name); - var newValue = descriptor.Property.GetValue(parsedTarget); + newValue = field.GetValue(defaultTarget); + } if (Equals(currentValue, newValue)) continue; - parsedValues[descriptor.ConfigName] = newValue; + parsedValues[configName] = newValue; changes.Add(new ConfigChange { - PropertyName = descriptor.ConfigName, + PropertyName = configName, KeyPath = descriptor.Attribute.KeyPath, RawOldValue = currentValue, RawNewValue = newValue }); } - return (changes, parsedValues); + return (changes, parsedValues, hostChanges); } private static IReadOnlyList DiscoverDescriptors() From 988ab7da2c574096475bc28da6a631ab2d519cfc Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 6 Mar 2026 15:44:53 -0500 Subject: [PATCH 45/46] activate hosts via HostCollectionManager --- src/SimpleL7Proxy/Backend/Backends.cs | 4 +- .../Backend/HostCollectionManager.cs | 38 +++++++++++++++--- .../Backend/HostCollectionSnapshot.cs | 36 ++++++++++++++++- .../Backend/IHostHealthCollection.cs | 6 +++ .../Config/AzureAppConfigurationExtensions.cs | 8 +++- .../Config/ConfigBootstrapper.cs | 13 ++++++- .../CoordinatedShutdownService.cs | 2 +- src/SimpleL7Proxy/ProbeServer.cs | 3 +- src/SimpleL7Proxy/Program.cs | 15 ++++--- src/SimpleL7Proxy/server.cs | 39 ++++++++++++++++++- 10 files changed, 142 insertions(+), 22 deletions(-) diff --git a/src/SimpleL7Proxy/Backend/Backends.cs b/src/SimpleL7Proxy/Backend/Backends.cs index 3622a965..0d2b9d81 100644 --- a/src/SimpleL7Proxy/Backend/Backends.cs +++ b/src/SimpleL7Proxy/Backend/Backends.cs @@ -98,9 +98,7 @@ public Backends( _activeHosts = []; _successRate = bo.SuccessRate / 100.0; - // Stage hosts from config into a pending snapshot, then activate - _backendHostCollection.LoadFromConfig(bo.Hosts); - _backendHostCollection.Activate(); + // Hosts are staged and activated by ConfigBootstrapper.RegisterBackends _logger.LogDebug("[INIT] Backends service starting"); diff --git a/src/SimpleL7Proxy/Backend/HostCollectionManager.cs b/src/SimpleL7Proxy/Backend/HostCollectionManager.cs index cbe84df2..701fd49f 100644 --- a/src/SimpleL7Proxy/Backend/HostCollectionManager.cs +++ b/src/SimpleL7Proxy/Backend/HostCollectionManager.cs @@ -14,8 +14,8 @@ namespace SimpleL7Proxy.Backend; /// /// Startup flow: /// 1. Constructor starts with Empty snapshot -/// 2. LoadFromConfig() builds hosts from BackendOptions into a pending snapshot -/// 3. Activate() swaps the pending snapshot in as Current +/// 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 @@ -23,6 +23,7 @@ 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; @@ -36,11 +37,25 @@ public HostCollectionManager(ILogger logger) _logger = logger; _version = 0; - // Start empty — hosts are loaded via LoadFromConfig() then Activate() + // 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. @@ -60,19 +75,30 @@ public void LoadFromConfig(IEnumerable hostConfigs) /// /// Atomically swaps the pending snapshot in as Current. - /// After this, readers see the new hosts immediately. - /// Old snapshots remain valid for in-flight workers until GC reclaims them. + /// 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"); + _logger.LogWarning("[HOST-MANAGER] Activate() called with no pending snapshot or staged hosts"); return; } + _pending.Freeze(); + var oldVersion = _current.Version; _current = _pending; _pending = null; diff --git a/src/SimpleL7Proxy/Backend/HostCollectionSnapshot.cs b/src/SimpleL7Proxy/Backend/HostCollectionSnapshot.cs index c8a95128..a31c0c8f 100644 --- a/src/SimpleL7Proxy/Backend/HostCollectionSnapshot.cs +++ b/src/SimpleL7Proxy/Backend/HostCollectionSnapshot.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using System.Collections.Frozen; +using Microsoft.Extensions.Logging; namespace SimpleL7Proxy.Backend; @@ -21,6 +22,15 @@ public sealed class HostCollectionSnapshot /// 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, @@ -34,7 +44,29 @@ private HostCollectionSnapshot( } /// Empty snapshot for startup / error states. - public static HostCollectionSnapshot Empty { get; } = new([], [], [], 0); + 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. diff --git a/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs b/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs index 1c07e674..7f47e431 100644 --- a/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs +++ b/src/SimpleL7Proxy/Backend/IHostHealthCollection.cs @@ -12,6 +12,12 @@ public interface IHostHealthCollection /// 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. diff --git a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs index 38f60dfe..e72d8da8 100644 --- a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using Azure.Data.AppConfiguration; using Azure.Identity; +using SimpleL7Proxy.Backend; #if AZURE_APPCONFIG_FULL using Microsoft.Extensions.DependencyInjection; @@ -200,6 +201,7 @@ public class AzureAppConfigurationRefreshService : BackgroundService 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; @@ -217,7 +219,8 @@ public AzureAppConfigurationRefreshService( AppConfigurationSnapshot appConfigurationSnapshot, IOptions backendOptions, ILogger logger, - ConfigChangeNotifier notifier) + ConfigChangeNotifier notifier, + IHostHealthCollection? hostCollection = null) { _refresher = refresher; _configuration = configuration; @@ -225,6 +228,7 @@ public AzureAppConfigurationRefreshService( _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. @@ -418,7 +422,7 @@ private async Task ProcessRefreshCycleAsync(CancellationToken stoppingToken) if (hostChanges.Count > 0) { // CALL HOST REPARSER - ConfigBootstrapper.RegisterBackends(_backendOptions.Value, null, hostChanges); + ConfigBootstrapper.RegisterBackends(_backendOptions.Value, null, hostChanges, _hostCollection); } } diff --git a/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs index f89c6d14..9f012554 100644 --- a/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs +++ b/src/SimpleL7Proxy/Config/ConfigBootstrapper.cs @@ -351,6 +351,11 @@ private static void ConfigureHttpClientFromOptions(Dictionary en /// 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. @@ -366,7 +371,11 @@ private static void ConfigureHttpClientFromOptions(Dictionary en /// Optional flat dictionary of host-family settings (e.g. from a warm snapshot). /// Takes precedence over when supplied. /// - public static void RegisterBackends(BackendOptions backendOptions, IConfiguration? configuration = null, Dictionary? cfg = null) + /// + /// 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); @@ -424,6 +433,7 @@ public static void RegisterBackends(BackendOptions backendOptions, IConfiguratio // 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}"); } @@ -453,5 +463,6 @@ public static void RegisterBackends(BackendOptions backendOptions, IConfiguratio } // Snapshot is updated only after all Host/Probe_path/IP entries are parsed and applied. + hostCollection?.Activate(); } } diff --git a/src/SimpleL7Proxy/CoordinatedShutdownService.cs b/src/SimpleL7Proxy/CoordinatedShutdownService.cs index be8b8f8e..6c359c8a 100644 --- a/src/SimpleL7Proxy/CoordinatedShutdownService.cs +++ b/src/SimpleL7Proxy/CoordinatedShutdownService.cs @@ -162,7 +162,7 @@ public async Task StopAsync(CancellationToken cancellationToken) // (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/ProbeServer.cs b/src/SimpleL7Proxy/ProbeServer.cs index 55035f05..88a22cde 100644 --- a/src/SimpleL7Proxy/ProbeServer.cs +++ b/src/SimpleL7Proxy/ProbeServer.cs @@ -58,7 +58,7 @@ public ProbeServer(IBackendService backends, HealthCheckService healthService, I _backendOptions = backendOptions?.Value ?? throw new ArgumentNullException(nameof(backendOptions)); // Subscribe for HealthProbeSidecar changes (HealthProbeSidecarEnabled & Url are parsed from it) - configChangeNotifier.Subscribe(this, "HealthProbeSidecar"); + configChangeNotifier.Subscribe(this, options => options.HealthProbeSidecar); } /// @@ -268,6 +268,7 @@ public Task OnConfigChangedAsync( CancellationToken cancellationToken) { _logger.LogInformation("[CONFIG] HealthProbeSidecar changed — restarting probe server"); + // apply the changes StopProbeServer(); StartProbeServer(); return Task.CompletedTask; diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index 7174fc0d..69b6b632 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -60,10 +60,11 @@ 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(startupLogger); + appConfigBootstrap.Start(); var hostBuilder = Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration((hostContext, config) => @@ -82,7 +83,7 @@ public static async Task Main(string[] args) .ConfigureServices((hostContext, services) => { ConfigureApplicationInsights(services); - ConfigureDependencyInjection(services, startupLogger); + ConfigureDependencyInjection(services, startupLogger, appConfigBootstrap); }); @@ -121,7 +122,9 @@ public static async Task Main(string[] args) HostConfig.Initialize(backendTokenProvider, startupLogger, serviceProvider); // Register backends after DI container is built and HostConfig is initialized - ConfigBootstrapper.RegisterBackends(options.Value); + var configuration = serviceProvider.GetService(); + var hostCollection = serviceProvider.GetRequiredService(); + ConfigBootstrapper.RegisterBackends(options.Value, configuration, null, hostCollection); try { @@ -211,8 +214,10 @@ 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. @@ -285,7 +290,7 @@ private static void ConfigureDependencyInjection(IServiceCollection services, IL } - var backendOptions = ConfigBootstrapper.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) diff --git a/src/SimpleL7Proxy/server.cs b/src/SimpleL7Proxy/server.cs index b6a3402d..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; @@ -67,6 +67,7 @@ public Server( IBlobWriter blobWriter, HealthCheckService healthService, ProbeServer probeServer, + ConfigChangeNotifier configChangeNotifier, ILogger logger) { ArgumentNullException.ThrowIfNull(backendOptions, nameof(backendOptions)); @@ -93,6 +94,32 @@ 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( @@ -117,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; From 96e8b64eb9fbbbf9b4a328dc3be519413850d3a6 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Fri, 6 Mar 2026 16:14:07 -0500 Subject: [PATCH 46/46] updated version to 2.2.10 --- ReleaseNotes/version2.2.md | 7 + .../Config/AppConfigBootstrap.cs | 143 +++++ .../Config/AppConfigurationSnapshot.cs | 23 + .../Config/AzureAppConfigurationExtensions.cs | 547 +----------------- .../AzureAppConfigurationRefreshService.cs | 338 +++++++++++ src/SimpleL7Proxy/Constants.cs | 2 +- src/SimpleL7Proxy/Program.cs | 1 + 7 files changed, 514 insertions(+), 547 deletions(-) create mode 100644 src/SimpleL7Proxy/Config/AppConfigBootstrap.cs create mode 100644 src/SimpleL7Proxy/Config/AppConfigurationSnapshot.cs create mode 100644 src/SimpleL7Proxy/Config/AzureAppConfigurationRefreshService.cs diff --git a/ReleaseNotes/version2.2.md b/ReleaseNotes/version2.2.md index 251fcec5..d3679bc1 100644 --- a/ReleaseNotes/version2.2.md +++ b/ReleaseNotes/version2.2.md @@ -1,5 +1,8 @@ # Release Notes # + +2.2.10 + Proxy: * Implement app configuration @@ -7,9 +10,13 @@ Proxy: * 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 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 index e72d8da8..6c671dcd 100644 --- a/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs +++ b/src/SimpleL7Proxy/Config/AzureAppConfigurationExtensions.cs @@ -1,533 +1,18 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Azure.Data.AppConfiguration; using Azure.Identity; -using SimpleL7Proxy.Backend; -#if AZURE_APPCONFIG_FULL using Microsoft.Extensions.DependencyInjection; -#endif 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; - } - } -} - -/// -/// 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; - } - } - -} - -/// -/// Azure App Configuration integration for hot-reloading [Warm] settings. -/// -/// Core behaviour (always compiled): -/// - AzureAppConfigurationRefreshService: a BackgroundService that polls -/// the App Configuration sentinel key every N seconds (configurable via -/// AZURE_APPCONFIG_REFRESH_SECONDS, default 30) and applies changes to -/// BackendOptions. -/// -/// Extended wiring helpers and ASP.NET-style middleware are available when -/// the compile-time constant AZURE_APPCONFIG_FULL is defined. -/// csproj: <DefineConstants>$(DefineConstants);AZURE_APPCONFIG_FULL</DefineConstants> -/// - -// ────────────────────────────────────────────────────────────────────── -// Core: Background polling service (always compiled) -// ────────────────────────────────────────────────────────────────────── - -/// -/// 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) - { - 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"); - } -} - // ────────────────────────────────────────────────────────────────────── // Extended: DI wiring helpers & middleware -// Compile with: AZURE_APPCONFIG_FULL // ────────────────────────────────────────────────────────────────────── -#if AZURE_APPCONFIG_FULL /// /// Extension methods for registering Azure App Configuration services in DI. -/// Only compiled when AZURE_APPCONFIG_FULL is defined. /// public static class AzureAppConfigurationExtensions { @@ -643,34 +128,4 @@ public static IConfigurationBuilder AddAzureAppConfigurationWithWarmSupport( return builder; } -} - -/// -/// Middleware to trigger configuration refresh on each HTTP request. -/// Only compiled when AZURE_APPCONFIG_FULL is defined. -/// Note: This project uses the Worker SDK, not ASP.NET Core, -/// so this middleware is provided for reference only. -/// -public class AzureAppConfigurationRefreshMiddleware -{ - private readonly RequestDelegate _next; - private readonly IConfigurationRefresher _refresher; - - public AzureAppConfigurationRefreshMiddleware(RequestDelegate next, IConfigurationRefresher refresher) - { - _next = next; - _refresher = refresher; - } - - public async Task InvokeAsync(HttpContext context) - { - _ = _refresher.TryRefreshAsync(); - await _next(context); - } -} - -// Placeholder types - this app uses Worker SDK, not ASP.NET Core -public class HttpContext { } -public delegate Task RequestDelegate(HttpContext context); - -#endif // AZURE_APPCONFIG_FULL +} \ 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/Constants.cs b/src/SimpleL7Proxy/Constants.cs index b860663e..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.10-d1"; + public const string VERSION = "2.2.10"; public const int AnyPriority = -1; diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index 69b6b632..927635ed 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -364,6 +364,7 @@ private static void ConfigureDependencyInjection(IServiceCollection services, IL services.AddSingleton(); services.AddTransient(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton>();