diff --git a/README.md b/README.md index a20d600..839330f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ This repository contains comprehensive sample projects demonstrating how to deve | [Function App and Storage](./samples/function-app-storage-http/dotnet/README.md) | Azure Functions App using Blob, Queue, and Table Storage | | [Function App and Front Door](./samples/function-app-front-door/python/README.md) | Azure Functions App exposed via Front Door | | [Function App and Managed Identities](./samples/function-app-managed-identity/python/README.md) | Azure Function App using Managed Identities | +| [Function App and Service Bus](./samples/function-app-service-bus/dotnet/README.md) | Azure Function App using Service Bus | | [Web App and CosmosDB for MongoDB API ](./samples/web-app-cosmosdb-mongodb-api/python/README.md) | Azure Web App using CosmosDB for MongoDB API | | [Web App and CosmosDB for NoSQL API ](./samples/web-app-cosmosdb-nosql-api/python/README.md) | Azure Web App using CosmosDB for NoSQL API | | [Web App and Managed Identities](./samples/web-app-managed-identity/python/README.md) | Azure Web App using Managed Identities | diff --git a/samples/function-app-managed-identity/python/README.md b/samples/function-app-managed-identity/python/README.md index 7d0c015..ec3b2db 100644 --- a/samples/function-app-managed-identity/python/README.md +++ b/samples/function-app-managed-identity/python/README.md @@ -45,9 +45,9 @@ The LocalStack emulator emulates the following services, which are necessary at - [Azure Subscription](https://azure.microsoft.com/free/) - [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [Azure Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) - [Python](https://www.python.org/downloads/) - [Flask](https://flask.palletsprojects.com/) -- [pyodbc](https://github.com/mkleehammer/pyodbc) - [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep), if you plan to install the sample via Bicep. - [Terraform](https://developer.hashicorp.com/terraform/downloads), if you plan to install the sample via Terraform. diff --git a/samples/function-app-service-bus/dotnet/README.md b/samples/function-app-service-bus/dotnet/README.md new file mode 100644 index 0000000..31b19d5 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/README.md @@ -0,0 +1,123 @@ +# Azure Functions App with Service Bus Messaging + +This sample demonstrates how to deploy an [Azure Functions App](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview) on an [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans) that exchanges messages through queues in a [Service Bus](https://learn.microsoft.com/en-us/azure/service-bus/service-bus-overview) namespace. The function app authenticates to Azure resources using a [user-assigned managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) and connects to the Service Bus namespace via an [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) for secure, private network communication. + +## Architecture + +The following diagram illustrates the architecture of the solution: + +![Architecture Diagram](./images/architecture.png) + +The function app is composed of the following functions: + +- **GreetingRequester**: Timer-triggered function that periodically sends a request message containing a randomly generated name to the `input` queue. +- **GreetingHandler**: Uses the [Azure Service Bus trigger](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus-trigger) to listen for incoming messages on the `input` queue. When a message arrives, it extracts the name, composes a greeting, and sends the result to the `output` queue using the [Azure Service Bus output binding](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus-output). +- **GreetingConsumer**: Timer-triggered function that periodically polls the `output` queue, retrieves greeting response messages, and logs them for monitoring. +- **GetGreetings**: HTTP-triggered function that returns the most recent greetings stored in an in-memory circular buffer. Greetings are returned in reverse chronological order (newest first), providing a quick way to verify the message pipeline is working. + +The solution is composed of the following Azure resources: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): A logical container scoping all resources in this sample. +2. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview): Hosts two subnets: + - *app-subnet*: Dedicated to [regional VNet integration](https://learn.microsoft.com/azure/azure-functions/functions-networking-options?tabs=azure-portal#outbound-networking-features) with the Function App. + - *pe-subnet*: Used for hosting Azure Private Endpoints. +3. [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone): Provide internal DNS resolution so that resources within the virtual network can reach Private Endpoints by hostname rather than public addresses. There is a separate Azure Private DNS Zone for the following resource types: + - Azure Service Bus namespace + - Azure Blob Storage + - Azure Queue Storage + - Azure Table Storage +4. [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview): Provide secure, private network connectivity to Azure resources by exposing them through private IP addresses within the virtual network, eliminating the need for traffic to traverse the public internet. There is a separate Azure Private Endpoint for the following resources: + - Azure Service Bus namespace + - Azure Blob Storage + - Azure Queue Storage + - Azure Table Storage +5. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview): Provides deterministic outbound connectivity and a stable public IP address for the Function App's outbound traffic. Included for architectural completeness; the sample app itself does not call any external services. +6. [Azure Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): Enforces inbound and outbound traffic rules across the virtual network's subnets. +7. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview): Centralizes diagnostic logs and metrics from all resources in the solution, enabling unified querying and analysis across the entire deployment. +8. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview-hosting-plans): Defines the underlying compute tier and scaling behavior for the function app. +9. [Azure Functions App](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview): Hosts the sample function app. +10. [Azure Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview): Provides application performance monitoring (APM), collecting and analyzing requests, traces, and metrics generated by the function app to surface performance bottlenecks and failures. +11. [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview): A fully managed enterprise message broker. This namespace hosts the `input` and `output` queues used by the function app to exchange messages asynchronously. +12. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides durable storage used internally by the Azure Functions runtime for state management, including distributed locks, checkpoints, and timer trigger coordination. +13. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): This identity is assigned the necessary RBAC roles and is used by the function app to authenticate securely—without storing credentials—to the following Azure resources: + - Azure Service Bus namespace + - Azure Storage + - Azure Application Insights + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [Azure Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) is required to build, run, and deploy the Azure Functions app locally +- [.NET SDK](https://dotnet.microsoft.com/en-us/download) is required to compile and run the C# Azure Functions project +- [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep), if you plan to install the sample via Bicep. +- [Terraform](https://developer.hashicorp.com/terraform/downloads), if you plan to install the sample via Terraform. + +## 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/?__hstc=108988063.8aad2b1a7229945859f4d9b9bb71e05d.1743148429561.1758793541854.1758810151462.32&__hssc=108988063.3.1758810151462&__hsfp=3945774529) 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-alpha). To pull the image, execute: + +```bash +docker pull localstack/localstack-azure-alpha +``` + +Start the LocalStack Azure emulator by running: + +```bash +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure-alpha localstack start + ``` + +Deploy the application to LocalStack for Azure using one of these methods: + +- [Azure CLI Deployment](./scripts/README.md) +- [Bicep Deployment](./bicep/README.md) +- [Terraform Deployment](./terraform/README.md) + +All deployment methods have been fully tested against Azure and the LocalStack for Azure local emulator. + +> **Note** +> When you deploy the application to LocalStack for Azure for the first time, the initialization process involves downloading and building Docker images. This is a one-time operation—subsequent deployments will be significantly faster. Depending on your internet connection and system resources, this initial setup may take several minutes. + +## Test + +Once the resources and function app have been deployed, you can use the [call-http-trigger.sh](./scripts/call-http-trigger.sh) Bash script to invoke the **GetGreetings** HTTP-triggered function. This function returns the most recent greetings stored in the in-memory circular buffer, allowing you to verify that the entire message pipeline is working end to end. + +You can also inspect the function app's runtime behavior by viewing the logs of its Docker container. Run `docker logs ls-local-func-test-xxxxxx` (replacing `xxxxxx` with the actual container suffix) to see output similar to the following: + +```bash +[2026-03-17T13:11:30.000Z] Executing 'Functions.GreetingRequester' (Reason='Timer fired at 2026-03-17T13:11:30.0002087+00:00', Id=1677eef3-d54a-434a-b21c-0bb1606ebedc) +[2026-03-17T13:11:30.001Z] [GreetingRequester] Timer trigger function started. +[2026-03-17T13:11:30.001Z] [GreetingRequester] Creating Service Bus client for sending messages... +[2026-03-17T13:11:30.001Z] [GreetingRequester] Creating sender for input queue 'input' +[2026-03-17T13:11:30.001Z] [GreetingRequester] Sending message to input queue 'input'... +[2026-03-17T13:11:30.219Z] [GreetingRequester] Successfully sent message to input queue 'input' with name: Jane +[2026-03-17T13:11:30.219Z] [GreetingRequester] Function Ran. Next timer schedule = (null) +[2026-03-17T13:11:30.219Z] Executed 'Functions.GreetingRequester' (Succeeded, Id=1677eef3-d54a-434a-b21c-0bb1606ebedc, Duration=218ms) +[2026-03-17T13:11:30.298Z] Executing 'Functions.GreetingHandler' (Reason='(null)', Id=5ec3e867-0073-44d4-8dc8-869e0ce95401) +[2026-03-17T13:11:30.298Z] Trigger Details: MessageId: c5a1b026-b435-4f62-b273-de9f6e2224a1, SequenceNumber: 1, DeliveryCount: 1, EnqueuedTimeUtc: 2026-03-17T13:11:30.2170000+00:00, LockedUntilUtc: 2026-03-17T13:12:30.2170000+00:00, SessionId: (null) +[2026-03-17T13:11:30.299Z] [GreetingHandler] Message ID: c5a1b026-b435-4f62-b273-de9f6e2224a1 +[2026-03-17T13:11:30.299Z] [GreetingHandler] Message Body: {"name":"Jane"} +[2026-03-17T13:11:30.299Z] [GreetingHandler] Message Content-Type: application/json +[2026-03-17T13:11:30.299Z] [GreetingHandler] Processing request for name: Jane +[2026-03-17T13:11:30.299Z] Start processing HTTP request POST http://127.0.0.1:43127/Settlement/Complete +[2026-03-17T13:11:30.299Z] Sending HTTP request POST http://127.0.0.1:43127/Settlement/Complete +[2026-03-17T13:11:30.302Z] Received HTTP response headers after 2.609ms - 200 +[2026-03-17T13:11:30.302Z] End processing HTTP request after 2.6465ms - 200 +[2026-03-17T13:11:30.302Z] [GreetingHandler] Processed message [c5a1b026-b435-4f62-b273-de9f6e2224a1] successfully: Hi Jane, great to see you! +[2026-03-17T13:11:30.303Z] Executed 'Functions.GreetingHandler' (Succeeded, Id=5ec3e867-0073-44d4-8dc8-869e0ce95401, Duration=4ms) +[2026-03-17T13:11:34.375Z] [GreetingConsumer] Function Ran. Next timer schedule = (null) +[2026-03-17T13:11:34.375Z] Executed 'Functions.GreetingConsumer' (Succeeded, Id=32dbc94a-4ff7-4315-9de4-ea36593589fc, Duration=10427ms) +[2026-03-17T13:11:34.380Z] Executing 'Functions.GreetingConsumer' (Reason='Timer fired at 2026-03-17T13:11:34.3806261+00:00', Id=af37440c-360b-4114-97ea-bf88f1843bcf) +[2026-03-17T13:11:34.381Z] [GreetingConsumer] Timer trigger function started. +[2026-03-17T13:11:34.381Z] [GreetingConsumer] Creating Service Bus client for receiving messages... +[2026-03-17T13:11:34.381Z] [GreetingConsumer] Starting to receive messages from output queue 'output' +[2026-03-17T13:11:34.799Z] [GreetingConsumer] Successfully received and deserialized message from output queue. Date: 2026-03-17T13:06:30, Text: Hi Jane, great to see you! +[2026-03-17T13:11:39.802Z] [GreetingConsumer] No more messages available in output queue 'output' +``` + +## References + +- [Azure Functions Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) +- [LocalStack for Azure](https://azure.localstack.cloud/) \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/bicep/README.md b/samples/function-app-service-bus/dotnet/bicep/README.md new file mode 100644 index 0000000..d1c3c23 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/README.md @@ -0,0 +1,317 @@ +# Bicep Deployment + +This directory contains the Bicep template and a deployment script for provisioning Azure services in LocalStack for Azure. For further details about the sample application, refer to the [Azure Functions App with Service Bus Messaging](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [LocalStack for Azure](https://azure.localstack.cloud/): Local Azure cloud emulator for development and testing +- [Visual Studio Code](https://code.visualstudio.com/): Code editor installed on one of the [supported platforms](https://code.visualstudio.com/docs/supporting/requirements#_platforms) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [Azlocal CLI](https://azure.localstack.cloud/user-guides/sdks/az/): LocalStack Azure CLI wrapper +- [Azure Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) is required to build, run, and deploy the Azure Functions app locally +- [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep): VS Code extension for Bicep language support and IntelliSense +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [.NET SDK](https://dotnet.microsoft.com/en-us/download) is required to compile and run the C# Azure Functions project +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing azlocal CLI + +The [deploy.sh](deploy.sh) Bash script uses the `azlocal` CLI instead of the standard Azure CLI to work with LocalStack. Install it using: + +```bash +pip install azlocal +``` + +For more information, see [Get started with the az tool on LocalStack](https://azure.localstack.cloud/user-guides/sdks/az/). + +## Architecture Overview + +The [deploy.sh](deploy.sh) script creates the [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli) for all the Azure resources, while the Bicep modules create the following Azure resources: + +1. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview): Hosts two subnets: + - *app-subnet*: Dedicated to [regional VNet integration](https://learn.microsoft.com/azure/azure-functions/functions-networking-options?tabs=azure-portal#outbound-networking-features) with the Function App. + - *pe-subnet*: Used for hosting Azure Private Endpoints. +2. [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone): Provide internal DNS resolution so that resources within the virtual network can reach Private Endpoints by hostname rather than public addresses. There is a separate Azure Private DNS Zone for the following resource types: + - Azure Service Bus namespace + - Azure Blob Storage + - Azure Queue Storage + - Azure Table Storage +3. [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview): Provide secure, private network connectivity to Azure resources by exposing them through private IP addresses within the virtual network, eliminating the need for traffic to traverse the public internet. There is a separate Azure Private Endpoint for the following resources: + - Azure Service Bus namespace + - Azure Blob Storage + - Azure Queue Storage + - Azure Table Storage +4. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview): Provides deterministic outbound connectivity and a stable public IP address for the Function App's outbound traffic. Included for architectural completeness; the sample app itself does not call any external services. +5. [Azure Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): Enforces inbound and outbound traffic rules across the virtual network's subnets. +6. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview): Centralizes diagnostic logs and metrics from all resources in the solution, enabling unified querying and analysis across the entire deployment. +7. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview-hosting-plans): Defines the underlying compute tier and scaling behavior for the function app. +8. [Azure Functions App](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview): Hosts the sample function app. +9. [Azure Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview): Provides application performance monitoring (APM), collecting and analyzing requests, traces, and metrics generated by the function app to surface performance bottlenecks and failures. +10. [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview): A fully managed enterprise message broker. This namespace hosts the `input` and `output` queues used by the function app to exchange messages asynchronously. +11. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides durable storage used internally by the Azure Functions runtime for state management, including distributed locks, checkpoints, and timer trigger coordination. +12. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): This identity is assigned the necessary RBAC roles and is used by the function app to authenticate securely—without storing credentials—to the following Azure resources: + - Azure Service Bus namespace + - Azure Storage + - Azure Application Insights + +For more information on the sample application, see [Azure Functions App with Service Bus Messaging](../README.md). + +## Configuration + +Before deploying the `main.bicep` template, update the `bicep.bicepparam` file with your specific values: + +```bicep +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'python' +param runtimeVersion = '3.13' +param databaseName = 'sampledb' +param collectionName = 'activities' +param username = 'paolo' +param primaryRegion = 'westeurope' +param secondaryRegion = 'northeurope' +``` + +## Provisioning Scripts + +See [deploy.sh](deploy.sh) for the complete deployment automation. The script performs: + +- Detects environment (LocalStack vs Azure Cloud) and uses appropriate CLI +- Creates resource group if it doesn't exist +- Optionally validates the Bicep template +- Optionally runs what-if deployment for preview +- Deploys the main.bicep template with parameters from [main.bicepparam](main.bicepparam) +- Extracts deployment outputs (Function App name, Service Bus Namespace name details) +- Creates zip package of the Python application +- Deploys the zip to the Azure Functions App + +## Deployment + +You can set up the Azure emulator by utilizing 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/?__hstc=108988063.8aad2b1a7229945859f4d9b9bb71e05d.1743148429561.1758793541854.1758810151462.32&__hssc=108988063.3.1758810151462&__hsfp=3945774529) to obtain your Auth Token and specify 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-alpha). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure-alpha +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure-alpha localstack start +``` + +Navigate to the `bicep` folder: + +```bash +cd samples/function-app-service-bus/dotnet/bicep +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +Once the deployment completes, run the [validate.sh](../scripts/validate.sh) script to confirm that all resources were provisioned and configured as expected: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +FUNCTION_APP_SUBNET_NSG_NAME="${PREFIX}-func-subnet-nsg-${SUFFIX}" +PE_SUBNET_NSG_NAME="${PREFIX}-pe-subnet-nsg-${SUFFIX}" +NAT_GATEWAY_NAME="${PREFIX}-nat-gateway-${SUFFIX}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-plan-${SUFFIX}" +FUNCTION_APP_NAME="${PREFIX}-func-${SUFFIX}" +SERVICE_BUS_NAMESPACE_NAME="${PREFIX}-service-bus-${SUFFIX}" +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +APPLICATION_INSIGHTS_NAME="${PREFIX}-func-${SUFFIX}" +ENVIRONMENT=$(az account show --query environmentName --output tsv) +PRIVATE_DNS_ZONE_NAMES=( + "privatelink.servicebus.windows.net" + "privatelink.blob.core.windows.net" + "privatelink.queue.core.windows.net" + "privatelink.table.core.windows.net" +) +PE_NAMES=( + "${PREFIX}-service-bus-pe-${SUFFIX}" + "${PREFIX}-blob-storage-pe-${SUFFIX}" + "${PREFIX}-queue-storage-pe-${SUFFIX}" + "${PREFIX}-table-storage-pe-${SUFFIX}" +) + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +$AZ group show \ + --name "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check App Service Plan +echo -e "\n[$APP_SERVICE_PLAN_NAME] app service plan:\n" +$AZ appservice plan show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --output table \ + --only-show-errors + +# Check Azure Functions App +echo -e "\n[$FUNCTION_APP_NAME] web app:\n" +$AZ functionapp show \ + --name "$FUNCTION_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Service Bus Namespace +echo -e "\n[$SERVICE_BUS_NAMESPACE_NAME] service bus namespace:\n" +$AZ servicebus namespace show \ + --name "$SERVICE_BUS_NAMESPACE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --query '{Name:name,ServiceBusEndpoint:serviceBusEndpoint}' \ + --only-show-errors + +# Check Service Bus Queues +echo -e "\n[$SERVICE_BUS_NAMESPACE_NAME] service bus queues:\n" +$AZ servicebus queue list \ + --namespace-name "$SERVICE_BUS_NAMESPACE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --query '[].{Name:name,Status:status}' \ + --only-show-errors + + # Check Application Insights +echo -e "\n[$APPLICATION_INSIGHTS_NAME] application insights:\n" +$AZ monitor app-insights component show \ + --app "$APPLICATION_INSIGHTS_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check Storage Account +echo -e "\n[$STORAGE_ACCOUNT_NAME] storage account:\n" +$AZ storage account show \ + --name "$STORAGE_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:primaryLocation,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check Log Analytics Workspace +echo -e "\n[$LOG_ANALYTICS_NAME] log analytics workspace:\n" +$AZ monitor log-analytics workspace show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --workspace-name "$LOG_ANALYTICS_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check NAT Gateway +echo -e "\n[$NAT_GATEWAY_NAME] nat gateway:\n" +$AZ network nat gateway show \ + --name "$NAT_GATEWAY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Virtual Network +echo -e "\n[$VIRTUAL_NETWORK_NAME] virtual network:\n" +$AZ network vnet show \ + --name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Private DNS Zone +for PRIVATE_DNS_ZONE_NAME in "${PRIVATE_DNS_ZONE_NAMES[@]}"; do + echo -e "\n[$PRIVATE_DNS_ZONE_NAME] private dns zone:\n" + $AZ network private-dns zone show \ + --name "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup,RecordSets:recordSets,VirtualNetworkLinks:virtualNetworkLinks}' \ + --output table \ + --only-show-errors +done + +# Check Private Endpoint +for PRIVATE_ENDPOINT_NAME in "${PE_NAMES[@]}"; do + echo -e "\n[$PRIVATE_ENDPOINT_NAME] private endpoint:\n" + $AZ network private-endpoint show \ + --name "$PRIVATE_ENDPOINT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors +done + +# Check Functions App Subnet NSG +echo -e "\n[$FUNCTION_APP_SUBNET_NSG_NAME] network security group:\n" +$AZ network nsg show \ + --name "$FUNCTION_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Private Endpoint Subnet NSG +echo -e "\n[$PE_SUBNET_NSG_NAME] network security group:\n" +$AZ network nsg show \ + --name "$PE_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# List resources +echo -e "\n[$RESOURCE_GROUP_NAME] all resources:\n" +$AZ resource list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors +``` + +## Cleanup + +To destroy all created resources: + +```bash +# Delete resource group and all contained resources +az group delete --name local-rg --yes --no-wait + +# Verify deletion +az group list --output table +``` + +This will remove all Azure resources created by the CLI deployment script. + +## Related Documentation + +- [Azure Bicep Documentation](https://docs.microsoft.com/en-us/azure/azure-resource-manager/bicep/) +- [Bicep Language Reference](https://docs.microsoft.com/en-us/azure/azure-resource-manager/bicep/bicep-functions) +- [LocalStack for Azure Documentation](https://azure.localstack.cloud/) \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/bicep/deploy.sh b/samples/function-app-service-bus/dotnet/bicep/deploy.sh new file mode 100755 index 0000000..41d77ca --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/deploy.sh @@ -0,0 +1,173 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +TEMPLATE="main.bicep" +PARAMETERS="main.bicepparam" +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOCATION="westeurope" +VALIDATE_TEMPLATE=1 +USE_WHAT_IF=0 +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="functionapp.zip" +ENVIRONMENT=$(az account show --query environmentName --output tsv) + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Validates if the resource group exists in the subscription, if not creates it +echo "Checking if resource group [$RESOURCE_GROUP_NAME] exists in the subscription [$SUBSCRIPTION_NAME]..." +$AZ group show --name $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No resource group [$RESOURCE_GROUP_NAME] exists in the subscription [$SUBSCRIPTION_NAME]" + echo "Creating resource group [$RESOURCE_GROUP_NAME] in the subscription [$SUBSCRIPTION_NAME]..." + + # Create the resource group + $AZ group create \ + --name $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --only-show-errors 1> /dev/null + + if [[ $? == 0 ]]; then + echo "Resource group [$RESOURCE_GROUP_NAME] successfully created in the subscription [$SUBSCRIPTION_NAME]" + else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME] in the subscription [$SUBSCRIPTION_NAME]" + exit + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists in the subscription [$SUBSCRIPTION_NAME]" +fi + +# Validates the Bicep template +if [[ $VALIDATE_TEMPLATE == 1 ]]; then + if [[ $USE_WHAT_IF == 1 ]]; then + # Execute a deployment What-If operation at resource group scope. + echo "Previewing changes deployed by Bicep template [$TEMPLATE]..." + $AZ deployment group what-if \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $TEMPLATE \ + --parameters $PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + --only-show-errors + + if [[ $? == 0 ]]; then + echo "Bicep template [$TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$TEMPLATE]" + exit + fi + else + # Validate the Bicep template + echo "Validating Bicep template [$TEMPLATE]..." + output=$($AZ deployment group validate \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $TEMPLATE \ + --parameters $PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + --only-show-errors) + + if [[ $? == 0 ]]; then + echo "Bicep template [$TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$TEMPLATE]" + echo "$output" + exit + fi + fi +fi + +# Deploy the Bicep template +echo "Deploying Bicep template [$TEMPLATE]..." +if DEPLOYMENT_OUTPUTS=$($AZ deployment group create \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors \ + --template-file $TEMPLATE \ + --parameters $PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + --query 'properties.outputs' -o json); then + # Extract only the JSON portion (everything from first { to the end) + DEPLOYMENT_JSON=$(echo "$DEPLOYMENT_OUTPUTS" | sed -n '/{/,$ p') + echo "Bicep template [$TEMPLATE] deployed successfully. Outputs:" + echo "$DEPLOYMENT_JSON" | jq . + FUNCTION_APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.functionAppName.value') + SERVICE_BUS_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.serviceBusName.value') + echo "Deployment details:" + echo "Function App Name: $FUNCTION_APP_NAME" + echo "Service Bus Namespace: $SERVICE_BUS_NAME" +else + echo "Failed to deploy Bicep template [$TEMPLATE]" + exit 1 +fi + +if [[ -z "$FUNCTION_APP_NAME" || -z "$SERVICE_BUS_NAME" ]]; then + echo "Function App Name or Service Bus Namespace is empty. Exiting." + exit 1 +fi + +# Print the application settings of the function app +echo "Retrieving application settings for function app [$FUNCTION_APP_NAME]..." +$AZ functionapp config appsettings list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$FUNCTION_APP_NAME" + +# CD into the function app directory +cd ../src || exit + +# Remove any existing zip package of the function app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Build and publish the function app +echo "Building function app [$FUNCTION_APP_NAME]..." +if dotnet publish -c Release -o ./publish; then + echo "Function app [$FUNCTION_APP_NAME] built successfully." +else + echo "Failed to build function app [$FUNCTION_APP_NAME]." + exit 1 +fi + +# Create the zip package of the publish output +echo "Creating zip package of the function app..." +cd ./publish || exit +zip -r "../$ZIPFILE" . +cd .. + +# Deploy the function app +echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$ZIPFILE]..." +if $AZ functionapp deployment source config-zip \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$FUNCTION_APP_NAME" \ + --src "$ZIPFILE" 1>/dev/null; then + echo "Function app [$FUNCTION_APP_NAME] deployed successfully." +else + echo "Failed to deploy function app [$FUNCTION_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the function app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Print the list of resources in the resource group +echo "Listing resources in resource group [$RESOURCE_GROUP_NAME]..." +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/bicep/main.bicep b/samples/function-app-service-bus/dotnet/bicep/main.bicep new file mode 100644 index 0000000..60f3b54 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/main.bicep @@ -0,0 +1,575 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the prefix for the name of the Azure resources.') +@minLength(2) +param prefix string = take(uniqueString(resourceGroup().id), 4) + +@description('Specifies the suffix for the name of the Azure resources.') +@minLength(2) +param suffix string = take(uniqueString(resourceGroup().id), 4) + +@description('Specifies the location for all resources.') +param location string = resourceGroup().location + +@description('Specifies the tier name for the hosting plan.') +@allowed([ + 'Basic' + 'Standard' + 'ElasticPremium' + 'Premium' + 'PremiumV2' + 'Premium0V3' + 'PremiumV3' + 'PremiumMV3' + 'Isolated' + 'IsolatedV2' + 'WorkflowStandard' + 'FlexConsumption' +]) +param skuTier string = 'Standard' + +@description('Specifies the SKU name for the hosting plan.') +@allowed([ + 'B1' + 'B2' + 'B3' + 'S1' + 'S2' + 'S3' + 'EP1' + 'EP2' + 'EP3' + 'P1' + 'P2' + 'P3' + 'P1V2' + 'P2V2' + 'P3V2' + 'P0V3' + 'P1V3' + 'P2V3' + 'P3V3' + 'P1MV3' + 'P2MV3' + 'P3MV3' + 'P4MV3' + 'P5MV3' + 'I1' + 'I2' + 'I3' + 'I1V2' + 'I2V2' + 'I3V2' + 'I4V2' + 'I5V2' + 'I6V2' + 'WS1' + 'WS2' + 'WS3' + 'FC1' +]) +param skuName string = 'S1' + +@description('Specifies the kind of the hosting plan.') +@allowed([ + 'app' + 'elastic' + 'functionapp' + 'windows' + 'linux' +]) +param appServicePlanKind string = 'linux' + +@description('Specifies whether the hosting plan is reserved.') +param reserved bool = true + +@description('Specifies whether the hosting plan is zone redundant.') +param zoneRedundant bool = false + +@description('Specifies the language runtime used by the Azure Functions App.') +@allowed([ + 'dotnet' + 'dotnet-isolated' + 'python' + 'java' + 'node' + 'powerShell' + 'custom' +]) +param runtimeName string + +@description('Specifies the target language version used by the Azure Functions App.') +param runtimeVersion string + +@description('Specifies the kind of the hosting plan.') +@allowed([ + 'app' // Windows Web app + 'app,linux' // Linux Web app + 'app,linux,container' // Linux Container Web app + 'hyperV' // Windows Container Web App + 'app,container,windows' // Windows Container Web App + 'app,linux,kubernetes' // Linux Web App on ARC + 'app,linux,container,kubernetes' // Linux Container Web App on ARC + 'functionapp' // Function Code App + 'functionapp,linux' // Linux Consumption Function app + 'functionapp,linux,container,kubernetes' // Function Container App on ARC + 'functionapp,linux,kubernetes' // Function Code App on ARC +]) +param functionAppKind string = 'functionapp,linux' + +@description('Specifies whether HTTPS is enforced for the Azure Functions App.') +param httpsOnly bool = false + +@description('Specifies the minimum TLS version for the Azure Functions App.') +@allowed([ + '1.0' + '1.1' + '1.2' + '1.3' +]) +param minTlsVersion string = '1.2' + +@description('Specifies whether the public network access is enabled or disabled') +@allowed([ + 'Enabled' + 'Disabled' +]) +param publicNetworkAccess string = 'Enabled' + +@description('Specifies whether Always On is enabled for the Azure Functions App.') +param alwaysOn bool = true + +@description('Specifies the optional Git Repo URL.') +param repoUrl string = '' + +@description('Enabling this property creates a Premium Service Bus Namespace in regions supported availability zones.') +param serviceBusNamespaceZoneRedundant bool = true + +@description('Specifies the messaging units for the Service Bus namespace. For Premium tier, capacity are 1,2 and 4.') +param serviceBusNamespaceCapacity int = 1 + +@description('Specifies the name of Service Bus namespace SKU.') +@allowed([ + 'Basic' + 'Premium' + 'Standard' +]) +param serviceBusSkuName string = 'Premium' + +@description('Specifies a list of queue names.') +param queueNames array = [ + 'input' + 'output' +] + +@description('Specifies the name of the virtual network.') +param virtualNetworkName string = '' + +@description('Specifies the address prefixes of the virtual network.') +param virtualNetworkAddressPrefixes string = '10.0.0.0/8' + +@description('Specifies the name of the subnet used by the Azure Functions App for the regional virtual network integration.') +param functionAppSubnetName string = 'func-subnet' + +@description('Specifies the address prefix of the subnet used by the Azure Functions App for the regional virtual network integration.') +param functionAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Azure Functions App.') +param functionAppSubnetNsgName string = '' + +@description('Specifies the name of the subnet which contains the private endpoint to the Service Bus namespace.') +param peSubnetName string = 'pe-subnet' + +@description('Specifies the address prefix of the subnet which contains the private endpoint to the Service Bus namespace.') +param peSubnetAddressPrefix string = '10.0.1.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the private endpoint to the Service Bus namespace.') +param peSubnetNsgName string = '' + +@description('Specifies the length of the Public IP Prefix.') +@minValue(28) +@maxValue(32) +param natGatewayPublicIpPrefixLength int = 31 + +@description('Specifies the name of the Azure NAT Gateway.') +param natGatewayName string = '' + +@description('Specifies a list of availability zones denoting the zone in which Nat Gateway should be deployed.') +param natGatewayZones array = [] + +@description('Specifies the idle timeout in minutes for the Azure NAT Gateway.') +param natGatewayIdleTimeoutMins int = 30 + +@description('Specifies whether to allow public network access for the storage account.') +@allowed([ + 'Disabled' + 'Enabled' +]) +param storageAccountPublicNetworkAccess string = 'Enabled' + +@description('Specifies the access tier of the Azure Storage Account resource. The default value is Hot.') +param storageAccountAccessTier string = 'Hot' + +@description('Specifies whether the Azure Storage Account resource allows public access to blobs.') +param storageAccountAllowBlobPublicAccess bool = true + +@description('Specifies whether the Azure Storage Account resource allows shared key access.') +param storageAccountAllowSharedKeyAccess bool = true + +@description('Specifies whether the Azure Storage Account resource allows cross-tenant replication.') +param storageAccountAllowCrossTenantReplication bool = false + +@description('Specifies the minimum TLS version to be permitted on requests to the Azure Storage Account resource. The default value is TLS1_2.') +param storageAccountMinimumTlsVersion string = 'TLS1_2' + +@description('The default action of allow or deny when no other rules match. Allowed values: Allow or Deny') +@allowed([ + 'Allow' + 'Deny' +]) +param storageAccountANetworkAclsDefaultAction string = 'Allow' + +@description('Specifies whether the Azure Storage Account resource should only support HTTPS traffic.') +param storageAccountSupportsHttpsTrafficOnly bool = true + +@description('Specifies whether to create containers.') +param storageAccountCreateContainers bool = false + +@description('Specifies an array of containers to create.') +param storageAccountContainerNames array = [] + +@description('Specifies the name of the Azure Log Analytics resource.') +param logAnalyticsName string = '' + +@description('Specifies the service tier of the workspace: Free, Standalone, PerNode, Per-GB.') +@allowed([ + 'Free' + 'Standalone' + 'PerNode' + 'PerGB2018' +]) +param logAnalyticsSku string = 'PerNode' + +@description('Specifies the workspace data retention in days. -1 means Unlimited retention for the Unlimited Sku. 730 days is the maximum allowed for all other Skus.') +param logAnalyticsRetentionInDays int = 60 + +@description('Specifies the type of managed identity.') +@allowed([ + 'SystemAssigned' + 'UserAssigned' +]) +param managedIdentityType string = 'UserAssigned' + +@description('Specifies the tags to be applied to the resources.') +param tags object = { + environment: 'test' + deployment: 'bicep' +} + +@description('Specifies a list of names to be used as part of the sample data in the Azure Function App.') +param names string = 'Paolo,John,Jane,Max,Mary,Leo,Mia,Anna,Lisa,Anastasia' + +//******************************************** +// Variables +//******************************************** +var functionAppName = '${prefix}-func-${suffix}' +var appServicePlanName = '${prefix}-plan-${suffix}' +var serviceBusNamespaceName = '${prefix}-service-bus-${suffix}' +var storageAccountName = '${prefix}storage${suffix}' +var managedIdentityName = '${prefix}-identity-${suffix}' +var blobStoragePrivateEndpointName = '${prefix}-blob-storage-pe-${suffix}' +var queueStoragePrivateEndpointName = '${prefix}-queue-storage-pe-${suffix}' +var tableStoragePrivateEndpointName = '${prefix}-table-storage-pe-${suffix}' +var serviceBusPrivateEndpointName = '${prefix}-service-bus-pe-${suffix}' + +//******************************************** +// Modules and Resources +//******************************************** +module applicationInsights 'modules/application-insights.bicep' = { + name: 'applicationInsights' + params: { + // properties + name: functionAppName + location: location + tags: tags + workspaceId: workspace.outputs.id + } +} + +module workspace 'modules/log-analytics.bicep' = { + name: 'workspace' + params: { + // properties + name: empty(logAnalyticsName) ? toLower('${prefix}-log-analytics-${suffix}') : logAnalyticsName + location: location + tags: tags + sku: logAnalyticsSku + retentionInDays: logAnalyticsRetentionInDays + } +} + +module storageAccount 'modules/storage-account.bicep' = { + name: 'storageAccount' + params: { + // properties + name: storageAccountName + location: location + tags: tags + publicNetworkAccess: storageAccountPublicNetworkAccess + accessTier: storageAccountAccessTier + allowBlobPublicAccess: storageAccountAllowBlobPublicAccess + allowSharedKeyAccess: storageAccountAllowSharedKeyAccess + allowCrossTenantReplication: storageAccountAllowCrossTenantReplication + minimumTlsVersion: storageAccountMinimumTlsVersion + networkAclsDefaultAction: storageAccountANetworkAclsDefaultAction + supportsHttpsTrafficOnly: storageAccountSupportsHttpsTrafficOnly + workspaceId: workspace.outputs.id + createContainers: storageAccountCreateContainers + containerNames: storageAccountContainerNames + createFileShares: false + fileShareNames: [] + } +} + +module serviceBus 'modules/service-bus.bicep' = { + name: 'serviceBus' + params: { + name: serviceBusNamespaceName + location: location + capacity: serviceBusNamespaceCapacity + skuName: serviceBusSkuName + zoneRedundant: serviceBusNamespaceZoneRedundant + workspaceId: workspace.outputs.id + queueNames: queueNames + tags: tags + } +} + +module network 'modules/virtual-network.bicep' = { + name: 'network' + params: { + virtualNetworkName: empty(virtualNetworkName) ? toLower('${prefix}-vnet-${suffix}') : virtualNetworkName + virtualNetworkAddressPrefixes: virtualNetworkAddressPrefixes + functionAppSubnetName: functionAppSubnetName + functionAppSubnetAddressPrefix: functionAppSubnetAddressPrefix + functionAppSubnetNsgName: empty(functionAppSubnetNsgName) + ? toLower('${prefix}-webapp-subnet-nsg-${suffix}') + : functionAppSubnetNsgName + peSubnetName: peSubnetName + peSubnetAddressPrefix: peSubnetAddressPrefix + peSubnetNsgName: empty(peSubnetNsgName) ? toLower('${prefix}-pe-subnet-nsg-${suffix}') : peSubnetNsgName + natGatewayName: empty(natGatewayName) ? toLower('${prefix}-nat-gateway-${suffix}') : natGatewayName + natGatewayZones: natGatewayZones + natGatewayPublicIpPrefixName: toLower('${prefix}-nat-gateway-pip-prefix-${suffix}') + natGatewayPublicIpPrefixLength: natGatewayPublicIpPrefixLength + natGatewayIdleTimeoutMins: natGatewayIdleTimeoutMins + delegationServiceName: skuTier == 'FlexConsumption' ? 'Microsoft.App/environments' : 'Microsoft.Web/serverfarms' + workspaceId: workspace.outputs.id + location: location + tags: tags + } +} + +module blobStoragePrivateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'blobStoragePrivateDnsZone' + params: { + name: 'privatelink.blob.core.windows.net' + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module blobStoragePrivateEndpoint 'modules/private-endpoint.bicep' = { + name: 'blobStoragePrivateEndpoint' + params: { + name: blobStoragePrivateEndpointName + privateLinkServiceId: storageAccount.outputs.id + privateDnsZoneId: blobStoragePrivateDnsZone.outputs.id + subnetId: network.outputs.peSubnetId + groupIds: [ + 'blob' + ] + location: location + tags: tags + } +} + +module queueStoragePrivateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'queueStoragePrivateDnsZone' + params: { + name: 'privatelink.queue.core.windows.net' + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module queueStoragePrivateEndpoint 'modules/private-endpoint.bicep' = { + name: 'queueStoragePrivateEndpoint' + params: { + name: queueStoragePrivateEndpointName + privateLinkServiceId: storageAccount.outputs.id + privateDnsZoneId: queueStoragePrivateDnsZone.outputs.id + subnetId: network.outputs.peSubnetId + groupIds: [ + 'queue' + ] + location: location + tags: tags + } +} + +module tableStoragePrivateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'tableStoragePrivateDnsZone' + params: { + name: 'privatelink.table.core.windows.net' + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module tableStoragePrivateEndpoint 'modules/private-endpoint.bicep' = { + name: 'tableStoragePrivateEndpoint' + params: { + name: tableStoragePrivateEndpointName + privateLinkServiceId: storageAccount.outputs.id + privateDnsZoneId: tableStoragePrivateDnsZone.outputs.id + subnetId: network.outputs.peSubnetId + groupIds: [ + 'table' + ] + location: location + tags: tags + } +} + +module serviceBusPrivateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'serviceBusPrivateDnsZone' + params: { + name: 'privatelink.servicebus.windows.net' + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module serviceBusPrivateEndpoint 'modules/private-endpoint.bicep' = { + name: 'serviceBusPrivateEndpoint' + params: { + name: serviceBusPrivateEndpointName + privateLinkServiceId: serviceBus.outputs.id + privateDnsZoneId: serviceBusPrivateDnsZone.outputs.id + subnetId: network.outputs.peSubnetId + groupIds: [ + 'namespace' + ] + location: location + tags: tags + } +} + +module appServicePlan 'modules/app-service-plan.bicep' = { + name: 'appServicePlan' + params: { + name: appServicePlanName + location: location + skuName: skuName + skuTier: skuTier + kind: appServicePlanKind + reserved: reserved + zoneRedundant: zoneRedundant + workspaceId: workspace.outputs.id + tags: tags + } +} + +module managedIdentity 'modules/managed-identity.bicep' = if (managedIdentityType == 'UserAssigned') { + name: 'managedIdentity' + params: { + // properties + name: managedIdentityName + storageAccountName: storageAccount.outputs.name + applicationInsightsName: applicationInsights.outputs.name + serviceBusName: serviceBus.outputs.name + location: location + tags: tags + } +} + +module functionApp 'modules/function-app.bicep' = { + name: functionAppName + params: { + name: functionAppName + location: location + kind: functionAppKind + httpsOnly: httpsOnly + runtimeName: runtimeName + runtimeVersion: runtimeVersion + alwaysOn: alwaysOn + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + repoUrl: repoUrl + virtualNetworkName: network.outputs.virtualNetworkName + subnetName: network.outputs.functionAppSubnetName + hostingPlanName: appServicePlan.outputs.name + workspaceId: workspace.outputs.id + tags: tags + managedIdentityType: managedIdentityType + managedIdentityName: managedIdentityName + settings: [ + { + name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + value: 'false' + } + { + name: 'AzureWebJobsStorage' + value: storageAccount.outputs.connectionString + } + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: runtimeName + } + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } + { + name: 'AZURE_CLIENT_ID' + value: managedIdentityType == 'UserAssigned' ? managedIdentity.outputs.clientId : '' + } + { + name: 'SERVICE_BUS_CONNECTION_STRING__fullyQualifiedNamespace' + value: '${serviceBus.outputs.name}.servicebus.windows.net' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.outputs.connectionString + } + { + name: 'APPLICATIONINSIGHTS_AUTHENTICATION_STRING' + value: managedIdentityType == 'UserAssigned' ? 'ClientId=${managedIdentity.outputs.clientId};Authorization=AAD' : '' + } + { + name: 'INPUT_QUEUE_NAME' + value: 'input' + } + { + name: 'OUTPUT_QUEUE_NAME' + value: 'output' + } + { + name: 'NAMES' + value: names + } + { + name: 'TIMER_SCHEDULE' + value: '*/10 * * * * *' + } + ] + } +} + +//******************************************** +// Outputs +//******************************************** +output functionAppName string = functionApp.outputs.name +output serviceBusName string = serviceBus.outputs.name diff --git a/samples/function-app-service-bus/dotnet/bicep/main.bicepparam b/samples/function-app-service-bus/dotnet/bicep/main.bicepparam new file mode 100644 index 0000000..59c8ca1 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/main.bicepparam @@ -0,0 +1,6 @@ +using 'main.bicep' + +param prefix = 'paolo' +param suffix = 'test' +param runtimeName = 'dotnet-isolated' +param runtimeVersion = '10.0' diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/app-service-plan.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/app-service-plan.bicep new file mode 100644 index 0000000..4b5cfb3 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/app-service-plan.bicep @@ -0,0 +1,154 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the name of the App Service Plan.') +param name string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies the tier name for the hosting plan.') +@allowed([ + 'Basic' + 'Standard' + 'ElasticPremium' + 'Premium' + 'PremiumV2' + 'Premium0V3' + 'PremiumV3' + 'PremiumMV3' + 'Isolated' + 'IsolatedV2' + 'WorkflowStandard' + 'FlexConsumption' +]) +param skuTier string = 'Standard' + +@description('Specifies the SKU name for the hosting plan.') +@allowed([ + 'B1' + 'B2' + 'B3' + 'S1' + 'S2' + 'S3' + 'EP1' + 'EP2' + 'EP3' + 'P1' + 'P2' + 'P3' + 'P1V2' + 'P2V2' + 'P3V2' + 'P0V3' + 'P1V3' + 'P2V3' + 'P3V3' + 'P1MV3' + 'P2MV3' + 'P3MV3' + 'P4MV3' + 'P5MV3' + 'I1' + 'I2' + 'I3' + 'I1V2' + 'I2V2' + 'I3V2' + 'I4V2' + 'I5V2' + 'I6V2' + 'WS1' + 'WS2' + 'WS3' + 'FC1' +]) +param skuName string = 'S1' + +@description('Specifies the kind of the hosting plan.') +@allowed([ + 'app' + 'elastic' + 'functionapp' + 'windows' + 'linux' +]) +param kind string = 'linux' + +@description('Specifies whether the hosting plan is reserved.') +param reserved bool = true + +@description('Specifies whether the hosting plan is zone redundant.') +param zoneRedundant bool = false + +@description('Specifies the resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the tags to be applied to the resources.') +param tags object = {} + +//******************************************** +// Variables +//******************************************** + +var diagnosticSettingsName = 'default' +var logCategories = [] +var metricCategories = [ + 'AllMetrics' +] +var logs = [ + for category in logCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } + } +] +var metrics = [ + for category in metricCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } + } +] + +//******************************************** +// Resources +//******************************************** +resource appServicePlan 'Microsoft.Web/serverfarms@2024-11-01' = { + name: name + location: location + tags: tags + kind: kind + sku: { + tier: skuTier + name: skuName + } + properties: { + reserved: reserved + zoneRedundant: zoneRedundant + maximumElasticWorkerCount: skuTier == 'FlexConsumption' ? 1 : 20 + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if(!empty(workspaceId)) { + name: diagnosticSettingsName + scope: appServicePlan + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = appServicePlan.id +output name string = appServicePlan.name diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/application-insights.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/application-insights.bicep new file mode 100644 index 0000000..39ab4dd --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/application-insights.bicep @@ -0,0 +1,78 @@ + +//******************************************** +// Parameters +//******************************************** + +@description('Specifies the name of the Azure Application Insights.') +param name string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies the type of application being monitored..') +@allowed([ + 'web' + 'other' +]) +param applicationType string = 'web' + +@description('Specifies whether IP masking is enabled.') +param disableIpMasking bool = false + +@description('Specifies whether the application is enabled for local authentication.') +param disableLocalAuth bool = false + +@description('Specifies whether purging data immediately after 30 days.') +param immediatePurgeDataOn30Days bool = true + +@description('Specifies the network access type for accessing Application Insights ingestion.') +@allowed([ + 'Enabled' + 'Disabled' +]) +param publicNetworkAccessForIngestion string = 'Enabled' + +@description('Specifies the network access type for accessing Application Insights query.') +@allowed([ + 'Enabled' + 'Disabled' +]) +param publicNetworkAccessForQuery string = 'Enabled' + +@description('Specifies the Azure Log Analytics workspace ID.') +param workspaceId string + +@description('Specifies the resource tags.') +param tags object + +//******************************************** +// Resources +//******************************************** + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: applicationType + DisableIpMasking: disableIpMasking + DisableLocalAuth: disableLocalAuth + Flow_Type: 'Bluefield' + ImmediatePurgeDataOn30Days: immediatePurgeDataOn30Days + IngestionMode: 'LogAnalytics' + publicNetworkAccessForIngestion: publicNetworkAccessForIngestion + publicNetworkAccessForQuery: publicNetworkAccessForQuery + Request_Source: 'rest' + WorkspaceResourceId: workspaceId + } +} + +//******************************************** +// Outputs +//******************************************** + +output id string = applicationInsights.id +output name string = applicationInsights.name +output connectionString string = applicationInsights.properties.ConnectionString +output instrumentationKey string = applicationInsights.properties.InstrumentationKey diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/function-app.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/function-app.bicep new file mode 100644 index 0000000..72a7574 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/function-app.bicep @@ -0,0 +1,219 @@ +//******************************************** +// Parameters +//******************************************** + +@description('Specifies a globally unique name the Azure Functions App.') +param name string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies the kind of the hosting plan.') +@allowed([ + 'app' // Windows Web app + 'app,linux' // Linux Web app + 'app,linux,container' // Linux Container Web app + 'hyperV' // Windows Container Web App + 'app,container,windows' // Windows Container Web App + 'app,linux,kubernetes' // Linux Web App on ARC + 'app,linux,container,kubernetes' // Linux Container Web App on ARC + 'functionapp' // Function Code App + 'functionapp,linux' // Linux Consumption Function app + 'functionapp,linux,container,kubernetes' // Function Container App on ARC + 'functionapp,linux,kubernetes' // Function Code App on ARC +]) +param kind string = 'functionapp,linux' + +@description('Specifies the language runtime used by the Azure Functions App.') +@allowed([ + 'dotnet' + 'dotnet-isolated' + 'python' + 'java' + 'node' + 'powerShell' + 'custom' +]) +param runtimeName string + +@description('Specifies the target language version used by the Azure Functions App.') +param runtimeVersion string + +@description('Specifies the minimum TLS version for the Azure Functions App.') +@allowed([ + '1.0' + '1.1' + '1.2' + '1.3' +]) +param minTlsVersion string = '1.2' + +@description('Specifies whether the public network access is enabled or disabled') +@allowed([ + 'Enabled' + 'Disabled' +]) +param publicNetworkAccess string = 'Enabled' + +@description('Specifies whether Always On is enabled for the Azure Functions App.') +param alwaysOn bool = true + +@description('Specifies whether HTTPS is enforced for the Azure Functions App.') +param httpsOnly bool = true + +@description('Specifies the type of managed identity.') +@allowed([ + 'SystemAssigned' + 'UserAssigned' +]) +param managedIdentityType string = 'UserAssigned' + +@description('Specifies the name of a user-assigned managed identity.') +param managedIdentityName string = '' + +@description('Specifies the name of the hosting plan.') +param hostingPlanName string + +@description('Specifies allowed origins for client-side CORS requests on the site.') +param allowedCorsOrigins string[] = [] + +@description('Specifies the name of the virtual network.') +param virtualNetworkName string + +@description('Specifies the name of the subnet used by Azure Functions for the regional virtual network integration.') +param subnetName string + +@description('Specifies the app settings of the Azure Functions App') +param settings array = [] + +@description('Specifies the resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the optional Git Repo URL.') +param repoUrl string = ' ' + +@description('Specifies the resource tags.') +param tags object + +//******************************************** +// Variables +//******************************************** + +// Generates a unique container name for deployments. +var diagnosticSettingsName = 'diagnosticSettings' +var logCategories = [ + 'FunctionAppLogs' + 'AppServiceAuthenticationLogs' +] +var metricCategories = [ + 'AllMetrics' +] +var logs = [ + for category in logCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } + } +] +var metrics = [ + for category in metricCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } + } +] + +//******************************************** +// Resources +//******************************************** + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2025-01-31-preview' existing = { + name: managedIdentityName +} + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2025-05-01' existing = { + name: virtualNetworkName +} + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' existing = { + parent: virtualNetwork + name: subnetName +} + +resource hostingPlan 'Microsoft.Web/serverfarms@2025-03-01' existing = { + name: hostingPlanName +} + +resource functionApp 'Microsoft.Web/sites@2025-03-01' = { + name: name + location: location + kind: kind + tags: tags + identity: { + type: managedIdentityType + userAssignedIdentities : managedIdentityType == 'SystemAssigned' ? null : { + '${managedIdentity.id}': {} + } + } + properties: { + httpsOnly: httpsOnly + serverFarmId: hostingPlan.id + virtualNetworkSubnetId: subnet.id + outboundVnetRouting: { + allTraffic: true + } + siteConfig: { + minTlsVersion: minTlsVersion + alwaysOn: alwaysOn + linuxFxVersion: toUpper('${runtimeName}|${runtimeVersion}') + cors: { + allowedOrigins: union(['https://portal.azure.com', 'https://ms.portal.azure.com'], allowedCorsOrigins) + } + publicNetworkAccess: publicNetworkAccess + appSettings: [ + for setting in settings: { + name: setting.name + value: setting.value + } + ] + netFrameworkVersion: runtimeName == 'dotnet' || runtimeName == 'dotnet-isolated' ? runtimeVersion : null + nodeVersion: runtimeName == 'node' ? runtimeVersion : null + pythonVersion: runtimeName == 'python' ? runtimeVersion : null + javaVersion: runtimeName == 'java' ? runtimeVersion : null + } + } +} + +resource webAppSourceControl 'Microsoft.Web/sites/sourcecontrols@2024-11-01' = if (contains(repoUrl,'http')){ + name: 'web' + parent: functionApp + properties: { + repoUrl: repoUrl + branch: 'master' + isManualIntegration: true + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if(!empty(workspaceId)) { + name: diagnosticSettingsName + scope: functionApp + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** + +output id string = functionApp.id +output name string = functionApp.name +output defaultHostName string = functionApp.properties.defaultHostName diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/log-analytics.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/log-analytics.bicep new file mode 100644 index 0000000..2618829 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/log-analytics.bicep @@ -0,0 +1,45 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the name of the Log Analytics workspace.') +param name string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies the service tier of the workspace: Free, Standalone, PerNode, Per-GB.') +@allowed([ + 'Free' + 'Standalone' + 'PerNode' + 'PerGB2018' +]) +param sku string = 'PerNode' + +@description('Specifies the workspace data retention in days. -1 means Unlimited retention for the Unlimited Sku. 730 days is the maximum allowed for all other Skus.') +param retentionInDays int = 60 + +@description('Specifies the resource tags.') +param tags object + +//******************************************** +// Resources +//******************************************** +resource workspace 'Microsoft.OperationalInsights/workspaces@2025-07-01' = { + name: name + tags: tags + location: location + properties: { + sku: { + name: sku + } + retentionInDays: retentionInDays + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = workspace.id +output name string = workspace.name +output customerId string = workspace.properties.customerId diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/managed-identity.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/managed-identity.bicep new file mode 100644 index 0000000..83ff9ef --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/managed-identity.bicep @@ -0,0 +1,142 @@ +//******************************************** +// Parameters +//******************************************** + +@description('Specifies the name of the user-defined managed identity.') +param name string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies the name for the Azure Storage Account resource.') +param storageAccountName string + +@description('Specifies the name of the Azure Application Insights.') +param applicationInsightsName string + +@description('Specifies the name for the Azure Service Bus namespace.') +param serviceBusName string + +@description('Specifies the resource tags.') +param tags object + +//******************************************** +// Resources +//******************************************** + +resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' existing = { + name: storageAccountName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} + +resource namespace 'Microsoft.ServiceBus/namespaces@2025-05-01-preview' existing = { + name: serviceBusName +} + +resource storageAccountContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '17d1049b-9a84-46fb-8f53-869881c3d3ab' + scope: subscription() +} + +resource storageBlobDataOwnerRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' + scope: subscription() +} + +resource storageQueueDataContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '974c5e8b-45b9-4653-ba55-5f855dd0fb88' + scope: subscription() +} + +resource storageTableDataContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3' + scope: subscription() +} + +resource azureServiceBusDataOwnerRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '090c5cfd-751d-490a-894a-3ce6f1109419' + scope: subscription() +} + +resource monitoringMetricsPublisherRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '3913510d-42f4-4e42-8a64-420c390055eb' + scope: subscription() +} + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2025-01-31-preview' = { + name: name + location: location + tags: tags +} + +resource storageAccountContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, managedIdentity.id, storageAccountContributorRoleDefinition.id) + scope: storageAccount + properties: { + roleDefinitionId: storageAccountContributorRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource storageBlobDataOwnerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, managedIdentity.id, storageBlobDataOwnerRoleDefinition.id) + scope: storageAccount + properties: { + roleDefinitionId: storageBlobDataOwnerRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource storageQueueDataContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, managedIdentity.id, storageQueueDataContributorRoleDefinition.id) + scope: storageAccount + properties: { + roleDefinitionId: storageQueueDataContributorRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource storageTableDataContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, managedIdentity.id, storageTableDataContributorRoleDefinition.id) + scope: storageAccount + properties: { + roleDefinitionId: storageTableDataContributorRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource azureServiceBusDataOwnerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(namespace.id, managedIdentity.id, azureServiceBusDataOwnerRoleDefinition.id) + scope: namespace + properties: { + roleDefinitionId: azureServiceBusDataOwnerRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource monitoringMetricsPublisherRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(applicationInsights.id, managedIdentity.id, monitoringMetricsPublisherRoleDefinition.id) + scope: applicationInsights + properties: { + roleDefinitionId: monitoringMetricsPublisherRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +//******************************************** +// Outputs +//******************************************** + +output id string = managedIdentity.id +output name string = managedIdentity.name +output clientId string = managedIdentity.properties.clientId +output principalId string = managedIdentity.properties.principalId diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/private-dns-zone.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/private-dns-zone.bicep new file mode 100644 index 0000000..d849259 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/private-dns-zone.bicep @@ -0,0 +1,41 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the name of the private DNS zone.') +param name string + +@description('Specifies the resource ID of the virtual network where private endpoints will be created.') +param vnetId string + +@description('Specifies the resource tags.') +param tags object + +//******************************************** +// Resources +//******************************************** + +// Private DNS Zones +resource privateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: name + location: 'global' + tags: tags +} + +// Virtual Network Links +resource privateDnsZoneVirtualNetworkLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + parent: privateDnsZone + name: 'link-to-vnet' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnetId + } + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = privateDnsZone.id +output name string = privateDnsZone.name diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/private-endpoint.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/private-endpoint.bicep new file mode 100644 index 0000000..e758fa7 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/private-endpoint.bicep @@ -0,0 +1,69 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the name of the private endpoint.') +param name string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies the resource ID of the subnet where private endpoints will be created.') +param subnetId string + +@description('Specifies the group IDs for the private link service connection.') +param groupIds array + +@description('Specifies the resource ID of the target resource.') +param privateLinkServiceId string + +@description('Specifies the resource ID of the private DNS zone.') +param privateDnsZoneId string + +@description('Specifies the resource tags.') +param tags object + +//******************************************** +// Resources +//******************************************** + +// Private Endpoints +resource privateEndpoint 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: name + location: location + tags: tags + properties: { + privateLinkServiceConnections: [ + { + name: '${name}-pls-connection' + properties: { + privateLinkServiceId: privateLinkServiceId + groupIds: groupIds + } + } + ] + subnet: { + id: subnetId + } + } +} + +resource privateDnsZoneGroupName 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + parent: privateEndpoint + name: 'private-dns-zone-group' + properties: { + privateDnsZoneConfigs: [ + { + name: 'dnsConfig' + properties: { + privateDnsZoneId: privateDnsZoneId + } + } + ] + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = privateEndpoint.id +output name string = privateEndpoint.name diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/service-bus.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/service-bus.bicep new file mode 100644 index 0000000..96e1780 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/service-bus.bicep @@ -0,0 +1,108 @@ +// Parameters +@description('Specifies the name of the Service Bus namespace.') +param name string + +@description('Enabling this property creates a Premium Service Bus Namespace in regions supported availability zones.') +param zoneRedundant bool = true + +@description('Specifies the name of Service Bus namespace SKU.') +@allowed([ + 'Basic' + 'Premium' + 'Standard' +]) +param skuName string = 'Premium' + +@description('Specifies the messaging units for the Service Bus namespace. For Premium tier, capacity are 1,2 and 4.') +param capacity int = 1 + +@description('Specifies a list of queue names.') +param queueNames array = [] + +@description('Specifies the lock duration of the queue.') +param queueLockDuration string = 'PT5M' + +@description('Specifies the resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies the resource tags.') +param tags object + +// Variables +var diagnosticSettingsName = 'diagnosticSettings' +var logCategories = [ + 'OperationalLogs' + 'VNetAndIPFilteringLogs' + 'RuntimeAuditLogs' + 'ApplicationMetricsLogs' +] +var metricCategories = [ + 'AllMetrics' +] +var logs = [for category in logCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } +}] +var metrics = [for category in metricCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } +}] + +// Resources +resource namespace 'Microsoft.ServiceBus/namespaces@2025-05-01-preview' = { + name: name + location: location + tags: tags + sku: { + name: skuName + capacity: capacity + } + properties: { + zoneRedundant: zoneRedundant + } +} + +resource queue 'Microsoft.ServiceBus/namespaces/queues@2025-05-01-preview' = [for queueName in queueNames: { + parent: namespace + name: queueName + properties: { + lockDuration: queueLockDuration + maxSizeInMegabytes: 1024 + requiresDuplicateDetection: false + requiresSession: false + defaultMessageTimeToLive: 'P10675199DT2H48M5.4775807S' + deadLetteringOnMessageExpiration: false + duplicateDetectionHistoryTimeWindow: 'PT10M' + maxDeliveryCount: 10 + autoDeleteOnIdle: 'P10675199DT2H48M5.4775807S' + enablePartitioning: false + enableExpress: false + } +}] + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: diagnosticSettingsName + scope: namespace + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +// Outputs +output id string = namespace.id +output name string = namespace.name +output queueIds array = [for i in range(0, length(queueNames)): queue[i].id] +output queueNames array = [for i in range(0, length(queueNames)): queue[i].name] diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/storage-account.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/storage-account.bicep new file mode 100644 index 0000000..69c1458 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/storage-account.bicep @@ -0,0 +1,265 @@ +//******************************************** +// Parameters +//******************************************** + +@description('Specifies the name for the Azure Storage Account resource.') +param name string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies whether to allow public network access for the storage account.') +@allowed([ + 'Disabled' + 'Enabled' +]) +param publicNetworkAccess string = 'Disabled' + +@description('Specifies the resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the the storage SKU.') +@allowed([ + 'Standard_LRS' + 'Standard_ZRS' + 'Standard_GRS' + 'Standard_GZRS' + 'Standard_RAGRS' + 'Standard_RAGZRS' + 'Premium_LRS' + 'Premium_ZRS' +]) +param skuName string = 'Standard_LRS' + +@description('Specifies the access tier of the storage account. The default value is Hot.') +param accessTier string = 'Hot' + +@description('Specifies whether the storage account allows public access to blobs.') +param allowBlobPublicAccess bool = true + +@description('Specifies whether the storage account allows shared key access.') +param allowSharedKeyAccess bool = true + +@description('Specifies whether the storage account allows cross-tenant replication.') +param allowCrossTenantReplication bool = false + +@description('Specifies the minimum TLS version to be permitted on requests to storage. The default value is TLS1_2.') +param minimumTlsVersion string = 'TLS1_2' + +@description('The default action of allow or deny when no other rules match. Allowed values: Allow or Deny') +@allowed([ + 'Allow' + 'Deny' +]) +param networkAclsDefaultAction string = 'Deny' + +@description('Specifies whether Hierarchical Namespace is enabled.') +param isHnsEnabled bool = false + +@description('Specifies whether NFSv3 is enabled.') +param isNfsV3Enabled bool = false + +@description('Specifies the key expiration period in days.') +param keyExpirationPeriodInDays int = 7 + +@description('Specifies whether the storage account should only support HTTPS traffic.') +param supportsHttpsTrafficOnly bool = true + +@description('Specifies whether large file shares are enabled. The default value is Disabled.') +@allowed([ + 'Disabled' + 'Enabled' +]) +param largeFileSharesState string = 'Disabled' + +@description('Specifies the resource tags.') +param tags object + +@description('Specifies whether to create containers.') +param createContainers bool = false + +@description('Specifies an array of containers to create.') +param containerNames array = [] + +@description('Specifies whether to create file shares.') +param createFileShares bool = false + +@description('Specifies an array of file shares to create.') +param fileShareNames array = [] + +//******************************************** +// Variables +//******************************************** + +var diagnosticSettingsName = 'diagnosticSettings' +var logCategories = [ + 'StorageRead' + 'StorageWrite' + 'StorageDelete' +] +var metricCategories = [ + 'Transaction' +] +var logs = [ + for category in logCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } + } +] +var metrics = [ + for category in metricCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } + } +] + +//******************************************** +// Resources +//******************************************** + +resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' = { + name: name + location: location + tags: tags + sku: { + name: skuName + } + kind: 'StorageV2' + + // Containers live inside of a blob service + resource blobService 'blobServices' = { + name: 'default' + + // Creating containers with provided names if contition is true + resource containers 'containers' = [ + for containerName in containerNames: if (createContainers) { + name: containerName + properties: { + publicAccess: 'None' + } + } + ] + } + + resource queueService 'queueServices' = { + name: 'default' + } + + resource tableService 'tableServices' = { + name: 'default' + } + + resource fileService 'fileServices' = { + name: 'default' + + // Creating file shares with provided names if contition is true + resource shares 'shares' = [ + for fileShareName in fileShareNames: if (createFileShares) { + name: fileShareName + properties: { + enabledProtocols: 'SMB' + shareQuota: 100 // Quota in GB (adjust as needed) + } + } + ] + } + + properties: { + publicNetworkAccess: publicNetworkAccess + accessTier: accessTier + allowBlobPublicAccess: allowBlobPublicAccess + allowCrossTenantReplication: allowCrossTenantReplication + allowSharedKeyAccess: allowSharedKeyAccess + encryption: { + keySource: 'Microsoft.Storage' + requireInfrastructureEncryption: false + services: { + blob: { + enabled: true + keyType: 'Account' + } + file: { + enabled: true + keyType: 'Account' + } + queue: { + enabled: true + keyType: 'Service' + } + table: { + enabled: true + keyType: 'Service' + } + } + } + isHnsEnabled: isHnsEnabled + isNfsV3Enabled: isNfsV3Enabled + keyPolicy: { + keyExpirationPeriodInDays: keyExpirationPeriodInDays + } + largeFileSharesState: largeFileSharesState + minimumTlsVersion: minimumTlsVersion + networkAcls: { + bypass: 'AzureServices' + defaultAction: networkAclsDefaultAction + } + supportsHttpsTrafficOnly: supportsHttpsTrafficOnly + } +} + +resource blobServicesDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: '${diagnosticSettingsName}-blobService' + scope: storageAccount::blobService + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +resource queueServicesDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: '${diagnosticSettingsName}-queueService' + scope: storageAccount::queueService + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + + +resource tableServicesDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: '${diagnosticSettingsName}-tableService' + scope: storageAccount::tableService + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +resource fileServicesDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: '${diagnosticSettingsName}-fileService' + scope: storageAccount::fileService + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** + +output id string = storageAccount.id +output name string = storageAccount.name +output connectionString string = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' diff --git a/samples/function-app-service-bus/dotnet/bicep/modules/virtual-network.bicep b/samples/function-app-service-bus/dotnet/bicep/modules/virtual-network.bicep new file mode 100644 index 0000000..15a0d98 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/bicep/modules/virtual-network.bicep @@ -0,0 +1,239 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the name of the virtual network.') +param virtualNetworkName string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies the address prefixes of the virtual network.') +param virtualNetworkAddressPrefixes string = '10.0.0.0/8' + +@description('Specifies the name of the subnet used by the Web App for the regional virtual network integration.') +param functionAppSubnetName string = 'functionAppSubnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param functionAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param functionAppSubnetNsgName string = '' + +@description('Specifies the name of the subnet which contains the private endpoint to the Azure CosmosDB for MongoDB API account.') +param peSubnetName string = 'pe-subnet' + +@description('Specifies the address prefix of the subnet which contains the private endpoint to the Azure CosmosDB for MongoDB API account.') +param peSubnetAddressPrefix string = '10.0.1.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the private endpoint to the Azure CosmosDB for MongoDB API account.') +param peSubnetNsgName string = '' + +@description('Specifies the name of the Azure NAT Gateway.') +param natGatewayName string + +@description('Specifies a list of availability zones denoting the zone in which Nat Gateway should be deployed.') +param natGatewayZones array = [] + +@description('Specifies the name of the public IP prefix for the Azure NAT Gateway.') +param natGatewayPublicIpPrefixName string + +@description('Specifies the length of the Public IP Prefix.') +@minValue(28) +@maxValue(32) +param natGatewayPublicIpPrefixLength int = 31 + +@description('Specifies the idle timeout in minutes for the Azure NAT Gateway.') +param natGatewayIdleTimeoutMins int = 30 + +@description('Specifies the delegation service name.') +param delegationServiceName string + +@description('Specifies the resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the resource tags.') +param tags object + +//******************************************** +// Variables +//******************************************** +var diagnosticSettingsName = 'default' +var nsgLogCategories = [ + 'NetworkSecurityGroupEvent' + 'NetworkSecurityGroupRuleCounter' +] +var nsgLogs = [for category in nsgLogCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } +}] +var vnetLogCategories = [ + 'VMProtectionAlerts' +] +var vnetMetricCategories = [ + 'AllMetrics' +] +var vnetLogs = [for category in vnetLogCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } +}] +var vnetMetrics = [for category in vnetMetricCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: true + days: 0 + } +}] + +//******************************************** +// Resources +//******************************************** + +// Virtual Network +resource vnet 'Microsoft.Network/virtualNetworks@2024-03-01' = { + name: virtualNetworkName + location: location + tags: tags + properties: { + addressSpace: { + addressPrefixes: [ + virtualNetworkAddressPrefixes + ] + } + subnets: [ + { + name: functionAppSubnetName + properties: { + addressPrefix: functionAppSubnetAddressPrefix + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' + networkSecurityGroup: { + id: functionAppSubnetNsg.id + } + natGateway: { + id: natGateway.id + } + delegations: [ + { + name: 'delegation' + properties: { + serviceName: delegationServiceName + } + } + ] + } + } + { + name: peSubnetName + properties: { + addressPrefix: peSubnetAddressPrefix + networkSecurityGroup: { + id: peSubnetNsg.id + } + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' + natGateway: { + id: natGateway.id + } + } + } + ] + } +} + +resource functionAppSubnetNsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: functionAppSubnetNsgName + location: location + tags: tags + properties: { + securityRules: [ + ] + } +} + +resource peSubnetNsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: peSubnetNsgName + location: location + tags: tags + properties: { + securityRules: [ + ] + } +} + +// NAT Gateway +resource natGatewayPublicIpPrefix 'Microsoft.Network/publicIPPrefixes@2025-05-01' = { + name: natGatewayPublicIpPrefixName + location: location + sku: { + name: 'Standard' + } + zones: !empty(natGatewayZones) ? natGatewayZones : [] + properties: { + publicIPAddressVersion: 'IPv4' + prefixLength: natGatewayPublicIpPrefixLength + } +} + +resource natGateway 'Microsoft.Network/natGateways@2025-05-01' = { + name: natGatewayName + location: location + sku: { + name: 'Standard' + } + zones: !empty(natGatewayZones) ? natGatewayZones : [] + properties: { + publicIpPrefixes: [ + { + id: natGatewayPublicIpPrefix.id + } + ] + idleTimeoutInMinutes: natGatewayIdleTimeoutMins + } +} + +resource peSubnetNsgDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: diagnosticSettingsName + scope: peSubnetNsg + properties: { + workspaceId: workspaceId + logs: nsgLogs + } +} + +resource functionAppSubnetNsgDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: diagnosticSettingsName + scope: functionAppSubnetNsg + properties: { + workspaceId: workspaceId + logs: nsgLogs + } +} + +resource vnetDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: diagnosticSettingsName + scope: vnet + properties: { + workspaceId: workspaceId + logs: vnetLogs + metrics: vnetMetrics + } +} + +//******************************************** +// Outputs +//******************************************** +output virtualNetworkId string = vnet.id +output virtualNetworkName string = vnet.name +output functionAppSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, functionAppSubnetName) +output functionAppSubnetName string = functionAppSubnetName +output peSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, peSubnetName) +output peSubnetName string = peSubnetName diff --git a/samples/function-app-service-bus/dotnet/images/architecture.png b/samples/function-app-service-bus/dotnet/images/architecture.png new file mode 100644 index 0000000..4498534 Binary files /dev/null and b/samples/function-app-service-bus/dotnet/images/architecture.png differ diff --git a/samples/function-app-service-bus/dotnet/scripts/README.md b/samples/function-app-service-bus/dotnet/scripts/README.md new file mode 100644 index 0000000..cfac814 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/scripts/README.md @@ -0,0 +1,285 @@ +# Azure CLI Deployment + +This directory includes Bash scripts designed for deploying and testing the sample function app utilizing the `azlocal` CLI. For further details about the sample application, refer to the [Azure Functions App with Service Bus Messaging](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [LocalStack for Azure](https://azure.localstack.cloud/): Local Azure cloud emulator for development and testing +- [Visual Studio Code](https://code.visualstudio.com/): Code editor installed on one of the [supported platforms](https://code.visualstudio.com/docs/supporting/requirements#_platforms) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [Azlocal CLI](https://azure.localstack.cloud/user-guides/sdks/az/): LocalStack Azure CLI wrapper +- [Azure Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) is required to build, run, and deploy the Azure Functions app locally +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [.NET SDK](https://dotnet.microsoft.com/en-us/download) is required to compile and run the C# Azure Functions project +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing azlocal CLI + +The [deploy.sh](deploy.sh) Bash script uses the `azlocal` CLI to work with LocalStack. Install it using: + +```bash +pip install azlocal +``` + +For more information, see [Get started with the az tool on LocalStack](https://azure.localstack.cloud/user-guides/sdks/az/). + +## Architecture Overview + +This [deploy.sh](deploy.sh) script creates the following Azure resources using Azure CLI commands: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): A logical container scoping all resources in this sample. +2. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview): Hosts two subnets: + - *app-subnet*: Dedicated to [regional VNet integration](https://learn.microsoft.com/azure/azure-functions/functions-networking-options?tabs=azure-portal#outbound-networking-features) with the Function App. + - *pe-subnet*: Used for hosting Azure Private Endpoints. +3. [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone): Provide internal DNS resolution so that resources within the virtual network can reach Private Endpoints by hostname rather than public addresses. There is a separate Azure Private DNS Zone for the following resource types: + - Azure Service Bus namespace + - Azure Blob Storage + - Azure Queue Storage + - Azure Table Storage +4. [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview): Provide secure, private network connectivity to Azure resources by exposing them through private IP addresses within the virtual network, eliminating the need for traffic to traverse the public internet. There is a separate Azure Private Endpoint for the following resources: + - Azure Service Bus namespace + - Azure Blob Storage + - Azure Queue Storage + - Azure Table Storage +5. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview): Provides deterministic outbound connectivity and a stable public IP address for the Function App's outbound traffic. Included for architectural completeness; the sample app itself does not call any external services. +6. [Azure Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): Enforces inbound and outbound traffic rules across the virtual network's subnets. +7. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview): Centralizes diagnostic logs and metrics from all resources in the solution, enabling unified querying and analysis across the entire deployment. +8. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview-hosting-plans): Defines the underlying compute tier and scaling behavior for the function app. +9. [Azure Functions App](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview): Hosts the sample function app. +10. [Azure Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview): Provides application performance monitoring (APM), collecting and analyzing requests, traces, and metrics generated by the function app to surface performance bottlenecks and failures. +11. [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview): A fully managed enterprise message broker. This namespace hosts the `input` and `output` queues used by the function app to exchange messages asynchronously. +12. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides durable storage used internally by the Azure Functions runtime for state management, including distributed locks, checkpoints, and timer trigger coordination. +13. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): This identity is assigned the necessary RBAC roles and is used by the function app to authenticate securely—without storing credentials—to the following Azure resources: + - Azure Service Bus namespace + - Azure Storage + - Azure Application Insights + +For more information on the sample application, see [Azure Functions App with Service Bus Messaging](../README.md). + +## Deployment + +You can set up the Azure emulator by utilizing 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/?__hstc=108988063.8aad2b1a7229945859f4d9b9bb71e05d.1743148429561.1758793541854.1758810151462.32&__hssc=108988063.3.1758810151462&__hsfp=3945774529) to obtain your Auth Token and specify 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-alpha). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure-alpha +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure-alpha localstack start +``` + +Navigate to the `scripts` folder: + +```bash +cd samples/function-app-service-bus/dotnet/scripts +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +Once the deployment completes, run the [validate.sh](../scripts/validate.sh) script to confirm that all resources were provisioned and configured as expected: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +FUNCTION_APP_SUBNET_NSG_NAME="${PREFIX}-func-subnet-nsg-${SUFFIX}" +PE_SUBNET_NSG_NAME="${PREFIX}-pe-subnet-nsg-${SUFFIX}" +NAT_GATEWAY_NAME="${PREFIX}-nat-gateway-${SUFFIX}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-plan-${SUFFIX}" +FUNCTION_APP_NAME="${PREFIX}-func-${SUFFIX}" +SERVICE_BUS_NAMESPACE_NAME="${PREFIX}-service-bus-${SUFFIX}" +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +APPLICATION_INSIGHTS_NAME="${PREFIX}-func-${SUFFIX}" +ENVIRONMENT=$(az account show --query environmentName --output tsv) +PRIVATE_DNS_ZONE_NAMES=( + "privatelink.servicebus.windows.net" + "privatelink.blob.core.windows.net" + "privatelink.queue.core.windows.net" + "privatelink.table.core.windows.net" +) +PE_NAMES=( + "${PREFIX}-service-bus-pe-${SUFFIX}" + "${PREFIX}-blob-storage-pe-${SUFFIX}" + "${PREFIX}-queue-storage-pe-${SUFFIX}" + "${PREFIX}-table-storage-pe-${SUFFIX}" +) + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +$AZ group show \ + --name "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check App Service Plan +echo -e "\n[$APP_SERVICE_PLAN_NAME] app service plan:\n" +$AZ appservice plan show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --output table \ + --only-show-errors + +# Check Azure Functions App +echo -e "\n[$FUNCTION_APP_NAME] function app:\n" +$AZ functionapp show \ + --name "$FUNCTION_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Service Bus Namespace +echo -e "\n[$SERVICE_BUS_NAMESPACE_NAME] service bus namespace:\n" +$AZ servicebus namespace show \ + --name "$SERVICE_BUS_NAMESPACE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --query '{Name:name,ServiceBusEndpoint:serviceBusEndpoint}' \ + --only-show-errors + +# Check Service Bus Queues +echo -e "\n[$SERVICE_BUS_NAMESPACE_NAME] service bus queues:\n" +$AZ servicebus queue list \ + --namespace-name "$SERVICE_BUS_NAMESPACE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --query '[].{Name:name,Status:status}' \ + --only-show-errors + + # Check Application Insights +echo -e "\n[$APPLICATION_INSIGHTS_NAME] application insights:\n" +$AZ monitor app-insights component show \ + --app "$APPLICATION_INSIGHTS_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check Storage Account +echo -e "\n[$STORAGE_ACCOUNT_NAME] storage account:\n" +$AZ storage account show \ + --name "$STORAGE_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:primaryLocation,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check Log Analytics Workspace +echo -e "\n[$LOG_ANALYTICS_NAME] log analytics workspace:\n" +$AZ monitor log-analytics workspace show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --workspace-name "$LOG_ANALYTICS_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check NAT Gateway +echo -e "\n[$NAT_GATEWAY_NAME] nat gateway:\n" +$AZ network nat gateway show \ + --name "$NAT_GATEWAY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Virtual Network +echo -e "\n[$VIRTUAL_NETWORK_NAME] virtual network:\n" +$AZ network vnet show \ + --name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Private DNS Zone +for PRIVATE_DNS_ZONE_NAME in "${PRIVATE_DNS_ZONE_NAMES[@]}"; do + echo -e "\n[$PRIVATE_DNS_ZONE_NAME] private dns zone:\n" + $AZ network private-dns zone show \ + --name "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup,RecordSets:recordSets,VirtualNetworkLinks:virtualNetworkLinks}' \ + --output table \ + --only-show-errors +done + +# Check Private Endpoint +for PRIVATE_ENDPOINT_NAME in "${PE_NAMES[@]}"; do + echo -e "\n[$PRIVATE_ENDPOINT_NAME] private endpoint:\n" + $AZ network private-endpoint show \ + --name "$PRIVATE_ENDPOINT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors +done + +# Check Functions App Subnet NSG +echo -e "\n[$FUNCTION_APP_SUBNET_NSG_NAME] network security group:\n" +$AZ network nsg show \ + --name "$FUNCTION_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Private Endpoint Subnet NSG +echo -e "\n[$PE_SUBNET_NSG_NAME] network security group:\n" +$AZ network nsg show \ + --name "$PE_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# List resources +echo -e "\n[$RESOURCE_GROUP_NAME] all resources:\n" +$AZ resource list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors +``` + +## Cleanup + +To destroy all created resources: + +```bash +# Delete resource group and all contained resources +az group delete --name local-rg --yes --no-wait + +# Verify deletion +az group list --output table +``` + +This will remove all Azure resources created by the CLI deployment script. + +## Related Documentation + +- [Azure CLI Documentation](https://docs.microsoft.com/en-us/cli/azure/) +- [LocalStack for Azure Documentation](https://azure.localstack.cloud/) \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/scripts/call-http-trigger.sh b/samples/function-app-service-bus/dotnet/scripts/call-http-trigger.sh new file mode 100755 index 0000000..0ca3d53 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/scripts/call-http-trigger.sh @@ -0,0 +1,168 @@ +#!/bin/bash + +get_docker_container_name_by_prefix() { + local app_prefix="$1" + local container_name + + # Check if Docker is running + if ! docker info >/dev/null 2>&1; then + echo "Error: Docker is not running" >&2 + return 1 + fi + + echo "Looking for containers with names starting with [$app_prefix]..." >&2 + + # Find the container using grep + container_name=$(docker ps --format "{{.Names}}" | grep "^${app_prefix}" | head -1) + + if [ -z "$container_name" ]; then + echo "Error: No running container found with name starting with [$app_prefix]" >&2 + return 1 + fi + + echo "Found matching container [$container_name]" >&2 + echo "$container_name" +} + +get_docker_container_ip_address_by_name() { + local container_name="$1" + local ip_address + + if [ -z "$container_name" ]; then + echo "Error: Container name is required" >&2 + return 1 + fi + + # Get IP address + ip_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container_name") + + if [ -z "$ip_address" ]; then + echo "Error: Container [$container_name] has no IP address assigned" >&2 + return 1 + fi + + echo "$ip_address" +} + +get_docker_container_port_mapping() { + local container_name="$1" + local container_port="$2" + local host_port + + if [ -z "$container_name" ] || [ -z "$container_port" ]; then + echo "Error: Container name and container port are required" >&2 + return 1 + fi + + # Get host port mapping + host_port=$(docker inspect -f "{{(index (index .NetworkSettings.Ports \"${container_port}/tcp\") 0).HostPort}}" "$container_name") + + if [ -z "$host_port" ]; then + echo "Error: No host port mapping found for container [$container_name] port [$container_port]" >&2 + return 1 + fi + + echo "$host_port" +} + +call_http_trigger_function() { + # Get the function app name + echo "Getting function app name..." + function_app_name=$(azlocal functionapp list --query '[0].name' --output tsv) + + if [ -n "$function_app_name" ]; then + echo "Function app [$function_app_name] successfully retrieved." + else + echo "Error: No function app found" + exit 1 + fi + + # Get the resource group name + echo "Getting resource group name for function app [$function_app_name]..." + resource_group_name=$(azlocal functionapp list --query '[0].resourceGroup' --output tsv) + + if [ -n "$resource_group_name" ]; then + echo "Resource group [$resource_group_name] successfully retrieved." + else + echo "Error: No resource group found for function app [$function_app_name]" + exit 1 + fi + + # Get the the default host name of the function app + echo "Getting the default host name of the function app [$function_app_name]..." + function_host_name=$(azlocal functionapp show \ + --name "$function_app_name" \ + --resource-group "$resource_group_name" \ + --query 'defaultHostName' \ + --output tsv) + + if [ -n "$function_host_name" ]; then + echo "Function app default host name [$function_host_name] successfully retrieved." + else + echo "Error: No function app default host name found" + exit 1 + fi + + # Get the Docker container name + echo "Finding container name with prefix [ls-$function_app_name]..." + container_name=$(get_docker_container_name_by_prefix "ls-$function_app_name") + + if [ $? -eq 0 ] && [ -n "$container_name" ]; then + echo "Container [$container_name] found successfully" + else + echo "Failed to get container name" + exit 1 + fi + + # Get the container IP address + echo "Getting IP address for container [$container_name]..." + container_ip=$(get_docker_container_ip_address_by_name "$container_name") + greeting_count=10 + + if [ $? -eq 0 ] && [ -n "$container_ip" ]; then + echo "IP address [$container_ip] retrieved successfully for container [$container_name]" + else + echo "Failed to get container IP address" + exit 1 + fi + + # Get the mapped host port for function app HTTP trigger (internal port 80) + echo "Getting the host port mapped to internal port 80 in container [$container_name]..." + host_port=$(get_docker_container_port_mapping "$container_name" "80") + + if [ $? -eq 0 ] && [ -n "$host_port" ]; then + echo "Mapped host port [$host_port] retrieved successfully for container [$container_name]" + else + echo "Failed to get mapped host port for container [$container_name]" + exit 1 + fi + + # Retrieve LocalStack proxy port + proxy_port=$(curl http://localhost:4566/_localstack/proxy -s | jq '.proxy_port') + + if [ -n "$proxy_port" ]; then + # Call the GetGreetings HTTP trigger function to retrieve the last greetings via emulator + echo "Calling HTTP trigger function to retrieve the last [$greeting_count] greetings via emulator..." + curl --proxy "http://localhost:$proxy_port/" -s "http://$function_host_name/api/greetings?count=$greeting_count" | jq + else + echo "Failed to retrieve LocalStack proxy port" + fi + + if [ -n "$container_ip" ]; then + # Call the GetGreetings HTTP trigger function to retrieve the last greetings via the container IP address + echo "Calling HTTP trigger function to retrieve the last [$greeting_count] greetings via container IP address [$container_ip]..." + curl -s "http://$container_ip/api/greetings?count=$greeting_count" | jq + else + echo "Failed to retrieve container IP address" + fi + + if [ -n "$host_port" ]; then + # Call the GetGreetings HTTP trigger function to retrieve the last greetings via the host port + echo "Calling HTTP trigger function to retrieve the last [$greeting_count] greetings via host port [$host_port]..." + curl -s "http://localhost:$host_port/api/greetings?count=$greeting_count" | jq + else + echo "Failed to retrieve host port" + fi +} + +call_http_trigger_function \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/scripts/deploy.sh b/samples/function-app-service-bus/dotnet/scripts/deploy.sh new file mode 100755 index 0000000..9c2de5b --- /dev/null +++ b/samples/function-app-service-bus/dotnet/scripts/deploy.sh @@ -0,0 +1,1207 @@ +WEBAP#!/bin/bash + +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +DIAGNOSTIC_SETTINGS_NAME='default' +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" +FUNCTION_APP_SUBNET_NSG_NAME="${PREFIX}-func-subnet-nsg-${SUFFIX}" +PE_SUBNET_NSG_NAME="${PREFIX}-pe-subnet-nsg-${SUFFIX}" +NAT_GATEWAY_NAME="${PREFIX}-nat-gateway-${SUFFIX}" +PIP_PREFIX_NAME="${PREFIX}-nat-gateway-pip-prefix-${SUFFIX}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +VIRTUAL_NETWORK_ADDRESS_PREFIX="10.0.0.0/8" +FUNCTION_APP_SUBNET_NAME="func-subnet" +FUNCTION_APP_SUBNET_PREFIX="10.0.0.0/24" +PE_SUBNET_NAME="pe-subnet" +PE_SUBNET_PREFIX="10.0.1.0/24" +VIRTUAL_NETWORK_LINK_NAME="link-to-vnet" +PRIVATE_DNS_ZONE_GROUP_NAME="default" +APPLICATION_INSIGHTS_NAME="${PREFIX}-func-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-plan-${SUFFIX}" +FUNCTION_APP_NAME="${PREFIX}-func-${SUFFIX}" +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +SERVICE_BUS_NAMESPACE="${PREFIX}-service-bus-${SUFFIX}" +FUNCTIONS_VERSION="4" +RUNTIME="DOTNET-ISOLATED" +RUNTIME_VERSION="10" +SERVICE_BUS_CONNECTION_STRING='' +INPUT_QUEUE_NAME="input" +OUTPUT_QUEUE_NAME="output" +TAGS='environment=test deployment=azcli' +SUBSCRIPTION_ID=$(az account show --query id --output tsv) +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) +ENVIRONMENT=$(az account show --query environmentName --output tsv) +DEPLOY=1 +RETRY_COUNT=3 +SLEEP=5 +ZIPFILE="functionapp.zip" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +PRIVATE_DNS_ZONE_NAMES=( + "privatelink.servicebus.windows.net" + "privatelink.blob.core.windows.net" + "privatelink.queue.core.windows.net" + "privatelink.table.core.windows.net" +) +PE_NAMES=( + "${PREFIX}-service-bus-pe-${SUFFIX}" + "${PREFIX}-blob-storage-pe-${SUFFIX}" + "${PREFIX}-queue-storage-pe-${SUFFIX}" + "${PREFIX}-table-storage-pe-${SUFFIX}" +) +PE_GROUP_IDS=("namespace" "blob" "queue" "table") +PE_CONNECTION_NAMES=("servicebus-connection" "blob-connection" "queue-connection" "table-connection") +PE_DNS_ZONES=("privatelink.servicebus.windows.net" "privatelink.blob.core.windows.net" "privatelink.queue.core.windows.net" "privatelink.table.core.windows.net") +PE_DNS_ZONE_LABELS=("servicebus-zone" "blob-zone" "queue-zone" "table-zone") + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Create a resource group +echo "Checking if resource group [$RESOURCE_GROUP_NAME] exists in the subscription [$SUBSCRIPTION_NAME]..." +$AZ group show --name $RESOURCE_GROUP_NAME &>/dev/null +if [[ $? != 0 ]]; then + echo "No resource group [$RESOURCE_GROUP_NAME] exists in the subscription [$SUBSCRIPTION_NAME]" + echo "Creating resource group [$RESOURCE_GROUP_NAME] in the subscription [$SUBSCRIPTION_NAME]..." + + # Create the resource group + $AZ group create \ + --name $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Resource group [$RESOURCE_GROUP_NAME] successfully created in the subscription [$SUBSCRIPTION_NAME]" + else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME] in the subscription [$SUBSCRIPTION_NAME]" + exit + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists in the subscription [$SUBSCRIPTION_NAME]" +fi + +# Create a service bus namespace +echo "Checking if [$SERVICE_BUS_NAMESPACE] service bus namespace exists in the [$RESOURCE_GROUP_NAME] resource group..." +$AZ servicebus namespace show \ + --name $SERVICE_BUS_NAMESPACE \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$SERVICE_BUS_NAMESPACE] service bus namespace exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$SERVICE_BUS_NAMESPACE] service bus namespace in the [$RESOURCE_GROUP_NAME] resource group..." + $AZ servicebus namespace create \ + --name $SERVICE_BUS_NAMESPACE \ + --sku Premium \ + --location $LOCATION \ + --resource-group $RESOURCE_GROUP_NAME \ + --tags $TAGS 1>/dev/null + + if [ $? == 0 ]; then + echo "[$SERVICE_BUS_NAMESPACE] service bus namespace successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$SERVICE_BUS_NAMESPACE] service bus namespace in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "[$SERVICE_BUS_NAMESPACE] service bus namespace already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Create the service bus input queue +echo "Checking if [$INPUT_QUEUE_NAME] service bus queue exists in the [$SERVICE_BUS_NAMESPACE] service bus namespace..." +$AZ servicebus queue show \ + --name $INPUT_QUEUE_NAME \ + --namespace-name $SERVICE_BUS_NAMESPACE \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$INPUT_QUEUE_NAME] service bus queue exists in the [$SERVICE_BUS_NAMESPACE] service bus namespace" + echo "Creating [$INPUT_QUEUE_NAME] service bus queue in the [$SERVICE_BUS_NAMESPACE] service bus namespace..." + + $AZ servicebus queue create \ + --name $INPUT_QUEUE_NAME \ + --namespace-name $SERVICE_BUS_NAMESPACE \ + --resource-group $RESOURCE_GROUP_NAME 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$INPUT_QUEUE_NAME] service bus queue successfully created in the [$SERVICE_BUS_NAMESPACE] service bus namespace" + else + echo "Failed to create [$INPUT_QUEUE_NAME] service bus queue in the [$SERVICE_BUS_NAMESPACE] service bus namespace" + exit + fi +else + echo "[$INPUT_QUEUE_NAME] service bus queue already exists in the [$SERVICE_BUS_NAMESPACE] service bus namespace" +fi + +# Create the service bus output queue +echo "Checking if [$OUTPUT_QUEUE_NAME] service bus queue exists in the [$SERVICE_BUS_NAMESPACE] service bus namespace..." +$AZ servicebus queue show \ + --name $OUTPUT_QUEUE_NAME \ + --namespace-name $SERVICE_BUS_NAMESPACE \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$OUTPUT_QUEUE_NAME] service bus queue exists in the [$SERVICE_BUS_NAMESPACE] service bus namespace" + echo "Creating [$OUTPUT_QUEUE_NAME] service bus queue in the [$SERVICE_BUS_NAMESPACE] service bus namespace..." + + $AZ servicebus queue create \ + --name $OUTPUT_QUEUE_NAME \ + --namespace-name $SERVICE_BUS_NAMESPACE \ + --resource-group $RESOURCE_GROUP_NAME 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$OUTPUT_QUEUE_NAME] service bus queue successfully created in the [$SERVICE_BUS_NAMESPACE] service bus namespace" + else + echo "Failed to create [$OUTPUT_QUEUE_NAME] service bus queue in the [$SERVICE_BUS_NAMESPACE] service bus namespace" + exit + fi +else + echo "[$OUTPUT_QUEUE_NAME] service bus queue already exists in the [$SERVICE_BUS_NAMESPACE] service bus namespace" +fi + +# Retrieve and display connection string +SERVICE_BUS_CONNECTION_STRING=$($AZ servicebus namespace authorization-rule keys list \ + --name RootManageSharedAccessKey \ + --namespace-name $SERVICE_BUS_NAMESPACE \ + --resource-group $RESOURCE_GROUP_NAME \ + --query primaryConnectionString \ + --output tsv) + +echo "Service Bus connection string: $SERVICE_BUS_CONNECTION_STRING" + +# Get the Service Bus namespace resource id +echo "Getting [$SERVICE_BUS_NAMESPACE] service bus namespace resource id in the [$RESOURCE_GROUP_NAME] resource group..." +SERVICE_BUS_NAMESPACE_ID=$($AZ servicebus namespace show \ + --name $SERVICE_BUS_NAMESPACE \ + --resource-group $RESOURCE_GROUP_NAME \ + --query id \ + --output tsv) + +if [[ -n $SERVICE_BUS_NAMESPACE_ID ]]; then + echo "[$SERVICE_BUS_NAMESPACE] service bus namespace resource id retrieved successfully: $SERVICE_BUS_NAMESPACE_ID" +else + echo "Failed to retrieve [$SERVICE_BUS_NAMESPACE] service bus namespace resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Check if the user-assigned managed identity already exists +echo "Checking if [$MANAGED_IDENTITY_NAME] user-assigned managed identity actually exists in the [$RESOURCE_GROUP_NAME] resource group..." + +$AZ identity show \ + --name"$MANAGED_IDENTITY_NAME" \ + --resource-group $"$RESOURCE_GROUP_NAME" &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$MANAGED_IDENTITY_NAME] user-assigned managed identity actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$MANAGED_IDENTITY_NAME] user-assigned managed identity in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the user-assigned managed identity + $AZ identity create \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --subscription "$SUBSCRIPTION_ID" \ + --tags $TAGS 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$MANAGED_IDENTITY_NAME] user-assigned managed identity successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$MANAGED_IDENTITY_NAME] user-assigned managed identity in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$MANAGED_IDENTITY_NAME] user-assigned managed identity already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Retrieve the clientId of the user-assigned managed identity +echo "Retrieving clientId for [$MANAGED_IDENTITY_NAME] managed identity..." +CLIENT_ID=$($AZ identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query clientId \ + --output tsv) + +if [[ -n $CLIENT_ID ]]; then + echo "[$CLIENT_ID] clientId for the [$MANAGED_IDENTITY_NAME] managed identity successfully retrieved" +else + echo "Failed to retrieve clientId for the [$MANAGED_IDENTITY_NAME] managed identity" + exit 1 +fi + +# Retrieve the principalId of the user-assigned managed identity +echo "Retrieving principalId for [$MANAGED_IDENTITY_NAME] managed identity..." +PRINCIPAL_ID=$($AZ identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query principalId \ + --output tsv) + +if [[ -n $PRINCIPAL_ID ]]; then + echo "[$PRINCIPAL_ID] principalId for the [$MANAGED_IDENTITY_NAME] managed identity successfully retrieved" +else + echo "Failed to retrieve principalId for the [$MANAGED_IDENTITY_NAME] managed identity" + exit 1 +fi + +# Retrieve the resource id of the user-assigned managed identity +echo "Retrieving resource id for the [$MANAGED_IDENTITY_NAME] managed identity..." +IDENTITY_ID=$($AZ identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv) + +if [[ -n $IDENTITY_ID ]]; then + echo "Resource id for the [$MANAGED_IDENTITY_NAME] managed identity successfully retrieved" +else + echo "Failed to retrieve the resource id for the [$MANAGED_IDENTITY_NAME] managed identity" + exit 1 +fi + +# Assign the Azure Service Bus Data Owner role to the managed identity with the Service Bus namespace as a scope +ROLE="Azure Service Bus Data Owner" +echo "Checking if the managed identity with principal ID [$PRINCIPAL_ID] has the [$ROLE] role assignment on the Service Bus namespace [$SERVICE_BUS_NAMESPACE]..." +current=$($AZ role assignment list \ + --assignee "$PRINCIPAL_ID" \ + --scope "$SERVICE_BUS_NAMESPACE_ID" \ + --query "[?roleDefinitionName=='$ROLE'].roleDefinitionName" \ + --output tsv 2>/dev/null) + +if [[ $current == "$ROLE" ]]; then + echo "Managed identity already has the [$ROLE] role assignment on the Service Bus namespace [$SERVICE_BUS_NAMESPACE]" +else + echo "Managed identity does not have the [$ROLE] role assignment on the Service Bus namespace [$SERVICE_BUS_NAMESPACE]" + echo "Creating role assignment: assigning [$ROLE] role to managed identity on the Service Bus namespace [$SERVICE_BUS_NAMESPACE]..." + ATTEMPT=1 + while [ $ATTEMPT -le $RETRY_COUNT ]; do + echo "Attempt $ATTEMPT of $RETRY_COUNT to assign role..." + $AZ role assignment create \ + --assignee "$PRINCIPAL_ID" \ + --role "$ROLE" \ + --scope "$SERVICE_BUS_NAMESPACE_ID" 1>/dev/null + + if [[ $? == 0 ]]; then + break + else + if [ $ATTEMPT -lt $RETRY_COUNT ]; then + echo "Role assignment failed. Waiting [$SLEEP] seconds before retry..." + sleep $SLEEP + fi + ATTEMPT=$((ATTEMPT + 1)) + fi + done + + if [[ $? == 0 ]]; then + echo "Successfully assigned [$ROLE] role to managed identity on the Service Bus namespace [$SERVICE_BUS_NAMESPACE]" + else + echo "Failed to assign [$ROLE] role to managed identity on the Service Bus namespace [$SERVICE_BUS_NAMESPACE]" + exit + fi +fi + +# Check if the network security group for the function app subnet already exists +echo "Checking if [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +$AZ network nsg show \ + --name "$FUNCTION_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet..." + + # Create the network security group for the function app subnet + $AZ network nsg create \ + --name "$FUNCTION_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Get the resource id of the network security group for the function app subnet +echo "Getting [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet resource id in the [$RESOURCE_GROUP_NAME] resource group..." +FUNCTION_APP_SUBNET_NSG_ID=$($AZ network nsg show \ + --name "$FUNCTION_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $FUNCTION_APP_SUBNET_NSG_ID ]]; then + echo "[$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet resource id retrieved successfully: $FUNCTION_APP_SUBNET_NSG_ID" +else + echo "Failed to retrieve [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Check if the network security group for the private endpoint subnet already exists +echo "Checking if [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +$AZ network nsg show \ + --name "$PE_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet..." + + # Create the network security group for the private endpoint subnet + $AZ network nsg create \ + --name "$PE_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Get the resource id of the network security group for the private endpoint subnet +echo "Getting [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet resource id in the [$RESOURCE_GROUP_NAME] resource group..." +PE_SUBNET_NSG_ID=$($AZ network nsg show \ + --name "$PE_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $PE_SUBNET_NSG_ID ]]; then + echo "[$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet resource id retrieved successfully: $PE_SUBNET_NSG_ID" +else + echo "Failed to retrieve [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Check if the public IP prefix for the NAT Gateway already exists +echo "Checking if [$PIP_PREFIX_NAME] public IP prefix for the NAT Gateway actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +$AZ network public-ip prefix show \ + --name "$PIP_PREFIX_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$PIP_PREFIX_NAME] public IP prefix for the NAT Gateway actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PIP_PREFIX_NAME] public IP prefix for the NAT Gateway in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the public IP prefix for the NAT Gateway + $AZ network public-ip prefix create \ + --name "$PIP_PREFIX_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --length 31 \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$PIP_PREFIX_NAME] public IP prefix for the NAT Gateway successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$PIP_PREFIX_NAME] public IP prefix for the NAT Gateway in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$PIP_PREFIX_NAME] public IP prefix for the NAT Gateway already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the NAT Gateway already exists +echo "Checking if [$NAT_GATEWAY_NAME] NAT Gateway actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +$AZ network nat gateway show \ + --name "$NAT_GATEWAY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$NAT_GATEWAY_NAME] NAT Gateway actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$NAT_GATEWAY_NAME] NAT Gateway in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the NAT Gateway + $AZ network nat gateway create \ + --name "$NAT_GATEWAY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --public-ip-prefixes "$PIP_PREFIX_NAME" \ + --idle-timeout 4 \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$NAT_GATEWAY_NAME] NAT Gateway successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$NAT_GATEWAY_NAME] NAT Gateway in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$NAT_GATEWAY_NAME] NAT Gateway already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the virtual network already exists +echo "Checking if [$VIRTUAL_NETWORK_NAME] virtual network actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +$AZ network vnet show \ + --name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$VIRTUAL_NETWORK_NAME] virtual network actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$VIRTUAL_NETWORK_NAME] virtual network in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the virtual network + $AZ network vnet create \ + --name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --address-prefixes "$VIRTUAL_NETWORK_ADDRESS_PREFIX" \ + --subnet-name "$FUNCTION_APP_SUBNET_NAME" \ + --subnet-prefix "$FUNCTION_APP_SUBNET_PREFIX" \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$VIRTUAL_NETWORK_NAME] virtual network successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$VIRTUAL_NETWORK_NAME] virtual network in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi + + # Update the function app subnet to associate it with the NAT Gateway and the NSG + echo "Associating [$FUNCTION_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$FUNCTION_APP_SUBNET_NSG_NAME] network security group..." + + # Update the function app subnet to associate it with the NAT Gateway and the NSG + $AZ network vnet subnet update \ + --name "$FUNCTION_APP_SUBNET_NAME" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --nat-gateway "$NAT_GATEWAY_NAME" \ + --network-security-group "$FUNCTION_APP_SUBNET_NSG_NAME" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$FUNCTION_APP_SUBNET_NAME] subnet successfully associated with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$FUNCTION_APP_SUBNET_NSG_NAME] network security group" + else + echo "Failed to associate [$FUNCTION_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$FUNCTION_APP_SUBNET_NSG_NAME] network security group" + exit 1 + fi +else + echo "[$VIRTUAL_NETWORK_NAME] virtual network already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the subnet already exists +echo "Checking if [$PE_SUBNET_NAME] subnet actually exists in the [$VIRTUAL_NETWORK_NAME] virtual network..." +$AZ network vnet subnet show \ + --name "$PE_SUBNET_NAME" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$PE_SUBNET_NAME] subnet actually exists in the [$VIRTUAL_NETWORK_NAME] virtual network" + echo "Creating [$PE_SUBNET_NAME] subnet in the [$VIRTUAL_NETWORK_NAME] virtual network..." + + # Create the subnet + $AZ network vnet subnet create \ + --name "$PE_SUBNET_NAME" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --address-prefix "$PE_SUBNET_PREFIX" \ + --network-security-group "$PE_SUBNET_NSG_NAME" \ + --private-endpoint-network-policies "Disabled" \ + --private-link-service-network-policies "Disabled" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$PE_SUBNET_NAME] subnet successfully created in the [$VIRTUAL_NETWORK_NAME] virtual network" + else + echo "Failed to create [$PE_SUBNET_NAME] subnet in the [$VIRTUAL_NETWORK_NAME] virtual network" + exit + fi +else + echo "[$PE_SUBNET_NAME] subnet already exists in the [$VIRTUAL_NETWORK_NAME] virtual network" +fi + +# Retrieve the virtual network resource id +echo "Getting [$VIRTUAL_NETWORK_NAME] virtual network resource id in the [$RESOURCE_GROUP_NAME] resource group..." +VIRTUAL_NETWORK_ID=$($AZ network vnet show \ + --name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors \ + --query id \ + --output tsv) + +if [[ -n $VIRTUAL_NETWORK_ID ]]; then + echo "[$VIRTUAL_NETWORK_NAME] virtual network resource id retrieved successfully: $VIRTUAL_NETWORK_ID" +else + echo "Failed to retrieve [$VIRTUAL_NETWORK_NAME] virtual network resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit +fi + +# Create private DNS zones and virtual network links +for DNS_ZONE_NAME in "${PRIVATE_DNS_ZONE_NAMES[@]}"; do + # Check if the private DNS Zone already exists + echo "Checking if [$DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group..." + $AZ network private-dns zone show \ + --name "$DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + + if [[ $? != 0 ]]; then + echo "No [$DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$DNS_ZONE_NAME] private DNS zone in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the private DNS Zone + $AZ network private-dns zone create \ + --name "$DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DNS_ZONE_NAME] private DNS zone successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$DNS_ZONE_NAME] private DNS zone in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi + else + echo "[$DNS_ZONE_NAME] private DNS zone already exists in the [$RESOURCE_GROUP_NAME] resource group" + fi + + # Check if the virtual network link already exists + echo "Checking if [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network actually exists..." + $AZ network private-dns link vnet show \ + --name "$VIRTUAL_NETWORK_LINK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --zone-name "$DNS_ZONE_NAME" \ + --only-show-errors &>/dev/null + + if [[ $? != 0 ]]; then + echo "No [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network actually exists" + echo "Creating [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network..." + + # Create the virtual network link + $AZ network private-dns link vnet create \ + --name "$VIRTUAL_NETWORK_LINK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --zone-name "$DNS_ZONE_NAME" \ + --virtual-network "$VIRTUAL_NETWORK_ID" \ + --registration-enabled false \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network successfully created" + else + echo "Failed to create [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network" + exit + fi + else + echo "[$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network already exists" + fi +done + +# Create a storage account +echo "Checking if storage account [$STORAGE_ACCOUNT_NAME] exists in the resource group [$RESOURCE_GROUP_NAME]..." +$AZ storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No storage account [$STORAGE_ACCOUNT_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group." + echo "Creating storage account [$STORAGE_ACCOUNT_NAME] in the [$RESOURCE_GROUP_NAME] resource group..." + $AZ storage account create \ + --name $STORAGE_ACCOUNT_NAME \ + --location $LOCATION \ + --resource-group $RESOURCE_GROUP_NAME \ + --sku Standard_LRS \ + --tags $TAGS 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Storage account [$STORAGE_ACCOUNT_NAME] created successfully in the [$RESOURCE_GROUP_NAME] resource group." + else + echo "Failed to create storage account [$STORAGE_ACCOUNT_NAME] in the [$RESOURCE_GROUP_NAME] resource group." + exit 1 + fi +else + echo "Storage account [$STORAGE_ACCOUNT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group." +fi + +# Get the storage account key +echo "Getting storage account key for [$STORAGE_ACCOUNT_NAME]..." +STORAGE_ACCOUNT_KEY=$($AZ storage account keys list \ + --account-name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "[0].value" \ + --output tsv) + +if [ -n "$STORAGE_ACCOUNT_KEY" ]; then + echo "Storage account key retrieved successfully: [$STORAGE_ACCOUNT_KEY]" +else + echo "Failed to retrieve storage account key." + exit 1 +fi + +# Construct the storage connection string for LocalStack +STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=$STORAGE_ACCOUNT_NAME;AccountKey=$STORAGE_ACCOUNT_KEY;EndpointSuffix=core.windows.net" +echo "Storage connection string constructed: [$STORAGE_CONNECTION_STRING]" + +# Get the storage account resource id +echo "Getting [$STORAGE_ACCOUNT_NAME] storage account resource id in the [$RESOURCE_GROUP_NAME] resource group..." +STORAGE_ACCOUNT_ID=$($AZ storage account show \ + --name "$STORAGE_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $STORAGE_ACCOUNT_ID ]]; then + echo "[$STORAGE_ACCOUNT_NAME] storage account resource id retrieved successfully: $STORAGE_ACCOUNT_ID" +else + echo "Failed to retrieve [$STORAGE_ACCOUNT_NAME] storage account resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Create private endpoints and DNS zone groups +PE_RESOURCE_IDS=("$SERVICE_BUS_NAMESPACE_ID" "$STORAGE_ACCOUNT_ID" "$STORAGE_ACCOUNT_ID" "$STORAGE_ACCOUNT_ID") + +for i in "${!PE_NAMES[@]}"; do + PE_NAME="${PE_NAMES[$i]}" + PE_GROUP="${PE_GROUP_IDS[$i]}" + PE_RESOURCE_ID="${PE_RESOURCE_IDS[$i]}" + PE_CONNECTION="${PE_CONNECTION_NAMES[$i]}" + PE_DNS_ZONE="${PE_DNS_ZONES[$i]}" + PE_DNS_ZONE_LABEL="${PE_DNS_ZONE_LABELS[$i]}" + + # Check if the private endpoint already exists + echo "Checking if private endpoint [$PE_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group..." + privateEndpointId=$($AZ network private-endpoint list \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors \ + --query "[?name=='$PE_NAME'].id" \ + --output tsv) + + if [[ -z $privateEndpointId ]]; then + echo "Private endpoint [$PE_NAME] does not exist in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PE_NAME] private endpoint in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the private endpoint + $AZ network private-endpoint create \ + --name "$PE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --subnet "$PE_SUBNET_NAME" \ + --private-connection-resource-id "$PE_RESOURCE_ID" \ + --group-id "$PE_GROUP" \ + --connection-name "$PE_CONNECTION" \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private endpoint [$PE_NAME] successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create private endpoint [$PE_NAME] in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi + else + echo "Private endpoint [$PE_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" + fi + + # Check if the private DNS zone group is already created + echo "Checking if the private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PE_NAME] private endpoint already exists..." + NAME=$($AZ network private-endpoint dns-zone-group show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PE_NAME" \ + --name "$PRIVATE_DNS_ZONE_GROUP_NAME" \ + --query name \ + --output tsv \ + --only-show-errors) + + if [[ -z $NAME ]]; then + echo "No private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PE_NAME] private endpoint actually exists" + echo "Creating private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PE_NAME] private endpoint..." + + # Create the private DNS zone group + $AZ network private-endpoint dns-zone-group create \ + --name "$PRIVATE_DNS_ZONE_GROUP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PE_NAME" \ + --private-dns-zone "$PE_DNS_ZONE" \ + --zone-name "$PE_DNS_ZONE_LABEL" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PE_NAME] private endpoint successfully created" + else + echo "Failed to create private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PE_NAME] private endpoint" + exit + fi + else + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PE_NAME] private endpoint already exists" + fi +done + +if [ $DEPLOY -eq 0 ]; then + echo "Deployment flag is not set. Exiting deployment script." + exit 0 +fi + +# Check if the application insights component already exists +echo "Checking if [$APPLICATION_INSIGHTS_NAME] Application Insights component exists in the [$RESOURCE_GROUP_NAME] resource group..." +$AZ monitor app-insights component show \ + --app "$APPLICATION_INSIGHTS_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$APPLICATION_INSIGHTS_NAME] Application Insights component exists in the [$RESOURCE_GROUP_NAME] resource group." + echo "Creating [$APPLICATION_INSIGHTS_NAME] Application Insights component in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the application insights component + $AZ monitor app-insights component create \ + --app "$APPLICATION_INSIGHTS_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --application-type "web" \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$APPLICATION_INSIGHTS_NAME] Application Insights component created successfully in the [$RESOURCE_GROUP_NAME] resource group." + else + echo "Failed to create [$APPLICATION_INSIGHTS_NAME] Application Insights component in the [$RESOURCE_GROUP_NAME] resource group." + exit 1 + fi +else + echo "[$APPLICATION_INSIGHTS_NAME] Application Insights component already exists in the [$RESOURCE_GROUP_NAME] resource group." +fi + +# Create the app service plan +echo "Checking if app service plan [$APP_SERVICE_PLAN_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group..." +if ! $AZ appservice plan show \ + --name $APP_SERVICE_PLAN_NAME \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null; then + echo "No app service plan [$APP_SERVICE_PLAN_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group." + echo "Creating app service plan [$APP_SERVICE_PLAN_NAME] in the [$RESOURCE_GROUP_NAME] resource group..." + if $AZ appservice plan create \ + --name $APP_SERVICE_PLAN_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --sku B1 \ + --is-linux \ + --tags $TAGS \ + --only-show-errors 1>/dev/null; then + echo "App service plan [$APP_SERVICE_PLAN_NAME] created successfully in the [$RESOURCE_GROUP_NAME] resource group." + else + echo "Failed to create app service plan [$APP_SERVICE_PLAN_NAME] in the [$RESOURCE_GROUP_NAME] resource group." + exit 1 + fi +else + echo "App service plan [$APP_SERVICE_PLAN_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group." +fi + +# Get the app service plan resource id +echo "Getting [$APP_SERVICE_PLAN_NAME] app service plan resource id in the [$RESOURCE_GROUP_NAME] resource group..." +APP_SERVICE_PLAN_ID=$($AZ appservice plan show \ + --name "$APP_SERVICE_PLAN_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $APP_SERVICE_PLAN_ID ]]; then + echo "[$APP_SERVICE_PLAN_NAME] app service plan resource id retrieved successfully: $APP_SERVICE_PLAN_ID" +else + echo "Failed to retrieve [$APP_SERVICE_PLAN_NAME] app service plan resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Check if the function app already exists +echo "Checking if function app [$FUNCTION_APP_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group..." +if ! $AZ functionapp show \ + --name $FUNCTION_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null; then + echo "No function app [$FUNCTION_APP_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group." + echo "Creating function app [$FUNCTION_APP_NAME] in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the function app + $AZ functionapp create \ + --resource-group $RESOURCE_GROUP_NAME \ + --plan $APP_SERVICE_PLAN_NAME \ + --assign-identity "$IDENTITY_ID" \ + --runtime $RUNTIME \ + --runtime-version $RUNTIME_VERSION \ + --functions-version $FUNCTIONS_VERSION \ + --name $FUNCTION_APP_NAME \ + --os-type linux \ + --app-insights "$APPLICATION_INSIGHTS_NAME" \ + --storage-account $STORAGE_ACCOUNT_NAME \ + --vnet "$VIRTUAL_NETWORK_NAME" \ + --subnet "$FUNCTION_APP_SUBNET_NAME" \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Function app [$FUNCTION_APP_NAME] created successfully." + else + echo "Failed to create function app [$FUNCTION_APP_NAME]." + exit 1 + fi +else + echo "Function app [$FUNCTION_APP_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group." +fi + +# Get the function app resource id +echo "Getting [$FUNCTION_APP_NAME] function app resource id in the [$RESOURCE_GROUP_NAME] resource group..." +FUNCTION_APP_ID=$($AZ functionapp show \ + --name "$FUNCTION_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $FUNCTION_APP_ID ]]; then + echo "[$FUNCTION_APP_NAME] function app resource id retrieved successfully: $FUNCTION_APP_ID" +else + echo "Failed to retrieve [$FUNCTION_APP_NAME] function app resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Enable forced tunneling for the function app to route all outbound traffic through the virtual network and thus through the NAT Gateway +echo "Enabling forced tunneling for function app [$FUNCTION_APP_NAME] to route all outbound traffic through the virtual network..." + +$AZ resource update \ + --ids "$FUNCTION_APP_ID" \ + --set properties.outboundVnetRouting.allTraffic=true \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Forced tunneling enabled for function app [$FUNCTION_APP_NAME]." +else + echo "Failed to enable forced tunneling for function app [$FUNCTION_APP_NAME]." + exit 1 +fi + +# Set function app settings +echo "Setting function app settings for [$FUNCTION_APP_NAME]..." +$AZ functionapp config appsettings set \ + --name $FUNCTION_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --settings \ + AZURE_CLIENT_ID="$CLIENT_ID" \ + SCM_DO_BUILD_DURING_DEPLOYMENT=false \ + FUNCTIONS_WORKER_RUNTIME=${RUNTIME,,} \ + FUNCTIONS_EXTENSION_VERSION=~$FUNCTIONS_VERSION \ + AzureWebJobsStorage="$STORAGE_CONNECTION_STRING" \ + SERVICE_BUS_CONNECTION_STRING__fullyQualifiedNamespace="${SERVICE_BUS_NAMESPACE,,}.servicebus.windows.net" \ + INPUT_QUEUE_NAME="input" \ + OUTPUT_QUEUE_NAME="output" \ + NAMES="Paolo,John,Jane,Max,Mary,Leo,Mia,Anna,Lisa,Anastasia" \ + TIMER_SCHEDULE="*/10 * * * * *" 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Function app settings for [$FUNCTION_APP_NAME] set successfully." +else + echo "Failed to set function app settings for [$FUNCTION_APP_NAME]." + exit 1 +fi + +# Check if the log analytics workspace already exists +echo "Checking if [$LOG_ANALYTICS_NAME] Log Analytics workspace already exists in the [$RESOURCE_GROUP_NAME] resource group..." +$AZ monitor log-analytics workspace show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --workspace-name "$LOG_ANALYTICS_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$LOG_ANALYTICS_NAME] Log Analytics workspace actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$LOG_ANALYTICS_NAME] Log Analytics workspace in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the Log Analytics workspace + $AZ monitor log-analytics workspace create \ + --name "$LOG_ANALYTICS_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --query-access "Enabled" \ + --retention-time 30 \ + --sku "PerNode" \ + --tags $TAGS \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$LOG_ANALYTICS_NAME] Log Analytics workspace successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$LOG_ANALYTICS_NAME] Log Analytics workspace in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$LOG_ANALYTICS_NAME] Log Analytics workspace already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check whether the diagnostic settings for the function app already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_NAME] function app already exist..." +$AZ monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$FUNCTION_APP_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_NAME] function app actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_NAME] function app..." + + # Create the diagnostic settings for the function app to send logs to the Log Analytics workspace + $AZ monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$FUNCTION_APP_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "FunctionAppLogs", "enabled": true}, + {"category": "AppServiceAuthenticationLogs", "enabled": true} + ]' \ + --metrics '[ + {"category": "AllMetrics", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_NAME] function app successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_NAME] function app" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_NAME] function app already exist" +fi + +# Check whether the diagnostic settings for the app service plan already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_SERVICE_PLAN_NAME] app service plan already exist..." +$AZ monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$APP_SERVICE_PLAN_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_SERVICE_PLAN_NAME] app service plan actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_SERVICE_PLAN_NAME] app service plan..." + + # Create the diagnostic settings for the app service plan to send logs to the Log Analytics workspace + $AZ monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$APP_SERVICE_PLAN_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --metrics '[ + {"category": "AllMetrics", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_SERVICE_PLAN_NAME] app service plan successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_SERVICE_PLAN_NAME] app service plan" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_SERVICE_PLAN_NAME] app service plan already exist" +fi + +# Check whether the diagnostic settings for the service bus namespace already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$SERVICE_BUS_NAMESPACE] service bus namespace already exist..." +$AZ monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$SERVICE_BUS_NAMESPACE_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$SERVICE_BUS_NAMESPACE] service bus namespace actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$SERVICE_BUS_NAMESPACE] service bus namespace..." + + # Create the diagnostic settings for the service bus namespace to send logs to the Log Analytics workspace + $AZ monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$SERVICE_BUS_NAMESPACE_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "ApplicationMetricsLogs", "enabled": true}, + {"category": "DiagnosticErrorLogs", "enabled": true}, + {"category": "OperationalLogs", "enabled": true}, + {"category": "RuntimeAuditLogs", "enabled": true} + ]' \ + --metrics '[ + {"category": "AllMetrics", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$SERVICE_BUS_NAMESPACE] service bus namespace successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$SERVICE_BUS_NAMESPACE] service bus namespace" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$SERVICE_BUS_NAMESPACE] service bus namespace already exist" +fi + +# Check whether the diagnostic settings for the virtual network already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$VIRTUAL_NETWORK_NAME] virtual network already exist..." +$AZ monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$VIRTUAL_NETWORK_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$VIRTUAL_NETWORK_NAME] virtual network actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$VIRTUAL_NETWORK_NAME] virtual network..." + + # Create the diagnostic settings for the virtual network to send logs to the Log Analytics workspace + $AZ monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$VIRTUAL_NETWORK_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "VMProtectionAlerts", "enabled": true} + ]' \ + --metrics '[ + {"category": "AllMetrics", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$VIRTUAL_NETWORK_NAME] virtual network successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$VIRTUAL_NETWORK_NAME] virtual network" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$VIRTUAL_NETWORK_NAME] virtual network already exist" +fi + +# Check whether the diagnostic settings for the network security group for the function app subnet already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet already exist..." +$AZ monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$FUNCTION_APP_SUBNET_NSG_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet..." + + # Create the diagnostic settings for the network security group for the function app subnet to send logs to the Log Analytics workspace + $AZ monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$FUNCTION_APP_SUBNET_NSG_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "NetworkSecurityGroupEvent", "enabled": true}, + {"category": "NetworkSecurityGroupRuleCounter", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$FUNCTION_APP_SUBNET_NSG_NAME] network security group for the function app subnet already exist" +fi + +# Check whether the diagnostic settings for the network security group for the private endpoint subnet already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet already exist..." +$AZ monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$PE_SUBNET_NSG_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet..." + + # Create the diagnostic settings for the network security group for the private endpoint subnet to send logs to the Log Analytics workspace + $AZ monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$PE_SUBNET_NSG_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "NetworkSecurityGroupEvent", "enabled": true}, + {"category": "NetworkSecurityGroupRuleCounter", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$PE_SUBNET_NSG_NAME] network security group for the private endpoint subnet already exist" +fi + +# CD into the function app directory +cd ../src || exit + +# Remove any existing zip package of the function app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Build and publish the function app +echo "Building function app [$FUNCTION_APP_NAME]..." +if dotnet publish -c Release -o ./publish; then + echo "Function app [$FUNCTION_APP_NAME] built successfully." +else + echo "Failed to build function app [$FUNCTION_APP_NAME]." + exit 1 +fi + +# Create the zip package of the publish output +echo "Creating zip package of the function app..." +cd ./publish || exit +zip -r "../$ZIPFILE" . +cd .. + +# Deploy the function app +echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$ZIPFILE]..." +if $AZ functionapp deployment source config-zip \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$FUNCTION_APP_NAME" \ + --src "$ZIPFILE" 1>/dev/null; then + echo "Function app [$FUNCTION_APP_NAME] deployed successfully." +else + echo "Failed to deploy function app [$FUNCTION_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the function app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Print the list of resources in the resource group +echo "Listing resources in resource group [$RESOURCE_GROUP_NAME]..." +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/scripts/validate.sh b/samples/function-app-service-bus/dotnet/scripts/validate.sh new file mode 100755 index 0000000..c1dc8dc --- /dev/null +++ b/samples/function-app-service-bus/dotnet/scripts/validate.sh @@ -0,0 +1,166 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +FUNCTION_APP_SUBNET_NSG_NAME="${PREFIX}-func-subnet-nsg-${SUFFIX}" +PE_SUBNET_NSG_NAME="${PREFIX}-pe-subnet-nsg-${SUFFIX}" +NAT_GATEWAY_NAME="${PREFIX}-nat-gateway-${SUFFIX}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-plan-${SUFFIX}" +FUNCTION_APP_NAME="${PREFIX}-func-${SUFFIX}" +SERVICE_BUS_NAMESPACE_NAME="${PREFIX}-service-bus-${SUFFIX}" +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +APPLICATION_INSIGHTS_NAME="${PREFIX}-func-${SUFFIX}" +ENVIRONMENT=$(az account show --query environmentName --output tsv) +PRIVATE_DNS_ZONE_NAMES=( + "privatelink.servicebus.windows.net" + "privatelink.blob.core.windows.net" + "privatelink.queue.core.windows.net" + "privatelink.table.core.windows.net" +) +PE_NAMES=( + "${PREFIX}-service-bus-pe-${SUFFIX}" + "${PREFIX}-blob-storage-pe-${SUFFIX}" + "${PREFIX}-queue-storage-pe-${SUFFIX}" + "${PREFIX}-table-storage-pe-${SUFFIX}" +) + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +$AZ group show \ + --name "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check App Service Plan +echo -e "\n[$APP_SERVICE_PLAN_NAME] app service plan:\n" +$AZ appservice plan show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --output table \ + --only-show-errors + +# Check Azure Functions App +echo -e "\n[$FUNCTION_APP_NAME] function app:\n" +$AZ functionapp show \ + --name "$FUNCTION_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Service Bus Namespace +echo -e "\n[$SERVICE_BUS_NAMESPACE_NAME] service bus namespace:\n" +$AZ servicebus namespace show \ + --name "$SERVICE_BUS_NAMESPACE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --query '{Name:name,ServiceBusEndpoint:serviceBusEndpoint}' \ + --only-show-errors + +# Check Service Bus Queues +echo -e "\n[$SERVICE_BUS_NAMESPACE_NAME] service bus queues:\n" +$AZ servicebus queue list \ + --namespace-name "$SERVICE_BUS_NAMESPACE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --query '[].{Name:name,Status:status}' \ + --only-show-errors + + # Check Application Insights +echo -e "\n[$APPLICATION_INSIGHTS_NAME] application insights:\n" +$AZ monitor app-insights component show \ + --app "$APPLICATION_INSIGHTS_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check Storage Account +echo -e "\n[$STORAGE_ACCOUNT_NAME] storage account:\n" +$AZ storage account show \ + --name "$STORAGE_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:primaryLocation,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check Log Analytics Workspace +echo -e "\n[$LOG_ANALYTICS_NAME] log analytics workspace:\n" +$AZ monitor log-analytics workspace show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --workspace-name "$LOG_ANALYTICS_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check NAT Gateway +echo -e "\n[$NAT_GATEWAY_NAME] nat gateway:\n" +$AZ network nat gateway show \ + --name "$NAT_GATEWAY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Virtual Network +echo -e "\n[$VIRTUAL_NETWORK_NAME] virtual network:\n" +$AZ network vnet show \ + --name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Private DNS Zone +for PRIVATE_DNS_ZONE_NAME in "${PRIVATE_DNS_ZONE_NAMES[@]}"; do + echo -e "\n[$PRIVATE_DNS_ZONE_NAME] private dns zone:\n" + $AZ network private-dns zone show \ + --name "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup,RecordSets:recordSets,VirtualNetworkLinks:virtualNetworkLinks}' \ + --output table \ + --only-show-errors +done + +# Check Private Endpoint +for PRIVATE_ENDPOINT_NAME in "${PE_NAMES[@]}"; do + echo -e "\n[$PRIVATE_ENDPOINT_NAME] private endpoint:\n" + $AZ network private-endpoint show \ + --name "$PRIVATE_ENDPOINT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors +done + +# Check Functions App Subnet NSG +echo -e "\n[$FUNCTION_APP_SUBNET_NSG_NAME] network security group:\n" +$AZ network nsg show \ + --name "$FUNCTION_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Private Endpoint Subnet NSG +echo -e "\n[$PE_SUBNET_NSG_NAME] network security group:\n" +$AZ network nsg show \ + --name "$PE_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# List resources +echo -e "\n[$RESOURCE_GROUP_NAME] all resources:\n" +$AZ resource list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs b/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs new file mode 100644 index 0000000..8e0a41b --- /dev/null +++ b/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs @@ -0,0 +1,502 @@ +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 buffer storing the last 100 greetings + private const int MaxGreetingHistory = 100; + private static readonly string[] _greetingHistory = new string[MaxGreetingHistory]; + private static int _greetingIndex = 0; + private static int _greetingCount = 0; + private static readonly object _greetingLock = new(); + + // 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); + + // 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); + } + 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); + } + 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 (_greetingLock) + { + _greetingHistory[_greetingIndex] = greeting; + _greetingIndex = (_greetingIndex + 1) % MaxGreetingHistory; + if (_greetingCount < MaxGreetingHistory) + _greetingCount++; + } + + 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} greetings.", count); + + // Clamp count to valid range + if (count < 1) count = 1; + if (count > MaxGreetingHistory) count = MaxGreetingHistory; + + string[] result; + lock (_greetingLock) + { + var available = Math.Min(count, _greetingCount); + result = new string[available]; + + // Read backwards from the most recent entry + for (int i = 0; i < available; i++) + { + var idx = (_greetingIndex - 1 - i + MaxGreetingHistory) % MaxGreetingHistory; + result[i] = _greetingHistory[idx]; + } + } + + var response = request.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "application/json"); + await response.WriteStringAsync(JsonSerializer.Serialize(result)); + return response; + } +} + +/// +/// 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 new file mode 100644 index 0000000..c76837e --- /dev/null +++ b/samples/function-app-service-bus/dotnet/src/Program.cs @@ -0,0 +1,7 @@ +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + +host.Run(); diff --git a/samples/function-app-service-bus/dotnet/src/host.json b/samples/function-app-service-bus/dotnet/src/host.json new file mode 100644 index 0000000..007828f --- /dev/null +++ b/samples/function-app-service-bus/dotnet/src/host.json @@ -0,0 +1,39 @@ +{ + "version": "2.0", + "extensions": { + "serviceBus": { + "clientRetryOptions": { + "mode": "exponential", + "tryTimeout": "00:00:10", + "delay": "00:00:00.80", + "maxDelay": "00:01:00", + "maxRetries": 3 + }, + "prefetchCount": 0, + "transportType": "amqpTcp", + "autoCompleteMessages": true, + "maxAutoLockRenewalDuration": "00:05:00", + "maxConcurrentCalls": 16, + "maxConcurrentSessions": 8, + "maxMessageBatchSize": 1000, + "minMessageBatchSize": 1, + "maxBatchWaitTime": "00:00:30", + "sessionIdleTimeout": "00:01:00", + "enableCrossEntityTransactions": false + } + }, + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + }, + "logLevel": { + "Host.Startup": "Trace", + "Host.Results": "Trace", + "Function": "Trace" + } + } +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/src/local.settings.json b/samples/function-app-service-bus/dotnet/src/local.settings.json new file mode 100644 index 0000000..3a27b26 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/src/local.settings.json @@ -0,0 +1,12 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "DefaultEndpointsProtocol=http;AccountName=regmanaged100;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://regmanaged100blob.localhost.localstack.cloud:4566;QueueEndpoint=http://regmanaged100queue.localhost.localstack.cloud:4566;TableEndpoint=http://regmanaged100table.localhost.localstack.cloud:4566", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "SERVICE_BUS_CONNECTION_STRING": "", + "INPUT_QUEUE_NAME": "input", + "OUTPUT_QUEUE_NAME": "output", + "NAMES": "Alice,Paolo,Leo,Mia", + "TIMER_SCHEDULE": "*/10 * * * * *" + } +} diff --git a/samples/function-app-service-bus/dotnet/src/sample.csproj b/samples/function-app-service-bus/dotnet/src/sample.csproj new file mode 100644 index 0000000..8b21d36 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/src/sample.csproj @@ -0,0 +1,31 @@ + + + net10.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/README.md b/samples/function-app-service-bus/dotnet/terraform/README.md new file mode 100644 index 0000000..49b36e3 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/README.md @@ -0,0 +1,321 @@ +# Terraform Deployment + +This directory contains Terraform modules and a deployment script for provisioning Azure services in LocalStack for Azure. For further details about the sample application, refer to the [Azure Functions App with Service Bus Messaging](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [LocalStack for Azure](https://azure.localstack.cloud/): Local Azure cloud emulator for development and testing +- [Visual Studio Code](https://code.visualstudio.com/): Code editor installed on one of the [supported platforms](https://code.visualstudio.com/docs/supporting/requirements#_platforms) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [Azlocal CLI](https://azure.localstack.cloud/user-guides/sdks/az/): LocalStack Azure CLI wrapper +- [Azure Functions Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) is required to build, run, and deploy the Azure Functions app locally +- [Terraform](https://developer.hashicorp.com/terraform/downloads), if you plan to install the sample via Terraform. +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [.NET SDK](https://dotnet.microsoft.com/en-us/download) is required to compile and run the C# Azure Functions project +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing azlocal CLI + +The [deploy.sh](deploy.sh) Bash script uses the `azlocal` CLI instead of the standard Azure CLI to work with LocalStack. Install it using: + +```bash +pip install azlocal +``` + +For more information, see [Get started with the az tool on LocalStack](https://azure.localstack.cloud/user-guides/sdks/az/). + +## Architecture Overview + +The Terraform modules create the following Azure resources: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): A logical container scoping all resources in this sample. +2. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview): Hosts two subnets: + - *app-subnet*: Dedicated to [regional VNet integration](https://learn.microsoft.com/azure/azure-functions/functions-networking-options?tabs=azure-portal#outbound-networking-features) with the Function App. + - *pe-subnet*: Used for hosting Azure Private Endpoints. +3. [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone): Provide internal DNS resolution so that resources within the virtual network can reach Private Endpoints by hostname rather than public addresses. There is a separate Azure Private DNS Zone for the following resource types: + - Azure Service Bus namespace + - Azure Blob Storage + - Azure Queue Storage + - Azure Table Storage +4. [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview): Provide secure, private network connectivity to Azure resources by exposing them through private IP addresses within the virtual network, eliminating the need for traffic to traverse the public internet. There is a separate Azure Private Endpoint for the following resources: + - Azure Service Bus namespace + - Azure Blob Storage + - Azure Queue Storage + - Azure Table Storage +5. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview): Provides deterministic outbound connectivity and a stable public IP address for the Function App's outbound traffic. Included for architectural completeness; the sample app itself does not call any external services. +6. [Azure Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): Enforces inbound and outbound traffic rules across the virtual network's subnets. +7. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview): Centralizes diagnostic logs and metrics from all resources in the solution, enabling unified querying and analysis across the entire deployment. +8. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview-hosting-plans): Defines the underlying compute tier and scaling behavior for the function app. +9. [Azure Functions App](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview): Hosts the sample function app. +10. [Azure Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview): Provides application performance monitoring (APM), collecting and analyzing requests, traces, and metrics generated by the function app to surface performance bottlenecks and failures. +11. [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview): A fully managed enterprise message broker. This namespace hosts the `input` and `output` queues used by the function app to exchange messages asynchronously. +12. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides durable storage used internally by the Azure Functions runtime for state management, including distributed locks, checkpoints, and timer trigger coordination. +13. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): This identity is assigned the necessary RBAC roles and is used by the function app to authenticate securely—without storing credentials—to the following Azure resources: + - Azure Service Bus namespace + - Azure Storage + - Azure Application Insights + +For more information on the sample application, see [Azure Functions App with Service Bus Messaging](../README.md). + +## Provisioning Scripts + +You can use the [deploy.sh](deploy.sh) script to automate the deployment of all Azure resources and the sample application in a single step, streamlining setup and reducing manual configuration. The script executes the following steps: + +- Cleans up any previous Terraform state and plan files to ensure a fresh deployment. +- Initializes the Terraform working directory and downloads required plugins. +- Creates and validates a Terraform execution plan for the Azure infrastructure. +- Applies the Terraform plan to provision all necessary Azure resources. +- Extracts resource names and outputs from the Terraform deployment. +- Packages the code of the function app into a zip file for deployment. +- Deploys the zip package to the Azure Functions App using the LocalStack Azure CLI. + +## Configuration + +When using LocalStack for Azure, configure the `metadata_host` and `subscription_id` settings in the [Azure Provider for Terraform](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) to ensure proper connectivity: + + +```hcl +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } + + # Set the hostname of the Azure Metadata Service (for example management.azure.com) + # used to obtain the Cloud Environment when using LocalStack's Azure emulator. + # This allows the provider to correctly identify the environment and avoid making calls to the real Azure endpoints. + metadata_host="localhost.localstack.cloud:4566" + + # Set the subscription ID to a dummy value when using LocalStack's Azure emulator. + subscription_id = "00000000-0000-0000-0000-000000000000" +} +``` + +## Deployment + +You can set up the Azure emulator by utilizing 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/?__hstc=108988063.8aad2b1a7229945859f4d9b9bb71e05d.1743148429561.1758793541854.1758810151462.32&__hssc=108988063.3.1758810151462&__hsfp=3945774529) to obtain your Auth Token and specify 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-alpha). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure-alpha +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure-alpha localstack start +``` + +Navigate to the `terraform` folder: + +```bash +cd samples/function-app-service-bus/dotnet/terraform +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +Once the deployment completes, run the [validate.sh](../scripts/validate.sh) script to confirm that all resources were provisioned and configured as expected: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +FUNCTION_APP_SUBNET_NSG_NAME="${PREFIX}-func-subnet-nsg-${SUFFIX}" +PE_SUBNET_NSG_NAME="${PREFIX}-pe-subnet-nsg-${SUFFIX}" +NAT_GATEWAY_NAME="${PREFIX}-nat-gateway-${SUFFIX}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-plan-${SUFFIX}" +FUNCTION_APP_NAME="${PREFIX}-func-${SUFFIX}" +SERVICE_BUS_NAMESPACE_NAME="${PREFIX}-service-bus-${SUFFIX}" +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +APPLICATION_INSIGHTS_NAME="${PREFIX}-func-${SUFFIX}" +ENVIRONMENT=$(az account show --query environmentName --output tsv) +PRIVATE_DNS_ZONE_NAMES=( + "privatelink.servicebus.windows.net" + "privatelink.blob.core.windows.net" + "privatelink.queue.core.windows.net" + "privatelink.table.core.windows.net" +) +PE_NAMES=( + "${PREFIX}-service-bus-pe-${SUFFIX}" + "${PREFIX}-blob-storage-pe-${SUFFIX}" + "${PREFIX}-queue-storage-pe-${SUFFIX}" + "${PREFIX}-table-storage-pe-${SUFFIX}" +) + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +$AZ group show \ + --name "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check App Service Plan +echo -e "\n[$APP_SERVICE_PLAN_NAME] app service plan:\n" +$AZ appservice plan show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --output table \ + --only-show-errors + +# Check Azure Functions App +echo -e "\n[$FUNCTION_APP_NAME] function app:\n" +$AZ functionapp show \ + --name "$FUNCTION_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Service Bus Namespace +echo -e "\n[$SERVICE_BUS_NAMESPACE_NAME] service bus namespace:\n" +$AZ servicebus namespace show \ + --name "$SERVICE_BUS_NAMESPACE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --query '{Name:name,ServiceBusEndpoint:serviceBusEndpoint}' \ + --only-show-errors + +# Check Service Bus Queues +echo -e "\n[$SERVICE_BUS_NAMESPACE_NAME] service bus queues:\n" +$AZ servicebus queue list \ + --namespace-name "$SERVICE_BUS_NAMESPACE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --query '[].{Name:name,Status:status}' \ + --only-show-errors + + # Check Application Insights +echo -e "\n[$APPLICATION_INSIGHTS_NAME] application insights:\n" +$AZ monitor app-insights component show \ + --app "$APPLICATION_INSIGHTS_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check Storage Account +echo -e "\n[$STORAGE_ACCOUNT_NAME] storage account:\n" +$AZ storage account show \ + --name "$STORAGE_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:primaryLocation,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check Log Analytics Workspace +echo -e "\n[$LOG_ANALYTICS_NAME] log analytics workspace:\n" +$AZ monitor log-analytics workspace show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --workspace-name "$LOG_ANALYTICS_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check NAT Gateway +echo -e "\n[$NAT_GATEWAY_NAME] nat gateway:\n" +$AZ network nat gateway show \ + --name "$NAT_GATEWAY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Virtual Network +echo -e "\n[$VIRTUAL_NETWORK_NAME] virtual network:\n" +$AZ network vnet show \ + --name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Private DNS Zone +for PRIVATE_DNS_ZONE_NAME in "${PRIVATE_DNS_ZONE_NAMES[@]}"; do + echo -e "\n[$PRIVATE_DNS_ZONE_NAME] private dns zone:\n" + $AZ network private-dns zone show \ + --name "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup,RecordSets:recordSets,VirtualNetworkLinks:virtualNetworkLinks}' \ + --output table \ + --only-show-errors +done + +# Check Private Endpoint +for PRIVATE_ENDPOINT_NAME in "${PE_NAMES[@]}"; do + echo -e "\n[$PRIVATE_ENDPOINT_NAME] private endpoint:\n" + $AZ network private-endpoint show \ + --name "$PRIVATE_ENDPOINT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors +done + +# Check Functions App Subnet NSG +echo -e "\n[$FUNCTION_APP_SUBNET_NSG_NAME] network security group:\n" +$AZ network nsg show \ + --name "$FUNCTION_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Private Endpoint Subnet NSG +echo -e "\n[$PE_SUBNET_NSG_NAME] network security group:\n" +$AZ network nsg show \ + --name "$PE_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# List resources +echo -e "\n[$RESOURCE_GROUP_NAME] all resources:\n" +$AZ resource list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors +``` + +## Cleanup + +To destroy all created resources: + +```bash +# Delete resource group and all contained resources +az group delete --name local-rg --yes --no-wait + +# Verify deletion +az group list --output table +``` + +This will remove all Azure resources created by the CLI deployment script. + +## Related Documentation + +- [Terraform Azure Provider](https://registry.terraform.io/providers/hashicorp/azurerm/latest) +- [LocalStack for Azure Documentation](https://azure.localstack.cloud/) \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/deploy.sh b/samples/function-app-service-bus/dotnet/terraform/deploy.sh new file mode 100755 index 0000000..2252d12 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/deploy.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="functionapp.zip" +ENVIRONMENT=$(az account show --query environmentName --output tsv) + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Run terraform init and apply +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard terraform and az for AzureCloud environment." + AZ="az" +fi + +# Intialize Terraform +echo "Initializing Terraform..." +terraform init -upgrade + +# Run terraform plan and check for errors +echo "Planning Terraform deployment..." +terraform plan -out=tfplan \ + -var "prefix=$PREFIX" \ + -var "suffix=$SUFFIX" \ + -var "location=$LOCATION" + +if [[ $? != 0 ]]; then + echo "Terraform plan failed. Exiting." + exit 1 +fi + +# Apply the Terraform configuration +echo "Applying Terraform configuration..." +terraform apply -auto-approve tfplan + +if [[ $? != 0 ]]; then + echo "Terraform apply failed. Exiting." + exit 1 +fi + +# Get the output values +RESOURCE_GROUP_NAME=$(terraform output -raw resource_group_name) +FUNCTION_APP_NAME=$(terraform output -raw function_app_name) +SERVICE_BUS_NAMESPACE=$(terraform output -raw service_bus_namespace_name) + +if [[ -z "$RESOURCE_GROUP_NAME" || -z "$FUNCTION_APP_NAME" || -z "$SERVICE_BUS_NAMESPACE" ]]; then + echo "Resource Group Name, Function App Name, or Service Bus Namespace is empty. Exiting." + exit 1 +fi + +# Print the application settings of the function app +echo "Retrieving application settings for function app [$FUNCTION_APP_NAME]..." +$AZ functionapp config appsettings list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$FUNCTION_APP_NAME" + +# CD into the function app directory +cd ../src || exit + +# Remove any existing zip package of the function app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Build and publish the function app +echo "Building function app [$FUNCTION_APP_NAME]..." +if dotnet publish -c Release -o ./publish; then + echo "Function app [$FUNCTION_APP_NAME] built successfully." +else + echo "Failed to build function app [$FUNCTION_APP_NAME]." + exit 1 +fi + +# Create the zip package of the publish output +echo "Creating zip package of the function app..." +cd ./publish || exit +zip -r "../$ZIPFILE" . +cd .. + +# Deploy the function app +echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$ZIPFILE]..." +if $AZ functionapp deployment source config-zip \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$FUNCTION_APP_NAME" \ + --src "$ZIPFILE" 1>/dev/null; then + echo "Function app [$FUNCTION_APP_NAME] deployed successfully." +else + echo "Failed to deploy function app [$FUNCTION_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the function app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Print the list of resources in the resource group +echo "Listing resources in resource group [$RESOURCE_GROUP_NAME]..." +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/main.tf b/samples/function-app-service-bus/dotnet/terraform/main.tf new file mode 100644 index 0000000..7d0c309 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/main.tf @@ -0,0 +1,340 @@ +# Local Variables +locals { + prefix = lower(var.prefix) + suffix = lower(var.suffix) + resource_group_name = "${var.prefix}-rg" + log_analytics_name = "${local.prefix}-log-analytics-${local.suffix}" + storage_account_name = "${local.prefix}storage${local.suffix}" + virtual_network_name = "${local.prefix}-vnet-${local.suffix}" + nat_gateway_name = "${local.prefix}-nat-gateway-${local.suffix}" + service_bus_private_endpoint_name = "${local.prefix}-service-bus-pe-${local.suffix}" + blob_storage_private_endpoint_name = "${local.prefix}-blob-storage-pe-${local.suffix}" + queue_storage_private_endpoint_name = "${local.prefix}-queue-storage-pe-${local.suffix}" + table_storage_private_endpoint_name = "${local.prefix}-table-storage-pe-${local.suffix}" + network_security_group_name = "${local.prefix}-default-nsg-${local.suffix}" + cosmosdb_account_name = "${local.prefix}-mongodb-${local.suffix}" + service_bus_namespace_name = "${local.prefix}-service-bus-${local.suffix}" + app_service_plan_name = "${local.prefix}-app-service-plan-${local.suffix}" + function_app_name = "${local.prefix}-func-${local.suffix}" + application_insights_name = "${local.prefix}-func-${local.suffix}" + managed_identity_name = "${local.prefix}-identity-${local.suffix}" + private_dns_zone_group_name = "private-dns-zone-group" +} + +# Data Sources +data "azurerm_client_config" "current" { +} + +# Create a resource group +resource "azurerm_resource_group" "example" { + name = local.resource_group_name + location = var.location + tags = var.tags +} + +# Create a log analytics workspace +module "log_analytics_workspace" { + source = "./modules/log_analytics" + name = local.log_analytics_name + location = var.location + resource_group_name = azurerm_resource_group.example.name + tags = var.tags +} + +# Create a virtual network with subnets +module "virtual_network" { + source = "./modules/virtual_network" + resource_group_name = azurerm_resource_group.example.name + location = var.location + vnet_name = local.virtual_network_name + address_space = var.vnet_address_space + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags + + subnets = [ + { + name : var.webapp_subnet_name + address_prefixes : var.webapp_subnet_address_prefix + private_endpoint_network_policies : "Enabled" + private_link_service_network_policies_enabled : false + delegation : "Microsoft.Web/serverFarms" + }, + { + name : var.pe_subnet_name + address_prefixes : var.pe_subnet_address_prefix + private_endpoint_network_policies : "Enabled" + private_link_service_network_policies_enabled : false + delegation : null + } + ] +} + +# Create a network security group and associate it with both subnets +module "network_security_group" { + source = "./modules/network_security_group" + name = local.network_security_group_name + resource_group_name = azurerm_resource_group.example.name + location = var.location + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags + subnet_ids = { + (var.webapp_subnet_name) = module.virtual_network.subnet_ids[var.webapp_subnet_name] + (var.pe_subnet_name) = module.virtual_network.subnet_ids[var.pe_subnet_name] + } + +} + +# Create a NAT gateway and associate it with the default subnet +module "nat_gateway" { + source = "./modules/nat_gateway" + name = local.nat_gateway_name + resource_group_name = azurerm_resource_group.example.name + location = var.location + sku_name = var.nat_gateway_sku_name + idle_timeout_in_minutes = var.nat_gateway_idle_timeout_in_minutes + zones = var.nat_gateway_zones + subnet_ids = { + (var.webapp_subnet_name) = module.virtual_network.subnet_ids[var.webapp_subnet_name] + } + tags = var.tags +} + +# Create a storage account +module "storage_account" { + source = "./modules/storage_account" + name = local.storage_account_name + resource_group_name = azurerm_resource_group.example.name + location = var.location + account_kind = var.account_kind + account_tier = var.account_tier + replication_type = var.account_replication_type + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags +} + +# Create a private DNS zone for blob storage and link it to the virtual network +module "blob_storage_private_dns_zone" { + source = "./modules/private_dns_zone" + name = "privatelink.blob.core.windows.net" + resource_group_name = azurerm_resource_group.example.name + tags = var.tags + virtual_networks_to_link = { + (module.virtual_network.name) = { + subscription_id = data.azurerm_client_config.current.subscription_id + resource_group_name = azurerm_resource_group.example.name + } + } +} + +# Create a private endpoint for blob storage in the pe_subnet subnet +module "blob_storage_private_endpoint" { + source = "./modules/private_endpoint" + name = local.blob_storage_private_endpoint_name + location = var.location + resource_group_name = azurerm_resource_group.example.name + subnet_id = module.virtual_network.subnet_ids[var.pe_subnet_name] + tags = var.tags + private_connection_resource_id = module.storage_account.id + is_manual_connection = false + subresource_name = "blob" + private_dns_zone_group_name = local.private_dns_zone_group_name + private_dns_zone_group_ids = [module.blob_storage_private_dns_zone.id] +} + +# Create a private DNS zone for queue storage and link it to the virtual network +module "queue_storage_private_dns_zone" { + source = "./modules/private_dns_zone" + name = "privatelink.queue.core.windows.net" + resource_group_name = azurerm_resource_group.example.name + tags = var.tags + virtual_networks_to_link = { + (module.virtual_network.name) = { + subscription_id = data.azurerm_client_config.current.subscription_id + resource_group_name = azurerm_resource_group.example.name + } + } +} + +# Create a private endpoint for queue storage in the pe_subnet subnet +module "queue_storage_private_endpoint" { + source = "./modules/private_endpoint" + name = local.queue_storage_private_endpoint_name + location = var.location + resource_group_name = azurerm_resource_group.example.name + subnet_id = module.virtual_network.subnet_ids[var.pe_subnet_name] + tags = var.tags + private_connection_resource_id = module.storage_account.id + is_manual_connection = false + subresource_name = "queue" + private_dns_zone_group_name = local.private_dns_zone_group_name + private_dns_zone_group_ids = [module.queue_storage_private_dns_zone.id] +} + +# Create a private DNS zone for table storage and link it to the virtual network +module "table_storage_private_dns_zone" { + source = "./modules/private_dns_zone" + name = "privatelink.table.core.windows.net" + resource_group_name = azurerm_resource_group.example.name + tags = var.tags + virtual_networks_to_link = { + (module.virtual_network.name) = { + subscription_id = data.azurerm_client_config.current.subscription_id + resource_group_name = azurerm_resource_group.example.name + } + } +} + +# Create a private endpoint for table storage in the pe_subnet subnet +module "table_storage_private_endpoint" { + source = "./modules/private_endpoint" + name = local.table_storage_private_endpoint_name + location = var.location + resource_group_name = azurerm_resource_group.example.name + subnet_id = module.virtual_network.subnet_ids[var.pe_subnet_name] + tags = var.tags + private_connection_resource_id = module.storage_account.id + is_manual_connection = false + subresource_name = "table" + private_dns_zone_group_name = local.private_dns_zone_group_name + private_dns_zone_group_ids = [module.table_storage_private_dns_zone.id] +} + +# Create a private DNS zone for the Service Bus namespace account and link it to the virtual network +module "service_bus_private_dns_zone" { + source = "./modules/private_dns_zone" + name = "privatelink.servicebus.windows.net" + resource_group_name = azurerm_resource_group.example.name + tags = var.tags + virtual_networks_to_link = { + (module.virtual_network.name) = { + subscription_id = data.azurerm_client_config.current.subscription_id + resource_group_name = azurerm_resource_group.example.name + } + } +} + +# Create a private endpoint for the Service Bus namespace account in the pe_subnet subnet +module "service_bus_private_endpoint" { + source = "./modules/private_endpoint" + name = local.service_bus_private_endpoint_name + location = var.location + resource_group_name = azurerm_resource_group.example.name + subnet_id = module.virtual_network.subnet_ids[var.pe_subnet_name] + tags = var.tags + private_connection_resource_id = module.service_bus_namespace.id + is_manual_connection = false + subresource_name = "namespace" + private_dns_zone_group_name = local.private_dns_zone_group_name + private_dns_zone_group_ids = [module.service_bus_private_dns_zone.id] +} + +# Create Service Bus namespace resources using module +module "service_bus_namespace" { + source = "./modules/service_bus" + name = local.service_bus_namespace_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + sku = var.service_bus_sku + capacity = var.service_bus_capacity + premium_messaging_partitions = var.service_bus_premium_messaging_partitions + local_auth_enabled = var.service_bus_local_auth_enabled + public_network_access_enabled = var.service_bus_public_network_access_enabled + minimum_tls_version = var.minimum_tls_version + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags + queue_names = var.queue_names + lock_duration = var.queue_lock_duration + max_message_size_in_kilobytes = var.queue_max_message_size_in_kilobytes + max_size_in_megabytes = var.queue_max_size_in_megabytes + requires_duplicate_detection = var.queue_requires_duplicate_detection + requires_session = var.queue_requires_session + default_message_ttl = var.queue_default_message_ttl + dead_lettering_on_message_expiration = var.queue_dead_lettering_on_message_expiration + duplicate_detection_history_time_window = var.queue_duplicate_detection_history_time_window + max_delivery_count = var.queue_max_delivery_count + status = var.queue_status + batched_operations_enabled = var.queue_batched_operations_enabled + auto_delete_on_idle = var.queue_auto_delete_on_idle + partitioning_enabled = var.queue_partitioning_enabled + express_enabled = var.queue_express_enabled + forward_to = var.queue_forward_to + forward_dead_lettered_messages_to = var.queue_forward_dead_lettered_messages_to +} + +# Create App Service Plan using module +module "app_service_plan" { + source = "./modules/app_service_plan" + name = local.app_service_plan_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + sku_name = var.sku_name + os_type = var.os_type + zone_balancing_enabled = var.zone_balancing_enabled + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags +} + +# Create Application Insights +module "application_insights" { + source = "./modules/application_insights" + name = local.application_insights_name + location = var.location + resource_group_name = azurerm_resource_group.example.name + workspace_id = module.log_analytics_workspace.id + tags = var.tags +} + +# Create a user-assigned managed identity +module "managed_identity" { + source = "./modules/managed_identity" + name = local.managed_identity_name + resource_group_name = azurerm_resource_group.example.name + location = var.location + storage_account_id = module.storage_account.id + application_insights_id = module.application_insights.id + service_bus_id = module.service_bus_namespace.id + tags = var.tags +} + +# Create Web App using module +module "function_app" { + source = "./modules/function_app" + name = local.function_app_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + service_plan_id = module.app_service_plan.id + storage_account_name = module.storage_account.name + storage_account_access_key = module.storage_account.primary_access_key + https_only = var.https_only + virtual_network_subnet_id = module.virtual_network.subnet_ids[var.webapp_subnet_name] + vnet_route_all_enabled = true + public_network_access_enabled = var.public_network_access_enabled + always_on = var.always_on + http2_enabled = var.http2_enabled + minimum_tls_version = var.minimum_tls_version + use_dotnet_isolated_runtime = var.use_dotnet_isolated_runtime + java_version = var.java_version + node_version = var.node_version + dotnet_version = var.dotnet_version + python_version = var.python_version + managed_identity_type = var.managed_identity_type + managed_identity_id = var.managed_identity_type == "UserAssigned" ? module.managed_identity.id : null + repo_url = var.repo_url + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags + + app_settings = { + SCM_DO_BUILD_DURING_DEPLOYMENT = "false" + AzureWebJobsStorage = module.storage_account.primary_connection_string + FUNCTIONS_WORKER_RUNTIME = var.functions_worker_runtime + FUNCTIONS_EXTENSION_VERSION = var.functions_extension_version + AZURE_CLIENT_ID = module.managed_identity.client_id + SERVICE_BUS_CONNECTION_STRING__fullyQualifiedNamespace = "${module.service_bus_namespace.name}.servicebus.windows.net" + APPLICATIONINSIGHTS_CONNECTION_STRING = module.application_insights.connection_string + APPLICATIONINSIGHTS_AUTHENTICATION_STRING = "ClientId=${module.managed_identity.client_id};Authorization=AAD" + INPUT_QUEUE_NAME = var.input_queue_name + OUTPUT_QUEUE_NAME = var.output_queue_name + NAMES = var.names + TIMER_SCHEDULE = var.timer_schedule + } +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/app_service_plan/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/app_service_plan/main.tf new file mode 100644 index 0000000..98a3e4d --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/app_service_plan/main.tf @@ -0,0 +1,25 @@ +resource "azurerm_service_plan" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + sku_name = var.sku_name + os_type = var.os_type + zone_balancing_enabled = var.zone_balancing_enabled + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_service_plan.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_metric { + category = "AllMetrics" + } +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/app_service_plan/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/modules/app_service_plan/outputs.tf new file mode 100644 index 0000000..f1455ea --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/app_service_plan/outputs.tf @@ -0,0 +1,19 @@ +output "id" { + value = azurerm_service_plan.example.id + description = "Specifies the resource id of the App Service Plan" +} + +output "name" { + value = azurerm_service_plan.example.name + description = "Specifies the name of the App Service Plan" +} + +output "location" { + value = azurerm_service_plan.example.location + description = "Specifies the location of the App Service Plan" +} + +output "resource_group_name" { + value = azurerm_service_plan.example.resource_group_name + description = "Specifies the resource group name of the App Service Plan" +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/app_service_plan/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/app_service_plan/variables.tf new file mode 100644 index 0000000..e543066 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/app_service_plan/variables.tf @@ -0,0 +1,42 @@ +variable "resource_group_name" { + description = "(Required) Specifies the name of the resource group." + type = string +} + +variable "location" { + description = "(Required) Specifies the location for the App Service Plan." + type = string +} + +variable "name" { + description = "(Required) Specifies the name of the App Service Plan." + type = string +} + +variable "sku_name" { + description = "(Required) Specifies the SKU name for the App Service Plan." + type = string +} + +variable "os_type" { + description = "(Required) Specifies the O/S type for the App Services to be hosted in this plan." + type = string + default = "Linux" +} + +variable "zone_balancing_enabled" { + description = "(Optional) Should the Service Plan balance across Availability Zones in the region." + type = bool + default = false +} + +variable "tags" { + description = "(Optional) Specifies the tags to be applied to the resources." + type = map(any) + default = {} +} + +variable "log_analytics_workspace_id" { + description = "Specifies the resource id of the Azure Log Analytics workspace." + type = string +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/application_insights/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/application_insights/main.tf new file mode 100644 index 0000000..d397c22 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/application_insights/main.tf @@ -0,0 +1,18 @@ +resource "azurerm_application_insights" "example" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags + application_type = var.application_type + workspace_id = var.workspace_id + disable_ip_masking = var.disable_ip_masking + local_authentication_disabled = var.local_authentication_disabled + internet_ingestion_enabled = var.internet_ingestion_enabled + internet_query_enabled = var.internet_query_enabled + + lifecycle { + ignore_changes = [ + tags + ] + } +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/application_insights/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/modules/application_insights/outputs.tf new file mode 100644 index 0000000..2f085e7 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/application_insights/outputs.tf @@ -0,0 +1,24 @@ +output "name" { + value = azurerm_application_insights.example.name + description = "Specifies the name of the resource." +} + +output "id" { + value = azurerm_application_insights.example.id + description = "Specifies the resource id of the resource." +} + +output "instrumentation_key" { + value = azurerm_application_insights.example.instrumentation_key + description = "Specifies the instrumentation key of the Application Insights." +} + +output "app_id" { + value = azurerm_application_insights.example.app_id + description = "Specifies the resource id of the resource." +} + +output "connection_string" { + value = azurerm_application_insights.example.connection_string + description = "Specifies the connection string of the Application Insights." +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/application_insights/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/application_insights/variables.tf new file mode 100644 index 0000000..0dd17aa --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/application_insights/variables.tf @@ -0,0 +1,56 @@ + +variable "name" { + description = "(Required) Specifies the name of the resource. Changing this forces a new resource to be created." + type = string +} + +variable "resource_group_name" { + description = "(Required) The name of the resource group in which to create the resource. Changing this forces a new resource to be created." + type = string +} + +variable "location" { + description = "(Required) Specifies the supported Azure location where the resource exists. Changing this forces a new resource to be created." + type = string +} + +variable "application_type" { + description = "(Required) Specifies the type of Application Insights to create. Valid values are ios for iOS, java for Java web, MobileCenter for App Center, Node.JS for Node.js, other for General, phone for Windows Phone, store for Windows Store and web for ASP.NET. Please note these values are case sensitive; unmatched values are treated as ASP.NET by Azure. Changing this forces a new resource to be created." + type = string + default = "web" +} + +variable "workspace_id" { + description = "(Optional) Specifies the id of a log analytics workspace resource. Changing this forces a new resource to be created." + type = string +} + +variable "tags" { + description = "(Optional) Specifies the tags to be applied to the resources" + type = map(any) + default = {} +} + +variable "disable_ip_masking" { + description = "(Optional) Specifies whether IP masking is disabled." + type = bool + default = false +} + +variable "local_authentication_disabled" { + description = "(Optional) Specifies whether local authentication is disabled." + type = bool + default = false +} + +variable "internet_ingestion_enabled" { + description = "(Optional) Specifies whether the public network access for ingestion is enabled." + type = bool + default = true +} + +variable "internet_query_enabled" { + description = "(Optional) Specifies whether the public network access for query is enabled." + type = bool + default = true +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/function_app/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/function_app/main.tf new file mode 100644 index 0000000..10cdbdd --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/function_app/main.tf @@ -0,0 +1,67 @@ +resource "azurerm_linux_function_app" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + service_plan_id = var.service_plan_id + storage_account_name = var.storage_account_name + storage_account_access_key = var.storage_account_access_key + https_only = var.https_only + virtual_network_subnet_id = var.virtual_network_subnet_id + public_network_access_enabled = var.public_network_access_enabled + tags = var.tags + + identity { + type = var.managed_identity_type + identity_ids = var.managed_identity_type == "UserAssigned" ? [var.managed_identity_id] : null + } + + site_config { + always_on = var.always_on + http2_enabled = var.http2_enabled + minimum_tls_version = var.minimum_tls_version + vnet_route_all_enabled = var.vnet_route_all_enabled + application_stack { + dotnet_version = var.dotnet_version + java_version = var.java_version + node_version = var.node_version + python_version = var.python_version + use_dotnet_isolated_runtime = var.use_dotnet_isolated_runtime + } + } + + app_settings = var.app_settings + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Deploy code from a public GitHub repo +resource "azurerm_app_service_source_control" "example" { + count = var.repo_url == "" ? 0 : 1 + app_id = azurerm_linux_function_app.example.id + repo_url = var.repo_url + branch = var.repo_branch + use_manual_integration = true + use_mercurial = false +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_linux_function_app.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "FunctionAppLogs" + } + + enabled_log { + category = "AppServiceAuthenticationLogs" + } + + enabled_metric { + category = "AllMetrics" + } +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/function_app/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/modules/function_app/outputs.tf new file mode 100644 index 0000000..3283f33 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/function_app/outputs.tf @@ -0,0 +1,24 @@ +output "id" { + value = azurerm_linux_function_app.example.id + description = "Specifies the resource id of the Web App" +} + +output "name" { + value = azurerm_linux_function_app.example.name + description = "Specifies the name of the Web App" +} + +output "default_hostname" { + value = azurerm_linux_function_app.example.default_hostname + description = "Specifies the default hostname of the Web App" +} + +output "outbound_ip_addresses" { + value = azurerm_linux_function_app.example.outbound_ip_addresses + description = "Specifies the outbound IP addresses of the Web App" +} + +output "principal_id" { + value = azurerm_linux_function_app.example.identity[0].principal_id + description = "Specifies the Principal ID of the System Assigned Managed Identity" +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/function_app/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/function_app/variables.tf new file mode 100644 index 0000000..d1d45de --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/function_app/variables.tf @@ -0,0 +1,148 @@ +variable "resource_group_name" { + description = "(Required) Specifies the name of the resource group." + type = string +} + +variable "location" { + description = "(Required) Specifies the location for the Web App." + type = string +} + +variable "name" { + description = "(Required) Specifies the name of the Web App." + type = string +} + +variable "service_plan_id" { + description = "(Required) Specifies the ID of the App Service Plan within which to create this Web App." + type = string +} + +variable "storage_account_name" { + description = "(Required) Specifies the name of the storage account used by the Function App." + type = string +} + +variable "storage_account_access_key" { + description = "(Required) Specifies the primary access key of the storage account used by the Function App." + type = string + sensitive = true +} + +variable "https_only" { + description = "(Optional) Specifies whether the Web App requires HTTPS connections." + type = bool + default = false +} + +variable "virtual_network_subnet_id" { + description = "(Optional) The subnet id which will be used by this Web App for regional virtual network integration." + type = string + default = null +} + +variable "vnet_route_all_enabled" { + description = "(Optional) Specifies whether to route all traffic from the Web App into the virtual network. This is only applicable if virtual_network_subnet_id is specified. Defaults to false." + type = bool + default = false +} + +variable "public_network_access_enabled" { + description = "(Optional) Specifies whether the public network access is enabled or disabled." + type = bool + default = true +} + +variable "always_on" { + description = "(Optional) Specifies whether the Web App is Always On enabled." + type = bool + default = true +} + +variable "http2_enabled" { + description = "(Optional) Specifies whether HTTP/2 is enabled for the Web App." + type = bool + default = false +} + +variable "minimum_tls_version" { + description = "(Optional) Specifies the minimum version of TLS required for SSL requests." + type = string + default = "1.2" +} + +variable "use_dotnet_isolated_runtime" { + description = "(Optional) Should the DotNet process use an isolated runtime. Defaults to false." + type = bool + default = true +} + +variable "java_version" { + description = "(Optional) The Version of Java to use." + type = string + default = null +} + +variable "node_version" { + description = "(Optional) The version of Node.js to run." + type = string + default = null +} + +variable "dotnet_version" { + description = "(Optional) The version of .NET to use." + type = string + default = null +} + +variable "python_version" { + description = "(Optional) The version of Python to run." + type = string + default = null +} + +variable "app_settings" { + description = "(Optional) A map of key-value pairs for App Settings." + type = map(string) + default = {} +} + +variable "repo_url" { + description = "(Optional) Specifies the Git repository URL." + type = string + default = "" +} + +variable "repo_branch" { + description = "(Optional) Specifies the Git repository branch." + type = string + default = "main" +} + +variable "tags" { + description = "(Optional) Specifies the tags to be applied to the resources." + type = map(any) + default = {} +} + +variable "managed_identity_type" { + description = "(Optional) Specifies the type of managed identity." + type = string + default = "UserAssigned" + + validation { + condition = contains(["SystemAssigned", "UserAssigned"], var.managed_identity_type) + error_message = "The managed identity type must be either SystemAssigned or UserAssigned." + } +} + +variable "managed_identity_id" { + description = "(Optional) Specifies the resource id of the user-assigned managed identity." + type = string + default = null +} + +variable "log_analytics_workspace_id" { + description = "Specifies the resource id of the Azure Log Analytics workspace." + type = string +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/log_analytics/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/log_analytics/main.tf new file mode 100644 index 0000000..2f88414 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/log_analytics/main.tf @@ -0,0 +1,14 @@ +resource "azurerm_log_analytics_workspace" "example" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name + sku = var.sku + tags = var.tags + retention_in_days = var.retention_in_days != "" ? var.retention_in_days : null + + lifecycle { + ignore_changes = [ + tags + ] + } +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/log_analytics/output.tf b/samples/function-app-service-bus/dotnet/terraform/modules/log_analytics/output.tf new file mode 100644 index 0000000..fe2c398 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/log_analytics/output.tf @@ -0,0 +1,30 @@ +output "id" { + value = azurerm_log_analytics_workspace.example.id + description = "Specifies the resource id of the log analytics workspace" +} + +output "location" { + value = azurerm_log_analytics_workspace.example.location + description = "Specifies the location of the log analytics workspace" +} + +output "name" { + value = azurerm_log_analytics_workspace.example.name + description = "Specifies the name of the log analytics workspace" +} + +output "resource_group_name" { + value = azurerm_log_analytics_workspace.example.resource_group_name + description = "Specifies the name of the resource group that contains the log analytics workspace" +} + +output "workspace_id" { + value = azurerm_log_analytics_workspace.example.workspace_id + description = "Specifies the workspace id of the log analytics workspace" +} + +output "primary_shared_key" { + value = azurerm_log_analytics_workspace.example.primary_shared_key + description = "Specifies the workspace key of the log analytics workspace" + sensitive = true +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/log_analytics/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/log_analytics/variables.tf new file mode 100644 index 0000000..c399b5b --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/log_analytics/variables.tf @@ -0,0 +1,37 @@ +variable "resource_group_name" { + description = "(Required) Specifies the name of the resource group." + type = string +} + +variable "location" { + description = "(Required) Specifies the location of the Azure Log Analytics workspace" + type = string +} + +variable "name" { + description = "(Required) Specifies the name of the Azure Log Analytics workspace" + type = string +} + +variable "sku" { + description = "(Optional) Specifies the sku of the Azure Log Analytics workspace" + type = string + default = "PerGB2018" + + validation { + condition = contains(["Free", "Standalone", "PerNode", "PerGB2018"], var.sku) + error_message = "The log analytics sku is incorrect." + } +} + +variable "retention_in_days" { + description = " (Optional) Specifies the workspace data retention in days. Possible values are either 7 (Free Tier only) or range between 30 and 730." + type = number + default = 30 +} + +variable "tags" { + description = "(Optional) Specifies the tags of the Azure Log Analytics workspace." + type = map(any) + default = {} +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/managed_identity/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/managed_identity/main.tf new file mode 100644 index 0000000..af5ef36 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/managed_identity/main.tf @@ -0,0 +1,55 @@ + +resource "azurerm_user_assigned_identity" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_role_assignment" "storage_account_contributor_assignment" { + scope = var.storage_account_id + role_definition_name = "Storage Account Contributor" + principal_id = azurerm_user_assigned_identity.example.principal_id + skip_service_principal_aad_check = true +} + +resource "azurerm_role_assignment" "storage_blob_data_owner_assignment" { + scope = var.storage_account_id + role_definition_name = "Storage Blob Data Owner" + principal_id = azurerm_user_assigned_identity.example.principal_id + skip_service_principal_aad_check = true +} + +resource "azurerm_role_assignment" "storage_queue_data_contributor_assignment" { + scope = var.storage_account_id + role_definition_name = "Storage Queue Data Contributor" + principal_id = azurerm_user_assigned_identity.example.principal_id + skip_service_principal_aad_check = true +} + +resource "azurerm_role_assignment" "storage_table_data_contributor_assignment" { + scope = var.storage_account_id + role_definition_name = "Storage Table Data Contributor" + principal_id = azurerm_user_assigned_identity.example.principal_id + skip_service_principal_aad_check = true +} + +resource "azurerm_role_assignment" "service_bus_contributor_assignment" { + scope = var.service_bus_id + role_definition_name = "Azure Service Bus Data Owner" + principal_id = azurerm_user_assigned_identity.example.principal_id + skip_service_principal_aad_check = true +} + +resource "azurerm_role_assignment" "monitoring_metrics_publisher_assignment" { + scope = var.application_insights_id + role_definition_name = "Monitoring Metrics Publisher" + principal_id = azurerm_user_assigned_identity.example.principal_id + skip_service_principal_aad_check = true +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/managed_identity/output.tf b/samples/function-app-service-bus/dotnet/terraform/modules/managed_identity/output.tf new file mode 100644 index 0000000..a22e571 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/managed_identity/output.tf @@ -0,0 +1,25 @@ + +output "id" { + value = azurerm_user_assigned_identity.example.id + description = "Specifies the resource id of the workload user-defined managed identity" +} + +output "location" { + value = azurerm_user_assigned_identity.example.location + description = "Specifies the location of the workload user-defined managed identity" +} + +output "name" { + value = azurerm_user_assigned_identity.example.name + description = "Specifies the name of the workload user-defined managed identity" +} + +output "client_id" { + value = azurerm_user_assigned_identity.example.client_id + description = "Specifies the client id of the workload user-defined managed identity" +} + +output "principal_id" { + value = azurerm_user_assigned_identity.example.principal_id + description = "Specifies the principal id of the workload user-defined managed identity" +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/managed_identity/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/managed_identity/variables.tf new file mode 100644 index 0000000..4f43473 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/managed_identity/variables.tf @@ -0,0 +1,35 @@ +variable "name" { + description = "(Required) Specifies the name of the log analytics workspace" + type = string +} + +variable "resource_group_name" { + description = "(Required) Specifies the resource group name" + type = string +} + +variable "location" { + description = "(Required) Specifies the location of the log analytics workspace" + type = string +} + +variable "tags" { + description = "(Optional) Specifies the tags of the log analytics workspace" + type = map(any) + default = {} +} + +variable "storage_account_id" { + description = "(Required) Specifies resource id of the Azure Storage Account resource" + type = string +} + +variable "application_insights_id" { + description = "(Required) Specifies resource id of the Azure Application Insights resource" + type = string +} + +variable "service_bus_id" { + description = "(Required) Specifies resource id of the Azure Service Bus namespace resource" + type = string +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/nat_gateway/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/nat_gateway/main.tf new file mode 100644 index 0000000..cc384af --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/nat_gateway/main.tf @@ -0,0 +1,42 @@ +resource "azurerm_public_ip" "example" { + name = "${var.name}PublicIp" + location = var.location + resource_group_name = var.resource_group_name + allocation_method = "Static" + sku = "Standard" + zones = var.zones + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_nat_gateway" "example" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name + sku_name = var.sku_name + idle_timeout_in_minutes = var.idle_timeout_in_minutes + zones = var.zones + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_nat_gateway_public_ip_association" "example" { + nat_gateway_id = azurerm_nat_gateway.example.id + public_ip_address_id = azurerm_public_ip.example.id +} + +resource "azurerm_subnet_nat_gateway_association" "example" { + for_each = var.subnet_ids + subnet_id = each.value + nat_gateway_id = azurerm_nat_gateway.example.id +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/nat_gateway/output.tf b/samples/function-app-service-bus/dotnet/terraform/modules/nat_gateway/output.tf new file mode 100644 index 0000000..1e3fd03 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/nat_gateway/output.tf @@ -0,0 +1,14 @@ +output "name" { + value = azurerm_nat_gateway.example.name + description = "Specifies the name of the Azure NAT Gateway" +} + +output "id" { + value = azurerm_nat_gateway.example.id + description = "Specifies the resource id of the Azure NAT Gateway" +} + +output "public_ip_address" { + value = azurerm_public_ip.example.ip_address + description = "Contains the public IP address of the Azure NAT Gateway." +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/nat_gateway/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/nat_gateway/variables.tf new file mode 100644 index 0000000..c1c8ea5 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/nat_gateway/variables.tf @@ -0,0 +1,43 @@ +variable "resource_group_name" { + description = "(Required) Specifies the name of the resource group." + type = string +} + +variable "location" { + description = "(Required) Specifies the location of the Azure NAT Gateway" + type = string +} + +variable "name" { + description = "(Required) Specifies the name of the Azure NAT Gateway" + type = string +} + +variable "tags" { + description = "(Optional) Specifies the tags of the Azure NAT Gateway" + type = map(any) + default = {} +} + +variable "sku_name" { + description = "(Optional) The SKU which should be used. At this time the only supported value is Standard. Defaults to Standard" + type = string + default = "Standard" +} + +variable "idle_timeout_in_minutes" { + description = "(Optional) The idle timeout which should be used in minutes. Defaults to 4." + type = number + default = 4 +} + +variable "zones" { + description = " (Optional) A list of Availability Zones in which this NAT Gateway should be located. Changing this forces a new NAT Gateway to be created." + type = list(string) + default = [] +} + +variable "subnet_ids" { + description = "(Required) A map of subnet ids to associate with the NAT Gateway" + type = map(string) +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/network_security_group/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/network_security_group/main.tf new file mode 100644 index 0000000..c649652 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/network_security_group/main.tf @@ -0,0 +1,53 @@ +resource "azurerm_network_security_group" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + tags = var.tags + + dynamic "security_rule" { + for_each = try(var.security_rules, []) + content { + name = try(security_rule.value.name, null) + priority = try(security_rule.value.priority, null) + direction = try(security_rule.value.direction, null) + access = try(security_rule.value.access, null) + protocol = try(security_rule.value.protocol, null) + source_port_range = try(security_rule.value.source_port_range, null) + source_port_ranges = try(security_rule.value.source_port_ranges, null) + destination_port_range = try(security_rule.value.destination_port_range, null) + destination_port_ranges = try(security_rule.value.destination_port_ranges, null) + source_address_prefix = try(security_rule.value.source_address_prefix, null) + source_address_prefixes = try(security_rule.value.source_address_prefixes, null) + destination_address_prefix = try(security_rule.value.destination_address_prefix, null) + destination_address_prefixes = try(security_rule.value.destination_address_prefixes, null) + source_application_security_group_ids = try(security_rule.value.source_application_security_group_ids, null) + destination_application_security_group_ids = try(security_rule.value.destination_application_security_group_ids, null) + } + } + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_subnet_network_security_group_association" "example" { + for_each = var.subnet_ids + subnet_id = each.value + network_security_group_id = azurerm_network_security_group.example.id +} + +resource "azurerm_monitor_diagnostic_setting" "settings" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_network_security_group.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "NetworkSecurityGroupEvent" + } + + enabled_log { + category = "NetworkSecurityGroupRuleCounter" + } +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/network_security_group/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/modules/network_security_group/outputs.tf new file mode 100644 index 0000000..b8ca8d5 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/network_security_group/outputs.tf @@ -0,0 +1,9 @@ +output "name" { + description = "Specifies the name of the network security group" + value = azurerm_network_security_group.example.name +} + +output "id" { + description = "Specifies the resource id of the network security group" + value = azurerm_network_security_group.example.id +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/network_security_group/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/network_security_group/variables.tf new file mode 100644 index 0000000..04eb07e --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/network_security_group/variables.tf @@ -0,0 +1,51 @@ +variable "name" { + description = "(Required) Specifies the name of the Azure Network Security Group" + type = string +} + +variable "resource_group_name" { + description = "(Required) Specifies the name of the resource group. of the Azure Network Security Group" + type = string +} + +variable "location" { + description = "(Required) Specifies the location of the Azure Network Security Group" + type = string +} + +variable "security_rules" { + description = "(Optional) Specifies the security rules of the Azure Network Security Group" + type = list(object({ + name = string + priority = number + direction = string + access = string + protocol = string + source_port_range = string + source_port_ranges = list(string) + destination_port_range = string + destination_port_ranges = list(string) + source_address_prefix = string + source_address_prefixes = list(string) + destination_address_prefix = string + destination_address_prefixes = list(string) + source_application_security_group_ids = list(string) + destination_application_security_group_ids = list(string) + })) + default = [] +} + +variable "subnet_ids" { + description = "(Required) A map of subnet ids to associate with the Azure Network Security Group" + type = map(string) +} + +variable "tags" { + description = "(Optional) Specifies the tags of the Azure Network Security Group" + default = {} +} + +variable "log_analytics_workspace_id" { + description = "Specifies the resource id of the Azure Log Analytics workspace" + type = string +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/private_dns_zone/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/private_dns_zone/main.tf new file mode 100644 index 0000000..393f9dc --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/private_dns_zone/main.tf @@ -0,0 +1,26 @@ +resource "azurerm_private_dns_zone" "example" { + name = var.name + resource_group_name = var.resource_group_name + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_private_dns_zone_virtual_network_link" "example" { + for_each = var.virtual_networks_to_link + + name = "link_to_${lower(basename(each.key))}" + resource_group_name = var.resource_group_name + private_dns_zone_name = azurerm_private_dns_zone.example.name + virtual_network_id = "/subscriptions/${each.value.subscription_id}/resourceGroups/${each.value.resource_group_name}/providers/Microsoft.Network/virtualNetworks/${each.key}" + + lifecycle { + ignore_changes = [ + tags + ] + } +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/private_dns_zone/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/modules/private_dns_zone/outputs.tf new file mode 100644 index 0000000..ca141f3 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/private_dns_zone/outputs.tf @@ -0,0 +1,9 @@ +output "name" { + description = "Specifies the name of the private dns zone" + value = azurerm_private_dns_zone.example.name +} + +output "id" { + description = "Specifies the resource id of the private dns zone" + value = azurerm_private_dns_zone.example.id +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/private_dns_zone/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/private_dns_zone/variables.tf new file mode 100644 index 0000000..8d0c0cc --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/private_dns_zone/variables.tf @@ -0,0 +1,20 @@ +variable "name" { + description = "(Required) Specifies the name of the Azure Private DNS Zone" + type = string +} + +variable "resource_group_name" { + description = "(Required) Specifies the name of the resource group. of the Azure Private DNS Zone" + type = string +} + +variable "tags" { + description = "(Optional) Specifies the tags of the Azure Private DNS Zone" + default = {} +} + +variable "virtual_networks_to_link" { + description = "(Optional) Specifies the subscription id, resource group name, and name of the virtual networks to which create a virtual network link" + type = map(any) + default = {} +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/private_endpoint/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/private_endpoint/main.tf new file mode 100644 index 0000000..62bfbfb --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/private_endpoint/main.tf @@ -0,0 +1,26 @@ +resource "azurerm_private_endpoint" "example" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name + subnet_id = var.subnet_id + tags = var.tags + + private_service_connection { + name = "${var.name}Connection" + private_connection_resource_id = var.private_connection_resource_id + is_manual_connection = var.is_manual_connection + subresource_names = try([var.subresource_name], null) + request_message = try(var.request_message, null) + } + + private_dns_zone_group { + name = var.private_dns_zone_group_name + private_dns_zone_ids = var.private_dns_zone_group_ids + } + + lifecycle { + ignore_changes = [ + tags + ] + } +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/private_endpoint/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/modules/private_endpoint/outputs.tf new file mode 100644 index 0000000..367ab51 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/private_endpoint/outputs.tf @@ -0,0 +1,19 @@ +output "name" { + description = "Specifies the name of the private endpoint." + value = azurerm_private_endpoint.example.name +} + +output "id" { + description = "Specifies the resource id of the private endpoint." + value = azurerm_private_endpoint.example.id +} + +output "private_dns_zone_group" { + description = "Specifies the private dns zone group of the private endpoint." + value = azurerm_private_endpoint.example.private_dns_zone_group +} + +output "private_dns_zone_configs" { + description = "Specifies the private dns zone(s) configuration" + value = azurerm_private_endpoint.example.private_dns_zone_configs +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/private_endpoint/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/private_endpoint/variables.tf new file mode 100644 index 0000000..2b7a888 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/private_endpoint/variables.tf @@ -0,0 +1,61 @@ +variable "name" { + description = "(Required) Specifies the name of the Azure Private Endpoint. Changing this forces a new resource to be created." + type = string +} + +variable "resource_group_name" { + description = "(Required) The name of the resource group. Changing this forces a new resource to be created." + type = string +} + +variable "private_connection_resource_id" { + description = "(Required) Specifies the resource id of the private link service" + type = string +} + +variable "location" { + description = "(Required) Specifies the supported Azure location where the resource exists. Changing this forces a new resource to be created." + type = string +} + +variable "subnet_id" { + description = "(Required) Specifies the resource id of the subnet" + type = string +} + +variable "is_manual_connection" { + description = "(Optional) Specifies whether the Azure Private Endpoint connection requires manual approval from the remote resource owner." + type = string + default = false +} + +variable "subresource_name" { + description = "(Optional) Specifies a subresource name which the Azure Private Endpoint is able to connect to." + type = string + default = null +} + +variable "request_message" { + description = "(Optional) Specifies a message passed to the owner of the remote resource when the Azure Private Endpoint attempts to establish the connection to the remote resource." + type = string + default = null +} + +variable "private_dns_zone_group_name" { + description = "(Required) Specifies the Name of the Private DNS Zone Group. Changing this forces a new private_dns_zone_group resource to be created." + type = string +} + +variable "private_dns_zone_group_ids" { + description = "(Required) Specifies the list of Private DNS Zones to include within the private_dns_zone_group." + type = list(string) +} + +variable "tags" { + description = "(Optional) Specifies the tags of the Azure Azure Private Endpoint." + default = {} +} + +variable "private_dns" { + default = {} +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/service_bus/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/service_bus/main.tf new file mode 100644 index 0000000..84b61df --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/service_bus/main.tf @@ -0,0 +1,77 @@ +resource "azurerm_servicebus_namespace" "example" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name + sku = var.sku + capacity = var.capacity + premium_messaging_partitions = var.premium_messaging_partitions + local_auth_enabled = var.local_auth_enabled + public_network_access_enabled = var.public_network_access_enabled + minimum_tls_version = var.minimum_tls_version + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_servicebus_namespace_authorization_rule" "example" { + name = "${var.name}-auth-rule" + namespace_id = azurerm_servicebus_namespace.example.id + + send = true + listen = true + manage = true +} + +resource "azurerm_servicebus_queue" "example" { + for_each = var.queue_names + + name = each.value + namespace_id = azurerm_servicebus_namespace.example.id + + lock_duration = var.lock_duration + max_message_size_in_kilobytes = var.max_message_size_in_kilobytes + max_size_in_megabytes = var.max_size_in_megabytes + requires_duplicate_detection = var.requires_duplicate_detection + requires_session = var.requires_session + default_message_ttl = var.default_message_ttl + dead_lettering_on_message_expiration = var.dead_lettering_on_message_expiration + duplicate_detection_history_time_window = var.duplicate_detection_history_time_window + max_delivery_count = var.max_delivery_count + status = var.status + batched_operations_enabled = var.batched_operations_enabled + auto_delete_on_idle = var.auto_delete_on_idle + partitioning_enabled = var.partitioning_enabled + express_enabled = var.express_enabled + forward_to = var.forward_to + forward_dead_lettered_messages_to = var.forward_dead_lettered_messages_to +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_servicebus_namespace.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "OperationalLogs" + } + + enabled_log { + category = "VNetAndIPFilteringLogs" + } + + enabled_log { + category = "RuntimeAuditLogs" + } + + enabled_log { + category = "ApplicationMetricsLogs" + } + + enabled_metric { + category = "AllMetrics" + } +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/service_bus/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/modules/service_bus/outputs.tf new file mode 100644 index 0000000..6fc2dd0 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/service_bus/outputs.tf @@ -0,0 +1,17 @@ +output "namespace_connection_string" { + value = azurerm_servicebus_namespace.example.default_primary_connection_string +} + +output "shared_access_policy_primarykey" { + value = azurerm_servicebus_namespace.example.default_primary_key +} + +output "name" { + value = azurerm_servicebus_namespace.example.name + description = "Specifies the name of the Service Bus namespace." +} + +output "id" { + value = azurerm_servicebus_namespace.example.id + description = "Specifies the resource id of the Service Bus namespace." +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/service_bus/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/service_bus/variables.tf new file mode 100644 index 0000000..5f380b2 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/service_bus/variables.tf @@ -0,0 +1,173 @@ +variable "name" { + description = "(Required) Specifies the name of the resource. Changing this forces a new resource to be created." + type = string +} + +variable "resource_group_name" { + description = "(Required) The name of the resource group in which to create the resource. Changing this forces a new resource to be created." + type = string +} + +variable "location" { + description = "(Required) Specifies the supported Azure location where the resource exists. Changing this forces a new resource to be created." + type = string +} + +variable "log_analytics_workspace_id" { + description = "Specifies the resource id of the Azure Log Analytics workspace." + type = string +} + +variable "sku" { + description = "(Required) Specifies the SKU tier for the Service Bus Namespace. Options are Basic, Standard, or Premium." + type = string + default = "Premium" + + validation { + condition = contains(["Basic", "Standard", "Premium"], var.sku) + error_message = "The SKU must be one of Basic, Standard, or Premium." + } +} + +variable "capacity" { + description = "(Optional) Specifies the capacity for the Service Bus Namespace. When SKU is Premium, capacity can be 1, 2, 4, 8, or 16. When SKU is Basic or Standard, capacity can be 0 only." + type = number + default = 0 +} + +variable "premium_messaging_partitions" { + description = "(Optional) Specifies the number of messaging partitions. Only valid when SKU is Premium. Possible values include 0, 1, 2, and 4. Defaults to 0." + type = number + default = 0 +} + +variable "local_auth_enabled" { + description = "(Optional) Specifies whether SAS authentication is enabled for the Service Bus Namespace. Defaults to true." + type = bool + default = true +} + +variable "public_network_access_enabled" { + description = "(Optional) Specifies whether public network access is enabled for the Service Bus Namespace. Defaults to true." + type = bool + default = true +} + +variable "minimum_tls_version" { + description = "(Optional) Specifies the minimum supported TLS version for the Service Bus Namespace. Valid values are 1.0, 1.1, and 1.2. Defaults to 1.2." + type = string + default = "1.2" + + validation { + condition = contains(["1.0", "1.1", "1.2"], var.minimum_tls_version) + error_message = "The minimum TLS version must be one of 1.0, 1.1, or 1.2." + } +} + +variable "tags" { + description = "(Optional) Specifies the tags to be applied to the resources." + type = map(string) + default = {} +} + +variable "queue_names" { + description = "(Optional) Specifies the names of the queues to be created within the Service Bus Namespace." + type = set(string) + default = [] +} + +variable "lock_duration" { + description = "(Optional) Specifies the ISO 8601 timespan duration of a peek-lock. Maximum value is 5 minutes. Defaults to PT1M." + type = string + default = "PT4M" +} + +variable "max_message_size_in_kilobytes" { + description = "(Optional) Specifies the maximum size of a message allowed on the queue in kilobytes. Only applicable for Premium SKU." + type = number + default = null +} + +variable "max_size_in_megabytes" { + description = "(Optional) Specifies the size of memory allocated for the queue in megabytes." + type = number + default = null +} + +variable "requires_duplicate_detection" { + description = "(Optional) Specifies whether the queue requires duplicate detection. Changing this forces a new resource to be created. Defaults to false." + type = bool + default = true +} + +variable "requires_session" { + description = "(Optional) Specifies whether the queue requires sessions for ordered handling of unbounded sequences of related messages. Changing this forces a new resource to be created. Defaults to false." + type = bool + default = true +} + +variable "default_message_ttl" { + description = "(Optional) Specifies the ISO 8601 timespan duration of the TTL of messages sent to this queue." + type = string + default = "PT12S" +} + +variable "dead_lettering_on_message_expiration" { + description = "(Optional) Specifies whether the queue has dead letter support when a message expires. Defaults to false." + type = bool + default = true +} + +variable "duplicate_detection_history_time_window" { + description = "(Optional) Specifies the ISO 8601 timespan duration during which duplicates can be detected. Defaults to PT10M." + type = string + default = "PT10M" +} + +variable "max_delivery_count" { + description = "(Optional) Specifies the maximum number of deliveries before a message is automatically dead lettered. Defaults to 10." + type = number + default = 5 +} + +variable "status" { + description = "(Optional) Specifies the status of the queue. Possible values are Active, Creating, Deleting, Disabled, ReceiveDisabled, Renaming, SendDisabled, Unknown. Defaults to Active." + type = string + default = "Active" +} + +variable "batched_operations_enabled" { + description = "(Optional) Specifies whether server-side batched operations are enabled. Defaults to true." + type = bool + default = true +} + +variable "auto_delete_on_idle" { + description = "(Optional) Specifies the ISO 8601 timespan duration of the idle interval after which the queue is automatically deleted. Minimum of 5 minutes." + type = string + default = null +} + +variable "partitioning_enabled" { + description = "(Optional) Specifies whether the queue is partitioned across multiple message brokers. Changing this forces a new resource to be created. Defaults to false." + type = bool + default = true +} + +variable "express_enabled" { + description = "(Optional) Specifies whether Express Entities are enabled. An express queue holds a message in memory temporarily before writing it to persistent storage. Defaults to false." + type = bool + default = true +} + +variable "forward_to" { + description = "(Optional) Specifies the name of a queue or topic to automatically forward messages to." + type = string + default = null +} + +variable "forward_dead_lettered_messages_to" { + description = "(Optional) Specifies the name of a queue or topic to automatically forward dead lettered messages to." + type = string + default = null +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/storage_account/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/storage_account/main.tf new file mode 100644 index 0000000..eecc0e5 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/storage_account/main.tf @@ -0,0 +1,123 @@ +resource "azurerm_storage_account" "example" { + name = var.name + resource_group_name = var.resource_group_name + + location = var.location + account_kind = var.account_kind + account_tier = var.account_tier + account_replication_type = var.replication_type + access_tier = var.access_tier + is_hns_enabled = var.is_hns_enabled + shared_access_key_enabled = var.shared_access_key_enabled + min_tls_version = var.min_tls_version + https_traffic_only_enabled = var.https_traffic_only_enabled + allow_nested_items_to_be_public = var.allow_blob_public_access + cross_tenant_replication_enabled = var.cross_tenant_replication_enabled + public_network_access_enabled = var.public_network_access_enabled + tags = var.tags + + network_rules { + default_action = (length(var.ip_rules) + length(var.virtual_network_subnet_ids)) > 0 ? "Deny" : var.default_action + ip_rules = var.ip_rules + virtual_network_subnet_ids = var.virtual_network_subnet_ids + bypass = var.bypass + } + + identity { + type = "SystemAssigned" + } + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_monitor_diagnostic_setting" "blob" { + name = "DiagnosticsSettings-blobService" + target_resource_id = "${azurerm_storage_account.example.id}/blobServices/default/" + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "StorageRead" + } + + enabled_log { + category = "StorageWrite" + } + + enabled_log { + category = "StorageDelete" + } + + enabled_metric { + category = "Transaction" + } +} + +resource "azurerm_monitor_diagnostic_setting" "queue" { + name = "DiagnosticsSettings-queueService" + target_resource_id = "${azurerm_storage_account.example.id}/queueServices/default/" + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "StorageRead" + } + + enabled_log { + category = "StorageWrite" + } + + enabled_log { + category = "StorageDelete" + } + + enabled_metric { + category = "Transaction" + } +} + +resource "azurerm_monitor_diagnostic_setting" "table" { + name = "DiagnosticsSettings-tableService" + target_resource_id = "${azurerm_storage_account.example.id}/tableServices/default/" + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "StorageRead" + } + + enabled_log { + category = "StorageWrite" + } + + enabled_log { + category = "StorageDelete" + } + + enabled_metric { + category = "Transaction" + } +} + +resource "azurerm_monitor_diagnostic_setting" "file" { + name = "DiagnosticsSettings-fileService" + target_resource_id = "${azurerm_storage_account.example.id}/fileServices/default/" + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "StorageRead" + } + + enabled_log { + category = "StorageWrite" + } + + enabled_log { + category = "StorageDelete" + } + + enabled_metric { + category = "Transaction" + } +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/storage_account/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/modules/storage_account/outputs.tf new file mode 100644 index 0000000..6e83f70 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/storage_account/outputs.tf @@ -0,0 +1,30 @@ +output "name" { + description = "Specifies the name of the storage account" + value = azurerm_storage_account.example.name +} + +output "id" { + description = "Specifies the resource id of the storage account" + value = azurerm_storage_account.example.id +} + +output "primary_access_key" { + description = "Specifies the primary access key of the storage account" + value = azurerm_storage_account.example.primary_access_key +} + +output "principal_id" { + description = "Specifies the principal id of the system assigned managed identity of the storage account" + value = azurerm_storage_account.example.identity[0].principal_id +} + +output "primary_blob_endpoint" { + description = "Specifies the primary blob endpoint of the storage account" + value = azurerm_storage_account.example.primary_blob_endpoint +} + +output "primary_connection_string" { + description = "Specifies the primary connection string of the storage account" + value = azurerm_storage_account.example.primary_connection_string + sensitive = true +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/storage_account/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/storage_account/variables.tf new file mode 100644 index 0000000..ae24d62 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/storage_account/variables.tf @@ -0,0 +1,134 @@ +variable "resource_group_name" { + description = "(Required) Specifies the name of the resource group. of the Azure Storage Account" + type = string +} + +variable "name" { + description = "(Required) Specifies the name of the Azure Storage Account" + type = string +} + +variable "location" { + description = "(Required) Specifies the location of the Azure Storage Account" + type = string +} + +variable "account_kind" { + description = "(Optional) Specifies the account kind of the Azure Storage Account" + default = "StorageV2" + type = string + + validation { + condition = contains(["Storage", "StorageV2"], var.account_kind) + error_message = "The account kind of the Azure Storage Account is invalid." + } +} + +variable "account_tier" { + description = "(Optional) Specifies the account tier of the Azure Storage Account" + default = "Standard" + type = string + + validation { + condition = contains(["Standard", "Premium"], var.account_tier) + error_message = "The account tier of the Azure Storage Account is invalid." + } +} + +variable "replication_type" { + description = "(Optional) Specifies the replication type of the Azure Storage Account" + default = "LRS" + type = string + + validation { + condition = contains(["LRS", "ZRS", "GRS", "GZRS", "RA-GRS", "RA-GZRS"], var.replication_type) + error_message = "The replication type of the Azure Storage Account is invalid." + } +} + +variable "is_hns_enabled" { + description = "(Optional) Specifies the replication type of the Azure Storage Account" + default = false + type = bool +} + +variable "default_action" { + description = "Allow or disallow public access to all blobs or containers in the Azure Storage Accounts. The default interpretation is true for this property." + default = "Allow" + type = string +} + +variable "ip_rules" { + description = "Specifies IP rules for the Azure Storage Account" + default = [] + type = list(string) +} + +variable "virtual_network_subnet_ids" { + description = "Specifies a list of resource ids for subnets" + default = [] + type = list(string) +} + +variable "kind" { + description = "(Optional) Specifies the kind of the Azure Storage Account" + default = "" +} + +variable "bypass" { + description = " (Optional) Specifies whether traffic is bypassed for Logging/Metrics/AzureServices. Valid options are any combination of Logging, Metrics, AzureServices, or None." + default = ["Logging", "Metrics", "AzureServices"] + type = set(string) +} + +variable "shared_access_key_enabled" { + description = "(Optional) Indicates whether the storage account permits requests to be authorized with the account access key via Shared Key. If false, then all requests, including shared access signatures, must be authorized with Azure Active Directory (Azure AD). Defaults to true." + default = true + type = bool +} + +variable "access_tier" { + description = "(Optional) Specifies the access tier of the storage account. The default value is Hot." + type = string + default = "Hot" +} + +variable "min_tls_version" { + description = "(Optional) Specifies the minimum TLS version to be permitted on requests to storage. The default value is TLS1_2." + type = string + default = "TLS1_2" +} + +variable "https_traffic_only_enabled" { + description = "(Optional) Specifies whether the storage account should only support HTTPS traffic." + type = bool + default = true +} + +variable "allow_blob_public_access" { + description = "(Optional) Specifies whether the storage account allows public access to blobs." + type = bool + default = true +} + +variable "cross_tenant_replication_enabled" { + description = "(Optional) Specifies whether the storage account allows cross-tenant replication." + type = bool + default = false +} + +variable "public_network_access_enabled" { + description = "(Optional) Specifies whether public network access is enabled for the storage account." + type = bool + default = true +} + +variable "log_analytics_workspace_id" { + description = "Specifies the resource id of the Azure Log Analytics workspace." + type = string +} + +variable "tags" { + description = "(Optional) Specifies the tags of the Azure Storage Account" + default = {} +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/virtual_network/main.tf b/samples/function-app-service-bus/dotnet/terraform/modules/virtual_network/main.tf new file mode 100644 index 0000000..cec00f4 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/virtual_network/main.tf @@ -0,0 +1,55 @@ +resource "azurerm_virtual_network" "example" { + name = var.vnet_name + address_space = var.address_space + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_subnet" "example" { + for_each = { for subnet in var.subnets : subnet.name => subnet if subnet != null } + + name = each.key + resource_group_name = var.resource_group_name + virtual_network_name = azurerm_virtual_network.example.name + address_prefixes = each.value.address_prefixes + private_endpoint_network_policies = each.value.private_endpoint_network_policies + private_link_service_network_policies_enabled = each.value.private_link_service_network_policies_enabled + + dynamic "delegation" { + for_each = each.value.delegation != null ? [each.value.delegation] : [] + content { + name = "delegation" + + service_delegation { + name = delegation.value + } + } + } + + lifecycle { + ignore_changes = [ + delegation + ] + } +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_virtual_network.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "VMProtectionAlerts" + } + + enabled_metric { + category = "AllMetrics" + } +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/virtual_network/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/modules/virtual_network/outputs.tf new file mode 100644 index 0000000..b464308 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/virtual_network/outputs.tf @@ -0,0 +1,19 @@ +output "name" { + description = "Specifies the name of the virtual network" + value = azurerm_virtual_network.example.name +} + +output "vnet_id" { + description = "Specifies the resource id of the virtual network" + value = azurerm_virtual_network.example.id +} + +output "subnet_ids" { + description = "Contains a list of the the resource id of the subnets" + value = { for subnet in azurerm_subnet.example : subnet.name => subnet.id } +} + +output "subnet_ids_as_list" { + description = "Returns the list of the subnet ids as a list of strings." + value = [for subnet in azurerm_subnet.example : subnet.id] +} diff --git a/samples/function-app-service-bus/dotnet/terraform/modules/virtual_network/variables.tf b/samples/function-app-service-bus/dotnet/terraform/modules/virtual_network/variables.tf new file mode 100644 index 0000000..f8c0b0e --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/modules/virtual_network/variables.tf @@ -0,0 +1,40 @@ +variable "resource_group_name" { + description = "Resource Group name" + type = string +} + +variable "location" { + description = "Location in which to deploy the network" + type = string +} + +variable "vnet_name" { + description = "VNET name" + type = string +} + +variable "address_space" { + description = "VNET address space" + type = list(string) +} + +variable "subnets" { + description = "Subnets configuration" + type = list(object({ + name = string + address_prefixes = list(string) + private_endpoint_network_policies = string + private_link_service_network_policies_enabled = bool + delegation = string + })) +} + +variable "tags" { + description = "(Optional) Specifies the tags of the Azure Virtual Network resource." + default = {} +} + +variable "log_analytics_workspace_id" { + description = "Specifies the resource id of the Azure Log Analytics workspace." + type = string +} diff --git a/samples/function-app-service-bus/dotnet/terraform/outputs.tf b/samples/function-app-service-bus/dotnet/terraform/outputs.tf new file mode 100644 index 0000000..5697c61 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/outputs.tf @@ -0,0 +1,19 @@ +output "resource_group_name" { + value = local.resource_group_name +} + +output "app_service_plan_name" { + value = module.app_service_plan.name +} + +output "function_app_name" { + value = module.function_app.name +} + +output "function_app_default_hostname" { + value = module.function_app.default_hostname +} + +output "service_bus_namespace_name" { + value = module.service_bus_namespace.name +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/providers.tf b/samples/function-app-service-bus/dotnet/terraform/providers.tf new file mode 100644 index 0000000..3e04500 --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/providers.tf @@ -0,0 +1,24 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=4.60.0" + } + } +} + +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } + + # Set the hostname of the Azure Metadata Service (for example management.azure.com) + # used to obtain the Cloud Environment when using LocalStack's Azure emulator. + # This allows the provider to correctly identify the environment and avoid making calls to the real Azure endpoints. + metadata_host = "localhost.localstack.cloud:4566" + + # Set the subscription ID to a dummy value when using LocalStack's Azure emulator. + subscription_id = "00000000-0000-0000-0000-000000000000" +} \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/terraform.tfvars b/samples/function-app-service-bus/dotnet/terraform/terraform.tfvars new file mode 100644 index 0000000..a1f0ccc --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/terraform.tfvars @@ -0,0 +1,4 @@ +prefix = "local" +suffix = "test" +location = "westeurope" +dotnet_version = "10.0" \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/terraform/variables.tf b/samples/function-app-service-bus/dotnet/terraform/variables.tf new file mode 100644 index 0000000..cee2bed --- /dev/null +++ b/samples/function-app-service-bus/dotnet/terraform/variables.tf @@ -0,0 +1,557 @@ +variable "prefix" { + description = "(Optional) Specifies the prefix for the name of the Azure resources." + type = string + default = "local" + + validation { + condition = var.prefix == null || length(var.prefix) >= 2 + error_message = "The prefix must be at least 2 characters long." + } +} + +variable "suffix" { + description = "(Optional) Specifies the suffix for the name of the Azure resources." + type = string + default = "test" + + validation { + condition = var.suffix == null || length(var.suffix) >= 2 + error_message = "The suffix must be at least 2 characters long." + } +} + +variable "location" { + description = "(Required) Specifies the location for all resources." + type = string + default = "westeurope" +} + +variable "primary_region" { + description = "(Required) Specifies the primary region for the Azure Cosmos DB account." + type = string + default = "westeurope" +} + +variable "secondary_region" { + description = "(Required) Specifies the secondary region for the Azure Cosmos DB account." + type = string + default = "northeurope" +} + +variable "mongodb_server_version" { + description = "(Optional) Specifies the version of MongoDB API for the Azure Cosmos DB account." + type = string + default = "7.0" + + validation { + condition = contains([ + "3.2", + "3.6", + "4.0", + "4.2", + "5.0", + "6.0", + "7.0", + "8.0" + ], var.mongodb_server_version) + error_message = "The mongodb_server_version must be one of the supported versions: 3.2, 3.6, 4.0, 4.2, 5.0, 6.0, 7.0, 8.0." + } +} + +variable "database_throughput" { + description = "(Optional) Specifies the throughput for the MongoDB database." + type = number + default = 400 +} + +variable "consistency_level" { + description = "(Required) Specifies the consistency level for the Azure Cosmos DB account." + type = string + default = "Eventual" + + validation { + condition = contains([ + "Strong", + "BoundedStaleness", + "Session", + "Eventual" + ], var.consistency_level) + error_message = "The consistency_level must be one of the allowed values." + } +} + +variable "cosmosdb_database_name" { + description = "(Optional) Specifies the name of the Azure Cosmos DB for MongoDB database." + type = string + default = "sampledb" +} + +variable "cosmosdb_collection_name" { + description = "(Optional) Specifies the name of the Azure Cosmos DB for MongoDB collection." + type = string + default = "activities" +} + +variable "mongodb_index_keys" { + description = "A list of field names for which to create single-field indexes on the MongoDB collection." + type = list(string) + default = ["_id", "username", "activity", "timestamp"] +} + +variable "os_type" { + description = "(Required) Specifies the O/S type for the App Services to be hosted in this plan. Possible values include Windows, Linux, and WindowsContainer. Changing this forces a new resource to be created." + type = string + default = "Linux" + + validation { + condition = contains([ + "Windows", + "Linux", + "WindowsContainer" + ], var.os_type) + error_message = "The os_type must be either 'Windows', 'Linux', or 'WindowsContainer'." + } +} + +variable "zone_balancing_enabled" { + description = "(Optional) Should the Service Plan balance across Availability Zones in the region." + type = bool + default = false +} + +variable "sku_tier" { + description = "(Optional) Specifies the tier name for the hosting plan." + type = string + default = "Standard" + + validation { + condition = contains([ + "Basic", + "Standard", + "ElasticPremium", + "Premium", + "PremiumV2", + "Premium0V3", + "PremiumV3", + "PremiumMV3", + "Isolated", + "IsolatedV2", + "WorkflowStandard", + "FlexConsumption" + ], var.sku_tier) + error_message = "The sku_tier must be one of the allowed values." + } +} +variable "sku_name" { + description = "(Optional) Specifies the SKU name for the hosting plan." + type = string + default = "S1" + + validation { + condition = contains([ + "B1", "B2", "B3", + "S1", "S2", "S3", + "EP1", "EP2", "EP3", + "P1", "P2", "P3", + "P1V2", "P2V2", "P3V2", + "P0V3", "P1V3", "P2V3", "P3V3", + "P1MV3", "P2MV3", "P3MV3", "P4MV3", "P5MV3", + "I1", "I2", "I3", + "I1V2", "I2V2", "I3V2", "I4V2", "I5V2", "I6V2", + "WS1", "WS2", "WS3", + "FC1" + ], var.sku_name) + error_message = "The sku_name must be one of the allowed values." + } +} + +variable "use_dotnet_isolated_runtime" { + description = "(Optional) Should the DotNet process use an isolated runtime. Defaults to false." + type = bool + default = true +} + +variable "java_version" { + description = "(Optional) The Version of Java to use." + type = string + default = null +} + +variable "node_version" { + description = "(Optional) The version of Node.js to run." + type = string + default = null +} + +variable "dotnet_version" { + description = "(Optional) The version of .NET to use." + type = string + default = null +} + +variable "python_version" { + description = "(Optional) Specifies the version of Python to run. Possible values include 3.13, 3.12, 3.11, 3.10, 3.9, 3.8 and 3.7." + type = string + default = null +} + +variable "https_only" { + description = "(Optional) Specifies whether the Linux Web App require HTTPS connections. Defaults to false." + type = bool + default = false +} + +variable "minimum_tls_version" { + description = "(Optional) Specifies the minimum version of TLS required for SSL requests. Possible values include: 1.0, 1.1, 1.2 and 1.3. Defaults to 1.2." + type = string + default = "1.2" + + validation { + condition = contains([ + "1.0", + "1.1", + "1.2", + "1.3" + ], var.minimum_tls_version) + error_message = "The minimum_tls_version must be one of the allowed values." + } +} + +variable "always_on" { + description = "(Optional) Specifies whether the Linux Web App is Always On enabled. Defaults to true." + type = bool + default = true +} + +variable "http2_enabled" { + description = "(Optional) Specifies whether HTTP/2 is enabled for the Linux Web App." + type = bool + default = false +} + +variable "public_network_access_enabled" { + description = "(Optional) Specifies whether the public network access is enabled or disabled." + type = bool + default = true +} + +variable "repo_url" { + description = "(Optional) Specifies the Git repository URL." + type = string + default = "" + + validation { + condition = var.repo_url == "" || can(regex("^https?://", var.repo_url)) + error_message = "The repo_url must be empty or a valid HTTP/HTTPS URL." + } +} + +variable "login_name" { + description = "(Required) Specifies the login name for the application." + type = string + default = "paolo" +} + +variable "tags" { + description = "(Optional) Specifies the tags to be applied to the resources." + type = map(string) + default = { + environment = "test" + deployment = "terraform" + } +} + +variable "vnet_name" { + description = "Specifies the name of the virtual network." + default = "VNet" + type = string +} + +variable "vnet_address_space" { + description = "Specifies the address space of the virtual network." + default = ["10.0.0.0/8"] + type = list(string) +} + +variable "webapp_subnet_name" { + description = "Specifies the name of the web app subnet." + default = "app-subnet" + type = string +} + +variable "webapp_subnet_address_prefix" { + description = "Specifies the address prefix of the web app subnet." + default = ["10.0.0.0/24"] + type = list(string) +} + +variable "pe_subnet_name" { + description = "Specifies the name of the subnet that contains the private endpoints." + default = "pe-subnet" + type = string +} + +variable "pe_subnet_address_prefix" { + description = "Specifies the address prefix of the subnet that contains the private endpoints." + default = ["10.0.1.0/24"] + type = list(string) +} + +variable "nat_gateway_name" { + description = "(Required) Specifies the name of the NAT Gateway" + type = string + default = "NatGateway" +} + +variable "nat_gateway_sku_name" { + description = "(Optional) The SKU which should be used. At this time the only supported value is Standard. Defaults to Standard" + type = string + default = "Standard" +} + +variable "nat_gateway_idle_timeout_in_minutes" { + description = "(Optional) The idle timeout which should be used in minutes. Defaults to 4." + type = number + default = 4 +} + +variable "nat_gateway_zones" { + description = " (Optional) A list of Availability Zones in which this NAT Gateway should be located. Changing this forces a new NAT Gateway to be created." + type = list(string) + default = ["1"] +} + +variable "website_port" { + description = "(Optional) Specifies the port on which the Web App will listen. Defaults to 8000." + type = number + default = 8000 +} + +variable "queue_names" { + description = "(Optional) Specifies the names of the queues to be created within the Service Bus Namespace." + type = set(string) + default = ["input", "output"] +} + +variable "service_bus_sku" { + description = "(Required) Specifies the SKU tier for the Service Bus Namespace. Options are Basic, Standard, or Premium." + type = string + default = "Premium" + + validation { + condition = contains(["Basic", "Standard", "Premium"], var.service_bus_sku) + error_message = "The SKU must be one of Basic, Standard, or Premium." + } +} + +variable "service_bus_capacity" { + description = "(Optional) Specifies the capacity for the Service Bus Namespace. When SKU is Premium, capacity can be 1, 2, 4, 8, or 16. When SKU is Basic or Standard, capacity can be 0 only." + type = number + default = 1 +} + +variable "service_bus_premium_messaging_partitions" { + description = "(Optional) Specifies the number of messaging partitions. Only valid when SKU is Premium. Possible values include 1, 2, and 4. Defaults to 1." + type = number + default = 1 +} + +variable "service_bus_local_auth_enabled" { + description = "(Optional) Specifies whether SAS authentication is enabled for the Service Bus Namespace. Defaults to true." + type = bool + default = true +} + +variable "service_bus_public_network_access_enabled" { + description = "(Optional) Specifies whether public network access is enabled for the Service Bus Namespace. Defaults to true." + type = bool + default = true +} + +variable "queue_lock_duration" { + description = "(Optional) Specifies the ISO 8601 timespan duration of a peek-lock. Maximum value is 5 minutes. Defaults to PT5M." + type = string + default = "PT5M" +} + +variable "queue_max_message_size_in_kilobytes" { + description = "(Optional) Specifies the maximum size of a message allowed on the queue in kilobytes. Only applicable for Premium SKU." + type = number + default = null +} + +variable "queue_max_size_in_megabytes" { + description = "(Optional) Specifies the size of memory allocated for the queue in megabytes." + type = number + default = 1024 +} + +variable "queue_requires_duplicate_detection" { + description = "(Optional) Specifies whether the queue requires duplicate detection. Changing this forces a new resource to be created. Defaults to false." + type = bool + default = false +} + +variable "queue_requires_session" { + description = "(Optional) Specifies whether the queue requires sessions for ordered handling of unbounded sequences of related messages. Changing this forces a new resource to be created. Defaults to false." + type = bool + default = false +} + +variable "queue_default_message_ttl" { + description = "(Optional) Specifies the ISO 8601 timespan duration of the TTL of messages sent to this queue." + type = string + default = "P10675199DT2H48M5.4775807S" +} + +variable "queue_dead_lettering_on_message_expiration" { + description = "(Optional) Specifies whether the queue has dead letter support when a message expires. Defaults to false." + type = bool + default = false +} + +variable "queue_duplicate_detection_history_time_window" { + description = "(Optional) Specifies the ISO 8601 timespan duration during which duplicates can be detected. Defaults to PT10M." + type = string + default = "PT10M" +} + +variable "queue_max_delivery_count" { + description = "(Optional) Specifies the maximum number of deliveries before a message is automatically dead lettered. Defaults to 10." + type = number + default = 10 +} + +variable "queue_status" { + description = "(Optional) Specifies the status of the queue. Possible values are Active, Creating, Deleting, Disabled, ReceiveDisabled, Renaming, SendDisabled, Unknown. Defaults to Active." + type = string + default = "Active" +} + +variable "queue_batched_operations_enabled" { + description = "(Optional) Specifies whether server-side batched operations are enabled. Defaults to true." + type = bool + default = true +} + +variable "queue_auto_delete_on_idle" { + description = "(Optional) Specifies the ISO 8601 timespan duration of the idle interval after which the queue is automatically deleted. Minimum of 5 minutes." + type = string + default = null +} + +variable "queue_partitioning_enabled" { + description = "(Optional) Specifies whether the queue is partitioned across multiple message brokers. Changing this forces a new resource to be created. Defaults to false." + type = bool + default = false +} + +variable "queue_express_enabled" { + description = "(Optional) Specifies whether Express Entities are enabled. An express queue holds a message in memory temporarily before writing it to persistent storage. Defaults to false." + type = bool + default = false +} + +variable "queue_forward_to" { + description = "(Optional) Specifies the name of a queue or topic to automatically forward messages to." + type = string + default = null +} + +variable "queue_forward_dead_lettered_messages_to" { + description = "(Optional) Specifies the name of a queue or topic to automatically forward dead lettered messages to." + type = string + default = null +} + +variable "account_replication_type" { + description = "(Optional) Specifies the replication type for the storage account." + type = string + default = "LRS" + + validation { + condition = contains([ + "LRS", + "GRS", + "RAGRS", + "ZRS", + "GZRS", + "RAGZRS" + ], var.account_replication_type) + error_message = "The account_replication_type must be one of: LRS, GRS, RAGRS, ZRS, GZRS, RAGZRS." + } +} + +variable "account_kind" { + description = "(Optional) Specifies the account kind of the storage account." + default = "StorageV2" + type = string + + validation { + condition = contains(["Storage", "StorageV2"], var.account_kind) + error_message = "The account kind of the storage account is invalid." + } +} + +variable "account_tier" { + description = "(Optional) Specifies the account tier of the storage account." + default = "Standard" + type = string + + validation { + condition = contains(["Standard", "Premium"], var.account_tier) + error_message = "The account tier of the storage account is invalid." + } +} + +variable "functions_worker_runtime" { + description = "(Required) Specifies the language runtime used by the Azure Functions App." + type = string + default = "dotnet-isolated" + + validation { + condition = contains(["dotnet", "dotnet-isolated", "python", "java", "node", "powerShell", "custom"], var.functions_worker_runtime) + error_message = "The functions_worker_runtime must be one of: dotnet, dotnet-isolated, python, java, node, powerShell, custom." + } +} + +variable "functions_extension_version" { + description = "(Optional) Specifies the Azure Functions extension version. Defaults to ~4." + type = string + default = "~4" +} + +variable "input_queue_name" { + description = "(Optional) Specifies the name of the input queue." + type = string + default = "input" +} + +variable "output_queue_name" { + description = "(Optional) Specifies the name of the output queue." + type = string + default = "output" +} + +variable "names" { + description = "(Optional) Specifies a comma-separated list of names to be used as part of the sample data in the Azure Function App." + type = string + default = "Paolo,John,Jane,Max,Mary,Leo,Mia,Anna,Lisa,Anastasia" +} + +variable "timer_schedule" { + description = "(Optional) Specifies the CRON expression for the timer trigger." + type = string + default = "*/10 * * * * *" +} + +variable "managed_identity_type" { + description = "(Optional) Specifies the type of managed identity." + type = string + default = "UserAssigned" + + validation { + condition = contains(["SystemAssigned", "UserAssigned"], var.managed_identity_type) + error_message = "The managed identity type must be either SystemAssigned or UserAssigned." + } +} diff --git a/samples/function-app-service-bus/dotnet/visio/architecture.vsdx b/samples/function-app-service-bus/dotnet/visio/architecture.vsdx new file mode 100644 index 0000000..159df5f Binary files /dev/null and b/samples/function-app-service-bus/dotnet/visio/architecture.vsdx differ diff --git a/samples/web-app-cosmosdb-mongodb-api/python/bicep/README.md b/samples/web-app-cosmosdb-mongodb-api/python/bicep/README.md index 5629ea3..df8bb54 100644 --- a/samples/web-app-cosmosdb-mongodb-api/python/bicep/README.md +++ b/samples/web-app-cosmosdb-mongodb-api/python/bicep/README.md @@ -29,21 +29,20 @@ For more information, see [Get started with the az tool on LocalStack](https://a The [deploy.sh](deploy.sh) script creates the [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli) for all the Azure resources, while the Bicep modules create the following Azure resources: -1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): A logical container scoping all resources in this sample. -2. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview): Hosts two subnets: +1. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview): Hosts two subnets: - *app-subnet*: Dedicated to [regional VNet integration](https://learn.microsoft.com/azure/azure-functions/functions-networking-options?tabs=azure-portal#outbound-networking-features) with the Function App. - *pe-subnet*: Used for hosting Azure Private Endpoints. -3. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone): Handles DNS resolution for the CosmosDB for MongoDB Private Endpoint within the virtual network. -4. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview): Secures network access to the CosmosDB for MongoDB account via a private IP within the VNet. -5. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview): Provides deterministic outbound connectivity for the Web App. Included for completeness; the sample app does not call any external services. -6. [Azure Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): Enforces inbound and outbound traffic rules across the virtual network's subnets. -7. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview): Centralizes diagnostic logs and metrics from all resources in the solution. -8. [Azure Cosmos DB for MongoDB](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/introduction): A globally distributed database account optimized for MongoDB workloads, with multi-region failover enabled. -9. [MongoDB Database](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/overview): The `sampledb` database that holds all application data. -10. [MongoDB Collection](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/overview): The `activities` collection within `sampledb`, used to store vacation activity records. -11. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The underlying compute tier that hosts the web application. -12. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Runs the Python Flask single-page application (*Vacation Planner*), connected to CosmosDB for MongoDB via VNet integration. -13. [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 continuous deployment from a public GitHub repository. +2. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone): Handles DNS resolution for the CosmosDB for MongoDB Private Endpoint within the virtual network. +3. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview): Secures network access to the CosmosDB for MongoDB account via a private IP within the VNet. +4. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview): Provides deterministic outbound connectivity for the Web App. Included for completeness; the sample app does not call any external services. +5. [Azure Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): Enforces inbound and outbound traffic rules across the virtual network's subnets. +6. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview): Centralizes diagnostic logs and metrics from all resources in the solution. +7. [Azure Cosmos DB for MongoDB](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/introduction): A globally distributed database account optimized for MongoDB workloads, with multi-region failover enabled. +8. [MongoDB Database](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/overview): The `sampledb` database that holds all application data. +9. [MongoDB Collection](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/overview): The `activities` collection within `sampledb`, used to store vacation activity records. +10. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The underlying compute tier that hosts the web application. +11. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Runs the Python Flask single-page application (*Vacation Planner*), connected to CosmosDB for MongoDB via VNet integration. +12. [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 continuous deployment from a public GitHub repository. The web app enables users to plan and manage vacation activities, with all data persisted in a CosmosDB-backed MongoDB collection. For more information on the sample application, see [Azure Web App with Azure CosmosDB for MongoDB](../README.md). diff --git a/samples/web-app-managed-identity/python/README.md b/samples/web-app-managed-identity/python/README.md index a1fca79..9f835b1 100644 --- a/samples/web-app-managed-identity/python/README.md +++ b/samples/web-app-managed-identity/python/README.md @@ -44,7 +44,6 @@ The LocalStack emulator emulates the following services, which are necessary at - [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) - [Python](https://www.python.org/downloads/) - [Flask](https://flask.palletsprojects.com/) -- [pyodbc](https://github.com/mkleehammer/pyodbc) - [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep), if you plan to install the sample via Bicep. - [Terraform](https://developer.hashicorp.com/terraform/downloads), if you plan to install the sample via Terraform.