From a057a28e55c6e4483ee08caf575406ec28228fa1 Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Tue, 29 Sep 2026 14:58:23 +0200 Subject: [PATCH 1/2] Add support for Transparent Data Encryption (TDE) with customer-managed keys --- run-samples.sh | 4 +- .../dotnet/src/GreetingFunctions.cs | 1106 ++++++------ .../dotnet/src/Program.cs | 14 +- .../dotnet/src/sample.csproj | 60 +- .../dotnet/src/sample/GameSessionManager.cs | 1554 ++++++++--------- .../dotnet/src/sample/Program.cs | 14 +- .../dotnet/src/sample/sample.csproj | 64 +- samples/web-app-sql-database/dotnet/README.md | 13 +- .../dotnet/bicep/README.md | 42 +- .../dotnet/bicep/main.bicep | 58 +- .../modules/transparent-data-encryption.bicep | 35 + .../dotnet/scripts/README.md | 42 +- .../dotnet/scripts/deploy.sh | 112 +- .../dotnet/scripts/validate.sh | 39 + .../dotnet/terraform/README.md | 42 +- .../dotnet/terraform/main.tf | 90 +- samples/web-app-sql-database/python/README.md | 13 +- .../python/bicep/README.md | 9 +- .../python/bicep/main.bicep | 58 +- .../modules/transparent-data-encryption.bicep | 35 + .../python/scripts/README.md | 9 +- .../python/scripts/deploy.sh | 112 +- .../python/scripts/validate.sh | 39 + .../python/terraform/README.md | 9 +- .../python/terraform/main.tf | 90 +- 25 files changed, 2221 insertions(+), 1442 deletions(-) create mode 100644 samples/web-app-sql-database/dotnet/bicep/modules/transparent-data-encryption.bicep create mode 100644 samples/web-app-sql-database/python/bicep/modules/transparent-data-encryption.bicep diff --git a/run-samples.sh b/run-samples.sh index c101365..15aee08 100755 --- a/run-samples.sh +++ b/run-samples.sh @@ -70,8 +70,8 @@ TERRAFORM_SAMPLES=( "samples/web-app-cosmosdb-mongodb-api/dotnet/terraform|bash deploy.sh" "samples/web-app-managed-identity/python/terraform|bash deploy.sh" "samples/web-app-managed-identity/dotnet/terraform|bash deploy.sh" - "samples/web-app-sql-database/python/terraform|bash deploy.sh" - "samples/web-app-sql-database/dotnet/terraform|bash deploy.sh" + "samples/web-app-sql-database/python/terraform|bash deploy.sh|bash ../scripts/validate.sh" + "samples/web-app-sql-database/dotnet/terraform|bash deploy.sh|bash ../scripts/validate.sh" "samples/web-app-mysql-flexible-server/python/terraform|bash deploy.sh" "samples/web-app-mysql-flexible-server/dotnet/terraform|bash deploy.sh" "samples/web-app-postgresql-flexible-server/python/terraform|bash deploy.sh" diff --git a/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs b/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs index bbe47f8..3a3c86b 100644 --- a/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs +++ b/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs @@ -1,554 +1,554 @@ -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Azure.Identity; -using Azure.Messaging.ServiceBus; -using Azure.Messaging.ServiceBus.Administration; - -namespace LocalStack.Azure.Samples; - -/// -/// A simple Azure Function that processes Service Bus messages and responds with a greeting. -/// -public class HelloWorld -{ - // Instance field for logging - keeps proper Azure Functions execution context - private readonly ILogger _logger; - - // Static configuration values - initialized once per application lifetime - private static string? _connectionString; - private static string? _clientId; - private static string? _fullyQualifiedNamespace; - private static bool _hasConnectionString; - private static bool _hasClientId; - private static bool _hasFullyQualifiedNamespace; - private static string? _inputQueueName; - private static string? _outputQueueName; - private static bool _configurationValid = false; - private static string[]? _names; - - // Greeting templates used by GetGreeting to produce varied responses - private static readonly string[] _greetingTemplates = new[] - { - "Hello {0}, how are you?", - "Hi {0}, great to see you!", - "Hey {0}, hope you're having a wonderful day!", - "Good day {0}, welcome aboard!", - "Greetings {0}, nice to meet you!", - "Howdy {0}, what's going on?", - "Welcome {0}, glad you're here!", - "Salutations {0}, how's everything going?" - }; - - private static readonly Random _random = new(); - - // Circular buffers for message history across all functions - private const int MaxHistory = 100; - private static readonly object _historyLock = new(); - private static readonly CircularBuffer _requesterSent = new(MaxHistory); - private static readonly CircularBuffer _handlerReceived = new(MaxHistory); - private static readonly CircularBuffer _handlerSent = new(MaxHistory); - private static readonly CircularBuffer _consumerReceived = new(MaxHistory); - - // Static initialization - runs once per application lifetime - private static readonly Lazy _initialized = new Lazy(() => { Initialize(); return true; }); - - /// - /// Initializes a new instance of the class. - /// - /// The logger factory used to create loggers for this class. - public HelloWorld(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } - - /// - /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). - /// This method runs exactly once per application lifetime and stores configuration values in static fields. - /// - /// A task representing the asynchronous initialization operation. - private static void Initialize() - { - try - { - // Create a temporary configuration instance for initialization - var configBuilder = new ConfigurationBuilder() - .AddEnvironmentVariables() - .AddJsonFile("local.settings.json", optional: true); - var config = configBuilder.Build(); - - // Create a temporary logger for initialization - using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - - logger.LogInformation("[Initialize] Starting one-time initialization..."); - - // Read and store configuration values in static fields with fallback defaults - _connectionString = config["SERVICE_BUS_CONNECTION_STRING"]; - _clientId = config["AZURE_CLIENT_ID"]; - _fullyQualifiedNamespace = config["SERVICE_BUS_CONNECTION_STRING:fullyQualifiedNamespace"]; - _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; - _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; - _names = config["NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - _hasConnectionString = !string.IsNullOrWhiteSpace(_connectionString); - _hasClientId = !string.IsNullOrWhiteSpace(_clientId); - _hasFullyQualifiedNamespace = !string.IsNullOrWhiteSpace(_fullyQualifiedNamespace); - - // Check if names ae configured. If not use, use default names - if (_names == null || _names.Length == 0) - { - logger.LogWarning("[Initialize] NAMES configuration is missing or empty. Using default names."); - _names = new[] { "Alice", "Paolo", "Leo", "Mia" }; - } - - // Validate configuration and set the flag - _configurationValid = ValidateConfigurationValues(logger); - } - catch (Exception ex) - { - // Log error but don't throw - let functions continue to work even if initialization fails - Console.WriteLine("[Initialize] Initialization failed: {0}", ex.Message); - _configurationValid = false; - } - } - - /// - /// Validates that all required configuration values are present and not empty. - /// With default values in place, only the connection string is mandatory. - /// - /// Logger for reporting validation errors. - /// True if all configuration values are valid, false otherwise. - private static bool ValidateConfigurationValues(ILogger logger) - { - bool isValid = true; - - // Requirement: Must have (ID AND Namespace) OR (Connection String) - if (!(_hasClientId && _hasFullyQualifiedNamespace) && !_hasConnectionString) - { - logger.LogError("[ValidateConfigurationValues] Incomplete configuration. You must provide BOTH Client ID and Namespace, OR a Connection String."); - isValid = false; - } - - // Additional Safety: If they provided a partial Identity, catch it! - if (_hasClientId != _hasFullyQualifiedNamespace && !_hasConnectionString) - { - logger.LogError("[ValidateConfigurationValues] Partial Identity detected. Both Client ID and Namespace are required."); - isValid = false; - } - - // Log the configuration values being used (helpful for debugging) - if (isValid) - { - logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); - logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); - logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); - logger.LogInformation(" - Names: {names}", string.Join(", ", _names != null ? _names : Array.Empty())); - } - - return isValid; - } - - /// - /// Checks if configuration values have been successfully loaded and validated. - /// This method provides a fast runtime check without re-reading configuration. - /// With default values, this primarily checks if the connection string is available. - /// - /// True if configuration is valid and available, false otherwise. - private static bool IsConfigurationValid() - { - // Valid if we have a connection string OR (client ID + fully qualified namespace) - return _configurationValid && (_hasConnectionString || (_hasClientId && _hasFullyQualifiedNamespace)); - } - - /// - /// Processes a Service Bus message by reading, validating, and responding to the input message. - /// - /// The received Service Bus message containing the request payload as JSON. - /// Actions for managing the Service Bus message lifecycle (e.g., completion). - /// - /// A JSON-formatted response message containing a greeting and the current date, or null if the input is invalid. - /// - [Function("GreetingHandler")] - [ServiceBusOutput("%OUTPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING")] - public async Task GreetingHandlerAsync( - [ServiceBusTrigger("%INPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, - ServiceBusMessageActions messageActions) - { - // Log the incoming message details - _logger.LogInformation("[GreetingHandler] Message ID: {id}", message.MessageId); - _logger.LogInformation("[GreetingHandler] Message Body: {body}", message.Body); - _logger.LogInformation("[GreetingHandler] Message Content-Type: {contentType}", message.ContentType); - - // Read the message body as a byte array - byte[] bodyBytes = message.Body.ToArray(); - - // Check that the bodyBytes is not null or empty - if (bodyBytes == null || bodyBytes.Length == 0) - { - _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or null.", message.MessageId); - return null; - } - // Convert the byte array to a string - string json = System.Text.Encoding.UTF8.GetString(bodyBytes); - - // Check that the JSON is not null or empty - if (string.IsNullOrEmpty(json)) - { - _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or invalid.", message.MessageId); - return null; - } - - // Deserialize the JSON into a RequestMessage object - RequestMessage? requestMessage = JsonSerializer.Deserialize(json); - - // Check that the request message is not null or empty - if (requestMessage == null || string.IsNullOrWhiteSpace(requestMessage?.Name)) - { - _logger.LogError("[GreetingHandler] Received request message [{messageId}] body is empty or invalid.", message.MessageId); - return null; - } - - _logger.LogInformation("[GreetingHandler] Processing request for name: {name}", requestMessage.Name); - - // Record received name in history - lock (_historyLock) - { - _handlerReceived.Add(requestMessage.Name); - } - - // Create the response message - var greetingText = GetGreeting(requestMessage.Name); - var outputObj = new ResponseMessage - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - Text = greetingText - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Complete the message after processing - await messageActions.CompleteMessageAsync(message); - - // Log the successful processing of the message - _logger.LogInformation("[GreetingHandler] Processed message [{messageId}] successfully: {greetingText}", message.MessageId, greetingText); - - // Return the response message - return outputMessage; - } - - /// - /// Timer-triggered function that sends a greeting request message to the input queue. - /// - /// Timer metadata containing schedule status and next occurrence information. - [Function("GreetingRequester")] - [FixedDelayRetry(5, "00:00:10")] - public async Task GreetingRequesterAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) - { - // Log the start of the function execution - _logger.LogInformation("[GreetingRequester] Timer trigger function started."); - - // Ensure one-time initialization has run - _ = _initialized.Value; - - // Fast configuration validation using pre-loaded static values - if (!IsConfigurationValid()) - { - _logger.LogError("[GreetingRequester] Configuration is invalid or not loaded. Aborting function execution."); - return; - } - - if (_names == null || _names.Length == 0) - { - _logger.LogError("[GreetingRequester] Names are not configured. Aborting function execution."); - return; - } - - try - { - // Create Service Bus client - _logger.LogInformation("[GreetingRequester] Creating Service Bus client for sending messages..."); - await using var client = _hasClientId && _hasFullyQualifiedNamespace - ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) - : new ServiceBusClient(_connectionString); - - // Create message sender for the input queue - _logger.LogInformation("[GreetingRequester] Creating sender for input queue '{inputQueue}'", _inputQueueName); - await using var sender = client.CreateSender(_inputQueueName); - - // Create request message with randomly selected name - var random = new Random(); - var selectedName = _names[random.Next(_names.Length)]; - var requestMessage = new RequestMessage { Name = selectedName }; - var messageBody = JsonSerializer.Serialize(requestMessage); - - // Create and send Service Bus message - var serviceBusMessage = new ServiceBusMessage(messageBody) - { - ContentType = "application/json" - }; - - _logger.LogInformation("[GreetingRequester] Sending message to input queue '{inputQueue}'...", _inputQueueName); - await sender.SendMessageAsync(serviceBusMessage); - _logger.LogInformation("[GreetingRequester] Successfully sent message to input queue '{inputQueue}' with name: {Name}", _inputQueueName, selectedName); - - // Record sent name in history - lock (_historyLock) - { - _requesterSent.Add(selectedName); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "[GreetingRequester] Failed to send message to input queue '{inputQueue}'", _inputQueueName); - return; - } - - // Log the next scheduled timer occurrence - _logger.LogInformation("[GreetingRequester] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); - } - - /// - /// Timer-triggered function that receives and processes greeting response messages from the output queue. - /// - /// Timer metadata containing schedule status and next occurrence information. - [Function("GreetingConsumer")] - [FixedDelayRetry(5, "00:00:10")] - public async Task GreetingConsumerAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) - { - // Log the start of the function execution - _logger.LogInformation("[GreetingConsumer] Timer trigger function started."); - - // Ensure one-time initialization has run - _ = _initialized.Value; - - // Fast configuration validation using pre-loaded static values - if (!IsConfigurationValid()) - { - _logger.LogError("[GreetingConsumer] Configuration is invalid or not loaded. Aborting function execution."); - return; - } - - try - { - // Create Service Bus client for receiving messages from the output queue - _logger.LogInformation("[GreetingConsumer] Creating Service Bus client for receiving messages..."); - await using var client = _hasClientId && _hasFullyQualifiedNamespace - ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) - : new ServiceBusClient(_connectionString); - var receiver = client.CreateReceiver(_outputQueueName); - - _logger.LogInformation("[GreetingConsumer] Starting to receive messages from output queue '{outputQueue}'", _outputQueueName); - - // Loop to receive messages (with timeout to prevent infinite waiting) - var timeout = TimeSpan.FromSeconds(30); - var startTime = DateTime.UtcNow; - - try - { - while (DateTime.UtcNow - startTime < timeout) - { - try - { - // Receive message with a short timeout - var receivedMessage = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(5)); - - if (receivedMessage == null) - { - _logger.LogInformation("[GreetingConsumer] No more messages available in output queue '{outputQueue}'", _outputQueueName); - break; - } - - // Convert message body to string - var messageBody = receivedMessage.Body.ToString(); - - try - { - // Attempt to deserialize to ResponseMessage - var responseMessage = JsonSerializer.Deserialize(messageBody); - - if (responseMessage != null) - { - _logger.LogInformation("[GreetingConsumer] Successfully received and deserialized message from output queue. Date: {Date}, Text: {Text}", - responseMessage.Date, responseMessage.Text); - - // Complete the message after successful processing - await receiver.CompleteMessageAsync(receivedMessage); - - // Record received greeting in history - lock (_historyLock) - { - _consumerReceived.Add(responseMessage.Text); - } - } - else - { - _logger.LogWarning("[GreetingConsumer] Received message could not be deserialized to ResponseMessage (null result)"); - await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", "Message deserialized to null"); - } - } - catch (JsonException jsonEx) - { - _logger.LogError(jsonEx, "[GreetingConsumer] Failed to deserialize message from output queue. Message body: {messageBody}", messageBody); - await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", jsonEx.Message); - } - } - catch (Exception messageEx) - { - _logger.LogError(messageEx, "[GreetingConsumer] Error occurred while receiving message from output queue '{outputQueue}'", _outputQueueName); - // Continue the loop to try receiving more messages - } - } - } - finally - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - try - { - await receiver.CloseAsync(cts.Token); - } - catch - { /* timeout or error on close */ } - try - { - await client.DisposeAsync(); - } - catch - { /* benign */ - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "[GreetingConsumer] Failed to receive messages from output queue '{outputQueue}'", _outputQueueName); - } - - // Log the next scheduled timer occurrence - _logger.LogInformation("[GreetingConsumer] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); - } - - /// - /// Selects a random greeting template and formats it with the given name. - /// The generated greeting is also stored in a circular buffer for later retrieval. - /// - /// The name to include in the greeting. - /// A randomly chosen greeting string addressed to the specified name. - private static string GetGreeting(string name) - { - var template = _greetingTemplates[_random.Next(_greetingTemplates.Length)]; - var greeting = string.Format(template, name); - - lock (_historyLock) - { - _handlerSent.Add(greeting); - } - - return greeting; - } - - /// - /// HTTP-triggered function that returns the most recent greetings from the circular buffer. - /// Greetings are returned in reverse chronological order (newest first). - /// - /// The incoming HTTP request. - /// The number of greetings to return (default: 20, max: 100). - /// An HTTP response containing a JSON array of recent greetings. - [Function("GetGreetings")] - public async Task GetGreetingsAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "greetings")] HttpRequestData request, - int count = 20) - { - _logger.LogInformation("[GetGreetings] Retrieving last {count} entries.", count); - - // Clamp count to valid range - if (count < 1) count = 1; - if (count > MaxHistory) count = MaxHistory; - - object history; - lock (_historyLock) - { - history = new - { - requester = new - { - sent = _requesterSent.ToArray(count) - }, - handler = new - { - received = _handlerReceived.ToArray(count), - sent = _handlerSent.ToArray(count) - }, - consumer = new - { - received = _consumerReceived.ToArray(count) - } - }; - } - - var response = request.CreateResponse(HttpStatusCode.OK); - response.Headers.Add("Content-Type", "application/json"); - await response.WriteStringAsync(JsonSerializer.Serialize(history)); - return response; - } - - private sealed class CircularBuffer - { - private readonly string[] _items; - private int _index; - private int _count; - - public CircularBuffer(int capacity) => _items = new string[capacity]; - - public void Add(string item) - { - _items[_index] = item; - _index = (_index + 1) % _items.Length; - if (_count < _items.Length) _count++; - } - - public string[] ToArray(int count) - { - var available = Math.Min(count, _count); - var result = new string[available]; - for (int i = 0; i < available; i++) - { - var idx = (_index - available + i + _items.Length) % _items.Length; - result[i] = _items[idx]; - } - return result; - } - } -} - -/// -/// Represents the input payload for greeting requests. -/// -public class RequestMessage -{ - /// - /// Gets or sets the name to greet. - /// - [JsonPropertyName("name")] - public required string Name { get; set; } -} - -/// -/// Represents the response payload for greeting requests. -/// -public class ResponseMessage -{ - /// - /// Gets or sets the date of the response message. - /// - [JsonPropertyName("date")] - public required string Date { get; set; } - - /// - /// Gets or sets the text of the response message. - /// - [JsonPropertyName("text")] - public required string Text { get; set; } +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using Azure.Identity; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; + +namespace LocalStack.Azure.Samples; + +/// +/// A simple Azure Function that processes Service Bus messages and responds with a greeting. +/// +public class HelloWorld +{ + // Instance field for logging - keeps proper Azure Functions execution context + private readonly ILogger _logger; + + // Static configuration values - initialized once per application lifetime + private static string? _connectionString; + private static string? _clientId; + private static string? _fullyQualifiedNamespace; + private static bool _hasConnectionString; + private static bool _hasClientId; + private static bool _hasFullyQualifiedNamespace; + private static string? _inputQueueName; + private static string? _outputQueueName; + private static bool _configurationValid = false; + private static string[]? _names; + + // Greeting templates used by GetGreeting to produce varied responses + private static readonly string[] _greetingTemplates = new[] + { + "Hello {0}, how are you?", + "Hi {0}, great to see you!", + "Hey {0}, hope you're having a wonderful day!", + "Good day {0}, welcome aboard!", + "Greetings {0}, nice to meet you!", + "Howdy {0}, what's going on?", + "Welcome {0}, glad you're here!", + "Salutations {0}, how's everything going?" + }; + + private static readonly Random _random = new(); + + // Circular buffers for message history across all functions + private const int MaxHistory = 100; + private static readonly object _historyLock = new(); + private static readonly CircularBuffer _requesterSent = new(MaxHistory); + private static readonly CircularBuffer _handlerReceived = new(MaxHistory); + private static readonly CircularBuffer _handlerSent = new(MaxHistory); + private static readonly CircularBuffer _consumerReceived = new(MaxHistory); + + // Static initialization - runs once per application lifetime + private static readonly Lazy _initialized = new Lazy(() => { Initialize(); return true; }); + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory used to create loggers for this class. + public HelloWorld(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + /// + /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). + /// This method runs exactly once per application lifetime and stores configuration values in static fields. + /// + /// A task representing the asynchronous initialization operation. + private static void Initialize() + { + try + { + // Create a temporary configuration instance for initialization + var configBuilder = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddJsonFile("local.settings.json", optional: true); + var config = configBuilder.Build(); + + // Create a temporary logger for initialization + using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var logger = loggerFactory.CreateLogger(); + + logger.LogInformation("[Initialize] Starting one-time initialization..."); + + // Read and store configuration values in static fields with fallback defaults + _connectionString = config["SERVICE_BUS_CONNECTION_STRING"]; + _clientId = config["AZURE_CLIENT_ID"]; + _fullyQualifiedNamespace = config["SERVICE_BUS_CONNECTION_STRING:fullyQualifiedNamespace"]; + _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; + _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; + _names = config["NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + _hasConnectionString = !string.IsNullOrWhiteSpace(_connectionString); + _hasClientId = !string.IsNullOrWhiteSpace(_clientId); + _hasFullyQualifiedNamespace = !string.IsNullOrWhiteSpace(_fullyQualifiedNamespace); + + // Check if names ae configured. If not use, use default names + if (_names == null || _names.Length == 0) + { + logger.LogWarning("[Initialize] NAMES configuration is missing or empty. Using default names."); + _names = new[] { "Alice", "Paolo", "Leo", "Mia" }; + } + + // Validate configuration and set the flag + _configurationValid = ValidateConfigurationValues(logger); + } + catch (Exception ex) + { + // Log error but don't throw - let functions continue to work even if initialization fails + Console.WriteLine("[Initialize] Initialization failed: {0}", ex.Message); + _configurationValid = false; + } + } + + /// + /// Validates that all required configuration values are present and not empty. + /// With default values in place, only the connection string is mandatory. + /// + /// Logger for reporting validation errors. + /// True if all configuration values are valid, false otherwise. + private static bool ValidateConfigurationValues(ILogger logger) + { + bool isValid = true; + + // Requirement: Must have (ID AND Namespace) OR (Connection String) + if (!(_hasClientId && _hasFullyQualifiedNamespace) && !_hasConnectionString) + { + logger.LogError("[ValidateConfigurationValues] Incomplete configuration. You must provide BOTH Client ID and Namespace, OR a Connection String."); + isValid = false; + } + + // Additional Safety: If they provided a partial Identity, catch it! + if (_hasClientId != _hasFullyQualifiedNamespace && !_hasConnectionString) + { + logger.LogError("[ValidateConfigurationValues] Partial Identity detected. Both Client ID and Namespace are required."); + isValid = false; + } + + // Log the configuration values being used (helpful for debugging) + if (isValid) + { + logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); + logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); + logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); + logger.LogInformation(" - Names: {names}", string.Join(", ", _names != null ? _names : Array.Empty())); + } + + return isValid; + } + + /// + /// Checks if configuration values have been successfully loaded and validated. + /// This method provides a fast runtime check without re-reading configuration. + /// With default values, this primarily checks if the connection string is available. + /// + /// True if configuration is valid and available, false otherwise. + private static bool IsConfigurationValid() + { + // Valid if we have a connection string OR (client ID + fully qualified namespace) + return _configurationValid && (_hasConnectionString || (_hasClientId && _hasFullyQualifiedNamespace)); + } + + /// + /// Processes a Service Bus message by reading, validating, and responding to the input message. + /// + /// The received Service Bus message containing the request payload as JSON. + /// Actions for managing the Service Bus message lifecycle (e.g., completion). + /// + /// A JSON-formatted response message containing a greeting and the current date, or null if the input is invalid. + /// + [Function("GreetingHandler")] + [ServiceBusOutput("%OUTPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING")] + public async Task GreetingHandlerAsync( + [ServiceBusTrigger("%INPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, + ServiceBusMessageActions messageActions) + { + // Log the incoming message details + _logger.LogInformation("[GreetingHandler] Message ID: {id}", message.MessageId); + _logger.LogInformation("[GreetingHandler] Message Body: {body}", message.Body); + _logger.LogInformation("[GreetingHandler] Message Content-Type: {contentType}", message.ContentType); + + // Read the message body as a byte array + byte[] bodyBytes = message.Body.ToArray(); + + // Check that the bodyBytes is not null or empty + if (bodyBytes == null || bodyBytes.Length == 0) + { + _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or null.", message.MessageId); + return null; + } + // Convert the byte array to a string + string json = System.Text.Encoding.UTF8.GetString(bodyBytes); + + // Check that the JSON is not null or empty + if (string.IsNullOrEmpty(json)) + { + _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or invalid.", message.MessageId); + return null; + } + + // Deserialize the JSON into a RequestMessage object + RequestMessage? requestMessage = JsonSerializer.Deserialize(json); + + // Check that the request message is not null or empty + if (requestMessage == null || string.IsNullOrWhiteSpace(requestMessage?.Name)) + { + _logger.LogError("[GreetingHandler] Received request message [{messageId}] body is empty or invalid.", message.MessageId); + return null; + } + + _logger.LogInformation("[GreetingHandler] Processing request for name: {name}", requestMessage.Name); + + // Record received name in history + lock (_historyLock) + { + _handlerReceived.Add(requestMessage.Name); + } + + // Create the response message + var greetingText = GetGreeting(requestMessage.Name); + var outputObj = new ResponseMessage + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + Text = greetingText + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Complete the message after processing + await messageActions.CompleteMessageAsync(message); + + // Log the successful processing of the message + _logger.LogInformation("[GreetingHandler] Processed message [{messageId}] successfully: {greetingText}", message.MessageId, greetingText); + + // Return the response message + return outputMessage; + } + + /// + /// Timer-triggered function that sends a greeting request message to the input queue. + /// + /// Timer metadata containing schedule status and next occurrence information. + [Function("GreetingRequester")] + [FixedDelayRetry(5, "00:00:10")] + public async Task GreetingRequesterAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) + { + // Log the start of the function execution + _logger.LogInformation("[GreetingRequester] Timer trigger function started."); + + // Ensure one-time initialization has run + _ = _initialized.Value; + + // Fast configuration validation using pre-loaded static values + if (!IsConfigurationValid()) + { + _logger.LogError("[GreetingRequester] Configuration is invalid or not loaded. Aborting function execution."); + return; + } + + if (_names == null || _names.Length == 0) + { + _logger.LogError("[GreetingRequester] Names are not configured. Aborting function execution."); + return; + } + + try + { + // Create Service Bus client + _logger.LogInformation("[GreetingRequester] Creating Service Bus client for sending messages..."); + await using var client = _hasClientId && _hasFullyQualifiedNamespace + ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) + : new ServiceBusClient(_connectionString); + + // Create message sender for the input queue + _logger.LogInformation("[GreetingRequester] Creating sender for input queue '{inputQueue}'", _inputQueueName); + await using var sender = client.CreateSender(_inputQueueName); + + // Create request message with randomly selected name + var random = new Random(); + var selectedName = _names[random.Next(_names.Length)]; + var requestMessage = new RequestMessage { Name = selectedName }; + var messageBody = JsonSerializer.Serialize(requestMessage); + + // Create and send Service Bus message + var serviceBusMessage = new ServiceBusMessage(messageBody) + { + ContentType = "application/json" + }; + + _logger.LogInformation("[GreetingRequester] Sending message to input queue '{inputQueue}'...", _inputQueueName); + await sender.SendMessageAsync(serviceBusMessage); + _logger.LogInformation("[GreetingRequester] Successfully sent message to input queue '{inputQueue}' with name: {Name}", _inputQueueName, selectedName); + + // Record sent name in history + lock (_historyLock) + { + _requesterSent.Add(selectedName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[GreetingRequester] Failed to send message to input queue '{inputQueue}'", _inputQueueName); + return; + } + + // Log the next scheduled timer occurrence + _logger.LogInformation("[GreetingRequester] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); + } + + /// + /// Timer-triggered function that receives and processes greeting response messages from the output queue. + /// + /// Timer metadata containing schedule status and next occurrence information. + [Function("GreetingConsumer")] + [FixedDelayRetry(5, "00:00:10")] + public async Task GreetingConsumerAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) + { + // Log the start of the function execution + _logger.LogInformation("[GreetingConsumer] Timer trigger function started."); + + // Ensure one-time initialization has run + _ = _initialized.Value; + + // Fast configuration validation using pre-loaded static values + if (!IsConfigurationValid()) + { + _logger.LogError("[GreetingConsumer] Configuration is invalid or not loaded. Aborting function execution."); + return; + } + + try + { + // Create Service Bus client for receiving messages from the output queue + _logger.LogInformation("[GreetingConsumer] Creating Service Bus client for receiving messages..."); + await using var client = _hasClientId && _hasFullyQualifiedNamespace + ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) + : new ServiceBusClient(_connectionString); + var receiver = client.CreateReceiver(_outputQueueName); + + _logger.LogInformation("[GreetingConsumer] Starting to receive messages from output queue '{outputQueue}'", _outputQueueName); + + // Loop to receive messages (with timeout to prevent infinite waiting) + var timeout = TimeSpan.FromSeconds(30); + var startTime = DateTime.UtcNow; + + try + { + while (DateTime.UtcNow - startTime < timeout) + { + try + { + // Receive message with a short timeout + var receivedMessage = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(5)); + + if (receivedMessage == null) + { + _logger.LogInformation("[GreetingConsumer] No more messages available in output queue '{outputQueue}'", _outputQueueName); + break; + } + + // Convert message body to string + var messageBody = receivedMessage.Body.ToString(); + + try + { + // Attempt to deserialize to ResponseMessage + var responseMessage = JsonSerializer.Deserialize(messageBody); + + if (responseMessage != null) + { + _logger.LogInformation("[GreetingConsumer] Successfully received and deserialized message from output queue. Date: {Date}, Text: {Text}", + responseMessage.Date, responseMessage.Text); + + // Complete the message after successful processing + await receiver.CompleteMessageAsync(receivedMessage); + + // Record received greeting in history + lock (_historyLock) + { + _consumerReceived.Add(responseMessage.Text); + } + } + else + { + _logger.LogWarning("[GreetingConsumer] Received message could not be deserialized to ResponseMessage (null result)"); + await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", "Message deserialized to null"); + } + } + catch (JsonException jsonEx) + { + _logger.LogError(jsonEx, "[GreetingConsumer] Failed to deserialize message from output queue. Message body: {messageBody}", messageBody); + await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", jsonEx.Message); + } + } + catch (Exception messageEx) + { + _logger.LogError(messageEx, "[GreetingConsumer] Error occurred while receiving message from output queue '{outputQueue}'", _outputQueueName); + // Continue the loop to try receiving more messages + } + } + } + finally + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await receiver.CloseAsync(cts.Token); + } + catch + { /* timeout or error on close */ } + try + { + await client.DisposeAsync(); + } + catch + { /* benign */ + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[GreetingConsumer] Failed to receive messages from output queue '{outputQueue}'", _outputQueueName); + } + + // Log the next scheduled timer occurrence + _logger.LogInformation("[GreetingConsumer] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); + } + + /// + /// Selects a random greeting template and formats it with the given name. + /// The generated greeting is also stored in a circular buffer for later retrieval. + /// + /// The name to include in the greeting. + /// A randomly chosen greeting string addressed to the specified name. + private static string GetGreeting(string name) + { + var template = _greetingTemplates[_random.Next(_greetingTemplates.Length)]; + var greeting = string.Format(template, name); + + lock (_historyLock) + { + _handlerSent.Add(greeting); + } + + return greeting; + } + + /// + /// HTTP-triggered function that returns the most recent greetings from the circular buffer. + /// Greetings are returned in reverse chronological order (newest first). + /// + /// The incoming HTTP request. + /// The number of greetings to return (default: 20, max: 100). + /// An HTTP response containing a JSON array of recent greetings. + [Function("GetGreetings")] + public async Task GetGreetingsAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "greetings")] HttpRequestData request, + int count = 20) + { + _logger.LogInformation("[GetGreetings] Retrieving last {count} entries.", count); + + // Clamp count to valid range + if (count < 1) count = 1; + if (count > MaxHistory) count = MaxHistory; + + object history; + lock (_historyLock) + { + history = new + { + requester = new + { + sent = _requesterSent.ToArray(count) + }, + handler = new + { + received = _handlerReceived.ToArray(count), + sent = _handlerSent.ToArray(count) + }, + consumer = new + { + received = _consumerReceived.ToArray(count) + } + }; + } + + var response = request.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "application/json"); + await response.WriteStringAsync(JsonSerializer.Serialize(history)); + return response; + } + + private sealed class CircularBuffer + { + private readonly string[] _items; + private int _index; + private int _count; + + public CircularBuffer(int capacity) => _items = new string[capacity]; + + public void Add(string item) + { + _items[_index] = item; + _index = (_index + 1) % _items.Length; + if (_count < _items.Length) _count++; + } + + public string[] ToArray(int count) + { + var available = Math.Min(count, _count); + var result = new string[available]; + for (int i = 0; i < available; i++) + { + var idx = (_index - available + i + _items.Length) % _items.Length; + result[i] = _items[idx]; + } + return result; + } + } +} + +/// +/// Represents the input payload for greeting requests. +/// +public class RequestMessage +{ + /// + /// Gets or sets the name to greet. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } +} + +/// +/// Represents the response payload for greeting requests. +/// +public class ResponseMessage +{ + /// + /// Gets or sets the date of the response message. + /// + [JsonPropertyName("date")] + public required string Date { get; set; } + + /// + /// Gets or sets the text of the response message. + /// + [JsonPropertyName("text")] + public required string Text { get; set; } } \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/src/Program.cs b/samples/function-app-service-bus/dotnet/src/Program.cs index c76837e..51336f3 100644 --- a/samples/function-app-service-bus/dotnet/src/Program.cs +++ b/samples/function-app-service-bus/dotnet/src/Program.cs @@ -1,7 +1,7 @@ -using Microsoft.Extensions.Hosting; - -var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .Build(); - -host.Run(); +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + +host.Run(); diff --git a/samples/function-app-service-bus/dotnet/src/sample.csproj b/samples/function-app-service-bus/dotnet/src/sample.csproj index 8b21d36..c691742 100644 --- a/samples/function-app-service-bus/dotnet/src/sample.csproj +++ b/samples/function-app-service-bus/dotnet/src/sample.csproj @@ -1,31 +1,31 @@ - - - net10.0 - v4 - Exe - enable - enable - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - Never - - - - - + + + net10.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + \ No newline at end of file diff --git a/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs b/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs index ca1d8eb..9c500bb 100644 --- a/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs +++ b/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs @@ -1,778 +1,778 @@ -using System.Net; -using System.Text.Json; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Azure.Storage.Blobs; -using Azure.Storage.Queues; -using Azure.Storage.Queues.Models; -using Azure.Data.Tables; -using Azure; - -namespace LocalStack.Azure.Samples; - -/// -/// Manages game sessions and player interactions using Azure Functions with hybrid storage approach. -/// This class demonstrates a complete gaming scoreboard system with blob storage for game files, -/// queue processing for game events, table storage for winners, and internal dictionary for active game data. -/// The system processes GameStatusRequest/GameStatusResponse messages and maintains game state in memory -/// for improved performance while still utilizing Azure Storage services for persistence and messaging. -/// -public class GameSessionManager -{ - // Instance field for logging - keeps proper Azure Functions execution context - private readonly ILogger _logger; - - // Static configuration values - initialized once per application lifetime - private static string? _connectionString; - private static string? _inputQueueName; - private static string? _outputQueueName; - private static string? _triggerQueueName; - private static string? _inputContainerName; - private static string? _outputContainerName; - private static string? _inputTableName; - private static string? _outputTableName; - private static string[]? _playerNames; - private static bool _configurationValid; - private static int _gameId = 1; - - // Static dictionary to store game data in memory - game ID as key, list of player scores as value - // This replaces Azure Table Storage for demonstration purposes and provides faster access - private static readonly Dictionary> _gameData = new Dictionary>(); - private static readonly object _gameDataLock = new object(); - - // Static initialization - runs once per application lifetime - private static readonly Lazy _infrastructureInitialization = new Lazy(() => InitializeInfrastructureOnceAsync()); - - /// - /// Initializes a new instance of the class. - /// - /// The logger factory used to create loggers for this class. - public GameSessionManager(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } - - /// - /// Ensures that Azure infrastructure (queues, containers, tables) is initialized exactly once - /// during the application lifetime. This method is thread-safe and idempotent. - /// - /// A task that completes when infrastructure initialization is finished. - private async Task EnsureInfrastructureInitializedAsync() - { - await _infrastructureInitialization.Value; - } - - /// - /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). - /// This method runs exactly once per application lifetime and stores configuration values in static fields. - /// - /// A task representing the asynchronous initialization operation. - private static async Task InitializeInfrastructureOnceAsync() - { - try - { - // Create a temporary configuration instance for initialization - var configBuilder = new ConfigurationBuilder() - .AddEnvironmentVariables() - .AddJsonFile("local.settings.json", optional: true); - var config = configBuilder.Build(); - - // Create a temporary logger for initialization - using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - - logger.LogInformation("[InitializeInfrastructureOnceAsync] Starting one-time infrastructure initialization..."); - - // Read and store configuration values in static fields with fallback defaults - _connectionString = config["STORAGE_ACCOUNT_CONNECTION_STRING"]; - _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; - _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; - _triggerQueueName = config["TRIGGER_QUEUE_NAME"] ?? "trigger"; - _inputContainerName = config["INPUT_STORAGE_CONTAINER_NAME"] ?? "input"; - _outputContainerName = config["OUTPUT_STORAGE_CONTAINER_NAME"] ?? "output"; - _inputTableName = config["INPUT_TABLE_NAME"] ?? "scoreboards"; - _outputTableName = config["OUTPUT_TABLE_NAME"] ?? "winners"; - _playerNames = config["PLAYER_NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - // Check if player names ae configured. If not use, use default names - if (_playerNames == null || _playerNames.Length == 0) - { - logger.LogWarning("[InitializeInfrastructureOnceAsync] PLAYER_NAMES configuration is missing or empty. Using default names."); - _playerNames = new[] { "Alice", "Anastasia", "Paolo", "Leo", "Mia" }; - } - - // Validate configuration and set the flag - _configurationValid = ValidateConfigurationValues(logger); - - if (_configurationValid && _connectionString != null) - { - // Initialize all infrastructure components - await InitializeQueuesAsync(_connectionString, logger, new[] { _inputQueueName, _outputQueueName, _triggerQueueName }.Where(q => q != null).ToArray()!); - await InitializeContainersAsync(_connectionString, logger, new[] { _inputContainerName, _outputContainerName }.Where(c => c != null).ToArray()!); - await InitializeTablesAsync(_connectionString, logger, new[] { _inputTableName, _outputTableName }.Where(t => t != null).ToArray()!); - - logger.LogInformation("[InitializeInfrastructureOnceAsync] Infrastructure initialization completed successfully."); - } - else - { - logger.LogError("[InitializeInfrastructureOnceAsync] Configuration validation failed. Infrastructure initialization aborted."); - } - } - catch (Exception ex) - { - // Log error but don't throw - let functions continue to work even if initialization fails - Console.WriteLine("[InitializeInfrastructureOnceAsync] Failed to initialize infrastructure: {0}", ex.Message); - _configurationValid = false; - } - } - - /// - /// Validates that all required configuration values are present and not empty. - /// With default values in place, only the connection string is mandatory. - /// - /// Logger for reporting validation errors. - /// True if all configuration values are valid, false otherwise. - private static bool ValidateConfigurationValues(ILogger logger) - { - bool isValid = true; - - // Connection string is the only truly required value - everything else has defaults - if (string.IsNullOrWhiteSpace(_connectionString)) - { - logger.LogError("[ValidateConfigurationValues] STORAGE_ACCOUNT_CONNECTION_STRING configuration value is missing and is required."); - isValid = false; - } - - // Log the configuration values being used (helpful for debugging) - if (isValid) - { - logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); - logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); - logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); - logger.LogInformation(" - Trigger Queue: {triggerQueue}", _triggerQueueName); - logger.LogInformation(" - Input Container: {inputContainer}", _inputContainerName); - logger.LogInformation(" - Output Container: {outputContainer}", _outputContainerName); - logger.LogInformation(" - Input Table: {inputTable}", _inputTableName); - logger.LogInformation(" - Output Table: {outputTable}", _outputTableName); - } - - return isValid; - } - - /// - /// Checks if configuration values have been successfully loaded and validated. - /// This method provides a fast runtime check without re-reading configuration. - /// With default values, this primarily checks if the connection string is available. - /// - /// True if configuration is valid and available, false otherwise. - private static bool IsConfigurationValid() - { - // Since we have defaults for all values except connection string, - // we only need to check the configuration validation flag and connection string - return _configurationValid && !string.IsNullOrWhiteSpace(_connectionString); - } - - /// - /// Static version of queue initialization for one-time setup. - /// - private static async Task InitializeQueuesAsync(string connectionString, ILogger logger, string[] queues) - { - try - { - foreach (var queueName in queues.Where(q => !string.IsNullOrWhiteSpace(q))) - { - var queueClient = new QueueClient(connectionString, queueName); - await queueClient.CreateIfNotExistsAsync(); - logger.LogInformation("[InitializeQueuesAsync] Initialized queue: {queueName}", queueName); - } - } - catch (Exception ex) - { - logger.LogError(ex, "[InitializeQueuesAsync] Failed to initialize queues."); - } - } - - /// - /// Static version of container initialization for one-time setup. - /// - private static async Task InitializeContainersAsync(string connectionString, ILogger logger, string[] containers) - { - try - { - var blobServiceClient = new BlobServiceClient(connectionString); - foreach (var containerName in containers.Where(c => !string.IsNullOrWhiteSpace(c))) - { - var containerClient = blobServiceClient.GetBlobContainerClient(containerName); - await containerClient.CreateIfNotExistsAsync(); - logger.LogInformation("[InitializeContainersAsync] Initialized container: {containerName}", containerName); - } - } - catch (Exception ex) - { - logger.LogError(ex, "[InitializeContainersAsync] Failed to initialize containers."); - } - } - - /// - /// Static version of table initialization for one-time setup. - /// - private static async Task InitializeTablesAsync(string connectionString, ILogger logger, string[] tables) - { - try - { - foreach (var tableName in tables.Where(t => !string.IsNullOrWhiteSpace(t))) - { - var tableClient = new TableClient(connectionString, tableName); - await tableClient.CreateIfNotExistsAsync(); - logger.LogInformation("[InitializeTablesAsync] Initialized table: {tableName}", tableName); - } - } - catch (Exception ex) - { - logger.LogError(ex, "[InitializeTablesAsync] Failed to initialize tables."); - } - } - - /// - /// Handles HTTP GET requests to retrieve player score for a specific game and player. - /// - /// The HTTP request data. - /// The game ID to retrieve scores for, provided in the route. - /// The player name to retrieve status for, provided in the route. - /// An HTTP response with player score information or an error message. - [Function("GetPlayerScore")] - public async Task GetPlayerScoreAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "player/{gameId}/{name}/status")] HttpRequestData request, int gameId, string name) - { - HttpResponseData response; - - // Log the incoming request - _logger.LogInformation("[GetPlayerScore] Received GET request with gameId = {gameId}, name = {name}.", gameId, name ?? "NULL"); - - // Validate the name parameter - if (name == null || string.IsNullOrWhiteSpace(name)) - { - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("Invalid parameters: name parameter is required."); - return response; - } - - // Check if the game and player exist in the internal dictionary - PlayerScore? playerScore = null; - lock (_gameDataLock) - { - if (_gameData.TryGetValue(gameId, out var players)) - { - playerScore = players.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - } - } - - if (playerScore == null) - { - response = request.CreateResponse(HttpStatusCode.NotFound); - await response.WriteStringAsync($"Game {gameId} and player '{name}' tuple not found."); - return response; - } - - // Return the player score information - response = request.CreateResponse(HttpStatusCode.OK); - - // Create the response message - var outputObj = new PlayerScoreResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = gameId, - Name = playerScore.Name, - Score = playerScore.Score - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Write the response message to the HTTP response - await response.WriteStringAsync(outputMessage); - - // Log the successful processing - _logger.LogInformation("[GetPlayerScore] Processed request successfully with gameId = {gameId}, name = {name}, score = {score}.", gameId, name, playerScore.Score); - - // Return the HTTP response - return response; - } - - /// - /// Handles HTTP POST and PUT requests to retrieve game status information. - /// Accepts a GameStatusRequest in the request body and returns comprehensive game status - /// including winner determination and all player scores for the specified game. - /// - /// The HTTP request data containing a JSON-serialized GameStatusRequest. - /// An HTTP response with GameStatusResponse containing game status details, winner, and all players, or an error message if the game is not found or invalid. - [Function("CreateGameStatus")] - public async Task CreateGameStatusAsync([HttpTrigger(AuthorizationLevel.Function, "post", "put", Route = "game/session")] HttpRequestData request) - { - HttpResponseData response; - - // Log the incoming request method - _logger.LogInformation("[CreateGameStatus] Received {method} request.", request.Method); - - // Read the request body as a string - var requestBody = await request.ReadAsStringAsync(); - - // Validate that the request body is not empty - if (requestBody == null || string.IsNullOrWhiteSpace(requestBody)) - { - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is required."); - return response; - } - - GameStatusRequest? requestMessage; - try - { - // Attempt to deserialize the request body into a GameStatusRequest object - requestMessage = JsonSerializer.Deserialize(requestBody); - } - catch (JsonException) - { - // Handle invalid JSON format - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is not in the proper format."); - return response; - } - - // Validate that the GameId property is present and valid - if (requestMessage == null || requestMessage.GameId <= 0) - { - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("[CreateGameStatus] Invalid request message: 'GameId' is required and must be greater than 0."); - return response; - } - - // Check if the game exists in the internal dictionary - List? players = null; - bool gameFound = false; - lock (_gameDataLock) - { - gameFound = _gameData.TryGetValue(requestMessage.GameId, out players); - } - - if (!gameFound || players == null) - { - _logger.LogWarning("[CreateGameStatus] Game {gameId} not found in internal data store.", requestMessage.GameId); - response = request.CreateResponse(HttpStatusCode.NotFound); - await response.WriteStringAsync($"Game {requestMessage.GameId} not found."); - return response; - } - - if (players.Count == 0) - { - _logger.LogWarning("[CreateGameStatus] Game {gameId} has no player data.", requestMessage.GameId); - response = request.CreateResponse(HttpStatusCode.NotFound); - await response.WriteStringAsync($"Game {requestMessage.GameId} has no player data."); - return response; - } - - // Find the winner (player with highest score) - var winner = players.OrderByDescending(p => p.Score).First(); - - // Return a response if the request message is valid - response = request.CreateResponse(HttpStatusCode.OK); - - // Create the response message - var outputObj = new GameStatusResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = requestMessage.GameId, - Winner = winner.Name, - Players = new List(players) // Create a copy of the list - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Write the response message to the HTTP response - await response.WriteStringAsync(outputMessage); - - // Log the successful processing - _logger.LogInformation("[CreateGameStatus] Processed request successfully with gameId = {gameId}, winner = {winner}.", requestMessage.GameId, winner.Name); - - // Return the HTTP response - return response; - } - - /// - /// Processes uploaded game status files from blob storage and generates comprehensive game status responses. - /// Deserializes GameStatusRequest from blob content, retrieves game data from internal dictionary, - /// determines the winner, and returns a GameStatusResponse with complete game information. - /// - /// The blob content as byte array containing JSON-serialized GameStatusRequest. - /// The name of the blob file being processed. - /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players, or null if the input is invalid or game not found. - [Function("ProcessGameFile")] - [BlobOutput("%OUTPUT_STORAGE_CONTAINER_NAME%/{name}")] - public string? ProcessGameFile( - [BlobTrigger("%INPUT_STORAGE_CONTAINER_NAME%/{name}", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] byte[] blobBytes, - string name) - { - // Check that the blobBytes is not null or empty - if (blobBytes == null || blobBytes.Length == 0) - { - _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or null.", name); - return null; - } - - // Convert the byte array to a string - string json = System.Text.Encoding.UTF8.GetString(blobBytes); - - // Check that the JSON is not null or empty - if (string.IsNullOrEmpty(json)) - { - _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or invalid.", name); - return null; - } - - // Deserialize the JSON into a GameStatusRequest object - GameStatusRequest? gameStatusRequest = JsonSerializer.Deserialize(json); - - // Check that the request message is not null - if (gameStatusRequest == null) - { - _logger.LogError("[ProcessGameFile] Received [{name}] blob contains invalid GameStatusRequest.", name); - return null; - } - - // Check if the game exists in the internal dictionary - List? players = null; - lock (_gameDataLock) - { - if (!_gameData.TryGetValue(gameStatusRequest.GameId, out players)) - { - _logger.LogWarning("[ProcessGameFile] Game {gameId} not found in internal data store.", gameStatusRequest.GameId); - return null; - } - } - - if (players == null || players.Count == 0) - { - _logger.LogWarning("[ProcessGameFile] Game {gameId} has no player data.", gameStatusRequest.GameId); - return null; - } - - // Find the winner (player with highest score) - var winner = players.OrderByDescending(p => p.Score).First(); - - // Create the response message - var outputObj = new GameStatusResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = gameStatusRequest.GameId, - Winner = winner.Name, - Players = new List(players) // Create a copy of the list - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Log the successful processing of the blob - _logger.LogInformation("[ProcessGameFile] Processed blob [{name}] successfully for game {gameId}.", name, gameStatusRequest.GameId); - - // Return the response message - return outputMessage; - } - - /// - /// Handles game events from Azure Storage Queue and processes game status requests. - /// Deserializes GameStatusRequest from queue messages, retrieves game data from internal dictionary, - /// determines the winner, and returns a GameStatusResponse for further processing in the output queue. - /// - /// The incoming queue message containing JSON-serialized GameStatusRequest data. - /// The function execution context provided by the Azure Functions runtime. - /// - /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players for output queue processing, or null if the input is invalid or game not found. - /// - [Function("HandleGameEvent")] - [QueueOutput("%OUTPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] - public string? HandleGameEvent([QueueTrigger("%INPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] QueueMessage message, FunctionContext context) - { - - // Check that the message and the body are not null or empty - if (message == null || string.IsNullOrWhiteSpace(message.Body?.ToString())) - { - _logger.LogError("[HandleGameEvent] Received queue message is null or empty."); - return null; - } - - var json = message.Body.ToString() ?? string.Empty; - - // Check that the JSON is not null or empty - if (string.IsNullOrEmpty(json)) - { - _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message is empty or invalid.", message.MessageId); - return null; - } - - // Deserialize the JSON into a GameStatusRequest object - GameStatusRequest? requestMessage = JsonSerializer.Deserialize(json); - - // Check that the request message is not null - if (requestMessage == null) - { - _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message contains invalid GameStatusRequest.", message.MessageId); - return null; - } - - // Check if the game exists in the internal dictionary - List? players = null; - lock (_gameDataLock) - { - if (!_gameData.TryGetValue(requestMessage.GameId, out players)) - { - _logger.LogWarning("[HandleGameEvent] Game {gameId} not found in internal data store.", requestMessage.GameId); - // Return null for now, but could create an error response if needed - return null; - } - } - - if (players == null || players.Count == 0) - { - _logger.LogWarning("[HandleGameEvent] Game {gameId} has no player data.", requestMessage.GameId); - return null; - } - - // Find the winner (player with highest score) - var winner = players.OrderByDescending(p => p.Score).First(); - - // Create the response message - var outputObj = new GameStatusResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = requestMessage.GameId, - Winner = winner.Name, - Players = new List(players) // Create a copy of the list - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Log the successful processing of the queue message - _logger.LogInformation("[HandleGameEvent] Processed queue message [{messageId}] successfully for game {gameId}.", message.MessageId, requestMessage.GameId); - - // Return the response message - return outputMessage; - - } - - /// - /// Processes game scoreboards to determine winners and manage game results. - /// Retrieves scoreboard entries for a game, finds the highest score, and records the winner. - /// - /// The game ID from the queue message to filter scoreboard entries. - /// The scoreboard entities matching the specified game ID. - /// The winning ScoreboardEntity with the highest score, or null if no entities are found. - [Function("ProcessScoreboard")] - [TableOutput("%OUTPUT_TABLE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] - public ScoreboardEntity? ProcessScoreboard( - [QueueTrigger("%TRIGGER_QUEUE_NAME%")] string gameId, - [TableInput("%INPUT_TABLE_NAME%", "{queueTrigger}", - Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] IEnumerable entities) - { - // Find the entity with the highest score - var winner = entities.OrderByDescending(e => e.Score).FirstOrDefault(); - _logger.LogInformation("[ProcessScoreboard] Processed game ID {gameId}. Winner: {winnerName} with score {score}.", gameId, winner?.PlayerName ?? "No winner", winner?.Score.ToString() ?? "N/A"); - - if (winner != null) - { - // Create a new entity for the output table with a new RowKey - var winnerEntity = new ScoreboardEntity - { - PartitionKey = $"winner-game-{int.Parse(gameId):D3}", - RowKey = Guid.NewGuid().ToString(), - GameId = winner.GameId, - PlayerName = winner.PlayerName, - Score = winner.Score, - Timestamp = DateTimeOffset.UtcNow, - ETag = ETag.All - }; - - return winnerEntity; - } - - return null; - } - - /// - /// Timer-triggered function that generates new game rounds with random player data and initiates the complete gaming workflow. - /// Creates GameStatusRequest objects, uploads them as blobs, sends queue messages, creates internal dictionary entries - /// with random player scores, and triggers the scoreboard processing pipeline. Runs every minute and on startup. - /// - /// Timer metadata containing schedule status and next occurrence information. - /// A task that represents the asynchronous game round generation operation. - [Function("CreateGame")] - [FixedDelayRetry(5, "00:00:10")] - public async Task CreateGameAsync([TimerTrigger("0 */1 * * * *", RunOnStartup = true)] TimerInfo timerInfo) - { - _logger.LogInformation("[CreateGameAsync] Triggered execution."); - - // Ensure infrastructure is initialized (runs only once per app lifetime) - await EnsureInfrastructureInitializedAsync(); - - // Fast configuration validation using pre-loaded static values - if (!IsConfigurationValid()) - { - _logger.LogError("[CreateGameAsync] Configuration is invalid or not loaded. Aborting function execution."); - return; - } - - if (_playerNames == null || _playerNames.Length == 0) - { - _logger.LogError("[CreateGameAsync] Player names are not configured. Aborting function execution."); - return; - } - - var random = new Random(); - var gameStatusRequest = new GameStatusRequest { GameId = _gameId }; - - // Serialize the request message to JSON - var message = JsonSerializer.Serialize(gameStatusRequest); - - // Log the generated message and configuration values - _logger.LogInformation("[CreateGameAsync] Generated message: {message}", message); - - // Create a unique blob name with the required format - var now = DateTime.UtcNow; - var blobFileName = $"game-{_gameId:D3}-status-{now:yyyy-MM-dd-HH-mm-ss}.json"; - - // Create scoreboard entries for all players using the updated method - await CreateScoreboardEntriesAsync(_connectionString, _inputTableName); - - // Upload blob to the input container - await UploadBlobAsync(_connectionString, _inputContainerName, blobFileName, message); - - // Send message to the input queue - await SendQueueMessageAsync(_connectionString, _inputQueueName, message); - - // Send message to the trigger queue - await SendQueueMessageAsync(_connectionString, _triggerQueueName, _gameId.ToString()); - - // Increment game ID for next execution - _gameId++; - - // Log the next scheduled timer occurrence - _logger.LogInformation("[CreateGameAsync] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); - } - - /// - /// Uploads a message as a blob to the specified Azure Storage container. - /// - /// The storage account connection string. - /// The name of the container to upload to. - /// The name of the blob file to create. - /// The message content to upload as blob data. - /// A task that represents the asynchronous upload operation. - private async Task UploadBlobAsync(string? connectionString, string? inputContainerName, string blobFileName, string message) - { - try - { - var blobServiceClient = new BlobServiceClient(connectionString); - var blobContainerClient = blobServiceClient.GetBlobContainerClient(inputContainerName); - - await blobContainerClient.CreateIfNotExistsAsync(); - - var blobClient = blobContainerClient.GetBlobClient(blobFileName); - - using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(message))) - { - await blobClient.UploadAsync(stream, overwrite: true); - } - _logger.LogInformation("[UploadBlobAsync] Uploaded blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); - } - catch (Exception ex) - { - _logger.LogError(ex, "[UploadBlobAsync] Failed to upload blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); - } - } - - /// - /// Sends a message to the specified Azure Storage queue. - /// - /// The storage account connection string. - /// The name of the queue to send the message to. - /// The message content to send (will be Base64 encoded). - /// A task that represents the asynchronous send operation. - private async Task SendQueueMessageAsync(string? connectionString, string? queueName, string message) - { - try - { - var queueClient = new QueueClient(connectionString, queueName); - - await queueClient.CreateIfNotExistsAsync(); - - await queueClient.SendMessageAsync(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(message))); - _logger.LogInformation("[SendQueueMessageAsync] Sent message to queue: {queueName}", queueName); - } - catch (Exception ex) - { - _logger.LogError(ex, "[SendQueueMessageAsync] Failed to send message to queue: {queueName}", queueName); - } - } - - /// - /// Creates scoreboard entries for each player with random scores and stores them in the internal dictionary. - /// This method replaces Azure Table Storage operations by maintaining game data in memory using a thread-safe - /// Dictionary structure. Each game is stored with its unique game ID and contains all player scores. - /// - /// The storage account connection string. - /// The name of the table to store the scoreboard entries in. - /// A task that represents the asynchronous operation of creating and storing player scores. - private async Task CreateScoreboardEntriesAsync(string? connectionString, string? tableName) - { - try - { - var random = new Random(); - var playerScores = new List(); - var tableClient = new TableClient(connectionString, tableName); - await tableClient.CreateIfNotExistsAsync(); - var partitionKey = _gameId.ToString(); - - if (_playerNames == null || _playerNames.Length == 0) - { - _logger.LogWarning("[CreateScoreboardEntriesAsync] No player names configured. Skipping scoreboard entry creation."); - return; - } - - foreach (var name in _playerNames) - { - var score = Math.Max(0, random.Next(0, 101)); // Random number between 0 and 100, ensure >= 0 - var playerScore = new PlayerScore - { - Name = name, - Score = score - }; - - playerScores.Add(playerScore); - - var entity = new ScoreboardEntity - { - PartitionKey = partitionKey, - RowKey = Guid.NewGuid().ToString(), - GameId = _gameId, - PlayerName = name, - Score = score, - Timestamp = DateTimeOffset.UtcNow, - ETag = ETag.All - }; - - await tableClient.AddEntityAsync(entity); - - _logger.LogInformation("[CreateScoreboardEntriesAsync] Added scoreboard entry for {playerName} with score {score} in game {gameId}", name, score, _gameId); - } - - // Store the game data in the internal dictionary (thread-safe) - lock (_gameDataLock) - { - _gameData[_gameId] = playerScores; - } - - _logger.LogInformation("[CreateScoreboardEntriesAsync] Created {playerCount} scoreboard entries for game {gameId}.", _playerNames.Length, _gameId); - - // Simulate async work to maintain the async signature - await Task.CompletedTask; - } - catch (Exception ex) - { - _logger.LogError(ex, "[CreateScoreboardEntriesAsync] Failed to create scoreboard entries for game {gameId}", _gameId); - } - } +using System.Net; +using System.Text.Json; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using Azure.Storage.Blobs; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using Azure.Data.Tables; +using Azure; + +namespace LocalStack.Azure.Samples; + +/// +/// Manages game sessions and player interactions using Azure Functions with hybrid storage approach. +/// This class demonstrates a complete gaming scoreboard system with blob storage for game files, +/// queue processing for game events, table storage for winners, and internal dictionary for active game data. +/// The system processes GameStatusRequest/GameStatusResponse messages and maintains game state in memory +/// for improved performance while still utilizing Azure Storage services for persistence and messaging. +/// +public class GameSessionManager +{ + // Instance field for logging - keeps proper Azure Functions execution context + private readonly ILogger _logger; + + // Static configuration values - initialized once per application lifetime + private static string? _connectionString; + private static string? _inputQueueName; + private static string? _outputQueueName; + private static string? _triggerQueueName; + private static string? _inputContainerName; + private static string? _outputContainerName; + private static string? _inputTableName; + private static string? _outputTableName; + private static string[]? _playerNames; + private static bool _configurationValid; + private static int _gameId = 1; + + // Static dictionary to store game data in memory - game ID as key, list of player scores as value + // This replaces Azure Table Storage for demonstration purposes and provides faster access + private static readonly Dictionary> _gameData = new Dictionary>(); + private static readonly object _gameDataLock = new object(); + + // Static initialization - runs once per application lifetime + private static readonly Lazy _infrastructureInitialization = new Lazy(() => InitializeInfrastructureOnceAsync()); + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory used to create loggers for this class. + public GameSessionManager(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + /// + /// Ensures that Azure infrastructure (queues, containers, tables) is initialized exactly once + /// during the application lifetime. This method is thread-safe and idempotent. + /// + /// A task that completes when infrastructure initialization is finished. + private async Task EnsureInfrastructureInitializedAsync() + { + await _infrastructureInitialization.Value; + } + + /// + /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). + /// This method runs exactly once per application lifetime and stores configuration values in static fields. + /// + /// A task representing the asynchronous initialization operation. + private static async Task InitializeInfrastructureOnceAsync() + { + try + { + // Create a temporary configuration instance for initialization + var configBuilder = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddJsonFile("local.settings.json", optional: true); + var config = configBuilder.Build(); + + // Create a temporary logger for initialization + using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var logger = loggerFactory.CreateLogger(); + + logger.LogInformation("[InitializeInfrastructureOnceAsync] Starting one-time infrastructure initialization..."); + + // Read and store configuration values in static fields with fallback defaults + _connectionString = config["STORAGE_ACCOUNT_CONNECTION_STRING"]; + _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; + _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; + _triggerQueueName = config["TRIGGER_QUEUE_NAME"] ?? "trigger"; + _inputContainerName = config["INPUT_STORAGE_CONTAINER_NAME"] ?? "input"; + _outputContainerName = config["OUTPUT_STORAGE_CONTAINER_NAME"] ?? "output"; + _inputTableName = config["INPUT_TABLE_NAME"] ?? "scoreboards"; + _outputTableName = config["OUTPUT_TABLE_NAME"] ?? "winners"; + _playerNames = config["PLAYER_NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // Check if player names ae configured. If not use, use default names + if (_playerNames == null || _playerNames.Length == 0) + { + logger.LogWarning("[InitializeInfrastructureOnceAsync] PLAYER_NAMES configuration is missing or empty. Using default names."); + _playerNames = new[] { "Alice", "Anastasia", "Paolo", "Leo", "Mia" }; + } + + // Validate configuration and set the flag + _configurationValid = ValidateConfigurationValues(logger); + + if (_configurationValid && _connectionString != null) + { + // Initialize all infrastructure components + await InitializeQueuesAsync(_connectionString, logger, new[] { _inputQueueName, _outputQueueName, _triggerQueueName }.Where(q => q != null).ToArray()!); + await InitializeContainersAsync(_connectionString, logger, new[] { _inputContainerName, _outputContainerName }.Where(c => c != null).ToArray()!); + await InitializeTablesAsync(_connectionString, logger, new[] { _inputTableName, _outputTableName }.Where(t => t != null).ToArray()!); + + logger.LogInformation("[InitializeInfrastructureOnceAsync] Infrastructure initialization completed successfully."); + } + else + { + logger.LogError("[InitializeInfrastructureOnceAsync] Configuration validation failed. Infrastructure initialization aborted."); + } + } + catch (Exception ex) + { + // Log error but don't throw - let functions continue to work even if initialization fails + Console.WriteLine("[InitializeInfrastructureOnceAsync] Failed to initialize infrastructure: {0}", ex.Message); + _configurationValid = false; + } + } + + /// + /// Validates that all required configuration values are present and not empty. + /// With default values in place, only the connection string is mandatory. + /// + /// Logger for reporting validation errors. + /// True if all configuration values are valid, false otherwise. + private static bool ValidateConfigurationValues(ILogger logger) + { + bool isValid = true; + + // Connection string is the only truly required value - everything else has defaults + if (string.IsNullOrWhiteSpace(_connectionString)) + { + logger.LogError("[ValidateConfigurationValues] STORAGE_ACCOUNT_CONNECTION_STRING configuration value is missing and is required."); + isValid = false; + } + + // Log the configuration values being used (helpful for debugging) + if (isValid) + { + logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); + logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); + logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); + logger.LogInformation(" - Trigger Queue: {triggerQueue}", _triggerQueueName); + logger.LogInformation(" - Input Container: {inputContainer}", _inputContainerName); + logger.LogInformation(" - Output Container: {outputContainer}", _outputContainerName); + logger.LogInformation(" - Input Table: {inputTable}", _inputTableName); + logger.LogInformation(" - Output Table: {outputTable}", _outputTableName); + } + + return isValid; + } + + /// + /// Checks if configuration values have been successfully loaded and validated. + /// This method provides a fast runtime check without re-reading configuration. + /// With default values, this primarily checks if the connection string is available. + /// + /// True if configuration is valid and available, false otherwise. + private static bool IsConfigurationValid() + { + // Since we have defaults for all values except connection string, + // we only need to check the configuration validation flag and connection string + return _configurationValid && !string.IsNullOrWhiteSpace(_connectionString); + } + + /// + /// Static version of queue initialization for one-time setup. + /// + private static async Task InitializeQueuesAsync(string connectionString, ILogger logger, string[] queues) + { + try + { + foreach (var queueName in queues.Where(q => !string.IsNullOrWhiteSpace(q))) + { + var queueClient = new QueueClient(connectionString, queueName); + await queueClient.CreateIfNotExistsAsync(); + logger.LogInformation("[InitializeQueuesAsync] Initialized queue: {queueName}", queueName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[InitializeQueuesAsync] Failed to initialize queues."); + } + } + + /// + /// Static version of container initialization for one-time setup. + /// + private static async Task InitializeContainersAsync(string connectionString, ILogger logger, string[] containers) + { + try + { + var blobServiceClient = new BlobServiceClient(connectionString); + foreach (var containerName in containers.Where(c => !string.IsNullOrWhiteSpace(c))) + { + var containerClient = blobServiceClient.GetBlobContainerClient(containerName); + await containerClient.CreateIfNotExistsAsync(); + logger.LogInformation("[InitializeContainersAsync] Initialized container: {containerName}", containerName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[InitializeContainersAsync] Failed to initialize containers."); + } + } + + /// + /// Static version of table initialization for one-time setup. + /// + private static async Task InitializeTablesAsync(string connectionString, ILogger logger, string[] tables) + { + try + { + foreach (var tableName in tables.Where(t => !string.IsNullOrWhiteSpace(t))) + { + var tableClient = new TableClient(connectionString, tableName); + await tableClient.CreateIfNotExistsAsync(); + logger.LogInformation("[InitializeTablesAsync] Initialized table: {tableName}", tableName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[InitializeTablesAsync] Failed to initialize tables."); + } + } + + /// + /// Handles HTTP GET requests to retrieve player score for a specific game and player. + /// + /// The HTTP request data. + /// The game ID to retrieve scores for, provided in the route. + /// The player name to retrieve status for, provided in the route. + /// An HTTP response with player score information or an error message. + [Function("GetPlayerScore")] + public async Task GetPlayerScoreAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "player/{gameId}/{name}/status")] HttpRequestData request, int gameId, string name) + { + HttpResponseData response; + + // Log the incoming request + _logger.LogInformation("[GetPlayerScore] Received GET request with gameId = {gameId}, name = {name}.", gameId, name ?? "NULL"); + + // Validate the name parameter + if (name == null || string.IsNullOrWhiteSpace(name)) + { + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("Invalid parameters: name parameter is required."); + return response; + } + + // Check if the game and player exist in the internal dictionary + PlayerScore? playerScore = null; + lock (_gameDataLock) + { + if (_gameData.TryGetValue(gameId, out var players)) + { + playerScore = players.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + } + + if (playerScore == null) + { + response = request.CreateResponse(HttpStatusCode.NotFound); + await response.WriteStringAsync($"Game {gameId} and player '{name}' tuple not found."); + return response; + } + + // Return the player score information + response = request.CreateResponse(HttpStatusCode.OK); + + // Create the response message + var outputObj = new PlayerScoreResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = gameId, + Name = playerScore.Name, + Score = playerScore.Score + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Write the response message to the HTTP response + await response.WriteStringAsync(outputMessage); + + // Log the successful processing + _logger.LogInformation("[GetPlayerScore] Processed request successfully with gameId = {gameId}, name = {name}, score = {score}.", gameId, name, playerScore.Score); + + // Return the HTTP response + return response; + } + + /// + /// Handles HTTP POST and PUT requests to retrieve game status information. + /// Accepts a GameStatusRequest in the request body and returns comprehensive game status + /// including winner determination and all player scores for the specified game. + /// + /// The HTTP request data containing a JSON-serialized GameStatusRequest. + /// An HTTP response with GameStatusResponse containing game status details, winner, and all players, or an error message if the game is not found or invalid. + [Function("CreateGameStatus")] + public async Task CreateGameStatusAsync([HttpTrigger(AuthorizationLevel.Function, "post", "put", Route = "game/session")] HttpRequestData request) + { + HttpResponseData response; + + // Log the incoming request method + _logger.LogInformation("[CreateGameStatus] Received {method} request.", request.Method); + + // Read the request body as a string + var requestBody = await request.ReadAsStringAsync(); + + // Validate that the request body is not empty + if (requestBody == null || string.IsNullOrWhiteSpace(requestBody)) + { + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is required."); + return response; + } + + GameStatusRequest? requestMessage; + try + { + // Attempt to deserialize the request body into a GameStatusRequest object + requestMessage = JsonSerializer.Deserialize(requestBody); + } + catch (JsonException) + { + // Handle invalid JSON format + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is not in the proper format."); + return response; + } + + // Validate that the GameId property is present and valid + if (requestMessage == null || requestMessage.GameId <= 0) + { + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("[CreateGameStatus] Invalid request message: 'GameId' is required and must be greater than 0."); + return response; + } + + // Check if the game exists in the internal dictionary + List? players = null; + bool gameFound = false; + lock (_gameDataLock) + { + gameFound = _gameData.TryGetValue(requestMessage.GameId, out players); + } + + if (!gameFound || players == null) + { + _logger.LogWarning("[CreateGameStatus] Game {gameId} not found in internal data store.", requestMessage.GameId); + response = request.CreateResponse(HttpStatusCode.NotFound); + await response.WriteStringAsync($"Game {requestMessage.GameId} not found."); + return response; + } + + if (players.Count == 0) + { + _logger.LogWarning("[CreateGameStatus] Game {gameId} has no player data.", requestMessage.GameId); + response = request.CreateResponse(HttpStatusCode.NotFound); + await response.WriteStringAsync($"Game {requestMessage.GameId} has no player data."); + return response; + } + + // Find the winner (player with highest score) + var winner = players.OrderByDescending(p => p.Score).First(); + + // Return a response if the request message is valid + response = request.CreateResponse(HttpStatusCode.OK); + + // Create the response message + var outputObj = new GameStatusResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = requestMessage.GameId, + Winner = winner.Name, + Players = new List(players) // Create a copy of the list + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Write the response message to the HTTP response + await response.WriteStringAsync(outputMessage); + + // Log the successful processing + _logger.LogInformation("[CreateGameStatus] Processed request successfully with gameId = {gameId}, winner = {winner}.", requestMessage.GameId, winner.Name); + + // Return the HTTP response + return response; + } + + /// + /// Processes uploaded game status files from blob storage and generates comprehensive game status responses. + /// Deserializes GameStatusRequest from blob content, retrieves game data from internal dictionary, + /// determines the winner, and returns a GameStatusResponse with complete game information. + /// + /// The blob content as byte array containing JSON-serialized GameStatusRequest. + /// The name of the blob file being processed. + /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players, or null if the input is invalid or game not found. + [Function("ProcessGameFile")] + [BlobOutput("%OUTPUT_STORAGE_CONTAINER_NAME%/{name}")] + public string? ProcessGameFile( + [BlobTrigger("%INPUT_STORAGE_CONTAINER_NAME%/{name}", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] byte[] blobBytes, + string name) + { + // Check that the blobBytes is not null or empty + if (blobBytes == null || blobBytes.Length == 0) + { + _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or null.", name); + return null; + } + + // Convert the byte array to a string + string json = System.Text.Encoding.UTF8.GetString(blobBytes); + + // Check that the JSON is not null or empty + if (string.IsNullOrEmpty(json)) + { + _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or invalid.", name); + return null; + } + + // Deserialize the JSON into a GameStatusRequest object + GameStatusRequest? gameStatusRequest = JsonSerializer.Deserialize(json); + + // Check that the request message is not null + if (gameStatusRequest == null) + { + _logger.LogError("[ProcessGameFile] Received [{name}] blob contains invalid GameStatusRequest.", name); + return null; + } + + // Check if the game exists in the internal dictionary + List? players = null; + lock (_gameDataLock) + { + if (!_gameData.TryGetValue(gameStatusRequest.GameId, out players)) + { + _logger.LogWarning("[ProcessGameFile] Game {gameId} not found in internal data store.", gameStatusRequest.GameId); + return null; + } + } + + if (players == null || players.Count == 0) + { + _logger.LogWarning("[ProcessGameFile] Game {gameId} has no player data.", gameStatusRequest.GameId); + return null; + } + + // Find the winner (player with highest score) + var winner = players.OrderByDescending(p => p.Score).First(); + + // Create the response message + var outputObj = new GameStatusResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = gameStatusRequest.GameId, + Winner = winner.Name, + Players = new List(players) // Create a copy of the list + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Log the successful processing of the blob + _logger.LogInformation("[ProcessGameFile] Processed blob [{name}] successfully for game {gameId}.", name, gameStatusRequest.GameId); + + // Return the response message + return outputMessage; + } + + /// + /// Handles game events from Azure Storage Queue and processes game status requests. + /// Deserializes GameStatusRequest from queue messages, retrieves game data from internal dictionary, + /// determines the winner, and returns a GameStatusResponse for further processing in the output queue. + /// + /// The incoming queue message containing JSON-serialized GameStatusRequest data. + /// The function execution context provided by the Azure Functions runtime. + /// + /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players for output queue processing, or null if the input is invalid or game not found. + /// + [Function("HandleGameEvent")] + [QueueOutput("%OUTPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] + public string? HandleGameEvent([QueueTrigger("%INPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] QueueMessage message, FunctionContext context) + { + + // Check that the message and the body are not null or empty + if (message == null || string.IsNullOrWhiteSpace(message.Body?.ToString())) + { + _logger.LogError("[HandleGameEvent] Received queue message is null or empty."); + return null; + } + + var json = message.Body.ToString() ?? string.Empty; + + // Check that the JSON is not null or empty + if (string.IsNullOrEmpty(json)) + { + _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message is empty or invalid.", message.MessageId); + return null; + } + + // Deserialize the JSON into a GameStatusRequest object + GameStatusRequest? requestMessage = JsonSerializer.Deserialize(json); + + // Check that the request message is not null + if (requestMessage == null) + { + _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message contains invalid GameStatusRequest.", message.MessageId); + return null; + } + + // Check if the game exists in the internal dictionary + List? players = null; + lock (_gameDataLock) + { + if (!_gameData.TryGetValue(requestMessage.GameId, out players)) + { + _logger.LogWarning("[HandleGameEvent] Game {gameId} not found in internal data store.", requestMessage.GameId); + // Return null for now, but could create an error response if needed + return null; + } + } + + if (players == null || players.Count == 0) + { + _logger.LogWarning("[HandleGameEvent] Game {gameId} has no player data.", requestMessage.GameId); + return null; + } + + // Find the winner (player with highest score) + var winner = players.OrderByDescending(p => p.Score).First(); + + // Create the response message + var outputObj = new GameStatusResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = requestMessage.GameId, + Winner = winner.Name, + Players = new List(players) // Create a copy of the list + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Log the successful processing of the queue message + _logger.LogInformation("[HandleGameEvent] Processed queue message [{messageId}] successfully for game {gameId}.", message.MessageId, requestMessage.GameId); + + // Return the response message + return outputMessage; + + } + + /// + /// Processes game scoreboards to determine winners and manage game results. + /// Retrieves scoreboard entries for a game, finds the highest score, and records the winner. + /// + /// The game ID from the queue message to filter scoreboard entries. + /// The scoreboard entities matching the specified game ID. + /// The winning ScoreboardEntity with the highest score, or null if no entities are found. + [Function("ProcessScoreboard")] + [TableOutput("%OUTPUT_TABLE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] + public ScoreboardEntity? ProcessScoreboard( + [QueueTrigger("%TRIGGER_QUEUE_NAME%")] string gameId, + [TableInput("%INPUT_TABLE_NAME%", "{queueTrigger}", + Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] IEnumerable entities) + { + // Find the entity with the highest score + var winner = entities.OrderByDescending(e => e.Score).FirstOrDefault(); + _logger.LogInformation("[ProcessScoreboard] Processed game ID {gameId}. Winner: {winnerName} with score {score}.", gameId, winner?.PlayerName ?? "No winner", winner?.Score.ToString() ?? "N/A"); + + if (winner != null) + { + // Create a new entity for the output table with a new RowKey + var winnerEntity = new ScoreboardEntity + { + PartitionKey = $"winner-game-{int.Parse(gameId):D3}", + RowKey = Guid.NewGuid().ToString(), + GameId = winner.GameId, + PlayerName = winner.PlayerName, + Score = winner.Score, + Timestamp = DateTimeOffset.UtcNow, + ETag = ETag.All + }; + + return winnerEntity; + } + + return null; + } + + /// + /// Timer-triggered function that generates new game rounds with random player data and initiates the complete gaming workflow. + /// Creates GameStatusRequest objects, uploads them as blobs, sends queue messages, creates internal dictionary entries + /// with random player scores, and triggers the scoreboard processing pipeline. Runs every minute and on startup. + /// + /// Timer metadata containing schedule status and next occurrence information. + /// A task that represents the asynchronous game round generation operation. + [Function("CreateGame")] + [FixedDelayRetry(5, "00:00:10")] + public async Task CreateGameAsync([TimerTrigger("0 */1 * * * *", RunOnStartup = true)] TimerInfo timerInfo) + { + _logger.LogInformation("[CreateGameAsync] Triggered execution."); + + // Ensure infrastructure is initialized (runs only once per app lifetime) + await EnsureInfrastructureInitializedAsync(); + + // Fast configuration validation using pre-loaded static values + if (!IsConfigurationValid()) + { + _logger.LogError("[CreateGameAsync] Configuration is invalid or not loaded. Aborting function execution."); + return; + } + + if (_playerNames == null || _playerNames.Length == 0) + { + _logger.LogError("[CreateGameAsync] Player names are not configured. Aborting function execution."); + return; + } + + var random = new Random(); + var gameStatusRequest = new GameStatusRequest { GameId = _gameId }; + + // Serialize the request message to JSON + var message = JsonSerializer.Serialize(gameStatusRequest); + + // Log the generated message and configuration values + _logger.LogInformation("[CreateGameAsync] Generated message: {message}", message); + + // Create a unique blob name with the required format + var now = DateTime.UtcNow; + var blobFileName = $"game-{_gameId:D3}-status-{now:yyyy-MM-dd-HH-mm-ss}.json"; + + // Create scoreboard entries for all players using the updated method + await CreateScoreboardEntriesAsync(_connectionString, _inputTableName); + + // Upload blob to the input container + await UploadBlobAsync(_connectionString, _inputContainerName, blobFileName, message); + + // Send message to the input queue + await SendQueueMessageAsync(_connectionString, _inputQueueName, message); + + // Send message to the trigger queue + await SendQueueMessageAsync(_connectionString, _triggerQueueName, _gameId.ToString()); + + // Increment game ID for next execution + _gameId++; + + // Log the next scheduled timer occurrence + _logger.LogInformation("[CreateGameAsync] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); + } + + /// + /// Uploads a message as a blob to the specified Azure Storage container. + /// + /// The storage account connection string. + /// The name of the container to upload to. + /// The name of the blob file to create. + /// The message content to upload as blob data. + /// A task that represents the asynchronous upload operation. + private async Task UploadBlobAsync(string? connectionString, string? inputContainerName, string blobFileName, string message) + { + try + { + var blobServiceClient = new BlobServiceClient(connectionString); + var blobContainerClient = blobServiceClient.GetBlobContainerClient(inputContainerName); + + await blobContainerClient.CreateIfNotExistsAsync(); + + var blobClient = blobContainerClient.GetBlobClient(blobFileName); + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(message))) + { + await blobClient.UploadAsync(stream, overwrite: true); + } + _logger.LogInformation("[UploadBlobAsync] Uploaded blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); + } + catch (Exception ex) + { + _logger.LogError(ex, "[UploadBlobAsync] Failed to upload blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); + } + } + + /// + /// Sends a message to the specified Azure Storage queue. + /// + /// The storage account connection string. + /// The name of the queue to send the message to. + /// The message content to send (will be Base64 encoded). + /// A task that represents the asynchronous send operation. + private async Task SendQueueMessageAsync(string? connectionString, string? queueName, string message) + { + try + { + var queueClient = new QueueClient(connectionString, queueName); + + await queueClient.CreateIfNotExistsAsync(); + + await queueClient.SendMessageAsync(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(message))); + _logger.LogInformation("[SendQueueMessageAsync] Sent message to queue: {queueName}", queueName); + } + catch (Exception ex) + { + _logger.LogError(ex, "[SendQueueMessageAsync] Failed to send message to queue: {queueName}", queueName); + } + } + + /// + /// Creates scoreboard entries for each player with random scores and stores them in the internal dictionary. + /// This method replaces Azure Table Storage operations by maintaining game data in memory using a thread-safe + /// Dictionary structure. Each game is stored with its unique game ID and contains all player scores. + /// + /// The storage account connection string. + /// The name of the table to store the scoreboard entries in. + /// A task that represents the asynchronous operation of creating and storing player scores. + private async Task CreateScoreboardEntriesAsync(string? connectionString, string? tableName) + { + try + { + var random = new Random(); + var playerScores = new List(); + var tableClient = new TableClient(connectionString, tableName); + await tableClient.CreateIfNotExistsAsync(); + var partitionKey = _gameId.ToString(); + + if (_playerNames == null || _playerNames.Length == 0) + { + _logger.LogWarning("[CreateScoreboardEntriesAsync] No player names configured. Skipping scoreboard entry creation."); + return; + } + + foreach (var name in _playerNames) + { + var score = Math.Max(0, random.Next(0, 101)); // Random number between 0 and 100, ensure >= 0 + var playerScore = new PlayerScore + { + Name = name, + Score = score + }; + + playerScores.Add(playerScore); + + var entity = new ScoreboardEntity + { + PartitionKey = partitionKey, + RowKey = Guid.NewGuid().ToString(), + GameId = _gameId, + PlayerName = name, + Score = score, + Timestamp = DateTimeOffset.UtcNow, + ETag = ETag.All + }; + + await tableClient.AddEntityAsync(entity); + + _logger.LogInformation("[CreateScoreboardEntriesAsync] Added scoreboard entry for {playerName} with score {score} in game {gameId}", name, score, _gameId); + } + + // Store the game data in the internal dictionary (thread-safe) + lock (_gameDataLock) + { + _gameData[_gameId] = playerScores; + } + + _logger.LogInformation("[CreateScoreboardEntriesAsync] Created {playerCount} scoreboard entries for game {gameId}.", _playerNames.Length, _gameId); + + // Simulate async work to maintain the async signature + await Task.CompletedTask; + } + catch (Exception ex) + { + _logger.LogError(ex, "[CreateScoreboardEntriesAsync] Failed to create scoreboard entries for game {gameId}", _gameId); + } + } } \ No newline at end of file diff --git a/samples/function-app-storage-http/dotnet/src/sample/Program.cs b/samples/function-app-storage-http/dotnet/src/sample/Program.cs index c76837e..51336f3 100644 --- a/samples/function-app-storage-http/dotnet/src/sample/Program.cs +++ b/samples/function-app-storage-http/dotnet/src/sample/Program.cs @@ -1,7 +1,7 @@ -using Microsoft.Extensions.Hosting; - -var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .Build(); - -host.Run(); +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + +host.Run(); diff --git a/samples/function-app-storage-http/dotnet/src/sample/sample.csproj b/samples/function-app-storage-http/dotnet/src/sample/sample.csproj index d7c6345..97195a5 100644 --- a/samples/function-app-storage-http/dotnet/src/sample/sample.csproj +++ b/samples/function-app-storage-http/dotnet/src/sample/sample.csproj @@ -1,33 +1,33 @@ - - - net10.0 - v4 - Exe - enable - enable - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - Never - - - - - + + + net10.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + \ No newline at end of file diff --git a/samples/web-app-sql-database/dotnet/README.md b/samples/web-app-sql-database/dotnet/README.md index 399940b..04b7548 100644 --- a/samples/web-app-sql-database/dotnet/README.md +++ b/samples/web-app-sql-database/dotnet/README.md @@ -1,6 +1,6 @@ # Azure Web App with Azure SQL Database and Azure Key Vault -This sample demonstrates a ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in an `activities` table within the `sampledb` database on an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/) instance. The connection string of the SQL database is stored as a secret in [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview). The application also retrieves its certificate from Key Vault to serve traffic over HTTPS. +This sample demonstrates a ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in an `activities` table within the `sampledb` database on an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/) instance. The connection string of the SQL database is stored as a secret in [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview). The application also retrieves its certificate from Key Vault to serve traffic over HTTPS. The SQL server encrypts its databases at rest with [Transparent Data Encryption (TDE)](https://learn.microsoft.com/en-us/azure/azure-sql/database/transparent-data-encryption-byok-overview) protected by a customer-managed key: an RSA key in Key Vault that the server reaches through a user-assigned managed identity. ## Architecture @@ -11,8 +11,9 @@ The following diagram illustrates the architecture of the solution: - **Azure Web App**: Hosts the ASP.NET Core application - **Azure App Service Plan**: Provides compute resources for the web app -- **Azure SQL Database**: Stores activity data in a relational table -- **Azure Key Vault**: Stores the database connection string and the certificate used to secure HTTPS traffic +- **Azure SQL Database**: Stores activity data in a relational table, encrypted at rest with TDE +- **Azure Key Vault**: Stores the database connection string, the certificate used to secure HTTPS traffic, and the RSA key that serves as the TDE protector of the SQL server +- **User-Assigned Managed Identity**: The identity the SQL server uses to wrap and unwrap its database encryption keys with the Key Vault key ## Prerequisites @@ -44,12 +45,16 @@ The Vacation Planner Web App supports two common approaches for accessing Azure This flexibility allows the app to run securely in Azure or in emulated environments like [LocalStack for Azure](https://docs.localstack.cloud/azure/). The client code supports both authentication modes using [`ClientSecretCredential`](https://learn.microsoft.com/en-us/dotnet/api/azure.identity.clientsecretcredential) or [`DefaultAzureCredential`](https://learn.microsoft.com/en-us/dotnet/api/azure.identity.defaultazurecredential) from the Azure SDK. ## Azure Key Vault Integration -The application integrates with Azure Key Vault for managing secrets and certificates: +The application integrates with Azure Key Vault for managing secrets and certificates, and the SQL server uses a Key Vault key to protect its data at rest: Secrets: The SQL connection string is stored as a secret in Key Vault. At runtime, the app retrieves it using the Azure Key Vault Secrets SDK. This is configured via the KEY_VAULT_NAME and SECRET_NAME environment variables. Certificates: A self-signed certificate is created in Key Vault during deployment. The app exposes a GET /api/certificate endpoint that retrieves the certificate using the Azure Key Vault Certificates SDK and returns its name, confirming the integration works. This is configured via the KEYVAULT_URI and CERT_NAME environment variables. +Keys: An RSA key in Key Vault is the TDE protector of the SQL server, the customer-managed key that encrypts the database encryption key of every database on the server. The server reaches the key through its user-assigned managed identity, which holds the `get`, `wrapKey` and `unwrapKey` key permissions, and picks up new versions of the key automatically (auto-rotation). Azure requires soft delete and purge protection on the vault. With purge protection, a deleted vault cannot be purged: after the resource group is deleted, the vault stays soft-deleted for the 7-day retention period, and its name cannot be reused anywhere until then. To redeploy to Azure within that window, change `PREFIX` or `SUFFIX` in the deployment script you use and in `scripts/validate.sh` and `scripts/call-web-app.sh`. + +On LocalStack, the emulator registers the key on the server and checks that the key exists, but it does not encrypt the database with it. The Azure CLI variant registers the key and the protector with `az resource create`, because `az sql server key create` and `az sql server tde-key set` only accept key ids on the public Key Vault domains and reject the ones the emulator issues. + ## Deployment Set up the Azure emulator using the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and set it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure). To pull the image, execute: diff --git a/samples/web-app-sql-database/dotnet/bicep/README.md b/samples/web-app-sql-database/dotnet/bicep/README.md index 83500c1..98a764c 100644 --- a/samples/web-app-sql-database/dotnet/bicep/README.md +++ b/samples/web-app-sql-database/dotnet/bicep/README.md @@ -40,7 +40,8 @@ The [deploy.sh](deploy.sh) script creates the [Azure Resource Group](https://lea 3. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The compute resource that hosts the web application. 4. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the ASP.NET Core Razor Pages single-page application (*Vacation Planner*), connected to Azure SQL Database. 5. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Configures automatic deployment from a public GitHub repository. -6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret. +6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret and the RSA key that serves as the [TDE protector](https://learn.microsoft.com/en-us/azure/azure-sql/database/transparent-data-encryption-byok-overview) of the SQL server, registered by the [transparent-data-encryption.bicep](modules/transparent-data-encryption.bicep) module. +7. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): The identity the SQL server uses to reach its TDE protector key in Key Vault. The web app allows users to plan and manage vacation activities, storing all activity data in the `Activities` table in the `PlannerDB` database. For more information, see [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). @@ -100,6 +101,7 @@ SQL_DATABASE_NAME='PlannerDB' WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" SECRET_NAME="${PREFIX}-secret-${SUFFIX}" +TDE_KEY_NAME="${PREFIX}-tde-key-${SUFFIX}" # Check resource group echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" @@ -130,6 +132,44 @@ az sql db show \ --resource-group "$RESOURCE_GROUP_NAME" \ --output table +# Check that the Key Vault key is the TDE protector of the Azure SQL Server +echo -e "\n[$SQL_SERVER_NAME] SQL server TDE protector:\n" +# Read the key through Azure Resource Manager: the Bicep variant grants the caller no Key Vault data-plane access. +KEY_VAULT_ID=$(az keyvault show \ +--name "$KEY_VAULT_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "id" \ +--output tsv) +TDE_KEY_ID=$(az resource show \ +--ids "$KEY_VAULT_ID/keys/$TDE_KEY_NAME" \ +--api-version 2024-11-01 \ +--query "properties.keyUriWithVersion" \ +--output tsv) +TDE_PROTECTOR=$(az sql server tde-key show \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output json) +echo "$TDE_PROTECTOR" | jq '{serverKeyType, uri, autoRotationEnabled}' +if [[ "$(jq -r .serverKeyType <<< "$TDE_PROTECTOR")" != "AzureKeyVault" || + "$(jq -r .uri <<< "$TDE_PROTECTOR")" != "$TDE_KEY_ID" || + "$(jq -r .autoRotationEnabled <<< "$TDE_PROTECTOR")" != "true" ]]; then + echo "The TDE protector of [$SQL_SERVER_NAME] is not the auto-rotated Key Vault key [$TDE_KEY_ID]" + exit 1 +fi + +# Check that TDE is enabled on the Azure SQL Database +TDE_STATE=$(az sql db tde show \ +--database "$SQL_DATABASE_NAME" \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "state" \ +--output tsv) +echo -e "\n[$SQL_DATABASE_NAME] SQL database TDE state: [$TDE_STATE]" +if [[ "$TDE_STATE" != "Enabled" ]]; then + echo "TDE is not enabled on [$SQL_DATABASE_NAME]" + exit 1 +fi + # Check Azure Key Vault echo -e "\n[$KEY_VAULT_NAME] Key Vault:\n" az keyvault show \ diff --git a/samples/web-app-sql-database/dotnet/bicep/main.bicep b/samples/web-app-sql-database/dotnet/bicep/main.bicep index e5bec57..31075ed 100644 --- a/samples/web-app-sql-database/dotnet/bicep/main.bicep +++ b/samples/web-app-sql-database/dotnet/bicep/main.bicep @@ -154,9 +154,6 @@ param administratorLoginPassword string = 'P@ssw0rd1234!' @description('Conditional. The Azure Active Directory (AAD) administrator authentication. Required if no `administratorLogin` & `administratorLoginPassword` is provided.') param administrators object? -@description('Specifies the conditional Developmentresource ID of a user-assigned identityDevelopment to be used by default. This is required if `userAssignedIdentities` is not empty.') -param primaryUserAssignedIdentityResourceId string? - @allowed([ '1.0' '1.1' @@ -325,15 +322,25 @@ var webAppName = '${prefix}-webapp-${suffix}' var appServicePlanName = '${prefix}-app-service-plan-${suffix}' var keyVaultName = '${prefix}-kv-${suffix}' var sqlConnectionStringSecretName = '${prefix}-secret-${suffix}' -var identity = { - type: 'SystemAssigned' - } +var sqlServerIdentityName = '${prefix}-tde-identity-${suffix}' +var tdeKeyName = '${prefix}-tde-key-${suffix}' + +resource sqlServerIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: sqlServerIdentityName + location: location + tags: tags +} resource sqlServer 'Microsoft.Sql/servers@2024-05-01-preview' = { name: sqlServerName location: location tags: tags - identity: identity + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sqlServerIdentity.id}': {} + } + } properties: { administratorLogin: administratorLogin administratorLoginPassword: administratorLoginPassword @@ -342,7 +349,7 @@ resource sqlServer 'Microsoft.Sql/servers@2024-05-01-preview' = { isIPv6Enabled: isIPv6Enabled version: version minimalTlsVersion: minimalTlsVersion - primaryUserAssignedIdentityId: primaryUserAssignedIdentityResourceId + primaryUserAssignedIdentityId: sqlServerIdentity.id publicNetworkAccess: publicNetworkAccess restrictOutboundNetworkAccess: restrictOutboundNetworkAccess } @@ -453,10 +460,45 @@ resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { ] } } + { + tenantId: subscription().tenantId + objectId: sqlServerIdentity.properties.principalId + permissions: { + keys: [ + 'get' + 'wrapKey' + 'unwrapKey' + ] + } + } ] enableRbacAuthorization: false enableSoftDelete: true softDeleteRetentionInDays: 7 + enablePurgeProtection: true + } +} + +resource tdeKey 'Microsoft.KeyVault/vaults/keys@2024-11-01' = { + parent: keyVault + name: tdeKeyName + properties: { + kty: 'RSA' + keySize: 2048 + keyOps: [ + 'wrapKey' + 'unwrapKey' + ] + } +} + +module transparentDataEncryption 'modules/transparent-data-encryption.bicep' = { + name: 'transparentDataEncryption' + params: { + sqlServerName: sqlServer.name + keyVaultName: keyVault.name + keyName: tdeKey.name + keyUri: tdeKey.properties.keyUriWithVersion } } diff --git a/samples/web-app-sql-database/dotnet/bicep/modules/transparent-data-encryption.bicep b/samples/web-app-sql-database/dotnet/bicep/modules/transparent-data-encryption.bicep new file mode 100644 index 0000000..95d4a10 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/bicep/modules/transparent-data-encryption.bicep @@ -0,0 +1,35 @@ +@description('Specifies the name of the SQL logical server.') +param sqlServerName string + +@description('Specifies the name of the Key Vault that holds the TDE protector key.') +param keyVaultName string + +@description('Specifies the name of the Key Vault key used as the TDE protector.') +param keyName string + +@description('Specifies the versioned URI of the Key Vault key used as the TDE protector.') +param keyUri string + +resource sqlServer 'Microsoft.Sql/servers@2024-05-01-preview' existing = { + name: sqlServerName +} + +// A server key must be named after the vault, key and key version it points to. +resource serverKey 'Microsoft.Sql/servers/keys@2023-08-01' = { + parent: sqlServer + name: '${keyVaultName}_${keyName}_${last(split(keyUri, '/'))}' + properties: { + serverKeyType: 'AzureKeyVault' + uri: keyUri + } +} + +resource encryptionProtector 'Microsoft.Sql/servers/encryptionProtector@2023-08-01' = { + parent: sqlServer + name: 'current' + properties: { + serverKeyType: 'AzureKeyVault' + serverKeyName: serverKey.name + autoRotationEnabled: true + } +} diff --git a/samples/web-app-sql-database/dotnet/scripts/README.md b/samples/web-app-sql-database/dotnet/scripts/README.md index 4891865..c9abdda 100644 --- a/samples/web-app-sql-database/dotnet/scripts/README.md +++ b/samples/web-app-sql-database/dotnet/scripts/README.md @@ -40,7 +40,8 @@ The [deploy.sh](deploy.sh) Bash script creates the following Azure resources usi 4. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The compute resource that hosts the web application. 5. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the ASP.NET Core Razor Pages single-page application (*Vacation Planner*), connected to Azure SQL Database. 6. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Configures automatic deployment from a public GitHub repository. -7. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret. +7. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret and the RSA key that serves as the [TDE protector](https://learn.microsoft.com/en-us/azure/azure-sql/database/transparent-data-encryption-byok-overview) of the SQL server. +8. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): The identity the SQL server uses to reach its TDE protector key in Key Vault. The system implements a Vacation Planner web application that stores and retrieves activity data from Azure SQL Database. For more information, see [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). @@ -100,6 +101,7 @@ SQL_DATABASE_NAME='PlannerDB' WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" SECRET_NAME="${PREFIX}-secret-${SUFFIX}" +TDE_KEY_NAME="${PREFIX}-tde-key-${SUFFIX}" # Check resource group echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" @@ -130,6 +132,44 @@ az sql db show \ --resource-group "$RESOURCE_GROUP_NAME" \ --output table +# Check that the Key Vault key is the TDE protector of the Azure SQL Server +echo -e "\n[$SQL_SERVER_NAME] SQL server TDE protector:\n" +# Read the key through Azure Resource Manager: the Bicep variant grants the caller no Key Vault data-plane access. +KEY_VAULT_ID=$(az keyvault show \ +--name "$KEY_VAULT_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "id" \ +--output tsv) +TDE_KEY_ID=$(az resource show \ +--ids "$KEY_VAULT_ID/keys/$TDE_KEY_NAME" \ +--api-version 2024-11-01 \ +--query "properties.keyUriWithVersion" \ +--output tsv) +TDE_PROTECTOR=$(az sql server tde-key show \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output json) +echo "$TDE_PROTECTOR" | jq '{serverKeyType, uri, autoRotationEnabled}' +if [[ "$(jq -r .serverKeyType <<< "$TDE_PROTECTOR")" != "AzureKeyVault" || + "$(jq -r .uri <<< "$TDE_PROTECTOR")" != "$TDE_KEY_ID" || + "$(jq -r .autoRotationEnabled <<< "$TDE_PROTECTOR")" != "true" ]]; then + echo "The TDE protector of [$SQL_SERVER_NAME] is not the auto-rotated Key Vault key [$TDE_KEY_ID]" + exit 1 +fi + +# Check that TDE is enabled on the Azure SQL Database +TDE_STATE=$(az sql db tde show \ +--database "$SQL_DATABASE_NAME" \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "state" \ +--output tsv) +echo -e "\n[$SQL_DATABASE_NAME] SQL database TDE state: [$TDE_STATE]" +if [[ "$TDE_STATE" != "Enabled" ]]; then + echo "TDE is not enabled on [$SQL_DATABASE_NAME]" + exit 1 +fi + # Check Azure Key Vault echo -e "\n[$KEY_VAULT_NAME] Key Vault:\n" az keyvault show \ diff --git a/samples/web-app-sql-database/dotnet/scripts/deploy.sh b/samples/web-app-sql-database/dotnet/scripts/deploy.sh index eee4ed8..48ffaa8 100755 --- a/samples/web-app-sql-database/dotnet/scripts/deploy.sh +++ b/samples/web-app-sql-database/dotnet/scripts/deploy.sh @@ -6,6 +6,7 @@ SUFFIX='test' LOCATION='westeurope' RESOURCE_GROUP_NAME="${PREFIX}-rg" SQL_SERVER_NAME="${PREFIX}-sqlserver-${SUFFIX}" +SQL_SERVER_IDENTITY_NAME="${PREFIX}-tde-identity-${SUFFIX}" FIREWALL_RULE_NAME="AllowAllIPs" ADMIN_USER='sqladmin' ADMIN_PASSWORD='P@ssw0rd1234!' @@ -24,6 +25,7 @@ DEPLOY_APP=1 KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" SECRET_NAME="${PREFIX}-secret-${SUFFIX}" CERT_NAME="${PREFIX}-cert-${SUFFIX}" +TDE_KEY_NAME="${PREFIX}-tde-key-${SUFFIX}" # Change the current directory to the script's directory cd "$CURRENT_DIR" || exit @@ -41,6 +43,38 @@ else exit 1 fi +# Create the user-assigned managed identity the SQL server uses to reach the TDE protector key +echo "Creating user-assigned managed identity [$SQL_SERVER_IDENTITY_NAME]..." +az identity create \ + --name "$SQL_SERVER_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "User-assigned managed identity [$SQL_SERVER_IDENTITY_NAME] created successfully." +else + echo "Failed to create user-assigned managed identity [$SQL_SERVER_IDENTITY_NAME]." + exit 1 +fi + +SQL_SERVER_IDENTITY_ID=$(az identity show \ + --name "$SQL_SERVER_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "id" \ + --output tsv) + +SQL_SERVER_IDENTITY_PRINCIPAL_ID=$(az identity show \ + --name "$SQL_SERVER_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "principalId" \ + --output tsv) + +if [[ -z "$SQL_SERVER_IDENTITY_ID" || -z "$SQL_SERVER_IDENTITY_PRINCIPAL_ID" ]]; then + echo "Failed to retrieve the resource ID or principalId of [$SQL_SERVER_IDENTITY_NAME]" + exit 1 +fi + # Create a sql server echo "Checking if [$SQL_SERVER_NAME] sql server exists in the [$RESOURCE_GROUP_NAME] resource group..." az sql server show \ @@ -60,7 +94,9 @@ else --admin-user $ADMIN_USER \ --admin-password $ADMIN_PASSWORD \ --assign-identity \ - --identity-type SystemAssigned \ + --identity-type UserAssigned \ + --user-assigned-identity-id "$SQL_SERVER_IDENTITY_ID" \ + --primary-user-assigned-identity-id "$SQL_SERVER_IDENTITY_ID" \ --minimal-tls-version 1.2 \ --tags environment=test \ --only-show-errors 1>/dev/null @@ -334,6 +370,8 @@ az keyvault create \ --resource-group "$RESOURCE_GROUP_NAME" \ --location "$LOCATION" \ --enable-rbac-authorization false \ + --enable-purge-protection true \ + --retention-days 7 \ --only-show-errors 1>/dev/null if [ $? -eq 0 ]; then @@ -397,6 +435,78 @@ else exit 1 fi +# Assign access policy to the SQL server managed identity +echo "Assigning Key Vault access policy to the SQL server identity [$SQL_SERVER_IDENTITY_NAME]..." +az keyvault set-policy \ + --name "$KEY_VAULT_NAME" \ + --object-id "$SQL_SERVER_IDENTITY_PRINCIPAL_ID" \ + --key-permissions get wrapKey unwrapKey \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Key Vault access policy for [$SQL_SERVER_IDENTITY_NAME] assigned successfully." +else + echo "Failed to assign Key Vault access policy for [$SQL_SERVER_IDENTITY_NAME]." + exit 1 +fi + +# Create the RSA key that protects the database encryption keys of the SQL server +echo "Creating key [$TDE_KEY_NAME] in Key Vault [$KEY_VAULT_NAME]..." +TDE_KEY_ID=$(az keyvault key create \ + --vault-name "$KEY_VAULT_NAME" \ + --name "$TDE_KEY_NAME" \ + --kty RSA \ + --size 2048 \ + --ops wrapKey unwrapKey \ + --query "key.kid" \ + --output tsv \ + --only-show-errors) + +if [ -n "$TDE_KEY_ID" ]; then + echo "Key [$TDE_KEY_ID] created successfully." +else + echo "Failed to create key [$TDE_KEY_NAME] in Key Vault [$KEY_VAULT_NAME]." + exit 1 +fi + +# Register the key on the SQL server and make it the TDE protector. The generic az resource create +# is used because az sql server key create and az sql server tde-key set only accept key ids on the +# public Key Vault domains, which rejects the key ids the LocalStack emulator issues. +SQL_SERVER_ID=$(az sql server show \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "id" \ + --output tsv) +SERVER_KEY_NAME="${KEY_VAULT_NAME}_${TDE_KEY_NAME}_${TDE_KEY_ID##*/}" + +echo "Adding key [$TDE_KEY_NAME] to the [$SQL_SERVER_NAME] sql server..." +az resource create \ + --id "$SQL_SERVER_ID/keys/$SERVER_KEY_NAME" \ + --api-version 2023-08-01 \ + --properties "{\"serverKeyType\": \"AzureKeyVault\", \"uri\": \"$TDE_KEY_ID\"}" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Key [$TDE_KEY_NAME] added successfully to the [$SQL_SERVER_NAME] sql server." +else + echo "Failed to add key [$TDE_KEY_NAME] to the [$SQL_SERVER_NAME] sql server." + exit 1 +fi + +echo "Setting key [$TDE_KEY_NAME] as the TDE protector of the [$SQL_SERVER_NAME] sql server..." +az resource create \ + --id "$SQL_SERVER_ID/encryptionProtector/current" \ + --api-version 2023-08-01 \ + --properties "{\"serverKeyType\": \"AzureKeyVault\", \"serverKeyName\": \"$SERVER_KEY_NAME\", \"autoRotationEnabled\": true}" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Key [$TDE_KEY_NAME] set successfully as the TDE protector of the [$SQL_SERVER_NAME] sql server." +else + echo "Failed to set key [$TDE_KEY_NAME] as the TDE protector of the [$SQL_SERVER_NAME] sql server." + exit 1 +fi + # Get Key Vault URI echo "Retrieving Key Vault URI..." KEYVAULT_URI=$(az keyvault show \ diff --git a/samples/web-app-sql-database/dotnet/scripts/validate.sh b/samples/web-app-sql-database/dotnet/scripts/validate.sh index 19d5997..c6ca9bf 100755 --- a/samples/web-app-sql-database/dotnet/scripts/validate.sh +++ b/samples/web-app-sql-database/dotnet/scripts/validate.sh @@ -9,6 +9,7 @@ SQL_DATABASE_NAME='PlannerDB' WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" SECRET_NAME="${PREFIX}-secret-${SUFFIX}" +TDE_KEY_NAME="${PREFIX}-tde-key-${SUFFIX}" # Check resource group echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" @@ -39,6 +40,44 @@ az sql db show \ --resource-group "$RESOURCE_GROUP_NAME" \ --output table +# Check that the Key Vault key is the TDE protector of the Azure SQL Server +echo -e "\n[$SQL_SERVER_NAME] SQL server TDE protector:\n" +# Read the key through Azure Resource Manager: the Bicep variant grants the caller no Key Vault data-plane access. +KEY_VAULT_ID=$(az keyvault show \ +--name "$KEY_VAULT_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "id" \ +--output tsv) +TDE_KEY_ID=$(az resource show \ +--ids "$KEY_VAULT_ID/keys/$TDE_KEY_NAME" \ +--api-version 2024-11-01 \ +--query "properties.keyUriWithVersion" \ +--output tsv) +TDE_PROTECTOR=$(az sql server tde-key show \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output json) +echo "$TDE_PROTECTOR" | jq '{serverKeyType, uri, autoRotationEnabled}' +if [[ "$(jq -r .serverKeyType <<< "$TDE_PROTECTOR")" != "AzureKeyVault" || + "$(jq -r .uri <<< "$TDE_PROTECTOR")" != "$TDE_KEY_ID" || + "$(jq -r .autoRotationEnabled <<< "$TDE_PROTECTOR")" != "true" ]]; then + echo "The TDE protector of [$SQL_SERVER_NAME] is not the auto-rotated Key Vault key [$TDE_KEY_ID]" + exit 1 +fi + +# Check that TDE is enabled on the Azure SQL Database +TDE_STATE=$(az sql db tde show \ +--database "$SQL_DATABASE_NAME" \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "state" \ +--output tsv) +echo -e "\n[$SQL_DATABASE_NAME] SQL database TDE state: [$TDE_STATE]" +if [[ "$TDE_STATE" != "Enabled" ]]; then + echo "TDE is not enabled on [$SQL_DATABASE_NAME]" + exit 1 +fi + # Check Azure Key Vault echo -e "\n[$KEY_VAULT_NAME] Key Vault:\n" az keyvault show \ diff --git a/samples/web-app-sql-database/dotnet/terraform/README.md b/samples/web-app-sql-database/dotnet/terraform/README.md index fac861f..4a776a0 100644 --- a/samples/web-app-sql-database/dotnet/terraform/README.md +++ b/samples/web-app-sql-database/dotnet/terraform/README.md @@ -40,8 +40,9 @@ The [main.tf](main.tf) Terraform module creates the following Azure resources: 3. [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/): The `PlannerDB` database storing relational vacation activity data. 4. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The compute resource that hosts the web application. 5. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the ASP.NET Core Razor Pages single-page application (*Vacation Planner*), connected to Azure SQL Database. -6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string as a secret and a self-signed certificate for HTTPS. +6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string as a secret, a self-signed certificate for HTTPS, and the RSA key that serves as the [TDE protector](https://learn.microsoft.com/en-us/azure/azure-sql/database/transparent-data-encryption-byok-overview) of the SQL server. 7. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Configures automatic deployment from a public GitHub repository. +8. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): The identity the SQL server uses to reach its TDE protector key in Key Vault. The system implements a Vacation Planner web application that stores and retrieves activity data from Azure SQL Database. For more information, see [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). @@ -124,6 +125,7 @@ SQL_DATABASE_NAME='PlannerDB' WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" SECRET_NAME="${PREFIX}-secret-${SUFFIX}" +TDE_KEY_NAME="${PREFIX}-tde-key-${SUFFIX}" # Check resource group echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" @@ -154,6 +156,44 @@ az sql db show \ --resource-group "$RESOURCE_GROUP_NAME" \ --output table +# Check that the Key Vault key is the TDE protector of the Azure SQL Server +echo -e "\n[$SQL_SERVER_NAME] SQL server TDE protector:\n" +# Read the key through Azure Resource Manager: the Bicep variant grants the caller no Key Vault data-plane access. +KEY_VAULT_ID=$(az keyvault show \ +--name "$KEY_VAULT_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "id" \ +--output tsv) +TDE_KEY_ID=$(az resource show \ +--ids "$KEY_VAULT_ID/keys/$TDE_KEY_NAME" \ +--api-version 2024-11-01 \ +--query "properties.keyUriWithVersion" \ +--output tsv) +TDE_PROTECTOR=$(az sql server tde-key show \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output json) +echo "$TDE_PROTECTOR" | jq '{serverKeyType, uri, autoRotationEnabled}' +if [[ "$(jq -r .serverKeyType <<< "$TDE_PROTECTOR")" != "AzureKeyVault" || + "$(jq -r .uri <<< "$TDE_PROTECTOR")" != "$TDE_KEY_ID" || + "$(jq -r .autoRotationEnabled <<< "$TDE_PROTECTOR")" != "true" ]]; then + echo "The TDE protector of [$SQL_SERVER_NAME] is not the auto-rotated Key Vault key [$TDE_KEY_ID]" + exit 1 +fi + +# Check that TDE is enabled on the Azure SQL Database +TDE_STATE=$(az sql db tde show \ +--database "$SQL_DATABASE_NAME" \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "state" \ +--output tsv) +echo -e "\n[$SQL_DATABASE_NAME] SQL database TDE state: [$TDE_STATE]" +if [[ "$TDE_STATE" != "Enabled" ]]; then + echo "TDE is not enabled on [$SQL_DATABASE_NAME]" + exit 1 +fi + # Check Azure Key Vault echo -e "\n[$KEY_VAULT_NAME] Key Vault:\n" az keyvault show \ diff --git a/samples/web-app-sql-database/dotnet/terraform/main.tf b/samples/web-app-sql-database/dotnet/terraform/main.tf index 6353bc9..089a413 100644 --- a/samples/web-app-sql-database/dotnet/terraform/main.tf +++ b/samples/web-app-sql-database/dotnet/terraform/main.tf @@ -6,6 +6,8 @@ locals { app_service_plan_name = "${var.prefix}-app-service-plan-${var.suffix}" web_app_name = "${var.prefix}-webapp-${var.suffix}" key_vault_name = "${var.prefix}-kv-${var.suffix}" + sql_identity_name = "${var.prefix}-tde-identity-${var.suffix}" + tde_key_name = "${var.prefix}-tde-key-${var.suffix}" } # Retrieve the current Azure client configuration @@ -18,6 +20,20 @@ resource "azurerm_resource_group" "example" { tags = var.tags } +# Create the user-assigned managed identity the SQL server uses to reach the TDE protector key +resource "azurerm_user_assigned_identity" "sql_server" { + name = local.sql_identity_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + # Create a SQL server resource "azurerm_mssql_server" "example" { name = local.sql_server_name @@ -29,11 +45,19 @@ resource "azurerm_mssql_server" "example" { public_network_access_enabled = var.public_network_access_enabled outbound_network_restriction_enabled = var.outbound_network_restriction_enabled version = var.sql_version + primary_user_assigned_identity_id = azurerm_user_assigned_identity.sql_server.id tags = var.tags + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.sql_server.id] + } + + # The TDE protector is managed by azurerm_mssql_server_transparent_data_encryption below lifecycle { ignore_changes = [ - tags + tags, + transparent_data_encryption_key_vault_key_id ] } } @@ -138,6 +162,7 @@ resource "azurerm_key_vault" "example" { sku_name = "standard" rbac_authorization_enabled = false soft_delete_retention_days = 7 + purge_protection_enabled = true tags = var.tags lifecycle { @@ -163,11 +188,72 @@ resource "azurerm_key_vault_access_policy" "web_app" { ] } +# Grant the identity running Terraform access to manage the Key Vault key, secret and certificate +resource "azurerm_key_vault_access_policy" "deployer" { + key_vault_id = azurerm_key_vault.example.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + key_permissions = [ + "Create", + "Delete", + "Get", + "GetRotationPolicy", + ] + + secret_permissions = [ + "Delete", + "Get", + "Set", + ] + + certificate_permissions = [ + "Create", + "Delete", + "Get", + ] +} + +# Grant the SQL server managed identity access to the TDE protector key +resource "azurerm_key_vault_access_policy" "sql_server" { + key_vault_id = azurerm_key_vault.example.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = azurerm_user_assigned_identity.sql_server.principal_id + + key_permissions = [ + "Get", + "UnwrapKey", + "WrapKey", + ] +} + +# Create the RSA key that protects the database encryption keys of the SQL server +resource "azurerm_key_vault_key" "tde" { + name = local.tde_key_name + key_vault_id = azurerm_key_vault.example.id + key_type = "RSA" + key_size = 2048 + key_opts = ["unwrapKey", "wrapKey"] + + depends_on = [azurerm_key_vault_access_policy.deployer] +} + +# Make the Key Vault key the TDE protector of the SQL server +resource "azurerm_mssql_server_transparent_data_encryption" "example" { + server_id = azurerm_mssql_server.example.id + key_vault_key_id = azurerm_key_vault_key.tde.id + auto_rotation_enabled = true + + depends_on = [azurerm_key_vault_access_policy.sql_server] +} + # Create a Key Vault secret for SQL connection string resource "azurerm_key_vault_secret" "sql_connection_string" { name = var.secret_name value = "Server=tcp:${azurerm_mssql_server.example.fully_qualified_domain_name},1433;Database=${azurerm_mssql_database.example.name};User ID=${var.sql_database_username};Password=${var.sql_database_password};Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;" key_vault_id = azurerm_key_vault.example.id + + depends_on = [azurerm_key_vault_access_policy.deployer] } # Create a self-signed certificate in Key Vault @@ -201,4 +287,6 @@ resource "azurerm_key_vault_certificate" "example" { ] } } + + depends_on = [azurerm_key_vault_access_policy.deployer] } diff --git a/samples/web-app-sql-database/python/README.md b/samples/web-app-sql-database/python/README.md index 66c1eac..9f090e9 100644 --- a/samples/web-app-sql-database/python/README.md +++ b/samples/web-app-sql-database/python/README.md @@ -1,6 +1,6 @@ # Azure Web App with Azure SQL Database and Azure Key Vault -This sample demonstrates a Python Flask single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in an `activities` table within the `sampledb` database on an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/) instance. The connection string of the SQL database is stored as a secret in [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview). The application also retrieves its certificate from Key Vault to serve traffic over HTTPS. +This sample demonstrates a Python Flask single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in an `activities` table within the `sampledb` database on an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/) instance. The connection string of the SQL database is stored as a secret in [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview). The application also retrieves its certificate from Key Vault to serve traffic over HTTPS. The SQL server encrypts its databases at rest with [Transparent Data Encryption (TDE)](https://learn.microsoft.com/en-us/azure/azure-sql/database/transparent-data-encryption-byok-overview) protected by a customer-managed key: an RSA key in Key Vault that the server reaches through a user-assigned managed identity. ## Architecture @@ -11,8 +11,9 @@ The following diagram illustrates the architecture of the solution: - **Azure Web App**: Hosts the Python Flask application - **Azure App Service Plan**: Provides compute resources for the web app -- **Azure SQL Database**: Stores activity data in a relational table -- **Azure Key Vault**: Stores the database connection string and the certificate used to secure HTTPS traffic +- **Azure SQL Database**: Stores activity data in a relational table, encrypted at rest with TDE +- **Azure Key Vault**: Stores the database connection string, the certificate used to secure HTTPS traffic, and the RSA key that serves as the TDE protector of the SQL server +- **User-Assigned Managed Identity**: The identity the SQL server uses to wrap and unwrap its database encryption keys with the Key Vault key ## Prerequisites @@ -44,12 +45,16 @@ The Vacation Planner Web App supports two common approaches for accessing Azure This flexibility allows the app to run securely in Azure or in emulated environments like [LocalStack for Azure](https://docs.localstack.cloud/azure/). The client code supports both authentication modes using [`ClientSecretCredential`](https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.clientsecretcredential?view=azure-python) or [`DefaultAzureCredential`](https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.defaultazurecredential?view=azure-python) from the Azure SDK. ## Azure Key Vault Integration -The application integrates with Azure Key Vault for managing secrets and certificates: +The application integrates with Azure Key Vault for managing secrets and certificates, and the SQL server uses a Key Vault key to protect its data at rest: Secrets: The SQL connection string is stored as a secret in Key Vault. At runtime, the app retrieves it using the Azure Key Vault Secrets SDK. This is configured via the KEY_VAULT_NAME and SECRET_NAME environment variables. Certificates: A self-signed certificate is created in Key Vault during deployment. The app exposes a GET /api/certificate endpoint that retrieves the certificate using the Azure Key Vault Certificates SDK and returns its name, confirming the integration works. This is configured via the KEYVAULT_URI and CERT_NAME environment variables. +Keys: An RSA key in Key Vault is the TDE protector of the SQL server, the customer-managed key that encrypts the database encryption key of every database on the server. The server reaches the key through its user-assigned managed identity, which holds the `get`, `wrapKey` and `unwrapKey` key permissions, and picks up new versions of the key automatically (auto-rotation). Azure requires soft delete and purge protection on the vault. With purge protection, a deleted vault cannot be purged: after the resource group is deleted, the vault stays soft-deleted for the 7-day retention period, and its name cannot be reused anywhere until then. To redeploy to Azure within that window, change `PREFIX` or `SUFFIX` in the deployment script you use and in `scripts/validate.sh` and `scripts/call-web-app.sh`. + +On LocalStack, the emulator registers the key on the server and checks that the key exists, but it does not encrypt the database with it. The Azure CLI variant registers the key and the protector with `az resource create`, because `az sql server key create` and `az sql server tde-key set` only accept key ids on the public Key Vault domains and reject the ones the emulator issues. + ## Deployment Set up the Azure emulator using the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and set it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure). To pull the image, execute: diff --git a/samples/web-app-sql-database/python/bicep/README.md b/samples/web-app-sql-database/python/bicep/README.md index 90a73e4..fd3ca0c 100644 --- a/samples/web-app-sql-database/python/bicep/README.md +++ b/samples/web-app-sql-database/python/bicep/README.md @@ -40,7 +40,8 @@ The [deploy.sh](deploy.sh) script creates the [Azure Resource Group](https://lea 3. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The compute resource that hosts the web application. 4. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the Python Flask single-page application (*Vacation Planner*), connected to Azure SQL Database. 5. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Configures automatic deployment from a public GitHub repository. -6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret. +6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret and the RSA key that serves as the [TDE protector](https://learn.microsoft.com/en-us/azure/azure-sql/database/transparent-data-encryption-byok-overview) of the SQL server, registered by the [transparent-data-encryption.bicep](modules/transparent-data-encryption.bicep) module. +7. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): The identity the SQL server uses to reach its TDE protector key in Key Vault. The web app allows users to plan and manage vacation activities, storing all activity data in the `Activities` table in the `PlannerDB` database. For more information, see [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). @@ -120,6 +121,12 @@ az sql db show \ --server local-sqlserver-test \ --resource-group local-rg \ --output table + +# Check the TDE protector of the Azure SQL Server +az sql server tde-key show \ +--server local-sqlserver-test \ +--resource-group local-rg \ +--output table ``` ## Cleanup diff --git a/samples/web-app-sql-database/python/bicep/main.bicep b/samples/web-app-sql-database/python/bicep/main.bicep index cdd900c..f077ace 100644 --- a/samples/web-app-sql-database/python/bicep/main.bicep +++ b/samples/web-app-sql-database/python/bicep/main.bicep @@ -153,9 +153,6 @@ param administratorLoginPassword string = 'P@ssw0rd1234!' @description('Conditional. The Azure Active Directory (AAD) administrator authentication. Required if no `administratorLogin` & `administratorLoginPassword` is provided.') param administrators object? -@description('Specifies the conditional Developmentresource ID of a user-assigned identityDevelopment to be used by default. This is required if `userAssignedIdentities` is not empty.') -param primaryUserAssignedIdentityResourceId string? - @allowed([ '1.0' '1.1' @@ -324,15 +321,25 @@ var webAppName = '${prefix}-webapp-${suffix}' var appServicePlanName = '${prefix}-app-service-plan-${suffix}' var keyVaultName = '${prefix}-kv-${suffix}' var sqlConnectionStringSecretName = '${prefix}-secret-${suffix}' -var identity = { - type: 'SystemAssigned' - } +var sqlServerIdentityName = '${prefix}-tde-identity-${suffix}' +var tdeKeyName = '${prefix}-tde-key-${suffix}' + +resource sqlServerIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: sqlServerIdentityName + location: location + tags: tags +} resource sqlServer 'Microsoft.Sql/servers@2024-05-01-preview' = { name: sqlServerName location: location tags: tags - identity: identity + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sqlServerIdentity.id}': {} + } + } properties: { administratorLogin: administratorLogin administratorLoginPassword: administratorLoginPassword @@ -341,7 +348,7 @@ resource sqlServer 'Microsoft.Sql/servers@2024-05-01-preview' = { isIPv6Enabled: isIPv6Enabled version: version minimalTlsVersion: minimalTlsVersion - primaryUserAssignedIdentityId: primaryUserAssignedIdentityResourceId + primaryUserAssignedIdentityId: sqlServerIdentity.id publicNetworkAccess: publicNetworkAccess restrictOutboundNetworkAccess: restrictOutboundNetworkAccess } @@ -452,10 +459,45 @@ resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { ] } } + { + tenantId: subscription().tenantId + objectId: sqlServerIdentity.properties.principalId + permissions: { + keys: [ + 'get' + 'wrapKey' + 'unwrapKey' + ] + } + } ] enableRbacAuthorization: false enableSoftDelete: true softDeleteRetentionInDays: 7 + enablePurgeProtection: true + } +} + +resource tdeKey 'Microsoft.KeyVault/vaults/keys@2024-11-01' = { + parent: keyVault + name: tdeKeyName + properties: { + kty: 'RSA' + keySize: 2048 + keyOps: [ + 'wrapKey' + 'unwrapKey' + ] + } +} + +module transparentDataEncryption 'modules/transparent-data-encryption.bicep' = { + name: 'transparentDataEncryption' + params: { + sqlServerName: sqlServer.name + keyVaultName: keyVault.name + keyName: tdeKey.name + keyUri: tdeKey.properties.keyUriWithVersion } } diff --git a/samples/web-app-sql-database/python/bicep/modules/transparent-data-encryption.bicep b/samples/web-app-sql-database/python/bicep/modules/transparent-data-encryption.bicep new file mode 100644 index 0000000..95d4a10 --- /dev/null +++ b/samples/web-app-sql-database/python/bicep/modules/transparent-data-encryption.bicep @@ -0,0 +1,35 @@ +@description('Specifies the name of the SQL logical server.') +param sqlServerName string + +@description('Specifies the name of the Key Vault that holds the TDE protector key.') +param keyVaultName string + +@description('Specifies the name of the Key Vault key used as the TDE protector.') +param keyName string + +@description('Specifies the versioned URI of the Key Vault key used as the TDE protector.') +param keyUri string + +resource sqlServer 'Microsoft.Sql/servers@2024-05-01-preview' existing = { + name: sqlServerName +} + +// A server key must be named after the vault, key and key version it points to. +resource serverKey 'Microsoft.Sql/servers/keys@2023-08-01' = { + parent: sqlServer + name: '${keyVaultName}_${keyName}_${last(split(keyUri, '/'))}' + properties: { + serverKeyType: 'AzureKeyVault' + uri: keyUri + } +} + +resource encryptionProtector 'Microsoft.Sql/servers/encryptionProtector@2023-08-01' = { + parent: sqlServer + name: 'current' + properties: { + serverKeyType: 'AzureKeyVault' + serverKeyName: serverKey.name + autoRotationEnabled: true + } +} diff --git a/samples/web-app-sql-database/python/scripts/README.md b/samples/web-app-sql-database/python/scripts/README.md index 3543fa2..f2d001a 100644 --- a/samples/web-app-sql-database/python/scripts/README.md +++ b/samples/web-app-sql-database/python/scripts/README.md @@ -40,7 +40,8 @@ The [deploy.sh](deploy.sh) Bash script creates the following Azure resources usi 4. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The compute resource that hosts the web application. 5. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the Python Flask single-page application (*Vacation Planner*), connected to Azure SQL Database. 6. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Configures automatic deployment from a public GitHub repository. -7. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret. +7. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret and the RSA key that serves as the [TDE protector](https://learn.microsoft.com/en-us/azure/azure-sql/database/transparent-data-encryption-byok-overview) of the SQL server. +8. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): The identity the SQL server uses to reach its TDE protector key in Key Vault. The system implements a Vacation Planner web application that stores and retrieves activity data from Azure SQL Database. For more information, see [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). @@ -120,6 +121,12 @@ az sql db show \ --server local-sqlserver-test \ --resource-group local-rg \ --output table + +# Check the TDE protector of the Azure SQL Server +az sql server tde-key show \ +--server local-sqlserver-test \ +--resource-group local-rg \ +--output table ``` ## Cleanup diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index 58d66a5..47d00b7 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -6,6 +6,7 @@ SUFFIX='test' LOCATION='westeurope' RESOURCE_GROUP_NAME="${PREFIX}-rg" SQL_SERVER_NAME="${PREFIX}-sqlserver-${SUFFIX}" +SQL_SERVER_IDENTITY_NAME="${PREFIX}-tde-identity-${SUFFIX}" FIREWALL_RULE_NAME="AllowAllIPs" ADMIN_USER='sqladmin' ADMIN_PASSWORD='P@ssw0rd1234!' @@ -24,6 +25,7 @@ DEPLOY_APP=1 KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" SECRET_NAME="${PREFIX}-secret-${SUFFIX}" CERT_NAME="${PREFIX}-cert-${SUFFIX}" +TDE_KEY_NAME="${PREFIX}-tde-key-${SUFFIX}" # Change the current directory to the script's directory cd "$CURRENT_DIR" || exit @@ -41,6 +43,38 @@ else exit 1 fi +# Create the user-assigned managed identity the SQL server uses to reach the TDE protector key +echo "Creating user-assigned managed identity [$SQL_SERVER_IDENTITY_NAME]..." +az identity create \ + --name "$SQL_SERVER_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "User-assigned managed identity [$SQL_SERVER_IDENTITY_NAME] created successfully." +else + echo "Failed to create user-assigned managed identity [$SQL_SERVER_IDENTITY_NAME]." + exit 1 +fi + +SQL_SERVER_IDENTITY_ID=$(az identity show \ + --name "$SQL_SERVER_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "id" \ + --output tsv) + +SQL_SERVER_IDENTITY_PRINCIPAL_ID=$(az identity show \ + --name "$SQL_SERVER_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "principalId" \ + --output tsv) + +if [[ -z "$SQL_SERVER_IDENTITY_ID" || -z "$SQL_SERVER_IDENTITY_PRINCIPAL_ID" ]]; then + echo "Failed to retrieve the resource ID or principalId of [$SQL_SERVER_IDENTITY_NAME]" + exit 1 +fi + # Create a sql server echo "Checking if [$SQL_SERVER_NAME] sql server exists in the [$RESOURCE_GROUP_NAME] resource group..." az sql server show \ @@ -60,7 +94,9 @@ else --admin-user $ADMIN_USER \ --admin-password $ADMIN_PASSWORD \ --assign-identity \ - --identity-type SystemAssigned \ + --identity-type UserAssigned \ + --user-assigned-identity-id "$SQL_SERVER_IDENTITY_ID" \ + --primary-user-assigned-identity-id "$SQL_SERVER_IDENTITY_ID" \ --minimal-tls-version 1.2 \ --tags environment=test \ --only-show-errors 1>/dev/null @@ -334,6 +370,8 @@ az keyvault create \ --resource-group "$RESOURCE_GROUP_NAME" \ --location "$LOCATION" \ --enable-rbac-authorization false \ + --enable-purge-protection true \ + --retention-days 7 \ --only-show-errors 1>/dev/null if [ $? -eq 0 ]; then @@ -397,6 +435,78 @@ else exit 1 fi +# Assign access policy to the SQL server managed identity +echo "Assigning Key Vault access policy to the SQL server identity [$SQL_SERVER_IDENTITY_NAME]..." +az keyvault set-policy \ + --name "$KEY_VAULT_NAME" \ + --object-id "$SQL_SERVER_IDENTITY_PRINCIPAL_ID" \ + --key-permissions get wrapKey unwrapKey \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Key Vault access policy for [$SQL_SERVER_IDENTITY_NAME] assigned successfully." +else + echo "Failed to assign Key Vault access policy for [$SQL_SERVER_IDENTITY_NAME]." + exit 1 +fi + +# Create the RSA key that protects the database encryption keys of the SQL server +echo "Creating key [$TDE_KEY_NAME] in Key Vault [$KEY_VAULT_NAME]..." +TDE_KEY_ID=$(az keyvault key create \ + --vault-name "$KEY_VAULT_NAME" \ + --name "$TDE_KEY_NAME" \ + --kty RSA \ + --size 2048 \ + --ops wrapKey unwrapKey \ + --query "key.kid" \ + --output tsv \ + --only-show-errors) + +if [ -n "$TDE_KEY_ID" ]; then + echo "Key [$TDE_KEY_ID] created successfully." +else + echo "Failed to create key [$TDE_KEY_NAME] in Key Vault [$KEY_VAULT_NAME]." + exit 1 +fi + +# Register the key on the SQL server and make it the TDE protector. The generic az resource create +# is used because az sql server key create and az sql server tde-key set only accept key ids on the +# public Key Vault domains, which rejects the key ids the LocalStack emulator issues. +SQL_SERVER_ID=$(az sql server show \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "id" \ + --output tsv) +SERVER_KEY_NAME="${KEY_VAULT_NAME}_${TDE_KEY_NAME}_${TDE_KEY_ID##*/}" + +echo "Adding key [$TDE_KEY_NAME] to the [$SQL_SERVER_NAME] sql server..." +az resource create \ + --id "$SQL_SERVER_ID/keys/$SERVER_KEY_NAME" \ + --api-version 2023-08-01 \ + --properties "{\"serverKeyType\": \"AzureKeyVault\", \"uri\": \"$TDE_KEY_ID\"}" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Key [$TDE_KEY_NAME] added successfully to the [$SQL_SERVER_NAME] sql server." +else + echo "Failed to add key [$TDE_KEY_NAME] to the [$SQL_SERVER_NAME] sql server." + exit 1 +fi + +echo "Setting key [$TDE_KEY_NAME] as the TDE protector of the [$SQL_SERVER_NAME] sql server..." +az resource create \ + --id "$SQL_SERVER_ID/encryptionProtector/current" \ + --api-version 2023-08-01 \ + --properties "{\"serverKeyType\": \"AzureKeyVault\", \"serverKeyName\": \"$SERVER_KEY_NAME\", \"autoRotationEnabled\": true}" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Key [$TDE_KEY_NAME] set successfully as the TDE protector of the [$SQL_SERVER_NAME] sql server." +else + echo "Failed to set key [$TDE_KEY_NAME] as the TDE protector of the [$SQL_SERVER_NAME] sql server." + exit 1 +fi + # Get Key Vault URI echo "Retrieving Key Vault URI..." KEYVAULT_URI=$(az keyvault show \ diff --git a/samples/web-app-sql-database/python/scripts/validate.sh b/samples/web-app-sql-database/python/scripts/validate.sh index 19d5997..c6ca9bf 100755 --- a/samples/web-app-sql-database/python/scripts/validate.sh +++ b/samples/web-app-sql-database/python/scripts/validate.sh @@ -9,6 +9,7 @@ SQL_DATABASE_NAME='PlannerDB' WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" SECRET_NAME="${PREFIX}-secret-${SUFFIX}" +TDE_KEY_NAME="${PREFIX}-tde-key-${SUFFIX}" # Check resource group echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" @@ -39,6 +40,44 @@ az sql db show \ --resource-group "$RESOURCE_GROUP_NAME" \ --output table +# Check that the Key Vault key is the TDE protector of the Azure SQL Server +echo -e "\n[$SQL_SERVER_NAME] SQL server TDE protector:\n" +# Read the key through Azure Resource Manager: the Bicep variant grants the caller no Key Vault data-plane access. +KEY_VAULT_ID=$(az keyvault show \ +--name "$KEY_VAULT_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "id" \ +--output tsv) +TDE_KEY_ID=$(az resource show \ +--ids "$KEY_VAULT_ID/keys/$TDE_KEY_NAME" \ +--api-version 2024-11-01 \ +--query "properties.keyUriWithVersion" \ +--output tsv) +TDE_PROTECTOR=$(az sql server tde-key show \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output json) +echo "$TDE_PROTECTOR" | jq '{serverKeyType, uri, autoRotationEnabled}' +if [[ "$(jq -r .serverKeyType <<< "$TDE_PROTECTOR")" != "AzureKeyVault" || + "$(jq -r .uri <<< "$TDE_PROTECTOR")" != "$TDE_KEY_ID" || + "$(jq -r .autoRotationEnabled <<< "$TDE_PROTECTOR")" != "true" ]]; then + echo "The TDE protector of [$SQL_SERVER_NAME] is not the auto-rotated Key Vault key [$TDE_KEY_ID]" + exit 1 +fi + +# Check that TDE is enabled on the Azure SQL Database +TDE_STATE=$(az sql db tde show \ +--database "$SQL_DATABASE_NAME" \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "state" \ +--output tsv) +echo -e "\n[$SQL_DATABASE_NAME] SQL database TDE state: [$TDE_STATE]" +if [[ "$TDE_STATE" != "Enabled" ]]; then + echo "TDE is not enabled on [$SQL_DATABASE_NAME]" + exit 1 +fi + # Check Azure Key Vault echo -e "\n[$KEY_VAULT_NAME] Key Vault:\n" az keyvault show \ diff --git a/samples/web-app-sql-database/python/terraform/README.md b/samples/web-app-sql-database/python/terraform/README.md index 082ff14..8b7aed6 100644 --- a/samples/web-app-sql-database/python/terraform/README.md +++ b/samples/web-app-sql-database/python/terraform/README.md @@ -40,8 +40,9 @@ The [main.tf](main.tf) Terraform module creates the following Azure resources: 3. [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/): The `PlannerDB` database storing relational vacation activity data. 4. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The compute resource that hosts the web application. 5. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the Python Flask single-page application (*Vacation Planner*), connected to Azure SQL Database. -6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string as a secret and a self-signed certificate for HTTPS. +6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string as a secret, a self-signed certificate for HTTPS, and the RSA key that serves as the [TDE protector](https://learn.microsoft.com/en-us/azure/azure-sql/database/transparent-data-encryption-byok-overview) of the SQL server. 7. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Configures automatic deployment from a public GitHub repository. +8. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): The identity the SQL server uses to reach its TDE protector key in Key Vault. The system implements a Vacation Planner web application that stores and retrieves activity data from Azure SQL Database. For more information, see [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). @@ -144,6 +145,12 @@ az sql db show \ --server local-sqlserver-test \ --resource-group local-rg \ --output table + +# Check the TDE protector of the Azure SQL Server +az sql server tde-key show \ +--server local-sqlserver-test \ +--resource-group local-rg \ +--output table ``` ## Cleanup diff --git a/samples/web-app-sql-database/python/terraform/main.tf b/samples/web-app-sql-database/python/terraform/main.tf index 1a5a296..9e5a04e 100644 --- a/samples/web-app-sql-database/python/terraform/main.tf +++ b/samples/web-app-sql-database/python/terraform/main.tf @@ -6,6 +6,8 @@ locals { app_service_plan_name = "${var.prefix}-app-service-plan-${var.suffix}" web_app_name = "${var.prefix}-webapp-${var.suffix}" key_vault_name = "${var.prefix}-kv-${var.suffix}" + sql_identity_name = "${var.prefix}-tde-identity-${var.suffix}" + tde_key_name = "${var.prefix}-tde-key-${var.suffix}" } # Retrieve the current Azure client configuration @@ -18,6 +20,20 @@ resource "azurerm_resource_group" "example" { tags = var.tags } +# Create the user-assigned managed identity the SQL server uses to reach the TDE protector key +resource "azurerm_user_assigned_identity" "sql_server" { + name = local.sql_identity_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + # Create a SQL server resource "azurerm_mssql_server" "example" { name = local.sql_server_name @@ -29,11 +45,19 @@ resource "azurerm_mssql_server" "example" { public_network_access_enabled = var.public_network_access_enabled outbound_network_restriction_enabled = var.outbound_network_restriction_enabled version = var.sql_version + primary_user_assigned_identity_id = azurerm_user_assigned_identity.sql_server.id tags = var.tags + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.sql_server.id] + } + + # The TDE protector is managed by azurerm_mssql_server_transparent_data_encryption below lifecycle { ignore_changes = [ - tags + tags, + transparent_data_encryption_key_vault_key_id ] } } @@ -138,6 +162,7 @@ resource "azurerm_key_vault" "example" { sku_name = "standard" rbac_authorization_enabled = false soft_delete_retention_days = 7 + purge_protection_enabled = true tags = var.tags lifecycle { @@ -163,11 +188,72 @@ resource "azurerm_key_vault_access_policy" "web_app" { ] } +# Grant the identity running Terraform access to manage the Key Vault key, secret and certificate +resource "azurerm_key_vault_access_policy" "deployer" { + key_vault_id = azurerm_key_vault.example.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + key_permissions = [ + "Create", + "Delete", + "Get", + "GetRotationPolicy", + ] + + secret_permissions = [ + "Delete", + "Get", + "Set", + ] + + certificate_permissions = [ + "Create", + "Delete", + "Get", + ] +} + +# Grant the SQL server managed identity access to the TDE protector key +resource "azurerm_key_vault_access_policy" "sql_server" { + key_vault_id = azurerm_key_vault.example.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = azurerm_user_assigned_identity.sql_server.principal_id + + key_permissions = [ + "Get", + "UnwrapKey", + "WrapKey", + ] +} + +# Create the RSA key that protects the database encryption keys of the SQL server +resource "azurerm_key_vault_key" "tde" { + name = local.tde_key_name + key_vault_id = azurerm_key_vault.example.id + key_type = "RSA" + key_size = 2048 + key_opts = ["unwrapKey", "wrapKey"] + + depends_on = [azurerm_key_vault_access_policy.deployer] +} + +# Make the Key Vault key the TDE protector of the SQL server +resource "azurerm_mssql_server_transparent_data_encryption" "example" { + server_id = azurerm_mssql_server.example.id + key_vault_key_id = azurerm_key_vault_key.tde.id + auto_rotation_enabled = true + + depends_on = [azurerm_key_vault_access_policy.sql_server] +} + # Create a Key Vault secret for SQL connection string resource "azurerm_key_vault_secret" "sql_connection_string" { name = var.secret_name value = "Server=tcp:${azurerm_mssql_server.example.fully_qualified_domain_name},1433;Database=${azurerm_mssql_database.example.name};User ID=${var.sql_database_username};Password=${var.sql_database_password};Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;" key_vault_id = azurerm_key_vault.example.id + + depends_on = [azurerm_key_vault_access_policy.deployer] } # Create a self-signed certificate in Key Vault @@ -201,4 +287,6 @@ resource "azurerm_key_vault_certificate" "example" { ] } } + + depends_on = [azurerm_key_vault_access_policy.deployer] } From 7ba843c976586f200d10c44a953f6f7f54df49f9 Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Tue, 29 Sep 2026 15:05:41 +0200 Subject: [PATCH 2/2] Refactor Program.cs and update sample.csproj formatting --- .../dotnet/src/GreetingFunctions.cs | 1106 ++++++------ .../dotnet/src/Program.cs | 14 +- .../dotnet/src/sample.csproj | 60 +- .../dotnet/src/sample/GameSessionManager.cs | 1554 ++++++++--------- .../dotnet/src/sample/Program.cs | 14 +- .../dotnet/src/sample/sample.csproj | 64 +- 6 files changed, 1406 insertions(+), 1406 deletions(-) diff --git a/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs b/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs index 3a3c86b..bbe47f8 100644 --- a/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs +++ b/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs @@ -1,554 +1,554 @@ -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Azure.Identity; -using Azure.Messaging.ServiceBus; -using Azure.Messaging.ServiceBus.Administration; - -namespace LocalStack.Azure.Samples; - -/// -/// A simple Azure Function that processes Service Bus messages and responds with a greeting. -/// -public class HelloWorld -{ - // Instance field for logging - keeps proper Azure Functions execution context - private readonly ILogger _logger; - - // Static configuration values - initialized once per application lifetime - private static string? _connectionString; - private static string? _clientId; - private static string? _fullyQualifiedNamespace; - private static bool _hasConnectionString; - private static bool _hasClientId; - private static bool _hasFullyQualifiedNamespace; - private static string? _inputQueueName; - private static string? _outputQueueName; - private static bool _configurationValid = false; - private static string[]? _names; - - // Greeting templates used by GetGreeting to produce varied responses - private static readonly string[] _greetingTemplates = new[] - { - "Hello {0}, how are you?", - "Hi {0}, great to see you!", - "Hey {0}, hope you're having a wonderful day!", - "Good day {0}, welcome aboard!", - "Greetings {0}, nice to meet you!", - "Howdy {0}, what's going on?", - "Welcome {0}, glad you're here!", - "Salutations {0}, how's everything going?" - }; - - private static readonly Random _random = new(); - - // Circular buffers for message history across all functions - private const int MaxHistory = 100; - private static readonly object _historyLock = new(); - private static readonly CircularBuffer _requesterSent = new(MaxHistory); - private static readonly CircularBuffer _handlerReceived = new(MaxHistory); - private static readonly CircularBuffer _handlerSent = new(MaxHistory); - private static readonly CircularBuffer _consumerReceived = new(MaxHistory); - - // Static initialization - runs once per application lifetime - private static readonly Lazy _initialized = new Lazy(() => { Initialize(); return true; }); - - /// - /// Initializes a new instance of the class. - /// - /// The logger factory used to create loggers for this class. - public HelloWorld(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } - - /// - /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). - /// This method runs exactly once per application lifetime and stores configuration values in static fields. - /// - /// A task representing the asynchronous initialization operation. - private static void Initialize() - { - try - { - // Create a temporary configuration instance for initialization - var configBuilder = new ConfigurationBuilder() - .AddEnvironmentVariables() - .AddJsonFile("local.settings.json", optional: true); - var config = configBuilder.Build(); - - // Create a temporary logger for initialization - using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - - logger.LogInformation("[Initialize] Starting one-time initialization..."); - - // Read and store configuration values in static fields with fallback defaults - _connectionString = config["SERVICE_BUS_CONNECTION_STRING"]; - _clientId = config["AZURE_CLIENT_ID"]; - _fullyQualifiedNamespace = config["SERVICE_BUS_CONNECTION_STRING:fullyQualifiedNamespace"]; - _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; - _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; - _names = config["NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - _hasConnectionString = !string.IsNullOrWhiteSpace(_connectionString); - _hasClientId = !string.IsNullOrWhiteSpace(_clientId); - _hasFullyQualifiedNamespace = !string.IsNullOrWhiteSpace(_fullyQualifiedNamespace); - - // Check if names ae configured. If not use, use default names - if (_names == null || _names.Length == 0) - { - logger.LogWarning("[Initialize] NAMES configuration is missing or empty. Using default names."); - _names = new[] { "Alice", "Paolo", "Leo", "Mia" }; - } - - // Validate configuration and set the flag - _configurationValid = ValidateConfigurationValues(logger); - } - catch (Exception ex) - { - // Log error but don't throw - let functions continue to work even if initialization fails - Console.WriteLine("[Initialize] Initialization failed: {0}", ex.Message); - _configurationValid = false; - } - } - - /// - /// Validates that all required configuration values are present and not empty. - /// With default values in place, only the connection string is mandatory. - /// - /// Logger for reporting validation errors. - /// True if all configuration values are valid, false otherwise. - private static bool ValidateConfigurationValues(ILogger logger) - { - bool isValid = true; - - // Requirement: Must have (ID AND Namespace) OR (Connection String) - if (!(_hasClientId && _hasFullyQualifiedNamespace) && !_hasConnectionString) - { - logger.LogError("[ValidateConfigurationValues] Incomplete configuration. You must provide BOTH Client ID and Namespace, OR a Connection String."); - isValid = false; - } - - // Additional Safety: If they provided a partial Identity, catch it! - if (_hasClientId != _hasFullyQualifiedNamespace && !_hasConnectionString) - { - logger.LogError("[ValidateConfigurationValues] Partial Identity detected. Both Client ID and Namespace are required."); - isValid = false; - } - - // Log the configuration values being used (helpful for debugging) - if (isValid) - { - logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); - logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); - logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); - logger.LogInformation(" - Names: {names}", string.Join(", ", _names != null ? _names : Array.Empty())); - } - - return isValid; - } - - /// - /// Checks if configuration values have been successfully loaded and validated. - /// This method provides a fast runtime check without re-reading configuration. - /// With default values, this primarily checks if the connection string is available. - /// - /// True if configuration is valid and available, false otherwise. - private static bool IsConfigurationValid() - { - // Valid if we have a connection string OR (client ID + fully qualified namespace) - return _configurationValid && (_hasConnectionString || (_hasClientId && _hasFullyQualifiedNamespace)); - } - - /// - /// Processes a Service Bus message by reading, validating, and responding to the input message. - /// - /// The received Service Bus message containing the request payload as JSON. - /// Actions for managing the Service Bus message lifecycle (e.g., completion). - /// - /// A JSON-formatted response message containing a greeting and the current date, or null if the input is invalid. - /// - [Function("GreetingHandler")] - [ServiceBusOutput("%OUTPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING")] - public async Task GreetingHandlerAsync( - [ServiceBusTrigger("%INPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, - ServiceBusMessageActions messageActions) - { - // Log the incoming message details - _logger.LogInformation("[GreetingHandler] Message ID: {id}", message.MessageId); - _logger.LogInformation("[GreetingHandler] Message Body: {body}", message.Body); - _logger.LogInformation("[GreetingHandler] Message Content-Type: {contentType}", message.ContentType); - - // Read the message body as a byte array - byte[] bodyBytes = message.Body.ToArray(); - - // Check that the bodyBytes is not null or empty - if (bodyBytes == null || bodyBytes.Length == 0) - { - _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or null.", message.MessageId); - return null; - } - // Convert the byte array to a string - string json = System.Text.Encoding.UTF8.GetString(bodyBytes); - - // Check that the JSON is not null or empty - if (string.IsNullOrEmpty(json)) - { - _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or invalid.", message.MessageId); - return null; - } - - // Deserialize the JSON into a RequestMessage object - RequestMessage? requestMessage = JsonSerializer.Deserialize(json); - - // Check that the request message is not null or empty - if (requestMessage == null || string.IsNullOrWhiteSpace(requestMessage?.Name)) - { - _logger.LogError("[GreetingHandler] Received request message [{messageId}] body is empty or invalid.", message.MessageId); - return null; - } - - _logger.LogInformation("[GreetingHandler] Processing request for name: {name}", requestMessage.Name); - - // Record received name in history - lock (_historyLock) - { - _handlerReceived.Add(requestMessage.Name); - } - - // Create the response message - var greetingText = GetGreeting(requestMessage.Name); - var outputObj = new ResponseMessage - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - Text = greetingText - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Complete the message after processing - await messageActions.CompleteMessageAsync(message); - - // Log the successful processing of the message - _logger.LogInformation("[GreetingHandler] Processed message [{messageId}] successfully: {greetingText}", message.MessageId, greetingText); - - // Return the response message - return outputMessage; - } - - /// - /// Timer-triggered function that sends a greeting request message to the input queue. - /// - /// Timer metadata containing schedule status and next occurrence information. - [Function("GreetingRequester")] - [FixedDelayRetry(5, "00:00:10")] - public async Task GreetingRequesterAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) - { - // Log the start of the function execution - _logger.LogInformation("[GreetingRequester] Timer trigger function started."); - - // Ensure one-time initialization has run - _ = _initialized.Value; - - // Fast configuration validation using pre-loaded static values - if (!IsConfigurationValid()) - { - _logger.LogError("[GreetingRequester] Configuration is invalid or not loaded. Aborting function execution."); - return; - } - - if (_names == null || _names.Length == 0) - { - _logger.LogError("[GreetingRequester] Names are not configured. Aborting function execution."); - return; - } - - try - { - // Create Service Bus client - _logger.LogInformation("[GreetingRequester] Creating Service Bus client for sending messages..."); - await using var client = _hasClientId && _hasFullyQualifiedNamespace - ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) - : new ServiceBusClient(_connectionString); - - // Create message sender for the input queue - _logger.LogInformation("[GreetingRequester] Creating sender for input queue '{inputQueue}'", _inputQueueName); - await using var sender = client.CreateSender(_inputQueueName); - - // Create request message with randomly selected name - var random = new Random(); - var selectedName = _names[random.Next(_names.Length)]; - var requestMessage = new RequestMessage { Name = selectedName }; - var messageBody = JsonSerializer.Serialize(requestMessage); - - // Create and send Service Bus message - var serviceBusMessage = new ServiceBusMessage(messageBody) - { - ContentType = "application/json" - }; - - _logger.LogInformation("[GreetingRequester] Sending message to input queue '{inputQueue}'...", _inputQueueName); - await sender.SendMessageAsync(serviceBusMessage); - _logger.LogInformation("[GreetingRequester] Successfully sent message to input queue '{inputQueue}' with name: {Name}", _inputQueueName, selectedName); - - // Record sent name in history - lock (_historyLock) - { - _requesterSent.Add(selectedName); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "[GreetingRequester] Failed to send message to input queue '{inputQueue}'", _inputQueueName); - return; - } - - // Log the next scheduled timer occurrence - _logger.LogInformation("[GreetingRequester] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); - } - - /// - /// Timer-triggered function that receives and processes greeting response messages from the output queue. - /// - /// Timer metadata containing schedule status and next occurrence information. - [Function("GreetingConsumer")] - [FixedDelayRetry(5, "00:00:10")] - public async Task GreetingConsumerAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) - { - // Log the start of the function execution - _logger.LogInformation("[GreetingConsumer] Timer trigger function started."); - - // Ensure one-time initialization has run - _ = _initialized.Value; - - // Fast configuration validation using pre-loaded static values - if (!IsConfigurationValid()) - { - _logger.LogError("[GreetingConsumer] Configuration is invalid or not loaded. Aborting function execution."); - return; - } - - try - { - // Create Service Bus client for receiving messages from the output queue - _logger.LogInformation("[GreetingConsumer] Creating Service Bus client for receiving messages..."); - await using var client = _hasClientId && _hasFullyQualifiedNamespace - ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) - : new ServiceBusClient(_connectionString); - var receiver = client.CreateReceiver(_outputQueueName); - - _logger.LogInformation("[GreetingConsumer] Starting to receive messages from output queue '{outputQueue}'", _outputQueueName); - - // Loop to receive messages (with timeout to prevent infinite waiting) - var timeout = TimeSpan.FromSeconds(30); - var startTime = DateTime.UtcNow; - - try - { - while (DateTime.UtcNow - startTime < timeout) - { - try - { - // Receive message with a short timeout - var receivedMessage = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(5)); - - if (receivedMessage == null) - { - _logger.LogInformation("[GreetingConsumer] No more messages available in output queue '{outputQueue}'", _outputQueueName); - break; - } - - // Convert message body to string - var messageBody = receivedMessage.Body.ToString(); - - try - { - // Attempt to deserialize to ResponseMessage - var responseMessage = JsonSerializer.Deserialize(messageBody); - - if (responseMessage != null) - { - _logger.LogInformation("[GreetingConsumer] Successfully received and deserialized message from output queue. Date: {Date}, Text: {Text}", - responseMessage.Date, responseMessage.Text); - - // Complete the message after successful processing - await receiver.CompleteMessageAsync(receivedMessage); - - // Record received greeting in history - lock (_historyLock) - { - _consumerReceived.Add(responseMessage.Text); - } - } - else - { - _logger.LogWarning("[GreetingConsumer] Received message could not be deserialized to ResponseMessage (null result)"); - await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", "Message deserialized to null"); - } - } - catch (JsonException jsonEx) - { - _logger.LogError(jsonEx, "[GreetingConsumer] Failed to deserialize message from output queue. Message body: {messageBody}", messageBody); - await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", jsonEx.Message); - } - } - catch (Exception messageEx) - { - _logger.LogError(messageEx, "[GreetingConsumer] Error occurred while receiving message from output queue '{outputQueue}'", _outputQueueName); - // Continue the loop to try receiving more messages - } - } - } - finally - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - try - { - await receiver.CloseAsync(cts.Token); - } - catch - { /* timeout or error on close */ } - try - { - await client.DisposeAsync(); - } - catch - { /* benign */ - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "[GreetingConsumer] Failed to receive messages from output queue '{outputQueue}'", _outputQueueName); - } - - // Log the next scheduled timer occurrence - _logger.LogInformation("[GreetingConsumer] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); - } - - /// - /// Selects a random greeting template and formats it with the given name. - /// The generated greeting is also stored in a circular buffer for later retrieval. - /// - /// The name to include in the greeting. - /// A randomly chosen greeting string addressed to the specified name. - private static string GetGreeting(string name) - { - var template = _greetingTemplates[_random.Next(_greetingTemplates.Length)]; - var greeting = string.Format(template, name); - - lock (_historyLock) - { - _handlerSent.Add(greeting); - } - - return greeting; - } - - /// - /// HTTP-triggered function that returns the most recent greetings from the circular buffer. - /// Greetings are returned in reverse chronological order (newest first). - /// - /// The incoming HTTP request. - /// The number of greetings to return (default: 20, max: 100). - /// An HTTP response containing a JSON array of recent greetings. - [Function("GetGreetings")] - public async Task GetGreetingsAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "greetings")] HttpRequestData request, - int count = 20) - { - _logger.LogInformation("[GetGreetings] Retrieving last {count} entries.", count); - - // Clamp count to valid range - if (count < 1) count = 1; - if (count > MaxHistory) count = MaxHistory; - - object history; - lock (_historyLock) - { - history = new - { - requester = new - { - sent = _requesterSent.ToArray(count) - }, - handler = new - { - received = _handlerReceived.ToArray(count), - sent = _handlerSent.ToArray(count) - }, - consumer = new - { - received = _consumerReceived.ToArray(count) - } - }; - } - - var response = request.CreateResponse(HttpStatusCode.OK); - response.Headers.Add("Content-Type", "application/json"); - await response.WriteStringAsync(JsonSerializer.Serialize(history)); - return response; - } - - private sealed class CircularBuffer - { - private readonly string[] _items; - private int _index; - private int _count; - - public CircularBuffer(int capacity) => _items = new string[capacity]; - - public void Add(string item) - { - _items[_index] = item; - _index = (_index + 1) % _items.Length; - if (_count < _items.Length) _count++; - } - - public string[] ToArray(int count) - { - var available = Math.Min(count, _count); - var result = new string[available]; - for (int i = 0; i < available; i++) - { - var idx = (_index - available + i + _items.Length) % _items.Length; - result[i] = _items[idx]; - } - return result; - } - } -} - -/// -/// Represents the input payload for greeting requests. -/// -public class RequestMessage -{ - /// - /// Gets or sets the name to greet. - /// - [JsonPropertyName("name")] - public required string Name { get; set; } -} - -/// -/// Represents the response payload for greeting requests. -/// -public class ResponseMessage -{ - /// - /// Gets or sets the date of the response message. - /// - [JsonPropertyName("date")] - public required string Date { get; set; } - - /// - /// Gets or sets the text of the response message. - /// - [JsonPropertyName("text")] - public required string Text { get; set; } +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using Azure.Identity; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; + +namespace LocalStack.Azure.Samples; + +/// +/// A simple Azure Function that processes Service Bus messages and responds with a greeting. +/// +public class HelloWorld +{ + // Instance field for logging - keeps proper Azure Functions execution context + private readonly ILogger _logger; + + // Static configuration values - initialized once per application lifetime + private static string? _connectionString; + private static string? _clientId; + private static string? _fullyQualifiedNamespace; + private static bool _hasConnectionString; + private static bool _hasClientId; + private static bool _hasFullyQualifiedNamespace; + private static string? _inputQueueName; + private static string? _outputQueueName; + private static bool _configurationValid = false; + private static string[]? _names; + + // Greeting templates used by GetGreeting to produce varied responses + private static readonly string[] _greetingTemplates = new[] + { + "Hello {0}, how are you?", + "Hi {0}, great to see you!", + "Hey {0}, hope you're having a wonderful day!", + "Good day {0}, welcome aboard!", + "Greetings {0}, nice to meet you!", + "Howdy {0}, what's going on?", + "Welcome {0}, glad you're here!", + "Salutations {0}, how's everything going?" + }; + + private static readonly Random _random = new(); + + // Circular buffers for message history across all functions + private const int MaxHistory = 100; + private static readonly object _historyLock = new(); + private static readonly CircularBuffer _requesterSent = new(MaxHistory); + private static readonly CircularBuffer _handlerReceived = new(MaxHistory); + private static readonly CircularBuffer _handlerSent = new(MaxHistory); + private static readonly CircularBuffer _consumerReceived = new(MaxHistory); + + // Static initialization - runs once per application lifetime + private static readonly Lazy _initialized = new Lazy(() => { Initialize(); return true; }); + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory used to create loggers for this class. + public HelloWorld(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + /// + /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). + /// This method runs exactly once per application lifetime and stores configuration values in static fields. + /// + /// A task representing the asynchronous initialization operation. + private static void Initialize() + { + try + { + // Create a temporary configuration instance for initialization + var configBuilder = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddJsonFile("local.settings.json", optional: true); + var config = configBuilder.Build(); + + // Create a temporary logger for initialization + using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var logger = loggerFactory.CreateLogger(); + + logger.LogInformation("[Initialize] Starting one-time initialization..."); + + // Read and store configuration values in static fields with fallback defaults + _connectionString = config["SERVICE_BUS_CONNECTION_STRING"]; + _clientId = config["AZURE_CLIENT_ID"]; + _fullyQualifiedNamespace = config["SERVICE_BUS_CONNECTION_STRING:fullyQualifiedNamespace"]; + _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; + _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; + _names = config["NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + _hasConnectionString = !string.IsNullOrWhiteSpace(_connectionString); + _hasClientId = !string.IsNullOrWhiteSpace(_clientId); + _hasFullyQualifiedNamespace = !string.IsNullOrWhiteSpace(_fullyQualifiedNamespace); + + // Check if names ae configured. If not use, use default names + if (_names == null || _names.Length == 0) + { + logger.LogWarning("[Initialize] NAMES configuration is missing or empty. Using default names."); + _names = new[] { "Alice", "Paolo", "Leo", "Mia" }; + } + + // Validate configuration and set the flag + _configurationValid = ValidateConfigurationValues(logger); + } + catch (Exception ex) + { + // Log error but don't throw - let functions continue to work even if initialization fails + Console.WriteLine("[Initialize] Initialization failed: {0}", ex.Message); + _configurationValid = false; + } + } + + /// + /// Validates that all required configuration values are present and not empty. + /// With default values in place, only the connection string is mandatory. + /// + /// Logger for reporting validation errors. + /// True if all configuration values are valid, false otherwise. + private static bool ValidateConfigurationValues(ILogger logger) + { + bool isValid = true; + + // Requirement: Must have (ID AND Namespace) OR (Connection String) + if (!(_hasClientId && _hasFullyQualifiedNamespace) && !_hasConnectionString) + { + logger.LogError("[ValidateConfigurationValues] Incomplete configuration. You must provide BOTH Client ID and Namespace, OR a Connection String."); + isValid = false; + } + + // Additional Safety: If they provided a partial Identity, catch it! + if (_hasClientId != _hasFullyQualifiedNamespace && !_hasConnectionString) + { + logger.LogError("[ValidateConfigurationValues] Partial Identity detected. Both Client ID and Namespace are required."); + isValid = false; + } + + // Log the configuration values being used (helpful for debugging) + if (isValid) + { + logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); + logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); + logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); + logger.LogInformation(" - Names: {names}", string.Join(", ", _names != null ? _names : Array.Empty())); + } + + return isValid; + } + + /// + /// Checks if configuration values have been successfully loaded and validated. + /// This method provides a fast runtime check without re-reading configuration. + /// With default values, this primarily checks if the connection string is available. + /// + /// True if configuration is valid and available, false otherwise. + private static bool IsConfigurationValid() + { + // Valid if we have a connection string OR (client ID + fully qualified namespace) + return _configurationValid && (_hasConnectionString || (_hasClientId && _hasFullyQualifiedNamespace)); + } + + /// + /// Processes a Service Bus message by reading, validating, and responding to the input message. + /// + /// The received Service Bus message containing the request payload as JSON. + /// Actions for managing the Service Bus message lifecycle (e.g., completion). + /// + /// A JSON-formatted response message containing a greeting and the current date, or null if the input is invalid. + /// + [Function("GreetingHandler")] + [ServiceBusOutput("%OUTPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING")] + public async Task GreetingHandlerAsync( + [ServiceBusTrigger("%INPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, + ServiceBusMessageActions messageActions) + { + // Log the incoming message details + _logger.LogInformation("[GreetingHandler] Message ID: {id}", message.MessageId); + _logger.LogInformation("[GreetingHandler] Message Body: {body}", message.Body); + _logger.LogInformation("[GreetingHandler] Message Content-Type: {contentType}", message.ContentType); + + // Read the message body as a byte array + byte[] bodyBytes = message.Body.ToArray(); + + // Check that the bodyBytes is not null or empty + if (bodyBytes == null || bodyBytes.Length == 0) + { + _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or null.", message.MessageId); + return null; + } + // Convert the byte array to a string + string json = System.Text.Encoding.UTF8.GetString(bodyBytes); + + // Check that the JSON is not null or empty + if (string.IsNullOrEmpty(json)) + { + _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or invalid.", message.MessageId); + return null; + } + + // Deserialize the JSON into a RequestMessage object + RequestMessage? requestMessage = JsonSerializer.Deserialize(json); + + // Check that the request message is not null or empty + if (requestMessage == null || string.IsNullOrWhiteSpace(requestMessage?.Name)) + { + _logger.LogError("[GreetingHandler] Received request message [{messageId}] body is empty or invalid.", message.MessageId); + return null; + } + + _logger.LogInformation("[GreetingHandler] Processing request for name: {name}", requestMessage.Name); + + // Record received name in history + lock (_historyLock) + { + _handlerReceived.Add(requestMessage.Name); + } + + // Create the response message + var greetingText = GetGreeting(requestMessage.Name); + var outputObj = new ResponseMessage + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + Text = greetingText + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Complete the message after processing + await messageActions.CompleteMessageAsync(message); + + // Log the successful processing of the message + _logger.LogInformation("[GreetingHandler] Processed message [{messageId}] successfully: {greetingText}", message.MessageId, greetingText); + + // Return the response message + return outputMessage; + } + + /// + /// Timer-triggered function that sends a greeting request message to the input queue. + /// + /// Timer metadata containing schedule status and next occurrence information. + [Function("GreetingRequester")] + [FixedDelayRetry(5, "00:00:10")] + public async Task GreetingRequesterAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) + { + // Log the start of the function execution + _logger.LogInformation("[GreetingRequester] Timer trigger function started."); + + // Ensure one-time initialization has run + _ = _initialized.Value; + + // Fast configuration validation using pre-loaded static values + if (!IsConfigurationValid()) + { + _logger.LogError("[GreetingRequester] Configuration is invalid or not loaded. Aborting function execution."); + return; + } + + if (_names == null || _names.Length == 0) + { + _logger.LogError("[GreetingRequester] Names are not configured. Aborting function execution."); + return; + } + + try + { + // Create Service Bus client + _logger.LogInformation("[GreetingRequester] Creating Service Bus client for sending messages..."); + await using var client = _hasClientId && _hasFullyQualifiedNamespace + ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) + : new ServiceBusClient(_connectionString); + + // Create message sender for the input queue + _logger.LogInformation("[GreetingRequester] Creating sender for input queue '{inputQueue}'", _inputQueueName); + await using var sender = client.CreateSender(_inputQueueName); + + // Create request message with randomly selected name + var random = new Random(); + var selectedName = _names[random.Next(_names.Length)]; + var requestMessage = new RequestMessage { Name = selectedName }; + var messageBody = JsonSerializer.Serialize(requestMessage); + + // Create and send Service Bus message + var serviceBusMessage = new ServiceBusMessage(messageBody) + { + ContentType = "application/json" + }; + + _logger.LogInformation("[GreetingRequester] Sending message to input queue '{inputQueue}'...", _inputQueueName); + await sender.SendMessageAsync(serviceBusMessage); + _logger.LogInformation("[GreetingRequester] Successfully sent message to input queue '{inputQueue}' with name: {Name}", _inputQueueName, selectedName); + + // Record sent name in history + lock (_historyLock) + { + _requesterSent.Add(selectedName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[GreetingRequester] Failed to send message to input queue '{inputQueue}'", _inputQueueName); + return; + } + + // Log the next scheduled timer occurrence + _logger.LogInformation("[GreetingRequester] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); + } + + /// + /// Timer-triggered function that receives and processes greeting response messages from the output queue. + /// + /// Timer metadata containing schedule status and next occurrence information. + [Function("GreetingConsumer")] + [FixedDelayRetry(5, "00:00:10")] + public async Task GreetingConsumerAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) + { + // Log the start of the function execution + _logger.LogInformation("[GreetingConsumer] Timer trigger function started."); + + // Ensure one-time initialization has run + _ = _initialized.Value; + + // Fast configuration validation using pre-loaded static values + if (!IsConfigurationValid()) + { + _logger.LogError("[GreetingConsumer] Configuration is invalid or not loaded. Aborting function execution."); + return; + } + + try + { + // Create Service Bus client for receiving messages from the output queue + _logger.LogInformation("[GreetingConsumer] Creating Service Bus client for receiving messages..."); + await using var client = _hasClientId && _hasFullyQualifiedNamespace + ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) + : new ServiceBusClient(_connectionString); + var receiver = client.CreateReceiver(_outputQueueName); + + _logger.LogInformation("[GreetingConsumer] Starting to receive messages from output queue '{outputQueue}'", _outputQueueName); + + // Loop to receive messages (with timeout to prevent infinite waiting) + var timeout = TimeSpan.FromSeconds(30); + var startTime = DateTime.UtcNow; + + try + { + while (DateTime.UtcNow - startTime < timeout) + { + try + { + // Receive message with a short timeout + var receivedMessage = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(5)); + + if (receivedMessage == null) + { + _logger.LogInformation("[GreetingConsumer] No more messages available in output queue '{outputQueue}'", _outputQueueName); + break; + } + + // Convert message body to string + var messageBody = receivedMessage.Body.ToString(); + + try + { + // Attempt to deserialize to ResponseMessage + var responseMessage = JsonSerializer.Deserialize(messageBody); + + if (responseMessage != null) + { + _logger.LogInformation("[GreetingConsumer] Successfully received and deserialized message from output queue. Date: {Date}, Text: {Text}", + responseMessage.Date, responseMessage.Text); + + // Complete the message after successful processing + await receiver.CompleteMessageAsync(receivedMessage); + + // Record received greeting in history + lock (_historyLock) + { + _consumerReceived.Add(responseMessage.Text); + } + } + else + { + _logger.LogWarning("[GreetingConsumer] Received message could not be deserialized to ResponseMessage (null result)"); + await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", "Message deserialized to null"); + } + } + catch (JsonException jsonEx) + { + _logger.LogError(jsonEx, "[GreetingConsumer] Failed to deserialize message from output queue. Message body: {messageBody}", messageBody); + await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", jsonEx.Message); + } + } + catch (Exception messageEx) + { + _logger.LogError(messageEx, "[GreetingConsumer] Error occurred while receiving message from output queue '{outputQueue}'", _outputQueueName); + // Continue the loop to try receiving more messages + } + } + } + finally + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await receiver.CloseAsync(cts.Token); + } + catch + { /* timeout or error on close */ } + try + { + await client.DisposeAsync(); + } + catch + { /* benign */ + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[GreetingConsumer] Failed to receive messages from output queue '{outputQueue}'", _outputQueueName); + } + + // Log the next scheduled timer occurrence + _logger.LogInformation("[GreetingConsumer] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); + } + + /// + /// Selects a random greeting template and formats it with the given name. + /// The generated greeting is also stored in a circular buffer for later retrieval. + /// + /// The name to include in the greeting. + /// A randomly chosen greeting string addressed to the specified name. + private static string GetGreeting(string name) + { + var template = _greetingTemplates[_random.Next(_greetingTemplates.Length)]; + var greeting = string.Format(template, name); + + lock (_historyLock) + { + _handlerSent.Add(greeting); + } + + return greeting; + } + + /// + /// HTTP-triggered function that returns the most recent greetings from the circular buffer. + /// Greetings are returned in reverse chronological order (newest first). + /// + /// The incoming HTTP request. + /// The number of greetings to return (default: 20, max: 100). + /// An HTTP response containing a JSON array of recent greetings. + [Function("GetGreetings")] + public async Task GetGreetingsAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "greetings")] HttpRequestData request, + int count = 20) + { + _logger.LogInformation("[GetGreetings] Retrieving last {count} entries.", count); + + // Clamp count to valid range + if (count < 1) count = 1; + if (count > MaxHistory) count = MaxHistory; + + object history; + lock (_historyLock) + { + history = new + { + requester = new + { + sent = _requesterSent.ToArray(count) + }, + handler = new + { + received = _handlerReceived.ToArray(count), + sent = _handlerSent.ToArray(count) + }, + consumer = new + { + received = _consumerReceived.ToArray(count) + } + }; + } + + var response = request.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "application/json"); + await response.WriteStringAsync(JsonSerializer.Serialize(history)); + return response; + } + + private sealed class CircularBuffer + { + private readonly string[] _items; + private int _index; + private int _count; + + public CircularBuffer(int capacity) => _items = new string[capacity]; + + public void Add(string item) + { + _items[_index] = item; + _index = (_index + 1) % _items.Length; + if (_count < _items.Length) _count++; + } + + public string[] ToArray(int count) + { + var available = Math.Min(count, _count); + var result = new string[available]; + for (int i = 0; i < available; i++) + { + var idx = (_index - available + i + _items.Length) % _items.Length; + result[i] = _items[idx]; + } + return result; + } + } +} + +/// +/// Represents the input payload for greeting requests. +/// +public class RequestMessage +{ + /// + /// Gets or sets the name to greet. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } +} + +/// +/// Represents the response payload for greeting requests. +/// +public class ResponseMessage +{ + /// + /// Gets or sets the date of the response message. + /// + [JsonPropertyName("date")] + public required string Date { get; set; } + + /// + /// Gets or sets the text of the response message. + /// + [JsonPropertyName("text")] + public required string Text { get; set; } } \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/src/Program.cs b/samples/function-app-service-bus/dotnet/src/Program.cs index 51336f3..c76837e 100644 --- a/samples/function-app-service-bus/dotnet/src/Program.cs +++ b/samples/function-app-service-bus/dotnet/src/Program.cs @@ -1,7 +1,7 @@ -using Microsoft.Extensions.Hosting; - -var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .Build(); - -host.Run(); +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + +host.Run(); diff --git a/samples/function-app-service-bus/dotnet/src/sample.csproj b/samples/function-app-service-bus/dotnet/src/sample.csproj index c691742..8b21d36 100644 --- a/samples/function-app-service-bus/dotnet/src/sample.csproj +++ b/samples/function-app-service-bus/dotnet/src/sample.csproj @@ -1,31 +1,31 @@ - - - net10.0 - v4 - Exe - enable - enable - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - Never - - - - - + + + net10.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + \ No newline at end of file diff --git a/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs b/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs index 9c500bb..ca1d8eb 100644 --- a/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs +++ b/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs @@ -1,778 +1,778 @@ -using System.Net; -using System.Text.Json; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Azure.Storage.Blobs; -using Azure.Storage.Queues; -using Azure.Storage.Queues.Models; -using Azure.Data.Tables; -using Azure; - -namespace LocalStack.Azure.Samples; - -/// -/// Manages game sessions and player interactions using Azure Functions with hybrid storage approach. -/// This class demonstrates a complete gaming scoreboard system with blob storage for game files, -/// queue processing for game events, table storage for winners, and internal dictionary for active game data. -/// The system processes GameStatusRequest/GameStatusResponse messages and maintains game state in memory -/// for improved performance while still utilizing Azure Storage services for persistence and messaging. -/// -public class GameSessionManager -{ - // Instance field for logging - keeps proper Azure Functions execution context - private readonly ILogger _logger; - - // Static configuration values - initialized once per application lifetime - private static string? _connectionString; - private static string? _inputQueueName; - private static string? _outputQueueName; - private static string? _triggerQueueName; - private static string? _inputContainerName; - private static string? _outputContainerName; - private static string? _inputTableName; - private static string? _outputTableName; - private static string[]? _playerNames; - private static bool _configurationValid; - private static int _gameId = 1; - - // Static dictionary to store game data in memory - game ID as key, list of player scores as value - // This replaces Azure Table Storage for demonstration purposes and provides faster access - private static readonly Dictionary> _gameData = new Dictionary>(); - private static readonly object _gameDataLock = new object(); - - // Static initialization - runs once per application lifetime - private static readonly Lazy _infrastructureInitialization = new Lazy(() => InitializeInfrastructureOnceAsync()); - - /// - /// Initializes a new instance of the class. - /// - /// The logger factory used to create loggers for this class. - public GameSessionManager(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } - - /// - /// Ensures that Azure infrastructure (queues, containers, tables) is initialized exactly once - /// during the application lifetime. This method is thread-safe and idempotent. - /// - /// A task that completes when infrastructure initialization is finished. - private async Task EnsureInfrastructureInitializedAsync() - { - await _infrastructureInitialization.Value; - } - - /// - /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). - /// This method runs exactly once per application lifetime and stores configuration values in static fields. - /// - /// A task representing the asynchronous initialization operation. - private static async Task InitializeInfrastructureOnceAsync() - { - try - { - // Create a temporary configuration instance for initialization - var configBuilder = new ConfigurationBuilder() - .AddEnvironmentVariables() - .AddJsonFile("local.settings.json", optional: true); - var config = configBuilder.Build(); - - // Create a temporary logger for initialization - using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - - logger.LogInformation("[InitializeInfrastructureOnceAsync] Starting one-time infrastructure initialization..."); - - // Read and store configuration values in static fields with fallback defaults - _connectionString = config["STORAGE_ACCOUNT_CONNECTION_STRING"]; - _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; - _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; - _triggerQueueName = config["TRIGGER_QUEUE_NAME"] ?? "trigger"; - _inputContainerName = config["INPUT_STORAGE_CONTAINER_NAME"] ?? "input"; - _outputContainerName = config["OUTPUT_STORAGE_CONTAINER_NAME"] ?? "output"; - _inputTableName = config["INPUT_TABLE_NAME"] ?? "scoreboards"; - _outputTableName = config["OUTPUT_TABLE_NAME"] ?? "winners"; - _playerNames = config["PLAYER_NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - // Check if player names ae configured. If not use, use default names - if (_playerNames == null || _playerNames.Length == 0) - { - logger.LogWarning("[InitializeInfrastructureOnceAsync] PLAYER_NAMES configuration is missing or empty. Using default names."); - _playerNames = new[] { "Alice", "Anastasia", "Paolo", "Leo", "Mia" }; - } - - // Validate configuration and set the flag - _configurationValid = ValidateConfigurationValues(logger); - - if (_configurationValid && _connectionString != null) - { - // Initialize all infrastructure components - await InitializeQueuesAsync(_connectionString, logger, new[] { _inputQueueName, _outputQueueName, _triggerQueueName }.Where(q => q != null).ToArray()!); - await InitializeContainersAsync(_connectionString, logger, new[] { _inputContainerName, _outputContainerName }.Where(c => c != null).ToArray()!); - await InitializeTablesAsync(_connectionString, logger, new[] { _inputTableName, _outputTableName }.Where(t => t != null).ToArray()!); - - logger.LogInformation("[InitializeInfrastructureOnceAsync] Infrastructure initialization completed successfully."); - } - else - { - logger.LogError("[InitializeInfrastructureOnceAsync] Configuration validation failed. Infrastructure initialization aborted."); - } - } - catch (Exception ex) - { - // Log error but don't throw - let functions continue to work even if initialization fails - Console.WriteLine("[InitializeInfrastructureOnceAsync] Failed to initialize infrastructure: {0}", ex.Message); - _configurationValid = false; - } - } - - /// - /// Validates that all required configuration values are present and not empty. - /// With default values in place, only the connection string is mandatory. - /// - /// Logger for reporting validation errors. - /// True if all configuration values are valid, false otherwise. - private static bool ValidateConfigurationValues(ILogger logger) - { - bool isValid = true; - - // Connection string is the only truly required value - everything else has defaults - if (string.IsNullOrWhiteSpace(_connectionString)) - { - logger.LogError("[ValidateConfigurationValues] STORAGE_ACCOUNT_CONNECTION_STRING configuration value is missing and is required."); - isValid = false; - } - - // Log the configuration values being used (helpful for debugging) - if (isValid) - { - logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); - logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); - logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); - logger.LogInformation(" - Trigger Queue: {triggerQueue}", _triggerQueueName); - logger.LogInformation(" - Input Container: {inputContainer}", _inputContainerName); - logger.LogInformation(" - Output Container: {outputContainer}", _outputContainerName); - logger.LogInformation(" - Input Table: {inputTable}", _inputTableName); - logger.LogInformation(" - Output Table: {outputTable}", _outputTableName); - } - - return isValid; - } - - /// - /// Checks if configuration values have been successfully loaded and validated. - /// This method provides a fast runtime check without re-reading configuration. - /// With default values, this primarily checks if the connection string is available. - /// - /// True if configuration is valid and available, false otherwise. - private static bool IsConfigurationValid() - { - // Since we have defaults for all values except connection string, - // we only need to check the configuration validation flag and connection string - return _configurationValid && !string.IsNullOrWhiteSpace(_connectionString); - } - - /// - /// Static version of queue initialization for one-time setup. - /// - private static async Task InitializeQueuesAsync(string connectionString, ILogger logger, string[] queues) - { - try - { - foreach (var queueName in queues.Where(q => !string.IsNullOrWhiteSpace(q))) - { - var queueClient = new QueueClient(connectionString, queueName); - await queueClient.CreateIfNotExistsAsync(); - logger.LogInformation("[InitializeQueuesAsync] Initialized queue: {queueName}", queueName); - } - } - catch (Exception ex) - { - logger.LogError(ex, "[InitializeQueuesAsync] Failed to initialize queues."); - } - } - - /// - /// Static version of container initialization for one-time setup. - /// - private static async Task InitializeContainersAsync(string connectionString, ILogger logger, string[] containers) - { - try - { - var blobServiceClient = new BlobServiceClient(connectionString); - foreach (var containerName in containers.Where(c => !string.IsNullOrWhiteSpace(c))) - { - var containerClient = blobServiceClient.GetBlobContainerClient(containerName); - await containerClient.CreateIfNotExistsAsync(); - logger.LogInformation("[InitializeContainersAsync] Initialized container: {containerName}", containerName); - } - } - catch (Exception ex) - { - logger.LogError(ex, "[InitializeContainersAsync] Failed to initialize containers."); - } - } - - /// - /// Static version of table initialization for one-time setup. - /// - private static async Task InitializeTablesAsync(string connectionString, ILogger logger, string[] tables) - { - try - { - foreach (var tableName in tables.Where(t => !string.IsNullOrWhiteSpace(t))) - { - var tableClient = new TableClient(connectionString, tableName); - await tableClient.CreateIfNotExistsAsync(); - logger.LogInformation("[InitializeTablesAsync] Initialized table: {tableName}", tableName); - } - } - catch (Exception ex) - { - logger.LogError(ex, "[InitializeTablesAsync] Failed to initialize tables."); - } - } - - /// - /// Handles HTTP GET requests to retrieve player score for a specific game and player. - /// - /// The HTTP request data. - /// The game ID to retrieve scores for, provided in the route. - /// The player name to retrieve status for, provided in the route. - /// An HTTP response with player score information or an error message. - [Function("GetPlayerScore")] - public async Task GetPlayerScoreAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "player/{gameId}/{name}/status")] HttpRequestData request, int gameId, string name) - { - HttpResponseData response; - - // Log the incoming request - _logger.LogInformation("[GetPlayerScore] Received GET request with gameId = {gameId}, name = {name}.", gameId, name ?? "NULL"); - - // Validate the name parameter - if (name == null || string.IsNullOrWhiteSpace(name)) - { - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("Invalid parameters: name parameter is required."); - return response; - } - - // Check if the game and player exist in the internal dictionary - PlayerScore? playerScore = null; - lock (_gameDataLock) - { - if (_gameData.TryGetValue(gameId, out var players)) - { - playerScore = players.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - } - } - - if (playerScore == null) - { - response = request.CreateResponse(HttpStatusCode.NotFound); - await response.WriteStringAsync($"Game {gameId} and player '{name}' tuple not found."); - return response; - } - - // Return the player score information - response = request.CreateResponse(HttpStatusCode.OK); - - // Create the response message - var outputObj = new PlayerScoreResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = gameId, - Name = playerScore.Name, - Score = playerScore.Score - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Write the response message to the HTTP response - await response.WriteStringAsync(outputMessage); - - // Log the successful processing - _logger.LogInformation("[GetPlayerScore] Processed request successfully with gameId = {gameId}, name = {name}, score = {score}.", gameId, name, playerScore.Score); - - // Return the HTTP response - return response; - } - - /// - /// Handles HTTP POST and PUT requests to retrieve game status information. - /// Accepts a GameStatusRequest in the request body and returns comprehensive game status - /// including winner determination and all player scores for the specified game. - /// - /// The HTTP request data containing a JSON-serialized GameStatusRequest. - /// An HTTP response with GameStatusResponse containing game status details, winner, and all players, or an error message if the game is not found or invalid. - [Function("CreateGameStatus")] - public async Task CreateGameStatusAsync([HttpTrigger(AuthorizationLevel.Function, "post", "put", Route = "game/session")] HttpRequestData request) - { - HttpResponseData response; - - // Log the incoming request method - _logger.LogInformation("[CreateGameStatus] Received {method} request.", request.Method); - - // Read the request body as a string - var requestBody = await request.ReadAsStringAsync(); - - // Validate that the request body is not empty - if (requestBody == null || string.IsNullOrWhiteSpace(requestBody)) - { - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is required."); - return response; - } - - GameStatusRequest? requestMessage; - try - { - // Attempt to deserialize the request body into a GameStatusRequest object - requestMessage = JsonSerializer.Deserialize(requestBody); - } - catch (JsonException) - { - // Handle invalid JSON format - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is not in the proper format."); - return response; - } - - // Validate that the GameId property is present and valid - if (requestMessage == null || requestMessage.GameId <= 0) - { - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("[CreateGameStatus] Invalid request message: 'GameId' is required and must be greater than 0."); - return response; - } - - // Check if the game exists in the internal dictionary - List? players = null; - bool gameFound = false; - lock (_gameDataLock) - { - gameFound = _gameData.TryGetValue(requestMessage.GameId, out players); - } - - if (!gameFound || players == null) - { - _logger.LogWarning("[CreateGameStatus] Game {gameId} not found in internal data store.", requestMessage.GameId); - response = request.CreateResponse(HttpStatusCode.NotFound); - await response.WriteStringAsync($"Game {requestMessage.GameId} not found."); - return response; - } - - if (players.Count == 0) - { - _logger.LogWarning("[CreateGameStatus] Game {gameId} has no player data.", requestMessage.GameId); - response = request.CreateResponse(HttpStatusCode.NotFound); - await response.WriteStringAsync($"Game {requestMessage.GameId} has no player data."); - return response; - } - - // Find the winner (player with highest score) - var winner = players.OrderByDescending(p => p.Score).First(); - - // Return a response if the request message is valid - response = request.CreateResponse(HttpStatusCode.OK); - - // Create the response message - var outputObj = new GameStatusResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = requestMessage.GameId, - Winner = winner.Name, - Players = new List(players) // Create a copy of the list - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Write the response message to the HTTP response - await response.WriteStringAsync(outputMessage); - - // Log the successful processing - _logger.LogInformation("[CreateGameStatus] Processed request successfully with gameId = {gameId}, winner = {winner}.", requestMessage.GameId, winner.Name); - - // Return the HTTP response - return response; - } - - /// - /// Processes uploaded game status files from blob storage and generates comprehensive game status responses. - /// Deserializes GameStatusRequest from blob content, retrieves game data from internal dictionary, - /// determines the winner, and returns a GameStatusResponse with complete game information. - /// - /// The blob content as byte array containing JSON-serialized GameStatusRequest. - /// The name of the blob file being processed. - /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players, or null if the input is invalid or game not found. - [Function("ProcessGameFile")] - [BlobOutput("%OUTPUT_STORAGE_CONTAINER_NAME%/{name}")] - public string? ProcessGameFile( - [BlobTrigger("%INPUT_STORAGE_CONTAINER_NAME%/{name}", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] byte[] blobBytes, - string name) - { - // Check that the blobBytes is not null or empty - if (blobBytes == null || blobBytes.Length == 0) - { - _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or null.", name); - return null; - } - - // Convert the byte array to a string - string json = System.Text.Encoding.UTF8.GetString(blobBytes); - - // Check that the JSON is not null or empty - if (string.IsNullOrEmpty(json)) - { - _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or invalid.", name); - return null; - } - - // Deserialize the JSON into a GameStatusRequest object - GameStatusRequest? gameStatusRequest = JsonSerializer.Deserialize(json); - - // Check that the request message is not null - if (gameStatusRequest == null) - { - _logger.LogError("[ProcessGameFile] Received [{name}] blob contains invalid GameStatusRequest.", name); - return null; - } - - // Check if the game exists in the internal dictionary - List? players = null; - lock (_gameDataLock) - { - if (!_gameData.TryGetValue(gameStatusRequest.GameId, out players)) - { - _logger.LogWarning("[ProcessGameFile] Game {gameId} not found in internal data store.", gameStatusRequest.GameId); - return null; - } - } - - if (players == null || players.Count == 0) - { - _logger.LogWarning("[ProcessGameFile] Game {gameId} has no player data.", gameStatusRequest.GameId); - return null; - } - - // Find the winner (player with highest score) - var winner = players.OrderByDescending(p => p.Score).First(); - - // Create the response message - var outputObj = new GameStatusResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = gameStatusRequest.GameId, - Winner = winner.Name, - Players = new List(players) // Create a copy of the list - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Log the successful processing of the blob - _logger.LogInformation("[ProcessGameFile] Processed blob [{name}] successfully for game {gameId}.", name, gameStatusRequest.GameId); - - // Return the response message - return outputMessage; - } - - /// - /// Handles game events from Azure Storage Queue and processes game status requests. - /// Deserializes GameStatusRequest from queue messages, retrieves game data from internal dictionary, - /// determines the winner, and returns a GameStatusResponse for further processing in the output queue. - /// - /// The incoming queue message containing JSON-serialized GameStatusRequest data. - /// The function execution context provided by the Azure Functions runtime. - /// - /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players for output queue processing, or null if the input is invalid or game not found. - /// - [Function("HandleGameEvent")] - [QueueOutput("%OUTPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] - public string? HandleGameEvent([QueueTrigger("%INPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] QueueMessage message, FunctionContext context) - { - - // Check that the message and the body are not null or empty - if (message == null || string.IsNullOrWhiteSpace(message.Body?.ToString())) - { - _logger.LogError("[HandleGameEvent] Received queue message is null or empty."); - return null; - } - - var json = message.Body.ToString() ?? string.Empty; - - // Check that the JSON is not null or empty - if (string.IsNullOrEmpty(json)) - { - _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message is empty or invalid.", message.MessageId); - return null; - } - - // Deserialize the JSON into a GameStatusRequest object - GameStatusRequest? requestMessage = JsonSerializer.Deserialize(json); - - // Check that the request message is not null - if (requestMessage == null) - { - _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message contains invalid GameStatusRequest.", message.MessageId); - return null; - } - - // Check if the game exists in the internal dictionary - List? players = null; - lock (_gameDataLock) - { - if (!_gameData.TryGetValue(requestMessage.GameId, out players)) - { - _logger.LogWarning("[HandleGameEvent] Game {gameId} not found in internal data store.", requestMessage.GameId); - // Return null for now, but could create an error response if needed - return null; - } - } - - if (players == null || players.Count == 0) - { - _logger.LogWarning("[HandleGameEvent] Game {gameId} has no player data.", requestMessage.GameId); - return null; - } - - // Find the winner (player with highest score) - var winner = players.OrderByDescending(p => p.Score).First(); - - // Create the response message - var outputObj = new GameStatusResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = requestMessage.GameId, - Winner = winner.Name, - Players = new List(players) // Create a copy of the list - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Log the successful processing of the queue message - _logger.LogInformation("[HandleGameEvent] Processed queue message [{messageId}] successfully for game {gameId}.", message.MessageId, requestMessage.GameId); - - // Return the response message - return outputMessage; - - } - - /// - /// Processes game scoreboards to determine winners and manage game results. - /// Retrieves scoreboard entries for a game, finds the highest score, and records the winner. - /// - /// The game ID from the queue message to filter scoreboard entries. - /// The scoreboard entities matching the specified game ID. - /// The winning ScoreboardEntity with the highest score, or null if no entities are found. - [Function("ProcessScoreboard")] - [TableOutput("%OUTPUT_TABLE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] - public ScoreboardEntity? ProcessScoreboard( - [QueueTrigger("%TRIGGER_QUEUE_NAME%")] string gameId, - [TableInput("%INPUT_TABLE_NAME%", "{queueTrigger}", - Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] IEnumerable entities) - { - // Find the entity with the highest score - var winner = entities.OrderByDescending(e => e.Score).FirstOrDefault(); - _logger.LogInformation("[ProcessScoreboard] Processed game ID {gameId}. Winner: {winnerName} with score {score}.", gameId, winner?.PlayerName ?? "No winner", winner?.Score.ToString() ?? "N/A"); - - if (winner != null) - { - // Create a new entity for the output table with a new RowKey - var winnerEntity = new ScoreboardEntity - { - PartitionKey = $"winner-game-{int.Parse(gameId):D3}", - RowKey = Guid.NewGuid().ToString(), - GameId = winner.GameId, - PlayerName = winner.PlayerName, - Score = winner.Score, - Timestamp = DateTimeOffset.UtcNow, - ETag = ETag.All - }; - - return winnerEntity; - } - - return null; - } - - /// - /// Timer-triggered function that generates new game rounds with random player data and initiates the complete gaming workflow. - /// Creates GameStatusRequest objects, uploads them as blobs, sends queue messages, creates internal dictionary entries - /// with random player scores, and triggers the scoreboard processing pipeline. Runs every minute and on startup. - /// - /// Timer metadata containing schedule status and next occurrence information. - /// A task that represents the asynchronous game round generation operation. - [Function("CreateGame")] - [FixedDelayRetry(5, "00:00:10")] - public async Task CreateGameAsync([TimerTrigger("0 */1 * * * *", RunOnStartup = true)] TimerInfo timerInfo) - { - _logger.LogInformation("[CreateGameAsync] Triggered execution."); - - // Ensure infrastructure is initialized (runs only once per app lifetime) - await EnsureInfrastructureInitializedAsync(); - - // Fast configuration validation using pre-loaded static values - if (!IsConfigurationValid()) - { - _logger.LogError("[CreateGameAsync] Configuration is invalid or not loaded. Aborting function execution."); - return; - } - - if (_playerNames == null || _playerNames.Length == 0) - { - _logger.LogError("[CreateGameAsync] Player names are not configured. Aborting function execution."); - return; - } - - var random = new Random(); - var gameStatusRequest = new GameStatusRequest { GameId = _gameId }; - - // Serialize the request message to JSON - var message = JsonSerializer.Serialize(gameStatusRequest); - - // Log the generated message and configuration values - _logger.LogInformation("[CreateGameAsync] Generated message: {message}", message); - - // Create a unique blob name with the required format - var now = DateTime.UtcNow; - var blobFileName = $"game-{_gameId:D3}-status-{now:yyyy-MM-dd-HH-mm-ss}.json"; - - // Create scoreboard entries for all players using the updated method - await CreateScoreboardEntriesAsync(_connectionString, _inputTableName); - - // Upload blob to the input container - await UploadBlobAsync(_connectionString, _inputContainerName, blobFileName, message); - - // Send message to the input queue - await SendQueueMessageAsync(_connectionString, _inputQueueName, message); - - // Send message to the trigger queue - await SendQueueMessageAsync(_connectionString, _triggerQueueName, _gameId.ToString()); - - // Increment game ID for next execution - _gameId++; - - // Log the next scheduled timer occurrence - _logger.LogInformation("[CreateGameAsync] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); - } - - /// - /// Uploads a message as a blob to the specified Azure Storage container. - /// - /// The storage account connection string. - /// The name of the container to upload to. - /// The name of the blob file to create. - /// The message content to upload as blob data. - /// A task that represents the asynchronous upload operation. - private async Task UploadBlobAsync(string? connectionString, string? inputContainerName, string blobFileName, string message) - { - try - { - var blobServiceClient = new BlobServiceClient(connectionString); - var blobContainerClient = blobServiceClient.GetBlobContainerClient(inputContainerName); - - await blobContainerClient.CreateIfNotExistsAsync(); - - var blobClient = blobContainerClient.GetBlobClient(blobFileName); - - using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(message))) - { - await blobClient.UploadAsync(stream, overwrite: true); - } - _logger.LogInformation("[UploadBlobAsync] Uploaded blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); - } - catch (Exception ex) - { - _logger.LogError(ex, "[UploadBlobAsync] Failed to upload blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); - } - } - - /// - /// Sends a message to the specified Azure Storage queue. - /// - /// The storage account connection string. - /// The name of the queue to send the message to. - /// The message content to send (will be Base64 encoded). - /// A task that represents the asynchronous send operation. - private async Task SendQueueMessageAsync(string? connectionString, string? queueName, string message) - { - try - { - var queueClient = new QueueClient(connectionString, queueName); - - await queueClient.CreateIfNotExistsAsync(); - - await queueClient.SendMessageAsync(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(message))); - _logger.LogInformation("[SendQueueMessageAsync] Sent message to queue: {queueName}", queueName); - } - catch (Exception ex) - { - _logger.LogError(ex, "[SendQueueMessageAsync] Failed to send message to queue: {queueName}", queueName); - } - } - - /// - /// Creates scoreboard entries for each player with random scores and stores them in the internal dictionary. - /// This method replaces Azure Table Storage operations by maintaining game data in memory using a thread-safe - /// Dictionary structure. Each game is stored with its unique game ID and contains all player scores. - /// - /// The storage account connection string. - /// The name of the table to store the scoreboard entries in. - /// A task that represents the asynchronous operation of creating and storing player scores. - private async Task CreateScoreboardEntriesAsync(string? connectionString, string? tableName) - { - try - { - var random = new Random(); - var playerScores = new List(); - var tableClient = new TableClient(connectionString, tableName); - await tableClient.CreateIfNotExistsAsync(); - var partitionKey = _gameId.ToString(); - - if (_playerNames == null || _playerNames.Length == 0) - { - _logger.LogWarning("[CreateScoreboardEntriesAsync] No player names configured. Skipping scoreboard entry creation."); - return; - } - - foreach (var name in _playerNames) - { - var score = Math.Max(0, random.Next(0, 101)); // Random number between 0 and 100, ensure >= 0 - var playerScore = new PlayerScore - { - Name = name, - Score = score - }; - - playerScores.Add(playerScore); - - var entity = new ScoreboardEntity - { - PartitionKey = partitionKey, - RowKey = Guid.NewGuid().ToString(), - GameId = _gameId, - PlayerName = name, - Score = score, - Timestamp = DateTimeOffset.UtcNow, - ETag = ETag.All - }; - - await tableClient.AddEntityAsync(entity); - - _logger.LogInformation("[CreateScoreboardEntriesAsync] Added scoreboard entry for {playerName} with score {score} in game {gameId}", name, score, _gameId); - } - - // Store the game data in the internal dictionary (thread-safe) - lock (_gameDataLock) - { - _gameData[_gameId] = playerScores; - } - - _logger.LogInformation("[CreateScoreboardEntriesAsync] Created {playerCount} scoreboard entries for game {gameId}.", _playerNames.Length, _gameId); - - // Simulate async work to maintain the async signature - await Task.CompletedTask; - } - catch (Exception ex) - { - _logger.LogError(ex, "[CreateScoreboardEntriesAsync] Failed to create scoreboard entries for game {gameId}", _gameId); - } - } +using System.Net; +using System.Text.Json; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using Azure.Storage.Blobs; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using Azure.Data.Tables; +using Azure; + +namespace LocalStack.Azure.Samples; + +/// +/// Manages game sessions and player interactions using Azure Functions with hybrid storage approach. +/// This class demonstrates a complete gaming scoreboard system with blob storage for game files, +/// queue processing for game events, table storage for winners, and internal dictionary for active game data. +/// The system processes GameStatusRequest/GameStatusResponse messages and maintains game state in memory +/// for improved performance while still utilizing Azure Storage services for persistence and messaging. +/// +public class GameSessionManager +{ + // Instance field for logging - keeps proper Azure Functions execution context + private readonly ILogger _logger; + + // Static configuration values - initialized once per application lifetime + private static string? _connectionString; + private static string? _inputQueueName; + private static string? _outputQueueName; + private static string? _triggerQueueName; + private static string? _inputContainerName; + private static string? _outputContainerName; + private static string? _inputTableName; + private static string? _outputTableName; + private static string[]? _playerNames; + private static bool _configurationValid; + private static int _gameId = 1; + + // Static dictionary to store game data in memory - game ID as key, list of player scores as value + // This replaces Azure Table Storage for demonstration purposes and provides faster access + private static readonly Dictionary> _gameData = new Dictionary>(); + private static readonly object _gameDataLock = new object(); + + // Static initialization - runs once per application lifetime + private static readonly Lazy _infrastructureInitialization = new Lazy(() => InitializeInfrastructureOnceAsync()); + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory used to create loggers for this class. + public GameSessionManager(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + /// + /// Ensures that Azure infrastructure (queues, containers, tables) is initialized exactly once + /// during the application lifetime. This method is thread-safe and idempotent. + /// + /// A task that completes when infrastructure initialization is finished. + private async Task EnsureInfrastructureInitializedAsync() + { + await _infrastructureInitialization.Value; + } + + /// + /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). + /// This method runs exactly once per application lifetime and stores configuration values in static fields. + /// + /// A task representing the asynchronous initialization operation. + private static async Task InitializeInfrastructureOnceAsync() + { + try + { + // Create a temporary configuration instance for initialization + var configBuilder = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddJsonFile("local.settings.json", optional: true); + var config = configBuilder.Build(); + + // Create a temporary logger for initialization + using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var logger = loggerFactory.CreateLogger(); + + logger.LogInformation("[InitializeInfrastructureOnceAsync] Starting one-time infrastructure initialization..."); + + // Read and store configuration values in static fields with fallback defaults + _connectionString = config["STORAGE_ACCOUNT_CONNECTION_STRING"]; + _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; + _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; + _triggerQueueName = config["TRIGGER_QUEUE_NAME"] ?? "trigger"; + _inputContainerName = config["INPUT_STORAGE_CONTAINER_NAME"] ?? "input"; + _outputContainerName = config["OUTPUT_STORAGE_CONTAINER_NAME"] ?? "output"; + _inputTableName = config["INPUT_TABLE_NAME"] ?? "scoreboards"; + _outputTableName = config["OUTPUT_TABLE_NAME"] ?? "winners"; + _playerNames = config["PLAYER_NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // Check if player names ae configured. If not use, use default names + if (_playerNames == null || _playerNames.Length == 0) + { + logger.LogWarning("[InitializeInfrastructureOnceAsync] PLAYER_NAMES configuration is missing or empty. Using default names."); + _playerNames = new[] { "Alice", "Anastasia", "Paolo", "Leo", "Mia" }; + } + + // Validate configuration and set the flag + _configurationValid = ValidateConfigurationValues(logger); + + if (_configurationValid && _connectionString != null) + { + // Initialize all infrastructure components + await InitializeQueuesAsync(_connectionString, logger, new[] { _inputQueueName, _outputQueueName, _triggerQueueName }.Where(q => q != null).ToArray()!); + await InitializeContainersAsync(_connectionString, logger, new[] { _inputContainerName, _outputContainerName }.Where(c => c != null).ToArray()!); + await InitializeTablesAsync(_connectionString, logger, new[] { _inputTableName, _outputTableName }.Where(t => t != null).ToArray()!); + + logger.LogInformation("[InitializeInfrastructureOnceAsync] Infrastructure initialization completed successfully."); + } + else + { + logger.LogError("[InitializeInfrastructureOnceAsync] Configuration validation failed. Infrastructure initialization aborted."); + } + } + catch (Exception ex) + { + // Log error but don't throw - let functions continue to work even if initialization fails + Console.WriteLine("[InitializeInfrastructureOnceAsync] Failed to initialize infrastructure: {0}", ex.Message); + _configurationValid = false; + } + } + + /// + /// Validates that all required configuration values are present and not empty. + /// With default values in place, only the connection string is mandatory. + /// + /// Logger for reporting validation errors. + /// True if all configuration values are valid, false otherwise. + private static bool ValidateConfigurationValues(ILogger logger) + { + bool isValid = true; + + // Connection string is the only truly required value - everything else has defaults + if (string.IsNullOrWhiteSpace(_connectionString)) + { + logger.LogError("[ValidateConfigurationValues] STORAGE_ACCOUNT_CONNECTION_STRING configuration value is missing and is required."); + isValid = false; + } + + // Log the configuration values being used (helpful for debugging) + if (isValid) + { + logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); + logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); + logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); + logger.LogInformation(" - Trigger Queue: {triggerQueue}", _triggerQueueName); + logger.LogInformation(" - Input Container: {inputContainer}", _inputContainerName); + logger.LogInformation(" - Output Container: {outputContainer}", _outputContainerName); + logger.LogInformation(" - Input Table: {inputTable}", _inputTableName); + logger.LogInformation(" - Output Table: {outputTable}", _outputTableName); + } + + return isValid; + } + + /// + /// Checks if configuration values have been successfully loaded and validated. + /// This method provides a fast runtime check without re-reading configuration. + /// With default values, this primarily checks if the connection string is available. + /// + /// True if configuration is valid and available, false otherwise. + private static bool IsConfigurationValid() + { + // Since we have defaults for all values except connection string, + // we only need to check the configuration validation flag and connection string + return _configurationValid && !string.IsNullOrWhiteSpace(_connectionString); + } + + /// + /// Static version of queue initialization for one-time setup. + /// + private static async Task InitializeQueuesAsync(string connectionString, ILogger logger, string[] queues) + { + try + { + foreach (var queueName in queues.Where(q => !string.IsNullOrWhiteSpace(q))) + { + var queueClient = new QueueClient(connectionString, queueName); + await queueClient.CreateIfNotExistsAsync(); + logger.LogInformation("[InitializeQueuesAsync] Initialized queue: {queueName}", queueName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[InitializeQueuesAsync] Failed to initialize queues."); + } + } + + /// + /// Static version of container initialization for one-time setup. + /// + private static async Task InitializeContainersAsync(string connectionString, ILogger logger, string[] containers) + { + try + { + var blobServiceClient = new BlobServiceClient(connectionString); + foreach (var containerName in containers.Where(c => !string.IsNullOrWhiteSpace(c))) + { + var containerClient = blobServiceClient.GetBlobContainerClient(containerName); + await containerClient.CreateIfNotExistsAsync(); + logger.LogInformation("[InitializeContainersAsync] Initialized container: {containerName}", containerName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[InitializeContainersAsync] Failed to initialize containers."); + } + } + + /// + /// Static version of table initialization for one-time setup. + /// + private static async Task InitializeTablesAsync(string connectionString, ILogger logger, string[] tables) + { + try + { + foreach (var tableName in tables.Where(t => !string.IsNullOrWhiteSpace(t))) + { + var tableClient = new TableClient(connectionString, tableName); + await tableClient.CreateIfNotExistsAsync(); + logger.LogInformation("[InitializeTablesAsync] Initialized table: {tableName}", tableName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[InitializeTablesAsync] Failed to initialize tables."); + } + } + + /// + /// Handles HTTP GET requests to retrieve player score for a specific game and player. + /// + /// The HTTP request data. + /// The game ID to retrieve scores for, provided in the route. + /// The player name to retrieve status for, provided in the route. + /// An HTTP response with player score information or an error message. + [Function("GetPlayerScore")] + public async Task GetPlayerScoreAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "player/{gameId}/{name}/status")] HttpRequestData request, int gameId, string name) + { + HttpResponseData response; + + // Log the incoming request + _logger.LogInformation("[GetPlayerScore] Received GET request with gameId = {gameId}, name = {name}.", gameId, name ?? "NULL"); + + // Validate the name parameter + if (name == null || string.IsNullOrWhiteSpace(name)) + { + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("Invalid parameters: name parameter is required."); + return response; + } + + // Check if the game and player exist in the internal dictionary + PlayerScore? playerScore = null; + lock (_gameDataLock) + { + if (_gameData.TryGetValue(gameId, out var players)) + { + playerScore = players.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + } + + if (playerScore == null) + { + response = request.CreateResponse(HttpStatusCode.NotFound); + await response.WriteStringAsync($"Game {gameId} and player '{name}' tuple not found."); + return response; + } + + // Return the player score information + response = request.CreateResponse(HttpStatusCode.OK); + + // Create the response message + var outputObj = new PlayerScoreResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = gameId, + Name = playerScore.Name, + Score = playerScore.Score + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Write the response message to the HTTP response + await response.WriteStringAsync(outputMessage); + + // Log the successful processing + _logger.LogInformation("[GetPlayerScore] Processed request successfully with gameId = {gameId}, name = {name}, score = {score}.", gameId, name, playerScore.Score); + + // Return the HTTP response + return response; + } + + /// + /// Handles HTTP POST and PUT requests to retrieve game status information. + /// Accepts a GameStatusRequest in the request body and returns comprehensive game status + /// including winner determination and all player scores for the specified game. + /// + /// The HTTP request data containing a JSON-serialized GameStatusRequest. + /// An HTTP response with GameStatusResponse containing game status details, winner, and all players, or an error message if the game is not found or invalid. + [Function("CreateGameStatus")] + public async Task CreateGameStatusAsync([HttpTrigger(AuthorizationLevel.Function, "post", "put", Route = "game/session")] HttpRequestData request) + { + HttpResponseData response; + + // Log the incoming request method + _logger.LogInformation("[CreateGameStatus] Received {method} request.", request.Method); + + // Read the request body as a string + var requestBody = await request.ReadAsStringAsync(); + + // Validate that the request body is not empty + if (requestBody == null || string.IsNullOrWhiteSpace(requestBody)) + { + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is required."); + return response; + } + + GameStatusRequest? requestMessage; + try + { + // Attempt to deserialize the request body into a GameStatusRequest object + requestMessage = JsonSerializer.Deserialize(requestBody); + } + catch (JsonException) + { + // Handle invalid JSON format + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is not in the proper format."); + return response; + } + + // Validate that the GameId property is present and valid + if (requestMessage == null || requestMessage.GameId <= 0) + { + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("[CreateGameStatus] Invalid request message: 'GameId' is required and must be greater than 0."); + return response; + } + + // Check if the game exists in the internal dictionary + List? players = null; + bool gameFound = false; + lock (_gameDataLock) + { + gameFound = _gameData.TryGetValue(requestMessage.GameId, out players); + } + + if (!gameFound || players == null) + { + _logger.LogWarning("[CreateGameStatus] Game {gameId} not found in internal data store.", requestMessage.GameId); + response = request.CreateResponse(HttpStatusCode.NotFound); + await response.WriteStringAsync($"Game {requestMessage.GameId} not found."); + return response; + } + + if (players.Count == 0) + { + _logger.LogWarning("[CreateGameStatus] Game {gameId} has no player data.", requestMessage.GameId); + response = request.CreateResponse(HttpStatusCode.NotFound); + await response.WriteStringAsync($"Game {requestMessage.GameId} has no player data."); + return response; + } + + // Find the winner (player with highest score) + var winner = players.OrderByDescending(p => p.Score).First(); + + // Return a response if the request message is valid + response = request.CreateResponse(HttpStatusCode.OK); + + // Create the response message + var outputObj = new GameStatusResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = requestMessage.GameId, + Winner = winner.Name, + Players = new List(players) // Create a copy of the list + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Write the response message to the HTTP response + await response.WriteStringAsync(outputMessage); + + // Log the successful processing + _logger.LogInformation("[CreateGameStatus] Processed request successfully with gameId = {gameId}, winner = {winner}.", requestMessage.GameId, winner.Name); + + // Return the HTTP response + return response; + } + + /// + /// Processes uploaded game status files from blob storage and generates comprehensive game status responses. + /// Deserializes GameStatusRequest from blob content, retrieves game data from internal dictionary, + /// determines the winner, and returns a GameStatusResponse with complete game information. + /// + /// The blob content as byte array containing JSON-serialized GameStatusRequest. + /// The name of the blob file being processed. + /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players, or null if the input is invalid or game not found. + [Function("ProcessGameFile")] + [BlobOutput("%OUTPUT_STORAGE_CONTAINER_NAME%/{name}")] + public string? ProcessGameFile( + [BlobTrigger("%INPUT_STORAGE_CONTAINER_NAME%/{name}", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] byte[] blobBytes, + string name) + { + // Check that the blobBytes is not null or empty + if (blobBytes == null || blobBytes.Length == 0) + { + _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or null.", name); + return null; + } + + // Convert the byte array to a string + string json = System.Text.Encoding.UTF8.GetString(blobBytes); + + // Check that the JSON is not null or empty + if (string.IsNullOrEmpty(json)) + { + _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or invalid.", name); + return null; + } + + // Deserialize the JSON into a GameStatusRequest object + GameStatusRequest? gameStatusRequest = JsonSerializer.Deserialize(json); + + // Check that the request message is not null + if (gameStatusRequest == null) + { + _logger.LogError("[ProcessGameFile] Received [{name}] blob contains invalid GameStatusRequest.", name); + return null; + } + + // Check if the game exists in the internal dictionary + List? players = null; + lock (_gameDataLock) + { + if (!_gameData.TryGetValue(gameStatusRequest.GameId, out players)) + { + _logger.LogWarning("[ProcessGameFile] Game {gameId} not found in internal data store.", gameStatusRequest.GameId); + return null; + } + } + + if (players == null || players.Count == 0) + { + _logger.LogWarning("[ProcessGameFile] Game {gameId} has no player data.", gameStatusRequest.GameId); + return null; + } + + // Find the winner (player with highest score) + var winner = players.OrderByDescending(p => p.Score).First(); + + // Create the response message + var outputObj = new GameStatusResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = gameStatusRequest.GameId, + Winner = winner.Name, + Players = new List(players) // Create a copy of the list + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Log the successful processing of the blob + _logger.LogInformation("[ProcessGameFile] Processed blob [{name}] successfully for game {gameId}.", name, gameStatusRequest.GameId); + + // Return the response message + return outputMessage; + } + + /// + /// Handles game events from Azure Storage Queue and processes game status requests. + /// Deserializes GameStatusRequest from queue messages, retrieves game data from internal dictionary, + /// determines the winner, and returns a GameStatusResponse for further processing in the output queue. + /// + /// The incoming queue message containing JSON-serialized GameStatusRequest data. + /// The function execution context provided by the Azure Functions runtime. + /// + /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players for output queue processing, or null if the input is invalid or game not found. + /// + [Function("HandleGameEvent")] + [QueueOutput("%OUTPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] + public string? HandleGameEvent([QueueTrigger("%INPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] QueueMessage message, FunctionContext context) + { + + // Check that the message and the body are not null or empty + if (message == null || string.IsNullOrWhiteSpace(message.Body?.ToString())) + { + _logger.LogError("[HandleGameEvent] Received queue message is null or empty."); + return null; + } + + var json = message.Body.ToString() ?? string.Empty; + + // Check that the JSON is not null or empty + if (string.IsNullOrEmpty(json)) + { + _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message is empty or invalid.", message.MessageId); + return null; + } + + // Deserialize the JSON into a GameStatusRequest object + GameStatusRequest? requestMessage = JsonSerializer.Deserialize(json); + + // Check that the request message is not null + if (requestMessage == null) + { + _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message contains invalid GameStatusRequest.", message.MessageId); + return null; + } + + // Check if the game exists in the internal dictionary + List? players = null; + lock (_gameDataLock) + { + if (!_gameData.TryGetValue(requestMessage.GameId, out players)) + { + _logger.LogWarning("[HandleGameEvent] Game {gameId} not found in internal data store.", requestMessage.GameId); + // Return null for now, but could create an error response if needed + return null; + } + } + + if (players == null || players.Count == 0) + { + _logger.LogWarning("[HandleGameEvent] Game {gameId} has no player data.", requestMessage.GameId); + return null; + } + + // Find the winner (player with highest score) + var winner = players.OrderByDescending(p => p.Score).First(); + + // Create the response message + var outputObj = new GameStatusResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = requestMessage.GameId, + Winner = winner.Name, + Players = new List(players) // Create a copy of the list + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Log the successful processing of the queue message + _logger.LogInformation("[HandleGameEvent] Processed queue message [{messageId}] successfully for game {gameId}.", message.MessageId, requestMessage.GameId); + + // Return the response message + return outputMessage; + + } + + /// + /// Processes game scoreboards to determine winners and manage game results. + /// Retrieves scoreboard entries for a game, finds the highest score, and records the winner. + /// + /// The game ID from the queue message to filter scoreboard entries. + /// The scoreboard entities matching the specified game ID. + /// The winning ScoreboardEntity with the highest score, or null if no entities are found. + [Function("ProcessScoreboard")] + [TableOutput("%OUTPUT_TABLE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] + public ScoreboardEntity? ProcessScoreboard( + [QueueTrigger("%TRIGGER_QUEUE_NAME%")] string gameId, + [TableInput("%INPUT_TABLE_NAME%", "{queueTrigger}", + Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] IEnumerable entities) + { + // Find the entity with the highest score + var winner = entities.OrderByDescending(e => e.Score).FirstOrDefault(); + _logger.LogInformation("[ProcessScoreboard] Processed game ID {gameId}. Winner: {winnerName} with score {score}.", gameId, winner?.PlayerName ?? "No winner", winner?.Score.ToString() ?? "N/A"); + + if (winner != null) + { + // Create a new entity for the output table with a new RowKey + var winnerEntity = new ScoreboardEntity + { + PartitionKey = $"winner-game-{int.Parse(gameId):D3}", + RowKey = Guid.NewGuid().ToString(), + GameId = winner.GameId, + PlayerName = winner.PlayerName, + Score = winner.Score, + Timestamp = DateTimeOffset.UtcNow, + ETag = ETag.All + }; + + return winnerEntity; + } + + return null; + } + + /// + /// Timer-triggered function that generates new game rounds with random player data and initiates the complete gaming workflow. + /// Creates GameStatusRequest objects, uploads them as blobs, sends queue messages, creates internal dictionary entries + /// with random player scores, and triggers the scoreboard processing pipeline. Runs every minute and on startup. + /// + /// Timer metadata containing schedule status and next occurrence information. + /// A task that represents the asynchronous game round generation operation. + [Function("CreateGame")] + [FixedDelayRetry(5, "00:00:10")] + public async Task CreateGameAsync([TimerTrigger("0 */1 * * * *", RunOnStartup = true)] TimerInfo timerInfo) + { + _logger.LogInformation("[CreateGameAsync] Triggered execution."); + + // Ensure infrastructure is initialized (runs only once per app lifetime) + await EnsureInfrastructureInitializedAsync(); + + // Fast configuration validation using pre-loaded static values + if (!IsConfigurationValid()) + { + _logger.LogError("[CreateGameAsync] Configuration is invalid or not loaded. Aborting function execution."); + return; + } + + if (_playerNames == null || _playerNames.Length == 0) + { + _logger.LogError("[CreateGameAsync] Player names are not configured. Aborting function execution."); + return; + } + + var random = new Random(); + var gameStatusRequest = new GameStatusRequest { GameId = _gameId }; + + // Serialize the request message to JSON + var message = JsonSerializer.Serialize(gameStatusRequest); + + // Log the generated message and configuration values + _logger.LogInformation("[CreateGameAsync] Generated message: {message}", message); + + // Create a unique blob name with the required format + var now = DateTime.UtcNow; + var blobFileName = $"game-{_gameId:D3}-status-{now:yyyy-MM-dd-HH-mm-ss}.json"; + + // Create scoreboard entries for all players using the updated method + await CreateScoreboardEntriesAsync(_connectionString, _inputTableName); + + // Upload blob to the input container + await UploadBlobAsync(_connectionString, _inputContainerName, blobFileName, message); + + // Send message to the input queue + await SendQueueMessageAsync(_connectionString, _inputQueueName, message); + + // Send message to the trigger queue + await SendQueueMessageAsync(_connectionString, _triggerQueueName, _gameId.ToString()); + + // Increment game ID for next execution + _gameId++; + + // Log the next scheduled timer occurrence + _logger.LogInformation("[CreateGameAsync] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); + } + + /// + /// Uploads a message as a blob to the specified Azure Storage container. + /// + /// The storage account connection string. + /// The name of the container to upload to. + /// The name of the blob file to create. + /// The message content to upload as blob data. + /// A task that represents the asynchronous upload operation. + private async Task UploadBlobAsync(string? connectionString, string? inputContainerName, string blobFileName, string message) + { + try + { + var blobServiceClient = new BlobServiceClient(connectionString); + var blobContainerClient = blobServiceClient.GetBlobContainerClient(inputContainerName); + + await blobContainerClient.CreateIfNotExistsAsync(); + + var blobClient = blobContainerClient.GetBlobClient(blobFileName); + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(message))) + { + await blobClient.UploadAsync(stream, overwrite: true); + } + _logger.LogInformation("[UploadBlobAsync] Uploaded blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); + } + catch (Exception ex) + { + _logger.LogError(ex, "[UploadBlobAsync] Failed to upload blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); + } + } + + /// + /// Sends a message to the specified Azure Storage queue. + /// + /// The storage account connection string. + /// The name of the queue to send the message to. + /// The message content to send (will be Base64 encoded). + /// A task that represents the asynchronous send operation. + private async Task SendQueueMessageAsync(string? connectionString, string? queueName, string message) + { + try + { + var queueClient = new QueueClient(connectionString, queueName); + + await queueClient.CreateIfNotExistsAsync(); + + await queueClient.SendMessageAsync(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(message))); + _logger.LogInformation("[SendQueueMessageAsync] Sent message to queue: {queueName}", queueName); + } + catch (Exception ex) + { + _logger.LogError(ex, "[SendQueueMessageAsync] Failed to send message to queue: {queueName}", queueName); + } + } + + /// + /// Creates scoreboard entries for each player with random scores and stores them in the internal dictionary. + /// This method replaces Azure Table Storage operations by maintaining game data in memory using a thread-safe + /// Dictionary structure. Each game is stored with its unique game ID and contains all player scores. + /// + /// The storage account connection string. + /// The name of the table to store the scoreboard entries in. + /// A task that represents the asynchronous operation of creating and storing player scores. + private async Task CreateScoreboardEntriesAsync(string? connectionString, string? tableName) + { + try + { + var random = new Random(); + var playerScores = new List(); + var tableClient = new TableClient(connectionString, tableName); + await tableClient.CreateIfNotExistsAsync(); + var partitionKey = _gameId.ToString(); + + if (_playerNames == null || _playerNames.Length == 0) + { + _logger.LogWarning("[CreateScoreboardEntriesAsync] No player names configured. Skipping scoreboard entry creation."); + return; + } + + foreach (var name in _playerNames) + { + var score = Math.Max(0, random.Next(0, 101)); // Random number between 0 and 100, ensure >= 0 + var playerScore = new PlayerScore + { + Name = name, + Score = score + }; + + playerScores.Add(playerScore); + + var entity = new ScoreboardEntity + { + PartitionKey = partitionKey, + RowKey = Guid.NewGuid().ToString(), + GameId = _gameId, + PlayerName = name, + Score = score, + Timestamp = DateTimeOffset.UtcNow, + ETag = ETag.All + }; + + await tableClient.AddEntityAsync(entity); + + _logger.LogInformation("[CreateScoreboardEntriesAsync] Added scoreboard entry for {playerName} with score {score} in game {gameId}", name, score, _gameId); + } + + // Store the game data in the internal dictionary (thread-safe) + lock (_gameDataLock) + { + _gameData[_gameId] = playerScores; + } + + _logger.LogInformation("[CreateScoreboardEntriesAsync] Created {playerCount} scoreboard entries for game {gameId}.", _playerNames.Length, _gameId); + + // Simulate async work to maintain the async signature + await Task.CompletedTask; + } + catch (Exception ex) + { + _logger.LogError(ex, "[CreateScoreboardEntriesAsync] Failed to create scoreboard entries for game {gameId}", _gameId); + } + } } \ No newline at end of file diff --git a/samples/function-app-storage-http/dotnet/src/sample/Program.cs b/samples/function-app-storage-http/dotnet/src/sample/Program.cs index 51336f3..c76837e 100644 --- a/samples/function-app-storage-http/dotnet/src/sample/Program.cs +++ b/samples/function-app-storage-http/dotnet/src/sample/Program.cs @@ -1,7 +1,7 @@ -using Microsoft.Extensions.Hosting; - -var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .Build(); - -host.Run(); +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + +host.Run(); diff --git a/samples/function-app-storage-http/dotnet/src/sample/sample.csproj b/samples/function-app-storage-http/dotnet/src/sample/sample.csproj index 97195a5..d7c6345 100644 --- a/samples/function-app-storage-http/dotnet/src/sample/sample.csproj +++ b/samples/function-app-storage-http/dotnet/src/sample/sample.csproj @@ -1,33 +1,33 @@ - - - net10.0 - v4 - Exe - enable - enable - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - Never - - - - - + + + net10.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + \ No newline at end of file