diff --git a/.gitattributes b/.gitattributes index e3e2de9..827944e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,6 @@ *.yml text eol=lf *.bicep text eol=lf *.tf text eol=lf +*.cs text eol=lf +*.csproj text eol=lf +*.cshtml text eol=lf diff --git a/.github/workflows/run-samples.yml b/.github/workflows/run-samples.yml index 458dd37..05f9b4a 100644 --- a/.github/workflows/run-samples.yml +++ b/.github/workflows/run-samples.yml @@ -92,6 +92,9 @@ jobs: strategy: fail-fast: false matrix: ${{ fromJSON(needs.setup.outputs.matrix) }} + # A sample takes ~10-25 minutes; the bound covers a retry and stops a hung deployment from + # holding a runner for GitHub's 6-hour default. + timeout-minutes: 90 # ubuntu-22.04 for amd64, ubuntu-22.04-arm for arm64 — the matrix carries the label. # GitHub-hosted arm64 runners are free for public repositories. runs-on: ${{ matrix.runner }} @@ -191,10 +194,30 @@ jobs: # I/O pushed some starts past a 120s budget - and, on the slowest runners, # occasionally past 300s as well. A healthy emulator still returns as soon as # it is ready, so a larger timeout costs nothing on the happy path. + # A start that never becomes ready is retried on a fresh container (see below). run: | source .venv/bin/activate - python -m localstack_cli.cli.main start -d - python -m localstack_cli.cli.main wait -t 600 + # Even at 600s the wait occasionally expires: ~80 concurrent jobs compete for runner I/O + # and image pulls, and a start that stalls failed the job before the sample ever ran. + # Give it one clean retry, and print the container's logs when the first attempt expires. + # A healthy start reports ready in well under a minute, so a wait that reaches the + # timeout means the boot has stalled rather than slowed: observed on an arm64 runner, + # where the container logged nothing for the last ten minutes of the wait. Three shorter + # attempts therefore beat two long ones - same overall budget, one more chance, and a + # stalled container is replaced sooner. + attempts=3 + for attempt in $(seq 1 "${attempts}"); do + python -m localstack_cli.cli.main start -d + if python -m localstack_cli.cli.main wait -t 400; then + echo "Emulator ready (attempt ${attempt}/${attempts})." + exit 0 + fi + echo "::warning::Emulator was not ready within 400s (attempt ${attempt}/${attempts})." + docker logs localstack-main --tail 100 2>&1 || true + python -m localstack_cli.cli.main stop || true + done + echo "::error::Emulator failed to become ready after ${attempts} attempts." + exit 1 env: IMAGE_NAME: ${{ env.IMAGE_NAME }}:${{ env.DEFAULT_TAG }} LOCALSTACK_AUTH_TOKEN: ${{ secrets.TEST_LOCALSTACK_AUTH_TOKEN }} @@ -260,6 +283,12 @@ jobs: - name: "Run: ${{ matrix.name }}" # Each job runs exactly one test. SPLITS equals the total test count, and SHARD # is the 1-based index of this specific test, so run-samples.sh executes only it. + id: initial_run + # Samples provision real containers through the emulator, so a single transient error (a 500 + # from a provisioning call, a slow container start) fails the whole sample - which is what + # has been needing manual job re-runs. The retry step below decides this job's outcome, so a + # genuinely broken sample still fails: it fails both attempts. + continue-on-error: true run: make test SHARD="${SHARD}" SPLITS="${SPLITS}" env: # Dynamic matrix values routed via env, not interpolated into the shell (template-injection) @@ -268,6 +297,31 @@ jobs: LOCALSTACK_AUTH_TOKEN: ${{ secrets.TEST_LOCALSTACK_AUTH_TOKEN }} PURGE_DOCKER: "1" + - name: "Retry: ${{ matrix.name }}" + # The deploy scripts are not idempotent - they fail on resources that already exist - so the + # retry runs against a fresh emulator instead of the half-provisioned state the failed + # attempt left behind. + if: steps.initial_run.outcome == 'failure' + run: | + source .venv/bin/activate + echo "::warning::${MATRIX_NAME} failed once; retrying on a fresh emulator." + python -m localstack_cli.cli.main stop || true + python -m localstack_cli.cli.main start -d + python -m localstack_cli.cli.main wait -t 600 + make test SHARD="${SHARD}" SPLITS="${SPLITS}" + env: + MATRIX_NAME: ${{ matrix.name }} + SHARD: ${{ matrix.shard }} + SPLITS: ${{ matrix.splits }} + IMAGE_NAME: ${{ env.IMAGE_NAME }}:${{ env.DEFAULT_TAG }} + LOCALSTACK_AUTH_TOKEN: ${{ secrets.TEST_LOCALSTACK_AUTH_TOKEN }} + DOCKER_FLAGS: "-e MSSQL_ACCEPT_EULA=Y -e GITHUB_API_TOKEN=${{ github.token }}" + LS_LOG: "DEBUG" + DISABLE_EVENTS: "1" + ACTIVATE_PRO: "1" + DNS_ADDRESS: "0" + PURGE_DOCKER: "1" + - name: Get LocalStack Logs # Captured on failure or success to provide a detailed audit trail of the emulator's activity. if: always() diff --git a/README.md b/README.md index b98547d..a82c529 100644 --- a/README.md +++ b/README.md @@ -27,26 +27,28 @@ This repository contains comprehensive sample projects demonstrating how to deve ## Outline -| Sample Name | Description | -|-------------|-------------| -| [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 | -| [Web App and SQL Database ](./samples/web-app-sql-database/python/README.md) | Azure Web App using SQL Database | -| [Web App and PostgreSQL Database ](./samples/web-app-postgresql-flexible-server/python/README.md) | Azure Web App using PostgreSQL Database | -| [Web App and MySQL Database ](./samples/web-app-mysql-flexible-server/python/README.md) | Azure Web App using MySQL Database | -| [Web App with Custom Docker Image](./samples/web-app-custom-image/python/README.md) | Azure Web App running a custom Docker image | -| [ACI and Blob Storage](./samples/aci-blob-storage/python/README.md) | Azure Container Instances with ACR, Key Vault, and Blob Storage | -| [Container Apps and Blob Storage](./samples/container-apps-blob-storage/python/README.md) | Azure Container Apps running a guestbook app from ACR with Blob Storage, secrets, revisions, replicas and scale rules | -| [Azure Service Bus with Spring Boot](./samples/servicebus/java/README.md) | Azure Service Bus used by a Spring Boot application | -| [URL Shortener](./samples/url-shortener/python/README.md) | URL shortener composing Web App, Functions, Storage, Key Vault, Service Bus and PostgreSQL | -| [Event Hubs Fraud Detection Pipeline](./samples/eventhubs/python/README.md) | Real-time payment stream processing with Event Hubs (AMQP, Kafka and HTTPS ingestion, Capture, Schema Registry), an Event Hubs-triggered Function App, Key Vault, Storage and a Web App dashboard | -| [Event Hubs Cold-Path Automation](./samples/eventhubs-eventgrid/python/README.md) | Event Hubs Capture raises `Microsoft.EventHub.CaptureFileCreated` to an Event Grid system topic, a subscription delivers it into a second event hub, and an Event Hubs-triggered Function App decodes each Avro archive and writes per-device summaries | - +Each sample is a self-contained project with its own README, Azure CLI scripts and, where applicable, Bicep and Terraform deployments that target both real Azure and the LocalStack for Azure emulator. The language of each implementation is shown in parentheses; the web-app samples share the same *Vacation Planner* application and come in a Python (Flask) and a .NET 10 (ASP.NET Core Razor Pages) version. + +| Sample | Description | +|--------|-------------| +| [Function App and Storage (.NET)](./samples/function-app-storage-http/dotnet/README.md) | A gaming scoreboard built on Azure Functions (isolated worker): HTTP triggers record player scores in Table Storage, publish messages to Queue Storage and write game-session summaries to Blob Storage, all against the emulated storage account. | +| [Function App and Front Door (Python)](./samples/function-app-front-door/python/README.md) | A minimal Python Function App answering `/{name}`, published behind an Azure Front Door (Standard) profile so requests reach the function through the Front Door endpoint; deployable to real Azure or to the emulator. | +| [Function App and Managed Identities (Python)](./samples/function-app-managed-identity/python/README.md) | A serverless text processor: an Azure Functions app reads text blobs from an `input` container, converts them to uppercase and writes the result to an `output` container, authenticating to the storage account with a managed identity instead of keys. | +| [Function App and Service Bus (.NET)](./samples/function-app-service-bus/dotnet/README.md) | An Azure Functions app on an App Service plan that exchanges messages through Service Bus queues: an HTTP trigger sends greetings and a queue trigger consumes them, connecting with either a connection string or a managed identity. | +| Web App and CosmosDB for MongoDB API ([Python](./samples/web-app-cosmosdb-mongodb-api/python/README.md), [.NET](./samples/web-app-cosmosdb-mongodb-api/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App with regional VNet integration, storing activities in the `activities` collection of an Azure Cosmos DB for MongoDB account reached through a private endpoint. | +| Web App and CosmosDB for NoSQL API ([Python](./samples/web-app-cosmosdb-nosql-api/python/README.md), [.NET](./samples/web-app-cosmosdb-nosql-api/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing activities as JSON items in the `activities` container of an Azure Cosmos DB for NoSQL database partitioned by user; deployed with Azure CLI scripts. | +| Web App and Managed Identities ([Python](./samples/web-app-managed-identity/python/README.md), [.NET](./samples/web-app-managed-identity/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing each activity as a blob in an `activities` container and accessing Blob Storage through a user-assigned or system-assigned managed identity rather than connection strings. | +| Web App and SQL Database ([Python](./samples/web-app-sql-database/python/README.md), [.NET](./samples/web-app-sql-database/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing activities in an Azure SQL Database. The connection string and the HTTPS certificate are read from Azure Key Vault, and an API endpoint verifies the Key Vault certificate. | +| Web App and PostgreSQL Database ([Python](./samples/web-app-postgresql-flexible-server/python/README.md), [.NET](./samples/web-app-postgresql-flexible-server/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing activities in an Azure Database for PostgreSQL flexible server injected into a virtual network (delegated subnet and private DNS zone), with the database and application user created by the deploy scripts. | +| Web App and MySQL Database ([Python](./samples/web-app-mysql-flexible-server/python/README.md), [.NET](./samples/web-app-mysql-flexible-server/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing activities in an Azure Database for MySQL flexible server injected into a virtual network, using TLS-only connections, with the database and application user created by the deploy scripts. | +| Web App with Custom Docker Image ([Python](./samples/web-app-custom-image/python/README.md), [.NET](./samples/web-app-custom-image/dotnet/README.md)) | A web app that runs a custom container image built locally and pushed to Azure Container Registry; the web app pulls it with a managed identity (AcrPull) through a VNet-integrated network and reports its image and host name on `/api/status`. | +| [ACI and Blob Storage (Python)](./samples/aci-blob-storage/python/README.md) | A containerized Flask web app on Azure Container Instances, with its image in Azure Container Registry, its secrets in Key Vault and its data in Blob Storage. | +| [Container Apps and Blob Storage (Python)](./samples/container-apps-blob-storage/python/README.md) | A guestbook web app on Azure Container Apps pulled from Azure Container Registry that persists entries in Blob Storage and exercises secrets, revisions, replicas and scale rules. | +| [Azure Service Bus with Spring Boot (Java)](./samples/servicebus/java/README.md) | A Java Spring Boot application that sends and receives Service Bus messages through the Spring Cloud Azure Service Bus Stream Binder. | +| [URL Shortener (Python)](./samples/url-shortener/python/README.md) | *Linklet*, a Flask URL shortener on an Azure Web App with an event-driven Azure Functions worker, composing Web App, Functions, Storage, Key Vault, Service Bus and PostgreSQL into a single causal chain. | +| [Event Hubs Fraud Detection Pipeline (Python)](./samples/eventhubs/python/README.md) | A streaming platform in miniature: payments ingested into Event Hubs over AMQP, Kafka and HTTPS, validated against Schema Registry, archived with Capture, scored by an Event Hubs-triggered Function App and shown on a Web App dashboard, with secrets in Key Vault. | +| [Event Hubs Cold-Path Automation (Python)](./samples/eventhubs-eventgrid/python/README.md) | A cold-chain monitoring pipeline in which Event Hubs Capture raises `Microsoft.EventHub.CaptureFileCreated` events to an Event Grid system topic; a subscription delivers them into a second event hub and an Event Hubs-triggered Function App decodes each Avro archive into per-device summaries. | + ## Sample Structure Each sample project is organized by Azure service and includes: diff --git a/run-samples.sh b/run-samples.sh index 9ebb94c..9009023 100755 --- a/run-samples.sh +++ b/run-samples.sh @@ -37,11 +37,18 @@ SAMPLES=( "samples/function-app-service-bus/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-http-trigger.sh" "samples/function-app-storage-http/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-http-triggers.sh" "samples/web-app-cosmosdb-mongodb-api/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-cosmosdb-mongodb-api/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-cosmosdb-nosql-api/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-managed-identity/python|bash scripts/user-assigned.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" - "samples/web-app-sql-database/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/get-web-app-url.sh" + "samples/web-app-managed-identity/dotnet|bash scripts/user-assigned.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-sql-database/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-sql-database/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-mysql-flexible-server/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-mysql-flexible-server/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-postgresql-flexible-server/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-postgresql-flexible-server/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-custom-image/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-custom-image/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/aci-blob-storage/python|bash scripts/deploy.sh|bash scripts/validate.sh" "samples/container-apps-blob-storage/python|bash scripts/deploy.sh|bash scripts/validate.sh" "samples/url-shortener/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" @@ -56,10 +63,15 @@ TERRAFORM_SAMPLES=( "samples/function-app-service-bus/dotnet/terraform|bash deploy.sh" "samples/function-app-storage-http/dotnet/terraform|bash deploy.sh" "samples/web-app-cosmosdb-mongodb-api/python/terraform|bash deploy.sh" + "samples/web-app-cosmosdb-mongodb-api/dotnet/terraform|bash deploy.sh" "samples/web-app-managed-identity/python/terraform|bash deploy.sh" + "samples/web-app-managed-identity/dotnet/terraform|bash deploy.sh" "samples/web-app-sql-database/python/terraform|bash deploy.sh" + "samples/web-app-sql-database/dotnet/terraform|bash deploy.sh" "samples/web-app-mysql-flexible-server/python/terraform|bash deploy.sh" + "samples/web-app-mysql-flexible-server/dotnet/terraform|bash deploy.sh" "samples/web-app-postgresql-flexible-server/python/terraform|bash deploy.sh" + "samples/web-app-postgresql-flexible-server/dotnet/terraform|bash deploy.sh" "samples/aci-blob-storage/python/terraform|bash deploy.sh" "samples/container-apps-blob-storage/python/terraform|bash deploy.sh" "samples/url-shortener/python/terraform|bash deploy.sh|bash ../scripts/validate.sh" @@ -75,9 +87,13 @@ BICEP_SAMPLES=( "samples/function-app-service-bus/dotnet/bicep|bash deploy.sh" "samples/function-app-storage-http/dotnet/bicep|bash deploy.sh" "samples/web-app-cosmosdb-mongodb-api/python/bicep|bash deploy.sh" + "samples/web-app-cosmosdb-mongodb-api/dotnet/bicep|bash deploy.sh" "samples/web-app-managed-identity/python/bicep|bash deploy.sh" + "samples/web-app-managed-identity/dotnet/bicep|bash deploy.sh" "samples/web-app-mysql-flexible-server/python/bicep|bash deploy.sh" + "samples/web-app-mysql-flexible-server/dotnet/bicep|bash deploy.sh" "samples/web-app-postgresql-flexible-server/python/bicep|bash deploy.sh" + "samples/web-app-postgresql-flexible-server/dotnet/bicep|bash deploy.sh" "samples/aci-blob-storage/python/bicep|bash deploy.sh" "samples/container-apps-blob-storage/python/bicep|bash deploy.sh" "samples/url-shortener/python/bicep|bash deploy.sh|bash ../scripts/validate.sh" @@ -122,6 +138,7 @@ ARM64_SAMPLE_DIRS=( "samples/function-app-service-bus/dotnet" "samples/function-app-storage-http/dotnet" "samples/web-app-custom-image/python" + "samples/web-app-custom-image/dotnet" ) # Fail loudly if an entry no longer matches a registered sample: a rename would otherwise diff --git a/samples/aci-blob-storage/python/terraform/README.md b/samples/aci-blob-storage/python/terraform/README.md index 1c3dedf..94facad 100644 --- a/samples/aci-blob-storage/python/terraform/README.md +++ b/samples/aci-blob-storage/python/terraform/README.md @@ -38,7 +38,7 @@ provider "azurerm" { prevent_deletion_if_contains_resources = false } } - metadata_host="localhost.localstack.cloud:4566" + metadata_host="azure.localhost.localstack.cloud:4566" subscription_id = "00000000-0000-0000-0000-000000000000" } ``` diff --git a/samples/aci-blob-storage/python/terraform/providers.tf b/samples/aci-blob-storage/python/terraform/providers.tf index 5201455..266e75d 100644 --- a/samples/aci-blob-storage/python/terraform/providers.tf +++ b/samples/aci-blob-storage/python/terraform/providers.tf @@ -19,7 +19,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/container-apps-blob-storage/python/terraform/README.md b/samples/container-apps-blob-storage/python/terraform/README.md index 1e03c8a..8b380df 100644 --- a/samples/container-apps-blob-storage/python/terraform/README.md +++ b/samples/container-apps-blob-storage/python/terraform/README.md @@ -36,7 +36,7 @@ provider "azurerm" { prevent_deletion_if_contains_resources = false } } - metadata_host="localhost.localstack.cloud:4566" + metadata_host="azure.localhost.localstack.cloud:4566" subscription_id = "00000000-0000-0000-0000-000000000000" } ``` diff --git a/samples/container-apps-blob-storage/python/terraform/providers.tf b/samples/container-apps-blob-storage/python/terraform/providers.tf index 5201455..266e75d 100644 --- a/samples/container-apps-blob-storage/python/terraform/providers.tf +++ b/samples/container-apps-blob-storage/python/terraform/providers.tf @@ -19,7 +19,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/eventhubs-eventgrid/python/terraform/providers.tf b/samples/eventhubs-eventgrid/python/terraform/providers.tf index cfe920b..f3beceb 100644 --- a/samples/eventhubs-eventgrid/python/terraform/providers.tf +++ b/samples/eventhubs-eventgrid/python/terraform/providers.tf @@ -20,7 +20,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/eventhubs/python/terraform/README.md b/samples/eventhubs/python/terraform/README.md index 49e006d..bd6a438 100644 --- a/samples/eventhubs/python/terraform/README.md +++ b/samples/eventhubs/python/terraform/README.md @@ -9,7 +9,7 @@ application code with the Azure CLI. - `terraform` and `az` are on the PATH. The provider is already pointed at the emulator in `providers.tf` -(`metadata_host = "localhost.localstack.cloud:4566"`), so no `tflocal` wrapper is needed. +(`metadata_host = "azure.localhost.localstack.cloud:4566"`), so no `tflocal` wrapper is needed. ## Usage diff --git a/samples/eventhubs/python/terraform/providers.tf b/samples/eventhubs/python/terraform/providers.tf index cfe920b..f3beceb 100644 --- a/samples/eventhubs/python/terraform/providers.tf +++ b/samples/eventhubs/python/terraform/providers.tf @@ -20,7 +20,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/function-app-managed-identity/python/terraform/README.md b/samples/function-app-managed-identity/python/terraform/README.md index b65526f..46e15f7 100644 --- a/samples/function-app-managed-identity/python/terraform/README.md +++ b/samples/function-app-managed-identity/python/terraform/README.md @@ -76,7 +76,7 @@ provider "azurerm" { # 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" + metadata_host="azure.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" diff --git a/samples/function-app-managed-identity/python/terraform/providers.tf b/samples/function-app-managed-identity/python/terraform/providers.tf index 25af634..6682178 100644 --- a/samples/function-app-managed-identity/python/terraform/providers.tf +++ b/samples/function-app-managed-identity/python/terraform/providers.tf @@ -19,7 +19,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/function-app-service-bus/dotnet/terraform/README.md b/samples/function-app-service-bus/dotnet/terraform/README.md index 55c5ccd..ed4e8a2 100644 --- a/samples/function-app-service-bus/dotnet/terraform/README.md +++ b/samples/function-app-service-bus/dotnet/terraform/README.md @@ -94,7 +94,7 @@ provider "azurerm" { # 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" + metadata_host="azure.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" diff --git a/samples/function-app-service-bus/dotnet/terraform/providers.tf b/samples/function-app-service-bus/dotnet/terraform/providers.tf index 0b17881..1f06025 100644 --- a/samples/function-app-service-bus/dotnet/terraform/providers.tf +++ b/samples/function-app-service-bus/dotnet/terraform/providers.tf @@ -17,7 +17,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/function-app-storage-http/dotnet/terraform/README.md b/samples/function-app-storage-http/dotnet/terraform/README.md index 9db1b48..f1374b5 100644 --- a/samples/function-app-storage-http/dotnet/terraform/README.md +++ b/samples/function-app-storage-http/dotnet/terraform/README.md @@ -71,7 +71,7 @@ provider "azurerm" { # 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" + metadata_host="azure.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" diff --git a/samples/function-app-storage-http/dotnet/terraform/providers.tf b/samples/function-app-storage-http/dotnet/terraform/providers.tf index 25af634..6682178 100644 --- a/samples/function-app-storage-http/dotnet/terraform/providers.tf +++ b/samples/function-app-storage-http/dotnet/terraform/providers.tf @@ -19,7 +19,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/servicebus/java/terraform/README.md b/samples/servicebus/java/terraform/README.md index c2b31f7..072fc62 100644 --- a/samples/servicebus/java/terraform/README.md +++ b/samples/servicebus/java/terraform/README.md @@ -69,7 +69,7 @@ provider "azurerm" { # 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" + metadata_host="azure.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" diff --git a/samples/servicebus/java/terraform/providers.tf b/samples/servicebus/java/terraform/providers.tf index 0b17881..1f06025 100644 --- a/samples/servicebus/java/terraform/providers.tf +++ b/samples/servicebus/java/terraform/providers.tf @@ -17,7 +17,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/url-shortener/python/terraform/providers.tf b/samples/url-shortener/python/terraform/providers.tf index 95bbc9d..85b3364 100644 --- a/samples/url-shortener/python/terraform/providers.tf +++ b/samples/url-shortener/python/terraform/providers.tf @@ -23,7 +23,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md b/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md new file mode 100644 index 0000000..59ee963 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md @@ -0,0 +1,153 @@ +# Azure Web App with Azure CosmosDB for MongoDB + +This sample demonstrates a ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in the `activities` collection of the `sampledb` MongoDB database on an [Azure CosmosDB for MongoDB](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/introduction) account. + +## Architecture + +The following diagram illustrates the architecture of the solution: + +![Architecture Diagram](./images/architecture.png) + +The web app enables users to plan and manage vacation activities, with all data persisted in a CosmosDB-backed MongoDB collection. 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 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 ASP.NET Core Razor Pages 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. + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/) +- [MongoDB C# Driver](https://www.mongodb.com/docs/drivers/csharp/current/) +- [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/) to obtain your Auth Token and set it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure). To pull the image, execute: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator by running: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +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 + +1. Retrieve the port published and mapped to port 80 by the Docker container hosting the emulated Web App. +2. Open a web browser and navigate to `http://localhost:`. +3. If the deployment was successful, you will see the following user interface for adding and removing activities: + +![Architecture Diagram](./images/vacation-planner.png) + +You can use the `call-web-app.sh` Bash script below to call the web app. The script demonstrates three methods for calling web apps: + +1. **Through the LocalStack for Azure emulator**: Call the web app via the emulator using its default host name. The emulator acts as a proxy to the web app. +2. **Via localhost and host port mapped to the container's port**: Use `127.0.0.1` with the host port mapped to the container's port `80`. +3. **Via container IP address**: Use the app container's IP address on port `80`. This technique is only available when accessing the web app from the Docker host machine. +4. **Via the default hostname**: Call the web app via the default hostname `.azurewebsites.azure.localhost.localstack.cloud:4566`. + +## MongoDB Tooling + +You can utilize [MongoDB Compass](https://www.mongodb.com/try/download/compass) to explore and manage your MongoDB databases and collections. Ensure you connect using `mongodb://localhost:port` connection string, where `port` corresponds to the port published by the MongoDB container on the host and mapped to the internal MongoDB port `27017`. + +Alternatively, you can use the [MongoDB Shell](https://www.mongodb.com/docs/mongodb-shell/) to interact with and administer your MongoDB instance, as shown in the following table: + +```bash +~$ mongosh mongodb://localhost:32770 +Current Mongosh Log ID: 6914588406320f60899dc29c +Connecting to: mongodb://localhost:32770/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.5.9 +Using MongoDB: 8.0.15 +Using Mongosh: 2.5.9 + +For mongosh info see: https://www.mongodb.com/docs/mongodb-shell/ + +------ + The server generated these startup warnings when booting + 2025-11-12T09:28:07.726+00:00: Using the XFS filesystem is strongly recommended with the WiredTiger storage engine. See http://dochub.mongodb.org/core/prodnotes-filesystem + 2025-11-12T09:28:07.892+00:00: Access control is not enabled for the database. Read and write access to data and configuration is unrestricted + 2025-11-12T09:28:07.892+00:00: For customers running the current memory allocator, we suggest changing the contents of the following sysfsFile + 2025-11-12T09:28:07.892+00:00: We suggest setting the contents of sysfsFile to 0. + 2025-11-12T09:28:07.892+00:00: vm.max_map_count is too low + 2025-11-12T09:28:07.892+00:00: We suggest setting swappiness to 0 or 1, as swapping can cause performance problems. +------ + +test> show dbs +admin 100.00 KiB +config 108.00 KiB +local 40.00 KiB +sampledb 180.00 KiB +test> use sampledb +switched to db sampledb +sampledb> show collections +activities +sampledb> db.activities.find().pretty() +[ + { + _id: '39ab62c2aaa0015ed5309876053e4146', + username: 'Paolo', + activity: 'Go to Paris', + timestamp: '2025-11-12T09:31:43.338268' + }, + { + _id: '4fb8f53442d3ebe9167245f9555bac51', + username: 'Paolo', + activity: 'Go to Madrid', + timestamp: '2025-11-12T09:31:50.109456' + }, + { + _id: '84646160cb1db21a7083b4c5b6e2d9d0', + username: 'Paolo', + activity: 'Go to Rome', + timestamp: '2025-11-12T09:32:21.781936' + } +] +``` + +## References + +- [Azure Web Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [Azure CosmosDB for MongoDB API](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/introduction) +- [Quickstart: Deploy an ASP.NET web app to Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/quickstart-dotnetcore?pivots=development-environment-cli) +- [Quickstart: CosmosDB for MongoDB](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/quickstart-dotnet) +- [Azure Identity client library for .NET](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/identity-readme) +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/README.md b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/README.md new file mode 100644 index 0000000..4ba1684 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/README.md @@ -0,0 +1,287 @@ +# 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 Web App with Azure CosmosDB for MongoDB](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [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 +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## 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 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 ASP.NET Core Razor Pages 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). + +## 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 = 'dotnetcore' +param runtimeVersion = '10.0' +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 (Web App name, CosmosDB details) +- Creates zip package of the ASP.NET Core application source +- Deploys the zip to Azure Web 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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `bicep` folder: + +```bash +cd samples/web-app-cosmosdb-mongodb-api/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}" +WEBAPP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +PRIVATE_DNS_ZONE_NAME="privatelink.mongo.cosmos.azure.com" +PRIVATE_ENDPOINT_NAME="${PREFIX}-mongodb-pe-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEBAPP_NAME="${PREFIX}-webapp-${SUFFIX}" +COSMOSDB_ACCOUNT_NAME="${PREFIX}-mongodb-${SUFFIX}" +MONGODB_DATABASE_NAME="sampledb" +COLLECTION_NAME="activities" +# 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 Web App +echo -e "\n[$WEBAPP_NAME] web app:\n" +az webapp show \ + --name "$WEBAPP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Azure CosmosDB account +echo -e "\n[$COSMOSDB_ACCOUNT_NAME] cosmosdb account:\n" +az cosmosdb show \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,DocumentEndpoint:documentEndpoint}' \ + --output table \ + --only-show-errors + +# Check MongoDB database +echo -e "\n[$MONGODB_DATABASE_NAME] mongodb database:\n" +az cosmosdb mongodb database show \ + --name "$MONGODB_DATABASE_NAME" \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check MongoDB collection +echo -e "\n[$COLLECTION_NAME] mongodb collection:\n" +az cosmosdb mongodb collection show \ + --name "$COLLECTION_NAME" \ + --database-name "$MONGODB_DATABASE_NAME" \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEBAPP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEBAPP_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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/deploy.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/deploy.sh new file mode 100755 index 0000000..cd7172f --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/deploy.sh @@ -0,0 +1,156 @@ +#!/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="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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 . + WEB_APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.webAppName.value') + ACCOUNT_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.accountName.value') + DATABASE_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.databaseName.value') + COLLECTION_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.collectionName.value') + DOCUMENT_ENDPOINT=$(echo "$DEPLOYMENT_JSON" | jq -r '.documentEndpoint.value') + echo "Deployment details:" + echo "Web App Name: $WEB_APP_NAME" + echo "Database Account Name: $ACCOUNT_NAME" + echo "Database Name: $DATABASE_NAME" + echo "Collection Name: $COLLECTION_NAME" + echo "Document Endpoint: $DOCUMENT_ENDPOINT" +else + echo "Failed to deploy Bicep template [$TEMPLATE]" + exit 1 +fi + +if [[ -z "$WEB_APP_NAME" || -z "$ACCOUNT_NAME" ]]; then + echo "Web App Name or Cosmos DB Account Name is empty. Exiting." + exit 1 +fi + +# Print the application settings of the web app +echo "Retrieving application settings for web app [$WEB_APP_NAME]..." +az webapp config appsettings list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +# Remove the zip package of the web 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/web-app-cosmosdb-mongodb-api/dotnet/bicep/main.bicep b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/main.bicep new file mode 100644 index 0000000..ef4e9d4 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/main.bicep @@ -0,0 +1,407 @@ +//******************************************** +// 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 Web App.') +@allowed([ + 'dotnet' + 'dotnet-isolated' + 'dotnetcore' + 'python' + 'java' + 'node' + 'powerShell' + 'custom' +]) +param runtimeName string + +@description('Specifies the target language version used by the Azure Web 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 webAppKind string = 'app,linux' + +@description('Specifies whether HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = false + +@description('Specifies the minimum TLS version for the Azure Web App.') +@allowed([ + '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 the optional Git Repo URL.') +param repoUrl string = ' ' + +@description('Specifies the primary replica region for the Cosmos DB account.') +param primaryRegion string = 'westeurope' + +@description('Specifies the secondary replica region for the Cosmos DB account.') +param secondaryRegion string = 'northeurope' + +@allowed([ + 'Eventual' + 'ConsistentPrefix' + 'Session' + 'BoundedStaleness' + 'Strong' +]) +@description('Specifies the default consistency level of the Cosmos DB account.') +param defaultConsistencyLevel string = 'Eventual' + +@allowed([ + '3.2' + '3.6' + '4.0' + '4.2' + '5.0' + '6.0' + '7.0' + '8.0' +]) + + +@description('Specifies the Cosmos DB server version to use.') +param serverVersion string = '7.0' + +@minValue(10) +@maxValue(2147483647) +@description('Specifies the max stale requests. Required for BoundedStaleness. Valid ranges, Single Region: 10 to 2147483647. Multi Region: 100000 to 2147483647.') +param maxStalenessPrefix int = 100000 + +@minValue(5) +@maxValue(86400) +@description('Specifies the max lag time (seconds). Required for BoundedStaleness. Valid ranges, Single Region: 5 to 84600. Multi Region: 300 to 86400.') +param maxIntervalInSeconds int = 300 + +@description('Specifies the name for the Mongo DB database.') +param databaseName string = 'sampledb' + +@minValue(400) +@maxValue(1000000) +@description('Specifies the shared throughput for the Mongo DB database, up to 25 collections.') +param sharedThroughput int = 400 + +@description('Specifies the name for the Mongo DB collection.') +param collectionName string = 'activities' + +@minValue(400) +@maxValue(1000000) +@description('Specifies the dedicated throughput for the Mongo DB collection.') +param dedicatedThroughput int = 400 + +@description('Specifies a list of field names for which to create single-field indexes on the MongoDB collection.') +param mongoDbIndexKeys array = ['_id','username', 'activity', 'timestamp'] + +@description('Specifies the username for the application.') +param username string = 'paolo' + +@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 Web App for the regional virtual network integration.') +param webAppSubnetName string = 'app-subnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param webAppSubnetNsgName 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 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 the name of the private endpoint to the Azure CosmosDB for MongoDB API account.') +param cosmosDbPrivateEndpointName string = '' + +@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 tags to be applied to the resources.') +param tags object = { + environment: 'test' + iac: 'bicep' +} + +//******************************************** +// Variables +//******************************************** +var webAppName = '${prefix}-webapp-${suffix}' +var appServicePlanName = '${prefix}-app-service-plan-${suffix}' +var accountName = '${prefix}-mongodb-${suffix}' +var privateDnsZoneName = 'privatelink.mongo.cosmos.azure.com' + +//******************************************** +// Modules and Resources +//******************************************** +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 mongoDb 'modules/mongo-db.bicep' = { + name: 'mongoDb' + params: { + name: accountName + location: location + primaryRegion: primaryRegion + secondaryRegion: secondaryRegion + defaultConsistencyLevel: defaultConsistencyLevel + serverVersion: serverVersion + maxStalenessPrefix: maxStalenessPrefix + maxIntervalInSeconds: maxIntervalInSeconds + databaseName: databaseName + sharedThroughput: sharedThroughput + collectionName: collectionName + dedicatedThroughput: dedicatedThroughput + mongoDbIndexKeys: mongoDbIndexKeys + workspaceId: workspace.outputs.id + tags: tags + } +} + +module network 'modules/virtual-network.bicep' = { + name: 'network' + params: { + virtualNetworkName: empty(virtualNetworkName) ? toLower('${prefix}-vnet-${suffix}') : virtualNetworkName + virtualNetworkAddressPrefixes: virtualNetworkAddressPrefixes + webAppSubnetName: webAppSubnetName + webAppSubnetAddressPrefix: webAppSubnetAddressPrefix + webAppSubnetNsgName: empty(webAppSubnetNsgName) ? toLower('${prefix}-webapp-subnet-nsg-${suffix}') : webAppSubnetNsgName + 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 privateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'privateDnsZone' + params: { + name: privateDnsZoneName + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module privateEndpoints 'modules/private-endpoint.bicep' = { + name: 'privateEndpoints' + params: { + name: empty(cosmosDbPrivateEndpointName) + ? toLower('${prefix}-mongodb-pe-${suffix}') + : cosmosDbPrivateEndpointName + privateLinkServiceId: mongoDb.outputs.id + privateDnsZoneId: privateDnsZone.outputs.id + vnetId: network.outputs.virtualNetworkId + subnetId: network.outputs.peSubnetId + groupIds: [ + 'mongodb' + ] + 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 webApp 'modules/web-app.bicep' = { + name: webAppName + params: { + name: webAppName + location: location + kind: webAppKind + httpsOnly: httpsOnly + runtimeName: runtimeName + runtimeVersion: runtimeVersion + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + repoUrl: repoUrl + virtualNetworkName: network.outputs.virtualNetworkName + subnetName: network.outputs.webAppSubnetName + hostingPlanName: appServicePlan.outputs.name + accountName: mongoDb.outputs.name + databaseName: mongoDb.outputs.databaseName + collectionName: mongoDb.outputs.collectionName + username: username + workspaceId: workspace.outputs.id + tags: tags + } +} + +//******************************************** +// Outputs +//******************************************** +output webAppName string = webApp.outputs.name +output accountName string = mongoDb.outputs.name +output databaseName string = mongoDb.outputs.databaseName +output collectionName string = collectionName +output documentEndpoint string = mongoDb.outputs.documentEndpoint diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/main.bicepparam b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/main.bicepparam new file mode 100644 index 0000000..aac557d --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/main.bicepparam @@ -0,0 +1,11 @@ +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'dotnetcore' +param runtimeVersion = '10.0' +param databaseName = 'sampledb' +param collectionName = 'activities' +param username = 'paolo' +param primaryRegion = 'westeurope' +param secondaryRegion = 'northeurope' diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/app-service-plan.bicep b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/app-service-plan.bicep new file mode 100644 index 0000000..4b5cfb3 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/log-analytics.bicep b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/log-analytics.bicep new file mode 100644 index 0000000..2618829 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/mongo-db.bicep b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/mongo-db.bicep new file mode 100644 index 0000000..b95aef4 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/mongo-db.bicep @@ -0,0 +1,217 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies a globally unique name the Azure Web App.') +param name string + +@description('Specifies the location for all resources.') +param location string = resourceGroup().location + +@description('Specifies the name for the Mongo DB database.') +param databaseName string = 'sampledb' + +@minValue(400) +@maxValue(1000000) +@description('Specifies the shared throughput for the Mongo DB database, up to 25 collections.') +param sharedThroughput int = 400 + +@description('Specifies the name for the Mongo DB collection.') +param collectionName string = 'activities' + +@minValue(400) +@maxValue(1000000) +@description('Specifies the dedicated throughput for the Mongo DB collection.') +param dedicatedThroughput int = 400 + +@description('Specifies a list of field names for which to create single-field indexes on the MongoDB collection.') +param mongoDbIndexKeys array = ['_id','username', 'activity', 'timestamp'] + +@description('Specifies the primary replica region for the Cosmos DB account.') +param primaryRegion string = 'westeurope' + +@description('Specifies the secondary replica region for the Cosmos DB account.') +param secondaryRegion string = 'northeurope' + +@allowed([ + 'Eventual' + 'ConsistentPrefix' + 'Session' + 'BoundedStaleness' + 'Strong' +]) +@description('Specifies the default consistency level of the Cosmos DB account.') +param defaultConsistencyLevel string = 'Eventual' + +@allowed([ + '3.2' + '3.6' + '4.0' + '4.2' + '5.0' + '6.0' + '7.0' + '8.0' +]) + +@description('Specifies the Cosmos DB server version to use.') +param serverVersion string = '7.0' + +@minValue(10) +@maxValue(2147483647) +@description('Specifies the max stale requests. Required for BoundedStaleness. Valid ranges, Single Region: 10 to 2147483647. Multi Region: 100000 to 2147483647.') +param maxStalenessPrefix int = 100000 + +@minValue(5) +@maxValue(86400) +@description('Specifies the max lag time (seconds). Required for BoundedStaleness. Valid ranges, Single Region: 5 to 84600. Multi Region: 300 to 86400.') +param maxIntervalInSeconds int = 300 + +@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 consistencyPolicy = { + Eventual: { + defaultConsistencyLevel: 'Eventual' + } + ConsistentPrefix: { + defaultConsistencyLevel: 'ConsistentPrefix' + } + Session: { + defaultConsistencyLevel: 'Session' + } + BoundedStaleness: { + defaultConsistencyLevel: 'BoundedStaleness' + maxStalenessPrefix: maxStalenessPrefix + maxIntervalInSeconds: maxIntervalInSeconds + } + Strong: { + defaultConsistencyLevel: 'Strong' + } +} +var locations = [ + { + locationName: primaryRegion + failoverPriority: 0 + isZoneRedundant: false + } + { + locationName: secondaryRegion + failoverPriority: 1 + isZoneRedundant: false + } +] +var diagnosticSettingsName = 'default' +var logCategories = [ + 'DataPlaneRequests' + 'MongoRequests' +] +var metricCategories = [ + 'Requests' +] +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 account 'Microsoft.DocumentDB/databaseAccounts@2025-04-15' = { + name: toLower(name) + location: location + kind: 'MongoDB' + tags: tags + properties: { + consistencyPolicy: consistencyPolicy[defaultConsistencyLevel] + locations: locations + databaseAccountOfferType: 'Standard' + enableAutomaticFailover: true + apiProperties: { + serverVersion: serverVersion + } + capabilities: [ + { + name: 'DisableRateLimitingResponses' + } + ] + } +} + +resource database 'Microsoft.DocumentDB/databaseAccounts/mongodbDatabases@2025-04-15' = { + parent: account + name: databaseName + tags: tags + properties: { + resource: { + id: databaseName + } + options: { + throughput: sharedThroughput + } + } +} + +resource collection 'Microsoft.DocumentDB/databaseAccounts/mongodbDatabases/collections@2025-04-15' = { + parent: database + name: collectionName + tags: tags + properties: { + resource: { + id: collectionName + shardKey: { + username: 'Hash' + } + // Use a for loop to dynamically create the 'indexes' array based on the 'mongoDbIndexKeys' parameter + indexes: [for key in mongoDbIndexKeys: { + key: { + keys: [ + key + ] + } + }] + } + options: { + throughput: dedicatedThroughput + } + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: diagnosticSettingsName + scope: account + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = account.id +output name string = account.name +output documentEndpoint string = account.properties.documentEndpoint +output databaseId string = database.id +output databaseName string = database.name +output collectionId string = collection.id +output collectionName string = collection.name diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/private-dns-zone.bicep b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/private-dns-zone.bicep new file mode 100644 index 0000000..d849259 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/private-endpoint.bicep b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/private-endpoint.bicep new file mode 100644 index 0000000..8fd35b8 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/private-endpoint.bicep @@ -0,0 +1,72 @@ +//******************************************** +// 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 virtual network where private endpoints will be created.') +param vnetId string + +@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/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/virtual-network.bicep b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/virtual-network.bicep new file mode 100644 index 0000000..1c7a088 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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 webAppSubnetName string = 'functionAppSubnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param webAppSubnetNsgName 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: webAppSubnetName + properties: { + addressPrefix: webAppSubnetAddressPrefix + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' + networkSecurityGroup: { + id: webAppSubnetNsg.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 webAppSubnetNsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: webAppSubnetNsgName + 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 webAppSubnetNsgDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: diagnosticSettingsName + scope: webAppSubnetNsg + 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 webAppSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, webAppSubnetName) +output webAppSubnetName string = webAppSubnetName +output peSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, peSubnetName) +output peSubnetName string = peSubnetName diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/web-app.bicep b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/web-app.bicep new file mode 100644 index 0000000..dccb5e9 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/bicep/modules/web-app.bicep @@ -0,0 +1,213 @@ +//******************************************** +// Parameters +//******************************************** + +@description('Specifies a globally unique name the Azure Web 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 = 'app,linux' + +@description('Specifies the language runtime used by the Azure Web App.') +@allowed([ + 'dotnet' + 'dotnet-isolated' + 'dotnetcore' + 'python' + 'java' + 'node' + 'powerShell' + 'custom' +]) +param runtimeName string + +@description('Specifies the target language version used by the Azure Web App.') +param runtimeVersion string + +@description('Specifies the minimum TLS version for the Azure Web App.') +@allowed([ + '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 HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = true + +@description('Specifies the name of the hosting plan.') +param hostingPlanName string + +@description('Specifies the name of the Azure Cosmos DB account.') +param accountName 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 resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the name for the Mongo DB database.') +param databaseName string = 'sampledb' + +@description('Specifies the name for the Mongo DB collection.') +param collectionName string = 'activities' + +@description('Specifies the username for the application.') +param username string = 'paolo' + +@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 = 'default' +var logCategories = [ + 'AppServiceHTTPLogs' + 'AppServiceConsoleLogs' + 'AppServiceAppLogs' + 'AppServiceAuditLogs' + 'AppServiceIPSecAuditLogs' + 'AppServicePlatformLogs' + '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 virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: virtualNetworkName +} + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = { + parent: virtualNetwork + name: subnetName +} + +resource hostingPlan 'Microsoft.Web/serverfarms@2024-04-01' existing = { + name: hostingPlanName +} + +resource account 'Microsoft.DocumentDB/databaseAccounts@2024-12-01-preview' existing = { + name: toLower(accountName) +} + +resource webApp 'Microsoft.Web/sites@2025-03-01' = { + name: name + location: location + tags: tags + kind: kind + properties: { + httpsOnly: httpsOnly + serverFarmId: hostingPlan.id + virtualNetworkSubnetId: subnet.id + outboundVnetRouting: { + allTraffic: true + } + siteConfig: { + linuxFxVersion: toUpper('${runtimeName}|${runtimeVersion}') + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + } + } + identity: { + type: 'SystemAssigned' + } +} + + +resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = { + parent: webApp + name: 'appsettings' + properties: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + COSMOSDB_CONNECTION_STRING: account.listConnectionStrings().connectionStrings[0].connectionString + COSMOSDB_DATABASE_NAME: databaseName + COSMOSDB_COLLECTION_NAME: collectionName + WEBSITES_PORT: '8000' + LOGIN_NAME: username + } +} + +resource webAppSourceControl 'Microsoft.Web/sites/sourcecontrols@2024-11-01' = if (contains(repoUrl,'http')){ + name: 'web' + parent: webApp + properties: { + repoUrl: repoUrl + branch: 'master' + isManualIntegration: true + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if(!empty(workspaceId)) { + name: diagnosticSettingsName + scope: webApp + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = webApp.id +output name string = webApp.name +output defaultHostName string = webApp.properties.defaultHostName diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/images/architecture.png b/samples/web-app-cosmosdb-mongodb-api/dotnet/images/architecture.png new file mode 100644 index 0000000..377dfbc Binary files /dev/null and b/samples/web-app-cosmosdb-mongodb-api/dotnet/images/architecture.png differ diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/images/vacation-planner.png b/samples/web-app-cosmosdb-mongodb-api/dotnet/images/vacation-planner.png new file mode 100644 index 0000000..a7c5151 Binary files /dev/null and b/samples/web-app-cosmosdb-mongodb-api/dotnet/images/vacation-planner.png differ diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/README.md b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/README.md new file mode 100644 index 0000000..7c33da4 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/README.md @@ -0,0 +1,271 @@ +# Azure CLI Deployment + +This directory includes Bash scripts designed for deploying and testing the sample Web App utilizing the `lstk` CLI. For further details about the sample application, refer to the [Azure Web App with Azure CosmosDB for MongoDB](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## 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 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 ASP.NET Core Razor Pages 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. + +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). + +## Provisioning Scripts + +See [deploy.sh](deploy.sh) for the complete deployment script. The script performs: + +- Detects environment (LocalStack vs Azure Cloud) and uses appropriate CLI +- Creates resource group +- Creates CosmosDB account with MongoDB kind (API version 7.0) +- Retrieves document endpoint +- Creates MongoDB database and collection with indexes and sharding +- Retrieves CosmosDB connection string +- Creates App Service Plan (Linux) +- Creates Web App with the .NET (DOTNETCORE) runtime +- Configures Web App settings (CosmosDB connection, database/collection names) +- Creates zip package of the application +- Deploys the zip to Azure Web 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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `scripts` folder: + +```bash +cd samples/web-app-cosmosdb-mongodb-api/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}" +WEBAPP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +PRIVATE_DNS_ZONE_NAME="privatelink.mongo.cosmos.azure.com" +PRIVATE_ENDPOINT_NAME="${PREFIX}-mongodb-pe-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEBAPP_NAME="${PREFIX}-webapp-${SUFFIX}" +COSMOSDB_ACCOUNT_NAME="${PREFIX}-mongodb-${SUFFIX}" +MONGODB_DATABASE_NAME="sampledb" +COLLECTION_NAME="activities" +# 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 Web App +echo -e "\n[$WEBAPP_NAME] web app:\n" +az webapp show \ + --name "$WEBAPP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Azure CosmosDB account +echo -e "\n[$COSMOSDB_ACCOUNT_NAME] cosmosdb account:\n" +az cosmosdb show \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,DocumentEndpoint:documentEndpoint}' \ + --output table \ + --only-show-errors + +# Check MongoDB database +echo -e "\n[$MONGODB_DATABASE_NAME] mongodb database:\n" +az cosmosdb mongodb database show \ + --name "$MONGODB_DATABASE_NAME" \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check MongoDB collection +echo -e "\n[$COLLECTION_NAME] mongodb collection:\n" +az cosmosdb mongodb collection show \ + --name "$COLLECTION_NAME" \ + --database-name "$MONGODB_DATABASE_NAME" \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEBAPP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEBAPP_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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/call-web-app.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/call-web-app.sh new file mode 100755 index 0000000..9c3945f --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/call-web-app.sh @@ -0,0 +1,198 @@ +#!/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_web_app() { + # Get the web app name + echo "Getting web app name..." + web_app_name=$(az webapp list --query '[0].name' --output tsv) + + if [ -n "$web_app_name" ]; then + echo "Web app [$web_app_name] successfully retrieved." + else + echo "Error: No web app found" + exit 1 + fi + + # Get the resource group name + echo "Getting resource group name for web app [$web_app_name]..." + resource_group_name=$(az webapp 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 web app [$web_app_name]" + exit 1 + fi + + # Get the the default host name of the web app + echo "Getting the default host name of the web app [$web_app_name]..." + app_host_name=$(az webapp show \ + --name "$web_app_name" \ + --resource-group "$resource_group_name" \ + --query 'defaultHostName' \ + --output tsv) + + if [ -n "$app_host_name" ]; then + echo "Web app default host name [$app_host_name] successfully retrieved." + else + echo "Error: No web app default host name found" + exit 1 + fi + + # Get the Docker container name + echo "Finding container name with prefix [ls-$web_app_name]..." + container_name=$(get_docker_container_name_by_prefix "ls-$web_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") + + 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 web app HTTP trigger (internal port 8000) + echo "Getting the host port mapped to internal port 8000 in container [$container_name]..." + host_port=$(get_docker_container_port_mapping "$container_name" "8000") + + 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]" + fi + + # Retrieve LocalStack proxy port + proxy_port=$(curl --max-time 10 http://localhost:4566/_localstack/proxy -s | jq '.proxy_port') + + if [ -n "$proxy_port" ]; then + # Call the web app via emulator proxy + echo "Calling web app [$web_app_name] via emulator..." + curl --max-time 10 --proxy "http://localhost:$proxy_port/" -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via emulator proxy port [$proxy_port] succeeded." + else + echo "Web app call via emulator proxy port [$proxy_port] failed." + fi + else + echo "Failed to retrieve LocalStack proxy port" + fi + + if [ -n "$container_ip" ]; then + # Call the web app via the container IP address + echo "Calling web app [$web_app_name] via container IP address [$container_ip]..." + curl --max-time 10 -s "http://$container_ip:8000/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via container IP address [$container_ip] succeeded." + else + echo "Web app call via container IP address [$container_ip] failed." + fi + else + echo "Failed to retrieve container IP address" + fi + + if [ -n "$host_port" ]; then + # Call the web app via the host port + echo "Calling web app [$web_app_name] via host port [$host_port]..." + curl --max-time 10 -s "http://127.0.0.1:$host_port/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via host port [$host_port] succeeded." + else + echo "Web app call via host port [$host_port] failed." + fi + else + echo "Failed to retrieve host port" + fi + + if [ -n "$app_host_name" ]; then + # Call the web app via the default hostname + echo "Calling web app [$web_app_name] via default hostname [$app_host_name]..." + curl --max-time 10 -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via default hostname [$app_host_name] succeeded." + else + echo "Web app call via default hostname [$app_host_name] failed." + fi + else + echo "Failed to retrieve web app hostname" + fi +} + +call_web_app \ No newline at end of file diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deploy.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deploy.sh new file mode 100755 index 0000000..eab28e6 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deploy.sh @@ -0,0 +1,945 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +DIAGNOSTIC_SETTINGS_NAME='default' +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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" +WEB_APP_SUBNET_NAME="app-subnet" +WEB_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_NAME="privatelink.mongo.cosmos.azure.com" +PRIVATE_ENDPOINT_NAME="${PREFIX}-mongodb-pe-${SUFFIX}" +PRIVATE_ENDPOINT_GROUP="mongodb" +PRIVATE_DNS_ZONE_GROUP_NAME="default" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="S1" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +COSMOSDB_ACCOUNT_NAME="${PREFIX}-mongodb-${SUFFIX}" +MONGODB_API_VERSION="7.0" +MONGODB_DATABASE_NAME="sampledb" +COLLECTION_NAME="activities" +INDEXES='[{"key":{"keys":["_id"]}},{"key":{"keys":["username"]}},{"key":{"keys":["activity"]}},{"key":{"keys":["timestamp"]}}]' +SHARD="username" +THROUGHPUT=400 +RUNTIME="dotnetcore" +RUNTIME_VERSION="10.0" +LOGIN_NAME="paolo" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit +# Create a resource group +echo "Creating resource group [$RESOURCE_GROUP_NAME]..." +az group create \ + --name $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created successfully." +else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 +fi + +# Check if the CosmosDB account already exists +echo "Checking if [$COSMOSDB_ACCOUNT_NAME] CosmosDB account already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az cosmosdb show \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$COSMOSDB_ACCOUNT_NAME] CosmosDB account already exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$COSMOSDB_ACCOUNT_NAME] CosmosDB account in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create a CosmosDB account with MongoDB kind + az cosmosdb create \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --locations regionName=$LOCATION \ + --kind MongoDB \ + --server-version $MONGODB_API_VERSION \ + --default-consistency-level Session \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "[$COSMOSDB_ACCOUNT_NAME] CosmosDB account successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$COSMOSDB_ACCOUNT_NAME] CosmosDB account in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$COSMOSDB_ACCOUNT_NAME] CosmosDB account already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Retrieve account resource id +echo "Getting [$COSMOSDB_ACCOUNT_NAME] CosmosDB account resource id in the [$RESOURCE_GROUP_NAME] resource group..." +COSMOSDB_ACCOUNT_ID=$(az cosmosdb show \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query id \ + --output tsv \ + --only-show-errors) + +if [ -n "$COSMOSDB_ACCOUNT_ID" ]; then + echo "CosmosDB account resource id retrieved successfully: $COSMOSDB_ACCOUNT_ID" +else + echo "Failed to retrieve CosmosDB account resource id." + exit 1 +fi + +# Retrieve document endpoint +echo "Getting [$COSMOSDB_ACCOUNT_NAME] CosmosDB account document endpoint in the [$RESOURCE_GROUP_NAME] resource group..." +DOCUMENT_ENDPOINT=$(az cosmosdb show \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "documentEndpoint" \ + --output tsv \ + --only-show-errors) + +if [ -n "$DOCUMENT_ENDPOINT" ]; then + echo "Document endpoint retrieved successfully: $DOCUMENT_ENDPOINT" +else + echo "Failed to retrieve document endpoint." + exit 1 +fi + +# Check if the MongoDB database already exists +echo "Checking if [$MONGODB_DATABASE_NAME] MongoDB database already exists in the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account..." +az cosmosdb mongodb database show \ + --account-name $COSMOSDB_ACCOUNT_NAME \ + --name $MONGODB_DATABASE_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$MONGODB_DATABASE_NAME] MongoDB database already exists in the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account" + echo "Creating [$MONGODB_DATABASE_NAME] MongoDB database in the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account..." + + # Create MongoDB database in the CosmosDB account + az cosmosdb mongodb database create \ + --account-name $COSMOSDB_ACCOUNT_NAME \ + --name $MONGODB_DATABASE_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --output json \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "[$MONGODB_DATABASE_NAME] MongoDB database successfully created in the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account" + else + echo "Failed to create [$MONGODB_DATABASE_NAME] MongoDB database in the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account" + exit 1 + fi +else + echo "[$MONGODB_DATABASE_NAME] MongoDB database already exists in the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account" +fi + +# Check if the MongoDB database collection already exists +echo "Checking if [$COLLECTION_NAME] collection already exists in the [$MONGODB_DATABASE_NAME] MongoDB database..." +az cosmosdb mongodb collection show \ + --account-name $COSMOSDB_ACCOUNT_NAME \ + --database-name $MONGODB_DATABASE_NAME \ + --name $COLLECTION_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$COLLECTION_NAME] collection already exists in the [$MONGODB_DATABASE_NAME] MongoDB database" + echo "Creating [$COLLECTION_NAME] collection in the [$MONGODB_DATABASE_NAME] MongoDB database..." + + # Create a MongoDB database collection + az cosmosdb mongodb collection create \ + --account-name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --database-name $MONGODB_DATABASE_NAME \ + --name $COLLECTION_NAME \ + --idx "$INDEXES" \ + --shard $SHARD \ + --throughput $THROUGHPUT \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "[$COLLECTION_NAME] collection successfully created in the [$MONGODB_DATABASE_NAME] MongoDB database" + else + echo "Failed to create [$COLLECTION_NAME] collection in the [$MONGODB_DATABASE_NAME] MongoDB database" + exit 1 + fi +else + echo "[$COLLECTION_NAME] collection already exists in the [$MONGODB_DATABASE_NAME] MongoDB database" +fi + +# List CosmosDB connection strings +echo "Listing connection strings for CosmosDB account [$COSMOSDB_ACCOUNT_NAME]..." +COSMOSDB_CONNECTION_STRING=$(az cosmosdb keys list \ + --name $COSMOSDB_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --type connection-strings \ + --query "connectionStrings[0].connectionString" \ + --output tsv) + +if [ $? -eq 0 ]; then + echo "CosmosDB connection strings retrieved successfully." + echo "Connection String: $COSMOSDB_CONNECTION_STRING" +else + echo "Failed to retrieve CosmosDB connection strings." +fi + +# Check if the network security group for the web app subnet already exists +echo "Checking if [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az network nsg show \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet..." + + # Create the network security group for the web app subnet + az network nsg create \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Get the resource id of the network security group for the web app subnet +echo "Getting [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet resource id in the [$RESOURCE_GROUP_NAME] resource group..." +WEB_APP_SUBNET_NSG_ID=$(az network nsg show \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_SUBNET_NSG_ID ]]; then + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet resource id retrieved successfully: $WEB_APP_SUBNET_NSG_ID" +else + echo "Failed to retrieve [$WEB_APP_SUBNET_NSG_NAME] network security group for the web 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" \ + --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 \ + --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 \ + --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 "$WEB_APP_SUBNET_NAME" \ + --subnet-prefix "$WEB_APP_SUBNET_PREFIX" \ + --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 web app subnet to associate it with the NAT Gateway and the NSG + echo "Associating [$WEB_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_APP_SUBNET_NSG_NAME] network security group..." + + # Update the web app subnet to associate it with the NAT Gateway and the NSG + az network vnet subnet update \ + --name "$WEB_APP_SUBNET_NAME" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --nat-gateway "$NAT_GATEWAY_NAME" \ + --network-security-group "$WEB_APP_SUBNET_NSG_NAME" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$WEB_APP_SUBNET_NAME] subnet successfully associated with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_APP_SUBNET_NSG_NAME] network security group" + else + echo "Failed to associate [$WEB_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_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 + +# Check if the private DNS Zone already exists +echo "Checking if [$PRIVATE_DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az network private-dns zone show \ + --name "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$PRIVATE_DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PRIVATE_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 "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$PRIVATE_DNS_ZONE_NAME] private DNS zone successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$PRIVATE_DNS_ZONE_NAME] private DNS zone in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "[$PRIVATE_DNS_ZONE_NAME] private DNS zone already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the virtual network link between the private DNS zone and the virtual network already exists +echo "Checking if [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_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 "$PRIVATE_DNS_ZONE_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network actually exists" + + echo "Creating [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network..." + + # Create the virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network + az network private-dns link vnet create \ + --name "$VIRTUAL_NETWORK_LINK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --zone-name "$PRIVATE_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 [$PRIVATE_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 [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network" + exit + fi +else + echo "[$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network already exists" +fi + +# Check if the private endpoint already exists +echo "Checking if private endpoint [$PRIVATE_ENDPOINT_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=='$PRIVATE_ENDPOINT_NAME'].id" \ + --output tsv) + +if [[ -z $privateEndpointId ]]; then + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] does not exist in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PRIVATE_ENDPOINT_NAME] private endpoint for the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create a private endpoint for the CosmosDB account + az network private-endpoint create \ + --name "$PRIVATE_ENDPOINT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --subnet "$PE_SUBNET_NAME" \ + --private-connection-resource-id "$COSMOSDB_ACCOUNT_ID" \ + --group-id "$PRIVATE_ENDPOINT_GROUP" \ + --connection-name "mongodb-connection" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] successfully created for the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create a private endpoint for the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the private DNS zone grou is already created for the CosmosDB account private endpoint +echo "Checking if the private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint already exists..." +NAME=$(az network private-endpoint dns-zone-group show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PRIVATE_ENDPOINT_NAME" \ + --name "$PRIVATE_DNS_ZONE_GROUP_NAME" \ + --query name \ + --output tsv \ + --only-show-errors 2>/dev/null) + +if [[ -z $NAME ]]; then + echo "No private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint actually exists" + echo "Creating private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint..." + + # Create the private DNS zone group for the CosmosDB account private endpoint + az network private-endpoint dns-zone-group create \ + --name "$PRIVATE_DNS_ZONE_GROUP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PRIVATE_ENDPOINT_NAME" \ + --private-dns-zone "$PRIVATE_DNS_ZONE_NAME" \ + --zone-name "mongodb-zone" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint successfully created" + else + echo "Failed to create private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint" + exit + fi +else + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint already exists" +fi + +# Create app service plan +echo "Creating app service plan [$APP_SERVICE_PLAN_NAME]..." +az appservice plan create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --location "$LOCATION" \ + --sku "$APP_SERVICE_PLAN_SKU" \ + --is-linux \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "app service plan [$APP_SERVICE_PLAN_NAME] created successfully." +else + echo "Failed to create app service plan [$APP_SERVICE_PLAN_NAME]." + exit 1 +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 + +# Create the web app +echo "Creating web app [$WEB_APP_NAME]..." +az webapp create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --plan "$APP_SERVICE_PLAN_NAME" \ + --name "$WEB_APP_NAME" \ + --runtime "$RUNTIME:$RUNTIME_VERSION" \ + --vnet "$VIRTUAL_NETWORK_NAME" \ + --subnet "$WEB_APP_SUBNET_NAME" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Get the web app resource id +echo "Getting [$WEB_APP_NAME] web app resource id in the [$RESOURCE_GROUP_NAME] resource group..." +WEB_APP_ID=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_ID ]]; then + echo "[$WEB_APP_NAME] web app resource id retrieved successfully: $WEB_APP_ID" +else + echo "Failed to retrieve [$WEB_APP_NAME] web app resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Enabling forced tunneling for web app [$WEB_APP_NAME] to route all outbound traffic through the virtual network... +echo "Enabling forced tunneling for web app [$WEB_APP_NAME] to route all outbound traffic through the virtual network..." + +az resource update \ + --ids "$WEB_APP_ID" \ + --set properties.outboundVnetRouting.allTraffic=true \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Forced tunneling enabled for web app [$WEB_APP_NAME]." +else + echo "Failed to enable forced tunneling for web app [$WEB_APP_NAME]." + exit 1 +fi + +# Set web app settings +echo "Setting web app settings for [$WEB_APP_NAME]..." +az webapp config appsettings set \ + --name $WEB_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --settings \ + SCM_DO_BUILD_DURING_DEPLOYMENT='true' \ + ENABLE_ORYX_BUILD='true' \ + COSMOSDB_CONNECTION_STRING="$COSMOSDB_CONNECTION_STRING" \ + COSMOSDB_DATABASE_NAME="$MONGODB_DATABASE_NAME" \ + COSMOSDB_COLLECTION_NAME="$COLLECTION_NAME" \ + LOGIN_NAME="$LOGIN_NAME" \ + WEBSITES_PORT="8000" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app settings for [$WEB_APP_NAME] set successfully." +else + echo "Failed to set web app settings for [$WEB_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" \ + --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 web app already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app..." + + # Create the diagnostic settings for the web app to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "AppServiceHTTPLogs", "enabled": true}, + {"category": "AppServiceConsoleLogs", "enabled": true}, + {"category": "AppServiceAppLogs", "enabled": true}, + {"category": "AppServiceAuditLogs", "enabled": true}, + {"category": "AppServiceIPSecAuditLogs", "enabled": true}, + {"category": "AppServicePlatformLogs", "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 [$WEB_APP_NAME] web app successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web 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 CosmosDB account already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$COSMOSDB_ACCOUNT_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account..." + + # Create the diagnostic settings for the CosmosDB account to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$COSMOSDB_ACCOUNT_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "DataPlaneRequests", "enabled": true}, + {"category": "MongoRequests", "enabled": true} + ]' \ + --metrics '[ + {"category": "Requests", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$COSMOSDB_ACCOUNT_NAME] CosmosDB account 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 web app subnet already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_SUBNET_NSG_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet..." + + # Create the diagnostic settings for the network security group for the web app subnet to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_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 [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web 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 + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# List the contents of the zip package +echo "Contents of the zip package [$ZIPFILE]:" +unzip -l "$ZIPFILE" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +echo "Using standard az webapp deploy command for AzureCloud environment." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +# Remove the zip package of the web 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 diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/validate.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/validate.sh new file mode 100755 index 0000000..fd2815c --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/validate.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +PRIVATE_DNS_ZONE_NAME="privatelink.mongo.cosmos.azure.com" +PRIVATE_ENDPOINT_NAME="${PREFIX}-mongodb-pe-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +COSMOSDB_ACCOUNT_NAME="${PREFIX}-mongodb-${SUFFIX}" +MONGODB_DATABASE_NAME="sampledb" +COLLECTION_NAME="activities" +# 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 Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,State:state,Location:location,DefaultHostName:defaultHostName}' \ + --output table \ + --only-show-errors + +# Check Azure CosmosDB account +echo -e "\n[$COSMOSDB_ACCOUNT_NAME] cosmosdb account:\n" +az cosmosdb show \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,DocumentEndpoint:documentEndpoint}' \ + --output table \ + --only-show-errors + +# Check MongoDB database +echo -e "\n[$MONGODB_DATABASE_NAME] mongodb database:\n" +az cosmosdb mongodb database show \ + --name "$MONGODB_DATABASE_NAME" \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check MongoDB collection +echo -e "\n[$COLLECTION_NAME] mongodb collection:\n" +az cosmosdb mongodb collection show \ + --name "$COLLECTION_NAME" \ + --database-name "$MONGODB_DATABASE_NAME" \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEB_APP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEB_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/web-app-cosmosdb-mongodb-api/dotnet/src/Models/Activity.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..84277d4 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id)) + { + await store.DeleteAsync(id, cancellationToken); + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..904f8c5 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + await store.UpdateAsync(id, text, cancellationToken); + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated!"; + } + else + { + await store.AddAsync(text, cancellationToken); + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added!"; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs new file mode 100644 index 0000000..310f3c3 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Code deployments built by Oryx export ASPNETCORE_URLS; custom images and local runs only set PORT. +if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") is null + && Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseUrls($"http://*:{port}"); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var mongoOptions = MongoOptions.FromEnvironment(); + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new MongoActivityStore(mongoOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +// One log line per request, the equivalent of the gunicorn access log the Python sample produces. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/ActivityId.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/ActivityId.cs new file mode 100644 index 0000000..8654aaf --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/ActivityId.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VacationPlanner.Services; + +/// MD5 of username + activity + timestamp: the id scheme shared by the Vacation Planner samples. +public static class ActivityId +{ + public static string Create(string username, string activity) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"); + var hash = MD5.HashData(Encoding.UTF8.GetBytes($"{username}_{activity}_{timestamp}")); + return Convert.ToHexStringLower(hash); + } +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/IActivityStore.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..3b24e5f --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,21 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + Task AddAsync(string text, CancellationToken cancellationToken); + + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs new file mode 100644 index 0000000..0b4e42f --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs @@ -0,0 +1,120 @@ +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Driver; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Activities as documents {_id, username, activity, timestamp} in an Azure Cosmos DB for MongoDB collection. +public sealed class MongoActivityStore : IActivityStore +{ + private readonly IMongoDatabase _database; + private readonly IMongoCollection _collection; + private readonly MongoOptions _options; + private readonly ILogger _logger; + + /// Documents are logged indented, like the Python sample's json.dumps(indent=3) output. + private static readonly JsonWriterSettings Indented = new() { Indent = true }; + + public MongoActivityStore(MongoOptions options, ILogger logger) + { + _options = options; + _logger = logger; + var client = new MongoClient(options.ConnectionString); + _database = client.GetDatabase(options.DatabaseName); + _collection = _database.GetCollection(options.CollectionName); + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + var existing = await (await _database.ListCollectionNamesAsync(cancellationToken: cancellationToken)).ToListAsync(cancellationToken); + if (existing.Contains(_options.CollectionName)) + { + _logger.LogInformation("Collection '{Collection}' already exists in database '{Database}'", _options.CollectionName, _options.DatabaseName); + return; + } + + await _database.CreateCollectionAsync(_options.CollectionName, cancellationToken: cancellationToken); + var keys = Builders.IndexKeys; + await _collection.Indexes.CreateManyAsync( + [ + new CreateIndexModel(keys.Ascending("username")), + new CreateIndexModel(keys.Ascending("activity")), + new CreateIndexModel(keys.Ascending("timestamp")), + ], cancellationToken); + _logger.LogInformation("Created collection '{Collection}' in database '{Database}'", _options.CollectionName, _options.DatabaseName); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + var filter = Builders.Filter.Eq("username", _options.Username); + var documents = await _collection.Find(filter).ToListAsync(cancellationToken); + _logger.LogInformation( + "Retrieved {Count} document(s) from collection '{Collection}': {Documents}", + documents.Count, + _options.CollectionName, + documents.ToJson(Indented) + ); + return documents.Select(d => new Activity(d["_id"].AsString, d["activity"].AsString)).ToList(); + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + var document = new BsonDocument + { + ["_id"] = ActivityId.Create(_options.Username, text), + ["username"] = _options.Username, + ["activity"] = text, + ["timestamp"] = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"), + }; + await _collection.InsertOneAsync(document, cancellationToken: cancellationToken); + _logger.LogInformation( + "Inserted document into collection '{Collection}': {Document}", + _options.CollectionName, + document.ToJson(Indented) + ); + } + + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + var result = await _collection.UpdateOneAsync( + Builders.Filter.Eq("_id", id), + Builders.Update.Set("activity", text), + cancellationToken: cancellationToken + ); + _logger.LogInformation( + "Updated {Count} document(s) with id {Id} in collection '{Collection}'", + result.ModifiedCount, + id, + _options.CollectionName + ); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + var result = await _collection.DeleteOneAsync( + Builders.Filter.Eq("_id", id), + cancellationToken + ); + _logger.LogInformation( + "Deleted {Count} document(s) with id {Id} from collection '{Collection}'", + result.DeletedCount, + id, + _options.CollectionName + ); + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await _database.RunCommandAsync(new BsonDocument("ping", 1), cancellationToken: cancellationToken); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "MongoDB health check failed"); + return false; + } + } +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoOptions.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoOptions.cs new file mode 100644 index 0000000..871c308 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoOptions.cs @@ -0,0 +1,32 @@ +namespace VacationPlanner.Services; + +/// Settings read from the same environment variables the Python sample uses. +public sealed record MongoOptions(string ConnectionString, string DatabaseName, string CollectionName, string Username) +{ + public static MongoOptions FromEnvironment() + { + var connectionString = Environment.GetEnvironmentVariable("COSMOSDB_CONNECTION_STRING") + ?? Environment.GetEnvironmentVariable("MONGODB_CONNECTION_STRING"); + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException("Missing required environment variable: COSMOSDB_CONNECTION_STRING or MONGODB_CONNECTION_STRING"); + } + + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "paolo"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("Username cannot be None or empty"); + } + + return new MongoOptions( + ConnectionString: connectionString, + DatabaseName: Require("COSMOSDB_DATABASE_NAME"), + CollectionName: Require("COSMOSDB_COLLECTION_NAME"), + Username: username); + } + + private static string Require(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException($"Missing required environment variable: {name}"); +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/VacationPlanner.csproj b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..9a89b96 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/favicon.ico b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/style.css b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/README.md b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/README.md new file mode 100644 index 0000000..c4e7066 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/README.md @@ -0,0 +1,291 @@ +# 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 Web App with Azure CosmosDB for MongoDB](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [Terraform](https://developer.hashicorp.com/terraform/downloads): Infrastructure as Code tool for provisioning Azure resources +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## 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 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 ASP.NET Core Razor Pages 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. + +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). + +## 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 web application into a zip file for deployment. +- Deploys the zip package to the Azure Web 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="azure.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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `terraform` folder: + +```bash +cd samples/web-app-cosmosdb-mongodb-api/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}" +WEBAPP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +PRIVATE_DNS_ZONE_NAME="privatelink.mongo.cosmos.azure.com" +PRIVATE_ENDPOINT_NAME="${PREFIX}-mongodb-pe-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEBAPP_NAME="${PREFIX}-webapp-${SUFFIX}" +COSMOSDB_ACCOUNT_NAME="${PREFIX}-mongodb-${SUFFIX}" +MONGODB_DATABASE_NAME="sampledb" +COLLECTION_NAME="activities" +# 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 Web App +echo -e "\n[$WEBAPP_NAME] web app:\n" +az webapp show \ + --name "$WEBAPP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table \ + --only-show-errors + +# Check Azure CosmosDB account +echo -e "\n[$COSMOSDB_ACCOUNT_NAME] cosmosdb account:\n" +az cosmosdb show \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,DocumentEndpoint:documentEndpoint}' \ + --output table \ + --only-show-errors + +# Check MongoDB database +echo -e "\n[$MONGODB_DATABASE_NAME] mongodb database:\n" +az cosmosdb mongodb database show \ + --name "$MONGODB_DATABASE_NAME" \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup}' \ + --output table \ + --only-show-errors + +# Check MongoDB collection +echo -e "\n[$COLLECTION_NAME] mongodb collection:\n" +az cosmosdb mongodb collection show \ + --name "$COLLECTION_NAME" \ + --database-name "$MONGODB_DATABASE_NAME" \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEBAPP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEBAPP_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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/deploy.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/deploy.sh new file mode 100755 index 0000000..fd202c9 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/deploy.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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) +WEB_APP_NAME=$(terraform output -raw web_app_name) +ACCOUNT_NAME=$(terraform output -raw cosmosdb_account_name) + +if [[ -z "$RESOURCE_GROUP_NAME" || -z "$WEB_APP_NAME" || -z "$ACCOUNT_NAME" ]]; then + echo "Resource Group Name, Web App Name, or Cosmos DB Account Name is empty. Exiting." + exit 1 +fi + +# Print the application settings of the web app +echo "Retrieving application settings for web app [$WEB_APP_NAME]..." +az webapp config appsettings list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +# Remove the zip package of the web 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/web-app-cosmosdb-mongodb-api/dotnet/terraform/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/main.tf new file mode 100644 index 0000000..f020a3e --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/main.tf @@ -0,0 +1,192 @@ +# 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}" + virtual_network_name = "${local.prefix}-vnet-${local.suffix}" + nat_gateway_name = "${local.prefix}-nat-gateway-${local.suffix}" + private_endpoint_name = "${local.prefix}-mongodb-pe-${local.suffix}" + webapp_subnet_nsg_name = "${local.prefix}-webapp-subnet-nsg-${local.suffix}" + pe_subnet_nsg_name = "${local.prefix}-pe-subnet-nsg-${local.suffix}" + cosmosdb_account_name = "${local.prefix}-mongodb-${local.suffix}" + app_service_plan_name = "${local.prefix}-app-service-plan-${local.suffix}" + web_app_name = "${local.prefix}-webapp-${local.suffix}" +} + +# 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 the webapp subnet +module "webapp_subnet_network_security_group" { + source = "./modules/network_security_group" + name = local.webapp_subnet_nsg_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] + } +} + +# Create a network security group and associate it with the private endpoint subnet +module "pe_subnet_network_security_group" { + source = "./modules/network_security_group" + name = local.pe_subnet_nsg_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.pe_subnet_name) = module.virtual_network.subnet_ids[var.pe_subnet_name] + } +} + +# Create a NAT gateway and associate it with the webapp 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 private DNS zone for the CosmosDB MongoDB account and link it to the virtual network +module "private_dns_zone" { + source = "./modules/private_dns_zone" + name = "privatelink.mongo.cosmos.azure.com" + 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 CosmosDB MongoDB account in the pe_subnet subnet +module "private_endpoint" { + source = "./modules/private_endpoint" + name = local.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.cosmosdb_mongodb.id + is_manual_connection = false + subresource_name = "mongodb" + private_dns_zone_group_name = "private-dns-zone-group" + private_dns_zone_group_ids = [module.private_dns_zone.id] +} + +# Create CosmosDB MongoDB resources using module +module "cosmosdb_mongodb" { + source = "./modules/cosmosdb_mongodb" + name = local.cosmosdb_account_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + mongo_server_version = var.mongodb_server_version + consistency_level = var.consistency_level + primary_region = var.primary_region + secondary_region = var.secondary_region + database_name = var.cosmosdb_database_name + database_throughput = var.database_throughput + collection_name = var.cosmosdb_collection_name + index_keys = var.mongodb_index_keys + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags +} + +# 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 Web App using module +module "web_app" { + source = "./modules/web_app" + name = local.web_app_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + service_plan_id = module.app_service_plan.id + 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 + dotnet_version = var.dotnet_version + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags + + app_settings = { + SCM_DO_BUILD_DURING_DEPLOYMENT = "true" + COSMOSDB_CONNECTION_STRING = module.cosmosdb_mongodb.primary_mongodb_connection_string + COSMOSDB_DATABASE_NAME = module.cosmosdb_mongodb.database_name + COSMOSDB_COLLECTION_NAME = var.cosmosdb_collection_name + LOGIN_NAME = var.login_name + WEBSITES_PORT = var.websites_port + } +} \ No newline at end of file diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/app_service_plan/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/app_service_plan/main.tf new file mode 100644 index 0000000..98a3e4d --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/app_service_plan/outputs.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/app_service_plan/outputs.tf new file mode 100644 index 0000000..f1455ea --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/app_service_plan/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/app_service_plan/variables.tf new file mode 100644 index 0000000..e543066 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/cosmosdb_mongodb/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/cosmosdb_mongodb/main.tf new file mode 100644 index 0000000..1cd082c --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/cosmosdb_mongodb/main.tf @@ -0,0 +1,78 @@ +resource "azurerm_cosmosdb_account" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + offer_type = "Standard" + kind = "MongoDB" + mongo_server_version = var.mongo_server_version + automatic_failover_enabled = false + tags = var.tags + + consistency_policy { + consistency_level = var.consistency_level + max_interval_in_seconds = 300 + max_staleness_prefix = 100000 + } + + geo_location { + location = var.primary_region + failover_priority = 0 + } + + geo_location { + location = var.secondary_region + failover_priority = 1 + } + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_cosmosdb_mongo_database" "example" { + name = var.database_name + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.example.name + throughput = var.database_throughput +} + +resource "azurerm_cosmosdb_mongo_collection" "example" { + name = var.collection_name + resource_group_name = var.resource_group_name + account_name = azurerm_cosmosdb_account.example.name + database_name = azurerm_cosmosdb_mongo_database.example.name + + default_ttl_seconds = var.default_ttl_seconds + shard_key = var.shard_key + throughput = var.collection_throughput + + # Dynamically create the 'index' blocks using a for_each loop over the variable + dynamic "index" { + # The for_each expression iterates over the list of keys from the variable + for_each = var.index_keys + content { + # The value of the current item in the iteration (e.g., "$**", "_id", etc.) + keys = [index.value] + } + } +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_cosmosdb_account.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "DataPlaneRequests" + } + + enabled_log { + category = "MongoRequests" + } + + enabled_metric { + category = "Requests" + } +} \ No newline at end of file diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/cosmosdb_mongodb/outputs.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/cosmosdb_mongodb/outputs.tf new file mode 100644 index 0000000..09bcd7e --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/cosmosdb_mongodb/outputs.tf @@ -0,0 +1,30 @@ +output "id" { + value = azurerm_cosmosdb_account.example.id + description = "Specifies the resource id of the Cosmos DB account" +} + +output "name" { + value = azurerm_cosmosdb_account.example.name + description = "Specifies the name of the Cosmos DB account" +} + +output "endpoint" { + value = azurerm_cosmosdb_account.example.endpoint + description = "Specifies the endpoint of the Cosmos DB account" +} + +output "primary_mongodb_connection_string" { + value = azurerm_cosmosdb_account.example.primary_mongodb_connection_string + description = "Specifies the primary MongoDB connection string" + sensitive = true +} + +output "database_name" { + value = azurerm_cosmosdb_mongo_database.example.name + description = "Specifies the name of the MongoDB database" +} + +output "collection_name" { + value = azurerm_cosmosdb_mongo_collection.example.name + description = "Specifies the name of the MongoDB collection" +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/cosmosdb_mongodb/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/cosmosdb_mongodb/variables.tf new file mode 100644 index 0000000..71e3cbe --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/cosmosdb_mongodb/variables.tf @@ -0,0 +1,87 @@ +variable "resource_group_name" { + description = "(Required) Specifies the name of the resource group." + type = string +} + +variable "location" { + description = "(Required) Specifies the location for the Cosmos DB account." + type = string +} + +variable "name" { + description = "(Required) Specifies the name of the Cosmos DB account." + type = string +} + +variable "mongo_server_version" { + description = "(Optional) Specifies the version of MongoDB API for the Azure Cosmos DB account." + type = string + default = "7.0" +} + +variable "consistency_level" { + description = "(Required) Specifies the consistency level for the Azure Cosmos DB account." + type = string + default = "Eventual" +} + +variable "primary_region" { + description = "(Required) Specifies the primary region for the Azure Cosmos DB account." + type = string +} + +variable "secondary_region" { + description = "(Required) Specifies the secondary region for the Azure Cosmos DB account." + type = string +} + +variable "database_name" { + description = "(Required) Specifies the name of the MongoDB database." + type = string +} + +variable "database_throughput" { + description = "(Optional) Specifies the throughput for the MongoDB database." + type = number + default = 400 +} + +variable "collection_name" { + description = "(Required) Specifies the name of the MongoDB collection." + type = string +} + +variable "collection_throughput" { + description = "(Optional) Specifies the throughput for the MongoDB collection." + type = number + default = 400 +} + +variable "default_ttl_seconds" { + description = "(Optional) Specifies the default TTL in seconds for documents in the collection." + type = string + default = "777" +} + +variable "shard_key" { + description = "(Optional) Specifies the shard key for the MongoDB collection." + type = string + default = "username" +} + +variable "index_keys" { + description = "A list of field names for which to create single-field indexes on the MongoDB collection." + type = list(string) + default = ["_id"] +} + +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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/log_analytics/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/log_analytics/main.tf new file mode 100644 index 0000000..2f88414 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/log_analytics/output.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/log_analytics/output.tf new file mode 100644 index 0000000..fe2c398 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/log_analytics/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/log_analytics/variables.tf new file mode 100644 index 0000000..2db6a01 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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 "tags" { + description = "(Optional) Specifies the tags of the Azure Log Analytics workspace." + type = map(any) + default = {} +} + +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 +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/nat_gateway/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/nat_gateway/main.tf new file mode 100644 index 0000000..cc384af --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/nat_gateway/output.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/nat_gateway/output.tf new file mode 100644 index 0000000..1e3fd03 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/nat_gateway/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/nat_gateway/variables.tf new file mode 100644 index 0000000..c1c8ea5 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/network_security_group/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/network_security_group/main.tf new file mode 100644 index 0000000..c649652 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/network_security_group/outputs.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/network_security_group/outputs.tf new file mode 100644 index 0000000..b8ca8d5 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/network_security_group/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/network_security_group/variables.tf new file mode 100644 index 0000000..04eb07e --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_dns_zone/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_dns_zone/main.tf new file mode 100644 index 0000000..e61df00 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_dns_zone/main.tf @@ -0,0 +1,25 @@ +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))}" + private_dns_zone_id = azurerm_private_dns_zone.example.id + 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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_dns_zone/outputs.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_dns_zone/outputs.tf new file mode 100644 index 0000000..ca141f3 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_dns_zone/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_dns_zone/variables.tf new file mode 100644 index 0000000..8d0c0cc --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_endpoint/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_endpoint/main.tf new file mode 100644 index 0000000..62bfbfb --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_endpoint/outputs.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_endpoint/outputs.tf new file mode 100644 index 0000000..367ab51 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_endpoint/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/private_endpoint/variables.tf new file mode 100644 index 0000000..2b7a888 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/storage_account/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/storage_account/main.tf new file mode 100644 index 0000000..27fb626 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/storage_account/main.tf @@ -0,0 +1,29 @@ +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 + is_hns_enabled = var.is_hns_enabled + shared_access_key_enabled = var.shared_access_key_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 + ] + } +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/storage_account/outputs.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/storage_account/outputs.tf new file mode 100644 index 0000000..02d32ec --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/storage_account/outputs.tf @@ -0,0 +1,24 @@ +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 +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/storage_account/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/storage_account/variables.tf new file mode 100644 index 0000000..d1e61d7 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/storage_account/variables.tf @@ -0,0 +1,93 @@ +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 "tags" { + description = "(Optional) Specifies the tags of the Azure Storage Account" + default = {} +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/virtual_network/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/virtual_network/main.tf new file mode 100644 index 0000000..cec00f4 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/virtual_network/outputs.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/virtual_network/outputs.tf new file mode 100644 index 0000000..b464308 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/virtual_network/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/virtual_network/variables.tf new file mode 100644 index 0000000..f8c0b0e --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/web_app/main.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/web_app/main.tf new file mode 100644 index 0000000..a2eed3a --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/web_app/main.tf @@ -0,0 +1,71 @@ +resource "azurerm_linux_web_app" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + service_plan_id = var.service_plan_id + https_only = var.https_only + virtual_network_subnet_id = var.virtual_network_subnet_id + public_network_access_enabled = var.public_network_access_enabled + client_affinity_enabled = false + tags = var.tags + + identity { + type = "SystemAssigned" + } + + 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 + } + } + + app_settings = var.app_settings + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_linux_web_app.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "AppServiceHTTPLogs" + } + + enabled_log { + category = "AppServiceConsoleLogs" + } + + enabled_log { + category = "AppServiceAppLogs" + } + + enabled_log { + category = "AppServiceAuditLogs" + } + + enabled_log { + category = "AppServiceIPSecAuditLogs" + } + + enabled_log { + category = "AppServicePlatformLogs" + } + + enabled_log { + category = "AppServiceAuthenticationLogs" + } + + enabled_metric { + category = "AllMetrics" + } +} \ No newline at end of file diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/web_app/outputs.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/web_app/outputs.tf new file mode 100644 index 0000000..d7b6981 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/web_app/outputs.tf @@ -0,0 +1,24 @@ +output "id" { + value = azurerm_linux_web_app.example.id + description = "Specifies the resource id of the Web App" +} + +output "name" { + value = azurerm_linux_web_app.example.name + description = "Specifies the name of the Web App" +} + +output "default_hostname" { + value = azurerm_linux_web_app.example.default_hostname + description = "Specifies the default hostname of the Web App" +} + +output "outbound_ip_addresses" { + value = azurerm_linux_web_app.example.outbound_ip_addresses + description = "Specifies the outbound IP addresses of the Web App" +} + +output "principal_id" { + value = azurerm_linux_web_app.example.identity[0].principal_id + description = "Specifies the Principal ID of the System Assigned Managed Identity" +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/web_app/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/web_app/variables.tf new file mode 100644 index 0000000..81e6679 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/modules/web_app/variables.tf @@ -0,0 +1,89 @@ +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 "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 "dotnet_version" { + description = "(Optional) Specifies the version of .NET to run. Possible values include 8.0, 9.0 and 10.0." + type = string + default = "10.0" + + validation { + condition = contains(["8.0", "9.0", "10.0"], var.dotnet_version) + error_message = "The dotnet_version must be one of the supported versions: 8.0, 9.0, 10.0." + } +} + +variable "app_settings" { + description = "(Optional) A map of key-value pairs for App Settings." + type = map(string) + default = {} +} + +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 +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/outputs.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/outputs.tf new file mode 100644 index 0000000..0faff98 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/outputs.tf @@ -0,0 +1,23 @@ +output "resource_group_name" { + value = local.resource_group_name +} + +output "cosmosdb_account_name" { + value = module.cosmosdb_mongodb.name +} + +output "cosmosdb_document_endpoint" { + value = module.cosmosdb_mongodb.endpoint +} + +output "app_service_plan_name" { + value = module.app_service_plan.name +} + +output "web_app_name" { + value = module.web_app.name +} + +output "web_app_url" { + value = module.web_app.default_hostname +} \ No newline at end of file diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/providers.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/providers.tf new file mode 100644 index 0000000..1f06025 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/providers.tf @@ -0,0 +1,24 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=5.1.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 = "azure.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/web-app-cosmosdb-mongodb-api/dotnet/terraform/terraform.tfvars b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/terraform.tfvars new file mode 100644 index 0000000..919af4f --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/terraform.tfvars @@ -0,0 +1,3 @@ +prefix = "local" +suffix = "test" +location = "westeurope" \ No newline at end of file diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/variables.tf b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/variables.tf new file mode 100644 index 0000000..60f05dd --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/terraform/variables.tf @@ -0,0 +1,298 @@ +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 "dotnet_version" { + description = "(Optional) Specifies the version of .NET to run. Possible values include 8.0, 9.0 and 10.0." + type = string + default = "10.0" + + validation { + condition = contains(["8.0", "9.0", "10.0"], var.dotnet_version) + error_message = "The dotnet_version must be one of the supported versions: 8.0, 9.0, 10.0." + } +} + +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 "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" + iac = "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 "websites_port" { + description = "(Optional) Specifies the port on which the Web App will listen. Defaults to 8000." + type = number + default = 8000 +} \ No newline at end of file diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/visio/architecture.vsdx b/samples/web-app-cosmosdb-mongodb-api/dotnet/visio/architecture.vsdx new file mode 100644 index 0000000..d6ee14b Binary files /dev/null and b/samples/web-app-cosmosdb-mongodb-api/dotnet/visio/architecture.vsdx differ diff --git a/samples/web-app-cosmosdb-mongodb-api/python/terraform/README.md b/samples/web-app-cosmosdb-mongodb-api/python/terraform/README.md index 4ccf1c2..c79660d 100644 --- a/samples/web-app-cosmosdb-mongodb-api/python/terraform/README.md +++ b/samples/web-app-cosmosdb-mongodb-api/python/terraform/README.md @@ -81,7 +81,7 @@ provider "azurerm" { # 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" + metadata_host="azure.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" diff --git a/samples/web-app-cosmosdb-mongodb-api/python/terraform/providers.tf b/samples/web-app-cosmosdb-mongodb-api/python/terraform/providers.tf index 0b17881..1f06025 100644 --- a/samples/web-app-cosmosdb-mongodb-api/python/terraform/providers.tf +++ b/samples/web-app-cosmosdb-mongodb-api/python/terraform/providers.tf @@ -17,7 +17,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/README.md b/samples/web-app-cosmosdb-nosql-api/dotnet/README.md new file mode 100644 index 0000000..925da47 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/README.md @@ -0,0 +1,80 @@ +# Azure Web App with Azure CosmosDB for NoSQL API + +This sample demonstrates a ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in the `activities` container of the `sampledb` NoSQL database on an [Azure CosmosDB for NoSQL](https://learn.microsoft.com/en-us/azure/cosmos-db/distributed-nosql) account. + +## Architecture + +The following diagram illustrates the architecture of the solution: + +![Architecture Diagram](./images/architecture.png) + +- **Azure Web App**: Hosts the ASP.NET Core application +- **Azure App Service Plan**: Provides compute resources for the web app +- **Azure CosmosDB for NoSQL API**: Stores activity data in a CosmosDB container + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/) + +## Deployment + +Set up the Azure emulator using the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and set it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure). To pull the image, execute: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator by running: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Deploy the application to LocalStack for Azure using: + +- [Azure CLI Deployment](./scripts/README.md) + +> **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 + +1. Retrieve the port published and mapped to port 80 by the Docker container hosting the emulated Web App. +2. Open a web browser and navigate to `http://localhost:`. +3. If the deployment was successful, you will see the following user interface for adding and removing activities: + +![Architecture Diagram](./images/vacation-planner.png) + +You can use the `call-web-app.sh` Bash script below to call the web app. The script demonstrates three methods for calling web apps: + +1. **Through the LocalStack for Azure emulator**: Call the web app via the emulator using its default host name. The emulator acts as a proxy to the web app. +2. **Via localhost and host port mapped to the container's port**: Use `127.0.0.1` with the host port mapped to the container's port `80`. +3. **Via container IP address**: Use the app container's IP address on port `80`. This technique is only available when accessing the web app from the Docker host machine. +4. **Via the default hostname**: Call the web app via the default hostname `.azurewebsites.azure.localhost.localstack.cloud:4566`. + +## CosmosDB Tooling + +You can utilize **CosmosDB Data Explorer** to explore and manage your CosmosDB databases and containers. Ensure you connect using `http://localhost:port` connection string, where `port` corresponds to the port published by the CosmosDB container on the host and mapped to the internal CosmosDB port `1234`. + +![CosmosDB Data Explorer](./images/nosql-api-data-explorer.png) + +## References + +- [Azure Web Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [Azure CosmosDB Documentation](https://learn.microsoft.com/en-us/azure/cosmos-db/) +- [Quickstart: Deploy an ASP.NET web app to Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/quickstart-dotnetcore?pivots=development-environment-cli) +- [Azure Identity client library for .NET](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/identity-readme) +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/images/architecture.png b/samples/web-app-cosmosdb-nosql-api/dotnet/images/architecture.png new file mode 100644 index 0000000..a513dbd Binary files /dev/null and b/samples/web-app-cosmosdb-nosql-api/dotnet/images/architecture.png differ diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/images/nosql-api-data-explorer.png b/samples/web-app-cosmosdb-nosql-api/dotnet/images/nosql-api-data-explorer.png new file mode 100644 index 0000000..49369f4 Binary files /dev/null and b/samples/web-app-cosmosdb-nosql-api/dotnet/images/nosql-api-data-explorer.png differ diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/images/vacation-planner.png b/samples/web-app-cosmosdb-nosql-api/dotnet/images/vacation-planner.png new file mode 100644 index 0000000..a7c5151 Binary files /dev/null and b/samples/web-app-cosmosdb-nosql-api/dotnet/images/vacation-planner.png differ diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/README.md b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/README.md new file mode 100644 index 0000000..fd33fcb --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/README.md @@ -0,0 +1,148 @@ +# Azure CLI Deployment + +This directory includes Bash scripts designed for deploying and testing the sample Web App utilizing the `lstk` CLI. For further details about the sample application, refer to the [Azure Web App with Azure CosmosDB for NoSQL API](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## 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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `scripts` folder: + +```bash +cd samples/web-app-cosmosdb-nosql-api/dotnet/scripts +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +WEB_APP_NAME="${PREFIX}-webapp-nosql-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${WEB_APP_NAME}" +COSMOSDB_ACCOUNT_NAME="${WEB_APP_NAME}" + +# 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 Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,State:state,Location:location,DefaultHostName:defaultHostName}' \ + --output table \ + --only-show-errors + +# Check Azure Cosmos DB account +echo -e "\n[$COSMOSDB_ACCOUNT_NAME] cosmos db account:\n" +az cosmosdb show \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,DocumentEndpoint:documentEndpoint,Kind:kind}' \ + --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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/call-web-app.sh b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/call-web-app.sh new file mode 100755 index 0000000..a82964c --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/call-web-app.sh @@ -0,0 +1,199 @@ +#!/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_web_app() { + # Get the web app name + echo "Getting web app name..." + web_app_name=$(az webapp list --query '[0].name' --output tsv) + + if [ -n "$web_app_name" ]; then + echo "Web app [$web_app_name] successfully retrieved." + else + echo "Error: No web app found" + exit 1 + fi + + # Get the resource group name + echo "Getting resource group name for web app [$web_app_name]..." + resource_group_name=$(az webapp 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 web app [$web_app_name]" + exit 1 + fi + + # Get the the default host name of the web app + echo "Getting the default host name of the web app [$web_app_name]..." + app_host_name=$(az webapp show \ + --name "$web_app_name" \ + --resource-group "$resource_group_name" \ + --query 'defaultHostName' \ + --output tsv) + + if [ -n "$app_host_name" ]; then + echo "Web app default host name [$app_host_name] successfully retrieved." + else + echo "Error: No web app default host name found" + exit 1 + fi + + # Get the Docker container name + echo "Finding container name with prefix [ls-$web_app_name]..." + container_name=$(get_docker_container_name_by_prefix "ls-$web_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") + + 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 web 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 --max-time 10 http://localhost:4566/_localstack/proxy -s | jq '.proxy_port') + + if [ -n "$proxy_port" ]; then + # Call the web app via emulator proxy + echo "Calling web app [$web_app_name] via emulator..." + curl --max-time 10 --proxy "http://localhost:$proxy_port/" -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via emulator proxy port [$proxy_port] succeeded." + else + echo "Web app call via emulator proxy port [$proxy_port] failed." + fi + else + echo "Failed to retrieve LocalStack proxy port" + fi + + if [ -n "$container_ip" ]; then + # Call the web app via the container IP address + echo "Calling web app [$web_app_name] via container IP address [$container_ip]..." + curl --max-time 10 -s "http://$container_ip/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via container IP address [$container_ip] succeeded." + else + echo "Web app call via container IP address [$container_ip] failed." + fi + else + echo "Failed to retrieve container IP address" + fi + + if [ -n "$host_port" ]; then + # Call the web app via the host port + echo "Calling web app [$web_app_name] via host port [$host_port]..." + curl --max-time 10 -s "http://127.0.0.1:$host_port/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via host port [$host_port] succeeded." + else + echo "Web app call via host port [$host_port] failed." + fi + else + echo "Failed to retrieve host port" + fi + + if [ -n "$app_host_name" ]; then + # Call the web app via the default hostname + echo "Calling web app [$web_app_name] via default hostname [$app_host_name]..." + curl --max-time 10 -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via default hostname [$app_host_name] succeeded." + else + echo "Web app call via default hostname [$app_host_name] failed." + fi + else + echo "Failed to retrieve web app hostname" + fi +} + +call_web_app \ No newline at end of file diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/deploy.sh b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/deploy.sh new file mode 100755 index 0000000..6c77fc6 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/deploy.sh @@ -0,0 +1,131 @@ +#!/bin/bash + + +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="S1" +WEB_APP_NAME="${PREFIX}-webapp-nosql-${SUFFIX}" +COSMOSDB_ACCOUNT_NAME="${PREFIX}-nosqlapi-${SUFFIX}" +ZIPFILE="${WEB_APP_NAME}.zip" + +RANDOM_SUFFIX=$(echo $RANDOM) +NEW_DB_NAME="vacationplanner_${RANDOM_SUFFIX}" +AZURECOSMOSDB_DATABASENAME=$NEW_DB_NAME +AZURECOSMOSDB_CONTAINERNAME="activities_${RANDOM_SUFFIX}" +AURECOSMOSDB_PARTITION_KEY="/username" + +# run-samples.sh runs this script as `bash scripts/deploy.sh` from the sample root, so relative +# paths such as ../src must resolve against the script's own location, not the caller's directory. +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$CURRENT_DIR" || exit + +# Validates if the resource group exists in the subscription, if not creates it +echo "Checking if resource group [$RESOURCE_GROUP_NAME] exists..." +az group show --name $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No resource group [$RESOURCE_GROUP_NAME] exists" + echo "Creating resource group [$RESOURCE_GROUP_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." + else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists." +fi + +echo "Create CosmosDB NoSQL Account" + export AZURECOSMOSDB_ENDPOINT=$(az cosmosdb create \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $WEB_APP_NAME \ + --locations regionName=$LOCATION \ + --query "documentEndpoint" \ + --output tsv) + +echo "Account created" +echo "AZURECOSMOSDB_ENDPOINT set to $AZURECOSMOSDB_ENDPOINT" + +echo "Create CosmosDB NoSQL Database" +az cosmosdb sql database create \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $AZURECOSMOSDB_DATABASENAME \ + --account-name $WEB_APP_NAME + +echo "Create CosmosDB NoSQL Container" +az cosmosdb sql container create \ + --resource-group $RESOURCE_GROUP_NAME \ + --account-name $WEB_APP_NAME \ + --database-name $AZURECOSMOSDB_DATABASENAME \ + --name $AZURECOSMOSDB_CONTAINERNAME \ + --partition-key-path $AURECOSMOSDB_PARTITION_KEY \ + --throughput 400 + +echo "Fetching DB Account primary master key" +export AZURECOSMOSDB_PRIMARY_KEY=$(az cosmosdb keys list \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $WEB_APP_NAME \ + --query "primaryMasterKey" \ + --output tsv) +echo "Primary master key is $AZURECOSMOSDB_PRIMARY_KEY" + +echo "Creating App service" +az appservice plan create --name $WEB_APP_NAME --resource-group $RESOURCE_GROUP_NAME --sku B1 --is-linux +echo "App service created" + +echo "Creating Web App" +az webapp create --name $WEB_APP_NAME --resource-group $RESOURCE_GROUP_NAME --plan $WEB_APP_NAME --runtime DOTNETCORE:10.0 +echo "Web App created" + +echo "Configure appsettings environment variables" +az webapp config appsettings set \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $WEB_APP_NAME \ + --settings AZURECOSMOSDB_ENDPOINT=$AZURECOSMOSDB_ENDPOINT \ + AZURECOSMOSDB_DATABASENAME=$AZURECOSMOSDB_DATABASENAME \ + AZURECOSMOSDB_CONTAINERNAME=$AZURECOSMOSDB_CONTAINERNAME \ + AZURECOSMOSDB_PRIMARY_KEY=$AZURECOSMOSDB_PRIMARY_KEY + +# Print the application settings of the web app +echo "Retrieving application settings for web app [$WEB_APP_NAME]..." +az webapp config appsettings list \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $WEB_APP_NAME + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +echo "Using az webapp deploy command for LocalStack emulator environment." +az webapp deploy \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $WEB_APP_NAME \ + --src-path ${ZIPFILE} \ + --type zip \ + --async true + +# Remove the zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/validate.sh b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/validate.sh new file mode 100755 index 0000000..542412a --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/validate.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +WEB_APP_NAME="${PREFIX}-webapp-nosql-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${WEB_APP_NAME}" +COSMOSDB_ACCOUNT_NAME="${WEB_APP_NAME}" + +# 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 Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,State:state,Location:location,DefaultHostName:defaultHostName}' \ + --output table \ + --only-show-errors + +# Check Azure Cosmos DB account +echo -e "\n[$COSMOSDB_ACCOUNT_NAME] cosmos db account:\n" +az cosmosdb show \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,DocumentEndpoint:documentEndpoint,Kind:kind}' \ + --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 diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Models/Activity.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..84277d4 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id)) + { + await store.DeleteAsync(id, cancellationToken); + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..758fa51 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + await store.UpdateAsync(id, text, cancellationToken); + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated."; + } + else + { + await store.AddAsync(text, cancellationToken); + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added."; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs new file mode 100644 index 0000000..92ddf01 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Code deployments built by Oryx export ASPNETCORE_URLS; custom images and local runs only set PORT. +if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") is null + && Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseUrls($"http://*:{port}"); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var cosmosOptions = CosmosOptions.FromEnvironment(); + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new CosmosActivityStore(cosmosOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +// One log line per request, the equivalent of the gunicorn access log the Python sample produces. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityDocument.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityDocument.cs new file mode 100644 index 0000000..c3e2552 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityDocument.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace VacationPlanner.Services; + +/// The Cosmos DB item shape shared with the Python sample: {id, username, activity, timestamp}. +public sealed class ActivityDocument +{ + [JsonProperty("id")] + public string Id { get; set; } = ""; + + [JsonProperty("username")] + public string Username { get; set; } = ""; + + [JsonProperty("activity")] + public string Activity { get; set; } = ""; + + [JsonProperty("timestamp")] + public string Timestamp { get; set; } = ""; +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityId.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityId.cs new file mode 100644 index 0000000..8654aaf --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityId.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VacationPlanner.Services; + +/// MD5 of username + activity + timestamp: the id scheme shared by the Vacation Planner samples. +public static class ActivityId +{ + public static string Create(string username, string activity) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"); + var hash = MD5.HashData(Encoding.UTF8.GetBytes($"{username}_{activity}_{timestamp}")); + return Convert.ToHexStringLower(hash); + } +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs new file mode 100644 index 0000000..d701e0e --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs @@ -0,0 +1,120 @@ +using System.Net; +using Microsoft.Azure.Cosmos; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Activities as items in an Azure Cosmos DB for NoSQL container partitioned by /username. +public sealed class CosmosActivityStore : IActivityStore +{ + private readonly CosmosClient _client; + private readonly CosmosOptions _options; + private readonly ILogger _logger; + private Container? _container; + + public CosmosActivityStore(CosmosOptions options, ILogger logger) + { + _options = options; + _logger = logger; + // Gateway mode talks plain HTTPS to the account endpoint, which is what the LocalStack emulator + // exposes; Direct mode (the SDK default) needs the TCP replica endpoints of a real account. + _client = new CosmosClient(options.Endpoint, options.Key, new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + }); + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + var database = await _client.CreateDatabaseIfNotExistsAsync(_options.DatabaseName, cancellationToken: cancellationToken); + var container = await database.Database.CreateContainerIfNotExistsAsync( + new ContainerProperties(_options.ContainerName, "/username"), throughput: 400, cancellationToken: cancellationToken); + _container = container.Container; + _logger.LogInformation("Cosmos DB database '{Database}' and container '{Container}' are ready", _options.DatabaseName, _options.ContainerName); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.username = @username").WithParameter("@username", _options.Username); + var activities = new List(); + using var iterator = Container.GetItemQueryIterator(query); + while (iterator.HasMoreResults) + { + foreach (var document in await iterator.ReadNextAsync(cancellationToken)) + { + activities.Add(new Activity(document.Id, document.Activity)); + } + } + + _logger.LogInformation( + "Retrieved {Count} item(s) for user: {Username} from container '{Container}'", + activities.Count, + _options.Username, + _options.ContainerName + ); + return activities; + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + var document = new ActivityDocument + { + Id = ActivityId.Create(_options.Username, text), + Username = _options.Username, + Activity = text, + Timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"), + }; + await Container.CreateItemAsync(document, new PartitionKey(_options.Username), cancellationToken: cancellationToken); + _logger.LogInformation( + "Created item {Id} in container '{Container}': {Activity}", + document.Id, + _options.ContainerName, + text + ); + } + + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + try + { + var item = await Container.ReadItemAsync(id, new PartitionKey(_options.Username), cancellationToken: cancellationToken); + item.Resource.Activity = text; + await Container.ReplaceItemAsync(item.Resource, id, new PartitionKey(_options.Username), cancellationToken: cancellationToken); + _logger.LogInformation("Updated item {Id} in container '{Container}'", id, _options.ContainerName); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + _logger.LogWarning("Activity {Id} was not found; nothing to update", id); + } + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Deleting item {Id} from container '{Container}'", id, _options.ContainerName); + await Container.DeleteItemAsync(id, new PartitionKey(_options.Username), cancellationToken: cancellationToken); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + _logger.LogWarning("Activity {Id} was not found; nothing to delete", id); + } + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await Container.ReadContainerAsync(cancellationToken: cancellationToken); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cosmos DB health check failed"); + return false; + } + } + + private Container Container => _container ?? _client.GetContainer(_options.DatabaseName, _options.ContainerName); +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosOptions.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosOptions.cs new file mode 100644 index 0000000..4412f8f --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosOptions.cs @@ -0,0 +1,26 @@ +namespace VacationPlanner.Services; + +/// Settings read from the same environment variables the Python sample uses. +public sealed record CosmosOptions(string Endpoint, string Key, string DatabaseName, string ContainerName, string Username) +{ + public static CosmosOptions FromEnvironment() + { + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "alex"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("Username cannot be empty"); + } + + return new CosmosOptions( + Endpoint: Require("AZURECOSMOSDB_ENDPOINT"), + Key: Require("AZURECOSMOSDB_PRIMARY_KEY"), + DatabaseName: Require("AZURECOSMOSDB_DATABASENAME"), + ContainerName: Require("AZURECOSMOSDB_CONTAINERNAME"), + Username: username); + } + + private static string Require(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException($"Missing required environment variable: {name}"); +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/IActivityStore.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..3b24e5f --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,21 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + Task AddAsync(string text, CancellationToken cancellationToken); + + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/VacationPlanner.csproj b/samples/web-app-cosmosdb-nosql-api/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..ee66710 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + + diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json b/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/favicon.ico b/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/style.css b/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-cosmosdb-nosql-api/python/scripts/deploy.sh b/samples/web-app-cosmosdb-nosql-api/python/scripts/deploy.sh index 22f49b2..1adc031 100755 --- a/samples/web-app-cosmosdb-nosql-api/python/scripts/deploy.sh +++ b/samples/web-app-cosmosdb-nosql-api/python/scripts/deploy.sh @@ -17,6 +17,11 @@ AZURECOSMOSDB_DATABASENAME=$NEW_DB_NAME AZURECOSMOSDB_CONTAINERNAME="activities_${RANDOM_SUFFIX}" AURECOSMOSDB_PARTITION_KEY="/username" +# run-samples.sh runs this script as `bash scripts/deploy.sh` from the sample root, so relative +# paths such as ../src must resolve against the script's own location, not the caller's directory. +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$CURRENT_DIR" || exit + # Validates if the resource group exists in the subscription, if not creates it echo "Checking if resource group [$RESOURCE_GROUP_NAME] exists..." az group show --name $RESOURCE_GROUP_NAME &>/dev/null diff --git a/samples/web-app-custom-image/dotnet/README.md b/samples/web-app-custom-image/dotnet/README.md new file mode 100644 index 0000000..6d7bcac --- /dev/null +++ b/samples/web-app-custom-image/dotnet/README.md @@ -0,0 +1,83 @@ +# Azure Web App with Custom Docker Image + +This sample demonstrates an ASP.NET Core web application hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) using a custom Docker image. The app runs on an Azure App Service Plan and uses a container image stored in an [Azure Container Registry](https://learn.microsoft.com/azure/container-registry/container-registry-intro). For more information on configuring a web app to use a custom container, see [Configure a custom container for Azure App Service](https://learn.microsoft.com/azure/app-service/configure-custom-container?tabs=debian&pivots=container-linux). + +## Architecture + +The following diagram illustrates the architecture of the solution: + +![Architecture Diagram](./images/architecture.png) + +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 Web 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 Azure Container Registry 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 Azure Container Registry 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 Container Registry](https://learn.microsoft.com/azure/container-registry/container-registry-intro): A fully-managed container registry service based on the open-source [Docker platform](https://docs.docker.com/get-started/docker-overview/) used to hold the container image used by the web app. +9. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview): Assigned the [AcrPull](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/containers#acrpull) role on the Azure Container Registry, enabling the Web App to pull the container image without storing credentials. +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 ASP.NET Core application from the custom container image stored in the Azure Container Registry. + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [Docker](https://docs.docker.com/get-docker/) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0), only if you want to build or run the web application outside Docker. +- [Bicep](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. +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +## Deployment + +Set up the Azure emulator using the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and set it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure). To pull the image, execute: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator by running: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +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 + +You can use the `call-web-app.sh` Bash script below to call the web app. The script calls the web app via the default hostname `.azurewebsites.azure.localhost.localstack.cloud:4566`. + +## References + +- [Azure Web Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [Azure Container Registry Documentation](https://learn.microsoft.com/azure/container-registry/container-registry-intro) +- [Configure a custom container for Azure App Service](https://learn.microsoft.com/azure/app-service/configure-custom-container?tabs=debian&pivots=container-linux) +- [Azure Identity client library for .NET](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/identity-readme) +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-custom-image/dotnet/bicep/README.md b/samples/web-app-custom-image/dotnet/bicep/README.md new file mode 100644 index 0000000..b02028a --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/README.md @@ -0,0 +1,294 @@ +# Bicep Deployment + +This directory contains the Bicep templates and a deployment script for provisioning Azure services in LocalStack for Azure. For further details about the sample application, refer to the [Azure Web App with Custom Docker Image](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [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 and building the custom image +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## 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. The deployment is split into two Bicep phases with an image push step between them. + +### First Bicep Deployment +In this phase, the [acr.bicep](acr.bicep) template deploys: + +1. [Azure Container Registry](https://learn.microsoft.com/azure/container-registry/container-registry-intro): A fully-managed container registry service based on the open-source [Docker platform](https://docs.docker.com/get-started/docker-overview/) used to hold the container image used by the web app. +2. [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. + +## Container Image Push +After the first Bicep deployment, the script builds the container image locally from the `src/Dockerfile` and pushes it to the Azure Container Registry. + +### Second Bicep Deployment +The [main.bicep](main.bicep) template deploys the remaining 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 Web App. + - *pe-subnet*: Used for hosting Azure Private Endpoints. +2. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone): Handles DNS resolution for the Azure Container Registry 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 Azure Container Registry 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 App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The underlying compute tier that hosts the web application. +7. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview): Assigned the [AcrPull](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/containers#acrpull) role on the Azure Container Registry, enabling the Web App to pull the container image without storing credentials. +8. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Runs the ASP.NET Core application from the custom container image stored in the Azure Container Registry. + +The provisioning process assigns the user-defined managed identity to the web app and uses its credentials to access the Azure Container Registry to pull the container image. The [AcrPull](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/containers#acrpull) role is assigned to the managed identity with the Azure Container Registry as the scope. + +For more information on the sample application, see [Azure Web App with Custom Docker Image](../README.md). + +## Configuration + +Before deploying the `main.bicep` template, update the `main.bicepparam` file with your specific values: + +```bicep +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param imageName = 'custom-image-webapp' +param imageTag = 'v1' +param tags = { + environment: 'test' + project: 'custom-image-webapp' +} +``` + +## 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 [acr.bicep](acr.bicep) to provision the Azure Container Registry and Log Analytics Workspace +- Extracts deployment outputs (ACR name, ACR login server) +- Builds the container image locally and pushes it to the Azure Container Registry +- Deploys [main.bicep](main.bicep) with parameters from [main.bicepparam](main.bicepparam) to provision the remaining resources +- Extracts deployment outputs (Web App name, App Service Plan name, Managed Identity name) + +## Deployment + +You can set up the Azure emulator by utilizing the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `bicep` folder: + +```bash +cd samples/web-app-custom-image/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 +set -euo pipefail + +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX}acr${SUFFIX}" +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +PRIVATE_DNS_ZONE_NAME="privatelink.azurecr.io" +PRIVATE_ENDPOINT_NAME="${PREFIX}-acr-pe-${SUFFIX}" +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +az group show \ + --name "$RESOURCE_GROUP_NAME" \ + --output table + +# Check managed identity +echo -e "[$MANAGED_IDENTITY_NAME] managed identity:\n" +az identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check App Service Plan +echo -e "\n[$APP_SERVICE_PLAN_NAME] App Service Plan:\n" +az appservice plan show \ + --name "$APP_SERVICE_PLAN_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check Azure Container Registry +echo -e "\n[$ACR_NAME] Azure Container Registry:\n" +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check Azure Web App +echo -e "\n[$WEB_APP_NAME] Web App:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "{name:name, state:state, defaultHostName:defaultHostName, kind:kind}" \ + --output table + +# Check App Settings +echo -e "\n[$WEB_APP_NAME] app settings:\n" +az webapp config appsettings list \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "[?name=='IMAGE_NAME' || name=='APP_NAME' || name=='WEBSITES_PORT']" \ + --output table + +# 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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEB_APP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEB_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 + +# 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 Public IP Prefix +echo -e "\n[$PIP_PREFIX_NAME] public ip prefix:\n" +az network public-ip prefix show \ + --name "$PIP_PREFIX_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --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 + +echo -e "\nResources in [$RESOURCE_GROUP_NAME]:\n" +az resource list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table +``` + +## 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 Bicep 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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-custom-image/dotnet/bicep/acr.bicep b/samples/web-app-custom-image/dotnet/bicep/acr.bicep new file mode 100644 index 0000000..01a4e6b --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/acr.bicep @@ -0,0 +1,81 @@ +//******************************************** +// 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 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('Tier of your Azure Container Registry.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param acrSku string = 'Premium' + +@description('Specifies the tags to be applied to the resources.') +param tags object = { + environment: 'test' + iac: 'bicep' +} + +//******************************************** +// Variables +//******************************************** +var acrName = '${prefix}acr${suffix}' + +//******************************************** +// Modules and Resources +//******************************************** +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 containerRegistry './modules/container-registry.bicep' = { + name: 'containerRegistry' + params: { + name: acrName + sku: acrSku + adminUserEnabled: true + workspaceId: workspace.outputs.id + location: location + tags: tags + } +} + +//******************************************** +// Outputs +//******************************************** +output acrName string = containerRegistry.outputs.name +output acrLoginServer string = containerRegistry.outputs.loginServer +output logAnalyticsWorkspaceName string = workspace.outputs.name diff --git a/samples/web-app-custom-image/dotnet/bicep/acr.bicepparam b/samples/web-app-custom-image/dotnet/bicep/acr.bicepparam new file mode 100644 index 0000000..f58b20d --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/acr.bicepparam @@ -0,0 +1,8 @@ +using 'acr.bicep' + +param prefix = 'local' +param suffix = 'test' +param tags = { + environment: 'test' + project: 'custom-image-webapp' +} diff --git a/samples/web-app-custom-image/dotnet/bicep/deploy.sh b/samples/web-app-custom-image/dotnet/bicep/deploy.sh new file mode 100755 index 0000000..9c16125 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/deploy.sh @@ -0,0 +1,254 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +ACR_TEMPLATE="acr.bicep" +ACR_PARAMETERS="acr.bicepparam" +MAIN_TEMPLATE="main.bicep" +MAIN_PARAMETERS="main.bicepparam" +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOCATION="westeurope" +IMAGE_NAME="custom-image-webapp" +IMAGE_TAG="v1" +LOCAL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" +VALIDATE_TEMPLATE=1 +USE_WHAT_IF=0 +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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 + +echo "Deploying Azure Container Registry and Log Analytics Workspace Bicep..." + +# 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 [$ACR_TEMPLATE]..." + az deployment group what-if \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $ACR_TEMPLATE \ + --parameters $ACR_PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + --only-show-errors + + if [[ $? == 0 ]]; then + echo "Bicep template [$ACR_TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$ACR_TEMPLATE]" + exit + fi + else + # Validate the Bicep template + echo "Validating Bicep template [$ACR_TEMPLATE]..." + output=$(az deployment group validate \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $ACR_TEMPLATE \ + --parameters $ACR_PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + --only-show-errors) + + if [[ $? == 0 ]]; then + echo "Bicep template [$ACR_TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$ACR_TEMPLATE]" + echo "$output" + exit + fi + fi +fi + +# Deploy the Bicep template +echo "Deploying Bicep template [$ACR_TEMPLATE]..." +if DEPLOYMENT_OUTPUTS=$(az deployment group create \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors \ + --template-file $ACR_TEMPLATE \ + --parameters $ACR_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 [$ACR_TEMPLATE] deployed successfully. Outputs:" + echo "$DEPLOYMENT_JSON" | jq . + ACR_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.acrName.value') + ACR_LOGIN_SERVER=$(echo "$DEPLOYMENT_JSON" | jq -r '.acrLoginServer.value') + LOG_ANALYTICS_WORKSPACE_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.logAnalyticsWorkspaceName.value') + echo "Deployment complete." + echo "Resource Group: $RESOURCE_GROUP_NAME" + echo "Azure Container Registry: $ACR_NAME ($ACR_LOGIN_SERVER)" +else + echo "Failed to deploy Bicep template [$ACR_TEMPLATE]" + exit 1 +fi + +if [[ -z "$ACR_NAME" || -z "$ACR_LOGIN_SERVER" || -z "$LOG_ANALYTICS_WORKSPACE_NAME" ]]; then + echo "ACR Name, ACR Login Server, or Log Analytics Workspace Name is empty. Exiting." + exit 1 +fi + +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name "$ACR_NAME" --only-show-errors + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +# Create full image name with login server, image name, and tag +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +echo "Building custom Docker image [$LOCAL_IMAGE]..." +docker build -t "$LOCAL_IMAGE" ../src/ + +if [ $? -eq 0 ]; then + echo "Docker image [$LOCAL_IMAGE] built successfully." +else + echo "Failed to build Docker image [$LOCAL_IMAGE]." + exit 1 +fi + +echo "Tagging Docker image [$LOCAL_IMAGE] as [$FULL_IMAGE]..." +docker tag "$LOCAL_IMAGE" "$FULL_IMAGE" + +if [ $? -eq 0 ]; then + echo "Docker image [$LOCAL_IMAGE] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$LOCAL_IMAGE] as [$FULL_IMAGE]." + exit 1 +fi + +echo "Pushing image [$FULL_IMAGE] to ACR..." +docker push "$FULL_IMAGE" + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + exit 1 +fi + +echo "Deploying the remaining Azure resources..." + +# 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 [$MAIN_TEMPLATE]..." + az deployment group what-if \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $MAIN_TEMPLATE \ + --parameters $MAIN_PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + imageName=$IMAGE_NAME \ + imageTag=$IMAGE_TAG \ + acrName="$ACR_NAME" \ + logAnalyticsWorkspaceName="$LOG_ANALYTICS_WORKSPACE_NAME" \ + --only-show-errors + + if [[ $? == 0 ]]; then + echo "Bicep template [$MAIN_TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$MAIN_TEMPLATE]" + exit + fi + else + # Validate the Bicep template + echo "Validating Bicep template [$MAIN_TEMPLATE]..." + output=$(az deployment group validate \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $MAIN_TEMPLATE \ + --parameters $MAIN_PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + imageName=$IMAGE_NAME \ + imageTag=$IMAGE_TAG \ + acrName="$ACR_NAME" \ + logAnalyticsWorkspaceName="$LOG_ANALYTICS_WORKSPACE_NAME" \ + --only-show-errors) + + if [[ $? == 0 ]]; then + echo "Bicep template [$MAIN_TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$MAIN_TEMPLATE]" + echo "$output" + exit + fi + fi +fi + +# Deploy the Bicep template +echo "Deploying Bicep template [$MAIN_TEMPLATE]..." +if DEPLOYMENT_OUTPUTS=$(az deployment group create \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors \ + --template-file $MAIN_TEMPLATE \ + --parameters $MAIN_PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + imageName=$IMAGE_NAME \ + imageTag=$IMAGE_TAG \ + acrName="$ACR_NAME" \ + logAnalyticsWorkspaceName="$LOG_ANALYTICS_WORKSPACE_NAME" \ + --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 [$MAIN_TEMPLATE] deployed successfully. Outputs:" + echo "$DEPLOYMENT_JSON" | jq . + APP_SERVICE_PLAN_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.appServicePlanName.value') + WEB_APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.webAppName.value') + ACR_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.acrName.value') + ACR_LOGIN_SERVER=$(echo "$DEPLOYMENT_JSON" | jq -r '.acrLoginServer.value') + MANAGED_IDENTITY_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.managedIdentityName.value') + echo "Deployment complete." + echo "Resource Group: $RESOURCE_GROUP_NAME" + echo "App Service Plan: $APP_SERVICE_PLAN_NAME" + echo "Web App: $WEB_APP_NAME" + echo "Azure Container Registry: $ACR_NAME ($ACR_LOGIN_SERVER)" + echo "Managed Identity: $MANAGED_IDENTITY_NAME" +else + echo "Failed to deploy Bicep template [$MAIN_TEMPLATE]" + exit 1 +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/web-app-custom-image/dotnet/bicep/main.bicep b/samples/web-app-custom-image/dotnet/bicep/main.bicep new file mode 100644 index 0000000..802b75c --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/main.bicep @@ -0,0 +1,305 @@ +//******************************************** +// 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 name of the image to be used for the Web App.') +param imageName string + +@description('Specifies the tag of the image to be used for the Web App.') +param imageTag string + +@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 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 webAppKind string = 'app,linux' + +@description('Specifies whether HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = false + +@description('Specifies the minimum TLS version for the Azure Web App.') +@allowed([ + '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 the optional Git Repo URL.') +param repoUrl string = ' ' + +@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 Web App for the regional virtual network integration.') +param webAppSubnetName string = 'app-subnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param webAppSubnetNsgName 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 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 the name of the Azure Container Registry resource.') +param acrName string = '' + +@description('Specifies the name of the Azure Log Analytics resource.') +param logAnalyticsWorkspaceName string = '' + +@description('Specifies the tags to be applied to the resources.') +param tags object = { + environment: 'test' + iac: 'bicep' +} + +//******************************************** +// Variables +//******************************************** +var webAppName = '${prefix}-webapp-${suffix}' +var appServicePlanName = '${prefix}-app-service-plan-${suffix}' +var managedIdentityName = '${prefix}-identity-${suffix}' +var privateDnsZoneName = 'privatelink.azurecr.io' +var privateEndpointName = '${prefix}-acr-pe-${suffix}' + +//******************************************** +// Modules and Resources +//******************************************** +resource workspace 'Microsoft.OperationalInsights/workspaces@2025-07-01' existing = { + name: logAnalyticsWorkspaceName == '' ? toLower('${prefix}-log-analytics-${suffix}') : logAnalyticsWorkspaceName +} +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2024-11-01-preview' existing = { + name: acrName == '' ? toLower('${prefix}acr${suffix}') : acrName +} + +module managedIdentity 'modules/managed-identity.bicep' = { + name: 'managedIdentity' + params: { + // properties + name: managedIdentityName + containerRegistryName: containerRegistry.name + location: location + tags: tags + } +} + +module network 'modules/virtual-network.bicep' = { + name: 'network' + params: { + virtualNetworkName: empty(virtualNetworkName) ? toLower('${prefix}-vnet-${suffix}') : virtualNetworkName + virtualNetworkAddressPrefixes: virtualNetworkAddressPrefixes + webAppSubnetName: webAppSubnetName + webAppSubnetAddressPrefix: webAppSubnetAddressPrefix + webAppSubnetNsgName: empty(webAppSubnetNsgName) ? toLower('${prefix}-webapp-subnet-nsg-${suffix}') : webAppSubnetNsgName + 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.id + location: location + tags: tags + } +} + +module privateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'privateDnsZone' + params: { + name: privateDnsZoneName + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module privateEndpoints 'modules/private-endpoint.bicep' = { + name: 'privateEndpoints' + params: { + name: privateEndpointName + privateLinkServiceId: containerRegistry.id + privateDnsZoneId: privateDnsZone.outputs.id + vnetId: network.outputs.virtualNetworkId + subnetId: network.outputs.peSubnetId + groupIds: [ + 'registry' + ] + 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.id + tags: tags + } +} + +module webApp 'modules/web-app.bicep' = { + name: webAppName + params: { + name: webAppName + location: location + kind: webAppKind + httpsOnly: httpsOnly + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + repoUrl: repoUrl + virtualNetworkName: network.outputs.virtualNetworkName + subnetName: network.outputs.webAppSubnetName + hostingPlanName: appServicePlan.outputs.name + loginServer: containerRegistry.properties.loginServer + imageName: imageName + imageTag: imageTag + managedIdentityName: managedIdentity.outputs.name + managedIdentityType: 'UserAssigned' + workspaceId: workspace.id + tags: tags + } +} + +//******************************************** +// Outputs +//******************************************** +output appServicePlanName string = appServicePlan.outputs.name +output webAppName string = webApp.outputs.name +output acrName string = containerRegistry.name +output acrLoginServer string = containerRegistry.properties.loginServer +output managedIdentityName string = managedIdentity.outputs.name diff --git a/samples/web-app-custom-image/dotnet/bicep/main.bicepparam b/samples/web-app-custom-image/dotnet/bicep/main.bicepparam new file mode 100644 index 0000000..0bfd022 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/main.bicepparam @@ -0,0 +1,10 @@ +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param imageName = 'custom-image-webapp' +param imageTag = 'v1' +param tags = { + environment: 'test' + project: 'custom-image-webapp' +} diff --git a/samples/web-app-custom-image/dotnet/bicep/modules/app-service-plan.bicep b/samples/web-app-custom-image/dotnet/bicep/modules/app-service-plan.bicep new file mode 100644 index 0000000..4b5cfb3 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/bicep/modules/container-registry.bicep b/samples/web-app-custom-image/dotnet/bicep/modules/container-registry.bicep new file mode 100644 index 0000000..e830e39 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/modules/container-registry.bicep @@ -0,0 +1,135 @@ + +//******************************************** +// Parameters +//******************************************** + +@description('Name of your Azure Container Registry') +@minLength(5) +@maxLength(50) +param name string = 'acr${uniqueString(resourceGroup().id)}' + +@description('Enable admin user that have push / pull permission to the registry.') +param adminUserEnabled bool = true + +@description('Specifies whether to allow public network access for the container registry.') +@allowed([ + 'Disabled' + 'Enabled' +]) +param publicNetworkAccess string = 'Enabled' + +@description('Tier of your Azure Container Registry.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param sku string = 'Premium' + +@description('Specifies whether or not registry-wide pull is enabled from unauthenticated clients.') +param anonymousPullEnabled bool = true + +@description('Specifies whether or not a single data endpoint is enabled per region for serving data.') +param dataEndpointEnabled bool = true + +@description('Specifies the network rule set for the container registry.') +param networkRuleSet object = { + defaultAction: 'Allow' +} + +@description('Specifies ehether to allow trusted Azure services to access a network restricted registry.') +@allowed([ + 'AzureServices' + 'None' +]) +param networkRuleBypassOptions string = 'AzureServices' + +@description('Specifies whether or not zone redundancy is enabled for this container registry.') +@allowed([ + 'Disabled' + 'Enabled' +]) +param zoneRedundancy string = 'Disabled' + +@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 = [ + 'ContainerRegistryRepositoryEvents' + 'ContainerRegistryLoginEvents' +] +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 containerRegistry 'Microsoft.ContainerRegistry/registries@2024-11-01-preview' = { + name: name + location: location + tags: tags + sku: { + name: sku + } + properties: { + adminUserEnabled: adminUserEnabled + anonymousPullEnabled: anonymousPullEnabled + dataEndpointEnabled: dataEndpointEnabled + networkRuleBypassOptions: networkRuleBypassOptions + networkRuleSet: networkRuleSet + publicNetworkAccess: publicNetworkAccess + zoneRedundancy: zoneRedundancy + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: diagnosticSettingsName + scope: containerRegistry + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** + +output id string = containerRegistry.id +output name string = containerRegistry.name +output sku string = containerRegistry.sku.name +output loginServer string = containerRegistry.properties.loginServer diff --git a/samples/web-app-custom-image/dotnet/bicep/modules/log-analytics.bicep b/samples/web-app-custom-image/dotnet/bicep/modules/log-analytics.bicep new file mode 100644 index 0000000..2618829 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/bicep/modules/managed-identity.bicep b/samples/web-app-custom-image/dotnet/bicep/modules/managed-identity.bicep new file mode 100644 index 0000000..656f1a4 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/modules/managed-identity.bicep @@ -0,0 +1,54 @@ +//******************************************** +// 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 of the Azure Container Registry.') +param containerRegistryName string + +@description('Specifies the resource tags.') +param tags object + +//******************************************** +// Resources +//******************************************** + + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2025-06-01-preview' existing = { + name: containerRegistryName +} + +resource acrPullRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '7f951dda-4ed3-4680-a7ca-43fe172d538d' + scope: subscription() +} + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2025-01-31-preview' = { + name: name + location: location + tags: tags +} + +resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(containerRegistry.id, managedIdentity.id, acrPullRoleDefinition.id) + scope: containerRegistry + properties: { + roleDefinitionId: acrPullRoleDefinition.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/web-app-custom-image/dotnet/bicep/modules/private-dns-zone.bicep b/samples/web-app-custom-image/dotnet/bicep/modules/private-dns-zone.bicep new file mode 100644 index 0000000..d849259 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/bicep/modules/private-endpoint.bicep b/samples/web-app-custom-image/dotnet/bicep/modules/private-endpoint.bicep new file mode 100644 index 0000000..8fd35b8 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/modules/private-endpoint.bicep @@ -0,0 +1,72 @@ +//******************************************** +// 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 virtual network where private endpoints will be created.') +param vnetId string + +@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/web-app-custom-image/dotnet/bicep/modules/virtual-network.bicep b/samples/web-app-custom-image/dotnet/bicep/modules/virtual-network.bicep new file mode 100644 index 0000000..1c7a088 --- /dev/null +++ b/samples/web-app-custom-image/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 webAppSubnetName string = 'functionAppSubnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param webAppSubnetNsgName 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: webAppSubnetName + properties: { + addressPrefix: webAppSubnetAddressPrefix + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' + networkSecurityGroup: { + id: webAppSubnetNsg.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 webAppSubnetNsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: webAppSubnetNsgName + 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 webAppSubnetNsgDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: diagnosticSettingsName + scope: webAppSubnetNsg + 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 webAppSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, webAppSubnetName) +output webAppSubnetName string = webAppSubnetName +output peSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, peSubnetName) +output peSubnetName string = peSubnetName diff --git a/samples/web-app-custom-image/dotnet/bicep/modules/web-app.bicep b/samples/web-app-custom-image/dotnet/bicep/modules/web-app.bicep new file mode 100644 index 0000000..a51c307 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/bicep/modules/web-app.bicep @@ -0,0 +1,210 @@ +//******************************************** +// Parameters +//******************************************** + +@description('Specifies a globally unique name the Azure Web 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 = 'app,linux' + +@description('Specifies the minimum TLS version for the Azure Web App.') +@allowed([ + '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 HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = true + +@description('Specifies the name of the hosting plan.') +param hostingPlanName 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 resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the login server of the Azure Container Registry.') +param loginServer string + +@description('Specifies the name of the image to be used for the Web App.') +param imageName string + +@description('Specifies the tag of the image to be used for the Web App.') +param imageTag string + +@description('Specifies the type of the managed identity to be used by the Web App.') +@allowed([ + 'SystemAssigned' + 'UserAssigned' +]) +param managedIdentityType string = 'SystemAssigned' + +@description('Specifies the name of the managed identity to be used by the Web App if user assigned identity is selected.') +param managedIdentityName 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 = 'default' +var logCategories = [ + 'AppServiceHTTPLogs' + 'AppServiceConsoleLogs' + 'AppServiceAppLogs' + 'AppServiceAuditLogs' + 'AppServiceIPSecAuditLogs' + 'AppServicePlatformLogs' + '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@2024-05-01' existing = { + name: virtualNetworkName +} + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = { + parent: virtualNetwork + name: subnetName +} + +resource hostingPlan 'Microsoft.Web/serverfarms@2024-04-01' existing = { + name: hostingPlanName +} + +resource webApp 'Microsoft.Web/sites@2025-03-01' = { + name: name + location: location + tags: tags + kind: kind + properties: { + httpsOnly: httpsOnly + serverFarmId: hostingPlan.id + virtualNetworkSubnetId: subnet.id + outboundVnetRouting: { + allTraffic: true + applicationTraffic: true + contentShareTraffic: true + imagePullTraffic: true + backupRestoreTraffic: true + } + siteConfig: { + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: managedIdentity.properties.clientId + linuxFxVersion: 'DOCKER|${loginServer}/${imageName}:${imageTag}' + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + } + } + identity: { + type: managedIdentityType + userAssignedIdentities : managedIdentityType == 'SystemAssigned' ? null : { + '${managedIdentity.id}': {} + } + } +} + +resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = { + parent: webApp + name: 'appsettings' + properties: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + WEBSITES_PORT: '80' + APP_NAME: 'Custom Image' + IMAGE_NAME: '${loginServer}/${imageName}:${imageTag}' + } +} + +resource webAppSourceControl 'Microsoft.Web/sites/sourcecontrols@2024-11-01' = if (contains(repoUrl,'http')){ + name: 'web' + parent: webApp + properties: { + repoUrl: repoUrl + branch: 'master' + isManualIntegration: true + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if(!empty(workspaceId)) { + name: diagnosticSettingsName + scope: webApp + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = webApp.id +output name string = webApp.name +output defaultHostName string = webApp.properties.defaultHostName diff --git a/samples/web-app-custom-image/dotnet/images/architecture.png b/samples/web-app-custom-image/dotnet/images/architecture.png new file mode 100644 index 0000000..6ca0fe3 Binary files /dev/null and b/samples/web-app-custom-image/dotnet/images/architecture.png differ diff --git a/samples/web-app-custom-image/dotnet/scripts/README.md b/samples/web-app-custom-image/dotnet/scripts/README.md new file mode 100644 index 0000000..a0ee2b4 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/scripts/README.md @@ -0,0 +1,258 @@ +# Azure CLI Deployment + +This directory contains the Azure CLI scripts for provisioning Azure services in LocalStack for Azure. For further details about the sample application, refer to the [Azure Web App with Custom Docker Image](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): Local Azure cloud emulator for development and testing +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack and building the custom image +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## Architecture Overview + +The [deploy.sh](deploy.sh) script creates all Azure resources from scratch using the Azure CLI: + +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 Web 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 Azure Container Registry 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 Azure Container Registry 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 Container Registry](https://learn.microsoft.com/azure/container-registry/container-registry-intro): A fully-managed container registry service based on the open-source [Docker platform](https://docs.docker.com/get-started/docker-overview/) used to hold the container image used by the web app. +9. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview): Assigned the [AcrPull](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/containers#acrpull) role on the Azure Container Registry, enabling the Web App to pull the container image without storing credentials. +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 ASP.NET Core application from the custom container image stored in the Azure Container Registry. + +## Provisioning Scripts + +See [deploy.sh](deploy.sh) for the complete deployment automation. The script performs: + +- Creates resource group +- Deploys Azure Container Registry +- Builds container image locally and pushes it to ACR +- Deploys remaining Azure resources (VNet, NSG, NAT Gateway, DNS, Private Endpoint, App Service Plan, managed identity, Web App) +- Configures Web App to use the container image from ACR +- Assigns [AcrPull](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/containers#acrpull) role to the user-assigned managed identity + +## Deployment + +You can set up the Azure emulator by utilizing the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `scripts` folder: + +```bash +cd samples/web-app-custom-image/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](validate.sh) script to confirm that all resources were provisioned and configured as expected: + +```bash +#!/bin/bash +set -euo pipefail + +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX}acr${SUFFIX}" +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +PRIVATE_DNS_ZONE_NAME="privatelink.azurecr.io" +PRIVATE_ENDPOINT_NAME="${PREFIX}-acr-pe-${SUFFIX}" +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +az group show \ + --name "$RESOURCE_GROUP_NAME" \ + --output table + +# Check managed identity +echo -e "[$MANAGED_IDENTITY_NAME] managed identity:\n" +az identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check App Service Plan +echo -e "\n[$APP_SERVICE_PLAN_NAME] App Service Plan:\n" +az appservice plan show \ + --name "$APP_SERVICE_PLAN_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check Azure Container Registry +echo -e "\n[$ACR_NAME] Azure Container Registry:\n" +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check Azure Web App +echo -e "\n[$WEB_APP_NAME] Web App:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "{name:name, state:state, defaultHostName:defaultHostName, kind:kind}" \ + --output table + +# Check App Settings +echo -e "\n[$WEB_APP_NAME] app settings:\n" +az webapp config appsettings list \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "[?name=='IMAGE_NAME' || name=='APP_NAME' || name=='WEBSITES_PORT']" \ + --output table + +# 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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEB_APP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEB_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 + +# 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 Public IP Prefix +echo -e "\n[$PIP_PREFIX_NAME] public ip prefix:\n" +az network public-ip prefix show \ + --name "$PIP_PREFIX_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --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 + +echo -e "\nResources in [$RESOURCE_GROUP_NAME]:\n" +az resource list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table +``` + +## 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 Azure CLI deployment script. + +## Related Documentation + +- [Azure CLI Documentation](https://learn.microsoft.com/en-us/cli/azure/) +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-custom-image/dotnet/scripts/call-web-app.sh b/samples/web-app-custom-image/dotnet/scripts/call-web-app.sh new file mode 100755 index 0000000..0c77196 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/scripts/call-web-app.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -euo pipefail + +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" + +APP_HOST_NAME=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "defaultHostName" \ + --output tsv \ + --only-show-errors) + +if [ -z "$APP_HOST_NAME" ]; then + echo "Failed to retrieve Web App hostname." + exit 1 +fi + +echo "Web App hostname: $APP_HOST_NAME" + +echo "Calling Web App using $APP_HOST_NAME..." +curl --max-time 10 -fsS "http://$APP_HOST_NAME/api/status" +echo "" diff --git a/samples/web-app-custom-image/dotnet/scripts/deploy.sh b/samples/web-app-custom-image/dotnet/scripts/deploy.sh new file mode 100755 index 0000000..fbd7ada --- /dev/null +++ b/samples/web-app-custom-image/dotnet/scripts/deploy.sh @@ -0,0 +1,1023 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX}acr${SUFFIX}" +ACR_SKU='Premium' +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="S1" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +IMAGE_NAME="custom-image-webapp" +IMAGE_TAG="v1" +LOCAL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +VIRTUAL_NETWORK_ADDRESS_PREFIX="10.0.0.0/8" +WEB_APP_SUBNET_NAME="app-subnet" +WEB_APP_SUBNET_PREFIX="10.0.0.0/24" +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-subnet-nsg-${SUFFIX}" +PE_SUBNET_NAME="pe-subnet" +PE_SUBNET_PREFIX="10.0.1.0/24" +PE_SUBNET_NSG_NAME="${PREFIX}-pe-subnet-nsg-${SUFFIX}" +VIRTUAL_NETWORK_LINK_NAME="link-to-vnet" +PRIVATE_DNS_ZONE_NAME="privatelink.azurecr.io" +PRIVATE_ENDPOINT_NAME="${PREFIX}-acr-pe-${SUFFIX}" +PRIVATE_ENDPOINT_GROUP="registry" +PRIVATE_DNS_ZONE_GROUP_NAME="default" +NAT_GATEWAY_NAME="${PREFIX}-nat-gateway-${SUFFIX}" +PIP_PREFIX_NAME="${PREFIX}-nat-gateway-pip-prefix-${SUFFIX}" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +DIAGNOSTIC_SETTINGS_NAME='default' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +RETRY_COUNT=3 +SLEEP=5 + +cd "$CURRENT_DIR" || exit + +# Create a resource group +echo "Creating resource group [$RESOURCE_GROUP_NAME]..." +az group create \ + --name "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created successfully." +else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 +fi + +# Check if the Azure Container Registry already exists +echo "Checking if [$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$ACR_NAME] Azure Container Registry exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled true \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created successfully." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Get the Azure Container Registry resource id +echo "Getting [$ACR_NAME] Azure Container Registry resource id in the [$RESOURCE_GROUP_NAME] resource group..." +ACR_ID=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $ACR_ID ]]; then + echo "[$ACR_NAME] Azure Container Registry resource id retrieved successfully: $ACR_ID" +else + echo "Failed to retrieve [$ACR_NAME] Azure Container Registry resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name "$ACR_NAME" --only-show-errors + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +echo "Getting login server for Azure Container Registry [$ACR_NAME]..." +ACR_LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +if [ -n "$ACR_LOGIN_SERVER" ]; then + echo "Login server retrieved successfully: $ACR_LOGIN_SERVER" +else + echo "Failed to retrieve login server for Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +# Create full image name with login server, image name, and tag +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +echo "Building custom Docker image [$LOCAL_IMAGE]..." +docker build -t "$LOCAL_IMAGE" ../src/ + +if [ $? -eq 0 ]; then + echo "Docker image [$LOCAL_IMAGE] built successfully." +else + echo "Failed to build Docker image [$LOCAL_IMAGE]." + exit 1 +fi + +echo "Tagging Docker image [$LOCAL_IMAGE] as [$FULL_IMAGE]..." +docker tag "$LOCAL_IMAGE" "$FULL_IMAGE" + +if [ $? -eq 0 ]; then + echo "Docker image [$LOCAL_IMAGE] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$LOCAL_IMAGE] as [$FULL_IMAGE]." + exit 1 +fi + +echo "Pushing image [$FULL_IMAGE] to ACR..." +docker push "$FULL_IMAGE" + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + 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" 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 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 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 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 AcrPull role to the managed identity with the Azure Container Registry as scope +ROLE="AcrPull" +echo "Checking if the [$MANAGED_IDENTITY_NAME] managed identity has the [$ROLE] role assignment on Azure Container Registry [$ACR_NAME]..." +current=$(az role assignment list \ + --assignee "$PRINCIPAL_ID" \ + --scope "$ACR_ID" \ + --query "[?roleDefinitionName=='$ROLE'].roleDefinitionName" \ + --output tsv 2>/dev/null) + +if [[ $current == "$ROLE" ]]; then + echo "Managed identity [$MANAGED_IDENTITY_NAME] already has the [$ROLE] role assignment on Azure Container Registry [$ACR_NAME]" +else + echo "Managed identity [$MANAGED_IDENTITY_NAME] does not have the [$ROLE] role assignment on Azure Container Registry [$ACR_NAME]" + echo "Creating role assignment: assigning [$ROLE] role to managed identity [$MANAGED_IDENTITY_NAME] on Azure Container Registry [$ACR_NAME]..." + 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 "$ACR_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 [$MANAGED_IDENTITY_NAME] on Azure Container Registry [$ACR_NAME]" + else + echo "Failed to assign [$ROLE] role to managed identity [$MANAGED_IDENTITY_NAME] on Azure Container Registry [$ACR_NAME]" + exit 1 + fi +fi + +# Check if the network security group for the web app subnet already exists +echo "Checking if [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az network nsg show \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet..." + + # Create the network security group for the web app subnet + az network nsg create \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Get the resource id of the network security group for the web app subnet +echo "Getting [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet resource id in the [$RESOURCE_GROUP_NAME] resource group..." +WEB_APP_SUBNET_NSG_ID=$(az network nsg show \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_SUBNET_NSG_ID ]]; then + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet resource id retrieved successfully: $WEB_APP_SUBNET_NSG_ID" +else + echo "Failed to retrieve [$WEB_APP_SUBNET_NSG_NAME] network security group for the web 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" \ + --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 \ + --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 \ + --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 "$WEB_APP_SUBNET_NAME" \ + --subnet-prefix "$WEB_APP_SUBNET_PREFIX" \ + --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 1 + fi + + # Update the web app subnet to associate it with the NAT Gateway and the NSG + echo "Associating [$WEB_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_APP_SUBNET_NSG_NAME] network security group..." + az network vnet subnet update \ + --name "$WEB_APP_SUBNET_NAME" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --nat-gateway "$NAT_GATEWAY_NAME" \ + --network-security-group "$WEB_APP_SUBNET_NSG_NAME" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$WEB_APP_SUBNET_NAME] subnet successfully associated with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_APP_SUBNET_NSG_NAME] network security group" + else + echo "Failed to associate [$WEB_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_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" \ + --query id \ + --output tsv \ + --only-show-errors) + +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 1 +fi + +# Check if the private DNS Zone already exists +echo "Checking if [$PRIVATE_DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az network private-dns zone show \ + --name "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$PRIVATE_DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PRIVATE_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 "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$PRIVATE_DNS_ZONE_NAME] private DNS zone successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$PRIVATE_DNS_ZONE_NAME] private DNS zone in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "[$PRIVATE_DNS_ZONE_NAME] private DNS zone already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the virtual network link between the private DNS zone and the virtual network already exists +echo "Checking if [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_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 "$PRIVATE_DNS_ZONE_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network actually exists" + + echo "Creating [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network..." + + # Create the virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network + az network private-dns link vnet create \ + --name "$VIRTUAL_NETWORK_LINK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --zone-name "$PRIVATE_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 [$PRIVATE_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 [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network" + exit + fi +else + echo "[$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network already exists" +fi + +# Check if the private endpoint already exists +echo "Checking if private endpoint [$PRIVATE_ENDPOINT_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=='$PRIVATE_ENDPOINT_NAME'].id" \ + --output tsv) + +if [[ -z $privateEndpointId ]]; then + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] does not exist in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PRIVATE_ENDPOINT_NAME] private endpoint for the [$ACR_NAME] Azure Container Registry in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create a private endpoint for the Azure Container Registry + az network private-endpoint create \ + --name "$PRIVATE_ENDPOINT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --subnet "$PE_SUBNET_NAME" \ + --private-connection-resource-id "$ACR_ID" \ + --group-id "$PRIVATE_ENDPOINT_GROUP" \ + --connection-name "acr-connection" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] successfully created for the [$ACR_NAME] Azure Container Registry in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create a private endpoint for the [$ACR_NAME] Azure Container Registry in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the private DNS zone group is already created for the Azure Container Registry private endpoint +echo "Checking if the private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint already exists..." +NAME=$(az network private-endpoint dns-zone-group show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PRIVATE_ENDPOINT_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 [$PRIVATE_ENDPOINT_NAME] private endpoint actually exists" + echo "Creating private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint..." + + # Create the private DNS zone group for the Azure Container Registry private endpoint + az network private-endpoint dns-zone-group create \ + --name "$PRIVATE_DNS_ZONE_GROUP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PRIVATE_ENDPOINT_NAME" \ + --private-dns-zone "$PRIVATE_DNS_ZONE_NAME" \ + --zone-name "$PRIVATE_DNS_ZONE_NAME" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint successfully created" + else + echo "Failed to create private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint" + exit + fi +else + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint already exists" +fi + +# Check if the App Service Plan already exists +echo "Checking if [$APP_SERVICE_PLAN_NAME] App Service Plan already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az appservice plan show \ + --name "$APP_SERVICE_PLAN_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$APP_SERVICE_PLAN_NAME] App Service Plan exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Linux App Service Plan [$APP_SERVICE_PLAN_NAME]..." + az appservice plan create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --location "$LOCATION" \ + --sku "$APP_SERVICE_PLAN_SKU" \ + --is-linux \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "App Service Plan [$APP_SERVICE_PLAN_NAME] created successfully." + else + echo "Failed to create App Service Plan [$APP_SERVICE_PLAN_NAME]." + exit 1 + fi +else + echo "[$APP_SERVICE_PLAN_NAME] App Service Plan 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 Web App already exists +echo "Checking if [$WEB_APP_NAME] Web App already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$WEB_APP_NAME] Web App exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Web App [$WEB_APP_NAME] from custom image [$FULL_IMAGE]..." + az webapp create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --plan "$APP_SERVICE_PLAN_NAME" \ + --name "$WEB_APP_NAME" \ + --assign-identity "${IDENTITY_ID}" \ + --container-image-name "$FULL_IMAGE" \ + --vnet "$VIRTUAL_NETWORK_NAME" \ + --subnet "$WEB_APP_SUBNET_NAME" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Web App [$WEB_APP_NAME] created successfully." + else + echo "Failed to create Web App [$WEB_APP_NAME]." + exit 1 + fi +else + echo "[$WEB_APP_NAME] Web App already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Configure the App Service to use managed identity for ACR authentication +echo "Configuring Web App [$WEB_APP_NAME] to use managed identity [$MANAGED_IDENTITY_NAME] to access Azure Container Registry [$ACR_NAME]..." +az webapp config set \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --generic-configurations "{\"acrUseManagedIdentityCreds\": true, \"acrUserManagedIdentityID\": \"$CLIENT_ID\"}" 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web App [$WEB_APP_NAME] configured to use managed identity [$MANAGED_IDENTITY_NAME] to access Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to configure Web App [$WEB_APP_NAME] to use managed identity [$MANAGED_IDENTITY_NAME] to access Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +# Get the Web App resource id +echo "Getting [$WEB_APP_NAME] Web App resource id in the [$RESOURCE_GROUP_NAME] resource group..." +WEB_APP_ID=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_ID ]]; then + echo "[$WEB_APP_NAME] Web App resource id retrieved successfully: $WEB_APP_ID" +else + echo "Failed to retrieve [$WEB_APP_NAME] Web App resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Enabling forced tunneling for the web app to route all outbound traffic through the virtual network +echo "Enabling forced tunneling for web app [$WEB_APP_NAME] to route all outbound traffic through the virtual network..." + +az resource update \ + --ids "$WEB_APP_ID" \ + --set properties.outboundVnetRouting.allTraffic=true \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Forced tunneling enabled for web app [$WEB_APP_NAME]." +else + echo "Failed to enable forced tunneling for web app [$WEB_APP_NAME]." + exit 1 +fi + +# Set web app settings +echo "Setting Web App container settings for [$WEB_APP_NAME]..." +az webapp config appsettings set \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --settings \ + WEBSITES_PORT="80" \ + APP_NAME="Custom Image" \ + IMAGE_NAME="$FULL_IMAGE" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web App settings for [$WEB_APP_NAME] set successfully." +else + echo "Failed to set Web App settings for [$WEB_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" \ + --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 container registry already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$ACR_NAME] container registry already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$ACR_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$ACR_NAME] container registry actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$ACR_NAME] container registry..." + + # Create the diagnostic settings for the container registry to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$ACR_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "ContainerRegistryRepositoryEvents", "enabled": true}, + {"category": "ContainerRegistryLoginEvents", "enabled": true} + ]' \ + --metrics '[ + {"category": "AllMetrics", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$ACR_NAME] container registry successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$ACR_NAME] container registry" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$ACR_NAME] container registry already exist" +fi + +# Check whether the diagnostic settings for the web app already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app..." + + # Create the diagnostic settings for the web app to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "AppServiceHTTPLogs", "enabled": true}, + {"category": "AppServiceConsoleLogs", "enabled": true}, + {"category": "AppServiceAppLogs", "enabled": true}, + {"category": "AppServiceAuditLogs", "enabled": true}, + {"category": "AppServiceIPSecAuditLogs", "enabled": true}, + {"category": "AppServicePlatformLogs", "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 [$WEB_APP_NAME] web app successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web 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 metrics 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 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 web app subnet already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_SUBNET_NSG_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet..." + + # Create the diagnostic settings for the network security group for the web app subnet to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_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 [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web 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 + +echo "Listing resources in resource group [$RESOURCE_GROUP_NAME]..." +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table + +echo "" +echo "Deployment complete." +echo "Resource Group: $RESOURCE_GROUP_NAME" +echo "App Service Plan: $APP_SERVICE_PLAN_NAME" +echo "Web App: $WEB_APP_NAME" +echo "Azure Container Registry: $ACR_NAME ($ACR_LOGIN_SERVER)" +echo "Image: $FULL_IMAGE" +echo "Managed Identity: $MANAGED_IDENTITY_NAME" +echo "" +echo "Run 'bash scripts/validate.sh' to verify the deployment." diff --git a/samples/web-app-custom-image/dotnet/scripts/validate.sh b/samples/web-app-custom-image/dotnet/scripts/validate.sh new file mode 100755 index 0000000..b58fb61 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/scripts/validate.sh @@ -0,0 +1,132 @@ +#!/bin/bash +set -euo pipefail + +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX}acr${SUFFIX}" +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +PRIVATE_DNS_ZONE_NAME="privatelink.azurecr.io" +PRIVATE_ENDPOINT_NAME="${PREFIX}-acr-pe-${SUFFIX}" +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +az group show \ + --name "$RESOURCE_GROUP_NAME" \ + --output table + +# Check managed identity +echo -e "[$MANAGED_IDENTITY_NAME] managed identity:\n" +az identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check App Service Plan +echo -e "\n[$APP_SERVICE_PLAN_NAME] App Service Plan:\n" +az appservice plan show \ + --name "$APP_SERVICE_PLAN_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check Azure Container Registry +echo -e "\n[$ACR_NAME] Azure Container Registry:\n" +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check Azure Web App +echo -e "\n[$WEB_APP_NAME] Web App:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "{name:name, state:state, defaultHostName:defaultHostName, kind:kind}" \ + --output table + +# Check App Settings +echo -e "\n[$WEB_APP_NAME] app settings:\n" +az webapp config appsettings list \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "[?name=='IMAGE_NAME' || name=='APP_NAME' || name=='WEBSITES_PORT']" \ + --output table + +# 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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEB_APP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEB_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 + +# 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 Public IP Prefix +echo -e "\n[$PIP_PREFIX_NAME] public ip prefix:\n" +az network public-ip prefix show \ + --name "$PIP_PREFIX_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --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 + +echo -e "\nResources in [$RESOURCE_GROUP_NAME]:\n" +az resource list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table diff --git a/samples/web-app-custom-image/dotnet/src/.dockerignore b/samples/web-app-custom-image/dotnet/src/.dockerignore new file mode 100644 index 0000000..c68ede2 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/.dockerignore @@ -0,0 +1,4 @@ +.git +bin/ +obj/ +*.zip diff --git a/samples/web-app-custom-image/dotnet/src/AppInfo.cs b/samples/web-app-custom-image/dotnet/src/AppInfo.cs new file mode 100644 index 0000000..0e938e3 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/AppInfo.cs @@ -0,0 +1,11 @@ +namespace VacationPlanner; + +/// Deployment details shown on the page and returned by /api/status. +public static class AppInfo +{ + public static string AppName => Environment.GetEnvironmentVariable("APP_NAME") ?? "Custom Image Web App"; + + public static string ImageName => Environment.GetEnvironmentVariable("IMAGE_NAME") ?? "custom-image-webapp:v1"; + + public static string HostName => Environment.MachineName; +} diff --git a/samples/web-app-custom-image/dotnet/src/Dockerfile b/samples/web-app-custom-image/dotnet/src/Dockerfile new file mode 100644 index 0000000..29a6679 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY VacationPlanner.csproj . +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/publish . +# App Service (and the emulator) read the container port from the image's PORT variable; +# Kestrel binds it through ASPNETCORE_URLS. +ENV PORT=80 \ + ASPNETCORE_URLS=http://+:80 +EXPOSE 80 +ENTRYPOINT ["dotnet", "VacationPlanner.dll"] diff --git a/samples/web-app-custom-image/dotnet/src/Pages/Index.cshtml b/samples/web-app-custom-image/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..ba477ef --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,35 @@ +@page +@model IndexModel + + + + + + @Model.AppName + + + +
+
+

Azure Web App for Containers

+

@Model.AppName

+

This ASP.NET Core app is running from a custom Docker image on an emulated Azure Web App.

+
+ +
+
+ Image + @Model.ImageName +
+
+ Host + @Model.HostName +
+
+ Status endpoint + /api/status +
+
+
+ + diff --git a/samples/web-app-custom-image/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-custom-image/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..e9b15cf --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace VacationPlanner.Pages; + +public class IndexModel : PageModel +{ + public string AppName => AppInfo.AppName; + + public string ImageName => AppInfo.ImageName; + + public string HostName => AppInfo.HostName; + + public void OnGet() + { + } +} diff --git a/samples/web-app-custom-image/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-custom-image/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..e3c5b64 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using VacationPlanner +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-custom-image/dotnet/src/Program.cs b/samples/web-app-custom-image/dotnet/src/Program.cs new file mode 100644 index 0000000..1cbbcdb --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/Program.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; +using VacationPlanner; + +var builder = WebApplication.CreateBuilder(args); + +// Code deployments built by Oryx export ASPNETCORE_URLS; custom images and local runs only set PORT. +if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") is null + && Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseUrls($"http://*:{port}"); +} + +builder.Services.AddRazorPages(); + +var app = builder.Build(); + +// One log line per request, the equivalent of the gunicorn access log the Python sample produces. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/api/status", () => Results.Json(new +{ + status = "ok", + app = AppInfo.AppName, + image = AppInfo.ImageName, + hostname = AppInfo.HostName, +})); + +app.MapGet("/health", () => Results.Json(new { status = "ok" })); + +app.Run(); diff --git a/samples/web-app-custom-image/dotnet/src/VacationPlanner.csproj b/samples/web-app-custom-image/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..f1ee0c1 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,8 @@ + + + net10.0 + enable + enable + VacationPlanner + + diff --git a/samples/web-app-custom-image/dotnet/src/appsettings.json b/samples/web-app-custom-image/dotnet/src/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-custom-image/dotnet/src/wwwroot/style.css b/samples/web-app-custom-image/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..0764737 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/src/wwwroot/style.css @@ -0,0 +1,100 @@ +:root { + color-scheme: light; + --ink: #172033; + --muted: #53606f; + --surface: #ffffff; + --line: #d8dee7; + --accent: #0f766e; + --accent-2: #b45309; + --page: #f5f7fb; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--ink); + background: + linear-gradient(120deg, rgba(15, 118, 110, 0.12), transparent 36%), + linear-gradient(300deg, rgba(180, 83, 9, 0.12), transparent 34%), + var(--page); +} + +.shell { + width: min(1040px, calc(100% - 32px)); + margin: 0 auto; + padding: 64px 0; +} + +.hero { + padding: 48px 0 36px; +} + +.eyebrow { + margin: 0 0 12px; + color: var(--accent); + font-size: 0.84rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +h1 { + margin: 0; + max-width: 760px; + font-size: clamp(2.5rem, 6vw, 5.2rem); + line-height: 0.95; + letter-spacing: 0; +} + +.lede { + max-width: 640px; + margin: 24px 0 0; + color: var(--muted); + font-size: 1.16rem; + line-height: 1.6; +} + +.details { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-top: 32px; +} + +article { + min-height: 120px; + padding: 20px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +span { + display: block; + color: var(--muted); + font-size: 0.85rem; + font-weight: 700; +} + +strong { + display: block; + margin-top: 10px; + overflow-wrap: anywhere; + font-size: 1.05rem; + line-height: 1.35; +} + +@media (max-width: 720px) { + .shell { + padding: 36px 0; + } + + .details { + grid-template-columns: 1fr; + } +} diff --git a/samples/web-app-custom-image/dotnet/terraform/README.md b/samples/web-app-custom-image/dotnet/terraform/README.md new file mode 100644 index 0000000..d743d7f --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/README.md @@ -0,0 +1,287 @@ +# 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 Web App with Custom Docker Image](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [Terraform](https://developer.hashicorp.com/terraform/downloads): Infrastructure as Code tool for provisioning Azure resources +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack and building the custom image +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0): only needed to build or run the web application outside Docker +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## 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 Web 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 Azure Container Registry 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 Azure Container Registry 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 Container Registry](https://learn.microsoft.com/azure/container-registry/container-registry-intro): A fully-managed container registry service based on the open-source [Docker platform](https://docs.docker.com/get-started/docker-overview/) used to hold the container image used by the web app. +9. [User-Assigned Managed Identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview): Created and associated with the Web App. +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 ASP.NET Core application from the custom container image stored in the Azure Container Registry. + +> **Note** +> The Terraform [azurerm_linux_web_app](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_web_app) resource does not support using a managed identity for container image pull from Azure Container Registry. The Terraform deployment uses ACR admin username and password instead of a managed identity. + +## Provisioning Scripts + +You can use the [deploy.sh](deploy.sh) script to automate the deployment of all Azure resources 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. +- Uses a [`local-exec` provisioner](https://developer.hashicorp.com/terraform/language/resources/provisioners/local-exec) on a `null_resource` to build and push the container image to the Azure Container Registry locally before deploying the Web App. +- Deploys the Web App configured to pull the container image using ACR admin credentials. + +## 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 = "azure.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 the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `terraform` folder: + +```bash +cd samples/web-app-custom-image/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 +set -euo pipefail + +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX}acr${SUFFIX}" +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +VIRTUAL_NETWORK_NAME="${PREFIX}-vnet-${SUFFIX}" +PRIVATE_DNS_ZONE_NAME="privatelink.azurecr.io" +PRIVATE_ENDPOINT_NAME="${PREFIX}-acr-pe-${SUFFIX}" +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +az group show \ + --name "$RESOURCE_GROUP_NAME" \ + --output table + +# Check managed identity +echo -e "[$MANAGED_IDENTITY_NAME] managed identity:\n" +az identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check App Service Plan +echo -e "\n[$APP_SERVICE_PLAN_NAME] App Service Plan:\n" +az appservice plan show \ + --name "$APP_SERVICE_PLAN_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check Azure Container Registry +echo -e "\n[$ACR_NAME] Azure Container Registry:\n" +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +# Check Azure Web App +echo -e "\n[$WEB_APP_NAME] Web App:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "{name:name, state:state, defaultHostName:defaultHostName, kind:kind}" \ + --output table + +# Check App Settings +echo -e "\n[$WEB_APP_NAME] app settings:\n" +az webapp config appsettings list \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "[?name=='IMAGE_NAME' || name=='APP_NAME' || name=='WEBSITES_PORT']" \ + --output table + +# 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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEB_APP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEB_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 + +# 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 Public IP Prefix +echo -e "\n[$PIP_PREFIX_NAME] public ip prefix:\n" +az network public-ip prefix show \ + --name "$PIP_PREFIX_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --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 + +echo -e "\nResources in [$RESOURCE_GROUP_NAME]:\n" +az resource list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table +``` + +## 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 Terraform deployment. + +## Related Documentation + +- [Terraform Azure Provider Documentation](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) +- [Terraform local-exec Provisioner](https://developer.hashicorp.com/terraform/language/resources/provisioners/local-exec) +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-custom-image/dotnet/terraform/deploy.sh b/samples/web-app-custom-image/dotnet/terraform/deploy.sh new file mode 100755 index 0000000..63e42fb --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/deploy.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +IMAGE_NAME="custom-image-webapp" +IMAGE_TAG="v1" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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" \ + -var "image_name=$IMAGE_NAME" \ + -var "image_tag=$IMAGE_TAG" + +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) +WEB_APP_NAME=$(terraform output -raw web_app_name) +ACR_NAME=$(terraform output -raw container_registry_name) + +if [[ -z "$RESOURCE_GROUP_NAME" || -z "$WEB_APP_NAME" || -z "$ACR_NAME" ]]; then + echo "Resource Group Name, Web App Name, or ACR Name is empty. Exiting." + exit 1 +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/web-app-custom-image/dotnet/terraform/main.tf b/samples/web-app-custom-image/dotnet/terraform/main.tf new file mode 100644 index 0000000..ae6ad62 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/main.tf @@ -0,0 +1,224 @@ +# 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}" + virtual_network_name = "${local.prefix}-vnet-${local.suffix}" + nat_gateway_name = "${local.prefix}-nat-gateway-${local.suffix}" + nat_gateway_ip_prefix_name = "${local.prefix}-nat-gateway-pip-prefix-${local.suffix}" + private_endpoint_name = "${local.prefix}-acr-pe-${local.suffix}" + webapp_subnet_nsg_name = "${local.prefix}-webapp-subnet-nsg-${local.suffix}" + pe_subnet_nsg_name = "${local.prefix}-pe-subnet-nsg-${local.suffix}" + acr_name = "${local.prefix}acr${local.suffix}" + managed_identity_name = "${local.prefix}-identity-${local.suffix}" + app_service_plan_name = "${local.prefix}-app-service-plan-${local.suffix}" + web_app_name = "${local.prefix}-webapp-${local.suffix}" +} + +# 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 container registry +module "container_registry" { + source = "./modules/container_registry" + name = local.acr_name + resource_group_name = azurerm_resource_group.example.name + location = var.location + sku = var.acr_sku + admin_enabled = var.acr_admin_enabled + georeplication_locations = var.acr_georeplication_locations + log_analytics_workspace_id = module.log_analytics_workspace.id + 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 the webapp subnet +module "webapp_subnet_network_security_group" { + source = "./modules/network_security_group" + name = local.webapp_subnet_nsg_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] + } +} + +# Create a network security group and associate it with the private endpoint subnet +module "pe_subnet_network_security_group" { + source = "./modules/network_security_group" + name = local.pe_subnet_nsg_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.pe_subnet_name) = module.virtual_network.subnet_ids[var.pe_subnet_name] + } +} + +# Create a NAT gateway and associate it with the webapp 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 + public_ip_prefix_name = local.nat_gateway_ip_prefix_name + public_ip_prefix_length = 31 + 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 private DNS zone for the CosmosDB MongoDB account and link it to the virtual network +module "private_dns_zone" { + source = "./modules/private_dns_zone" + name = "privatelink.azurecr.io" + 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 CosmosDB MongoDB account in the pe_subnet subnet +module "private_endpoint" { + source = "./modules/private_endpoint" + name = local.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.container_registry.id + is_manual_connection = false + subresource_name = "registry" + private_dns_zone_group_name = "private-dns-zone-group" + private_dns_zone_group_ids = [module.private_dns_zone.id] +} + +# 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 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 + acr_id = module.container_registry.id + tags = var.tags +} + +# Push container image to the registry +resource "null_resource" "push_image" { + count = (var.image_name != null && var.image_name != "" && var.image_tag != null && var.image_tag != "") ? 1 : 0 + + provisioner "local-exec" { + command = "${path.root}/push_image.sh" + environment = { + ACR_NAME = local.acr_name + ACR_LOGIN_SERVER = module.container_registry.login_server + IMAGE_NAME = var.image_name + IMAGE_TAG = var.image_tag + } + } + + depends_on = [module.container_registry] +} + +# Create Web App using module +module "web_app" { + source = "./modules/web_app" + name = local.web_app_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + managed_identity_id = module.managed_identity.id + service_plan_id = module.app_service_plan.id + 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 + image_name = var.image_name + image_tag = var.image_tag + docker_registry_url = module.container_registry.login_server_url + docker_registry_username = module.container_registry.admin_username + docker_registry_password = module.container_registry.admin_password + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags + + app_settings = { + SCM_DO_BUILD_DURING_DEPLOYMENT = "true" + ENABLE_ORYX_BUILD = "true" + WEBSITES_PORT = var.websites_port + APP_NAME = "Custom Image" + IMAGE_NAME = "${module.container_registry.login_server}/${var.image_name}:${var.image_tag}" + } + + depends_on = [null_resource.push_image] +} \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/app_service_plan/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/app_service_plan/main.tf new file mode 100644 index 0000000..98a3e4d --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/app_service_plan/outputs.tf b/samples/web-app-custom-image/dotnet/terraform/modules/app_service_plan/outputs.tf new file mode 100644 index 0000000..f1455ea --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/app_service_plan/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/app_service_plan/variables.tf new file mode 100644 index 0000000..e543066 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/container_registry/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/container_registry/main.tf new file mode 100644 index 0000000..98b1e22 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/container_registry/main.tf @@ -0,0 +1,65 @@ +resource "azurerm_container_registry" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + sku = var.sku + admin_enabled = var.admin_enabled + tags = var.tags + + identity { + type = "UserAssigned" + identity_ids = [ + azurerm_user_assigned_identity.identity.id + ] + } + + dynamic "georeplications" { + for_each = var.georeplication_locations + + content { + location = georeplications.value + tags = var.tags + # Required since azurerm 5.0; true matches Azure's default of serving replicas + # through the geo-replicated login server (regional endpoints stay opt-in). + global_endpoint_routing_enabled = true + } + } + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_user_assigned_identity" "identity" { + resource_group_name = var.resource_group_name + location = var.location + tags = var.tags + + name = "${var.name}Identity" + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_container_registry.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "ContainerRegistryRepositoryEvents" + } + + enabled_log { + category = "ContainerRegistryLoginEvents" + } + + enabled_metric { + category = "AllMetrics" + } +} \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/container_registry/output.tf b/samples/web-app-custom-image/dotnet/terraform/modules/container_registry/output.tf new file mode 100644 index 0000000..5e6fcd1 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/container_registry/output.tf @@ -0,0 +1,34 @@ +output "name" { + description = "Specifies the name of the container registry." + value = azurerm_container_registry.example.name +} + +output "id" { + description = "Specifies the resource id of the container registry." + value = azurerm_container_registry.example.id +} + +output "resource_group_name" { + description = "Specifies the name of the resource group." + value = var.resource_group_name +} + +output "login_server" { + description = "Specifies the login server of the container registry." + value = azurerm_container_registry.example.login_server +} + +output "login_server_url" { + description = "Specifies the login server url of the container registry." + value = "https://${azurerm_container_registry.example.login_server}" +} + +output "admin_username" { + description = "Specifies the admin username of the container registry." + value = azurerm_container_registry.example.admin_username +} + +output "admin_password" { + description = "Specifies the admin password of the container registry." + value = azurerm_container_registry.example.admin_password +} \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/container_registry/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/container_registry/variables.tf new file mode 100644 index 0000000..c3fc7cc --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/container_registry/variables.tf @@ -0,0 +1,60 @@ +variable "name" { + description = "(Required) Specifies the name of the Container Registry. 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 Container Registry. 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 "admin_enabled" { + description = "(Optional) Specifies whether the admin user is enabled. Defaults to false." + type = bool + default = false +} + +variable "sku" { + description = "(Optional) The SKU name of the container registry. Possible values are Basic, Standard and Premium. Defaults to Basic" + type = string + default = "Basic" + + validation { + condition = contains(["Basic", "Standard", "Premium"], var.sku) + error_message = "The container registry sku is invalid." + } +} + +variable "tags" { + description = "(Optional) A mapping of tags to assign to the resource." + type = map(any) + default = {} +} + +variable "georeplication_locations" { + description = "(Optional) A list of Azure locations where the container registry should be geo-replicated." + type = list(string) + default = [] +} + +variable "log_analytics_workspace_id" { + description = "Specifies the resource id of the Azure Log Analytics workspace." + type = string +} + +variable "image_name" { + description = "(Required) Specifies the name of the container image to deploy to the Web App." + type = string + default = "custom-image-webapp" +} + +variable "image_tag" { + description = "(Required) Specifies the tag of the container image to deploy to the Web App." + type = string + default = "v1" +} \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/log_analytics/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/log_analytics/main.tf new file mode 100644 index 0000000..fcd4398 --- /dev/null +++ b/samples/web-app-custom-image/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 != null ? var.retention_in_days : null + + lifecycle { + ignore_changes = [ + tags + ] + } +} diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/log_analytics/output.tf b/samples/web-app-custom-image/dotnet/terraform/modules/log_analytics/output.tf new file mode 100644 index 0000000..fe2c398 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/log_analytics/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/log_analytics/variables.tf new file mode 100644 index 0000000..2db6a01 --- /dev/null +++ b/samples/web-app-custom-image/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 "tags" { + description = "(Optional) Specifies the tags of the Azure Log Analytics workspace." + type = map(any) + default = {} +} + +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 +} diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/managed_identity/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/managed_identity/main.tf new file mode 100644 index 0000000..95f89a8 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/managed_identity/main.tf @@ -0,0 +1,20 @@ + +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" "example" { + scope = var.acr_id + role_definition_name = "AcrPull" + principal_id = azurerm_user_assigned_identity.example.principal_id + skip_service_principal_aad_check = true +} diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/managed_identity/output.tf b/samples/web-app-custom-image/dotnet/terraform/modules/managed_identity/output.tf new file mode 100644 index 0000000..a22e571 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/managed_identity/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/managed_identity/variables.tf new file mode 100644 index 0000000..5da194a --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/managed_identity/variables.tf @@ -0,0 +1,26 @@ +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 "acr_id" { + description = "(Required) Specifies resource id of the Azure Container Registry resource" + type = string +} + diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/nat_gateway/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/nat_gateway/main.tf new file mode 100644 index 0000000..0dea868 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/nat_gateway/main.tf @@ -0,0 +1,42 @@ +resource "azurerm_public_ip_prefix" "example" { + name = var.public_ip_prefix_name + location = var.location + resource_group_name = var.resource_group_name + sku = var.sku_name + zones = var.zones + tags = var.tags + prefix_length = var.public_ip_prefix_length + + 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_prefix_association" "example" { + nat_gateway_id = azurerm_nat_gateway.example.id + public_ip_prefix_id = azurerm_public_ip_prefix.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/web-app-custom-image/dotnet/terraform/modules/nat_gateway/output.tf b/samples/web-app-custom-image/dotnet/terraform/modules/nat_gateway/output.tf new file mode 100644 index 0000000..3f5d284 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/nat_gateway/output.tf @@ -0,0 +1,9 @@ +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" +} diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/nat_gateway/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/nat_gateway/variables.tf new file mode 100644 index 0000000..a6b8e69 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/nat_gateway/variables.tf @@ -0,0 +1,55 @@ +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) +} + +variable "public_ip_prefix_name" { + description = "(Required) The name of the public IP prefix to create and associate with the NAT Gateway." + type = string + default = null +} + +variable "public_ip_prefix_length" { + description = "(Required) The length of the public IP prefix to create and associate with the NAT Gateway. Must be between 28 and 31." + type = number + default = 31 +} \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/network_security_group/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/network_security_group/main.tf new file mode 100644 index 0000000..c649652 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/network_security_group/outputs.tf b/samples/web-app-custom-image/dotnet/terraform/modules/network_security_group/outputs.tf new file mode 100644 index 0000000..b8ca8d5 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/network_security_group/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/network_security_group/variables.tf new file mode 100644 index 0000000..04eb07e --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/private_dns_zone/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/private_dns_zone/main.tf new file mode 100644 index 0000000..e61df00 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/private_dns_zone/main.tf @@ -0,0 +1,25 @@ +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))}" + private_dns_zone_id = azurerm_private_dns_zone.example.id + 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/web-app-custom-image/dotnet/terraform/modules/private_dns_zone/outputs.tf b/samples/web-app-custom-image/dotnet/terraform/modules/private_dns_zone/outputs.tf new file mode 100644 index 0000000..ca141f3 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/private_dns_zone/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/private_dns_zone/variables.tf new file mode 100644 index 0000000..8d0c0cc --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/private_endpoint/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/private_endpoint/main.tf new file mode 100644 index 0000000..b21566e --- /dev/null +++ b/samples/web-app-custom-image/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 = var.subresource_name != null ? [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/web-app-custom-image/dotnet/terraform/modules/private_endpoint/outputs.tf b/samples/web-app-custom-image/dotnet/terraform/modules/private_endpoint/outputs.tf new file mode 100644 index 0000000..367ab51 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/private_endpoint/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/private_endpoint/variables.tf new file mode 100644 index 0000000..ca1cde1 --- /dev/null +++ b/samples/web-app-custom-image/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 = bool + 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/web-app-custom-image/dotnet/terraform/modules/virtual_network/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/virtual_network/main.tf new file mode 100644 index 0000000..cec00f4 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/virtual_network/outputs.tf b/samples/web-app-custom-image/dotnet/terraform/modules/virtual_network/outputs.tf new file mode 100644 index 0000000..b464308 --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/virtual_network/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/virtual_network/variables.tf new file mode 100644 index 0000000..f8c0b0e --- /dev/null +++ b/samples/web-app-custom-image/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/web-app-custom-image/dotnet/terraform/modules/web_app/main.tf b/samples/web-app-custom-image/dotnet/terraform/modules/web_app/main.tf new file mode 100644 index 0000000..e1f3d26 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/web_app/main.tf @@ -0,0 +1,78 @@ +resource "azurerm_linux_web_app" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + service_plan_id = var.service_plan_id + https_only = var.https_only + virtual_network_subnet_id = var.virtual_network_subnet_id + public_network_access_enabled = var.public_network_access_enabled + client_affinity_enabled = false + tags = var.tags + + dynamic "identity" { + for_each = var.managed_identity_id != null ? [1] : [] + content { + type = "UserAssigned" + identity_ids = [var.managed_identity_id] + } + } + + 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 { + docker_image_name = "${var.image_name}:${var.image_tag}" + docker_registry_url = var.docker_registry_url + docker_registry_username = var.docker_registry_username + docker_registry_password = var.docker_registry_password + } + } + + app_settings = var.app_settings + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_linux_web_app.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "AppServiceHTTPLogs" + } + + enabled_log { + category = "AppServiceConsoleLogs" + } + + enabled_log { + category = "AppServiceAppLogs" + } + + enabled_log { + category = "AppServiceAuditLogs" + } + + enabled_log { + category = "AppServiceIPSecAuditLogs" + } + + enabled_log { + category = "AppServicePlatformLogs" + } + + enabled_log { + category = "AppServiceAuthenticationLogs" + } + + enabled_metric { + category = "AllMetrics" + } +} \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/web_app/outputs.tf b/samples/web-app-custom-image/dotnet/terraform/modules/web_app/outputs.tf new file mode 100644 index 0000000..d7b6981 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/web_app/outputs.tf @@ -0,0 +1,24 @@ +output "id" { + value = azurerm_linux_web_app.example.id + description = "Specifies the resource id of the Web App" +} + +output "name" { + value = azurerm_linux_web_app.example.name + description = "Specifies the name of the Web App" +} + +output "default_hostname" { + value = azurerm_linux_web_app.example.default_hostname + description = "Specifies the default hostname of the Web App" +} + +output "outbound_ip_addresses" { + value = azurerm_linux_web_app.example.outbound_ip_addresses + description = "Specifies the outbound IP addresses of the Web App" +} + +output "principal_id" { + value = azurerm_linux_web_app.example.identity[0].principal_id + description = "Specifies the Principal ID of the System Assigned Managed Identity" +} diff --git a/samples/web-app-custom-image/dotnet/terraform/modules/web_app/variables.tf b/samples/web-app-custom-image/dotnet/terraform/modules/web_app/variables.tf new file mode 100644 index 0000000..dad9182 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/modules/web_app/variables.tf @@ -0,0 +1,114 @@ +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 "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 "app_settings" { + description = "(Optional) A map of key-value pairs for App Settings." + type = map(string) + default = {} +} + +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 +} + +variable "image_name" { + description = "(Required) Specifies the name of the container image to deploy to the Web App." + type = string + default = "custom-image-webapp" +} + +variable "image_tag" { + description = "(Required) Specifies the tag of the container image to deploy to the Web App." + type = string + default = "v1" +} + +variable "docker_registry_url" { + description = "(Optional) Specifies the URL of the Docker registry where the container image is stored. This is required if the container image is stored in a private registry." + type = string + default = null +} + +variable "managed_identity_id" { + description = "(Optional) Specifies the ID of the user-assigned managed identity to be assigned to the Web App." + type = string + default = null +} + +variable "docker_registry_username" { + description = "Specifies the username of the Docker registry. This is required if the container image is stored in a private registry." + type = string + default = null +} + +variable "docker_registry_password" { + description = "Specifies the password of the Docker registry. This is required if the container image is stored in a private registry." + type = string + default = null +} \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/outputs.tf b/samples/web-app-custom-image/dotnet/terraform/outputs.tf new file mode 100644 index 0000000..a4d9b25 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/outputs.tf @@ -0,0 +1,23 @@ +output "resource_group_name" { + value = local.resource_group_name +} + +output "container_registry_name" { + value = module.container_registry.name +} + +output "container_registry_login_server" { + value = module.container_registry.login_server +} + +output "app_service_plan_name" { + value = module.app_service_plan.name +} + +output "web_app_name" { + value = module.web_app.name +} + +output "web_app_url" { + value = module.web_app.default_hostname +} \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/providers.tf b/samples/web-app-custom-image/dotnet/terraform/providers.tf new file mode 100644 index 0000000..1246b2e --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/providers.tf @@ -0,0 +1,28 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=5.1.0" + } + null = { + source = "hashicorp/null" + version = "~> 3.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 = "azure.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/web-app-custom-image/dotnet/terraform/push_image.sh b/samples/web-app-custom-image/dotnet/terraform/push_image.sh new file mode 100755 index 0000000..a86add2 --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/push_image.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +LOCAL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name "$ACR_NAME" --only-show-errors + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +echo "Building custom Docker image [$LOCAL_IMAGE]..." +docker build -t "$LOCAL_IMAGE" ../src/ + +if [ $? -eq 0 ]; then + echo "Docker image [$LOCAL_IMAGE] built successfully." +else + echo "Failed to build Docker image [$LOCAL_IMAGE]." + exit 1 +fi + +echo "Tagging Docker image [$LOCAL_IMAGE] as [$FULL_IMAGE]..." +docker tag "$LOCAL_IMAGE" "$FULL_IMAGE" + +if [ $? -eq 0 ]; then + echo "Docker image [$LOCAL_IMAGE] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$LOCAL_IMAGE] as [$FULL_IMAGE]." + exit 1 +fi + +echo "Pushing image [$FULL_IMAGE] to ACR..." +docker push "$FULL_IMAGE" + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + exit 1 +fi \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/terraform.tfvars b/samples/web-app-custom-image/dotnet/terraform/terraform.tfvars new file mode 100644 index 0000000..919af4f --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/terraform.tfvars @@ -0,0 +1,3 @@ +prefix = "local" +suffix = "test" +location = "westeurope" \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/terraform/variables.tf b/samples/web-app-custom-image/dotnet/terraform/variables.tf new file mode 100644 index 0000000..cd110df --- /dev/null +++ b/samples/web-app-custom-image/dotnet/terraform/variables.tf @@ -0,0 +1,251 @@ +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 "acr_sku" { + description = "Specifies the name of the container registry" + type = string + default = "Premium" + + validation { + condition = contains(["Basic", "Standard", "Premium"], var.acr_sku) + error_message = "The container registry sku is invalid." + } +} + +variable "acr_admin_enabled" { + description = "Specifies whether admin is enabled for the container registry" + type = bool + default = true +} + +variable "acr_georeplication_locations" { + description = "(Optional) A list of Azure locations where the container registry should be geo-replicated." + type = list(string) + default = [] +} + +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 "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 "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" + iac = "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 "websites_port" { + description = "(Optional) Specifies the port on which the Web App will listen. Defaults to 8000." + type = number + default = 80 +} + +variable "image_name" { + description = "(Required) Specifies the name of the container image to deploy to the Web App." + type = string + default = "custom-image-webapp" +} + +variable "image_tag" { + description = "(Required) Specifies the tag of the container image to deploy to the Web App." + type = string + default = "v1" +} \ No newline at end of file diff --git a/samples/web-app-custom-image/dotnet/visio/web-app-custom-image.vsdx b/samples/web-app-custom-image/dotnet/visio/web-app-custom-image.vsdx new file mode 100644 index 0000000..bd6c655 Binary files /dev/null and b/samples/web-app-custom-image/dotnet/visio/web-app-custom-image.vsdx differ diff --git a/samples/web-app-custom-image/python/scripts/call-web-app.sh b/samples/web-app-custom-image/python/scripts/call-web-app.sh index 9189998..0c77196 100755 --- a/samples/web-app-custom-image/python/scripts/call-web-app.sh +++ b/samples/web-app-custom-image/python/scripts/call-web-app.sh @@ -6,17 +6,6 @@ SUFFIX='test' RESOURCE_GROUP_NAME="${PREFIX}-rg" WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" -get_docker_container_name_by_prefix() { - local app_prefix="$1" - docker ps --format "{{.Names}}" | grep "^${app_prefix}" | head -1 -} - -get_docker_container_port_mapping() { - local container_name="$1" - local container_port="$2" - docker inspect -f "{{(index (index .NetworkSettings.Ports \"${container_port}/tcp\") 0).HostPort}}" "$container_name" -} - APP_HOST_NAME=$(az webapp show \ --name "$WEB_APP_NAME" \ --resource-group "$RESOURCE_GROUP_NAME" \ diff --git a/samples/web-app-custom-image/python/terraform/README.md b/samples/web-app-custom-image/python/terraform/README.md index c8a1c18..c607381 100644 --- a/samples/web-app-custom-image/python/terraform/README.md +++ b/samples/web-app-custom-image/python/terraform/README.md @@ -78,7 +78,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/web-app-custom-image/python/terraform/providers.tf b/samples/web-app-custom-image/python/terraform/providers.tf index 873ef7f..1246b2e 100644 --- a/samples/web-app-custom-image/python/terraform/providers.tf +++ b/samples/web-app-custom-image/python/terraform/providers.tf @@ -21,7 +21,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/web-app-managed-identity/dotnet/README.md b/samples/web-app-managed-identity/dotnet/README.md new file mode 100644 index 0000000..884eb97 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/README.md @@ -0,0 +1,120 @@ +# Azure Web App with Managed Identity + +This sample demonstrates an ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in an `activities` container within [Azure Blob Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction). The web app uses a user-assigned or system-assigned managed identity to access storage. + +A managed identity from Microsoft Entra ID allows your app to easily access other Microsoft Entra-protected resources, such as Azure Key Vault. The Azure platform manages the identity, so you don't need to provision or rotate any secrets. For more information about managed identities in Microsoft Entra ID, see [Managed identities for Azure resources](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview). + +You can configure the Azure Web App to use two types of managed identities: + +- A **system-assigned identity** is tied to the application and is deleted when the application is deleted. An application can have only one system-assigned identity. +- A **user-assigned identity** is a standalone Azure resource that can be assigned to your application. An application can have multiple user-assigned identities. A single user-assigned identity can be assigned to multiple Azure resources, such as multiple App Service applications. + +For more information on how to create a managed identity for Azure App Service and Azure Functions applications, and how to use it to access other resources, see [Use managed identities for App Service and Azure Functions](https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity). + +## Architecture + +The following diagram illustrates the architecture of the solution: + +![Architecture Diagram](./images/architecture.png) + +- **Azure Web App**: Hosts the ASP.NET Core application +- **Azure App Service Plan**: Provides compute resources for the web app +- **Azure Blob Storage**: Stores activity data as blobs in a container +- **Azure Entra Tenant**: Issues security tokens via OAuth 2.0 + +## Security + +This sample demonstrates how to configure an [Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-provider-aad?tabs=workforce-configuration), specifically a Web App, to use either a user-assigned identity or a system-assigned identity to acquire a security token from [Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/what-is-entra) for accessing downstream services such as Azure Blob Storage. You must configure the target resource to allow access from your app. For most Azure services, configure the target resource by [creating a role assignment](https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-steps) for the user-assigned or system-assigned managed identity used by the application via Azure role-based access control (Azure RBAC). For more information, see [What is Azure RBAC?](https://learn.microsoft.com/en-us/azure/role-based-access-control/overview) + +Some services use mechanisms other than Azure role-based access control. To understand how to configure access using an identity, refer to the Azure documentation for each target resource. To learn more about which resources support Microsoft Entra tokens, see [Azure services that support Microsoft Entra authentication](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/services-support-managed-identities#azure-services-that-support-azure-ad-authentication). + +For example, if you [request a token](https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#connect-to-azure-services-in-app-code) to access a secret in Azure Key Vault, you must also create a role assignment that allows the managed identity to work with secrets in the target vault. Otherwise, Key Vault will reject your calls even if you use a valid token. The same is true for Azure SQL Database and other Azure services. + +In this sample, the provisioning process assigns the [Storage Blob Data Contributor](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-blob-data-contributor) built-in role to the managed identity used by the web app, with the demo storage account as the scope. This ensures the managed identity has the proper permissions to allow the application code to read and write blobs in the target container. + +The LocalStack emulator emulates the following services, which are necessary at provisioning time and runtime: + +- **Microsoft Entra Tenant**: This REST API is responsible for issuing a security token to the application to access the target service. For more information, see [Microsoft identity platform and OAuth 2.0 authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow). +- **Microsoft Graph REST API**: Microsoft Graph is the gateway to Microsoft cloud services like Microsoft Entra and Microsoft 365. In particular, it provides access to [service principals](https://learn.microsoft.com/en-us/graph/api/resources/serviceprincipal?view=graph-rest-1.0) used by applications via [workload identities](https://learn.microsoft.com/en-us/entra/workload-id/workload-identities-overview). In Microsoft Entra, workload identities are applications, service principals, and managed identities. +- **Azure Role-Based Access Control (RBAC)**: Azure role-based access control (Azure RBAC) helps you manage who has access to Azure resources, what they can do with those resources, and what areas they have access to. LocalStack for Azure fully supports and mocks built-in [role definitions](https://learn.microsoft.com/en-us/azure/role-based-access-control/role-definitions), custom role definitions, and [role assignments](https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments), but does not enforce or check permissions. + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/) +- [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/) to obtain your Auth Token and set it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure). To pull the image, execute: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator by running: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +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 with both user-assigned and system-assigned managed identities 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 + +1. Retrieve the port published and mapped to port 80 by the Docker container hosting the emulated Web App. +2. Open a web browser and navigate to `http://localhost:`. +3. If the deployment was successful, you will see the following user interface for adding and removing activities: + +![Architecture Diagram](./images/vacation-planner.png) + +You can use the `call-web-app.sh` Bash script below to call the web app. The script demonstrates three methods for calling web apps: + +1. **Through the LocalStack for Azure emulator**: Call the web app via the emulator using its default host name. The emulator acts as a proxy to the web app. +2. **Via localhost and host port mapped to the container's port**: Use `127.0.0.1` with the host port mapped to the container's port `80`. +3. **Via container IP address**: Use the app container's IP address on port `80`. This technique is only available when accessing the web app from the Docker host machine. +4. **Via the default hostname**: Call the web app via the default hostname `.azurewebsites.azure.localhost.localstack.cloud:4566`. + +## Storage Contents + +You can use [Azure Storage Explorer](https://learn.microsoft.com/en-us/azure/storage/storage-explorer/vs-azure-tools-storage-manage-with-storage-explorer) to confirm that your Azure Web App creates the blobs in the `activities` container in the emulated storage account. To do this: + +- Expand **Emulator & Attached**. +- Right-click **Storage Accounts**. +- Select **Connect to Azure Storage...**. +- In the dialog that appears, enter the name of the emulated storage account and specify the published ports, as shown in the following picture: + +![Azure Storage Explorer](./images/azure-storage-explorer.png) + +> **Note** +> When sending commands to an emulated storage account, make sure to use the primary key generated by the emulator itself. For convenience, if you're connecting to the storage account directly with Azure Storage Explorer, you can use the default Azurite password. For more details, see [Connect to Azurite with SDKs and tools](https://learn.microsoft.com/en-us/azure/storage/common/storage-connect-azurite). + +## References + +- [Azure Web Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [What is Azure Blob storage?](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-overview) +- [What is managed identities for Azure resources?](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) +- [How managed identities for Azure resources work with Azure virtual machines](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-managed-identities-work-vm) +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-managed-identity/dotnet/bicep/README.md b/samples/web-app-managed-identity/dotnet/bicep/README.md new file mode 100644 index 0000000..23f67a5 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/bicep/README.md @@ -0,0 +1,195 @@ +# Bicep Deployment + +This directory contains the Bicep template and a deployment script for provisioning Azure services in LocalStack for Azure. Refer to the [Azure Web App with Managed Identity](../README.md) guide for details about the sample application. + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [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 +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0): Required to build and run the ASP.NET Core web application locally +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## 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 [main.bicep](main.bicep) Bicep module creates the following Azure resources: + +1. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides blob storage for persisting vacation activity data. The web application stores each activity as a JSON blob file in the `activities` container. +2. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): Defines the compute resources (CPU, memory, and scaling options) that host the web application. +3. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the ASP.NET Core *Vacation Planner* application. The web app uses managed identity to securely access the Azure Storage Account without requiring explicit credentials. +4. [Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): Provides secure, credential-free authentication between the web app and storage account. Supports both system-assigned and user-assigned identity types. +5. [Role Assignment](https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments): Grants the web app's managed identity the *Storage Blob Data Contributor* role, enabling read/write access to blob containers. +6. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Enables continuous deployment from a Git repository for automated application updates. + +The web app allows users to plan and manage vacation activities, storing all activity data as blob files in the `activities` containers in the [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview). For more information, see [Azure Web App with Managed Identity](../README.md). + +## Provisioning Scripts + +See [deploy.sh](deploy.sh) for the complete deployment script. The script performs the following operations: + +- Detects environment (LocalStack or Azure Cloud) and selects appropriate CLI +- Creates resource group if it doesn't exist +- Validates Bicep template syntax and parameters +- Optionally runs what-if deployment for preview +- Deploys Bicep template to create all Azure resources +- Extracts deployment outputs (resource names, URLs) using jq +- Packages web application code into zip file +- Deploys zip package to Azure Web App +- Cleans up temporary artifacts + +## 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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `bicep` folder: + +```bash +cd samples/web-app-managed-identity/dotnet/bicep +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +CONTAINER_NAME='activities' +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" + +# 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 Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,State:state,Location:location,DefaultHostName:defaultHostName}' \ + --output table \ + --only-show-errors + +# Check user-assigned managed identity +echo -e "\n[$MANAGED_IDENTITY_NAME] managed identity:\n" +az identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,ClientId:clientId,PrincipalId:principalId}' \ + --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:location,ResourceGroup:resourceGroup,Kind:kind,Sku:sku.name}' \ + --output table \ + --only-show-errors + +# List storage containers +echo -e "\n[$STORAGE_ACCOUNT_NAME] storage containers:\n" +az storage container list \ + --account-name "$STORAGE_ACCOUNT_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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-managed-identity/dotnet/bicep/deploy.sh b/samples/web-app-managed-identity/dotnet/bicep/deploy.sh new file mode 100755 index 0000000..6ce9027 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/bicep/deploy.sh @@ -0,0 +1,153 @@ +#!/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="webapp_app.zip" +MANAGED_IDENTITY_TYPE="UserAssigned" # SystemAssigned or UserAssigned + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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 \ + managedIdentityType=$MANAGED_IDENTITY_TYPE \ + --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 \ + managedIdentityType=$MANAGED_IDENTITY_TYPE \ + --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 \ + managedIdentityType=$MANAGED_IDENTITY_TYPE \ + --query 'properties.outputs' -o json); then + echo "Bicep template [$TEMPLATE] deployed successfully. Outputs:" + echo "$DEPLOYMENT_OUTPUTS" | jq . + APP_SERVICE_PLAN_NAME=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r '.appServicePlanName.value') + WEB_APP_NAME=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r '.webAppName.value') + WEB_APP_URL=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r '.webAppUrl.value') + STORAGE_ACCOUNT_NAME=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r '.storageAccountName.value') + echo "Deployment details:" + echo "- appServicePlanName: $APP_SERVICE_PLAN_NAME" + echo "- webAppName: $WEB_APP_NAME" + echo "- webAppUrl: $WEB_APP_URL" + echo "- storageAccountName: $STORAGE_ACCOUNT_NAME" +else + echo "Failed to deploy Bicep template [$TEMPLATE]" + exit 1 +fi + +# Validation before deploying the web app +if [[ -z "$WEB_APP_NAME" ]]; then + echo "Web App Name is empty. Exiting." + exit 1 +fi + +# CD into the web app directory +cd ../src || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi diff --git a/samples/web-app-managed-identity/dotnet/bicep/main.bicep b/samples/web-app-managed-identity/dotnet/bicep/main.bicep new file mode 100644 index 0000000..74d8580 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/bicep/main.bicep @@ -0,0 +1,275 @@ +@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 appServicePlanZoneRedundant bool = false + +@description('Specifies the language runtime used by the Azure Web App.') +@allowed([ + 'dotnet' + 'dotnet-isolated' + 'dotnetcore' + 'python' + 'java' + 'node' + 'powerShell' + 'custom' +]) +param runtimeName string + +@description('Specifies the target language version used by the Azure Web 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 webAppKind string = 'app,linux' + +@description('Specifies whether HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = false + +@description('Specifies the minimum TLS version for the Azure Web 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 the optional Git Repo URL.') +param repoUrl string = '' + +@description('Specifies the tags to be applied to the resources.') +param tags object = { + environment: 'test' + iac: 'bicep' +} + +@description('Specifies the sku of the Azure Storage account.') +param storageAccountSku string = 'Standard_LRS' + +@description('Specifies the name of the blob container.') +param containerName string = 'activities' + +@description('Specifies the type of managed identity.') +@allowed([ + 'SystemAssigned' + 'UserAssigned' +]) +param managedIdentityType string = 'SystemAssigned' + +var webAppName = '${prefix}-webapp-${suffix}' +var appServicePlanName = '${prefix}-app-service-plan-${suffix}' +var storageAccountName = '${prefix}storage${suffix}' +var managedIdentityName = '${prefix}-identity-${suffix}' + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = if (managedIdentityType == 'UserAssigned') { + name: managedIdentityName + location: location + tags: tags +} + +resource storageBlobDataContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + scope: subscription() +} + +resource storageBlobDataOwnerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, webApp.id, storageBlobDataContributorRoleDefinition.id) + scope: storageAccount + properties: { + roleDefinitionId: storageBlobDataContributorRoleDefinition.id + principalId: managedIdentityType == 'SystemAssigned' ? webApp.identity.principalId : managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = { + name: storageAccountName + location: location + tags: tags + sku: { + name: storageAccountSku + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + } +} + +resource blobServices 'Microsoft.Storage/storageAccounts/blobServices@2025-01-01' = { + parent: storageAccount + name: 'default' +} + +resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-01-01' = { + parent: blobServices + name: containerName +} + +resource appServicePlan 'Microsoft.Web/serverfarms@2024-11-01' = { + name: appServicePlanName + location: location + tags: tags + kind: appServicePlanKind + sku: { + tier: skuTier + name: skuName + } + properties: { + reserved: reserved + zoneRedundant: appServicePlanZoneRedundant + maximumElasticWorkerCount: skuTier == 'FlexConsumption' ? 1 : 20 + } +} + +resource webApp 'Microsoft.Web/sites@2024-11-01' = { + name: webAppName + location: location + tags: tags + kind: webAppKind + properties: { + httpsOnly: httpsOnly + serverFarmId: appServicePlan.id + siteConfig: { + linuxFxVersion: toUpper('${runtimeName}|${runtimeVersion}') + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + } + } + identity: { + type: managedIdentityType + userAssignedIdentities : managedIdentityType == 'SystemAssigned' ? null : { + '${managedIdentity.id}': {} + } + } +} + +resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = { + parent: webApp + name: 'appsettings' + properties: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + CONTAINER_NAME: container.name + AZURE_STORAGE_ACCOUNT_URL: storageAccount.properties.primaryEndpoints.blob + AZURE_CLIENT_ID: managedIdentityType == 'SystemAssigned' ? '' : managedIdentity.properties.clientId + } + dependsOn: [ + storageBlobDataOwnerRoleAssignment + ] +} + +resource webAppSourceControl 'Microsoft.Web/sites/sourcecontrols@2024-11-01' = if (contains(repoUrl,'http')){ + name: 'web' + parent: webApp + properties: { + repoUrl: repoUrl + branch: 'master' + isManualIntegration: true + } +} + +output appServicePlanName string = appServicePlan.name +output webAppName string = webApp.name +output webAppUrl string = webApp.properties.defaultHostName +output storageAccountName string = storageAccountName diff --git a/samples/web-app-managed-identity/dotnet/bicep/main.bicepparam b/samples/web-app-managed-identity/dotnet/bicep/main.bicepparam new file mode 100644 index 0000000..d86b021 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/bicep/main.bicepparam @@ -0,0 +1,6 @@ +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'dotnetcore' +param runtimeVersion = '10.0' diff --git a/samples/web-app-managed-identity/dotnet/images/architecture.png b/samples/web-app-managed-identity/dotnet/images/architecture.png new file mode 100644 index 0000000..a9bb4f2 Binary files /dev/null and b/samples/web-app-managed-identity/dotnet/images/architecture.png differ diff --git a/samples/web-app-managed-identity/dotnet/images/azure-storage-explorer.png b/samples/web-app-managed-identity/dotnet/images/azure-storage-explorer.png new file mode 100644 index 0000000..07a6a10 Binary files /dev/null and b/samples/web-app-managed-identity/dotnet/images/azure-storage-explorer.png differ diff --git a/samples/web-app-managed-identity/dotnet/images/vacation-planner.png b/samples/web-app-managed-identity/dotnet/images/vacation-planner.png new file mode 100644 index 0000000..a7c5151 Binary files /dev/null and b/samples/web-app-managed-identity/dotnet/images/vacation-planner.png differ diff --git a/samples/web-app-managed-identity/dotnet/scripts/README.md b/samples/web-app-managed-identity/dotnet/scripts/README.md new file mode 100644 index 0000000..5c2e69e --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/README.md @@ -0,0 +1,210 @@ +# Azure CLI Deployment + +This directory includes Bash scripts designed for deploying and testing the sample Web App utilizing the `lstk` CLI. Refer to the [Azure Web App with Managed Identity](../README.md) guide for details about the sample application. + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0): Required to build and run the ASP.NET Core web application locally +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## Architecture Overview + +The [user-assigned.sh](user-assigned.sh) and [system-assigned.sh](system-assigned.sh) Bash scripts create the following Azure resources using Azure CLI commands: + +1. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides blob storage for persisting vacation activity data. The web application stores each activity as a JSON blob file in the `activities` container. +2. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): Defines the compute resources (CPU, memory, and scaling options) that host the web application. +3. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the ASP.NET Core *Vacation Planner* application. The web app uses managed identity to securely access the Azure Storage Account without requiring explicit credentials. +4. [Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): Provides secure, credential-free authentication between the web app and storage account. Supports both system-assigned and user-assigned identity types. +5. [Role Assignment](https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments): Grants the web app's managed identity the *Storage Blob Data Contributor* role, enabling read/write access to blob containers. +6. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Enables continuous deployment from a Git repository for automated application updates. + +The web app allows users to plan and manage vacation activities, storing all activity data as blob files in the `activities` containers in the [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview). For more information, see [Azure Web App with Managed Identity](../README.md). + +## Provisioning Scripts + +## Automation Scripts + +This sample provides two bash scripts to streamline the deployment process by automating the provisioning of Azure resources and the sample application: + +- [user-assigned.sh](user-assigned.sh): Configures the Azure Web App with a *user-assigned managed identity* +- [system-assigned.sh](system-assigned.sh): Configures the Azure Web App with a *system-assigned managed identity* + +See the script files for complete implementation. The scripts perform the following operations: + +- Detect environment (LocalStack or Azure Cloud) and select appropriate CLI +- Create resource group if it doesn't exist +- Provision storage account and retrieve access keys and endpoints +- Create blob container for activity data +- Create App Service Plan with Linux runtime +- Create user-assigned managed identity (user-assigned script only) +- Retrieve identity client ID, principal ID, and resource ID +- Create web app with the .NET (DOTNETCORE) runtime +- Assign managed identity to web app +- Configure Storage Blob Data Contributor role assignment with retry logic +- Set web app configuration settings (storage URL, container name, client ID) +- Package application code into zip file +- Deploy zip package to Azure Web App +- Clean up temporary artifacts + +These scripts eliminate manual configuration steps and enable one-command deployment of the entire infrastructure. + +> [!NOTE] +> You can use `lstk az` to proxy Azure CLI commands to the LocalStack for Azure emulator. Alternatively, run `lstk az start-interception` to automatically intercept and redirect all `az` commands to LocalStack. To revert back to the default behavior and send commands to the Azure cloud, run `lstk az stop-interception`. + +## 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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `scripts` folder: + +```bash +cd samples/web-app-managed-identity/dotnet/scripts +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +CONTAINER_NAME='activities' +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" + +# 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 Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,State:state,Location:location,DefaultHostName:defaultHostName}' \ + --output table \ + --only-show-errors + +# Check user-assigned managed identity +echo -e "\n[$MANAGED_IDENTITY_NAME] managed identity:\n" +az identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,ClientId:clientId,PrincipalId:principalId}' \ + --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:location,ResourceGroup:resourceGroup,Kind:kind,Sku:sku.name}' \ + --output table \ + --only-show-errors + +# List storage containers +echo -e "\n[$STORAGE_ACCOUNT_NAME] storage containers:\n" +az storage container list \ + --account-name "$STORAGE_ACCOUNT_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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-managed-identity/dotnet/scripts/api.sh b/samples/web-app-managed-identity/dotnet/scripts/api.sh new file mode 100755 index 0000000..71c0deb --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/api.sh @@ -0,0 +1,153 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +NEW_LOCATION='northeurope' +RANDOM_SUFFIX=$(head /dev/urandom | tr -dc 'a-z0-9' | head -c 4) +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}-${RANDOM_SUFFIX}" +RESOURCE_GROUP_NAME="${PREFIX}-rg" +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) +SUBSCRIPTION_ID=$(az account show --query id --output tsv) +PROXY_PORT=$(curl --max-time 10 http://localhost:4566/_localstack/proxy -s | jq '.proxy_port') +SUB_BASE_URL="https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/providers/Microsoft.ManagedIdentity/userAssignedIdentities" +RG_BASE_URL="https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP_NAME/providers/Microsoft.ManagedIdentity/userAssignedIdentities" +ENVIRONMENT=$(az account show --query environmentName --output tsv) +API_VERSION="2024-11-30" + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + CURL="env http_proxy=http://127.0.0.1:$PROXY_PORT https_proxy=http://127.0.0.1:$PROXY_PORT curl --max-time 10 -k -s" +else + CURL="curl --max-time 10 -s" +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" \ + --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 + +# Get security token +TOKEN=$(az account get-access-token --resource=https://management.azure.com/ --query accessToken --output tsv) + +# Create a new user-assigned managed identity +echo "Creating user-assigned managed identity [$MANAGED_IDENTITY_NAME]..." +$CURL \ + -X PUT \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "location": "'"$LOCATION"'", + "tags": { + "environment": "test", + "mode": "REST API" + } + }' \ + "$RG_BASE_URL/$MANAGED_IDENTITY_NAME?api-version=$API_VERSION" | jq -r . + + +if [ $? -eq 0 ]; then + echo "User-assigned managed identity [$MANAGED_IDENTITY_NAME] created successfully." +else + echo "Failed to create user-assigned managed identity [$MANAGED_IDENTITY_NAME]." + exit 1 +fi + +# Get the user-assigned managed identity +echo "Retrieving user-assigned managed identity [$MANAGED_IDENTITY_NAME]..." +$CURL \ + -X GET \ + -H "Authorization: Bearer $TOKEN" \ + "$RG_BASE_URL/$MANAGED_IDENTITY_NAME?api-version=$API_VERSION" | jq -r . + +if [ $? -eq 0 ]; then + echo "User-assigned managed identity [$MANAGED_IDENTITY_NAME] retrieved successfully." +else + echo "Failed to retrieve user-assigned managed identity [$MANAGED_IDENTITY_NAME]." + exit 1 +fi + +# List all user-assigned managed identities in the resource group +echo "Listing all user-assigned managed identities in resource group [$RESOURCE_GROUP_NAME]..." +$CURL \ + -X GET \ + -H "Authorization: Bearer $TOKEN" \ + "$RG_BASE_URL?api-version=$API_VERSION" | jq -r . + +if [ $? -eq 0 ]; then + echo "User-assigned managed identities listed successfully." +else + echo "Failed to list user-assigned managed identities." + exit 1 +fi + +# List all user-assigned managed identities in the subscription +echo "Listing all user-assigned managed identities in the subscription..." +$CURL \ + -X GET \ + -H "Authorization: Bearer $TOKEN" \ + "$SUB_BASE_URL?api-version=$API_VERSION" | jq -r . + +if [ $? -eq 0 ]; then + echo "User-assigned managed identities in the subscription listed successfully." +else + echo "Failed to list user-assigned managed identities in the subscription." + exit 1 +fi + +# Update the user-assigned managed identity +echo "Updating user-assigned managed identity [$MANAGED_IDENTITY_NAME]..." +$CURL \ + -X PATCH \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "location": "'"$NEW_LOCATION"'", + "tags": { + "environment": "LocalStack", + "mode": "Azure REST API" + } + }' \ + "$RG_BASE_URL/$MANAGED_IDENTITY_NAME?api-version=$API_VERSION" | jq -r . + + +if [ $? -eq 0 ]; then + echo "User-assigned managed identity [$MANAGED_IDENTITY_NAME] updated successfully." +else + echo "Failed to update user-assigned managed identity [$MANAGED_IDENTITY_NAME]." + exit 1 +fi + +# Delete the user-assigned managed identity +echo "Deleting user-assigned managed identity [$MANAGED_IDENTITY_NAME]..." +$CURL \ + -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + "$RG_BASE_URL/$MANAGED_IDENTITY_NAME?api-version=$API_VERSION" >/dev/null + +if [ $? -eq 0 ]; then + echo "User-assigned managed identity [$MANAGED_IDENTITY_NAME] deleted successfully." +else + echo "Failed to delete user-assigned managed identity [$MANAGED_IDENTITY_NAME]." + exit 1 +fi \ No newline at end of file diff --git a/samples/web-app-managed-identity/dotnet/scripts/call-web-app.sh b/samples/web-app-managed-identity/dotnet/scripts/call-web-app.sh new file mode 100755 index 0000000..a82964c --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/call-web-app.sh @@ -0,0 +1,199 @@ +#!/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_web_app() { + # Get the web app name + echo "Getting web app name..." + web_app_name=$(az webapp list --query '[0].name' --output tsv) + + if [ -n "$web_app_name" ]; then + echo "Web app [$web_app_name] successfully retrieved." + else + echo "Error: No web app found" + exit 1 + fi + + # Get the resource group name + echo "Getting resource group name for web app [$web_app_name]..." + resource_group_name=$(az webapp 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 web app [$web_app_name]" + exit 1 + fi + + # Get the the default host name of the web app + echo "Getting the default host name of the web app [$web_app_name]..." + app_host_name=$(az webapp show \ + --name "$web_app_name" \ + --resource-group "$resource_group_name" \ + --query 'defaultHostName' \ + --output tsv) + + if [ -n "$app_host_name" ]; then + echo "Web app default host name [$app_host_name] successfully retrieved." + else + echo "Error: No web app default host name found" + exit 1 + fi + + # Get the Docker container name + echo "Finding container name with prefix [ls-$web_app_name]..." + container_name=$(get_docker_container_name_by_prefix "ls-$web_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") + + 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 web 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 --max-time 10 http://localhost:4566/_localstack/proxy -s | jq '.proxy_port') + + if [ -n "$proxy_port" ]; then + # Call the web app via emulator proxy + echo "Calling web app [$web_app_name] via emulator..." + curl --max-time 10 --proxy "http://localhost:$proxy_port/" -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via emulator proxy port [$proxy_port] succeeded." + else + echo "Web app call via emulator proxy port [$proxy_port] failed." + fi + else + echo "Failed to retrieve LocalStack proxy port" + fi + + if [ -n "$container_ip" ]; then + # Call the web app via the container IP address + echo "Calling web app [$web_app_name] via container IP address [$container_ip]..." + curl --max-time 10 -s "http://$container_ip/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via container IP address [$container_ip] succeeded." + else + echo "Web app call via container IP address [$container_ip] failed." + fi + else + echo "Failed to retrieve container IP address" + fi + + if [ -n "$host_port" ]; then + # Call the web app via the host port + echo "Calling web app [$web_app_name] via host port [$host_port]..." + curl --max-time 10 -s "http://127.0.0.1:$host_port/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via host port [$host_port] succeeded." + else + echo "Web app call via host port [$host_port] failed." + fi + else + echo "Failed to retrieve host port" + fi + + if [ -n "$app_host_name" ]; then + # Call the web app via the default hostname + echo "Calling web app [$web_app_name] via default hostname [$app_host_name]..." + curl --max-time 10 -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via default hostname [$app_host_name] succeeded." + else + echo "Web app call via default hostname [$app_host_name] failed." + fi + else + echo "Failed to retrieve web app hostname" + fi +} + +call_web_app \ No newline at end of file diff --git a/samples/web-app-managed-identity/dotnet/scripts/cli.sh b/samples/web-app-managed-identity/dotnet/scripts/cli.sh new file mode 100755 index 0000000..a72c1c5 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/cli.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RANDOM_SUFFIX=$(head /dev/urandom | tr -dc 'a-z0-9' | head -c 4) +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}-${RANDOM_SUFFIX}" +RESOURCE_GROUP_NAME="${PREFIX}-rg" +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) + +# 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" \ + --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 new user-assigned managed identity +echo "Creating user-assigned managed identity [$MANAGED_IDENTITY_NAME]..." +az identity create \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --tags environment="$ENVIRONMENT" \ + --only-show-errors + +if [ $? -eq 0 ]; then + echo "User-assigned managed identity [$MANAGED_IDENTITY_NAME] created successfully." +else + echo "Failed to create user-assigned managed identity [$MANAGED_IDENTITY_NAME]." + exit 1 +fi + +# Get the user-assigned managed identity +echo "Retrieving user-assigned managed identity [$MANAGED_IDENTITY_NAME]..." +az identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors + +if [ $? -eq 0 ]; then + echo "User-assigned managed identity [$MANAGED_IDENTITY_NAME] retrieved successfully." +else + echo "Failed to retrieve user-assigned managed identity [$MANAGED_IDENTITY_NAME]." + exit 1 +fi + +# List all user-assigned managed identities in the resource group +echo "Listing all user-assigned managed identities in resource group [$RESOURCE_GROUP_NAME]..." +az identity list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors + +if [ $? -eq 0 ]; then + echo "User-assigned managed identities listed successfully." +else + echo "Failed to list user-assigned managed identities." + exit 1 +fi + +# Delete the user-assigned managed identity +echo "Deleting user-assigned managed identity [$MANAGED_IDENTITY_NAME]..." +az identity delete \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors + +if [ $? -eq 0 ]; then + echo "User-assigned managed identity [$MANAGED_IDENTITY_NAME] deleted successfully." +else + echo "Failed to delete user-assigned managed identity [$MANAGED_IDENTITY_NAME]." + exit 1 +fi \ No newline at end of file diff --git a/samples/web-app-managed-identity/dotnet/scripts/system-assigned.sh b/samples/web-app-managed-identity/dotnet/scripts/system-assigned.sh new file mode 100755 index 0000000..a5dc656 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/system-assigned.sh @@ -0,0 +1,272 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="B1" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +RESOURCE_GROUP_NAME="${PREFIX}-rg" +RUNTIME="dotnetcore" +RUNTIME_VERSION="10.0" +CONTAINER_NAME='activities' +ZIPFILE="webapp_app.zip" +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +RETRY_COUNT=3 +SLEEP=5 + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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 \ + --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 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 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 + +# Get the storage account resource ID +STORAGE_ACCOUNT_RESOURCE_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_RESOURCE_ID" ]; then + echo "Storage account resource ID retrieved successfully: $STORAGE_ACCOUNT_RESOURCE_ID" +else + echo "Failed to retrieve storage account resource ID." + exit 1 +fi + +# Get the storage account blob primary endpoint +AZURE_STORAGE_ACCOUNT_URL=$(az storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "primaryEndpoints.blob" \ + --output tsv \ + --only-show-errors) + +if [ -n "$AZURE_STORAGE_ACCOUNT_URL" ]; then + echo "Storage account blob primary endpoint retrieved successfully: $AZURE_STORAGE_ACCOUNT_URL" +else + echo "Failed to retrieve storage account blob primary endpoint." + exit 1 +fi + +# Create blob container +echo "Creating blob container [$CONTAINER_NAME] in the [$STORAGE_ACCOUNT_NAME] storage account..." +az storage container create \ + --name $CONTAINER_NAME \ + --account-name $STORAGE_ACCOUNT_NAME \ + --account-key "$STORAGE_ACCOUNT_KEY" \ + --public-access blob 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Blob container [$CONTAINER_NAME] created successfully in the [$STORAGE_ACCOUNT_NAME] storage account." +else + echo "Failed to create blob container [$CONTAINER_NAME] in the [$STORAGE_ACCOUNT_NAME] storage account." + exit 1 +fi + +# Create App Service Plan +echo "Creating App Service Plan [$APP_SERVICE_PLAN_NAME]..." +az appservice plan create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --location "$LOCATION" \ + --sku "$APP_SERVICE_PLAN_SKU" \ + --is-linux \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "App Service Plan [$APP_SERVICE_PLAN_NAME] created successfully." +else + echo "Failed to create App Service Plan [$APP_SERVICE_PLAN_NAME]." + exit 1 +fi + +# Create the web app +echo "Creating web app [$WEB_APP_NAME]..." +az webapp create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --plan "$APP_SERVICE_PLAN_NAME" \ + --name "$WEB_APP_NAME" \ + --runtime "$RUNTIME:$RUNTIME_VERSION" \ + --assign-identity '[system]' \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Retrieve the principalId of the system-assigned managed identity +MANAGED_IDENTITY_PRINCIPAL_ID=$(az webapp identity show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query principalId \ + --output tsv) + +if [ -n "$MANAGED_IDENTITY_PRINCIPAL_ID" ]; then + echo "Principal ID of the system-assigned managed identity of the web app [$WEB_APP_NAME] retrieved successfully" +else + echo "Failed to retrieve principal ID of the system-assigned managed identity of the web app [$WEB_APP_NAME]." + exit 1 +fi + +# Assign the Storage Blob Data Contributor role to the managed identity with the storage account as scope +ROLE="Storage Blob Data Contributor" +echo "Checking if the managed identity with principal ID [$MANAGED_IDENTITY_PRINCIPAL_ID] has the [$ROLE] role assignment on storage account [$STORAGE_ACCOUNT_NAME]..." +current=$(az role assignment list \ + --assignee "$MANAGED_IDENTITY_PRINCIPAL_ID" \ + --scope "$STORAGE_ACCOUNT_RESOURCE_ID" \ + --query "[?roleDefinitionName=='$ROLE'].roleDefinitionName" \ + --output tsv 2>/dev/null) + +if [[ $current == $ROLE ]]; then + echo "Managed identity already has the [$ROLE] role assignment on storage account [$STORAGE_ACCOUNT_NAME]" +else + echo "Managed identity does not have the [$ROLE] role assignment on storage account [$STORAGE_ACCOUNT_NAME]" + echo "Creating role assignment: assigning [$ROLE] role to managed identity on storage account [$STORAGE_ACCOUNT_NAME]..." + ATTEMPT=1 + while [ $ATTEMPT -le $RETRY_COUNT ]; do + echo "Attempt $ATTEMPT of $RETRY_COUNT to assign role..." + az role assignment create \ + --assignee "$MANAGED_IDENTITY_PRINCIPAL_ID" \ + --role "$ROLE" \ + --scope "$STORAGE_ACCOUNT_RESOURCE_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 storage account [$STORAGE_ACCOUNT_NAME]" + else + echo "Failed to assign [$ROLE] role to managed identity on storage account [$STORAGE_ACCOUNT_NAME]" + exit + fi +fi + +# Set web app settings +echo "Setting web app settings for [$WEB_APP_NAME]..." +az webapp config appsettings set \ + --name $WEB_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --settings \ + SCM_DO_BUILD_DURING_DEPLOYMENT='true' \ + ENABLE_ORYX_BUILD='true' \ + CONTAINER_NAME="$CONTAINER_NAME" \ + AZURE_STORAGE_ACCOUNT_URL="$AZURE_STORAGE_ACCOUNT_URL" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app settings for [$WEB_APP_NAME] set successfully." +else + echo "Failed to set web app settings for [$WEB_APP_NAME]." + exit 1 +fi + +# CD into the web app directory +cd ../src || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi diff --git a/samples/web-app-managed-identity/dotnet/scripts/user-assigned.sh b/samples/web-app-managed-identity/dotnet/scripts/user-assigned.sh new file mode 100755 index 0000000..a318226 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/user-assigned.sh @@ -0,0 +1,356 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="B1" +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +RESOURCE_GROUP_NAME="${PREFIX}-rg" +RUNTIME="dotnetcore" +RUNTIME_VERSION="10.0" +CONTAINER_NAME='activities' +ZIPFILE="webapp_app.zip" +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) +SUBSCRIPTION_ID=$(az account show --query id --output tsv) +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +RETRY_COUNT=3 +SLEEP=5 + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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" \ + --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 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 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 + +# Get the storage account resource ID +STORAGE_ACCOUNT_RESOURCE_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_RESOURCE_ID" ]; then + echo "Storage account resource ID retrieved successfully: $STORAGE_ACCOUNT_RESOURCE_ID" +else + echo "Failed to retrieve storage account resource ID." + exit 1 +fi + +# Get the storage account blob primary endpoint +AZURE_STORAGE_ACCOUNT_URL=$(az storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "primaryEndpoints.blob" \ + --output tsv \ + --only-show-errors) + +if [ -n "$AZURE_STORAGE_ACCOUNT_URL" ]; then + echo "Storage account blob primary endpoint retrieved successfully: $AZURE_STORAGE_ACCOUNT_URL" +else + echo "Failed to retrieve storage account blob primary endpoint." + exit 1 +fi + +# Create blob container +echo "Creating blob container [$CONTAINER_NAME] in the [$STORAGE_ACCOUNT_NAME] storage account..." +az storage container create \ + --name $CONTAINER_NAME \ + --account-name $STORAGE_ACCOUNT_NAME \ + --account-key "$STORAGE_ACCOUNT_KEY" \ + --public-access blob 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Blob container [$CONTAINER_NAME] created successfully in the [$STORAGE_ACCOUNT_NAME] storage account." +else + echo "Failed to create blob container [$CONTAINER_NAME] in the [$STORAGE_ACCOUNT_NAME] storage account." + exit 1 +fi + +# Check if the App Service Plan already exists +echo "Checking if App Service Plan [$APP_SERVICE_PLAN_NAME] exists in the resource group [$RESOURCE_GROUP_NAME]..." +az appservice plan show \ + --name $APP_SERVICE_PLAN_NAME \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No App Service Plan [$APP_SERVICE_PLAN_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group." + # Create App Service Plan + echo "Creating App Service Plan [$APP_SERVICE_PLAN_NAME]..." + az appservice plan create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --location "$LOCATION" \ + --sku "$APP_SERVICE_PLAN_SKU" \ + --is-linux \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "App Service Plan [$APP_SERVICE_PLAN_NAME] created successfully." + else + echo "Failed to create App Service Plan [$APP_SERVICE_PLAN_NAME]." + exit 1 + fi +else + echo "App Service Plan [$APP_SERVICE_PLAN_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group." +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" 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 + +# Check if the web app already exists +echo "Checking if web app [$WEB_APP_NAME] exists in the resource group [$RESOURCE_GROUP_NAME]..." +az webapp show \ + --name $WEB_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No web app [$WEB_APP_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group." + # Create the web app + echo "Creating web app [$WEB_APP_NAME]..." + az webapp create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --plan "$APP_SERVICE_PLAN_NAME" \ + --name "$WEB_APP_NAME" \ + --runtime "$RUNTIME:$RUNTIME_VERSION" \ + --assign-identity "${IDENTITY_ID}" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." + else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 + fi +else + echo "Web app [$WEB_APP_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group." +fi + +# Assign the Storage Blob Data Contributor role to the managed identity with the storage account as scope +ROLE="Storage Blob Data Contributor" +echo "Checking if the managed identity with principal ID [$PRINCIPAL_ID] has the [$ROLE] role assignment on storage account [$STORAGE_ACCOUNT_NAME]..." +current=$(az role assignment list \ + --assignee "$PRINCIPAL_ID" \ + --scope "$STORAGE_ACCOUNT_RESOURCE_ID" \ + --query "[?roleDefinitionName=='$ROLE'].roleDefinitionName" \ + --output tsv 2>/dev/null) + +if [[ $current == $ROLE ]]; then + echo "Managed identity already has the [$ROLE] role assignment on storage account [$STORAGE_ACCOUNT_NAME]" +else + echo "Managed identity does not have the [$ROLE] role assignment on storage account [$STORAGE_ACCOUNT_NAME]" + echo "Creating role assignment: assigning [$ROLE] role to managed identity on storage account [$STORAGE_ACCOUNT_NAME]..." + 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 "$STORAGE_ACCOUNT_RESOURCE_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 storage account [$STORAGE_ACCOUNT_NAME]" + else + echo "Failed to assign [$ROLE] role to managed identity on storage account [$STORAGE_ACCOUNT_NAME]" + exit + fi +fi + +# Set web app settings +echo "Setting web app settings for [$WEB_APP_NAME]..." +az webapp config appsettings set \ + --name $WEB_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --settings \ + SCM_DO_BUILD_DURING_DEPLOYMENT='true' \ + ENABLE_ORYX_BUILD='true' \ + AZURE_CLIENT_ID="$CLIENT_ID" \ + AZURE_STORAGE_ACCOUNT_URL="$AZURE_STORAGE_ACCOUNT_URL" \ + CONTAINER_NAME="$CONTAINER_NAME" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app settings for [$WEB_APP_NAME] set successfully." +else + echo "Failed to set web app settings for [$WEB_APP_NAME]." + exit 1 +fi + +# CD into the web app directory +cd ../src || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi diff --git a/samples/web-app-managed-identity/dotnet/scripts/validate.sh b/samples/web-app-managed-identity/dotnet/scripts/validate.sh new file mode 100755 index 0000000..67198f8 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/validate.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +CONTAINER_NAME='activities' +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" + +# 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 Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,State:state,Location:location,DefaultHostName:defaultHostName}' \ + --output table \ + --only-show-errors + +# Check user-assigned managed identity +echo -e "\n[$MANAGED_IDENTITY_NAME] managed identity:\n" +az identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,ClientId:clientId,PrincipalId:principalId}' \ + --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:location,ResourceGroup:resourceGroup,Kind:kind,Sku:sku.name}' \ + --output table \ + --only-show-errors + +# List storage containers +echo -e "\n[$STORAGE_ACCOUNT_NAME] storage containers:\n" +az storage container list \ + --account-name "$STORAGE_ACCOUNT_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 diff --git a/samples/web-app-managed-identity/dotnet/src/Models/Activity.cs b/samples/web-app-managed-identity/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..fb0b87d --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id)) + { + await store.DeleteAsync(id, cancellationToken); + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted successfully."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml b/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..cd0fd06 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + await store.UpdateAsync(id, text, cancellationToken); + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated successfully."; + } + else + { + await store.AddAsync(text, cancellationToken); + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added successfully."; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-managed-identity/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-managed-identity/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-managed-identity/dotnet/src/Program.cs b/samples/web-app-managed-identity/dotnet/src/Program.cs new file mode 100644 index 0000000..1da1285 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Program.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Code deployments built by Oryx export ASPNETCORE_URLS; custom images and local runs only set PORT. +if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") is null + && Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseUrls($"http://*:{port}"); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storageOptions = BlobStorageOptions.FromEnvironment(); + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new BlobActivityStore(storageOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +// One log line per request, the equivalent of the gunicorn access log the Python sample produces. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs b/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs new file mode 100644 index 0000000..d08d698 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs @@ -0,0 +1,95 @@ +using System.Text; +using Azure.Identity; +using Azure.Storage.Blobs; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// One blob per activity in a Blob Storage container; the blob name is the activity id and its content the text. +public sealed class BlobActivityStore : IActivityStore +{ + private readonly BlobContainerClient _container; + private readonly ILogger _logger; + + public BlobActivityStore(BlobStorageOptions options, ILogger logger) + { + BlobServiceClient service; + if (options is { ClientId: { Length: > 0 }, ClientSecret: { Length: > 0 }, TenantId: { Length: > 0 }, AccountUrl: { Length: > 0 } }) + { + logger.LogInformation("Using ClientSecretCredential with BlobServiceClient."); + var credential = new ClientSecretCredential(options.TenantId, options.ClientId, options.ClientSecret); + service = new BlobServiceClient(new Uri(options.AccountUrl), credential); + } + else if (!string.IsNullOrEmpty(options.ConnectionString)) + { + logger.LogInformation("Using storage account connection string with BlobServiceClient."); + service = new BlobServiceClient(options.ConnectionString); + } + else if (!string.IsNullOrEmpty(options.AccountUrl)) + { + // DefaultAzureCredential picks up AZURE_CLIENT_ID for the user-assigned managed identity. + logger.LogInformation("Using DefaultAzureCredential with BlobServiceClient."); + service = new BlobServiceClient(new Uri(options.AccountUrl), new DefaultAzureCredential()); + } + else + { + throw new InvalidOperationException( + "Insufficient configuration for BlobServiceClient. Set AZURE_STORAGE_ACCOUNT_URL (managed identity) or AZURE_STORAGE_ACCOUNT_CONNECTION_STRING."); + } + + _container = service.GetBlobContainerClient(options.ContainerName); + _logger = logger; + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await _container.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + var activities = new List(); + await foreach (var blob in _container.GetBlobsAsync(cancellationToken: cancellationToken)) + { + var content = await _container.GetBlobClient(blob.Name).DownloadContentAsync(cancellationToken); + _logger.LogInformation( + "Found blob '{Blob}' with size {Size} bytes", + blob.Name, + blob.Properties.ContentLength + ); + activities.Add(new Activity(blob.Name, content.Value.Content.ToString())); + } + + _logger.LogInformation( + "Retrieved {Count} blob(s) from container '{Container}'", + activities.Count, + _container.Name + ); + return activities; + } + + public Task AddAsync(string text, CancellationToken cancellationToken) + { + var name = $"{DateTime.Now:yyyy-MM-dd-HH-mm-ss}-activity.txt"; + return UploadAsync(name, text, cancellationToken); + } + + public Task UpdateAsync(string id, string text, CancellationToken cancellationToken) => UploadAsync(id, text, cancellationToken); + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + await _container.GetBlobClient(id).DeleteIfExistsAsync(cancellationToken: cancellationToken); + _logger.LogInformation("Deleted blob '{Blob}' from container '{Container}'", id, _container.Name); + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + return await _container.ExistsAsync(cancellationToken); + } + + private async Task UploadAsync(string name, string text, CancellationToken cancellationToken) + { + await _container.GetBlobClient(name).UploadAsync(new BinaryData(Encoding.UTF8.GetBytes(text)), overwrite: true, cancellationToken); + _logger.LogInformation("Uploaded blob '{Blob}' to container '{Container}'", name, _container.Name); + } +} diff --git a/samples/web-app-managed-identity/dotnet/src/Services/BlobStorageOptions.cs b/samples/web-app-managed-identity/dotnet/src/Services/BlobStorageOptions.cs new file mode 100644 index 0000000..c353256 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/BlobStorageOptions.cs @@ -0,0 +1,19 @@ +namespace VacationPlanner.Services; + +/// Settings read from the same environment variables the Python sample uses. +public sealed record BlobStorageOptions( + string? AccountUrl, + string? ConnectionString, + string ContainerName, + string? ClientId, + string? ClientSecret, + string? TenantId) +{ + public static BlobStorageOptions FromEnvironment() => new( + AccountUrl: Environment.GetEnvironmentVariable("AZURE_STORAGE_ACCOUNT_URL"), + ConnectionString: Environment.GetEnvironmentVariable("AZURE_STORAGE_ACCOUNT_CONNECTION_STRING"), + ContainerName: Environment.GetEnvironmentVariable("CONTAINER_NAME") ?? "activities", + ClientId: Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"), + ClientSecret: Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET"), + TenantId: Environment.GetEnvironmentVariable("AZURE_TENANT_ID")); +} diff --git a/samples/web-app-managed-identity/dotnet/src/Services/IActivityStore.cs b/samples/web-app-managed-identity/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..3b24e5f --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,21 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + Task AddAsync(string text, CancellationToken cancellationToken); + + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-managed-identity/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-managed-identity/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-managed-identity/dotnet/src/VacationPlanner.csproj b/samples/web-app-managed-identity/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..835cd67 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + + diff --git a/samples/web-app-managed-identity/dotnet/src/appsettings.json b/samples/web-app-managed-identity/dotnet/src/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-managed-identity/dotnet/src/wwwroot/favicon.ico b/samples/web-app-managed-identity/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-managed-identity/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-managed-identity/dotnet/src/wwwroot/style.css b/samples/web-app-managed-identity/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-managed-identity/dotnet/terraform/README.md b/samples/web-app-managed-identity/dotnet/terraform/README.md new file mode 100644 index 0000000..74a5c62 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/terraform/README.md @@ -0,0 +1,208 @@ +# Terraform Deployment + +This directory contains Terraform modules and a deployment script for provisioning Azure services in LocalStack for Azure. Refer to the [Azure Web App with Managed Identity](../README.md) guide for details about the sample application. + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [Terraform](https://developer.hashicorp.com/terraform/downloads): Infrastructure as Code tool for provisioning Azure resources +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0): Required to build and run the ASP.NET Core web application locally +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## Architecture Overview + +The [main.tf](main.tf) Terraform module creates the following Azure resources: + +1. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides blob storage for persisting vacation activity data. The web application stores each activity as a JSON blob file in the `activities` container. +2. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): Defines the compute resources (CPU, memory, and scaling options) that host the web application. +3. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the ASP.NET Core *Vacation Planner* application. The web app uses managed identity to securely access the Azure Storage Account without requiring explicit credentials. +4. [Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): Provides secure, credential-free authentication between the web app and storage account. Supports both system-assigned and user-assigned identity types. +5. [Role Assignment](https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments): Grants the web app's managed identity the *Storage Blob Data Contributor* role, enabling read/write access to blob containers. +6. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Enables continuous deployment from a Git repository for automated application updates. + +The web app allows users to plan and manage vacation activities, storing all activity data as blob files in the `activities` containers in the [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview). For more information, see [Azure Web App with Managed Identity](../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. Before running the script, customize the variable values based on your needs. In particular, use the `MANAGED_IDENTITY_TYPE` variable to specify the type of managed identity to provision: `SystemAssigned` or `UserAssigned`. + +## 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="azure.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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `terraform` folder: + +```bash +cd samples/web-app-managed-identity/dotnet/terraform +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +CONTAINER_NAME='activities' +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +MANAGED_IDENTITY_NAME="${PREFIX}-identity-${SUFFIX}" + +# 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 Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,State:state,Location:location,DefaultHostName:defaultHostName}' \ + --output table \ + --only-show-errors + +# Check user-assigned managed identity +echo -e "\n[$MANAGED_IDENTITY_NAME] managed identity:\n" +az identity show \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,ClientId:clientId,PrincipalId:principalId}' \ + --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:location,ResourceGroup:resourceGroup,Kind:kind,Sku:sku.name}' \ + --output table \ + --only-show-errors + +# List storage containers +echo -e "\n[$STORAGE_ACCOUNT_NAME] storage containers:\n" +az storage container list \ + --account-name "$STORAGE_ACCOUNT_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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-managed-identity/dotnet/terraform/deploy.sh b/samples/web-app-managed-identity/dotnet/terraform/deploy.sh new file mode 100755 index 0000000..25dbf65 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/terraform/deploy.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +MANAGED_IDENTITY_TYPE='UserAssigned' # SystemAssigned or UserAssigned +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +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" \ + -var "managed_identity_type=$MANAGED_IDENTITY_TYPE" + +# 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) +STORAGE_ACCOUNT_NAME=$(terraform output -raw storage_account_name) +WEB_APP_NAME=$(terraform output -raw web_app_name) + +# Check if output values are empty +if [[ -z "$WEB_APP_NAME" || -z "$STORAGE_ACCOUNT_NAME" ]]; then + echo "Web App Name or Storage Account Name is empty. Exiting." + exit 1 +fi + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi diff --git a/samples/web-app-managed-identity/dotnet/terraform/main.tf b/samples/web-app-managed-identity/dotnet/terraform/main.tf new file mode 100644 index 0000000..65dd3fa --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/terraform/main.tf @@ -0,0 +1,118 @@ +# Local Variables +locals { + resource_group_name = "${var.prefix}-rg" + storage_account_name = "${var.prefix}storage${var.suffix}" + app_service_plan_name = "${var.prefix}-app-service-plan-${var.suffix}" + web_app_name = "${var.prefix}-webapp-${var.suffix}" + managed_identity_name = "${var.prefix}-identity-${var.suffix}" +} + +# Create a resource group +resource "azurerm_resource_group" "example" { + name = local.resource_group_name + location = var.location + tags = var.tags +} + +# Create a storage account +resource "azurerm_storage_account" "example" { + name = local.storage_account_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + account_replication_type = var.account_replication_type + account_kind = var.account_kind + account_tier = var.account_tier + tags = var.tags + + identity { + type = "SystemAssigned" + } + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Create storage container +resource "azurerm_storage_container" "example" { + name = var.storage_container_name + storage_account_id = azurerm_storage_account.example.id + container_access_type = "private" +} + +# Conditionally create a user assigned identity for the function app +resource "azurerm_user_assigned_identity" "identity" { + count = var.managed_identity_type == "UserAssigned" ? 1 : 0 + + name = local.managed_identity_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location +} + +# Assign Storage Blob Data Contributor role to the function app identity +resource "azurerm_role_assignment" "blob_contributor" { + scope = azurerm_storage_account.example.id + role_definition_name = "Storage Blob Data Contributor" + principal_id = var.managed_identity_type == "UserAssigned" ? azurerm_user_assigned_identity.identity[0].principal_id : azurerm_linux_web_app.example.identity[0].principal_id +} + +# Create a service plan +resource "azurerm_service_plan" "example" { + 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 + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Create a web app +resource "azurerm_linux_web_app" "example" { + name = local.web_app_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + service_plan_id = azurerm_service_plan.example.id + https_only = var.https_only + public_network_access_enabled = var.webapp_public_network_access_enabled + client_affinity_enabled = false + tags = var.tags + + identity { + type = var.managed_identity_type + identity_ids = var.managed_identity_type == "UserAssigned" ? [ + azurerm_user_assigned_identity.identity[0].id + ] : [] + } + + site_config { + always_on = var.always_on + http2_enabled = var.http2_enabled + minimum_tls_version = var.minimum_tls_version + application_stack { + dotnet_version = var.dotnet_version + } + } + + app_settings = { + SCM_DO_BUILD_DURING_DEPLOYMENT = "true" + ENABLE_ORYX_BUILD = "true" + AZURE_STORAGE_ACCOUNT_URL = azurerm_storage_account.example.primary_blob_endpoint + CONTAINER_NAME = azurerm_storage_container.example.name + AZURE_CLIENT_ID = var.managed_identity_type == "UserAssigned" ? azurerm_user_assigned_identity.identity[0].client_id : "" + } + + lifecycle { + ignore_changes = [ + tags + ] + } +} diff --git a/samples/web-app-managed-identity/dotnet/terraform/outputs.tf b/samples/web-app-managed-identity/dotnet/terraform/outputs.tf new file mode 100644 index 0000000..0817488 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/terraform/outputs.tf @@ -0,0 +1,19 @@ +output "resource_group_name" { + value = local.resource_group_name +} + +output "storage_account_name" { + value = azurerm_storage_account.example.name +} + +output "app_service_plan_name" { + value = azurerm_service_plan.example.name +} + +output "web_app_name" { + value = azurerm_linux_web_app.example.name +} + +output "web_app_url" { + value = azurerm_linux_web_app.example.default_hostname +} \ No newline at end of file diff --git a/samples/web-app-managed-identity/dotnet/terraform/providers.tf b/samples/web-app-managed-identity/dotnet/terraform/providers.tf new file mode 100644 index 0000000..6682178 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/terraform/providers.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=5.1.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 = "azure.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" +} diff --git a/samples/web-app-managed-identity/dotnet/terraform/terraform.tfvars b/samples/web-app-managed-identity/dotnet/terraform/terraform.tfvars new file mode 100644 index 0000000..e95f069 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/terraform/terraform.tfvars @@ -0,0 +1,2 @@ +location = "westeurope" +dotnet_version = "10.0" \ No newline at end of file diff --git a/samples/web-app-managed-identity/dotnet/terraform/variables.tf b/samples/web-app-managed-identity/dotnet/terraform/variables.tf new file mode 100644 index 0000000..583470a --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/terraform/variables.tf @@ -0,0 +1,216 @@ +variable "prefix" { + description = "(Optional) Specifies the prefix for the name of the Azure resources." + type = string + default = "webmi" + + 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 = 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 "storage_container_name" { + description = "(Optional) Specifies the name of the storage container." + type = string + default = "activities" + + validation { + condition = can(regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", var.storage_container_name)) + error_message = "Container name must be lowercase alphanumeric characters or hyphens, between 3-63 characters." + } +} + +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 "dotnet_version" { + description = "(Optional) Specifies the version of .NET to run. Possible values include 8.0, 9.0 and 10.0." + type = string + default = "10.0" + + validation { + condition = contains(["8.0", "9.0", "10.0"], var.dotnet_version) + error_message = "The dotnet_version must be one of the supported versions: 8.0, 9.0, 10.0." + } +} + +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 "webapp_public_network_access_enabled" { + description = "(Optional) Specifies whether the public network access is enabled or disabled." + type = bool + default = true +} + +variable "managed_identity_type" { + description = "Specifies the type of managed identity." + type = string + default = "SystemAssigned" + + validation { + condition = contains(["SystemAssigned", "UserAssigned"], var.managed_identity_type) + error_message = "The managed_identity_type must be either 'SystemAssigned' or 'UserAssigned'." + } +} + +variable "tags" { + description = "(Optional) Specifies the tags to be applied to the resources." + type = map(string) + default = { + environment = "test" + iac = "terraform" + } +} \ No newline at end of file diff --git a/samples/web-app-managed-identity/dotnet/visio/architecture.vsdx b/samples/web-app-managed-identity/dotnet/visio/architecture.vsdx new file mode 100644 index 0000000..1d0bd3d Binary files /dev/null and b/samples/web-app-managed-identity/dotnet/visio/architecture.vsdx differ diff --git a/samples/web-app-managed-identity/python/terraform/README.md b/samples/web-app-managed-identity/python/terraform/README.md index 3aff1a3..8fd5cd5 100644 --- a/samples/web-app-managed-identity/python/terraform/README.md +++ b/samples/web-app-managed-identity/python/terraform/README.md @@ -65,7 +65,7 @@ provider "azurerm" { # 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" + metadata_host="azure.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" diff --git a/samples/web-app-managed-identity/python/terraform/providers.tf b/samples/web-app-managed-identity/python/terraform/providers.tf index 25af634..6682178 100644 --- a/samples/web-app-managed-identity/python/terraform/providers.tf +++ b/samples/web-app-managed-identity/python/terraform/providers.tf @@ -19,7 +19,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/web-app-mysql-flexible-server/dotnet/README.md b/samples/web-app-mysql-flexible-server/dotnet/README.md new file mode 100644 index 0000000..847b8bb --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/README.md @@ -0,0 +1,107 @@ +# Azure Web App with Azure Database for MySQL flexible server + +This sample demonstrates an ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in the `activities` table of the `plannerdb` database on an [Azure Database for MySQL flexible server](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/overview). The server is reached through a [Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (group `mysqlServer`) with the `privatelink.mysql.database.azure.com` Private DNS Zone, while a permissive server-level firewall rule lets the deploy machine run the post-create mysql bootstrap that creates the application user and seeds the schema. + +## Architecture + +![Architecture Diagram](./images/architecture.png) + +The web app enables users to plan and manage vacation activities; all data is persisted in MySQL. 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*: Delegated to `Microsoft.Web/serverFarms` for regional VNet integration of the Web App. + - *pe-subnet*: Hosts the Private Endpoint to the MySQL flexible server. +3. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) `privatelink.mysql.database.azure.com`, linked to the VNet. The Private Endpoint's DNS-zone group auto-registers the `A` record for the server, so the Web App resolves the server's private IP through the VNet. +4. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (group `mysqlServer`): Secures access to the MySQL flexible server from the VNet. +5. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview): Deterministic outbound connectivity for both subnets. +6. [Azure Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): One NSG per subnet. +7. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview): Centralizes diagnostic logs and metrics. +8. [Azure Database for MySQL flexible server](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/overview): Public-access server hosting the `plannerdb` database. Burstable `Standard_B1ms`, version 8.0.21, 32 GiB storage, 7-day backup retention, HA disabled. A permissive firewall rule (`0.0.0.0–255.255.255.255`) is created so the deploy machine can run the post-create mysql bootstrap; the Web App itself reaches the server through the Private Endpoint. +9. [MySQL database](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-create-manage-databases) `plannerdb`: Created at provisioning time; the post-deploy mysql step creates the `activities` table and seeds the demo rows. +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 ASP.NET Core *Vacation Planner* app with regional VNet integration into *app-subnet*. The Web App connects to MySQL using a dedicated application user (`testuser`) — the server-admin login is never used at runtime. +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 deploy scripts follow the same pattern as the sibling [`web-app-postgresql-flexible-server`](../../web-app-postgresql-flexible-server/dotnet/) sample: after provisioning, they (i) connect as the server admin via the public endpoint + firewall rule, (ii) create the application user `testuser` with its own password, (iii) grant privileges on `plannerdb`, (iv) create the `activities` table, (v) seed sample rows, and (vi) write `MYSQL_USER=testuser` + `MYSQL_PASSWORD` onto the Web App's app settings. The server-admin login is never written into the Web App's runtime configuration. + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/) +- [MySqlConnector](https://mysqlconnector.net/) +- [MySQL client tools](https://dev.mysql.com/downloads/) (`mysql`) — required by the deploy scripts to create the application user and seed data +- [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`. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain yours. Pull and start the emulator: + +```bash +docker pull localstack/localstack-azure + +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Deploy the application using one of these methods: + +- [Azure CLI Deployment](./scripts/README.md) +- [Bicep Deployment](./bicep/README.md) +- [Terraform Deployment](./terraform/README.md) + +All three variants provision the same topology: VNet + pe-subnet hosting a Private Endpoint targeting a public-access MySQL flexible server, with a Private DNS Zone linked to the VNet. + +> **Note** +> When you deploy the application to LocalStack for Azure for the first time, the initialization process pulls and builds Docker images (LocalStack itself plus the `mysql:8` backing container for the flexible-server emulator). This is a one-time operation — subsequent deployments are much faster. + +## Test + +1. Retrieve the port published and mapped to port 80 by the Docker container hosting the emulated Web App. +2. Open a web browser and navigate to `http://localhost:`. +3. If the deployment was successful, you will see the *Vacation Planner* UI with the seeded activities and can add, edit, and remove activities. + +![Vacation Planner UI](./images/vacation-planner.png) + +You can use the `scripts/call-web-app.sh` Bash script to call the web app from outside the emulator. The script demonstrates four call paths: + +1. **Through the LocalStack for Azure emulator** via the default hostname. +2. **Via localhost and host port** mapped to the container's port `80`. +3. **Via container IP address** on port `80`. +4. **Via the default hostname** `.azurewebsites.azure.localhost.localstack.cloud:4566`. + +## MySQL Tooling + +You can use [MySQL Workbench](https://www.mysql.com/products/workbench/) to explore and manage the deployed database. Connect using: + +| Field | Value | +| -------- | ------------------------------------------------------------------------------ | +| Host | `localhost` | +| Port | (see `docker ps` for the host-mapped port of the backing `mysql:8` container) | +| Database | `plannerdb` | +| Username | `testuser` *(or `myadmin` for admin operations)* | +| Password | `TestP@ssw0rd123` *(or `P@ssw0rd1234!` for the admin)* | + +Or use the [`mysql`](https://dev.mysql.com/doc/refman/8.0/en/mysql.html) command-line client: + +```bash +MYSQL_PWD='TestP@ssw0rd123' mysql -h localhost -P -u testuser plannerdb +mysql> SELECT id, username, activity, created_at FROM activities; +``` + +## References + +- [Azure Web Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [Azure Database for MySQL — flexible server](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/) +- [Quickstart: Deploy an ASP.NET web app to Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/quickstart-dotnetcore?pivots=development-environment-cli) +- [MySqlConnector documentation](https://mysqlconnector.net/) +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-mysql-flexible-server/dotnet/bicep/README.md b/samples/web-app-mysql-flexible-server/dotnet/bicep/README.md new file mode 100644 index 0000000..3d4519b --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/bicep/README.md @@ -0,0 +1,101 @@ +# Bicep Deployment + +This directory contains the Bicep template and a deployment script for provisioning the sample's Azure resources. For details about the sample application, see [Azure Web App with Azure Database for MySQL flexible server](../README.md). + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [Visual Studio Code](https://code.visualstudio.com/) + [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep) +- [Docker](https://docs.docker.com/get-docker/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) + [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [MySQL client (`mysql`)](https://dev.mysql.com/downloads/) +- [`jq`](https://jqlang.org/) + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +The [`deploy.sh`](deploy.sh) script creates the resource group while the Bicep modules create: + +1. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview) with two subnets: + - *app-subnet*: delegated to `Microsoft.Web/serverFarms` for the Web App's regional VNet integration. + - *pe-subnet*: hosts the Private Endpoint to the MySQL flexible server. +2. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) `privatelink.mysql.database.azure.com`, linked to the VNet. +3. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (group `mysqlServer`). +4. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview). +5. [Network Security Groups](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): one per subnet. +6. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview). +7. [Azure Database for MySQL flexible server](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/overview): public-access mode, Burstable `Standard_B1ms`, version 8.0.21, 32 GiB, HA disabled. A permissive firewall rule (`0.0.0.0–255.255.255.255`) lets the deploy machine reach the server for the post-create mysql bootstrap. +8. [MySQL database](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-create-manage-databases) `plannerdb` (utf8mb4 / `utf8mb4_unicode_ci`). +9. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). +10. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration into *app-subnet*. The Bicep template sets `MYSQL_HOST`, `MYSQL_PORT`, and `MYSQL_DATABASE` on the Web App but **does not** set `MYSQL_USER` or `MYSQL_PASSWORD` — those are written by `deploy.sh` after the mysql client creates the application user. + +## Configuration + +Update [`main.bicepparam`](main.bicepparam) before deploying. The defaults are: + +```bicep +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'dotnetcore' +param runtimeVersion = '10.0' +param databaseName = 'plannerdb' +param username = 'paolo' + +param mysqlAdminLogin = 'myadmin' +param mysqlAdminPassword = readEnvironmentVariable('MYSQL_ADMIN_PASSWORD', '') +param mysqlVersion = '8.0.21' +param mysqlSkuTier = 'Burstable' +param mysqlSkuName = 'Standard_B1ms' +param mysqlStorageSizeGB = 32 +param mysqlBackupRetentionDays = 7 +``` + +`mysqlAdminPassword` is read from the `MYSQL_ADMIN_PASSWORD` env var. `deploy.sh` sets a default (`P@ssw0rd1234!`) if not provided; override for non-dev deployments. + +## Deployment + +```bash +# default values +bash deploy.sh + +# override admin and app-user secrets +MYSQL_ADMIN_PASSWORD='' \ +MYSQL_APP_PASSWORD='' \ +bash deploy.sh +``` + +The script will: + +1. Ensure the resource group exists. +2. Validate `main.bicep`. +3. Deploy the template, passing `mysqlAdminPassword`. +4. Use the `mysql` client (connected via the public endpoint + firewall rule) to create the `testuser` user, the `activities` table, and the demo rows. +5. Set the Web App's `MYSQL_USER`/`MYSQL_PASSWORD` to `testuser` / `` — the server admin login is never written to the Web App. +6. Zip the application source under `../src` and deploy it. + +## Verification + +```bash +MYSQL_PWD='TestP@ssw0rd123' mysql -h -P -u testuser plannerdb \ + -e "SELECT id, username, activity, created_at FROM activities;" +``` + +`` is `3306` in real Azure, or the port suffix of the server's FQDN in LocalStack: + +```bash +az mysql flexible-server show \ + --resource-group local-rg --name local-mysqlflex-test \ + --query fullyQualifiedDomainName --output tsv +``` + +## Related Documentation + +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-mysql-flexible-server/dotnet/bicep/deploy.sh b/samples/web-app-mysql-flexible-server/dotnet/bicep/deploy.sh new file mode 100755 index 0000000..6eacd28 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/bicep/deploy.sh @@ -0,0 +1,325 @@ +#!/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) +MYSQL_ADMIN_USER="${MYSQL_ADMIN_USER:-myadmin}" +MYSQL_ADMIN_PASSWORD="${MYSQL_ADMIN_PASSWORD:-P@ssw0rd1234!}" +MYSQL_APP_USER="${MYSQL_APP_USER:-testuser}" +MYSQL_APP_PASSWORD="${MYSQL_APP_PASSWORD:-TestP@ssw0rd123}" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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 \ + mysqlAdminPassword="$MYSQL_ADMIN_PASSWORD" \ + --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 \ + mysqlAdminPassword="$MYSQL_ADMIN_PASSWORD" \ + --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 \ + mysqlAdminPassword="$MYSQL_ADMIN_PASSWORD" \ + --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 . + WEB_APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.webAppName.value') + MYSQL_SERVER_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.mysqlServerName.value') + MYSQL_FQDN_FULL=$(echo "$DEPLOYMENT_JSON" | jq -r '.mysqlFqdn.value') + DATABASE_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.databaseName.value') + echo "Deployment details:" + echo "Web App Name: $WEB_APP_NAME" + echo "MySQL Server Name: $MYSQL_SERVER_NAME" + echo "MySQL FQDN: $MYSQL_FQDN_FULL" + echo "Database Name: $DATABASE_NAME" +else + echo "Failed to deploy Bicep template [$TEMPLATE]" + exit 1 +fi + +if [[ -z "$WEB_APP_NAME" || -z "$MYSQL_SERVER_NAME" ]]; then + echo "Web App Name or MySQL Server Name is empty. Exiting." + exit 1 +fi + +# Split host:port — the LocalStack emulator embeds the dynamically allocated TCP-proxy port +# directly in fullyQualifiedDomainName, mirroring the storage / container registry emulators. +# Real Azure returns just the bare host so MYSQL_PORT defaults to 3306. +MYSQL_FQDN="${MYSQL_FQDN_FULL%%:*}" +if [[ "$MYSQL_FQDN_FULL" == *:* ]]; then + MYSQL_PORT="${MYSQL_FQDN_FULL##*:}" +else + MYSQL_PORT=3306 +fi +echo "MySQL host = $MYSQL_FQDN, port = $MYSQL_PORT" + +# Check if the MySQL CLI is installed +MYSQL_CHECK=$(command -v mysql) +if [[ -z "$MYSQL_CHECK" ]]; then + echo "[mysql] CLI is not installed. Install it with: sudo apt install -y mysql-client" >&2 + exit 1 +fi + +# Create application user [$MYSQL_APP_USER] on the MySQL flexible server +echo "Creating login [$MYSQL_APP_USER] on the [$MYSQL_SERVER_NAME] MySQL flexible server..." +MYSQL_PWD="$MYSQL_ADMIN_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_ADMIN_USER" \ + --protocol=TCP \ + -e "CREATE USER IF NOT EXISTS '$MYSQL_APP_USER'@'%' IDENTIFIED BY '$MYSQL_APP_PASSWORD'; + GRANT ALL PRIVILEGES ON \`$DATABASE_NAME\`.* TO '$MYSQL_APP_USER'@'%'; + FLUSH PRIVILEGES;" + +if [ $? -eq 0 ]; then + echo "Login [$MYSQL_APP_USER] created successfully" +else + echo "Failed to create login [$MYSQL_APP_USER]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$MYSQL_APP_USER]..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --database="$DATABASE_NAME" \ + -e "SELECT CURRENT_USER() AS user_name, DATABASE() AS db_name, NOW() AS server_time;" + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$MYSQL_APP_USER]" +else + echo "Connection test failed with user [$MYSQL_APP_USER]" + exit 1 +fi + +# Create [activities] table +echo "Creating [activities] table in the [$DATABASE_NAME] database..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --database="$DATABASE_NAME" \ + -e "CREATE TABLE IF NOT EXISTS activities ( + id VARCHAR(32) NOT NULL, + username VARCHAR(255) NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_activities_username (username), + INDEX idx_activities_created_at (created_at DESC) + );" + +if [ $? -eq 0 ]; then + echo "[activities] table created successfully" +else + echo "Failed to create [activities] table" + exit 1 +fi + +# Insert sample data +echo "Inserting sample data into [activities] table..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --database="$DATABASE_NAME" \ + -e "INSERT IGNORE INTO activities (id, username, activity) VALUES + (MD5('paolo_pisa_seed'), 'paolo', 'Visit the Leaning Tower in Pisa'), + (MD5('paolo_volterra_seed'), 'paolo', 'Explore Etruscan walls in Volterra'), + (MD5('paolo_san_gimignano_seed'), 'paolo', 'Climb Torre Grossa in San Gimignano'), + (MD5('paolo_siena_seed'), 'paolo', 'Walk across Piazza del Campo in Siena'), + (MD5('paolo_montalcino_seed'), 'paolo', 'Taste Brunello wine in Montalcino'), + (MD5('paolo_pienza_seed'), 'paolo', 'Sample Pecorino cheese in Pienza'), + (MD5('paolo_florence_seed'), 'paolo', 'Admire Michelangelo''s David in Florence'), + (MD5('paolo_viareggio_beach_seed'), 'paolo', 'Relax by the beach in Viareggio'), + (MD5('paolo_viareggio_promenade_seed'), 'paolo', 'Stroll along the Viareggio promenade');" + +if [ $? -eq 0 ]; then + echo "Sample data inserted successfully into [activities] table" +else + echo "Failed to insert sample data into [activities] table" + exit 1 +fi + +# Query sample data +echo "Querying sample data from [activities] table..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --database="$DATABASE_NAME" \ + -e "SELECT * FROM activities;" + +if [ $? -eq 0 ]; then + echo "Sample data queried successfully from [activities] table" +else + echo "Failed to query sample data from [activities] table" + exit 1 +fi + +# Set MYSQL_USER + MYSQL_PASSWORD on the web app to point at the application user +echo "Setting MYSQL_USER=[$MYSQL_APP_USER] and MYSQL_PASSWORD on the [$WEB_APP_NAME] web app..." +az webapp config appsettings set \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --settings MYSQL_USER="$MYSQL_APP_USER" MYSQL_PASSWORD="$MYSQL_APP_PASSWORD" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "MYSQL_USER and MYSQL_PASSWORD set successfully on the [$WEB_APP_NAME] web app" +else + echo "Failed to set MYSQL_USER and MYSQL_PASSWORD on the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Print the application settings of the web app +echo "Retrieving application settings for web app [$WEB_APP_NAME]..." +az webapp config appsettings list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +# Remove the zip package of the web 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 + +# Ping the web app to confirm the deployment is reachable +echo "Getting the default hostname of the [$WEB_APP_NAME] web app..." +WEB_APP_HOSTNAME=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query defaultHostName \ + --output tsv \ + --only-show-errors) +WEB_APP_URL="http://${WEB_APP_HOSTNAME}" +echo "You can visit your app at: $WEB_APP_URL" + +echo "Pinging [$WEB_APP_URL] to verify the web app responds..." +HTTP_CODE="000" +for attempt in $(seq 1 12); do + HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$WEB_APP_URL" || echo "000") + if [ "$HTTP_CODE" = "200" ]; then + echo "Web app responded with HTTP 200. Deployment verified successfully." + break + fi + echo "Web app not ready yet (attempt $attempt/12, HTTP $HTTP_CODE)..." + sleep 5 +done + +if [ "$HTTP_CODE" != "200" ]; then + echo "Web app did not return HTTP 200 after 12 attempts (last code: $HTTP_CODE). Deployment verification failed." + exit 1 +fi diff --git a/samples/web-app-mysql-flexible-server/dotnet/bicep/main.bicep b/samples/web-app-mysql-flexible-server/dotnet/bicep/main.bicep new file mode 100644 index 0000000..aad79d2 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/bicep/main.bicep @@ -0,0 +1,308 @@ +//******************************************** +// 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' + 'Premium' + 'PremiumV2' + 'Premium0V3' + 'PremiumV3' + 'PremiumMV3' +]) +param skuTier string = 'Standard' + +@description('Specifies the SKU name for the hosting plan.') +param skuName string = 'S1' + +@description('Specifies the kind of the hosting plan.') +@allowed(['app','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 Web App.') +@allowed(['dotnet','dotnetcore','python','java','node']) +param runtimeName string + +@description('Specifies the target language version used by the Azure Web App.') +param runtimeVersion string + +@description('Specifies the kind of the web app resource.') +param webAppKind string = 'app,linux' + +@description('Specifies whether HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = false + +@description('Specifies the minimum TLS version for the Azure Web App.') +@allowed(['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 the optional Git Repo URL.') +param repoUrl string = ' ' + +@description('Specifies the username for the application (used to scope activities).') +param username string = 'paolo' + +// +// MySQL flexible server +// +@description('Administrator login for the MySQL flexible server. Only used by the post-deploy mysql bootstrap; the Web App never authenticates with this account.') +param mysqlAdminLogin string = 'myadmin' + +@description('Administrator login password for the MySQL flexible server.') +@secure() +param mysqlAdminPassword string + +@description('MySQL major version.') +@allowed(['5.7','8.0.21']) +param mysqlVersion string = '8.0.21' + +@description('Compute tier for the MySQL flexible server.') +@allowed(['Burstable','GeneralPurpose','MemoryOptimized']) +param mysqlSkuTier string = 'Burstable' + +@description('Compute SKU name for the MySQL flexible server.') +param mysqlSkuName string = 'Standard_B1ms' + +@description('Storage size in GB for the MySQL flexible server.') +@minValue(20) +@maxValue(16384) +param mysqlStorageSizeGB int = 32 + +@description('Backup retention in days for the MySQL flexible server.') +@minValue(1) +@maxValue(35) +param mysqlBackupRetentionDays int = 7 + +@description('Name of the application database to create on the MySQL flexible server.') +param databaseName string = 'plannerdb' + +// +// Networking +// +@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 Web App for the regional virtual network integration.') +param webAppSubnetName string = 'app-subnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param webAppSubnetNsgName string = '' + +@description('Specifies the name of the subnet that hosts the private endpoint to the MySQL flexible server.') +param peSubnetName string = 'pe-subnet' + +@description('Specifies the address prefix of the private-endpoint subnet.') +param peSubnetAddressPrefix string = '10.0.1.0/24' + +@description('Specifies the name of the NSG associated to the private-endpoint subnet.') +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 the name of the private endpoint targeting the MySQL flexible server.') +param mysqlPrivateEndpointName string = '' + +// +// Observability +// +@description('Specifies the name of the Azure Log Analytics resource.') +param logAnalyticsName string = '' + +@description('Specifies the service tier of the workspace.') +@allowed(['Free','Standalone','PerNode','PerGB2018']) +param logAnalyticsSku string = 'PerNode' + +@description('Specifies the workspace data retention in days.') +param logAnalyticsRetentionInDays int = 60 + +@description('Specifies the tags to be applied to the resources.') +param tags object = { + environment: 'test' + iac: 'bicep' +} + +//******************************************** +// Variables +//******************************************** +var webAppName = '${prefix}-webapp-${suffix}' +var appServicePlanName = '${prefix}-app-service-plan-${suffix}' +var mysqlServerName = '${prefix}-mysqlflex-${suffix}' +var privateDnsZoneName = 'privatelink.mysql.database.azure.com' + +// The MySQL flexible-server emulator embeds the LS-side TCP-proxy port directly in +// fullyQualifiedDomainName (e.g. ".mysql.database.localhost.localstack.cloud:4515"). +// Real Azure returns just the bare host on 3306. Split on `:` so the Web App always gets the +// right host + port without any post-deploy shell logic. +var mysqlFqdnParts = split(mysqlServer.outputs.fqdn, ':') +var mysqlHost = mysqlFqdnParts[0] +var mysqlPort = length(mysqlFqdnParts) > 1 ? mysqlFqdnParts[1] : '3306' + +//******************************************** +// Modules and Resources +//******************************************** +module workspace 'modules/log-analytics.bicep' = { + name: 'workspace' + params: { + name: empty(logAnalyticsName) ? toLower('${prefix}-log-analytics-${suffix}') : logAnalyticsName + location: location + tags: tags + sku: logAnalyticsSku + retentionInDays: logAnalyticsRetentionInDays + } +} + +module network 'modules/virtual-network.bicep' = { + name: 'network' + params: { + virtualNetworkName: empty(virtualNetworkName) ? toLower('${prefix}-vnet-${suffix}') : virtualNetworkName + virtualNetworkAddressPrefixes: virtualNetworkAddressPrefixes + webAppSubnetName: webAppSubnetName + webAppSubnetAddressPrefix: webAppSubnetAddressPrefix + webAppSubnetNsgName: empty(webAppSubnetNsgName) ? toLower('${prefix}-webapp-subnet-nsg-${suffix}') : webAppSubnetNsgName + 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: 'Microsoft.Web/serverfarms' + workspaceId: workspace.outputs.id + location: location + tags: tags + } +} + +module mysqlServer 'modules/mysql-flexible-server.bicep' = { + name: 'mysqlServer' + params: { + name: mysqlServerName + location: location + administratorLogin: mysqlAdminLogin + administratorLoginPassword: mysqlAdminPassword + version: mysqlVersion + skuTier: mysqlSkuTier + skuName: mysqlSkuName + storageSizeGB: mysqlStorageSizeGB + backupRetentionDays: mysqlBackupRetentionDays + databaseName: databaseName + workspaceId: workspace.outputs.id + tags: tags + } +} + +module privateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'privateDnsZone' + params: { + name: privateDnsZoneName + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module privateEndpoint 'modules/private-endpoint.bicep' = { + name: 'privateEndpoint' + params: { + name: empty(mysqlPrivateEndpointName) + ? toLower('${prefix}-mysql-pe-${suffix}') + : mysqlPrivateEndpointName + privateLinkServiceId: mysqlServer.outputs.id + privateDnsZoneId: privateDnsZone.outputs.id + vnetId: network.outputs.virtualNetworkId + subnetId: network.outputs.peSubnetId + groupIds: [ + 'mysqlServer' + ] + 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 webApp 'modules/web-app.bicep' = { + name: webAppName + params: { + name: webAppName + location: location + kind: webAppKind + httpsOnly: httpsOnly + runtimeName: runtimeName + runtimeVersion: runtimeVersion + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + repoUrl: repoUrl + virtualNetworkName: network.outputs.virtualNetworkName + subnetName: network.outputs.webAppSubnetName + hostingPlanName: appServicePlan.outputs.name + mysqlHost: mysqlHost + mysqlPort: mysqlPort + mysqlDatabase: mysqlServer.outputs.databaseName + username: username + workspaceId: workspace.outputs.id + tags: tags + } +} + +//******************************************** +// Outputs +//******************************************** +output webAppName string = webApp.outputs.name +output webAppDefaultHostName string = webApp.outputs.defaultHostName +output mysqlServerName string = mysqlServer.outputs.name +output mysqlFqdn string = mysqlServer.outputs.fqdn +output databaseName string = mysqlServer.outputs.databaseName diff --git a/samples/web-app-mysql-flexible-server/dotnet/bicep/main.bicepparam b/samples/web-app-mysql-flexible-server/dotnet/bicep/main.bicepparam new file mode 100644 index 0000000..5584e03 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/bicep/main.bicepparam @@ -0,0 +1,19 @@ +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'dotnetcore' +param runtimeVersion = '10.0' +param databaseName = 'plannerdb' +param username = 'paolo' + +// MySQL flexible server +param mysqlAdminLogin = 'myadmin' +// Password is supplied at deploy time via the MYSQL_ADMIN_PASSWORD env var +// (see deploy.sh — it passes it as --parameters mysqlAdminPassword=...). Do not commit it here. +param mysqlAdminPassword = readEnvironmentVariable('MYSQL_ADMIN_PASSWORD', '') +param mysqlVersion = '8.0.21' +param mysqlSkuTier = 'Burstable' +param mysqlSkuName = 'Standard_B1ms' +param mysqlStorageSizeGB = 32 +param mysqlBackupRetentionDays = 7 diff --git a/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/app-service-plan.bicep b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/app-service-plan.bicep new file mode 100644 index 0000000..4b5cfb3 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/bicep/modules/log-analytics.bicep b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/log-analytics.bicep new file mode 100644 index 0000000..2618829 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/bicep/modules/mysql-flexible-server.bicep b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/mysql-flexible-server.bicep new file mode 100644 index 0000000..c018353 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/mysql-flexible-server.bicep @@ -0,0 +1,169 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the name of the Azure Database for MySQL flexible server.') +param name string + +@description('Specifies the location for all resources.') +param location string = resourceGroup().location + +@description('Specifies the administrator login for the MySQL server.') +param administratorLogin string = 'myadmin' + +@description('Specifies the administrator login password for the MySQL server.') +@secure() +param administratorLoginPassword string + +@description('Specifies the MySQL major version.') +@allowed([ + '5.7' + '8.0.21' +]) +param version string = '8.0.21' + +@description('Specifies the compute tier of the server.') +@allowed([ + 'Burstable' + 'GeneralPurpose' + 'MemoryOptimized' +]) +param skuTier string = 'Burstable' + +@description('Specifies the compute SKU name of the server.') +param skuName string = 'Standard_B1ms' + +@description('Specifies the storage size in GB.') +@minValue(20) +@maxValue(16384) +param storageSizeGB int = 32 + +@description('Specifies the backup retention period in days.') +@minValue(1) +@maxValue(35) +param backupRetentionDays int = 7 + +@description('Specifies the name of the database to create on the server.') +param databaseName string = 'plannerdb' + +@description('Specifies the database charset.') +param databaseCharset string = 'utf8mb4' + +@description('Specifies the database collation.') +param databaseCollation string = 'utf8mb4_unicode_ci' + +@description('Name of the server-level firewall rule that allows the deploy machine and Azure services to reach the server. Defaults to a permissive allow-all rule appropriate for the sample.') +param firewallRuleName string = 'AllowAllIPs' + +@description('Start IP of the firewall rule.') +param firewallStartIp string = '0.0.0.0' + +@description('End IP of the firewall rule.') +param firewallEndIp string = '255.255.255.255' + +@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 = [ + 'MySqlSlowLogs' + 'MySqlAuditLogs' +] +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 +//******************************************** +// Server is created in public-access mode and fronted by a Private Endpoint (see the +// private-endpoint module in main.bicep). The firewall rule lets the deploy machine reach the +// public endpoint just long enough to run the post-deploy mysql bootstrap that creates the +// application user and seed data; the Web App itself reaches the server over the private +// endpoint via the linked Private DNS Zone. +resource server 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = { + name: toLower(name) + location: location + tags: tags + sku: { + name: skuName + tier: skuTier + } + properties: { + administratorLogin: administratorLogin + administratorLoginPassword: administratorLoginPassword + version: version + createMode: 'Default' + storage: { + storageSizeGB: storageSizeGB + } + backup: { + backupRetentionDays: backupRetentionDays + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + network: { + publicNetworkAccess: 'Enabled' + } + } +} + +resource database 'Microsoft.DBforMySQL/flexibleServers/databases@2023-12-30' = { + parent: server + name: databaseName + properties: { + charset: databaseCharset + collation: databaseCollation + } +} + +resource firewallRule 'Microsoft.DBforMySQL/flexibleServers/firewallRules@2023-12-30' = { + parent: server + name: firewallRuleName + properties: { + startIpAddress: firewallStartIp + endIpAddress: firewallEndIp + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: diagnosticSettingsName + scope: server + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = server.id +output name string = server.name +output fqdn string = server.properties.fullyQualifiedDomainName +output databaseId string = database.id +output databaseName string = database.name diff --git a/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/private-dns-zone.bicep b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/private-dns-zone.bicep new file mode 100644 index 0000000..d849259 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/bicep/modules/private-endpoint.bicep b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/private-endpoint.bicep new file mode 100644 index 0000000..8fd35b8 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/private-endpoint.bicep @@ -0,0 +1,72 @@ +//******************************************** +// 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 virtual network where private endpoints will be created.') +param vnetId string + +@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/web-app-mysql-flexible-server/dotnet/bicep/modules/virtual-network.bicep b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/virtual-network.bicep new file mode 100644 index 0000000..9cf440d --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/virtual-network.bicep @@ -0,0 +1,238 @@ +//******************************************** +// 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 webAppSubnetName string = 'functionAppSubnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param webAppSubnetNsgName string = '' + +@description('Specifies the name of the subnet that hosts the private endpoint to the MySQL flexible server.') +param peSubnetName string = 'pe-subnet' + +@description('Specifies the address prefix of the subnet that hosts the private endpoint to the MySQL flexible server.') +param peSubnetAddressPrefix string = '10.0.1.0/24' + +@description('Specifies the name of the network security group associated with the private-endpoint subnet.') +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: webAppSubnetName + properties: { + addressPrefix: webAppSubnetAddressPrefix + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' + networkSecurityGroup: { + id: webAppSubnetNsg.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 webAppSubnetNsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: webAppSubnetNsgName + 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 webAppSubnetNsgDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: diagnosticSettingsName + scope: webAppSubnetNsg + 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 webAppSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, webAppSubnetName) +output webAppSubnetName string = webAppSubnetName +output peSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, peSubnetName) +output peSubnetName string = peSubnetName diff --git a/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/web-app.bicep b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/web-app.bicep new file mode 100644 index 0000000..bdda325 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/bicep/modules/web-app.bicep @@ -0,0 +1,214 @@ +//******************************************** +// Parameters +//******************************************** + +@description('Specifies a globally unique name the Azure Web 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 = 'app,linux' + +@description('Specifies the language runtime used by the Azure Web App.') +@allowed([ + 'dotnet' + 'dotnet-isolated' + 'dotnetcore' + 'python' + 'java' + 'node' + 'powerShell' + 'custom' +]) +param runtimeName string + +@description('Specifies the target language version used by the Azure Web App.') +param runtimeVersion string + +@description('Specifies the minimum TLS version for the Azure Web App.') +@allowed([ + '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 HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = true + +@description('Specifies the name of the hosting plan.') +param hostingPlanName string + +@description('Specifies the FQDN of the MySQL flexible server (e.g. .mysql.database.azure.com).') +param mysqlHost string + +@description('Specifies the TCP port the MySQL server listens on. 3306 in real Azure; in the emulator the FQDN encodes the dynamically allocated proxy port and main.bicep splits it.') +param mysqlPort string = '3306' + +@description('Specifies the name of the database to connect to.') +param mysqlDatabase string = 'sampledb' + +@description('Specifies the name of the virtual network.') +param virtualNetworkName string + +@description('Specifies the name of the subnet used by the Web App for the regional virtual network integration.') +param subnetName string + +@description('Specifies the resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the username for the application.') +param username string = 'paolo' + +@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 = 'default' +var logCategories = [ + 'AppServiceHTTPLogs' + 'AppServiceConsoleLogs' + 'AppServiceAppLogs' + 'AppServiceAuditLogs' + 'AppServiceIPSecAuditLogs' + 'AppServicePlatformLogs' + '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 virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: virtualNetworkName +} + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = { + parent: virtualNetwork + name: subnetName +} + +resource hostingPlan 'Microsoft.Web/serverfarms@2024-04-01' existing = { + name: hostingPlanName +} + +resource webApp 'Microsoft.Web/sites@2025-03-01' = { + name: name + location: location + tags: tags + kind: kind + properties: { + httpsOnly: httpsOnly + serverFarmId: hostingPlan.id + virtualNetworkSubnetId: subnet.id + outboundVnetRouting: { + allTraffic: true + } + siteConfig: { + linuxFxVersion: toUpper('${runtimeName}|${runtimeVersion}') + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + } + } + identity: { + type: 'SystemAssigned' + } +} + + +resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = { + parent: webApp + name: 'appsettings' + properties: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + // MYSQL_USER and MYSQL_PASSWORD are NOT set here. The post-deploy script connects to the + // server via the firewall-allowed public endpoint to (a) create the application user + // `testuser` and (b) write `MYSQL_USER` / `MYSQL_PASSWORD` onto this Web App via `az webapp + // config appsettings set`. The server-admin login is never exposed to the Web App at runtime. + MYSQL_HOST: mysqlHost + MYSQL_PORT: mysqlPort + MYSQL_DATABASE: mysqlDatabase + MYSQL_SSL: 'true' + WEBSITES_PORT: '8000' + LOGIN_NAME: username + } +} + +resource webAppSourceControl 'Microsoft.Web/sites/sourcecontrols@2024-11-01' = if (contains(repoUrl,'http')){ + name: 'web' + parent: webApp + properties: { + repoUrl: repoUrl + branch: 'master' + isManualIntegration: true + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if(!empty(workspaceId)) { + name: diagnosticSettingsName + scope: webApp + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = webApp.id +output name string = webApp.name +output defaultHostName string = webApp.properties.defaultHostName diff --git a/samples/web-app-mysql-flexible-server/dotnet/images/architecture.png b/samples/web-app-mysql-flexible-server/dotnet/images/architecture.png new file mode 100644 index 0000000..10dcc6f Binary files /dev/null and b/samples/web-app-mysql-flexible-server/dotnet/images/architecture.png differ diff --git a/samples/web-app-mysql-flexible-server/dotnet/images/vacation-planner.png b/samples/web-app-mysql-flexible-server/dotnet/images/vacation-planner.png new file mode 100644 index 0000000..a7c5151 Binary files /dev/null and b/samples/web-app-mysql-flexible-server/dotnet/images/vacation-planner.png differ diff --git a/samples/web-app-mysql-flexible-server/dotnet/scripts/README.md b/samples/web-app-mysql-flexible-server/dotnet/scripts/README.md new file mode 100644 index 0000000..5e3fd2d --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/scripts/README.md @@ -0,0 +1,71 @@ +# Azure CLI Deployment + +This directory contains Bash scripts for deploying and validating the sample using the `lstk` CLI. For details about the sample application, see [Azure Web App with Azure Database for MySQL flexible server](../README.md). + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [Docker](https://docs.docker.com/get-docker/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) + [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [MySQL client (`mysql`)](https://dev.mysql.com/downloads/) +- [`jq`](https://jqlang.org/) + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +[`deploy.sh`](deploy.sh) provisions the same resources as the Bicep and Terraform variants but with raw `az` commands: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli). +2. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview). +3. [Network Security Groups](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview) for both subnets. +4. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview). +5. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview) with: + - *app-subnet*: delegated to `Microsoft.Web/serverFarms` (with NAT gateway). + - *pe-subnet*: hosts the Private Endpoint (no delegation; `disable-private-endpoint-network-policies=true`). +6. [Azure Database for MySQL flexible server](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/overview): public-access mode, `Burstable / Standard_B1ms`, version 8.0.21, 32 GiB, HA disabled. With a permissive `AllowAllIPs` firewall rule. +7. The `plannerdb` [database](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-create-manage-databases). +8. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) `privatelink.mysql.database.azure.com`, linked to the VNet. +9. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) targeting the MySQL server with group `mysqlServer`, plus the DNS-zone group that auto-registers the A record. +10. A separate application user (`testuser`) created via the `mysql` client, with privileges on `plannerdb`. +11. The `activities` table and the seeded rows. +12. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). +13. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration into *app-subnet*, configured with `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER=testuser`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`, `LOGIN_NAME`, `WEBSITES_PORT`. + +The Web App uses `testuser` — the server-admin login is never written into the Web App's app settings. Use [`validate.sh`](validate.sh) after `deploy.sh` to inspect each Azure resource. + +## Usage + +```bash +# default secrets +bash deploy.sh + +# override secrets via env vars +MYSQL_ADMIN_PASSWORD='' \ +MYSQL_APP_PASSWORD='' \ +bash deploy.sh + +# inspect what was deployed +bash validate.sh +``` + +`deploy.sh` accepts the following environment overrides: + +| Env var | Default | Description | +| --------------------- | ------------------ | --------------------------------------------- | +| `MYSQL_ADMIN_USER` | `myadmin` | Server administrator login | +| `MYSQL_ADMIN_PASSWORD`| `P@ssw0rd1234!` | Server administrator password (sensitive) | +| `MYSQL_DATABASE_NAME` | `plannerdb` | Application database | +| `MYSQL_APP_USER` | `testuser` | Application user used by the Web App | +| `MYSQL_APP_PASSWORD` | `TestP@ssw0rd123` | Password for the application user | + +The script uses [`call-web-app.sh`](call-web-app.sh) (unchanged from the source sample) to demonstrate four ways of hitting the Web App from outside the emulator. + +## Related Documentation + +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-mysql-flexible-server/dotnet/scripts/call-web-app.sh b/samples/web-app-mysql-flexible-server/dotnet/scripts/call-web-app.sh new file mode 100755 index 0000000..b521aed --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/scripts/call-web-app.sh @@ -0,0 +1,201 @@ +#!/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_web_app() { + # Web app port + local web_app_port=8000 + + # Get the web app name + echo "Getting web app name..." + web_app_name=$(az webapp list --query '[0].name' --output tsv) + + if [ -n "$web_app_name" ]; then + echo "Web app [$web_app_name] successfully retrieved." + else + echo "Error: No web app found" + exit 1 + fi + + # Get the resource group name + echo "Getting resource group name for web app [$web_app_name]..." + resource_group_name=$(az webapp 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 web app [$web_app_name]" + exit 1 + fi + + # Get the the default host name of the web app + echo "Getting the default host name of the web app [$web_app_name]..." + app_host_name=$(az webapp show \ + --name "$web_app_name" \ + --resource-group "$resource_group_name" \ + --query 'defaultHostName' \ + --output tsv) + + if [ -n "$app_host_name" ]; then + echo "Web app default host name [$app_host_name] successfully retrieved." + else + echo "Error: No web app default host name found" + exit 1 + fi + + # Get the Docker container name + echo "Finding container name with prefix [ls-$web_app_name]..." + container_name=$(get_docker_container_name_by_prefix "ls-$web_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") + + 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 web app HTTP trigger (internal port 8000) + echo "Getting the host port mapped to internal port $web_app_port in container [$container_name]..." + host_port=$(get_docker_container_port_mapping "$container_name" "$web_app_port") + + 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]" + fi + + # Retrieve LocalStack proxy port + proxy_port=$(curl --max-time 10 http://localhost:4566/_localstack/proxy -s | jq '.proxy_port') + + if [ -n "$proxy_port" ]; then + # Call the web app via emulator proxy + echo "Calling web app [$web_app_name] via emulator..." + curl --max-time 10 --proxy "http://localhost:$proxy_port/" -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via emulator proxy port [$proxy_port] succeeded." + else + echo "Web app call via emulator proxy port [$proxy_port] failed." + fi + else + echo "Failed to retrieve LocalStack proxy port" + fi + + if [ -n "$container_ip" ]; then + # Call the web app via the container IP address + echo "Calling web app [$web_app_name] via container IP address [$container_ip]..." + curl --max-time 10 -s "http://$container_ip:$web_app_port/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via container IP address [$container_ip] succeeded." + else + echo "Web app call via container IP address [$container_ip] failed." + fi + else + echo "Failed to retrieve container IP address" + fi + + if [ -n "$host_port" ]; then + # Call the web app via the host port + echo "Calling web app [$web_app_name] via host port [$host_port]..." + curl --max-time 10 -s "http://127.0.0.1:$host_port/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via host port [$host_port] succeeded." + else + echo "Web app call via host port [$host_port] failed." + fi + else + echo "Failed to retrieve host port" + fi + + if [ -n "$app_host_name" ]; then + # Call the web app via the default hostname + echo "Calling web app [$web_app_name] via default hostname [$app_host_name]..." + curl --max-time 10 -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via default hostname [$app_host_name] succeeded." + else + echo "Web app call via default hostname [$app_host_name] failed." + fi + else + echo "Failed to retrieve web app hostname" + fi +} + +call_web_app \ No newline at end of file diff --git a/samples/web-app-mysql-flexible-server/dotnet/scripts/deploy.sh b/samples/web-app-mysql-flexible-server/dotnet/scripts/deploy.sh new file mode 100755 index 0000000..74e4921 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/scripts/deploy.sh @@ -0,0 +1,1147 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +DIAGNOSTIC_SETTINGS_NAME='default' +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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" +WEB_APP_SUBNET_NAME="app-subnet" +WEB_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_NAME="privatelink.mysql.database.azure.com" +PRIVATE_ENDPOINT_NAME="${PREFIX}-mysql-pe-${SUFFIX}" +PRIVATE_ENDPOINT_GROUP="mysqlServer" +PRIVATE_DNS_ZONE_GROUP_NAME="default" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="S1" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +MYSQL_SERVER_NAME="${PREFIX}-mysqlflex-${SUFFIX}" +MYSQL_VERSION="8.0.21" +MYSQL_SKU_NAME="Standard_B1ms" +MYSQL_SKU_TIER="Burstable" +MYSQL_STORAGE_SIZE_GB=32 +MYSQL_BACKUP_RETENTION_DAYS=7 +MYSQL_DATABASE_NAME="${MYSQL_DATABASE_NAME:-plannerdb}" +MYSQL_ADMIN_USER="${MYSQL_ADMIN_USER:-myadmin}" +MYSQL_ADMIN_PASSWORD="${MYSQL_ADMIN_PASSWORD:-P@ssw0rd1234!}" +MYSQL_APP_USER="${MYSQL_APP_USER:-testuser}" +MYSQL_APP_PASSWORD="${MYSQL_APP_PASSWORD:-TestP@ssw0rd123}" +FIREWALL_RULE_NAME="AllowAllIPs" +RUNTIME="dotnetcore" +RUNTIME_VERSION="10.0" +LOGIN_NAME="paolo" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit +# Create a resource group +echo "Creating resource group [$RESOURCE_GROUP_NAME]..." +az group create \ + --name $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created successfully." +else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 +fi + +# Check if the MySQL flexible server already exists +echo "Checking if [$MYSQL_SERVER_NAME] MySQL flexible server already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az mysql flexible-server show \ + --name $MYSQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$MYSQL_SERVER_NAME] MySQL flexible server already exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$MYSQL_SERVER_NAME] MySQL flexible server in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create a MySQL flexible server with public network access + az mysql flexible-server create \ + --name $MYSQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --tier $MYSQL_SKU_TIER \ + --sku-name $MYSQL_SKU_NAME \ + --version $MYSQL_VERSION \ + --storage-size $MYSQL_STORAGE_SIZE_GB \ + --backup-retention $MYSQL_BACKUP_RETENTION_DAYS \ + --geo-redundant-backup Disabled \ + --admin-user $MYSQL_ADMIN_USER \ + --admin-password "$MYSQL_ADMIN_PASSWORD" \ + --public-access Enabled \ + --high-availability Disabled \ + --yes \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "[$MYSQL_SERVER_NAME] MySQL flexible server successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$MYSQL_SERVER_NAME] MySQL flexible server in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$MYSQL_SERVER_NAME] MySQL flexible server already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Retrieve the resource id of the MySQL flexible server +echo "Getting [$MYSQL_SERVER_NAME] MySQL flexible server resource id in the [$RESOURCE_GROUP_NAME] resource group..." +MYSQL_SERVER_ID=$(az mysql flexible-server show \ + --name $MYSQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query id \ + --output tsv \ + --only-show-errors) + +if [ -n "$MYSQL_SERVER_ID" ]; then + echo "MySQL flexible server resource id retrieved successfully: $MYSQL_SERVER_ID" +else + echo "Failed to retrieve MySQL flexible server resource id." + exit 1 +fi + +# Retrieve the fullyQualifiedDomainName of the MySQL flexible server +echo "Getting [$MYSQL_SERVER_NAME] MySQL flexible server FQDN in the [$RESOURCE_GROUP_NAME] resource group..." +MYSQL_FQDN_FULL=$(az mysql flexible-server show \ + --name $MYSQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "fullyQualifiedDomainName" \ + --output tsv \ + --only-show-errors) + +if [ -n "$MYSQL_FQDN_FULL" ]; then + echo "MySQL flexible server FQDN retrieved successfully: $MYSQL_FQDN_FULL" +else + echo "Failed to retrieve MySQL flexible server FQDN." + exit 1 +fi + +# Split host:port — the LocalStack emulator embeds the dynamically allocated TCP-proxy port +# directly in fullyQualifiedDomainName, mirroring the storage / container registry emulators. +# Real Azure returns just the bare host so MYSQL_PORT defaults to 3306. +MYSQL_FQDN="${MYSQL_FQDN_FULL%%:*}" +if [[ "$MYSQL_FQDN_FULL" == *:* ]]; then + MYSQL_PORT="${MYSQL_FQDN_FULL##*:}" +else + MYSQL_PORT=3306 +fi +echo "MySQL host = $MYSQL_FQDN, port = $MYSQL_PORT" + +# Check if the server-level firewall rule already exists +echo "Checking if [$FIREWALL_RULE_NAME] firewall rule already exists on the [$MYSQL_SERVER_NAME] MySQL flexible server..." +az mysql flexible-server firewall-rule show \ + --name $MYSQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --rule-name $FIREWALL_RULE_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$FIREWALL_RULE_NAME] firewall rule already exists on the [$MYSQL_SERVER_NAME] MySQL flexible server" + echo "Creating [$FIREWALL_RULE_NAME] firewall rule on the [$MYSQL_SERVER_NAME] MySQL flexible server..." + + # Create a permissive firewall rule so the deploy machine can run the mysql bootstrap. + # The create is retried because this PUT intermittently answers 500 against the emulator while + # the server finishes provisioning, and the Azure CLI's own retries all land within a few seconds. + FIREWALL_RULE_CREATED=0 + for attempt in $(seq 1 5); do + if az mysql flexible-server firewall-rule create \ + --name $MYSQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --rule-name $FIREWALL_RULE_NAME \ + --start-ip-address "0.0.0.0" \ + --end-ip-address "255.255.255.255" \ + --only-show-errors 1>/dev/null; then + FIREWALL_RULE_CREATED=1 + break + fi + + if [ "$attempt" -lt 5 ]; then + echo "Attempt $attempt of 5 to create the [$FIREWALL_RULE_NAME] firewall rule failed; retrying in 10 seconds..." + sleep 10 + fi + done + + if [ $FIREWALL_RULE_CREATED -eq 1 ]; then + echo "[$FIREWALL_RULE_NAME] firewall rule successfully created on the [$MYSQL_SERVER_NAME] MySQL flexible server" + else + # Not fatal: the rule governs public network access, which the emulator does not enforce, and + # the mysql bootstrap below fails loudly if the server is genuinely unreachable. + echo "WARNING: could not create the [$FIREWALL_RULE_NAME] firewall rule on the [$MYSQL_SERVER_NAME] MySQL flexible server; continuing" + fi +else + echo "[$FIREWALL_RULE_NAME] firewall rule already exists on the [$MYSQL_SERVER_NAME] MySQL flexible server" +fi + +# Check if the MySQL database already exists +echo "Checking if [$MYSQL_DATABASE_NAME] database already exists on the [$MYSQL_SERVER_NAME] MySQL flexible server..." +az mysql flexible-server db show \ + --server-name $MYSQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --database-name $MYSQL_DATABASE_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$MYSQL_DATABASE_NAME] database already exists on the [$MYSQL_SERVER_NAME] MySQL flexible server" + echo "Creating [$MYSQL_DATABASE_NAME] database on the [$MYSQL_SERVER_NAME] MySQL flexible server..." + + # Create the application database + az mysql flexible-server db create \ + --server-name $MYSQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --database-name $MYSQL_DATABASE_NAME \ + --charset utf8mb4 \ + --collation utf8mb4_unicode_ci \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "[$MYSQL_DATABASE_NAME] database successfully created on the [$MYSQL_SERVER_NAME] MySQL flexible server" + else + echo "Failed to create [$MYSQL_DATABASE_NAME] database on the [$MYSQL_SERVER_NAME] MySQL flexible server" + exit 1 + fi +else + echo "[$MYSQL_DATABASE_NAME] database already exists on the [$MYSQL_SERVER_NAME] MySQL flexible server" +fi + +# Check if the network security group for the web app subnet already exists +echo "Checking if [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az network nsg show \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet..." + + # Create the network security group for the web app subnet + az network nsg create \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Get the resource id of the network security group for the web app subnet +echo "Getting [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet resource id in the [$RESOURCE_GROUP_NAME] resource group..." +WEB_APP_SUBNET_NSG_ID=$(az network nsg show \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_SUBNET_NSG_ID ]]; then + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet resource id retrieved successfully: $WEB_APP_SUBNET_NSG_ID" +else + echo "Failed to retrieve [$WEB_APP_SUBNET_NSG_NAME] network security group for the web 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 environment=test iac=az-cli \ + --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 environment=test iac=az-cli \ + --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 environment=test iac=az-cli \ + --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 "$WEB_APP_SUBNET_NAME" \ + --subnet-prefix "$WEB_APP_SUBNET_PREFIX" \ + --tags environment=test iac=az-cli \ + --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 web app subnet to associate it with the NAT Gateway and the NSG + echo "Associating [$WEB_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_APP_SUBNET_NSG_NAME] network security group..." + + # Update the web app subnet to associate it with the NAT Gateway and the NSG + az network vnet subnet update \ + --name "$WEB_APP_SUBNET_NAME" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --nat-gateway "$NAT_GATEWAY_NAME" \ + --network-security-group "$WEB_APP_SUBNET_NSG_NAME" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$WEB_APP_SUBNET_NAME] subnet successfully associated with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_APP_SUBNET_NSG_NAME] network security group" + else + echo "Failed to associate [$WEB_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_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 + +# Check if the private DNS Zone already exists +echo "Checking if [$PRIVATE_DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az network private-dns zone show \ + --name "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$PRIVATE_DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PRIVATE_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 "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$PRIVATE_DNS_ZONE_NAME] private DNS zone successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$PRIVATE_DNS_ZONE_NAME] private DNS zone in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "[$PRIVATE_DNS_ZONE_NAME] private DNS zone already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the virtual network link between the private DNS zone and the virtual network already exists +echo "Checking if [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_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 "$PRIVATE_DNS_ZONE_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network actually exists" + + echo "Creating [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network..." + + # Create the virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network + az network private-dns link vnet create \ + --name "$VIRTUAL_NETWORK_LINK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --zone-name "$PRIVATE_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 [$PRIVATE_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 [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network" + exit + fi +else + echo "[$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network already exists" +fi + +# Check if the private endpoint already exists +echo "Checking if private endpoint [$PRIVATE_ENDPOINT_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=='$PRIVATE_ENDPOINT_NAME'].id" \ + --output tsv) + +if [[ -z $privateEndpointId ]]; then + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] does not exist in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PRIVATE_ENDPOINT_NAME] private endpoint for the [$MYSQL_SERVER_NAME] MySQL flexible server in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create a private endpoint for the MySQL flexible server + az network private-endpoint create \ + --name "$PRIVATE_ENDPOINT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --subnet "$PE_SUBNET_NAME" \ + --private-connection-resource-id "$MYSQL_SERVER_ID" \ + --group-id "$PRIVATE_ENDPOINT_GROUP" \ + --connection-name "mysql-connection" \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] successfully created for the [$MYSQL_SERVER_NAME] MySQL flexible server in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create a private endpoint for the [$MYSQL_SERVER_NAME] MySQL flexible server in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the private DNS zone group is already created for the MySQL flexible server private endpoint +echo "Checking if the private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint already exists..." +NAME=$(az network private-endpoint dns-zone-group show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PRIVATE_ENDPOINT_NAME" \ + --name "$PRIVATE_DNS_ZONE_GROUP_NAME" \ + --query name \ + --output tsv \ + --only-show-errors 2>/dev/null) + +if [[ -z $NAME ]]; then + echo "No private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint actually exists" + echo "Creating private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint..." + + # Create the private DNS zone group for the MySQL flexible server private endpoint + az network private-endpoint dns-zone-group create \ + --name "$PRIVATE_DNS_ZONE_GROUP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PRIVATE_ENDPOINT_NAME" \ + --private-dns-zone "$PRIVATE_DNS_ZONE_NAME" \ + --zone-name "mysql-zone" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint successfully created" + else + echo "Failed to create private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint" + exit + fi +else + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint already exists" +fi + +echo "Waiting for the [$MYSQL_SERVER_NAME] MySQL flexible server to accept connections..." +MYSQL_READY=0 +for attempt in $(seq 1 30); do + if MYSQL_PWD="$MYSQL_ADMIN_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_ADMIN_USER" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + --connect-timeout=5 \ + -e "SELECT 1;" &>/dev/null; then + MYSQL_READY=1 + echo "MySQL flexible server is accepting connections (attempt $attempt/30)" + break + fi + echo "MySQL flexible server not ready yet (attempt $attempt/30)..." + sleep 2 +done + +if [ "$MYSQL_READY" -ne 1 ]; then + echo "MySQL flexible server did not become reachable after 30 attempts. Exiting." + exit 1 +fi + +# Check if the MySQL CLI is installed +MYSQL_CHECK=$(command -v mysql) +if [[ -z "$MYSQL_CHECK" ]]; then + echo "[mysql] CLI is not installed. Install it with: sudo apt install -y mysql-client" >&2 + exit 1 +fi + +# Create application user [$MYSQL_APP_USER] on the MySQL flexible server +echo "Creating login [$MYSQL_APP_USER] on the [$MYSQL_SERVER_NAME] MySQL flexible server..." +MYSQL_PWD="$MYSQL_ADMIN_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_ADMIN_USER" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + -e "CREATE USER IF NOT EXISTS '$MYSQL_APP_USER'@'%' IDENTIFIED BY '$MYSQL_APP_PASSWORD'; + GRANT ALL PRIVILEGES ON \`$MYSQL_DATABASE_NAME\`.* TO '$MYSQL_APP_USER'@'%'; + FLUSH PRIVILEGES;" + +if [ $? -eq 0 ]; then + echo "Login [$MYSQL_APP_USER] created successfully" +else + echo "Failed to create login [$MYSQL_APP_USER]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$MYSQL_APP_USER]..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + --database="$MYSQL_DATABASE_NAME" \ + -e "SELECT CURRENT_USER() AS user_name, DATABASE() AS db_name, NOW() AS server_time;" + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$MYSQL_APP_USER]" +else + echo "Connection test failed with user [$MYSQL_APP_USER]" + exit 1 +fi + +# Create [activities] table +echo "Creating [activities] table in the [$MYSQL_DATABASE_NAME] database..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + --database="$MYSQL_DATABASE_NAME" \ + -e "CREATE TABLE IF NOT EXISTS activities ( + id VARCHAR(32) NOT NULL, + username VARCHAR(255) NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_activities_username (username), + INDEX idx_activities_created_at (created_at DESC) + );" + +if [ $? -eq 0 ]; then + echo "[activities] table created successfully" +else + echo "Failed to create [activities] table" + exit 1 +fi + +# Insert sample data +echo "Inserting sample data into [activities] table..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + --database="$MYSQL_DATABASE_NAME" \ + -e "INSERT IGNORE INTO activities (id, username, activity) VALUES + (MD5('paolo_pisa_seed'), 'paolo', 'Visit the Leaning Tower in Pisa'), + (MD5('paolo_volterra_seed'), 'paolo', 'Explore Etruscan walls in Volterra'), + (MD5('paolo_san_gimignano_seed'), 'paolo', 'Climb Torre Grossa in San Gimignano'), + (MD5('paolo_siena_seed'), 'paolo', 'Walk across Piazza del Campo in Siena'), + (MD5('paolo_montalcino_seed'), 'paolo', 'Taste Brunello wine in Montalcino'), + (MD5('paolo_pienza_seed'), 'paolo', 'Sample Pecorino cheese in Pienza'), + (MD5('paolo_florence_seed'), 'paolo', 'Admire Michelangelo''s David in Florence'), + (MD5('paolo_viareggio_beach_seed'), 'paolo', 'Relax by the beach in Viareggio'), + (MD5('paolo_viareggio_promenade_seed'), 'paolo', 'Stroll along the Viareggio promenade');" + +if [ $? -eq 0 ]; then + echo "Sample data inserted successfully into [activities] table" +else + echo "Failed to insert sample data into [activities] table" + exit 1 +fi + +# Query sample data +echo "Querying sample data from [activities] table..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + --database="$MYSQL_DATABASE_NAME" \ + -e "SELECT * FROM activities;" + +if [ $? -eq 0 ]; then + echo "Sample data queried successfully from [activities] table" +else + echo "Failed to query sample data from [activities] table" + exit 1 +fi + +# Create app service plan +echo "Creating app service plan [$APP_SERVICE_PLAN_NAME]..." +az appservice plan create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --location "$LOCATION" \ + --sku "$APP_SERVICE_PLAN_SKU" \ + --is-linux \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "app service plan [$APP_SERVICE_PLAN_NAME] created successfully." +else + echo "Failed to create app service plan [$APP_SERVICE_PLAN_NAME]." + exit 1 +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 + +# Create the web app +echo "Creating web app [$WEB_APP_NAME]..." +az webapp create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --plan "$APP_SERVICE_PLAN_NAME" \ + --name "$WEB_APP_NAME" \ + --runtime "$RUNTIME:$RUNTIME_VERSION" \ + --vnet "$VIRTUAL_NETWORK_NAME" \ + --subnet "$WEB_APP_SUBNET_NAME" \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Get the web app resource id +echo "Getting [$WEB_APP_NAME] web app resource id in the [$RESOURCE_GROUP_NAME] resource group..." +WEB_APP_ID=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_ID ]]; then + echo "[$WEB_APP_NAME] web app resource id retrieved successfully: $WEB_APP_ID" +else + echo "Failed to retrieve [$WEB_APP_NAME] web app resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Enabling forced tunneling for web app [$WEB_APP_NAME] to route all outbound traffic through the virtual network... +echo "Enabling forced tunneling for web app [$WEB_APP_NAME] to route all outbound traffic through the virtual network..." + +az resource update \ + --ids "$WEB_APP_ID" \ + --set properties.outboundVnetRouting.allTraffic=true \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Forced tunneling enabled for web app [$WEB_APP_NAME]." +else + echo "Failed to enable forced tunneling for web app [$WEB_APP_NAME]." + exit 1 +fi + +# Set web app settings +echo "Setting web app settings for [$WEB_APP_NAME]..." +az webapp config appsettings set \ + --name $WEB_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --settings \ + SCM_DO_BUILD_DURING_DEPLOYMENT='true' \ + ENABLE_ORYX_BUILD='true' \ + MYSQL_HOST="$MYSQL_FQDN" \ + MYSQL_PORT="$MYSQL_PORT" \ + MYSQL_USER="$MYSQL_APP_USER" \ + MYSQL_PASSWORD="$MYSQL_APP_PASSWORD" \ + MYSQL_DATABASE="$MYSQL_DATABASE_NAME" \ + MYSQL_SSL="true" \ + LOGIN_NAME="$LOGIN_NAME" \ + WEBSITES_PORT="8000" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app settings for [$WEB_APP_NAME] set successfully." +else + echo "Failed to set web app settings for [$WEB_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 environment=test iac=az-cli \ + --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 web app already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app..." + + # Create the diagnostic settings for the web app to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "AppServiceHTTPLogs", "enabled": true}, + {"category": "AppServiceConsoleLogs", "enabled": true}, + {"category": "AppServiceAppLogs", "enabled": true}, + {"category": "AppServiceAuditLogs", "enabled": true}, + {"category": "AppServiceIPSecAuditLogs", "enabled": true}, + {"category": "AppServicePlatformLogs", "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 [$WEB_APP_NAME] web app successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web 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 MySQL flexible server already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$MYSQL_SERVER_NAME] MySQL flexible server already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$MYSQL_SERVER_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$MYSQL_SERVER_NAME] MySQL flexible server actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$MYSQL_SERVER_NAME] MySQL flexible server..." + + # Create the diagnostic settings for the MySQL flexible server to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$MYSQL_SERVER_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "MySqlSlowLogs", "enabled": true}, + {"category": "MySqlAuditLogs", "enabled": true} + ]' \ + --metrics '[ + {"category": "AllMetrics", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$MYSQL_SERVER_NAME] MySQL flexible server successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$MYSQL_SERVER_NAME] MySQL flexible server" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$MYSQL_SERVER_NAME] MySQL flexible server 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 web app subnet already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_SUBNET_NSG_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet..." + + # Create the diagnostic settings for the network security group for the web app subnet to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_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 [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web 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 + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# List the contents of the zip package +echo "Contents of the zip package [$ZIPFILE]:" +unzip -l "$ZIPFILE" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +echo "Using standard az webapp deploy command for AzureCloud environment." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +# Remove the zip package of the web 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 + +# Ping the web app to confirm the deployment is reachable +echo "Getting the default hostname of the [$WEB_APP_NAME] web app..." +WEB_APP_HOSTNAME=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query defaultHostName \ + --output tsv \ + --only-show-errors) +WEB_APP_URL="http://${WEB_APP_HOSTNAME}" +echo "You can visit your app at: $WEB_APP_URL" + +echo "Pinging [$WEB_APP_URL] to verify the web app responds..." +HTTP_CODE="000" +for attempt in $(seq 1 12); do + HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$WEB_APP_URL" || echo "000") + if [ "$HTTP_CODE" = "200" ]; then + echo "Web app responded with HTTP 200. Deployment verified successfully." + break + fi + echo "Web app not ready yet (attempt $attempt/12, HTTP $HTTP_CODE)..." + sleep 5 +done + +if [ "$HTTP_CODE" != "200" ]; then + echo "Web app did not return HTTP 200 after 12 attempts (last code: $HTTP_CODE). Deployment verification failed." + exit 1 +fi diff --git a/samples/web-app-mysql-flexible-server/dotnet/scripts/validate.sh b/samples/web-app-mysql-flexible-server/dotnet/scripts/validate.sh new file mode 100755 index 0000000..7538368 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/scripts/validate.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +PRIVATE_DNS_ZONE_NAME="privatelink.mysql.database.azure.com" +PRIVATE_ENDPOINT_NAME="${PREFIX}-mysql-pe-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +MYSQL_SERVER_NAME="${PREFIX}-mysqlflex-${SUFFIX}" +MYSQL_DATABASE_NAME="plannerdb" +FIREWALL_RULE_NAME="AllowAllIPs" + +# 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 Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,State:state,Location:location,DefaultHostName:defaultHostName}' \ + --output table \ + --only-show-errors + +# Check Azure Database for MySQL flexible server +echo -e "\n[$MYSQL_SERVER_NAME] MySQL flexible server:\n" +az mysql flexible-server show \ + --name "$MYSQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,State:state,Version:version,FQDN:fullyQualifiedDomainName,PublicNetworkAccess:network.publicNetworkAccess}' \ + --output table \ + --only-show-errors + +# Check MySQL database +echo -e "\n[$MYSQL_DATABASE_NAME] MySQL database:\n" +az mysql flexible-server db show \ + --server-name "$MYSQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --database-name "$MYSQL_DATABASE_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup,Charset:charset,Collation:collation}' \ + --output table \ + --only-show-errors + +# Check MySQL firewall rule +echo -e "\n[$FIREWALL_RULE_NAME] MySQL firewall rule:\n" +az mysql flexible-server firewall-rule show \ + --name "$MYSQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --rule-name "$FIREWALL_RULE_NAME" \ + --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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEB_APP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEB_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 diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Models/Activity.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..84277d4 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id)) + { + await store.DeleteAsync(id, cancellationToken); + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..904f8c5 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + await store.UpdateAsync(id, text, cancellationToken); + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated!"; + } + else + { + await store.AddAsync(text, cancellationToken); + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added!"; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Program.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Program.cs new file mode 100644 index 0000000..ede6368 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Program.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Code deployments built by Oryx export ASPNETCORE_URLS; custom images and local runs only set PORT. +if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") is null + && Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseUrls($"http://*:{port}"); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var databaseOptions = MySqlOptions.FromEnvironment(); + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new MySqlActivityStore(databaseOptions, sp.GetRequiredService>())); +// The flexible server can take a few seconds to accept connections on the first deploy. +builder.Services.AddHostedService(sp => new StoreInitializer( + sp.GetRequiredService(), + sp.GetRequiredService>(), + attempts: 30, + delay: TimeSpan.FromSeconds(2))); + +var app = builder.Build(); + +// One log line per request, the equivalent of the gunicorn access log the Python sample produces. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/ActivityId.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/ActivityId.cs new file mode 100644 index 0000000..8654aaf --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/ActivityId.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VacationPlanner.Services; + +/// MD5 of username + activity + timestamp: the id scheme shared by the Vacation Planner samples. +public static class ActivityId +{ + public static string Create(string username, string activity) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"); + var hash = MD5.HashData(Encoding.UTF8.GetBytes($"{username}_{activity}_{timestamp}")); + return Convert.ToHexStringLower(hash); + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/IActivityStore.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..3b24e5f --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,21 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + Task AddAsync(string text, CancellationToken cancellationToken); + + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlActivityStore.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlActivityStore.cs new file mode 100644 index 0000000..fd53ebc --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlActivityStore.cs @@ -0,0 +1,122 @@ +using MySqlConnector; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// +/// Activities in a MySQL activities table. Like the Python sample, the store is low-throughput +/// and opens a fresh connection per call instead of managing a pool explicitly. +/// +public sealed class MySqlActivityStore(MySqlOptions options, ILogger logger) : IActivityStore +{ + // Single statement on purpose: MySQL has no CREATE INDEX IF NOT EXISTS, so the indexes are declared + // inline and the whole DDL stays idempotent. `id` is VARCHAR(32) because the ids are MD5 hex digests. + private const string SchemaDdl = """ + CREATE TABLE IF NOT EXISTS activities ( + id VARCHAR(32) NOT NULL, + username VARCHAR(255) NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_activities_username (username), + INDEX idx_activities_created_at (created_at DESC) + ) + """; + + // Azure MySQL Flexible Server defaults to require_secure_transport=ON (the LocalStack emulator mirrors + // this), so the connection must use TLS or the server rejects it. The server certificate is publicly + // trusted on Azure but self-signed under LocalStack, so TLS is enabled without certificate verification + // (MySqlSslMode.Required) and the same code path works against both targets. MYSQL_SSL=false disables it. + private readonly string _connectionString = new MySqlConnectionStringBuilder + { + Server = options.Host, + Port = (uint)options.Port, + UserID = options.User, + Password = options.Password, + Database = options.Database, + CharacterSet = "utf8mb4", + ConnectionTimeout = 10, + SslMode = options.SslEnabled ? MySqlSslMode.Required : MySqlSslMode.Disabled, + }.ConnectionString; + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand(SchemaDdl, connection); + await command.ExecuteNonQueryAsync(cancellationToken); + logger.LogInformation("MySQL schema initialized"); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand( + "SELECT id, activity FROM activities WHERE username = @username ORDER BY created_at DESC", connection); + command.Parameters.AddWithValue("@username", options.Username); + + var activities = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + activities.Add(new Activity(reader.GetString(0), reader.GetString(1))); + } + + logger.LogInformation( + "Retrieved {Count} activities for user: {Username}", + activities.Count, + options.Username + ); + return activities; + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand( + "INSERT IGNORE INTO activities (id, username, activity) VALUES (@id, @username, @activity)", connection); + command.Parameters.AddWithValue("@id", ActivityId.Create(options.Username, text)); + command.Parameters.AddWithValue("@username", options.Username); + command.Parameters.AddWithValue("@activity", text); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand("UPDATE activities SET activity = @activity WHERE id = @id", connection); + command.Parameters.AddWithValue("@activity", text); + command.Parameters.AddWithValue("@id", id); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand("DELETE FROM activities WHERE id = @id", connection); + command.Parameters.AddWithValue("@id", id); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand("SELECT 1", connection); + await command.ExecuteScalarAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "MySQL health check failed"); + return false; + } + } + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new MySqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlOptions.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlOptions.cs new file mode 100644 index 0000000..355d924 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlOptions.cs @@ -0,0 +1,30 @@ +namespace VacationPlanner.Services; + +/// Connection settings read from the same environment variables the Python sample uses. +public sealed record MySqlOptions(string Host, int Port, string User, string Password, string Database, bool SslEnabled, string Username) +{ + public static MySqlOptions FromEnvironment() + { + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "paolo"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("LOGIN_NAME cannot be empty"); + } + + var ssl = (Environment.GetEnvironmentVariable("MYSQL_SSL") ?? "true").ToLowerInvariant(); + return new MySqlOptions( + Host: Require("MYSQL_HOST"), + Port: int.Parse(Environment.GetEnvironmentVariable("MYSQL_PORT") ?? "3306"), + User: Require("MYSQL_USER"), + Password: Require("MYSQL_PASSWORD"), + Database: Environment.GetEnvironmentVariable("MYSQL_DATABASE") ?? "sampledb", + SslEnabled: ssl is "true" or "1" or "yes", + Username: username); + } + + private static string Require(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException( + $"Missing required environment variable: {name}. Set MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD (and optionally MYSQL_PORT, MYSQL_DATABASE, MYSQL_SSL)."); +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/VacationPlanner.csproj b/samples/web-app-mysql-flexible-server/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..5034f28 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/appsettings.json b/samples/web-app-mysql-flexible-server/dotnet/src/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/favicon.ico b/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/style.css b/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/README.md b/samples/web-app-mysql-flexible-server/dotnet/terraform/README.md new file mode 100644 index 0000000..c938b0a --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/README.md @@ -0,0 +1,69 @@ +# Terraform Deployment + +This directory contains Terraform modules and a deployment script for provisioning the sample's Azure resources. For details about the sample application, see [Azure Web App with Azure Database for MySQL flexible server](../README.md). + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [Terraform](https://developer.hashicorp.com/terraform/downloads) (1.5+) +- [Docker](https://docs.docker.com/get-docker/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) + [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [MySQL client (`mysql`)](https://dev.mysql.com/downloads/) +- [`jq`](https://jqlang.org/) + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +The Terraform configuration provisions: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli). +2. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview) with two subnets: + - *app-subnet* (delegated to `Microsoft.Web/serverFarms` for the Web App's VNet integration) + - *pe-subnet* (hosts the Private Endpoint to the flex server) +3. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) `privatelink.mysql.database.azure.com`, linked to the VNet. +4. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (group `mysqlServer`). +5. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview). +6. [Network Security Groups](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): one per subnet. +7. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview). +8. [Azure Database for MySQL flexible server](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/overview): public-access mode, Burstable `Standard_B1ms`, version 8.0.21, 32 GiB, HA disabled. A permissive firewall rule (`AllowAllIPs`, `0.0.0.0–255.255.255.255`) lets the deploy machine reach the server for the post-apply mysql bootstrap. +9. [MySQL database](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-create-manage-databases) `plannerdb`. +10. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). +11. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration. `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_DATABASE` are written by Terraform; `MYSQL_USER` and `MYSQL_PASSWORD` are written by `deploy.sh` after the mysql client creates the application user. + +## Provisioning Script + +[`deploy.sh`](deploy.sh) performs: + +- `terraform init -upgrade` +- `terraform plan -out=tfplan` (passing `mysql_admin_password`) +- `terraform apply -auto-approve tfplan` +- Reads outputs (`resource_group_name`, `web_app_name`, `mysql_server_name`, `mysql_fqdn`, `mysql_database_name`). +- Connects to the server as the admin via the public endpoint + firewall rule and creates the `testuser` user, grants privileges, creates the `activities` table, and seeds the rows. +- Sets `MYSQL_USER=testuser` + `MYSQL_PASSWORD=` on the Web App via `az webapp config appsettings set`. +- Zips the source under `../src` and deploys via `az webapp deploy`. + +## Variables + +Override any of the variables in [`variables.tf`](variables.tf) by editing [`terraform.tfvars`](terraform.tfvars) or passing `-var` to `terraform plan`. Notable MySQL ones: + +| Variable | Default | Description | +| ----------------------------- | ----------------- | ---------------------------------------- | +| `mysql_admin_login` | `myadmin` | Server administrator login | +| `mysql_admin_password` | `P@ssw0rd1234!` | Server administrator password (sensitive) | +| `mysql_version` | `8.0.21` | MySQL major version | +| `mysql_sku_name` | `B_Standard_B1ms` | Compute SKU | +| `mysql_storage_size_gb` | `32` | Storage size in GB | +| `mysql_backup_retention_days` | `7` | Backup retention | +| `mysql_database_name` | `plannerdb` | Application database | + +For non-dev deployments, set `mysql_admin_password` via env var: `MYSQL_ADMIN_PASSWORD=... bash deploy.sh`. + +## Related Documentation + +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/deploy.sh b/samples/web-app-mysql-flexible-server/dotnet/terraform/deploy.sh new file mode 100755 index 0000000..739e2e2 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/deploy.sh @@ -0,0 +1,278 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +MYSQL_ADMIN_USER="${MYSQL_ADMIN_USER:-myadmin}" +MYSQL_ADMIN_PASSWORD="${MYSQL_ADMIN_PASSWORD:-P@ssw0rd1234!}" +MYSQL_APP_USER="${MYSQL_APP_USER:-testuser}" +MYSQL_APP_PASSWORD="${MYSQL_APP_PASSWORD:-TestP@ssw0rd123}" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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" \ + -var "mysql_admin_login=$MYSQL_ADMIN_USER" \ + -var "mysql_admin_password=$MYSQL_ADMIN_PASSWORD" + +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) +WEB_APP_NAME=$(terraform output -raw web_app_name) +MYSQL_SERVER_NAME=$(terraform output -raw mysql_server_name) +MYSQL_FQDN_FULL=$(terraform output -raw mysql_fqdn) +DATABASE_NAME=$(terraform output -raw mysql_database_name) + +if [[ -z "$RESOURCE_GROUP_NAME" || -z "$WEB_APP_NAME" || -z "$MYSQL_SERVER_NAME" ]]; then + echo "Resource Group Name, Web App Name, or MySQL Server Name is empty. Exiting." + exit 1 +fi + +# Split host:port — the LocalStack emulator embeds the dynamically allocated TCP-proxy port +# directly in fullyQualifiedDomainName, mirroring the storage / container registry emulators. +# Real Azure returns just the bare host so MYSQL_PORT defaults to 3306. +MYSQL_FQDN="${MYSQL_FQDN_FULL%%:*}" +if [[ "$MYSQL_FQDN_FULL" == *:* ]]; then + MYSQL_PORT="${MYSQL_FQDN_FULL##*:}" +else + MYSQL_PORT=3306 +fi +echo "MySQL host = $MYSQL_FQDN, port = $MYSQL_PORT" + +# Check if the MySQL CLI is installed +MYSQL_CHECK=$(command -v mysql) +if [[ -z "$MYSQL_CHECK" ]]; then + echo "[mysql] CLI is not installed. Install it with: sudo apt install -y mysql-client" >&2 + exit 1 +fi + +echo "Waiting for the [$MYSQL_SERVER_NAME] MySQL flexible server to accept connections..." +MYSQL_READY=0 +for attempt in $(seq 1 30); do + if MYSQL_PWD="$MYSQL_ADMIN_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_ADMIN_USER" \ + --protocol=TCP \ + --connect-timeout=5 \ + -e "SELECT 1;" &>/dev/null; then + MYSQL_READY=1 + echo "MySQL flexible server is accepting connections (attempt $attempt/30)" + break + fi + echo "MySQL flexible server not ready yet (attempt $attempt/30)..." + sleep 2 +done + +if [ "$MYSQL_READY" -ne 1 ]; then + echo "MySQL flexible server did not become reachable after 30 attempts. Exiting." + exit 1 +fi + +# Create application user [$MYSQL_APP_USER] on the MySQL flexible server +echo "Creating login [$MYSQL_APP_USER] on the [$MYSQL_SERVER_NAME] MySQL flexible server..." +MYSQL_PWD="$MYSQL_ADMIN_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_ADMIN_USER" \ + --protocol=TCP \ + -e "CREATE USER IF NOT EXISTS '$MYSQL_APP_USER'@'%' IDENTIFIED BY '$MYSQL_APP_PASSWORD'; + GRANT ALL PRIVILEGES ON \`$DATABASE_NAME\`.* TO '$MYSQL_APP_USER'@'%'; + FLUSH PRIVILEGES;" + +if [ $? -eq 0 ]; then + echo "Login [$MYSQL_APP_USER] created successfully" +else + echo "Failed to create login [$MYSQL_APP_USER]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$MYSQL_APP_USER]..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --database="$DATABASE_NAME" \ + -e "SELECT CURRENT_USER() AS user_name, DATABASE() AS db_name, NOW() AS server_time;" + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$MYSQL_APP_USER]" +else + echo "Connection test failed with user [$MYSQL_APP_USER]" + exit 1 +fi + +# Create [activities] table +echo "Creating [activities] table in the [$DATABASE_NAME] database..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --database="$DATABASE_NAME" \ + -e "CREATE TABLE IF NOT EXISTS activities ( + id VARCHAR(32) NOT NULL, + username VARCHAR(255) NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_activities_username (username), + INDEX idx_activities_created_at (created_at DESC) + );" + +if [ $? -eq 0 ]; then + echo "[activities] table created successfully" +else + echo "Failed to create [activities] table" + exit 1 +fi + +# Insert sample data +echo "Inserting sample data into [activities] table..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --database="$DATABASE_NAME" \ + -e "INSERT IGNORE INTO activities (id, username, activity) VALUES + (MD5('paolo_pisa_seed'), 'paolo', 'Visit the Leaning Tower in Pisa'), + (MD5('paolo_volterra_seed'), 'paolo', 'Explore Etruscan walls in Volterra'), + (MD5('paolo_san_gimignano_seed'), 'paolo', 'Climb Torre Grossa in San Gimignano'), + (MD5('paolo_siena_seed'), 'paolo', 'Walk across Piazza del Campo in Siena'), + (MD5('paolo_montalcino_seed'), 'paolo', 'Taste Brunello wine in Montalcino'), + (MD5('paolo_pienza_seed'), 'paolo', 'Sample Pecorino cheese in Pienza'), + (MD5('paolo_florence_seed'), 'paolo', 'Admire Michelangelo''s David in Florence'), + (MD5('paolo_viareggio_beach_seed'), 'paolo', 'Relax by the beach in Viareggio'), + (MD5('paolo_viareggio_promenade_seed'), 'paolo', 'Stroll along the Viareggio promenade');" + +if [ $? -eq 0 ]; then + echo "Sample data inserted successfully into [activities] table" +else + echo "Failed to insert sample data into [activities] table" + exit 1 +fi + +# Query sample data +echo "Querying sample data from [activities] table..." +MYSQL_PWD="$MYSQL_APP_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_APP_USER" \ + --protocol=TCP \ + --database="$DATABASE_NAME" \ + -e "SELECT * FROM activities;" + +if [ $? -eq 0 ]; then + echo "Sample data queried successfully from [activities] table" +else + echo "Failed to query sample data from [activities] table" + exit 1 +fi + +# Set MYSQL_USER + MYSQL_PASSWORD on the web app to point at the application user +echo "Setting MYSQL_USER=[$MYSQL_APP_USER] and MYSQL_PASSWORD on the [$WEB_APP_NAME] web app..." +az webapp config appsettings set \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --settings MYSQL_USER="$MYSQL_APP_USER" MYSQL_PASSWORD="$MYSQL_APP_PASSWORD" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "MYSQL_USER and MYSQL_PASSWORD set successfully on the [$WEB_APP_NAME] web app" +else + echo "Failed to set MYSQL_USER and MYSQL_PASSWORD on the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Print the application settings of the web app +echo "Retrieving application settings for web app [$WEB_APP_NAME]..." +az webapp config appsettings list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +# Remove the zip package of the web 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 + +# Ping the web app to confirm the deployment is reachable +echo "Getting the default hostname of the [$WEB_APP_NAME] web app..." +WEB_APP_HOSTNAME=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query defaultHostName \ + --output tsv \ + --only-show-errors) +WEB_APP_URL="http://${WEB_APP_HOSTNAME}" +echo "You can visit your app at: $WEB_APP_URL" + +echo "Pinging [$WEB_APP_URL] to verify the web app responds..." +HTTP_CODE="000" +for attempt in $(seq 1 12); do + HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$WEB_APP_URL" || echo "000") + if [ "$HTTP_CODE" = "200" ]; then + echo "Web app responded with HTTP 200. Deployment verified successfully." + break + fi + echo "Web app not ready yet (attempt $attempt/12, HTTP $HTTP_CODE)..." + sleep 5 +done + +if [ "$HTTP_CODE" != "200" ]; then + echo "Web app did not return HTTP 200 after 12 attempts (last code: $HTTP_CODE). Deployment verification failed." + exit 1 +fi diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/main.tf new file mode 100644 index 0000000..eadc9ee --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/main.tf @@ -0,0 +1,198 @@ +locals { + prefix = lower(var.prefix) + suffix = lower(var.suffix) + resource_group_name = "${var.prefix}-rg" + log_analytics_name = "${local.prefix}-log-analytics-${local.suffix}" + virtual_network_name = "${local.prefix}-vnet-${local.suffix}" + nat_gateway_name = "${local.prefix}-nat-gateway-${local.suffix}" + webapp_subnet_nsg_name = "${local.prefix}-webapp-subnet-nsg-${local.suffix}" + pe_subnet_nsg_name = "${local.prefix}-pe-subnet-nsg-${local.suffix}" + mysql_server_name = "${local.prefix}-mysqlflex-${local.suffix}" + private_endpoint_name = "${local.prefix}-mysql-pe-${local.suffix}" + app_service_plan_name = "${local.prefix}-app-service-plan-${local.suffix}" + web_app_name = "${local.prefix}-webapp-${local.suffix}" + private_dns_zone_name = "privatelink.mysql.database.azure.com" + + # The MySQL flexible-server emulator embeds the LS-side TCP-proxy port directly in + # `fullyQualifiedDomainName` (e.g. ".mysql.database.localhost.localstack.cloud:4515"). + # Real Azure returns just the bare host on 3306. Split on ":" so the Web App always gets the + # right host + port without any post-apply shell logic. + mysql_fqdn_parts = split(":", module.mysql_flexible_server.fqdn) + mysql_host = local.mysql_fqdn_parts[0] + mysql_port = length(local.mysql_fqdn_parts) > 1 ? local.mysql_fqdn_parts[1] : "3306" +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "example" { + name = local.resource_group_name + location = var.location + tags = var.tags +} + +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 +} + +# VNet with two subnets: +# * app-subnet — delegated to Microsoft.Web/serverFarms for the Web App's regional +# VNet integration. Outbound through the NAT Gateway. +# * pe-subnet — hosts the Private Endpoint to the MySQL flexible server (no +# delegation; standard private-link subnet). +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 + } + ] +} + +module "webapp_subnet_network_security_group" { + source = "./modules/network_security_group" + name = local.webapp_subnet_nsg_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] + } +} + +module "pe_subnet_network_security_group" { + source = "./modules/network_security_group" + name = local.pe_subnet_nsg_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.pe_subnet_name) = module.virtual_network.subnet_ids[var.pe_subnet_name] + } +} + +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 +} + +module "private_dns_zone" { + source = "./modules/private_dns_zone" + name = local.private_dns_zone_name + 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 + } + } +} + +module "mysql_flexible_server" { + source = "./modules/mysql_flexible_server" + name = local.mysql_server_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + administrator_login = var.mysql_admin_login + administrator_password = var.mysql_admin_password + mysql_version = var.mysql_version + sku_name = var.mysql_sku_name + storage_size_gb = var.mysql_storage_size_gb + backup_retention_days = var.mysql_backup_retention_days + database_name = var.mysql_database_name + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags +} + +module "private_endpoint" { + source = "./modules/private_endpoint" + name = local.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.mysql_flexible_server.id + is_manual_connection = false + subresource_name = "mysqlServer" + private_dns_zone_group_name = "private-dns-zone-group" + private_dns_zone_group_ids = [module.private_dns_zone.id] +} + +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 +} + +# Note: MYSQL_USER and MYSQL_PASSWORD are intentionally NOT set here. The post-apply step in +# deploy.sh connects to the server (via the firewall-allowed public endpoint) as the admin, +# creates the application user `testuser`, seeds the schema, and then writes `MYSQL_USER` / +# `MYSQL_PASSWORD` onto this Web App via `az webapp config appsettings set`. The server-admin +# login is never exposed to the Web App at runtime. +module "web_app" { + source = "./modules/web_app" + name = local.web_app_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + service_plan_id = module.app_service_plan.id + 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 + dotnet_version = var.dotnet_version + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags + + app_settings = { + SCM_DO_BUILD_DURING_DEPLOYMENT = "true" + ENABLE_ORYX_BUILD = "true" + MYSQL_HOST = local.mysql_host + MYSQL_PORT = local.mysql_port + MYSQL_DATABASE = module.mysql_flexible_server.database_name + MYSQL_SSL = "true" + LOGIN_NAME = var.login_name + WEBSITES_PORT = var.websites_port + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/app_service_plan/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/app_service_plan/main.tf new file mode 100644 index 0000000..98a3e4d --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/app_service_plan/outputs.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/app_service_plan/outputs.tf new file mode 100644 index 0000000..f1455ea --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/app_service_plan/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/app_service_plan/variables.tf new file mode 100644 index 0000000..e543066 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/log_analytics/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/log_analytics/main.tf new file mode 100644 index 0000000..2f88414 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/log_analytics/output.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/log_analytics/output.tf new file mode 100644 index 0000000..fe2c398 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/log_analytics/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/log_analytics/variables.tf new file mode 100644 index 0000000..2db6a01 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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 "tags" { + description = "(Optional) Specifies the tags of the Azure Log Analytics workspace." + type = map(any) + default = {} +} + +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 +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/mysql_flexible_server/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/mysql_flexible_server/main.tf new file mode 100644 index 0000000..c599dab --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/mysql_flexible_server/main.tf @@ -0,0 +1,55 @@ +resource "azurerm_mysql_flexible_server" "this" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + version = var.mysql_version + administrator_login = var.administrator_login + administrator_password = var.administrator_password + sku_name = var.sku_name + backup_retention_days = var.backup_retention_days + # Public access is enabled by default (no delegated_subnet_id / private_dns_zone_id is set), + # and a permissive firewall rule lets the deploy machine reach the server just long enough to + # run the post-deploy mysql bootstrap. The Web App itself reaches the server through a Private + # Endpoint (see the private_endpoint module in main.tf). + geo_redundant_backup_enabled = false + + storage { + size_gb = var.storage_size_gb + } + + tags = var.tags +} + +resource "azurerm_mysql_flexible_database" "this" { + name = var.database_name + resource_group_name = var.resource_group_name + server_name = azurerm_mysql_flexible_server.this.name + charset = var.database_charset + collation = var.database_collation +} + +resource "azurerm_mysql_flexible_server_firewall_rule" "allow_all" { + name = var.firewall_rule_name + resource_group_name = var.resource_group_name + server_name = azurerm_mysql_flexible_server.this.name + start_ip_address = var.firewall_start_ip + end_ip_address = var.firewall_end_ip +} + +resource "azurerm_monitor_diagnostic_setting" "this" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_mysql_flexible_server.this.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "MySqlSlowLogs" + } + + enabled_log { + category = "MySqlAuditLogs" + } + + enabled_metric { + category = "AllMetrics" + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/mysql_flexible_server/outputs.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/mysql_flexible_server/outputs.tf new file mode 100644 index 0000000..cda9c03 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/mysql_flexible_server/outputs.tf @@ -0,0 +1,15 @@ +output "id" { + value = azurerm_mysql_flexible_server.this.id +} + +output "name" { + value = azurerm_mysql_flexible_server.this.name +} + +output "fqdn" { + value = azurerm_mysql_flexible_server.this.fqdn +} + +output "database_name" { + value = azurerm_mysql_flexible_database.this.name +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/mysql_flexible_server/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/mysql_flexible_server/variables.tf new file mode 100644 index 0000000..2852548 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/mysql_flexible_server/variables.tf @@ -0,0 +1,81 @@ +variable "name" { + description = "Name of the MySQL flexible server." + type = string +} + +variable "resource_group_name" { + type = string +} + +variable "location" { + type = string +} + +variable "administrator_login" { + type = string +} + +variable "administrator_password" { + type = string + sensitive = true +} + +variable "mysql_version" { + type = string + default = "8.0.21" +} + +variable "sku_name" { + type = string + default = "B_Standard_B1ms" +} + +variable "storage_size_gb" { + type = number + default = 32 +} + +variable "backup_retention_days" { + type = number + default = 7 +} + +variable "database_name" { + type = string + default = "plannerdb" +} + +variable "database_charset" { + type = string + default = "utf8mb4" +} + +variable "database_collation" { + type = string + default = "utf8mb4_unicode_ci" +} + +variable "firewall_rule_name" { + description = "Server-level firewall rule that allows the deploy machine to run the mysql bootstrap." + type = string + default = "AllowAllIPs" +} + +variable "firewall_start_ip" { + type = string + default = "0.0.0.0" +} + +variable "firewall_end_ip" { + type = string + default = "255.255.255.255" +} + +variable "log_analytics_workspace_id" { + type = string +} + +variable "tags" { + type = map(string) + default = {} +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/nat_gateway/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/nat_gateway/main.tf new file mode 100644 index 0000000..cc384af --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/nat_gateway/output.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/nat_gateway/output.tf new file mode 100644 index 0000000..1e3fd03 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/nat_gateway/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/nat_gateway/variables.tf new file mode 100644 index 0000000..c1c8ea5 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/network_security_group/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/network_security_group/main.tf new file mode 100644 index 0000000..c649652 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/network_security_group/outputs.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/network_security_group/outputs.tf new file mode 100644 index 0000000..b8ca8d5 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/network_security_group/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/network_security_group/variables.tf new file mode 100644 index 0000000..04eb07e --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/private_dns_zone/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/private_dns_zone/main.tf new file mode 100644 index 0000000..e61df00 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/private_dns_zone/main.tf @@ -0,0 +1,25 @@ +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))}" + private_dns_zone_id = azurerm_private_dns_zone.example.id + 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/web-app-mysql-flexible-server/dotnet/terraform/modules/private_dns_zone/outputs.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/private_dns_zone/outputs.tf new file mode 100644 index 0000000..ca141f3 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/private_dns_zone/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/private_dns_zone/variables.tf new file mode 100644 index 0000000..8d0c0cc --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/private_endpoint/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/private_endpoint/main.tf new file mode 100644 index 0000000..62bfbfb --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/private_endpoint/outputs.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/private_endpoint/outputs.tf new file mode 100644 index 0000000..367ab51 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/private_endpoint/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/private_endpoint/variables.tf new file mode 100644 index 0000000..2b7a888 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/virtual_network/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/virtual_network/main.tf new file mode 100644 index 0000000..2b7af04 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/virtual_network/main.tf @@ -0,0 +1,58 @@ +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 + + # NOTE: we deliberately do NOT add `enabled_metric { category = "AllMetrics" }` here. + # Many Azure subscriptions have a built-in or org-level Azure Policy + # (DeployIfNotExists) that auto-creates a `diagnosticSettings` resource on every new VNet + # forwarding `AllMetrics` to a workspace. Azure rejects a second diag setting that targets + # the same (resource, category, sink) triplet with a 409 Conflict — even if its name is + # different. The policy-managed one already covers AllMetrics; we contribute only the + # VMProtectionAlerts logs (typically NOT included by the default policy). + enabled_log { + category = "VMProtectionAlerts" + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/virtual_network/outputs.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/virtual_network/outputs.tf new file mode 100644 index 0000000..b464308 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/virtual_network/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/virtual_network/variables.tf new file mode 100644 index 0000000..f8c0b0e --- /dev/null +++ b/samples/web-app-mysql-flexible-server/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/web-app-mysql-flexible-server/dotnet/terraform/modules/web_app/main.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/web_app/main.tf new file mode 100644 index 0000000..a2eed3a --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/web_app/main.tf @@ -0,0 +1,71 @@ +resource "azurerm_linux_web_app" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + service_plan_id = var.service_plan_id + https_only = var.https_only + virtual_network_subnet_id = var.virtual_network_subnet_id + public_network_access_enabled = var.public_network_access_enabled + client_affinity_enabled = false + tags = var.tags + + identity { + type = "SystemAssigned" + } + + 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 + } + } + + app_settings = var.app_settings + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_linux_web_app.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "AppServiceHTTPLogs" + } + + enabled_log { + category = "AppServiceConsoleLogs" + } + + enabled_log { + category = "AppServiceAppLogs" + } + + enabled_log { + category = "AppServiceAuditLogs" + } + + enabled_log { + category = "AppServiceIPSecAuditLogs" + } + + enabled_log { + category = "AppServicePlatformLogs" + } + + enabled_log { + category = "AppServiceAuthenticationLogs" + } + + enabled_metric { + category = "AllMetrics" + } +} \ No newline at end of file diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/web_app/outputs.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/web_app/outputs.tf new file mode 100644 index 0000000..d7b6981 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/web_app/outputs.tf @@ -0,0 +1,24 @@ +output "id" { + value = azurerm_linux_web_app.example.id + description = "Specifies the resource id of the Web App" +} + +output "name" { + value = azurerm_linux_web_app.example.name + description = "Specifies the name of the Web App" +} + +output "default_hostname" { + value = azurerm_linux_web_app.example.default_hostname + description = "Specifies the default hostname of the Web App" +} + +output "outbound_ip_addresses" { + value = azurerm_linux_web_app.example.outbound_ip_addresses + description = "Specifies the outbound IP addresses of the Web App" +} + +output "principal_id" { + value = azurerm_linux_web_app.example.identity[0].principal_id + description = "Specifies the Principal ID of the System Assigned Managed Identity" +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/web_app/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/web_app/variables.tf new file mode 100644 index 0000000..81e6679 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/modules/web_app/variables.tf @@ -0,0 +1,89 @@ +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 "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 "dotnet_version" { + description = "(Optional) Specifies the version of .NET to run. Possible values include 8.0, 9.0 and 10.0." + type = string + default = "10.0" + + validation { + condition = contains(["8.0", "9.0", "10.0"], var.dotnet_version) + error_message = "The dotnet_version must be one of the supported versions: 8.0, 9.0, 10.0." + } +} + +variable "app_settings" { + description = "(Optional) A map of key-value pairs for App Settings." + type = map(string) + default = {} +} + +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 +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/outputs.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/outputs.tf new file mode 100644 index 0000000..cd2b742 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/outputs.tf @@ -0,0 +1,27 @@ +output "resource_group_name" { + value = local.resource_group_name +} + +output "mysql_server_name" { + value = module.mysql_flexible_server.name +} + +output "mysql_fqdn" { + value = module.mysql_flexible_server.fqdn +} + +output "mysql_database_name" { + value = module.mysql_flexible_server.database_name +} + +output "app_service_plan_name" { + value = module.app_service_plan.name +} + +output "web_app_name" { + value = module.web_app.name +} + +output "web_app_url" { + value = module.web_app.default_hostname +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/providers.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/providers.tf new file mode 100644 index 0000000..1f06025 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/providers.tf @@ -0,0 +1,24 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=5.1.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 = "azure.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/web-app-mysql-flexible-server/dotnet/terraform/terraform.tfvars b/samples/web-app-mysql-flexible-server/dotnet/terraform/terraform.tfvars new file mode 100644 index 0000000..919af4f --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/terraform.tfvars @@ -0,0 +1,3 @@ +prefix = "local" +suffix = "test" +location = "westeurope" \ No newline at end of file diff --git a/samples/web-app-mysql-flexible-server/dotnet/terraform/variables.tf b/samples/web-app-mysql-flexible-server/dotnet/terraform/variables.tf new file mode 100644 index 0000000..ed8d7da --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/terraform/variables.tf @@ -0,0 +1,196 @@ +variable "prefix" { + description = "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 = "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 = "Specifies the location for all resources." + type = string + default = "westeurope" +} + +# ----------------------------------------------------------------------------- +# MySQL flexible server +# ----------------------------------------------------------------------------- +variable "mysql_admin_login" { + description = "Administrator login for the MySQL flexible server." + type = string + default = "myadmin" +} + +variable "mysql_admin_password" { + description = "Administrator password for the MySQL flexible server. Pass via -var or the MYSQL_ADMIN_PASSWORD env var; do NOT commit." + type = string + sensitive = true + default = "P@ssw0rd1234!" +} + +variable "mysql_version" { + description = "MySQL major version." + type = string + default = "8.0.21" + + validation { + condition = contains(["5.7", "8.0.21"], var.mysql_version) + error_message = "The mysql_version must be one of: 5.7, 8.0.21." + } +} + +variable "mysql_sku_name" { + description = "Compute SKU for the MySQL flexible server (e.g. B_Standard_B1ms)." + type = string + default = "B_Standard_B1ms" +} + +variable "mysql_storage_size_gb" { + description = "Storage size in GB for the MySQL flexible server." + type = number + default = 32 +} + +variable "mysql_backup_retention_days" { + description = "Backup retention period in days for the MySQL flexible server." + type = number + default = 7 +} + +variable "mysql_database_name" { + description = "Name of the application database to create on the MySQL flexible server." + type = string + default = "plannerdb" +} + +# ----------------------------------------------------------------------------- +# App Service / Web App +# ----------------------------------------------------------------------------- +variable "os_type" { + description = "OS type for the App Service Plan." + type = string + default = "Linux" +} + +variable "zone_balancing_enabled" { + type = bool + default = false +} + +variable "sku_name" { + description = "App Service Plan SKU name." + type = string + default = "S1" +} + +variable "dotnet_version" { + description = "(Optional) Specifies the version of .NET to run. Possible values include 8.0, 9.0 and 10.0." + type = string + default = "10.0" + + validation { + condition = contains(["8.0", "9.0", "10.0"], var.dotnet_version) + error_message = "The dotnet_version must be one of the supported versions: 8.0, 9.0, 10.0." + } +} + +variable "https_only" { + type = bool + default = false +} + +variable "minimum_tls_version" { + type = string + default = "1.2" +} + +variable "always_on" { + type = bool + default = true +} + +variable "http2_enabled" { + type = bool + default = false +} + +variable "public_network_access_enabled" { + type = bool + default = true +} + +variable "login_name" { + description = "Login name for the application (scopes activity ownership)." + type = string + default = "paolo" +} + +variable "websites_port" { + type = number + default = 8000 +} + +variable "tags" { + type = map(string) + default = { + environment = "test" + iac = "terraform" + } +} + +# ----------------------------------------------------------------------------- +# Networking +# ----------------------------------------------------------------------------- +variable "vnet_address_space" { + type = list(string) + default = ["10.0.0.0/8"] +} + +variable "webapp_subnet_name" { + type = string + default = "app-subnet" +} + +variable "webapp_subnet_address_prefix" { + type = list(string) + default = ["10.0.0.0/24"] +} + +variable "pe_subnet_name" { + type = string + default = "pe-subnet" +} + +variable "pe_subnet_address_prefix" { + type = list(string) + default = ["10.0.1.0/24"] +} + +variable "nat_gateway_sku_name" { + type = string + default = "Standard" +} + +variable "nat_gateway_idle_timeout_in_minutes" { + type = number + default = 4 +} + +variable "nat_gateway_zones" { + type = list(string) + default = ["1"] +} diff --git a/samples/web-app-mysql-flexible-server/python/scripts/deploy.sh b/samples/web-app-mysql-flexible-server/python/scripts/deploy.sh index 6a6724d..3738fd1 100755 --- a/samples/web-app-mysql-flexible-server/python/scripts/deploy.sh +++ b/samples/web-app-mysql-flexible-server/python/scripts/deploy.sh @@ -155,20 +155,34 @@ if [[ $? != 0 ]]; then echo "No [$FIREWALL_RULE_NAME] firewall rule already exists on the [$MYSQL_SERVER_NAME] MySQL flexible server" echo "Creating [$FIREWALL_RULE_NAME] firewall rule on the [$MYSQL_SERVER_NAME] MySQL flexible server..." - # Create a permissive firewall rule so the deploy machine can run the mysql bootstrap - az mysql flexible-server firewall-rule create \ - --name $MYSQL_SERVER_NAME \ - --resource-group $RESOURCE_GROUP_NAME \ - --rule-name $FIREWALL_RULE_NAME \ - --start-ip-address "0.0.0.0" \ - --end-ip-address "255.255.255.255" \ - --only-show-errors 1>/dev/null - - if [ $? -eq 0 ]; then + # Create a permissive firewall rule so the deploy machine can run the mysql bootstrap. + # The create is retried because this PUT intermittently answers 500 against the emulator while + # the server finishes provisioning, and the Azure CLI's own retries all land within a few seconds. + FIREWALL_RULE_CREATED=0 + for attempt in $(seq 1 5); do + if az mysql flexible-server firewall-rule create \ + --name $MYSQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --rule-name $FIREWALL_RULE_NAME \ + --start-ip-address "0.0.0.0" \ + --end-ip-address "255.255.255.255" \ + --only-show-errors 1>/dev/null; then + FIREWALL_RULE_CREATED=1 + break + fi + + if [ "$attempt" -lt 5 ]; then + echo "Attempt $attempt of 5 to create the [$FIREWALL_RULE_NAME] firewall rule failed; retrying in 10 seconds..." + sleep 10 + fi + done + + if [ $FIREWALL_RULE_CREATED -eq 1 ]; then echo "[$FIREWALL_RULE_NAME] firewall rule successfully created on the [$MYSQL_SERVER_NAME] MySQL flexible server" else - echo "Failed to create [$FIREWALL_RULE_NAME] firewall rule on the [$MYSQL_SERVER_NAME] MySQL flexible server" - exit 1 + # Not fatal: the rule governs public network access, which the emulator does not enforce, and + # the mysql bootstrap below fails loudly if the server is genuinely unreachable. + echo "WARNING: could not create the [$FIREWALL_RULE_NAME] firewall rule on the [$MYSQL_SERVER_NAME] MySQL flexible server; continuing" fi else echo "[$FIREWALL_RULE_NAME] firewall rule already exists on the [$MYSQL_SERVER_NAME] MySQL flexible server" diff --git a/samples/web-app-mysql-flexible-server/python/terraform/providers.tf b/samples/web-app-mysql-flexible-server/python/terraform/providers.tf index 0b17881..1f06025 100644 --- a/samples/web-app-mysql-flexible-server/python/terraform/providers.tf +++ b/samples/web-app-mysql-flexible-server/python/terraform/providers.tf @@ -17,7 +17,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/web-app-postgresql-flexible-server/dotnet/README.md b/samples/web-app-postgresql-flexible-server/dotnet/README.md new file mode 100644 index 0000000..dfdb347 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/README.md @@ -0,0 +1,107 @@ +# Azure Web App with Azure Database for PostgreSQL flexible server + +This sample demonstrates an ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in the `activities` table of the `PlannerDB` database on an [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview). The server is reached through a [Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (group `postgresqlServer`) with the `privatelink.postgres.database.azure.com` Private DNS Zone, while a permissive server-level firewall rule lets the deploy machine run the post-create psql bootstrap that creates the application role and seeds the schema. + +## Architecture + +![Architecture Diagram](./images/architecture.png) + +The web app enables users to plan and manage vacation activities; all data is persisted in PostgreSQL. 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*: Delegated to `Microsoft.Web/serverFarms` for regional VNet integration of the Web App. + - *pe-subnet*: Hosts the Private Endpoint to the PostgreSQL flexible server. +3. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) `privatelink.postgres.database.azure.com`, linked to the VNet. The Private Endpoint's DNS-zone group auto-registers the `A` record for the server, so the Web App resolves the server's private IP through the VNet. +4. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (group `postgresqlServer`): Secures access to the PostgreSQL flexible server from the VNet. +5. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview): Deterministic outbound connectivity for both subnets. +6. [Azure Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): One NSG per subnet. +7. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview): Centralizes diagnostic logs and metrics. +8. [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): Public-access server hosting the `PlannerDB` database. Burstable `Standard_B1ms`, version 16, 32 GiB storage, 7-day backup retention, HA disabled. A permissive firewall rule (`0.0.0.0–255.255.255.255`) is created so the deploy machine can run the post-create psql bootstrap; the Web App itself reaches the server through the Private Endpoint. +9. [PostgreSQL database](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers) `PlannerDB`: Created at provisioning time; the post-deploy psql step creates the `activities` table and seeds three demo rows. +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 ASP.NET Core *Vacation Planner* app with regional VNet integration into *app-subnet*. The Web App connects to PostgreSQL using a dedicated application role (`testuser`) — the server-admin login is never used at runtime. +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 deploy scripts follow the same pattern as the sibling [`web-app-sql-database`](../../web-app-sql-database/dotnet/) sample: after provisioning, they (i) connect as the server admin via the public endpoint + firewall rule, (ii) create the application role `testuser` with its own password, (iii) grant minimum schema privileges on `PlannerDB`, (iv) create the `activities` table, (v) seed three sample rows, and (vi) write `PG_USER=testuser` + `PG_PASSWORD` onto the Web App's app settings. The server-admin login is never written into the Web App's runtime configuration. + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/) +- [Npgsql](https://www.npgsql.org/doc/) +- [PostgreSQL client tools](https://www.postgresql.org/download/) (`psql`) — required by the deploy scripts to create the application role and seed data +- [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`. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain yours. Pull and start the emulator: + +```bash +docker pull localstack/localstack-azure + +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Deploy the application using one of these methods: + +- [Azure CLI Deployment](./scripts/README.md) +- [Bicep Deployment](./bicep/README.md) +- [Terraform Deployment](./terraform/README.md) + +All three variants provision the same topology: VNet + pe-subnet hosting a Private Endpoint targeting a public-access PostgreSQL flexible server, with a Private DNS Zone linked to the VNet. + +> **Note** +> When you deploy the application to LocalStack for Azure for the first time, the initialization process pulls and builds Docker images (LocalStack itself plus the `postgres:18` backing container for the flexible-server emulator). This is a one-time operation — subsequent deployments are much faster. + +## Test + +1. Retrieve the port published and mapped to port 80 by the Docker container hosting the emulated Web App. +2. Open a web browser and navigate to `http://localhost:`. +3. If the deployment was successful, you will see the *Vacation Planner* UI with the three seeded activities (*Go to Paris*, *Go to London*, *Go to Mexico*) and can add, edit, and remove activities. + +![Vacation Planner UI](./images/vacation-planner.png) + +You can use the `scripts/call-web-app.sh` Bash script to call the web app from outside the emulator. The script demonstrates four call paths: + +1. **Through the LocalStack for Azure emulator** via the default hostname. +2. **Via localhost and host port** mapped to the container's port `80`. +3. **Via container IP address** on port `80`. +4. **Via the default hostname** `.azurewebsites.azure.localhost.localstack.cloud:4566`. + +## PostgreSQL Tooling + +You can use [pgAdmin](https://www.pgadmin.org/) to explore and manage the deployed database. Connect using: + +| Field | Value | +| -------- | --------------------------------------------------------------------------- | +| Host | `localhost` | +| Port | (see `docker ps` for the host-mapped port of the backing `postgres:18` container) | +| Database | `PlannerDB` | +| Username | `testuser` *(or `pgadmin` for admin operations)* | +| Password | `TestP@ssw0rd123` *(or `P@ssw0rd1234!` for the admin)* | + +Or use [psql](https://www.postgresql.org/docs/current/app-psql.html): + +```bash +PGPASSWORD='TestP@ssw0rd123' psql -h localhost -p -U testuser -d PlannerDB +PlannerDB=> SELECT id, username, activity, created_at FROM activities; +``` + +## References + +- [Azure Web Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [Azure Database for PostgreSQL — flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/) +- [Quickstart: Deploy an ASP.NET web app to Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/quickstart-dotnetcore?pivots=development-environment-cli) +- [Npgsql documentation](https://www.npgsql.org/doc/) +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-postgresql-flexible-server/dotnet/bicep/README.md b/samples/web-app-postgresql-flexible-server/dotnet/bicep/README.md new file mode 100644 index 0000000..55ee831 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/bicep/README.md @@ -0,0 +1,101 @@ +# Bicep Deployment + +This directory contains the Bicep template and a deployment script for provisioning the sample's Azure resources. For details about the sample application, see [Azure Web App with Azure Database for PostgreSQL flexible server](../README.md). + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [Visual Studio Code](https://code.visualstudio.com/) + [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep) +- [Docker](https://docs.docker.com/get-docker/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) + [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [PostgreSQL client (`psql`)](https://www.postgresql.org/download/) +- [`jq`](https://jqlang.org/) + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +The [`deploy.sh`](deploy.sh) script creates the resource group while the Bicep modules create: + +1. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview) with two subnets: + - *app-subnet*: delegated to `Microsoft.Web/serverFarms` for the Web App's regional VNet integration. + - *pe-subnet*: hosts the Private Endpoint to the PostgreSQL flexible server. +2. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) `privatelink.postgres.database.azure.com`, linked to the VNet. +3. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (group `postgresqlServer`). +4. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview). +5. [Network Security Groups](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): one per subnet. +6. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview). +7. [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): public-access mode, Burstable `Standard_B1ms`, version 16, 32 GiB, HA disabled. A permissive firewall rule (`0.0.0.0–255.255.255.255`) lets the deploy machine reach the server for the post-create psql bootstrap. +8. [PostgreSQL database](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers) `sampledb` (UTF8 / `en_US.utf8`). +9. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). +10. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration into *app-subnet*. The Bicep template sets `PG_HOST`, `PG_PORT`, and `PG_DATABASE` on the Web App but **does not** set `PG_USER` or `PG_PASSWORD` — those are written by `deploy.sh` after psql creates the application role. + +## Configuration + +Update [`main.bicepparam`](main.bicepparam) before deploying. The defaults are: + +```bicep +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'dotnetcore' +param runtimeVersion = '10.0' +param databaseName = 'sampledb' +param username = 'paolo' + +param pgAdminLogin = 'pgadmin' +param pgAdminPassword = readEnvironmentVariable('PG_ADMIN_PASSWORD', '') +param pgVersion = '16' +param pgSkuTier = 'Burstable' +param pgSkuName = 'Standard_B1ms' +param pgStorageSizeGB = 32 +param pgBackupRetentionDays = 7 +``` + +`pgAdminPassword` is read from the `PG_ADMIN_PASSWORD` env var. `deploy.sh` sets a default (`P@ssw0rd1234!`) if not provided; override for non-dev deployments. + +## Deployment + +```bash +# default values +bash deploy.sh + +# override admin and app-user secrets +PG_ADMIN_PASSWORD='' \ +PG_APP_PASSWORD='' \ +bash deploy.sh +``` + +The script will: + +1. Ensure the resource group exists. +2. Validate `main.bicep`. +3. Deploy the template, passing `pgAdminPassword`. +4. Use `psql` (connected via the public endpoint + firewall rule) to create the `testuser` role, the `activities` table, and the three demo rows. +5. Set the Web App's `PG_USER`/`PG_PASSWORD` to `testuser` / `` — the server admin login is never written to the Web App. +6. Zip the application source under `../src` and deploy it. + +## Verification + +```bash +PGPASSWORD='TestP@ssw0rd123' psql -h -p -U testuser -d PlannerDB \ + -c "SELECT id, username, activity, created_at FROM activities;" +``` + +`` is `5432` in real Azure, or the port suffix of the server's FQDN in LocalStack: + +```bash +az postgres flexible-server show \ + --resource-group local-rg --name local-pgflex-test \ + --query fullyQualifiedDomainName --output tsv +``` + +## Related Documentation + +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-postgresql-flexible-server/dotnet/bicep/deploy.sh b/samples/web-app-postgresql-flexible-server/dotnet/bicep/deploy.sh new file mode 100755 index 0000000..e136ff4 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/bicep/deploy.sh @@ -0,0 +1,336 @@ +#!/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) +PG_ADMIN_USER="pgadmin" +PG_ADMIN_PASSWORD="P@ssw0rd1234!" +PG_APP_USER="testuser" +PG_APP_PASSWORD="TestP@ssw0rd123" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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 \ + pgAdminPassword="$PG_ADMIN_PASSWORD" \ + --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 \ + pgAdminPassword="$PG_ADMIN_PASSWORD" \ + --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 \ + pgAdminPassword="$PG_ADMIN_PASSWORD" \ + --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 . + WEB_APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.webAppName.value') + POSTGRES_SERVER_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.postgresServerName.value') + POSTGRES_FQDN_FULL=$(echo "$DEPLOYMENT_JSON" | jq -r '.postgresFqdn.value') + DATABASE_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.databaseName.value') + echo "Deployment details:" + echo "Web App Name: $WEB_APP_NAME" + echo "PostgreSQL Server Name: $POSTGRES_SERVER_NAME" + echo "PostgreSQL FQDN: $POSTGRES_FQDN_FULL" + echo "Database Name: $DATABASE_NAME" +else + echo "Failed to deploy Bicep template [$TEMPLATE]" + exit 1 +fi + +if [[ -z "$WEB_APP_NAME" || -z "$POSTGRES_SERVER_NAME" ]]; then + echo "Web App Name or PostgreSQL Server Name is empty. Exiting." + exit 1 +fi + +# Split host:port — the LocalStack emulator embeds the dynamically allocated TCP-proxy port +# directly in fullyQualifiedDomainName, mirroring the storage / container registry emulators. +# Real Azure returns just the bare host so PG_PORT defaults to 5432. +POSTGRES_FQDN="${POSTGRES_FQDN_FULL%%:*}" +if [[ "$POSTGRES_FQDN_FULL" == *:* ]]; then + POSTGRES_PORT="${POSTGRES_FQDN_FULL##*:}" +else + POSTGRES_PORT=5432 +fi +echo "PostgreSQL host = $POSTGRES_FQDN, port = $POSTGRES_PORT" + +# Create application role [$PG_APP_USER] on the PostgreSQL flexible server +echo "Creating login [$PG_APP_USER] on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "DO \$\$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$PG_APP_USER') THEN + CREATE ROLE \"$PG_APP_USER\" WITH LOGIN PASSWORD '$PG_APP_PASSWORD'; + END IF; +END +\$\$;" + +if [ $? -eq 0 ]; then + echo "Login [$PG_APP_USER] created successfully" +else + echo "Failed to create login [$PG_APP_USER]" + exit 1 +fi + +# Grant CONNECT on the database to [$PG_APP_USER] +echo "Granting CONNECT on [$DATABASE_NAME] to [$PG_APP_USER]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT CONNECT ON DATABASE \"$DATABASE_NAME\" TO \"$PG_APP_USER\";" + +if [ $? -eq 0 ]; then + echo "CONNECT granted successfully to [$PG_APP_USER]" +else + echo "Failed to grant CONNECT to [$PG_APP_USER]" + exit 1 +fi + +# Grant schema privileges to [$PG_APP_USER] +echo "Granting schema privileges on [$DATABASE_NAME] to [$PG_APP_USER]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT USAGE, CREATE ON SCHEMA public TO \"$PG_APP_USER\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"$PG_APP_USER\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"$PG_APP_USER\";" + +if [ $? -eq 0 ]; then + echo "Schema privileges granted successfully to [$PG_APP_USER]" +else + echo "Failed to grant schema privileges to [$PG_APP_USER]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$PG_APP_USER]..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + -c "SELECT current_user, current_database(), now();" + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$PG_APP_USER]" +else + echo "Connection test failed with user [$PG_APP_USER]" + exit 1 +fi + +# Create [activities] table +echo "Creating [activities] table in the [$DATABASE_NAME] database..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username); + CREATE INDEX IF NOT EXISTS idx_activities_created_at ON activities(created_at DESC);" + +if [ $? -eq 0 ]; then + echo "[activities] table created successfully" +else + echo "Failed to create [activities] table" + exit 1 +fi + +# Insert sample data +echo "Inserting sample data into [activities] table..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "INSERT INTO activities (id, username, activity) VALUES + (md5('paolo_pisa_seed'), 'paolo', 'Visit the Leaning Tower in Pisa'), + (md5('paolo_volterra_seed'), 'paolo', 'Explore Etruscan walls in Volterra'), + (md5('paolo_san_gimignano_seed'), 'paolo', 'Climb Torre Grossa in San Gimignano'), + (md5('paolo_siena_seed'), 'paolo', 'Walk across Piazza del Campo in Siena'), + (md5('paolo_montalcino_seed'), 'paolo', 'Taste Brunello wine in Montalcino'), + (md5('paolo_pienza_seed'), 'paolo', 'Sample Pecorino cheese in Pienza'), + (md5('paolo_florence_seed'), 'paolo', 'Admire Michelangelo''s David in Florence'), + (md5('paolo_viareggio_beach_seed'), 'paolo', 'Relax by the beach in Viareggio'), + (md5('paolo_viareggio_promenade_seed'), 'paolo', 'Stroll along the Viareggio promenade') + ON CONFLICT (id) DO NOTHING;" + +if [ $? -eq 0 ]; then + echo "Sample data inserted successfully into [activities] table" +else + echo "Failed to insert sample data into [activities] table" + exit 1 +fi + +# Query sample data +echo "Querying sample data from [activities] table..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + -c "SELECT * FROM activities;" + +if [ $? -eq 0 ]; then + echo "Sample data queried successfully from [activities] table" +else + echo "Failed to query sample data from [activities] table" + exit 1 +fi + +# Set PG_USER + PG_PASSWORD on the web app to point at the application role +echo "Setting PG_USER=[$PG_APP_USER] and PG_PASSWORD on the [$WEB_APP_NAME] web app..." +az webapp config appsettings set \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --settings PG_USER="$PG_APP_USER" PG_PASSWORD="$PG_APP_PASSWORD" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "PG_USER and PG_PASSWORD set successfully on the [$WEB_APP_NAME] web app" +else + echo "Failed to set PG_USER and PG_PASSWORD on the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Print the application settings of the web app +echo "Retrieving application settings for web app [$WEB_APP_NAME]..." +az webapp config appsettings list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +# Remove the zip package of the web 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 diff --git a/samples/web-app-postgresql-flexible-server/dotnet/bicep/main.bicep b/samples/web-app-postgresql-flexible-server/dotnet/bicep/main.bicep new file mode 100644 index 0000000..4f29890 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/bicep/main.bicep @@ -0,0 +1,308 @@ +//******************************************** +// 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' + 'Premium' + 'PremiumV2' + 'Premium0V3' + 'PremiumV3' + 'PremiumMV3' +]) +param skuTier string = 'Standard' + +@description('Specifies the SKU name for the hosting plan.') +param skuName string = 'S1' + +@description('Specifies the kind of the hosting plan.') +@allowed(['app','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 Web App.') +@allowed(['dotnet','dotnetcore','python','java','node']) +param runtimeName string + +@description('Specifies the target language version used by the Azure Web App.') +param runtimeVersion string + +@description('Specifies the kind of the web app resource.') +param webAppKind string = 'app,linux' + +@description('Specifies whether HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = false + +@description('Specifies the minimum TLS version for the Azure Web App.') +@allowed(['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 the optional Git Repo URL.') +param repoUrl string = ' ' + +@description('Specifies the username for the application (used to scope activities).') +param username string = 'paolo' + +// +// PostgreSQL flexible server +// +@description('Administrator login for the PostgreSQL flexible server. Only used by the post-deploy psql bootstrap; the Web App never authenticates with this account.') +param pgAdminLogin string = 'pgadmin' + +@description('Administrator login password for the PostgreSQL flexible server.') +@secure() +param pgAdminPassword string + +@description('PostgreSQL major version.') +@allowed(['13','14','15','16','17']) +param pgVersion string = '16' + +@description('Compute tier for the PostgreSQL flexible server.') +@allowed(['Burstable','GeneralPurpose','MemoryOptimized']) +param pgSkuTier string = 'Burstable' + +@description('Compute SKU name for the PostgreSQL flexible server.') +param pgSkuName string = 'Standard_B1ms' + +@description('Storage size in GB for the PostgreSQL flexible server.') +@minValue(32) +@maxValue(16384) +param pgStorageSizeGB int = 32 + +@description('Backup retention in days for the PostgreSQL flexible server.') +@minValue(7) +@maxValue(35) +param pgBackupRetentionDays int = 7 + +@description('Name of the application database to create on the PostgreSQL flexible server.') +param databaseName string = 'PlannerDB' + +// +// Networking +// +@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 Web App for the regional virtual network integration.') +param webAppSubnetName string = 'app-subnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param webAppSubnetNsgName string = '' + +@description('Specifies the name of the subnet that hosts the private endpoint to the PostgreSQL flexible server.') +param peSubnetName string = 'pe-subnet' + +@description('Specifies the address prefix of the private-endpoint subnet.') +param peSubnetAddressPrefix string = '10.0.1.0/24' + +@description('Specifies the name of the NSG associated to the private-endpoint subnet.') +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 the name of the private endpoint targeting the PostgreSQL flexible server.') +param postgresPrivateEndpointName string = '' + +// +// Observability +// +@description('Specifies the name of the Azure Log Analytics resource.') +param logAnalyticsName string = '' + +@description('Specifies the service tier of the workspace.') +@allowed(['Free','Standalone','PerNode','PerGB2018']) +param logAnalyticsSku string = 'PerNode' + +@description('Specifies the workspace data retention in days.') +param logAnalyticsRetentionInDays int = 60 + +@description('Specifies the tags to be applied to the resources.') +param tags object = { + environment: 'test' + iac: 'bicep' +} + +//******************************************** +// Variables +//******************************************** +var webAppName = '${prefix}-webapp-${suffix}' +var appServicePlanName = '${prefix}-app-service-plan-${suffix}' +var pgServerName = '${prefix}-pgflex-${suffix}' +var privateDnsZoneName = 'privatelink.postgres.database.azure.com' + +// The PostgreSQL flexible-server emulator embeds the LS-side TCP-proxy port directly in +// fullyQualifiedDomainName (e.g. ".postgres.database.localhost.localstack.cloud:4515"). +// Real Azure returns just the bare host on 5432. Split on `:` so the Web App always gets the +// right host + port without any post-deploy shell logic. +var pgFqdnParts = split(postgresqlServer.outputs.fqdn, ':') +var pgHost = pgFqdnParts[0] +var pgPort = length(pgFqdnParts) > 1 ? pgFqdnParts[1] : '5432' + +//******************************************** +// Modules and Resources +//******************************************** +module workspace 'modules/log-analytics.bicep' = { + name: 'workspace' + params: { + name: empty(logAnalyticsName) ? toLower('${prefix}-log-analytics-${suffix}') : logAnalyticsName + location: location + tags: tags + sku: logAnalyticsSku + retentionInDays: logAnalyticsRetentionInDays + } +} + +module network 'modules/virtual-network.bicep' = { + name: 'network' + params: { + virtualNetworkName: empty(virtualNetworkName) ? toLower('${prefix}-vnet-${suffix}') : virtualNetworkName + virtualNetworkAddressPrefixes: virtualNetworkAddressPrefixes + webAppSubnetName: webAppSubnetName + webAppSubnetAddressPrefix: webAppSubnetAddressPrefix + webAppSubnetNsgName: empty(webAppSubnetNsgName) ? toLower('${prefix}-webapp-subnet-nsg-${suffix}') : webAppSubnetNsgName + 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: 'Microsoft.Web/serverfarms' + workspaceId: workspace.outputs.id + location: location + tags: tags + } +} + +module postgresqlServer 'modules/postgresql-flexible-server.bicep' = { + name: 'postgresqlServer' + params: { + name: pgServerName + location: location + administratorLogin: pgAdminLogin + administratorLoginPassword: pgAdminPassword + version: pgVersion + skuTier: pgSkuTier + skuName: pgSkuName + storageSizeGB: pgStorageSizeGB + backupRetentionDays: pgBackupRetentionDays + databaseName: databaseName + workspaceId: workspace.outputs.id + tags: tags + } +} + +module privateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'privateDnsZone' + params: { + name: privateDnsZoneName + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module privateEndpoint 'modules/private-endpoint.bicep' = { + name: 'privateEndpoint' + params: { + name: empty(postgresPrivateEndpointName) + ? toLower('${prefix}-postgres-pe-${suffix}') + : postgresPrivateEndpointName + privateLinkServiceId: postgresqlServer.outputs.id + privateDnsZoneId: privateDnsZone.outputs.id + vnetId: network.outputs.virtualNetworkId + subnetId: network.outputs.peSubnetId + groupIds: [ + 'postgresqlServer' + ] + 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 webApp 'modules/web-app.bicep' = { + name: webAppName + params: { + name: webAppName + location: location + kind: webAppKind + httpsOnly: httpsOnly + runtimeName: runtimeName + runtimeVersion: runtimeVersion + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + repoUrl: repoUrl + virtualNetworkName: network.outputs.virtualNetworkName + subnetName: network.outputs.webAppSubnetName + hostingPlanName: appServicePlan.outputs.name + pgHost: pgHost + pgPort: pgPort + pgDatabase: postgresqlServer.outputs.databaseName + username: username + workspaceId: workspace.outputs.id + tags: tags + } +} + +//******************************************** +// Outputs +//******************************************** +output webAppName string = webApp.outputs.name +output webAppDefaultHostName string = webApp.outputs.defaultHostName +output postgresServerName string = postgresqlServer.outputs.name +output postgresFqdn string = postgresqlServer.outputs.fqdn +output databaseName string = postgresqlServer.outputs.databaseName diff --git a/samples/web-app-postgresql-flexible-server/dotnet/bicep/main.bicepparam b/samples/web-app-postgresql-flexible-server/dotnet/bicep/main.bicepparam new file mode 100644 index 0000000..4ef7012 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/bicep/main.bicepparam @@ -0,0 +1,19 @@ +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'dotnetcore' +param runtimeVersion = '10.0' +param databaseName = 'PlannerDB' +param username = 'paolo' + +// PostgreSQL flexible server +param pgAdminLogin = 'pgadmin' +// Password is supplied at deploy time via the PG_ADMIN_PASSWORD env var +// (see deploy.sh — it passes it as --parameters pgAdminPassword=...). Do not commit it here. +param pgAdminPassword = readEnvironmentVariable('PG_ADMIN_PASSWORD', '') +param pgVersion = '16' +param pgSkuTier = 'Burstable' +param pgSkuName = 'Standard_B1ms' +param pgStorageSizeGB = 32 +param pgBackupRetentionDays = 7 diff --git a/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/app-service-plan.bicep b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/app-service-plan.bicep new file mode 100644 index 0000000..4b5cfb3 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/bicep/modules/log-analytics.bicep b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/log-analytics.bicep new file mode 100644 index 0000000..2618829 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/bicep/modules/postgresql-flexible-server.bicep b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/postgresql-flexible-server.bicep new file mode 100644 index 0000000..5ebd2db --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/postgresql-flexible-server.bicep @@ -0,0 +1,171 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the name of the Azure Database for PostgreSQL flexible server.') +param name string + +@description('Specifies the location for all resources.') +param location string = resourceGroup().location + +@description('Specifies the administrator login for the PostgreSQL server.') +param administratorLogin string = 'pgadmin' + +@description('Specifies the administrator login password for the PostgreSQL server.') +@secure() +param administratorLoginPassword string + +@description('Specifies the PostgreSQL major version.') +@allowed([ + '13' + '14' + '15' + '16' + '17' +]) +param version string = '16' + +@description('Specifies the compute tier of the server.') +@allowed([ + 'Burstable' + 'GeneralPurpose' + 'MemoryOptimized' +]) +param skuTier string = 'Burstable' + +@description('Specifies the compute SKU name of the server.') +param skuName string = 'Standard_B1ms' + +@description('Specifies the storage size in GB.') +@minValue(32) +@maxValue(16384) +param storageSizeGB int = 32 + +@description('Specifies the backup retention period in days.') +@minValue(7) +@maxValue(35) +param backupRetentionDays int = 7 + +@description('Specifies the name of the database to create on the server.') +param databaseName string = 'PlannerDB' + +@description('Specifies the database charset.') +param databaseCharset string = 'UTF8' + +@description('Specifies the database collation.') +param databaseCollation string = 'en_US.utf8' + +@description('Name of the server-level firewall rule that allows the deploy machine and Azure services to reach the server. Defaults to a permissive allow-all rule appropriate for the sample.') +param firewallRuleName string = 'AllowAllIPs' + +@description('Start IP of the firewall rule.') +param firewallStartIp string = '0.0.0.0' + +@description('End IP of the firewall rule.') +param firewallEndIp string = '255.255.255.255' + +@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 = [ + 'PostgreSQLLogs' +] +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 +//******************************************** +// Server is created in public-access mode and fronted by a Private Endpoint (see the +// private-endpoint module in main.bicep). The firewall rule lets the deploy machine reach the +// public endpoint just long enough to run the post-deploy psql bootstrap that creates the +// application role and seed data; the Web App itself reaches the server over the private +// endpoint via the linked Private DNS Zone. +resource server 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { + name: toLower(name) + location: location + tags: tags + sku: { + name: skuName + tier: skuTier + } + properties: { + administratorLogin: administratorLogin + administratorLoginPassword: administratorLoginPassword + version: version + createMode: 'Default' + storage: { + storageSizeGB: storageSizeGB + } + backup: { + backupRetentionDays: backupRetentionDays + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + network: { + publicNetworkAccess: 'Enabled' + } + } +} + +resource database 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = { + parent: server + name: databaseName + properties: { + charset: databaseCharset + collation: databaseCollation + } +} + +resource firewallRule 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2024-08-01' = { + parent: server + name: firewallRuleName + properties: { + startIpAddress: firewallStartIp + endIpAddress: firewallEndIp + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: diagnosticSettingsName + scope: server + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = server.id +output name string = server.name +output fqdn string = server.properties.fullyQualifiedDomainName +output databaseId string = database.id +output databaseName string = database.name diff --git a/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/private-dns-zone.bicep b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/private-dns-zone.bicep new file mode 100644 index 0000000..d849259 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/bicep/modules/private-endpoint.bicep b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/private-endpoint.bicep new file mode 100644 index 0000000..8fd35b8 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/private-endpoint.bicep @@ -0,0 +1,72 @@ +//******************************************** +// 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 virtual network where private endpoints will be created.') +param vnetId string + +@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/web-app-postgresql-flexible-server/dotnet/bicep/modules/virtual-network.bicep b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/virtual-network.bicep new file mode 100644 index 0000000..e5f66ba --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/virtual-network.bicep @@ -0,0 +1,238 @@ +//******************************************** +// 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 webAppSubnetName string = 'functionAppSubnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param webAppSubnetNsgName string = '' + +@description('Specifies the name of the subnet that hosts the private endpoint to the PostgreSQL flexible server.') +param peSubnetName string = 'pe-subnet' + +@description('Specifies the address prefix of the subnet that hosts the private endpoint to the PostgreSQL flexible server.') +param peSubnetAddressPrefix string = '10.0.1.0/24' + +@description('Specifies the name of the network security group associated with the private-endpoint subnet.') +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: webAppSubnetName + properties: { + addressPrefix: webAppSubnetAddressPrefix + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Disabled' + networkSecurityGroup: { + id: webAppSubnetNsg.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 webAppSubnetNsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: webAppSubnetNsgName + 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 webAppSubnetNsgDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: diagnosticSettingsName + scope: webAppSubnetNsg + 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 webAppSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, webAppSubnetName) +output webAppSubnetName string = webAppSubnetName +output peSubnetId string = resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, peSubnetName) +output peSubnetName string = peSubnetName diff --git a/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/web-app.bicep b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/web-app.bicep new file mode 100644 index 0000000..7427764 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/bicep/modules/web-app.bicep @@ -0,0 +1,213 @@ +//******************************************** +// Parameters +//******************************************** + +@description('Specifies a globally unique name the Azure Web 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 = 'app,linux' + +@description('Specifies the language runtime used by the Azure Web App.') +@allowed([ + 'dotnet' + 'dotnet-isolated' + 'dotnetcore' + 'python' + 'java' + 'node' + 'powerShell' + 'custom' +]) +param runtimeName string + +@description('Specifies the target language version used by the Azure Web App.') +param runtimeVersion string + +@description('Specifies the minimum TLS version for the Azure Web App.') +@allowed([ + '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 HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = true + +@description('Specifies the name of the hosting plan.') +param hostingPlanName string + +@description('Specifies the FQDN of the PostgreSQL flexible server (e.g. .postgres.database.azure.com).') +param pgHost string + +@description('Specifies the TCP port the PostgreSQL server listens on. 5432 in real Azure; in the emulator the FQDN encodes the dynamically allocated proxy port and main.bicep splits it.') +param pgPort string = '5432' + +@description('Specifies the name of the database to connect to.') +param pgDatabase string = 'sampledb' + +@description('Specifies the name of the virtual network.') +param virtualNetworkName string + +@description('Specifies the name of the subnet used by the Web App for the regional virtual network integration.') +param subnetName string + +@description('Specifies the resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the username for the application.') +param username string = 'paolo' + +@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 = 'default' +var logCategories = [ + 'AppServiceHTTPLogs' + 'AppServiceConsoleLogs' + 'AppServiceAppLogs' + 'AppServiceAuditLogs' + 'AppServiceIPSecAuditLogs' + 'AppServicePlatformLogs' + '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 virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: virtualNetworkName +} + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = { + parent: virtualNetwork + name: subnetName +} + +resource hostingPlan 'Microsoft.Web/serverfarms@2024-04-01' existing = { + name: hostingPlanName +} + +resource webApp 'Microsoft.Web/sites@2025-03-01' = { + name: name + location: location + tags: tags + kind: kind + properties: { + httpsOnly: httpsOnly + serverFarmId: hostingPlan.id + virtualNetworkSubnetId: subnet.id + outboundVnetRouting: { + allTraffic: true + } + siteConfig: { + linuxFxVersion: toUpper('${runtimeName}|${runtimeVersion}') + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + } + } + identity: { + type: 'SystemAssigned' + } +} + + +resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = { + parent: webApp + name: 'appsettings' + properties: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + // PG_USER and PG_PASSWORD are NOT set here. The post-deploy script connects to the server + // via the firewall-allowed public endpoint to (a) create the application role `testuser` + // and (b) write `PG_USER` / `PG_PASSWORD` onto this Web App via `az webapp config + // appsettings set`. The server-admin login is never exposed to the Web App at runtime. + PG_HOST: pgHost + PG_PORT: pgPort + PG_DATABASE: pgDatabase + WEBSITES_PORT: '8000' + LOGIN_NAME: username + } +} + +resource webAppSourceControl 'Microsoft.Web/sites/sourcecontrols@2024-11-01' = if (contains(repoUrl,'http')){ + name: 'web' + parent: webApp + properties: { + repoUrl: repoUrl + branch: 'master' + isManualIntegration: true + } +} + +resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if(!empty(workspaceId)) { + name: diagnosticSettingsName + scope: webApp + properties: { + workspaceId: workspaceId + logs: logs + metrics: metrics + } +} + +//******************************************** +// Outputs +//******************************************** +output id string = webApp.id +output name string = webApp.name +output defaultHostName string = webApp.properties.defaultHostName diff --git a/samples/web-app-postgresql-flexible-server/dotnet/images/architecture.png b/samples/web-app-postgresql-flexible-server/dotnet/images/architecture.png new file mode 100644 index 0000000..d0337f7 Binary files /dev/null and b/samples/web-app-postgresql-flexible-server/dotnet/images/architecture.png differ diff --git a/samples/web-app-postgresql-flexible-server/dotnet/images/vacation-planner.png b/samples/web-app-postgresql-flexible-server/dotnet/images/vacation-planner.png new file mode 100644 index 0000000..a7c5151 Binary files /dev/null and b/samples/web-app-postgresql-flexible-server/dotnet/images/vacation-planner.png differ diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/README.md b/samples/web-app-postgresql-flexible-server/dotnet/scripts/README.md new file mode 100644 index 0000000..f68121a --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/README.md @@ -0,0 +1,72 @@ +# Azure CLI Deployment + +This directory contains Bash scripts for deploying and validating the sample using the `lstk` CLI. For details about the sample application, see [Azure Web App with Azure Database for PostgreSQL flexible server](../README.md). + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [Docker](https://docs.docker.com/get-docker/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) + [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [PostgreSQL client (`psql`)](https://www.postgresql.org/download/) +- [`jq`](https://jqlang.org/) + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +[`deploy.sh`](deploy.sh) provisions the same resources as the Bicep and Terraform variants but with raw `az` commands: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli). +2. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview). +3. [Network Security Groups](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview) for both subnets. +4. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview). +5. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview) with: + - *app-subnet*: delegated to `Microsoft.Web/serverFarms` (with NAT gateway). + - *pe-subnet*: hosts the Private Endpoint (no delegation; `disable-private-endpoint-network-policies=true`). +6. [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): public-access mode, `Burstable / Standard_B1ms`, version 16, 32 GiB, HA disabled. With a permissive `AllowAllIPs` firewall rule. +7. The `PlannerDB` [database](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers). +8. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) `privatelink.postgres.database.azure.com`, linked to the VNet. +9. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) targeting the PG server with group `postgresqlServer`, plus the DNS-zone group that auto-registers the A record. +10. A separate application role (`testuser`) created via `psql`, with the minimum schema privileges on `PlannerDB`. +11. The `activities` table and three seeded rows (*Go to Paris*, *Go to London*, *Go to Mexico*). +12. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). +13. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration into *app-subnet*, configured with `PG_HOST`, `PG_PORT`, `PG_USER=testuser`, `PG_PASSWORD`, `PG_DATABASE`, `LOGIN_NAME`, `WEBSITES_PORT`. + +The Web App uses `testuser` — the server-admin login is never written into the Web App's app settings. Use [`validate.sh`](validate.sh) after `deploy.sh` to inspect each Azure resource. + +## Usage + +```bash +# default secrets +bash deploy.sh + +# override secrets via env vars +PG_ADMIN_PASSWORD='' \ +PG_APP_PASSWORD='' \ +bash deploy.sh + +# inspect what was deployed +bash validate.sh +``` + +`deploy.sh` accepts the following environment overrides: + +| Env var | Default | Description | +| -------------------- | ------------------ | --------------------------------------------- | +| `PG_ADMIN_USER` | `pgadmin` | Server administrator login | +| `PG_ADMIN_PASSWORD` | `P@ssw0rd1234!` | Server administrator password (sensitive) | +| `PG_DATABASE_NAME` | `PlannerDB` | Application database | +| `PG_APP_USER` | `testuser` | Application role used by the Web App | +| `PG_APP_PASSWORD` | `TestP@ssw0rd123` | Password for the application role | +| `DEPLOY_APP` | `1` | Set to `0` to skip the zip deployment step | + +The script uses [`call-web-app.sh`](call-web-app.sh) (unchanged from the source sample) to demonstrate four ways of hitting the Web App from outside the emulator. + +## Related Documentation + +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/call-web-app.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/call-web-app.sh new file mode 100755 index 0000000..b521aed --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/call-web-app.sh @@ -0,0 +1,201 @@ +#!/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_web_app() { + # Web app port + local web_app_port=8000 + + # Get the web app name + echo "Getting web app name..." + web_app_name=$(az webapp list --query '[0].name' --output tsv) + + if [ -n "$web_app_name" ]; then + echo "Web app [$web_app_name] successfully retrieved." + else + echo "Error: No web app found" + exit 1 + fi + + # Get the resource group name + echo "Getting resource group name for web app [$web_app_name]..." + resource_group_name=$(az webapp 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 web app [$web_app_name]" + exit 1 + fi + + # Get the the default host name of the web app + echo "Getting the default host name of the web app [$web_app_name]..." + app_host_name=$(az webapp show \ + --name "$web_app_name" \ + --resource-group "$resource_group_name" \ + --query 'defaultHostName' \ + --output tsv) + + if [ -n "$app_host_name" ]; then + echo "Web app default host name [$app_host_name] successfully retrieved." + else + echo "Error: No web app default host name found" + exit 1 + fi + + # Get the Docker container name + echo "Finding container name with prefix [ls-$web_app_name]..." + container_name=$(get_docker_container_name_by_prefix "ls-$web_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") + + 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 web app HTTP trigger (internal port 8000) + echo "Getting the host port mapped to internal port $web_app_port in container [$container_name]..." + host_port=$(get_docker_container_port_mapping "$container_name" "$web_app_port") + + 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]" + fi + + # Retrieve LocalStack proxy port + proxy_port=$(curl --max-time 10 http://localhost:4566/_localstack/proxy -s | jq '.proxy_port') + + if [ -n "$proxy_port" ]; then + # Call the web app via emulator proxy + echo "Calling web app [$web_app_name] via emulator..." + curl --max-time 10 --proxy "http://localhost:$proxy_port/" -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via emulator proxy port [$proxy_port] succeeded." + else + echo "Web app call via emulator proxy port [$proxy_port] failed." + fi + else + echo "Failed to retrieve LocalStack proxy port" + fi + + if [ -n "$container_ip" ]; then + # Call the web app via the container IP address + echo "Calling web app [$web_app_name] via container IP address [$container_ip]..." + curl --max-time 10 -s "http://$container_ip:$web_app_port/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via container IP address [$container_ip] succeeded." + else + echo "Web app call via container IP address [$container_ip] failed." + fi + else + echo "Failed to retrieve container IP address" + fi + + if [ -n "$host_port" ]; then + # Call the web app via the host port + echo "Calling web app [$web_app_name] via host port [$host_port]..." + curl --max-time 10 -s "http://127.0.0.1:$host_port/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via host port [$host_port] succeeded." + else + echo "Web app call via host port [$host_port] failed." + fi + else + echo "Failed to retrieve host port" + fi + + if [ -n "$app_host_name" ]; then + # Call the web app via the default hostname + echo "Calling web app [$web_app_name] via default hostname [$app_host_name]..." + curl --max-time 10 -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via default hostname [$app_host_name] succeeded." + else + echo "Web app call via default hostname [$app_host_name] failed." + fi + else + echo "Failed to retrieve web app hostname" + fi +} + +call_web_app \ No newline at end of file diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/deploy.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/deploy.sh new file mode 100755 index 0000000..71d5bdc --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/deploy.sh @@ -0,0 +1,1127 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +DIAGNOSTIC_SETTINGS_NAME='default' +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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" +WEB_APP_SUBNET_NAME="app-subnet" +WEB_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_NAME="privatelink.postgres.database.azure.com" +PRIVATE_ENDPOINT_NAME="${PREFIX}-postgres-pe-${SUFFIX}" +PRIVATE_ENDPOINT_GROUP="postgresqlServer" +PRIVATE_DNS_ZONE_GROUP_NAME="default" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="S1" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +POSTGRES_SERVER_NAME="${PREFIX}-pgflex-${SUFFIX}" +POSTGRES_VERSION="16" +POSTGRES_SKU_NAME="Standard_B1ms" +POSTGRES_SKU_TIER="Burstable" +POSTGRES_STORAGE_SIZE_GB=32 +POSTGRES_BACKUP_RETENTION_DAYS=7 +POSTGRES_DATABASE_NAME="PlannerDB" +PG_ADMIN_USER="pgadmin" +PG_ADMIN_PASSWORD="P@ssw0rd1234!" +PG_APP_USER="testuser" +PG_APP_PASSWORD="TestP@ssw0rd123" +FIREWALL_RULE_NAME="AllowAllIPs" +RUNTIME="dotnetcore" +RUNTIME_VERSION="10.0" +LOGIN_NAME="paolo" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit +# Create a resource group +echo "Creating resource group [$RESOURCE_GROUP_NAME]..." +az group create \ + --name $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created successfully." +else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 +fi + +# Check if the PostgreSQL flexible server already exists +echo "Checking if [$POSTGRES_SERVER_NAME] PostgreSQL flexible server already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az postgres flexible-server show \ + --name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$POSTGRES_SERVER_NAME] PostgreSQL flexible server already exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$POSTGRES_SERVER_NAME] PostgreSQL flexible server in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create a PostgreSQL flexible server with public network access + az postgres flexible-server create \ + --name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --tier $POSTGRES_SKU_TIER \ + --sku-name $POSTGRES_SKU_NAME \ + --version $POSTGRES_VERSION \ + --storage-size $POSTGRES_STORAGE_SIZE_GB \ + --backup-retention $POSTGRES_BACKUP_RETENTION_DAYS \ + --geo-redundant-backup Disabled \ + --admin-user $PG_ADMIN_USER \ + --admin-password "$PG_ADMIN_PASSWORD" \ + --public-access Enabled \ + --zonal-resiliency Disabled \ + --yes \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "[$POSTGRES_SERVER_NAME] PostgreSQL flexible server successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$POSTGRES_SERVER_NAME] PostgreSQL flexible server in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$POSTGRES_SERVER_NAME] PostgreSQL flexible server already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Retrieve the resource id of the PostgreSQL flexible server +echo "Getting [$POSTGRES_SERVER_NAME] PostgreSQL flexible server resource id in the [$RESOURCE_GROUP_NAME] resource group..." +POSTGRES_SERVER_ID=$(az postgres flexible-server show \ + --name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query id \ + --output tsv \ + --only-show-errors) + +if [ -n "$POSTGRES_SERVER_ID" ]; then + echo "PostgreSQL flexible server resource id retrieved successfully: $POSTGRES_SERVER_ID" +else + echo "Failed to retrieve PostgreSQL flexible server resource id." + exit 1 +fi + +# Retrieve the fullyQualifiedDomainName of the PostgreSQL flexible server +echo "Getting [$POSTGRES_SERVER_NAME] PostgreSQL flexible server FQDN in the [$RESOURCE_GROUP_NAME] resource group..." +POSTGRES_FQDN_FULL=$(az postgres flexible-server show \ + --name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "fullyQualifiedDomainName" \ + --output tsv \ + --only-show-errors) + +if [ -n "$POSTGRES_FQDN_FULL" ]; then + echo "PostgreSQL flexible server FQDN retrieved successfully: $POSTGRES_FQDN_FULL" +else + echo "Failed to retrieve PostgreSQL flexible server FQDN." + exit 1 +fi + +# Split host:port — the LocalStack emulator embeds the dynamically allocated TCP-proxy port +# directly in fullyQualifiedDomainName, mirroring the storage / container registry emulators. +# Real Azure returns just the bare host so PG_PORT defaults to 5432. +POSTGRES_FQDN="${POSTGRES_FQDN_FULL%%:*}" +if [[ "$POSTGRES_FQDN_FULL" == *:* ]]; then + POSTGRES_PORT="${POSTGRES_FQDN_FULL##*:}" +else + POSTGRES_PORT=5432 +fi +echo "PostgreSQL host = $POSTGRES_FQDN, port = $POSTGRES_PORT" + +# Check if the server-level firewall rule already exists +echo "Checking if [$FIREWALL_RULE_NAME] firewall rule already exists on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." +az postgres flexible-server firewall-rule show \ + --server-name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $FIREWALL_RULE_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$FIREWALL_RULE_NAME] firewall rule already exists on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + echo "Creating [$FIREWALL_RULE_NAME] firewall rule on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." + + # Create a permissive firewall rule so the deploy machine can run the psql bootstrap. + # The create is retried because this PUT intermittently answers 500 against the emulator while + # the server finishes provisioning, and the Azure CLI's own retries all land within a few seconds. + FIREWALL_RULE_CREATED=0 + for attempt in $(seq 1 5); do + if az postgres flexible-server firewall-rule create \ + --name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --rule-name $FIREWALL_RULE_NAME \ + --start-ip-address "0.0.0.0" \ + --end-ip-address "255.255.255.255" \ + --only-show-errors 1>/dev/null; then + FIREWALL_RULE_CREATED=1 + break + fi + + if [ "$attempt" -lt 5 ]; then + echo "Attempt $attempt of 5 to create the [$FIREWALL_RULE_NAME] firewall rule failed; retrying in 10 seconds..." + sleep 10 + fi + done + + if [ $FIREWALL_RULE_CREATED -eq 1 ]; then + echo "[$FIREWALL_RULE_NAME] firewall rule successfully created on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + else + # Not fatal: the rule governs public network access, which the emulator does not enforce, and + # the psql bootstrap below fails loudly if the server is genuinely unreachable. + echo "WARNING: could not create the [$FIREWALL_RULE_NAME] firewall rule on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server; continuing" + fi +else + echo "[$FIREWALL_RULE_NAME] firewall rule already exists on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" +fi + +# Check if the PostgreSQL database already exists +echo "Checking if [$POSTGRES_DATABASE_NAME] database already exists on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." +az postgres flexible-server db show \ + --server-name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $POSTGRES_DATABASE_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$POSTGRES_DATABASE_NAME] database already exists on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + echo "Creating [$POSTGRES_DATABASE_NAME] database on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." + + # Create the application database + az postgres flexible-server db create \ + --server-name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $POSTGRES_DATABASE_NAME \ + --charset UTF8 \ + --collation en_US.utf8 \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "[$POSTGRES_DATABASE_NAME] database successfully created on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + else + echo "Failed to create [$POSTGRES_DATABASE_NAME] database on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + exit 1 + fi +else + echo "[$POSTGRES_DATABASE_NAME] database already exists on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" +fi + +# Check if the network security group for the web app subnet already exists +echo "Checking if [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az network nsg show \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet..." + + # Create the network security group for the web app subnet + az network nsg create \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Get the resource id of the network security group for the web app subnet +echo "Getting [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet resource id in the [$RESOURCE_GROUP_NAME] resource group..." +WEB_APP_SUBNET_NSG_ID=$(az network nsg show \ + --name "$WEB_APP_SUBNET_NSG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_SUBNET_NSG_ID ]]; then + echo "[$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet resource id retrieved successfully: $WEB_APP_SUBNET_NSG_ID" +else + echo "Failed to retrieve [$WEB_APP_SUBNET_NSG_NAME] network security group for the web 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 environment=test iac=az-cli \ + --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 environment=test iac=az-cli \ + --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 environment=test iac=az-cli \ + --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 "$WEB_APP_SUBNET_NAME" \ + --subnet-prefix "$WEB_APP_SUBNET_PREFIX" \ + --tags environment=test iac=az-cli \ + --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 web app subnet to associate it with the NAT Gateway and the NSG + echo "Associating [$WEB_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_APP_SUBNET_NSG_NAME] network security group..." + + # Update the web app subnet to associate it with the NAT Gateway and the NSG + az network vnet subnet update \ + --name "$WEB_APP_SUBNET_NAME" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --nat-gateway "$NAT_GATEWAY_NAME" \ + --network-security-group "$WEB_APP_SUBNET_NSG_NAME" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$WEB_APP_SUBNET_NAME] subnet successfully associated with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_APP_SUBNET_NSG_NAME] network security group" + else + echo "Failed to associate [$WEB_APP_SUBNET_NAME] subnet with the [$NAT_GATEWAY_NAME] NAT Gateway and the [$WEB_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 + +# Check if the private DNS Zone already exists +echo "Checking if [$PRIVATE_DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az network private-dns zone show \ + --name "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$PRIVATE_DNS_ZONE_NAME] private DNS zone actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PRIVATE_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 "$PRIVATE_DNS_ZONE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$PRIVATE_DNS_ZONE_NAME] private DNS zone successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$PRIVATE_DNS_ZONE_NAME] private DNS zone in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "[$PRIVATE_DNS_ZONE_NAME] private DNS zone already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the virtual network link between the private DNS zone and the virtual network already exists +echo "Checking if [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_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 "$PRIVATE_DNS_ZONE_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network actually exists" + + echo "Creating [$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network..." + + # Create the virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network + az network private-dns link vnet create \ + --name "$VIRTUAL_NETWORK_LINK_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --zone-name "$PRIVATE_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 [$PRIVATE_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 [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network" + exit + fi +else + echo "[$VIRTUAL_NETWORK_LINK_NAME] virtual network link between [$PRIVATE_DNS_ZONE_NAME] private DNS zone and [$VIRTUAL_NETWORK_NAME] virtual network already exists" +fi + +# Check if the private endpoint already exists +echo "Checking if private endpoint [$PRIVATE_ENDPOINT_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=='$PRIVATE_ENDPOINT_NAME'].id" \ + --output tsv) + +if [[ -z $privateEndpointId ]]; then + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] does not exist in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$PRIVATE_ENDPOINT_NAME] private endpoint for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create a private endpoint for the PostgreSQL flexible server + az network private-endpoint create \ + --name "$PRIVATE_ENDPOINT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --vnet-name "$VIRTUAL_NETWORK_NAME" \ + --subnet "$PE_SUBNET_NAME" \ + --private-connection-resource-id "$POSTGRES_SERVER_ID" \ + --group-id "$PRIVATE_ENDPOINT_GROUP" \ + --connection-name "postgres-connection" \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] successfully created for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create a private endpoint for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "Private endpoint [$PRIVATE_ENDPOINT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the private DNS zone group is already created for the PostgreSQL flexible server private endpoint +echo "Checking if the private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint already exists..." +NAME=$(az network private-endpoint dns-zone-group show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PRIVATE_ENDPOINT_NAME" \ + --name "$PRIVATE_DNS_ZONE_GROUP_NAME" \ + --query name \ + --output tsv \ + --only-show-errors 2>/dev/null) + +if [[ -z $NAME ]]; then + echo "No private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint actually exists" + echo "Creating private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint..." + + # Create the private DNS zone group for the PostgreSQL flexible server private endpoint + az network private-endpoint dns-zone-group create \ + --name "$PRIVATE_DNS_ZONE_GROUP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --endpoint-name "$PRIVATE_ENDPOINT_NAME" \ + --private-dns-zone "$PRIVATE_DNS_ZONE_NAME" \ + --zone-name "postgres-zone" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint successfully created" + else + echo "Failed to create private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint" + exit + fi +else + echo "Private DNS zone group [$PRIVATE_DNS_ZONE_GROUP_NAME] for the [$PRIVATE_ENDPOINT_NAME] private endpoint already exists" +fi + +# Create application role [$PG_APP_USER] on the PostgreSQL flexible server +echo "Creating login [$PG_APP_USER] on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "DO \$\$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$PG_APP_USER') THEN + CREATE ROLE \"$PG_APP_USER\" WITH LOGIN PASSWORD '$PG_APP_PASSWORD'; + END IF; +END +\$\$;" + +if [ $? -eq 0 ]; then + echo "Login [$PG_APP_USER] created successfully" +else + echo "Failed to create login [$PG_APP_USER]" + exit 1 +fi + +# Grant CONNECT on the database to [$PG_APP_USER] +echo "Granting CONNECT on [$POSTGRES_DATABASE_NAME] to [$PG_APP_USER]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT CONNECT ON DATABASE \"$POSTGRES_DATABASE_NAME\" TO \"$PG_APP_USER\";" + +if [ $? -eq 0 ]; then + echo "CONNECT granted successfully to [$PG_APP_USER]" +else + echo "Failed to grant CONNECT to [$PG_APP_USER]" + exit 1 +fi + +# Grant schema privileges to [$PG_APP_USER] +echo "Granting schema privileges on [$POSTGRES_DATABASE_NAME] to [$PG_APP_USER]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname="$POSTGRES_DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT USAGE, CREATE ON SCHEMA public TO \"$PG_APP_USER\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"$PG_APP_USER\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"$PG_APP_USER\";" + +if [ $? -eq 0 ]; then + echo "Schema privileges granted successfully to [$PG_APP_USER]" +else + echo "Failed to grant schema privileges to [$PG_APP_USER]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$PG_APP_USER]..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$POSTGRES_DATABASE_NAME" \ + --no-password \ + -c "SELECT current_user, current_database(), now();" + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$PG_APP_USER]" +else + echo "Connection test failed with user [$PG_APP_USER]" + exit 1 +fi + +# Create [activities] table +echo "Creating [activities] table in the [$POSTGRES_DATABASE_NAME] database..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$POSTGRES_DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username); + CREATE INDEX IF NOT EXISTS idx_activities_created_at ON activities(created_at DESC);" + +if [ $? -eq 0 ]; then + echo "[activities] table created successfully" +else + echo "Failed to create [activities] table" + exit 1 +fi + +# Insert sample data +echo "Inserting sample data into [activities] table..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$POSTGRES_DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "INSERT INTO activities (id, username, activity) VALUES + (md5('paolo_pisa_seed'), 'paolo', 'Visit the Leaning Tower in Pisa'), + (md5('paolo_volterra_seed'), 'paolo', 'Explore Etruscan walls in Volterra'), + (md5('paolo_san_gimignano_seed'), 'paolo', 'Climb Torre Grossa in San Gimignano'), + (md5('paolo_siena_seed'), 'paolo', 'Walk across Piazza del Campo in Siena'), + (md5('paolo_montalcino_seed'), 'paolo', 'Taste Brunello wine in Montalcino'), + (md5('paolo_pienza_seed'), 'paolo', 'Sample Pecorino cheese in Pienza'), + (md5('paolo_florence_seed'), 'paolo', 'Admire Michelangelo''s David in Florence'), + (md5('paolo_viareggio_beach_seed'), 'paolo', 'Relax by the beach in Viareggio'), + (md5('paolo_viareggio_promenade_seed'), 'paolo', 'Stroll along the Viareggio promenade') + ON CONFLICT (id) DO NOTHING;" + +if [ $? -eq 0 ]; then + echo "Sample data inserted successfully into [activities] table" +else + echo "Failed to insert sample data into [activities] table" + exit 1 +fi + +# Query sample data +echo "Querying sample data from [activities] table..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$POSTGRES_DATABASE_NAME" \ + --no-password \ + -c "SELECT * FROM activities;" + +if [ $? -eq 0 ]; then + echo "Sample data queried successfully from [activities] table" +else + echo "Failed to query sample data from [activities] table" + exit 1 +fi + +# Create app service plan +echo "Creating app service plan [$APP_SERVICE_PLAN_NAME]..." +az appservice plan create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --location "$LOCATION" \ + --sku "$APP_SERVICE_PLAN_SKU" \ + --is-linux \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "app service plan [$APP_SERVICE_PLAN_NAME] created successfully." +else + echo "Failed to create app service plan [$APP_SERVICE_PLAN_NAME]." + exit 1 +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 + +# Create the web app +echo "Creating web app [$WEB_APP_NAME]..." +az webapp create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --plan "$APP_SERVICE_PLAN_NAME" \ + --name "$WEB_APP_NAME" \ + --runtime "$RUNTIME:$RUNTIME_VERSION" \ + --vnet "$VIRTUAL_NETWORK_NAME" \ + --subnet "$WEB_APP_SUBNET_NAME" \ + --tags environment=test iac=az-cli \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Get the web app resource id +echo "Getting [$WEB_APP_NAME] web app resource id in the [$RESOURCE_GROUP_NAME] resource group..." +WEB_APP_ID=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_ID ]]; then + echo "[$WEB_APP_NAME] web app resource id retrieved successfully: $WEB_APP_ID" +else + echo "Failed to retrieve [$WEB_APP_NAME] web app resource id in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 +fi + +# Enabling forced tunneling for web app [$WEB_APP_NAME] to route all outbound traffic through the virtual network... +echo "Enabling forced tunneling for web app [$WEB_APP_NAME] to route all outbound traffic through the virtual network..." + +az resource update \ + --ids "$WEB_APP_ID" \ + --set properties.outboundVnetRouting.allTraffic=true \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Forced tunneling enabled for web app [$WEB_APP_NAME]." +else + echo "Failed to enable forced tunneling for web app [$WEB_APP_NAME]." + exit 1 +fi + +# Set web app settings +echo "Setting web app settings for [$WEB_APP_NAME]..." +az webapp config appsettings set \ + --name $WEB_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --settings \ + SCM_DO_BUILD_DURING_DEPLOYMENT='true' \ + ENABLE_ORYX_BUILD='true' \ + PG_HOST="$POSTGRES_FQDN" \ + PG_PORT="$POSTGRES_PORT" \ + PG_USER="$PG_APP_USER" \ + PG_PASSWORD="$PG_APP_PASSWORD" \ + PG_DATABASE="$POSTGRES_DATABASE_NAME" \ + LOGIN_NAME="$LOGIN_NAME" \ + WEBSITES_PORT="8000" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app settings for [$WEB_APP_NAME] set successfully." +else + echo "Failed to set web app settings for [$WEB_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 environment=test iac=az-cli \ + --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 web app already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app..." + + # Create the diagnostic settings for the web app to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "AppServiceHTTPLogs", "enabled": true}, + {"category": "AppServiceConsoleLogs", "enabled": true}, + {"category": "AppServiceAppLogs", "enabled": true}, + {"category": "AppServiceAuditLogs", "enabled": true}, + {"category": "AppServiceIPSecAuditLogs", "enabled": true}, + {"category": "AppServicePlatformLogs", "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 [$WEB_APP_NAME] web app successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web app" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_NAME] web 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 PostgreSQL flexible server already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$POSTGRES_SERVER_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." + + # Create the diagnostic settings for the PostgreSQL flexible server to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$POSTGRES_SERVER_ID" \ + --workspace "$LOG_ANALYTICS_NAME" \ + --logs '[ + {"category": "PostgreSQLLogs", "enabled": true} + ]' \ + --metrics '[ + {"category": "AllMetrics", "enabled": true} + ]' \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server 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 web app subnet already exist +echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet already exist..." +az monitor diagnostic-settings show \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_APP_SUBNET_NSG_ID" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet actually exist" + echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet..." + + # Create the diagnostic settings for the network security group for the web app subnet to send logs to the Log Analytics workspace + az monitor diagnostic-settings create \ + --name "$DIAGNOSTIC_SETTINGS_NAME" \ + --resource "$WEB_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 [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet successfully created" + else + echo "Failed to create [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web app subnet" + exit 1 + fi +else + echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$WEB_APP_SUBNET_NSG_NAME] network security group for the web 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 + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# List the contents of the zip package +echo "Contents of the zip package [$ZIPFILE]:" +unzip -l "$ZIPFILE" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +echo "Using standard az webapp deploy command for AzureCloud environment." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +# Remove the zip package of the web 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 diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/validate.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/validate.sh new file mode 100755 index 0000000..b76a693 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/validate.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +WEB_APP_SUBNET_NSG_NAME="${PREFIX}-webapp-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}" +PRIVATE_DNS_ZONE_NAME="privatelink.postgres.database.azure.com" +PRIVATE_ENDPOINT_NAME="${PREFIX}-postgres-pe-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +POSTGRES_SERVER_NAME="${PREFIX}-pgflex-${SUFFIX}" +POSTGRES_DATABASE_NAME="PlannerDB" +FIREWALL_RULE_NAME="AllowAllIPs" + +# 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 Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,State:state,Location:location,DefaultHostName:defaultHostName}' \ + --output table \ + --only-show-errors + +# Check Azure Database for PostgreSQL flexible server +echo -e "\n[$POSTGRES_SERVER_NAME] PostgreSQL flexible server:\n" +az postgres flexible-server show \ + --name "$POSTGRES_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query '{Name:name,Location:location,ResourceGroup:resourceGroup,State:state,Version:version,FQDN:fullyQualifiedDomainName,PublicNetworkAccess:network.publicNetworkAccess}' \ + --output table \ + --only-show-errors + +# Check PostgreSQL database +echo -e "\n[$POSTGRES_DATABASE_NAME] PostgreSQL database:\n" +az postgres flexible-server db show \ + --server-name "$POSTGRES_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$POSTGRES_DATABASE_NAME" \ + --query '{Name:name,ResourceGroup:resourceGroup,Charset:charset,Collation:collation}' \ + --output table \ + --only-show-errors + +# Check PostgreSQL firewall rule +echo -e "\n[$FIREWALL_RULE_NAME] PostgreSQL firewall rule:\n" +az postgres flexible-server firewall-rule show \ + --server-name "$POSTGRES_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$FIREWALL_RULE_NAME" \ + --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 +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 + +# Check Private Endpoint +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 + +# Check Web App Subnet NSG +echo -e "\n[$WEB_APP_SUBNET_NSG_NAME] network security group:\n" +az network nsg show \ + --name "$WEB_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 diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Models/Activity.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..84277d4 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id)) + { + await store.DeleteAsync(id, cancellationToken); + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..904f8c5 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + await store.UpdateAsync(id, text, cancellationToken); + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated!"; + } + else + { + await store.AddAsync(text, cancellationToken); + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added!"; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Program.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Program.cs new file mode 100644 index 0000000..88d860d --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Program.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Code deployments built by Oryx export ASPNETCORE_URLS; custom images and local runs only set PORT. +if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") is null + && Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseUrls($"http://*:{port}"); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var databaseOptions = PostgresOptions.FromEnvironment(); + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new PostgresActivityStore(databaseOptions, sp.GetRequiredService>())); +// The flexible server can take a few seconds to accept connections on the first deploy. +builder.Services.AddHostedService(sp => new StoreInitializer( + sp.GetRequiredService(), + sp.GetRequiredService>(), + attempts: 30, + delay: TimeSpan.FromSeconds(2))); + +var app = builder.Build(); + +// One log line per request, the equivalent of the gunicorn access log the Python sample produces. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/ActivityId.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/ActivityId.cs new file mode 100644 index 0000000..8654aaf --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/ActivityId.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VacationPlanner.Services; + +/// MD5 of username + activity + timestamp: the id scheme shared by the Vacation Planner samples. +public static class ActivityId +{ + public static string Create(string username, string activity) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"); + var hash = MD5.HashData(Encoding.UTF8.GetBytes($"{username}_{activity}_{timestamp}")); + return Convert.ToHexStringLower(hash); + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/IActivityStore.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..3b24e5f --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,21 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + Task AddAsync(string text, CancellationToken cancellationToken); + + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresActivityStore.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresActivityStore.cs new file mode 100644 index 0000000..2a1045d --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresActivityStore.cs @@ -0,0 +1,118 @@ +using Npgsql; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// +/// Activities in a PostgreSQL activities table. Like the Python sample, the store is low-throughput +/// and opens a fresh connection per call instead of managing a pool explicitly. +/// +public sealed class PostgresActivityStore(PostgresOptions options, ILogger logger) : IActivityStore +{ + private const string SchemaDdl = """ + CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username); + CREATE INDEX IF NOT EXISTS idx_activities_created_at ON activities(created_at DESC); + """; + + // Negotiate TLS when the server offers it, without certificate verification (libpq's "prefer", which + // the Python sample relies on): the flexible server's certificate is publicly trusted on Azure but + // self-signed under LocalStack. Npgsql only validates certificates with SslMode VerifyCA/VerifyFull. + private readonly string _connectionString = new NpgsqlConnectionStringBuilder + { + Host = options.Host, + Port = options.Port, + Username = options.User, + Password = options.Password, + Database = options.Database, + Timeout = 10, + SslMode = SslMode.Prefer, + }.ConnectionString; + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand(SchemaDdl, connection); + await command.ExecuteNonQueryAsync(cancellationToken); + logger.LogInformation("PostgreSQL schema initialized"); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand( + "SELECT id, activity FROM activities WHERE username = @username ORDER BY created_at DESC", connection); + command.Parameters.AddWithValue("username", options.Username); + + var activities = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + activities.Add(new Activity(reader.GetString(0), reader.GetString(1))); + } + + logger.LogInformation( + "Retrieved {Count} activities for user: {Username}", + activities.Count, + options.Username + ); + return activities; + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand( + "INSERT INTO activities (id, username, activity) VALUES (@id, @username, @activity) ON CONFLICT (id) DO NOTHING", + connection); + command.Parameters.AddWithValue("id", ActivityId.Create(options.Username, text)); + command.Parameters.AddWithValue("username", options.Username); + command.Parameters.AddWithValue("activity", text); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("UPDATE activities SET activity = @activity WHERE id = @id", connection); + command.Parameters.AddWithValue("activity", text); + command.Parameters.AddWithValue("id", id); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("DELETE FROM activities WHERE id = @id", connection); + command.Parameters.AddWithValue("id", id); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("SELECT 1", connection); + await command.ExecuteScalarAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "PostgreSQL health check failed"); + return false; + } + } + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresOptions.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresOptions.cs new file mode 100644 index 0000000..cc824d0 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresOptions.cs @@ -0,0 +1,28 @@ +namespace VacationPlanner.Services; + +/// Connection settings read from the same environment variables the Python sample uses. +public sealed record PostgresOptions(string Host, int Port, string User, string Password, string Database, string Username) +{ + public static PostgresOptions FromEnvironment() + { + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "paolo"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("LOGIN_NAME cannot be empty"); + } + + return new PostgresOptions( + Host: Require("PG_HOST"), + Port: int.Parse(Environment.GetEnvironmentVariable("PG_PORT") ?? "5432"), + User: Require("PG_USER"), + Password: Require("PG_PASSWORD"), + Database: Environment.GetEnvironmentVariable("PG_DATABASE") ?? "sampledb", + Username: username); + } + + private static string Require(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException( + $"Missing required environment variable: {name}. Set PG_HOST, PG_USER, PG_PASSWORD (and optionally PG_PORT, PG_DATABASE)."); +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/VacationPlanner.csproj b/samples/web-app-postgresql-flexible-server/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..afa176e --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/appsettings.json b/samples/web-app-postgresql-flexible-server/dotnet/src/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/favicon.ico b/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/style.css b/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/README.md b/samples/web-app-postgresql-flexible-server/dotnet/terraform/README.md new file mode 100644 index 0000000..7ce4285 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/README.md @@ -0,0 +1,69 @@ +# Terraform Deployment + +This directory contains Terraform modules and a deployment script for provisioning the sample's Azure resources. For details about the sample application, see [Azure Web App with Azure Database for PostgreSQL flexible server](../README.md). + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [Terraform](https://developer.hashicorp.com/terraform/downloads) (1.5+) +- [Docker](https://docs.docker.com/get-docker/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) + [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [PostgreSQL client (`psql`)](https://www.postgresql.org/download/) +- [`jq`](https://jqlang.org/) + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +The Terraform configuration provisions: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli). +2. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview) with two subnets: + - *app-subnet* (delegated to `Microsoft.Web/serverFarms` for the Web App's VNet integration) + - *pe-subnet* (hosts the Private Endpoint to the flex server) +3. [Azure Private DNS Zone](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) `privatelink.postgres.database.azure.com`, linked to the VNet. +4. [Azure Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (group `postgresqlServer`). +5. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview). +6. [Network Security Groups](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): one per subnet. +7. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview). +8. [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): public-access mode, Burstable `Standard_B1ms`, version 16, 32 GiB, HA disabled. A permissive firewall rule (`AllowAllIPs`, `0.0.0.0–255.255.255.255`) lets the deploy machine reach the server for the post-apply psql bootstrap. +9. [PostgreSQL database](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers) `PlannerDB`. +10. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). +11. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration. `PG_HOST` / `PG_PORT` / `PG_DATABASE` are written by Terraform; `PG_USER` and `PG_PASSWORD` are written by `deploy.sh` after psql creates the application role. + +## Provisioning Script + +[`deploy.sh`](deploy.sh) performs: + +- `terraform init -upgrade` +- `terraform plan -out=tfplan` (passing `pg_admin_password`) +- `terraform apply -auto-approve tfplan` +- Reads outputs (`resource_group_name`, `web_app_name`, `postgres_server_name`, `postgres_fqdn`, `postgres_database_name`). +- Connects to the server as the admin via the public endpoint + firewall rule and creates the `testuser` role, grants schema rights, creates the `activities` table, and seeds three rows. +- Sets `PG_USER=testuser` + `PG_PASSWORD=` on the Web App via `az webapp config appsettings set`. +- Zips the source under `../src` and deploys via `az webapp deploy`. + +## Variables + +Override any of the variables in [`variables.tf`](variables.tf) by editing [`terraform.tfvars`](terraform.tfvars) or passing `-var` to `terraform plan`. Notable PostgreSQL ones: + +| Variable | Default | Description | +| -------------------------- | ---------------- | ---------------------------------------- | +| `pg_admin_login` | `pgadmin` | Server administrator login | +| `pg_admin_password` | `P@ssw0rd1234!` | Server administrator password (sensitive) | +| `pg_version` | `16` | PostgreSQL major version | +| `pg_sku_name` | `B_Standard_B1ms`| Compute SKU | +| `pg_storage_mb` | `32768` | Storage size in MB | +| `pg_backup_retention_days` | `7` | Backup retention | +| `pg_database_name` | `PlannerDB` | Application database | + +For non-dev deployments, set `pg_admin_password` via env var: `PG_ADMIN_PASSWORD=... bash deploy.sh`. + +## Related Documentation + +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/deploy.sh b/samples/web-app-postgresql-flexible-server/dotnet/terraform/deploy.sh new file mode 100755 index 0000000..448d315 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/deploy.sh @@ -0,0 +1,266 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +PG_ADMIN_USER="pgadmin" +PG_ADMIN_PASSWORD="P@ssw0rd1234!" +PG_APP_USER="testuser" +PG_APP_PASSWORD="TestP@ssw0rd123" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# 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" \ + -var "pg_admin_login=$PG_ADMIN_USER" \ + -var "pg_admin_password=$PG_ADMIN_PASSWORD" + +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) +WEB_APP_NAME=$(terraform output -raw web_app_name) +POSTGRES_SERVER_NAME=$(terraform output -raw postgres_server_name) +POSTGRES_FQDN_FULL=$(terraform output -raw postgres_fqdn) +DATABASE_NAME=$(terraform output -raw postgres_database_name) + +if [[ -z "$RESOURCE_GROUP_NAME" || -z "$WEB_APP_NAME" || -z "$POSTGRES_SERVER_NAME" ]]; then + echo "Resource Group Name, Web App Name, or PostgreSQL Server Name is empty. Exiting." + exit 1 +fi + +# Split host:port — the LocalStack emulator embeds the dynamically allocated TCP-proxy port +# directly in fullyQualifiedDomainName, mirroring the storage / container registry emulators. +# Real Azure returns just the bare host so PG_PORT defaults to 5432. +POSTGRES_FQDN="${POSTGRES_FQDN_FULL%%:*}" +if [[ "$POSTGRES_FQDN_FULL" == *:* ]]; then + POSTGRES_PORT="${POSTGRES_FQDN_FULL##*:}" +else + POSTGRES_PORT=5432 +fi +echo "PostgreSQL host = $POSTGRES_FQDN, port = $POSTGRES_PORT" + +# Create application role [$PG_APP_USER] on the PostgreSQL flexible server +echo "Creating login [$PG_APP_USER] on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "DO \$\$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$PG_APP_USER') THEN + CREATE ROLE \"$PG_APP_USER\" WITH LOGIN PASSWORD '$PG_APP_PASSWORD'; + END IF; +END +\$\$;" + +if [ $? -eq 0 ]; then + echo "Login [$PG_APP_USER] created successfully" +else + echo "Failed to create login [$PG_APP_USER]" + exit 1 +fi + +# Grant CONNECT on the database to [$PG_APP_USER] +echo "Granting CONNECT on [$DATABASE_NAME] to [$PG_APP_USER]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT CONNECT ON DATABASE \"$DATABASE_NAME\" TO \"$PG_APP_USER\";" + +if [ $? -eq 0 ]; then + echo "CONNECT granted successfully to [$PG_APP_USER]" +else + echo "Failed to grant CONNECT to [$PG_APP_USER]" + exit 1 +fi + +# Grant schema privileges to [$PG_APP_USER] +echo "Granting schema privileges on [$DATABASE_NAME] to [$PG_APP_USER]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT USAGE, CREATE ON SCHEMA public TO \"$PG_APP_USER\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"$PG_APP_USER\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"$PG_APP_USER\";" + +if [ $? -eq 0 ]; then + echo "Schema privileges granted successfully to [$PG_APP_USER]" +else + echo "Failed to grant schema privileges to [$PG_APP_USER]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$PG_APP_USER]..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + -c "SELECT current_user, current_database(), now();" + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$PG_APP_USER]" +else + echo "Connection test failed with user [$PG_APP_USER]" + exit 1 +fi + +# Create [activities] table +echo "Creating [activities] table in the [$DATABASE_NAME] database..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username); + CREATE INDEX IF NOT EXISTS idx_activities_created_at ON activities(created_at DESC);" + +if [ $? -eq 0 ]; then + echo "[activities] table created successfully" +else + echo "Failed to create [activities] table" + exit 1 +fi + +# Insert sample data +echo "Inserting sample data into [activities] table..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "INSERT INTO activities (id, username, activity) VALUES + (md5('paolo_pisa_seed'), 'paolo', 'Visit the Leaning Tower in Pisa'), + (md5('paolo_volterra_seed'), 'paolo', 'Explore Etruscan walls in Volterra'), + (md5('paolo_san_gimignano_seed'), 'paolo', 'Climb Torre Grossa in San Gimignano'), + (md5('paolo_siena_seed'), 'paolo', 'Walk across Piazza del Campo in Siena'), + (md5('paolo_montalcino_seed'), 'paolo', 'Taste Brunello wine in Montalcino'), + (md5('paolo_pienza_seed'), 'paolo', 'Sample Pecorino cheese in Pienza'), + (md5('paolo_florence_seed'), 'paolo', 'Admire Michelangelo''s David in Florence'), + (md5('paolo_viareggio_beach_seed'), 'paolo', 'Relax by the beach in Viareggio'), + (md5('paolo_viareggio_promenade_seed'), 'paolo', 'Stroll along the Viareggio promenade') + ON CONFLICT (id) DO NOTHING;" + +if [ $? -eq 0 ]; then + echo "Sample data inserted successfully into [activities] table" +else + echo "Failed to insert sample data into [activities] table" + exit 1 +fi + +# Query sample data +echo "Querying sample data from [activities] table..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + -c "SELECT * FROM activities;" + +if [ $? -eq 0 ]; then + echo "Sample data queried successfully from [activities] table" +else + echo "Failed to query sample data from [activities] table" + exit 1 +fi + +# Set PG_USER + PG_PASSWORD on the web app to point at the application role +echo "Setting PG_USER=[$PG_APP_USER] and PG_PASSWORD on the [$WEB_APP_NAME] web app..." +az webapp config appsettings set \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --settings PG_USER="$PG_APP_USER" PG_PASSWORD="$PG_APP_PASSWORD" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "PG_USER and PG_PASSWORD set successfully on the [$WEB_APP_NAME] web app" +else + echo "Failed to set PG_USER and PG_PASSWORD on the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Print the application settings of the web app +echo "Retrieving application settings for web app [$WEB_APP_NAME]..." +az webapp config appsettings list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +# Remove the zip package of the web 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 diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/main.tf new file mode 100644 index 0000000..62b6d9a --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/main.tf @@ -0,0 +1,197 @@ +locals { + prefix = lower(var.prefix) + suffix = lower(var.suffix) + resource_group_name = "${var.prefix}-rg" + log_analytics_name = "${local.prefix}-log-analytics-${local.suffix}" + virtual_network_name = "${local.prefix}-vnet-${local.suffix}" + nat_gateway_name = "${local.prefix}-nat-gateway-${local.suffix}" + webapp_subnet_nsg_name = "${local.prefix}-webapp-subnet-nsg-${local.suffix}" + pe_subnet_nsg_name = "${local.prefix}-pe-subnet-nsg-${local.suffix}" + postgres_server_name = "${local.prefix}-pgflex-${local.suffix}" + private_endpoint_name = "${local.prefix}-postgres-pe-${local.suffix}" + app_service_plan_name = "${local.prefix}-app-service-plan-${local.suffix}" + web_app_name = "${local.prefix}-webapp-${local.suffix}" + private_dns_zone_name = "privatelink.postgres.database.azure.com" + + # The PostgreSQL flexible-server emulator embeds the LS-side TCP-proxy port directly in + # `fullyQualifiedDomainName` (e.g. ".postgres.database.localhost.localstack.cloud:4515"). + # Real Azure returns just the bare host on 5432. Split on ":" so the Web App always gets the + # right host + port without any post-apply shell logic. + pg_fqdn_parts = split(":", module.postgres_flexible_server.fqdn) + pg_host = local.pg_fqdn_parts[0] + pg_port = length(local.pg_fqdn_parts) > 1 ? local.pg_fqdn_parts[1] : "5432" +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "example" { + name = local.resource_group_name + location = var.location + tags = var.tags +} + +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 +} + +# VNet with two subnets: +# * app-subnet — delegated to Microsoft.Web/serverFarms for the Web App's regional +# VNet integration. Outbound through the NAT Gateway. +# * pe-subnet — hosts the Private Endpoint to the PostgreSQL flexible server (no +# delegation; standard private-link subnet). +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 + } + ] +} + +module "webapp_subnet_network_security_group" { + source = "./modules/network_security_group" + name = local.webapp_subnet_nsg_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] + } +} + +module "pe_subnet_network_security_group" { + source = "./modules/network_security_group" + name = local.pe_subnet_nsg_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.pe_subnet_name) = module.virtual_network.subnet_ids[var.pe_subnet_name] + } +} + +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 +} + +module "private_dns_zone" { + source = "./modules/private_dns_zone" + name = local.private_dns_zone_name + 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 + } + } +} + +module "postgres_flexible_server" { + source = "./modules/postgres_flexible_server" + name = local.postgres_server_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + administrator_login = var.pg_admin_login + administrator_password = var.pg_admin_password + postgresql_version = var.pg_version + sku_name = var.pg_sku_name + storage_mb = var.pg_storage_mb + backup_retention_days = var.pg_backup_retention_days + database_name = var.pg_database_name + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags +} + +module "private_endpoint" { + source = "./modules/private_endpoint" + name = local.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.postgres_flexible_server.id + is_manual_connection = false + subresource_name = "postgresqlServer" + private_dns_zone_group_name = "private-dns-zone-group" + private_dns_zone_group_ids = [module.private_dns_zone.id] +} + +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 +} + +# Note: PG_USER and PG_PASSWORD are intentionally NOT set here. The post-apply step in +# deploy.sh connects to the server (via the firewall-allowed public endpoint) as the admin, +# creates the application role `testuser`, seeds the schema, and then writes `PG_USER` / +# `PG_PASSWORD` onto this Web App via `az webapp config appsettings set`. The server-admin +# login is never exposed to the Web App at runtime. +module "web_app" { + source = "./modules/web_app" + name = local.web_app_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + service_plan_id = module.app_service_plan.id + 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 + dotnet_version = var.dotnet_version + log_analytics_workspace_id = module.log_analytics_workspace.id + tags = var.tags + + app_settings = { + SCM_DO_BUILD_DURING_DEPLOYMENT = "true" + ENABLE_ORYX_BUILD = "true" + PG_HOST = local.pg_host + PG_PORT = local.pg_port + PG_DATABASE = module.postgres_flexible_server.database_name + LOGIN_NAME = var.login_name + WEBSITES_PORT = var.websites_port + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/app_service_plan/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/app_service_plan/main.tf new file mode 100644 index 0000000..98a3e4d --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/app_service_plan/outputs.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/app_service_plan/outputs.tf new file mode 100644 index 0000000..f1455ea --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/app_service_plan/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/app_service_plan/variables.tf new file mode 100644 index 0000000..e543066 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/log_analytics/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/log_analytics/main.tf new file mode 100644 index 0000000..2f88414 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/log_analytics/output.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/log_analytics/output.tf new file mode 100644 index 0000000..fe2c398 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/log_analytics/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/log_analytics/variables.tf new file mode 100644 index 0000000..2db6a01 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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 "tags" { + description = "(Optional) Specifies the tags of the Azure Log Analytics workspace." + type = map(any) + default = {} +} + +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 +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/nat_gateway/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/nat_gateway/main.tf new file mode 100644 index 0000000..cc384af --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/nat_gateway/output.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/nat_gateway/output.tf new file mode 100644 index 0000000..1e3fd03 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/nat_gateway/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/nat_gateway/variables.tf new file mode 100644 index 0000000..c1c8ea5 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/network_security_group/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/network_security_group/main.tf new file mode 100644 index 0000000..c649652 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/network_security_group/outputs.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/network_security_group/outputs.tf new file mode 100644 index 0000000..b8ca8d5 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/network_security_group/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/network_security_group/variables.tf new file mode 100644 index 0000000..04eb07e --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/postgres_flexible_server/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/postgres_flexible_server/main.tf new file mode 100644 index 0000000..443327b --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/postgres_flexible_server/main.tf @@ -0,0 +1,46 @@ +resource "azurerm_postgresql_flexible_server" "this" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + version = var.postgresql_version + administrator_login = var.administrator_login + administrator_password = var.administrator_password + sku_name = var.sku_name + storage_mb = var.storage_mb + backup_retention_days = var.backup_retention_days + geo_redundant_backup_enabled = false + # Public access is enabled and a permissive firewall rule lets the deploy machine reach the + # server just long enough to run the post-deploy psql bootstrap. The Web App itself reaches + # the server through a Private Endpoint (see the private_endpoint module in main.tf). + public_network_access_enabled = true + + tags = var.tags +} + +resource "azurerm_postgresql_flexible_server_database" "this" { + name = var.database_name + server_id = azurerm_postgresql_flexible_server.this.id + charset = var.database_charset + collation = var.database_collation +} + +resource "azurerm_postgresql_flexible_server_firewall_rule" "allow_all" { + name = var.firewall_rule_name + server_id = azurerm_postgresql_flexible_server.this.id + start_ip_address = var.firewall_start_ip + end_ip_address = var.firewall_end_ip +} + +resource "azurerm_monitor_diagnostic_setting" "this" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_postgresql_flexible_server.this.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "PostgreSQLLogs" + } + + enabled_metric { + category = "AllMetrics" + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/postgres_flexible_server/outputs.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/postgres_flexible_server/outputs.tf new file mode 100644 index 0000000..faccfe8 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/postgres_flexible_server/outputs.tf @@ -0,0 +1,15 @@ +output "id" { + value = azurerm_postgresql_flexible_server.this.id +} + +output "name" { + value = azurerm_postgresql_flexible_server.this.name +} + +output "fqdn" { + value = azurerm_postgresql_flexible_server.this.fqdn +} + +output "database_name" { + value = azurerm_postgresql_flexible_server_database.this.name +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/postgres_flexible_server/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/postgres_flexible_server/variables.tf new file mode 100644 index 0000000..d8bd65a --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/postgres_flexible_server/variables.tf @@ -0,0 +1,81 @@ +variable "name" { + description = "Name of the PostgreSQL flexible server." + type = string +} + +variable "resource_group_name" { + type = string +} + +variable "location" { + type = string +} + +variable "administrator_login" { + type = string +} + +variable "administrator_password" { + type = string + sensitive = true +} + +variable "postgresql_version" { + type = string + default = "16" +} + +variable "sku_name" { + type = string + default = "B_Standard_B1ms" +} + +variable "storage_mb" { + type = number + default = 32768 +} + +variable "backup_retention_days" { + type = number + default = 7 +} + +variable "database_name" { + type = string + default = "PlannerDB" +} + +variable "database_charset" { + type = string + default = "UTF8" +} + +variable "database_collation" { + type = string + default = "en_US.utf8" +} + +variable "firewall_rule_name" { + description = "Server-level firewall rule that allows the deploy machine to run the psql bootstrap." + type = string + default = "AllowAllIPs" +} + +variable "firewall_start_ip" { + type = string + default = "0.0.0.0" +} + +variable "firewall_end_ip" { + type = string + default = "255.255.255.255" +} + +variable "log_analytics_workspace_id" { + type = string +} + +variable "tags" { + type = map(string) + default = {} +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_dns_zone/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_dns_zone/main.tf new file mode 100644 index 0000000..e61df00 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_dns_zone/main.tf @@ -0,0 +1,25 @@ +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))}" + private_dns_zone_id = azurerm_private_dns_zone.example.id + 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/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_dns_zone/outputs.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_dns_zone/outputs.tf new file mode 100644 index 0000000..ca141f3 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_dns_zone/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_dns_zone/variables.tf new file mode 100644 index 0000000..8d0c0cc --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_endpoint/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_endpoint/main.tf new file mode 100644 index 0000000..62bfbfb --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_endpoint/outputs.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_endpoint/outputs.tf new file mode 100644 index 0000000..367ab51 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_endpoint/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/private_endpoint/variables.tf new file mode 100644 index 0000000..2b7a888 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/virtual_network/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/virtual_network/main.tf new file mode 100644 index 0000000..2b7af04 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/virtual_network/main.tf @@ -0,0 +1,58 @@ +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 + + # NOTE: we deliberately do NOT add `enabled_metric { category = "AllMetrics" }` here. + # Many Azure subscriptions have a built-in or org-level Azure Policy + # (DeployIfNotExists) that auto-creates a `diagnosticSettings` resource on every new VNet + # forwarding `AllMetrics` to a workspace. Azure rejects a second diag setting that targets + # the same (resource, category, sink) triplet with a 409 Conflict — even if its name is + # different. The policy-managed one already covers AllMetrics; we contribute only the + # VMProtectionAlerts logs (typically NOT included by the default policy). + enabled_log { + category = "VMProtectionAlerts" + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/virtual_network/outputs.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/virtual_network/outputs.tf new file mode 100644 index 0000000..b464308 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/virtual_network/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/virtual_network/variables.tf new file mode 100644 index 0000000..f8c0b0e --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/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/web-app-postgresql-flexible-server/dotnet/terraform/modules/web_app/main.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/web_app/main.tf new file mode 100644 index 0000000..a2eed3a --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/web_app/main.tf @@ -0,0 +1,71 @@ +resource "azurerm_linux_web_app" "example" { + name = var.name + resource_group_name = var.resource_group_name + location = var.location + service_plan_id = var.service_plan_id + https_only = var.https_only + virtual_network_subnet_id = var.virtual_network_subnet_id + public_network_access_enabled = var.public_network_access_enabled + client_affinity_enabled = false + tags = var.tags + + identity { + type = "SystemAssigned" + } + + 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 + } + } + + app_settings = var.app_settings + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_monitor_diagnostic_setting" "example" { + name = "DiagnosticsSettings" + target_resource_id = azurerm_linux_web_app.example.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + enabled_log { + category = "AppServiceHTTPLogs" + } + + enabled_log { + category = "AppServiceConsoleLogs" + } + + enabled_log { + category = "AppServiceAppLogs" + } + + enabled_log { + category = "AppServiceAuditLogs" + } + + enabled_log { + category = "AppServiceIPSecAuditLogs" + } + + enabled_log { + category = "AppServicePlatformLogs" + } + + enabled_log { + category = "AppServiceAuthenticationLogs" + } + + enabled_metric { + category = "AllMetrics" + } +} \ No newline at end of file diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/web_app/outputs.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/web_app/outputs.tf new file mode 100644 index 0000000..d7b6981 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/web_app/outputs.tf @@ -0,0 +1,24 @@ +output "id" { + value = azurerm_linux_web_app.example.id + description = "Specifies the resource id of the Web App" +} + +output "name" { + value = azurerm_linux_web_app.example.name + description = "Specifies the name of the Web App" +} + +output "default_hostname" { + value = azurerm_linux_web_app.example.default_hostname + description = "Specifies the default hostname of the Web App" +} + +output "outbound_ip_addresses" { + value = azurerm_linux_web_app.example.outbound_ip_addresses + description = "Specifies the outbound IP addresses of the Web App" +} + +output "principal_id" { + value = azurerm_linux_web_app.example.identity[0].principal_id + description = "Specifies the Principal ID of the System Assigned Managed Identity" +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/web_app/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/web_app/variables.tf new file mode 100644 index 0000000..81e6679 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/modules/web_app/variables.tf @@ -0,0 +1,89 @@ +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 "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 "dotnet_version" { + description = "(Optional) Specifies the version of .NET to run. Possible values include 8.0, 9.0 and 10.0." + type = string + default = "10.0" + + validation { + condition = contains(["8.0", "9.0", "10.0"], var.dotnet_version) + error_message = "The dotnet_version must be one of the supported versions: 8.0, 9.0, 10.0." + } +} + +variable "app_settings" { + description = "(Optional) A map of key-value pairs for App Settings." + type = map(string) + default = {} +} + +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 +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/outputs.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/outputs.tf new file mode 100644 index 0000000..ae10778 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/outputs.tf @@ -0,0 +1,27 @@ +output "resource_group_name" { + value = local.resource_group_name +} + +output "postgres_server_name" { + value = module.postgres_flexible_server.name +} + +output "postgres_fqdn" { + value = module.postgres_flexible_server.fqdn +} + +output "postgres_database_name" { + value = module.postgres_flexible_server.database_name +} + +output "app_service_plan_name" { + value = module.app_service_plan.name +} + +output "web_app_name" { + value = module.web_app.name +} + +output "web_app_url" { + value = module.web_app.default_hostname +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/providers.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/providers.tf new file mode 100644 index 0000000..1f06025 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/providers.tf @@ -0,0 +1,24 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=5.1.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 = "azure.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/web-app-postgresql-flexible-server/dotnet/terraform/terraform.tfvars b/samples/web-app-postgresql-flexible-server/dotnet/terraform/terraform.tfvars new file mode 100644 index 0000000..919af4f --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/terraform.tfvars @@ -0,0 +1,3 @@ +prefix = "local" +suffix = "test" +location = "westeurope" \ No newline at end of file diff --git a/samples/web-app-postgresql-flexible-server/dotnet/terraform/variables.tf b/samples/web-app-postgresql-flexible-server/dotnet/terraform/variables.tf new file mode 100644 index 0000000..db048fd --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/terraform/variables.tf @@ -0,0 +1,196 @@ +variable "prefix" { + description = "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 = "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 = "Specifies the location for all resources." + type = string + default = "westeurope" +} + +# ----------------------------------------------------------------------------- +# PostgreSQL flexible server +# ----------------------------------------------------------------------------- +variable "pg_admin_login" { + description = "Administrator login for the PostgreSQL flexible server." + type = string + default = "pgadmin" +} + +variable "pg_admin_password" { + description = "Administrator password for the PostgreSQL flexible server. Pass via -var or the PG_ADMIN_PASSWORD env var; do NOT commit." + type = string + sensitive = true + default = "P@ssw0rd1234!" +} + +variable "pg_version" { + description = "PostgreSQL major version." + type = string + default = "16" + + validation { + condition = contains(["13", "14", "15", "16", "17"], var.pg_version) + error_message = "The pg_version must be one of: 13, 14, 15, 16, 17." + } +} + +variable "pg_sku_name" { + description = "Compute SKU for the PostgreSQL flexible server (e.g. B_Standard_B1ms)." + type = string + default = "B_Standard_B1ms" +} + +variable "pg_storage_mb" { + description = "Storage size in MB for the PostgreSQL flexible server." + type = number + default = 32768 +} + +variable "pg_backup_retention_days" { + description = "Backup retention period in days for the PostgreSQL flexible server." + type = number + default = 7 +} + +variable "pg_database_name" { + description = "Name of the application database to create on the PostgreSQL flexible server." + type = string + default = "PlannerDB" +} + +# ----------------------------------------------------------------------------- +# App Service / Web App +# ----------------------------------------------------------------------------- +variable "os_type" { + description = "OS type for the App Service Plan." + type = string + default = "Linux" +} + +variable "zone_balancing_enabled" { + type = bool + default = false +} + +variable "sku_name" { + description = "App Service Plan SKU name." + type = string + default = "S1" +} + +variable "dotnet_version" { + description = "(Optional) Specifies the version of .NET to run. Possible values include 8.0, 9.0 and 10.0." + type = string + default = "10.0" + + validation { + condition = contains(["8.0", "9.0", "10.0"], var.dotnet_version) + error_message = "The dotnet_version must be one of the supported versions: 8.0, 9.0, 10.0." + } +} + +variable "https_only" { + type = bool + default = false +} + +variable "minimum_tls_version" { + type = string + default = "1.2" +} + +variable "always_on" { + type = bool + default = true +} + +variable "http2_enabled" { + type = bool + default = false +} + +variable "public_network_access_enabled" { + type = bool + default = true +} + +variable "login_name" { + description = "Login name for the application (scopes activity ownership)." + type = string + default = "paolo" +} + +variable "websites_port" { + type = number + default = 8000 +} + +variable "tags" { + type = map(string) + default = { + environment = "test" + iac = "terraform" + } +} + +# ----------------------------------------------------------------------------- +# Networking +# ----------------------------------------------------------------------------- +variable "vnet_address_space" { + type = list(string) + default = ["10.0.0.0/8"] +} + +variable "webapp_subnet_name" { + type = string + default = "app-subnet" +} + +variable "webapp_subnet_address_prefix" { + type = list(string) + default = ["10.0.0.0/24"] +} + +variable "pe_subnet_name" { + type = string + default = "pe-subnet" +} + +variable "pe_subnet_address_prefix" { + type = list(string) + default = ["10.0.1.0/24"] +} + +variable "nat_gateway_sku_name" { + type = string + default = "Standard" +} + +variable "nat_gateway_idle_timeout_in_minutes" { + type = number + default = 4 +} + +variable "nat_gateway_zones" { + type = list(string) + default = ["1"] +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/visio/architecture.vsdx b/samples/web-app-postgresql-flexible-server/dotnet/visio/architecture.vsdx new file mode 100644 index 0000000..8a89ad6 Binary files /dev/null and b/samples/web-app-postgresql-flexible-server/dotnet/visio/architecture.vsdx differ diff --git a/samples/web-app-postgresql-flexible-server/python/scripts/deploy.sh b/samples/web-app-postgresql-flexible-server/python/scripts/deploy.sh index 6d7a1b0..000d8b2 100755 --- a/samples/web-app-postgresql-flexible-server/python/scripts/deploy.sh +++ b/samples/web-app-postgresql-flexible-server/python/scripts/deploy.sh @@ -155,20 +155,34 @@ if [[ $? != 0 ]]; then echo "No [$FIREWALL_RULE_NAME] firewall rule already exists on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" echo "Creating [$FIREWALL_RULE_NAME] firewall rule on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." - # Create a permissive firewall rule so the deploy machine can run the psql bootstrap - az postgres flexible-server firewall-rule create \ - --server-name $POSTGRES_SERVER_NAME \ - --resource-group $RESOURCE_GROUP_NAME \ - --name $FIREWALL_RULE_NAME \ - --start-ip-address "0.0.0.0" \ - --end-ip-address "255.255.255.255" \ - --only-show-errors 1>/dev/null - - if [ $? -eq 0 ]; then + # Create a permissive firewall rule so the deploy machine can run the psql bootstrap. + # The create is retried because this PUT intermittently answers 500 against the emulator while + # the server finishes provisioning, and the Azure CLI's own retries all land within a few seconds. + FIREWALL_RULE_CREATED=0 + for attempt in $(seq 1 5); do + if az postgres flexible-server firewall-rule create \ + --name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --rule-name $FIREWALL_RULE_NAME \ + --start-ip-address "0.0.0.0" \ + --end-ip-address "255.255.255.255" \ + --only-show-errors 1>/dev/null; then + FIREWALL_RULE_CREATED=1 + break + fi + + if [ "$attempt" -lt 5 ]; then + echo "Attempt $attempt of 5 to create the [$FIREWALL_RULE_NAME] firewall rule failed; retrying in 10 seconds..." + sleep 10 + fi + done + + if [ $FIREWALL_RULE_CREATED -eq 1 ]; then echo "[$FIREWALL_RULE_NAME] firewall rule successfully created on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" else - echo "Failed to create [$FIREWALL_RULE_NAME] firewall rule on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" - exit 1 + # Not fatal: the rule governs public network access, which the emulator does not enforce, and + # the psql bootstrap below fails loudly if the server is genuinely unreachable. + echo "WARNING: could not create the [$FIREWALL_RULE_NAME] firewall rule on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server; continuing" fi else echo "[$FIREWALL_RULE_NAME] firewall rule already exists on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" diff --git a/samples/web-app-postgresql-flexible-server/python/terraform/providers.tf b/samples/web-app-postgresql-flexible-server/python/terraform/providers.tf index 0b17881..1f06025 100644 --- a/samples/web-app-postgresql-flexible-server/python/terraform/providers.tf +++ b/samples/web-app-postgresql-flexible-server/python/terraform/providers.tf @@ -17,7 +17,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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" diff --git a/samples/web-app-sql-database/dotnet/README.md b/samples/web-app-sql-database/dotnet/README.md new file mode 100644 index 0000000..399940b --- /dev/null +++ b/samples/web-app-sql-database/dotnet/README.md @@ -0,0 +1,145 @@ +# Azure Web App with Azure SQL Database and Azure Key Vault + +This sample demonstrates a ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in an `activities` table within the `sampledb` database on an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/) instance. The connection string of the SQL database is stored as a secret in [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview). The application also retrieves its certificate from Key Vault to serve traffic over HTTPS. + + +## Architecture + +The following diagram illustrates the architecture of the solution: + +![Architecture Diagram](./images/architecture.png) + +- **Azure Web App**: Hosts the ASP.NET Core application +- **Azure App Service Plan**: Provides compute resources for the web app +- **Azure SQL Database**: Stores activity data in a relational table +- **Azure Key Vault**: Stores the database connection string and the certificate used to secure HTTPS traffic + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/) +- [Microsoft.Data.SqlClient](https://learn.microsoft.com/en-us/sql/connect/ado-net/microsoft-ado-net-sql-server) +- [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. + +## Security Configuration + +The Vacation Planner Web App supports two common approaches for accessing Azure SQL Database securely: + +1. **Using a SQL Database connection string**: Provide a standard SQL connection string using the following environment variables: + +- SQL_SERVER: The SQL server container IP address (e.g. 172.17.0.4). +- SQL_DATABASE: The name of the SQL database (e.g.PlannerDB). +- SQL_USERNAME: The username to connect to the SQL database (e.g. testuser). +- SQL_PASSWORD: The password to connect to the SQL database (e.g. TestP@ssw0rd123). + +2. **Using Microsoft Entra ID service principal credentials**: Specify the service principal credentials in your environment using the following environment variables: + + - [AZURE_CLIENT_ID](https://learn.microsoft.com/en-us/dotnet/api/azure.identity.environmentcredential): The service principal's client ID. + - [AZURE_CLIENT_SECRET](https://learn.microsoft.com/en-us/dotnet/api/azure.identity.environmentcredential): One of the service principal's client secrets. + - [AZURE_TENANT_ID](https://learn.microsoft.com/en-us/dotnet/api/azure.identity.environmentcredential): The Microsoft Entra Tenant ID. + +This flexibility allows the app to run securely in Azure or in emulated environments like [LocalStack for Azure](https://docs.localstack.cloud/azure/). The client code supports both authentication modes using [`ClientSecretCredential`](https://learn.microsoft.com/en-us/dotnet/api/azure.identity.clientsecretcredential) or [`DefaultAzureCredential`](https://learn.microsoft.com/en-us/dotnet/api/azure.identity.defaultazurecredential) from the Azure SDK. + +## Azure Key Vault Integration +The application integrates with Azure Key Vault for managing secrets and certificates: + +Secrets: The SQL connection string is stored as a secret in Key Vault. At runtime, the app retrieves it using the Azure Key Vault Secrets SDK. This is configured via the KEY_VAULT_NAME and SECRET_NAME environment variables. + +Certificates: A self-signed certificate is created in Key Vault during deployment. The app exposes a GET /api/certificate endpoint that retrieves the certificate using the Azure Key Vault Certificates SDK and returns its name, confirming the integration works. This is configured via the KEYVAULT_URI and CERT_NAME environment variables. + +## Deployment + +Set up the Azure emulator using the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and set it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure). To pull the image, execute: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator by running: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +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 + +1. Retrieve the port published and mapped to port 80 by the Docker container hosting the emulated Web App. +2. Open a web browser and navigate to `http://localhost:`. As an alternative, you can retrieve the URL using the following command and call the Web App using the HTTP or HTTPS protocol: + + ```bash + az webapp show \ + --name local-webapp-test \ + --resource-group local-rg \ + --query defaultHostName \ + --output tsv + ``` + +3. If the deployment was successful, you will see the following user interface for adding and removing activities: + +![Architecture Diagram](./images/vacation-planner.png) + +You can use the `call-web-app.sh` Bash script below to call the web app. The script demonstrates three methods for calling web apps: + +1. **Through the LocalStack for Azure emulator**: Call the web app via the emulator using its default host name. The emulator acts as a proxy to the web app. +2. **Via localhost and host port mapped to the container's port**: Use `127.0.0.1` with the host port mapped to the container's port `80`. +3. **Via container IP address**: Use the app container's IP address on port `80`. This technique is only available when accessing the web app from the Docker host machine. +4. **Via the default hostname**: Call the web app via the default hostname `.azurewebsites.azure.localhost.localstack.cloud:4566`. + +## SQL Server Tooling + +You can use [SQL Server Management Studio](https://learn.microsoft.com/en-us/ssms/install/install) to explore and manage your SQL databases. When connecting, use SQL Server Authentication and specify `127.0.0.1,port` as the server name in the Connect dialog box, where `port` is the host port mapped to the container's internal SQL Server port `1433`, as shown in the following picture: + +![SQL Server Management Studio](./images/connect.png) + +SQL Server Management Studio allows you to manage database objects such as tables and stored procedures, as well as query data, as shown in the following picture: + +![SQL Server Management Studio](./images/studio.png) + + +Alternatively, you can use the [sqlcmd](https://learn.microsoft.com/en-us/sql/tools/sqlcmd/sqlcmd-utility?view=sql-server-ver17&tabs=go%2Cwindows-support&pivots=cs1-bash) to interact with and administer your SQL databases, as shown in the following table: + +```bash +~$ sqlcmd -S 172.17.0.4 -d PlannerDB -U testuser -P TestP@ssw0rd123 +1> SELECT id, username, SUBSTRING(activity, 1, 20) FROM Activities; +2> GO +id username +------------------------------------ -------------------------------- -------------------- +e444433e-f36b-1410-88e7-0034efb7413b paolo Go to Paris +e644433e-f36b-1410-88e7-0034efb7413b paolo Go to London +e844433e-f36b-1410-88e7-0034efb7413b paolo Go to Mexico + +(3 rows affected) +``` + +## References + +- [Azure Web Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [Azure SQL Database Documentation](https://learn.microsoft.com/en-us/azure/azure-sql/database/) +- [Quickstart: Deploy an ASP.NET web app to Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/quickstart-dotnetcore?pivots=development-environment-cli) +- [Microsoft.Data.SqlClient](https://learn.microsoft.com/en-us/sql/connect/ado-net/microsoft-ado-net-sql-server) +- [Azure Identity client library for .NET](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/identity-readme) +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-sql-database/dotnet/bicep/README.md b/samples/web-app-sql-database/dotnet/bicep/README.md new file mode 100644 index 0000000..83500c1 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/bicep/README.md @@ -0,0 +1,173 @@ +# Bicep Deployment + +This directory contains the Bicep template and a deployment script for provisioning Azure services in LocalStack for Azure. Refer to the [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md) guide for details about the sample application. + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [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 +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## 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 [main.bicep](main.bicep) Bicep module creates the following Azure resources: + +1. [Azure SQL Server](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview): Logical server hosting one or more Azure SQL Databases. +2. [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/): The `PlannerDB` database storing relational vacation activity data. +3. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The compute resource that hosts the web application. +4. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the ASP.NET Core Razor Pages single-page application (*Vacation Planner*), connected to Azure SQL Database. +5. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Configures automatic deployment from a public GitHub repository. +6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret. + +The web app allows users to plan and manage vacation activities, storing all activity data in the `Activities` table in the `PlannerDB` database. For more information, see [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). + +## 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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `bicep` folder: + +```bash +cd samples/web-app-sql-database/dotnet/bicep +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +SQL_SERVER_NAME="${PREFIX}-sqlserver-${SUFFIX}" +SQL_DATABASE_NAME='PlannerDB' +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" +SECRET_NAME="${PREFIX}-secret-${SUFFIX}" + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +az group show \ +--name "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ +--name "$WEB_APP_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "{name:name, state:state, defaultHostName:defaultHostName}" \ +--output table + +# Check Azure SQL Server +echo -e "\n[$SQL_SERVER_NAME] SQL server:\n" +az sql server show \ +--name "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure SQL Database +echo -e "\n[$SQL_DATABASE_NAME] SQL database:\n" +az sql db show \ +--name "$SQL_DATABASE_NAME" \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure Key Vault +echo -e "\n[$KEY_VAULT_NAME] Key Vault:\n" +az keyvault show \ +--name "$KEY_VAULT_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Key Vault secret +echo -e "\n[$SECRET_NAME] Key Vault secret:\n" +az keyvault secret show \ +--vault-name "$KEY_VAULT_NAME" \ +--name "$SECRET_NAME" \ +--query "{name:name, enabled:attributes.enabled, created:attributes.created}" \ +--output table + +# Print the list of resources in the resource group +echo -e "\nListing resources in resource group [$RESOURCE_GROUP_NAME]...\n" +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table +``` + +## 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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-sql-database/dotnet/bicep/deploy.sh b/samples/web-app-sql-database/dotnet/bicep/deploy.sh new file mode 100755 index 0000000..47ce812 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/bicep/deploy.sh @@ -0,0 +1,319 @@ +#!/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) +ADMIN_USER='sqladmin' +ADMIN_PASSWORD='P@ssw0rd1234!' +DATABASE_USER_NAME='testuser' +DATABASE_USER_PASSWORD='TestP@ssw0rd123' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" +DEPLOY_APP=1 + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit +# 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 \ + administratorLogin=$ADMIN_USER \ + administratorLoginPassword=$ADMIN_PASSWORD \ + sqlDatabaseUsername=$DATABASE_USER_NAME \ + sqlDatabasePassword=$DATABASE_USER_PASSWORD \ + --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 \ + administratorLogin=$ADMIN_USER \ + administratorLoginPassword=$ADMIN_PASSWORD \ + sqlDatabaseUsername=$DATABASE_USER_NAME \ + sqlDatabasePassword=$DATABASE_USER_PASSWORD \ + --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 \ + administratorLogin=$ADMIN_USER \ + administratorLoginPassword=$ADMIN_PASSWORD \ + sqlDatabaseUsername=$DATABASE_USER_NAME \ + sqlDatabasePassword=$DATABASE_USER_PASSWORD \ + --query 'properties.outputs' \ + --output 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 . + APP_SERVICE_PLAN_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.appServicePlanName.value') + WEB_APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.webAppName.value') + SQL_SERVER_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.sqlServerName.value') + SQL_DATABASE_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.sqlDatabaseName.value') + echo "Deployment details:" + echo "appServicePlanName: $APP_SERVICE_PLAN_NAME" + echo "webAppName: $WEB_APP_NAME" + echo "webAppUrl: $WEB_APP_URL" + echo "sqlServerName: $SQL_SERVER_NAME" + echo "sqlDatabaseName: $SQL_DATABASE_NAME" +else + echo "Failed to deploy Bicep template [$TEMPLATE]" + exit 1 +fi + +if [[ -z "$WEB_APP_NAME" || -z "$SQL_SERVER_NAME" || -z "$SQL_DATABASE_NAME" ]]; then + echo "Web App Name, SQL Server Name, or SQL Database Name is empty. Exiting." + exit 1 +fi + +# Retrieve the fullyQualifiedDomainName of the SQL server +echo "Retrieving the fullyQualifiedDomainName of the [$SQL_SERVER_NAME] SQL server..." +SQL_SERVER_FQDN=$(az sql server show \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$SQL_SERVER_FQDN" ]; then + echo "Failed to retrieve the fullyQualifiedDomainName of the SQL server" + exit 1 +fi + +# Create server-level login +echo "Creating login [$DATABASE_USER_NAME] at server level..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d master \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -Q "IF NOT EXISTS (SELECT * FROM sys.sql_logins WHERE name = '$DATABASE_USER_NAME') + CREATE LOGIN [$DATABASE_USER_NAME] WITH PASSWORD = '$DATABASE_USER_PASSWORD';" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Login [$DATABASE_USER_NAME] created successfully" +else + echo "Failed to create login [$DATABASE_USER_NAME]" + exit 1 +fi + +# Create database user +echo "Creating user [$DATABASE_USER_NAME] in database [$SQL_DATABASE_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -Q "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = '$DATABASE_USER_NAME') + CREATE USER [$DATABASE_USER_NAME] FOR LOGIN [$DATABASE_USER_NAME];" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "User [$DATABASE_USER_NAME] created successfully in database [$SQL_DATABASE_NAME]" +else + echo "Failed to create user [$DATABASE_USER_NAME]" + exit 1 +fi + +# Grant permissions including DDL rights +echo "Granting permissions to [$DATABASE_USER_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -Q "ALTER ROLE db_datareader ADD MEMBER [$DATABASE_USER_NAME]; + ALTER ROLE db_datawriter ADD MEMBER [$DATABASE_USER_NAME]; + ALTER ROLE db_ddladmin ADD MEMBER [$DATABASE_USER_NAME];" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Permissions granted successfully to [$DATABASE_USER_NAME]" +else + echo "Failed to grant permissions to [$DATABASE_USER_NAME]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$DATABASE_USER_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -Q "SELECT SYSTEM_USER AS CurrentUser, DB_NAME() AS CurrentDatabase, GETDATE() AS CurrentTime;" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$DATABASE_USER_NAME]" +else + echo "Connection test failed with user [$DATABASE_USER_NAME]" + exit 1 +fi + +# Create table +echo "Creating test [Products] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -Q "IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Activities' AND schema_id = SCHEMA_ID('dbo')) + CREATE TABLE dbo.Activities ( + -- Primary Key: UNIQUEIDENTIFIER with a default of a new sequential GUID (best for indexing) + id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(), + + -- Username field + username VARCHAR(32) NOT NULL, + + -- Description of the activity + activity VARCHAR(128) NOT NULL, + + -- Timestamp of the activity + timestamp DATETIME NOT NULL + );" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test [Activities] table created successfully" +else + echo "Failed to create test [Activities] table" + exit 1 +fi + +# Insert data +echo "Inserting test data into [Activities] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -Q "INSERT INTO Activities (username, activity, timestamp) + VALUES + ('paolo', 'Go to Paris', GETDATE()), + ('paolo', 'Go to London', GETDATE()), + ('paolo', 'Go to Mexico', GETDATE());" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test data inserted successfully into [Activities] table" +else + echo "Failed to insert test data into [Activities] table" + exit 1 +fi + +# Query data +echo "Querying test data from [Activities] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -Q "SELECT * FROM Activities;" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test data queried successfully from [Activities] table" +else + echo "Failed to query test data from [Activities] table" + exit 1 +fi + +if [[ $DEPLOY_APP -eq 0 ]]; then + echo "Skipping web app deployment as DEPLOY_APP flag is set to 0." + exit 0 +fi + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi diff --git a/samples/web-app-sql-database/dotnet/bicep/main.bicep b/samples/web-app-sql-database/dotnet/bicep/main.bicep new file mode 100644 index 0000000..e5bec57 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/bicep/main.bicep @@ -0,0 +1,504 @@ +@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 appServicePlanZoneRedundant bool = false + +@description('Specifies the language runtime used by the Azure Web App.') +@allowed([ + 'dotnet' + 'dotnet-isolated' + 'dotnetcore' + 'python' + 'java' + 'node' + 'powerShell' + 'custom' +]) +param runtimeName string + +@description('Specifies the target language version used by the Azure Web 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 webAppKind string = 'app,linux' + +@description('Specifies whether HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = false + +@description('Specifies the minimum TLS version for the Azure Web 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 the optional Git Repo URL.') +param repoUrl string = '' + +@description('Specifies the tags to be applied to the resources.') +param tags object = { + environment: 'test' + iac: 'bicep' +} + +@description('Specifies the administrator username of the SQL logical server.') +param administratorLogin string = 'sqladmin' + +@description('Specifies the administrator password of the SQL logical server.') +@secure() +param administratorLoginPassword string = 'P@ssw0rd1234!' + +@description('Conditional. The Azure Active Directory (AAD) administrator authentication. Required if no `administratorLogin` & `administratorLoginPassword` is provided.') +param administrators object? + +@description('Specifies the conditional Developmentresource ID of a user-assigned identityDevelopment to be used by default. This is required if `userAssignedIdentities` is not empty.') +param primaryUserAssignedIdentityResourceId string? + +@allowed([ + '1.0' + '1.1' + '1.2' + '1.3' +]) +@description('Specifies the optional Developmentminimal TLS versionDevelopment allowed for connections.') +param minimalTlsVersion string = '1.2' + +@allowed([ + 'Disabled' + 'Enabled' +]) +@description('Specifies whether or not to optionally enable IPv6 support for this server.') +param isIPv6Enabled string = 'Disabled' + +@description('Specifies the version of the SQL server to deploy.') +param version string = '12.0' + +@description('Specifies whether to optionally restrict outbound network access for this server.') +@allowed([ + 'Enabled' + 'Disabled' +]) +param restrictOutboundNetworkAccess string? + +@description('Specifies the name of the SQL Database.') +param sqlDatabaseName string = 'PlannerDB' + +@description('Specifies the optional SKU for the database.') +param sku object = { + name: 'Standard' + tier: 'Standard' + capacity: 10 +} + +@description('Specifies the optional time in minutes after which the database automatically pauses. A value of -1 disables automatic pausing.') +param autoPauseDelay int = -1 + +@description('Specifies the required Developmentavailability zoneDevelopment. A value of 1, 2, or 3 hardcodes the zone; -1 defines no zone. Note that these are logical availability zones within your Azure subscription. Refer to the Azure documentation for the mapping between physical and logical zones.') +@allowed([ + -1 + 1 + 2 + 3 +]) +param availabilityZone int = -1 + +@description('Specifies the optional collation for the metadata catalog.') +param catalogCollation string = 'DATABASE_DEFAULT' + +@description('Specifies the optional collation for the database.') +param collation string = 'SQL_Latin1_General_CP1_CI_AS' + +@description('Specifies the optional mode used for database creation.') +param createMode + | 'Default' + | 'Copy' + | 'OnlineSecondary' + | 'PointInTimeRestore' + | 'Recovery' + | 'Restore' + | 'RestoreExternalBackup' + | 'RestoreExternalBackupSecondary' + | 'RestoreLongTermRetentionBackup' + | 'Secondary' = 'Default' + +@description('Specifies the optional resource ID of the elastic pool containing this database.') +param elasticPoolResourceId string? + +@description('Specifies the optional Client ID for cross-tenant per-database Customer-Managed Key (CMK) scenarios.') +@minLength(36) +@maxLength(36) +param federatedClientId string? + +@description('Specifies the optional behavior when monthly free limits are exhausted for a free database.') +param freeLimitExhaustionBehavior 'AutoPause' | 'BillOverUsage'? + +@description('Specifies the optional number of read-only secondary replicas associated with the database.') +param highAvailabilityReplicaCount int = 0 + +@description('Specifies whether or not this database is a Developmentledger databaseDevelopment. All tables will be ledger tables. Note: this value cannot be changed after database creation.') +param isLedgerOn bool = false + +@description('Specifies the optional license type to apply for this database.') +param licenseType 'BasePrice' | 'LicenseIncluded'? + +@description('Specifies the optional resource identifier of the long-term retention backup used for the create operation.') +param longTermRetentionBackupResourceId string? + +@description('Specifies the optional Maintenance Configuration ID assigned to the database, which defines the period for maintenance updates.') +param maintenanceConfigurationId string? + +@description('Specifies whether optional customer-controlled manual cutover is required during an Update Database operation to the Hyperscale tier.') +param manualCutover bool? + +@description('Specifies the optional minimal capacity (vCores) that the database will always have allocated.') +param minCapacity string = '0' + +@description('Specifies the optional trigger for a customer-controlled manual cutover during a wait state while a scaling operation is in progress.') +param performCutover bool? + +@description('Specifies the optional type of enclave requested for the database, either Default or VBS enclaves.') +param preferredEnclaveType 'Default' | 'VBS'? + +@description('Specifies the optional state of read-only routing.') +param readScale 'Enabled' | 'Disabled' = 'Disabled' + +@description('Specifies the optional resource identifier of the recoverable database associated with the create operation.') +param recoverableDatabaseResourceId string? + +@description('Specifies the optional resource identifier of the recovery point associated with the create operation.') +param recoveryServicesRecoveryPointResourceId string? + +@description('Specifies the optional storage account type to be used for storing database backups.') +param requestedBackupStorageRedundancy 'Geo' | 'GeoZone' | 'Local' | 'Zone' = 'Local' + +@description('Specifies the optional resource identifier of the restorable dropped database associated with the create operation.') +param restorableDroppedDatabaseResourceId string? + +@description('Specifies the optional point in time (ISO8601 format) of the source database to restore when `createMode` is set to `Restore` or `PointInTimeRestore`.') +param restorePointInTime string? + +@description('Specifies the optional name of the sample schema to apply when creating this database.') +param sampleName string = '' + +@description('Specifies the optional secondary type of the database, if it is a secondary.') +param secondaryType 'Geo' | 'Named' | 'Standby'? + +@description('Specifies the optional time the database was deleted when restoring a deleted database.') +param sourceDatabaseDeletionDate string? + +@description('Specifies the optional resource identifier of the source database associated with the create operation.') +param sourceDatabaseResourceId string? + +@description('Specifies the optional resource identifier of the source associated with the create operation of this database.') +param sourceResourceId string? + +@description('Specifies whether or not the database uses free monthly limits. This is allowed for only one database per subscription.') +param useFreeLimit bool? + +@description('Specifies whether or not this database is Developmentzone redundantDevelopment.') +param sqlDatabaseZoneRedundant bool = false + +@description('Specifies the username for the SQL Database.') +param sqlDatabaseUsername string = 'testuser' + +@description('Specifies the password for the SQL Database.') +@secure() +param sqlDatabasePassword string = 'TestP@ssw0rd123' + +@description('Specifies the required name of the Server Firewall Rule.') +param sqlFirewallRuleName string = 'AllowAllIPs' + +@description('Specifies the optional end IP address of the firewall rule. Must be in IPv4 format and greater than or equal to `startIpAddress`. Use \'0.0.0.0\' to allow all Azure-internal IP addresses.') +param endIpAddress string = '255.255.255.255' + +@description('Specifies the optional start IP address of the firewall rule. Must be in IPv4 format. Use \'0.0.0.0\' to allow all Azure-internal IP addresses.') +param startIpAddress string = '0.0.0.0' + +@description('Specifies the username for the application.') +param username string = 'paolo' + +var sqlServerName = '${prefix}-sqlserver-${suffix}' +var webAppName = '${prefix}-webapp-${suffix}' +var appServicePlanName = '${prefix}-app-service-plan-${suffix}' +var keyVaultName = '${prefix}-kv-${suffix}' +var sqlConnectionStringSecretName = '${prefix}-secret-${suffix}' +var identity = { + type: 'SystemAssigned' + } + +resource sqlServer 'Microsoft.Sql/servers@2024-05-01-preview' = { + name: sqlServerName + location: location + tags: tags + identity: identity + properties: { + administratorLogin: administratorLogin + administratorLoginPassword: administratorLoginPassword + administrators: union({ administratorType: 'ActiveDirectory' }, administrators ?? {}) + federatedClientId: federatedClientId + isIPv6Enabled: isIPv6Enabled + version: version + minimalTlsVersion: minimalTlsVersion + primaryUserAssignedIdentityId: primaryUserAssignedIdentityResourceId + publicNetworkAccess: publicNetworkAccess + restrictOutboundNetworkAccess: restrictOutboundNetworkAccess + } +} + +resource firewallRule 'Microsoft.Sql/servers/firewallRules@2024-11-01-preview' = { + name: sqlFirewallRuleName + parent: sqlServer + properties: { + endIpAddress: endIpAddress + startIpAddress: startIpAddress + } +} + +resource sqlDatabase 'Microsoft.Sql/servers/databases@2024-11-01-preview' = { + parent: sqlServer + name: sqlDatabaseName + location: location + tags: tags + sku: sku + properties: { + autoPauseDelay: autoPauseDelay + availabilityZone: availabilityZone != -1 ? string(availabilityZone) : 'NoPreference' + catalogCollation: catalogCollation + collation: collation + createMode: createMode + elasticPoolId: elasticPoolResourceId + federatedClientId: federatedClientId + freeLimitExhaustionBehavior: freeLimitExhaustionBehavior + highAvailabilityReplicaCount: highAvailabilityReplicaCount + isLedgerOn: isLedgerOn + licenseType: licenseType + longTermRetentionBackupResourceId: longTermRetentionBackupResourceId + maintenanceConfigurationId: maintenanceConfigurationId + manualCutover: manualCutover + minCapacity: !empty(minCapacity) ? json(minCapacity) : 0 + performCutover: performCutover + preferredEnclaveType: preferredEnclaveType + readScale: readScale + recoverableDatabaseId: recoverableDatabaseResourceId + recoveryServicesRecoveryPointId: recoveryServicesRecoveryPointResourceId + requestedBackupStorageRedundancy: requestedBackupStorageRedundancy + restorableDroppedDatabaseId: restorableDroppedDatabaseResourceId + restorePointInTime: restorePointInTime + sampleName: sampleName + secondaryType: secondaryType + sourceDatabaseDeletionDate: sourceDatabaseDeletionDate + sourceDatabaseId: sourceDatabaseResourceId + sourceResourceId: sourceResourceId + useFreeLimit: useFreeLimit + zoneRedundant: sqlDatabaseZoneRedundant + } +} + +resource appServicePlan 'Microsoft.Web/serverfarms@2024-11-01' = { + name: appServicePlanName + location: location + tags: tags + kind: appServicePlanKind + sku: { + tier: skuTier + name: skuName + } + properties: { + reserved: reserved + zoneRedundant: appServicePlanZoneRedundant + maximumElasticWorkerCount: skuTier == 'FlexConsumption' ? 1 : 20 + } +} + +resource webApp 'Microsoft.Web/sites@2024-11-01' = { + name: webAppName + location: location + tags: tags + kind: webAppKind + properties: { + httpsOnly: httpsOnly + serverFarmId: appServicePlan.id + siteConfig: { + linuxFxVersion: toUpper('${runtimeName}|${runtimeVersion}') + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + } + } + identity: { + type: 'SystemAssigned' + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: keyVaultName + location: location + tags: tags + properties: { + tenantId: subscription().tenantId + sku: { + family: 'A' + name: 'standard' + } + accessPolicies: [ + { + tenantId: subscription().tenantId + objectId: webApp.identity.principalId + permissions: { + secrets: [ + 'get' + 'list' + ] + } + } + ] + enableRbacAuthorization: false + enableSoftDelete: true + softDeleteRetentionInDays: 7 + } +} + +resource sqlConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { + parent: keyVault + name: sqlConnectionStringSecretName + properties: { + value: 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Database=${sqlDatabaseName};User ID=${sqlDatabaseUsername};Password=${sqlDatabasePassword};Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;' + } +} + +resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = { + parent: webApp + name: 'appsettings' + properties: { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + //Pass Key Vault name and secret name as app settings. + //The .NET SDK will retrieve the actual connection string value from Key Vault + KEY_VAULT_NAME: keyVaultName + SECRET_NAME: sqlConnectionStringSecretName + LOGIN_NAME: username + KEYVAULT_URI: keyVault.properties.vaultUri + } +} + +resource webAppSourceControl 'Microsoft.Web/sites/sourcecontrols@2024-11-01' = if (contains(repoUrl,'http')){ + name: 'web' + parent: webApp + properties: { + repoUrl: repoUrl + branch: 'master' + isManualIntegration: true + } +} + +output appServicePlanName string = appServicePlan.name +output webAppName string = webApp.name +output webAppUrl string = webApp.properties.defaultHostName +output sqlServerName string = sqlServer.name +output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName +output sqlDatabaseName string = sqlDatabase.name +output keyVaultName string = keyVault.name +output keyVaultUrl string = keyVault.properties.vaultUri +output sqlConnectionStringSecretUri string = sqlConnectionStringSecret.properties.secretUri diff --git a/samples/web-app-sql-database/dotnet/bicep/main.bicepparam b/samples/web-app-sql-database/dotnet/bicep/main.bicepparam new file mode 100644 index 0000000..a804b7a --- /dev/null +++ b/samples/web-app-sql-database/dotnet/bicep/main.bicepparam @@ -0,0 +1,7 @@ +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'dotnetcore' +param runtimeVersion = '10.0' +param username = 'paolo' diff --git a/samples/web-app-sql-database/dotnet/images/architecture.png b/samples/web-app-sql-database/dotnet/images/architecture.png new file mode 100644 index 0000000..728fa26 Binary files /dev/null and b/samples/web-app-sql-database/dotnet/images/architecture.png differ diff --git a/samples/web-app-sql-database/dotnet/images/connect.png b/samples/web-app-sql-database/dotnet/images/connect.png new file mode 100644 index 0000000..351f99e Binary files /dev/null and b/samples/web-app-sql-database/dotnet/images/connect.png differ diff --git a/samples/web-app-sql-database/dotnet/images/studio.png b/samples/web-app-sql-database/dotnet/images/studio.png new file mode 100644 index 0000000..ae8227c Binary files /dev/null and b/samples/web-app-sql-database/dotnet/images/studio.png differ diff --git a/samples/web-app-sql-database/dotnet/images/vacation-planner.png b/samples/web-app-sql-database/dotnet/images/vacation-planner.png new file mode 100644 index 0000000..a7c5151 Binary files /dev/null and b/samples/web-app-sql-database/dotnet/images/vacation-planner.png differ diff --git a/samples/web-app-sql-database/dotnet/scripts/README.md b/samples/web-app-sql-database/dotnet/scripts/README.md new file mode 100644 index 0000000..4891865 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/README.md @@ -0,0 +1,172 @@ +# Azure CLI Deployment + +This directory includes Bash scripts designed for deploying and testing the sample Web App utilizing the `lstk` CLI. For further details about the sample application, refer to the [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## Architecture Overview + +The [deploy.sh](deploy.sh) Bash 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): Logical container for all resources +2. [Azure SQL Server](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview): Logical server hosting one or more Azure SQL Databases. +3. [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/): The `PlannerDB` database storing relational vacation activity data. +4. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The compute resource that hosts the web application. +5. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the ASP.NET Core Razor Pages single-page application (*Vacation Planner*), connected to Azure SQL Database. +6. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Configures automatic deployment from a public GitHub repository. +7. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string in a secret. + +The system implements a Vacation Planner web application that stores and retrieves activity data from Azure SQL Database. For more information, see [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). + +## 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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `scripts` folder: + +```bash +cd samples/web-app-sql-database/dotnet/scripts +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +SQL_SERVER_NAME="${PREFIX}-sqlserver-${SUFFIX}" +SQL_DATABASE_NAME='PlannerDB' +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" +SECRET_NAME="${PREFIX}-secret-${SUFFIX}" + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +az group show \ +--name "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ +--name "$WEB_APP_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "{name:name, state:state, defaultHostName:defaultHostName}" \ +--output table + +# Check Azure SQL Server +echo -e "\n[$SQL_SERVER_NAME] SQL server:\n" +az sql server show \ +--name "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure SQL Database +echo -e "\n[$SQL_DATABASE_NAME] SQL database:\n" +az sql db show \ +--name "$SQL_DATABASE_NAME" \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure Key Vault +echo -e "\n[$KEY_VAULT_NAME] Key Vault:\n" +az keyvault show \ +--name "$KEY_VAULT_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Key Vault secret +echo -e "\n[$SECRET_NAME] Key Vault secret:\n" +az keyvault secret show \ +--vault-name "$KEY_VAULT_NAME" \ +--name "$SECRET_NAME" \ +--query "{name:name, enabled:attributes.enabled, created:attributes.created}" \ +--output table + +# Print the list of resources in the resource group +echo -e "\nListing resources in resource group [$RESOURCE_GROUP_NAME]...\n" +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table +``` + +## 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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-sql-database/python/scripts/get-web-app-url.sh b/samples/web-app-sql-database/dotnet/scripts/call-web-app.sh similarity index 85% rename from samples/web-app-sql-database/python/scripts/get-web-app-url.sh rename to samples/web-app-sql-database/dotnet/scripts/call-web-app.sh index 14a0f12..e7694df 100755 --- a/samples/web-app-sql-database/python/scripts/get-web-app-url.sh +++ b/samples/web-app-sql-database/dotnet/scripts/call-web-app.sh @@ -65,6 +65,14 @@ get_docker_container_port_mapping() { echo "$host_port" } +# Distinguished names are compared after normalization: OpenSSL 3 prints "CN = value", Key Vault +# returns "CN=value" and OpenSSL 1 printed "/CN=value" - the same subject in three spellings, which +# a literal comparison reports as a mismatch. +normalize_dn() { + echo "$1" | sed -e 's#^/##' -e 's#/#, #g' -e 's/[[:space:]]*=[[:space:]]*/=/g' \ + -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' +} + call_web_app() { # Get the web app name echo "Getting web app name..." @@ -196,12 +204,23 @@ call_web_app() { fi echo "Validating certificate from Key Vault..." - KV_RESPONSE=$(curl --max-time 10 -sk "https://$container_ip:8443/api/certificate") + if ! KV_RESPONSE=$(curl --max-time 10 -fsSk "https://$container_ip:8443/api/certificate"); then + echo "Failed to call https://$container_ip:8443/api/certificate (is HTTPS on port 8443 enabled?)" + exit 1 + fi KV_THUMBPRINT=$(echo "$KV_RESPONSE" | jq -r '.thumbprint') KV_NAME=$(echo "$KV_RESPONSE" | jq -r '.name') KV_SUBJECT=$(echo "$KV_RESPONSE" | jq -r '.subject') + if [ -z "$KV_THUMBPRINT" ] || [ "$KV_THUMBPRINT" == "null" ]; then + echo "The certificate endpoint returned no thumbprint: $KV_RESPONSE" + exit 1 + fi SSL_CERT=$(echo | openssl s_client -connect "$container_ip:8443" 2>/dev/null | openssl x509) + if [ -z "$SSL_CERT" ]; then + echo "Failed to retrieve the TLS certificate served on $container_ip:8443" + exit 1 + fi SSL_THUMBPRINT=$(echo "$SSL_CERT" \ | openssl x509 -fingerprint -noout -sha1 \ @@ -219,10 +238,12 @@ call_web_app() { | openssl x509 -noout -subject \ | sed 's/subject=//') - if echo "$SSL_SUBJECT" | grep -q "$KV_SUBJECT"; then + KV_SUBJECT_DN=$(normalize_dn "$KV_SUBJECT") + SSL_SUBJECT_DN=$(normalize_dn "$SSL_SUBJECT") + if grep -Fq "$KV_SUBJECT_DN" <<<"$SSL_SUBJECT_DN"; then echo "Certificate subject [$KV_SUBJECT] matches SSL certificate." else - echo "Certificate subject mismatch! KV: $KV_SUBJECT, SSL: $SSL_SUBJECT" + echo "Certificate subject mismatch! KV: [$KV_SUBJECT_DN], SSL: [$SSL_SUBJECT_DN]" exit 1 fi } diff --git a/samples/web-app-sql-database/dotnet/scripts/deploy.sh b/samples/web-app-sql-database/dotnet/scripts/deploy.sh new file mode 100755 index 0000000..eee4ed8 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/deploy.sh @@ -0,0 +1,481 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +SQL_SERVER_NAME="${PREFIX}-sqlserver-${SUFFIX}" +FIREWALL_RULE_NAME="AllowAllIPs" +ADMIN_USER='sqladmin' +ADMIN_PASSWORD='P@ssw0rd1234!' +DATABASE_USER_NAME='testuser' +DATABASE_USER_PASSWORD='TestP@ssw0rd123' +SQL_DATABASE_NAME='PlannerDB' +APP_SERVICE_PLAN_NAME="${PREFIX}-app-service-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="S1" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +LOGIN_NAME="Paolo" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" +RUNTIME="dotnetcore" +RUNTIME_VERSION="10.0" +DEPLOY_APP=1 +KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" +SECRET_NAME="${PREFIX}-secret-${SUFFIX}" +CERT_NAME="${PREFIX}-cert-${SUFFIX}" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit +# Create a resource group +echo "Creating resource group [$RESOURCE_GROUP_NAME]..." +az group create \ + --name $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created successfully." +else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 +fi + +# Create a sql server +echo "Checking if [$SQL_SERVER_NAME] sql server exists in the [$RESOURCE_GROUP_NAME] resource group..." +az sql server show \ + --name $SQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors &>/dev/null + +if [ $? -eq 0 ]; then + echo "[$SQL_SERVER_NAME] sql server already exists in the [$RESOURCE_GROUP_NAME] resource group. Exiting script." +else + echo "[$SQL_SERVER_NAME] sql server does not exist in the [$RESOURCE_GROUP_NAME] resource group. Proceeding to create it." + echo "Creating [$SQL_SERVER_NAME] sql server in the [$RESOURCE_GROUP_NAME] resource group..." + az sql server create \ + --name $SQL_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --admin-user $ADMIN_USER \ + --admin-password $ADMIN_PASSWORD \ + --assign-identity \ + --identity-type SystemAssigned \ + --minimal-tls-version 1.2 \ + --tags environment=test \ + --only-show-errors 1>/dev/null + + if [ $? == 0 ]; then + echo "[$SQL_SERVER_NAME] sql server successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$SQL_SERVER_NAME] sql server in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +fi + +# Add firewall rule to allow all local network addresses (for testing/development) +echo "Creating firewall rule to allow all IP addresses..." +az sql server firewall-rule create \ + --name $FIREWALL_RULE_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --server $SQL_SERVER_NAME \ + --start-ip-address 0.0.0.0 \ + --end-ip-address 255.255.255.255 \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Firewall rule [AllowLocalNetwork] created successfully" +else + echo "Failed to create firewall rule" + exit 1 +fi + +# Create database if it does not exist +echo "Checking if [$SQL_DATABASE_NAME] database exists in the [$SQL_SERVER_NAME] sql server..." +az sql db show \ + --name $SQL_DATABASE_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --server $SQL_SERVER_NAME \ + --only-show-errors &>/dev/null + +if [ $? -eq 0 ]; then + echo "[$SQL_DATABASE_NAME] database already exists in the [$SQL_SERVER_NAME] sql server." +else + echo "Creating [$SQL_DATABASE_NAME] database with Provisioned compute model in the [$SQL_SERVER_NAME] sql server..." + az sql db create \ + --name $SQL_DATABASE_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --server $SQL_SERVER_NAME \ + --service-objective S0 \ + --compute-model Provisioned \ + --zone-redundant false \ + --tags environment=test \ + --only-show-errors 1>/dev/null + + if [ $? == 0 ]; then + echo "[$SQL_DATABASE_NAME] database with Provisioned compute model successfully created in the [$SQL_SERVER_NAME] sql server" + else + echo "Failed to create [$SQL_DATABASE_NAME] with Provisioned compute model database in the [$SQL_SERVER_NAME] sql server" + exit 1 + fi +fi + +# Retrieve the fullyQualifiedDomainName of the SQL server +echo "Retrieving the fullyQualifiedDomainName of the [$SQL_SERVER_NAME] SQL server..." +SQL_SERVER_FQDN=$(az sql server show \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$SQL_SERVER_FQDN" ]; then + echo "Failed to retrieve the fullyQualifiedDomainName of the SQL server" + exit 1 +fi + +#if [[ $ENVIRONMENT == "LocalStack" ]]; then +# MSSQL_HOST_PORT=$(docker ps --filter "ancestor=mcr.microsoft.com/mssql/server:2022-latest" --format "{{.Ports}}" | grep -oP '0\.0\.0\.0:\K[0-9]+(?=->1433)' | head -1) +# if [ -n "$MSSQL_HOST_PORT" ]; then +# SQL_SERVER_FQDN_WITH_PORT="127.0.0.1,$MSSQL_HOST_PORT" +# echo "Using local SQL Server at [$SQL_SERVER_FQDN_WITH_PORT]" +# fi +#fi + +# Create server-level login +echo "Creating login [$DATABASE_USER_NAME] at server level..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d master \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -N -C \ + -Q "IF NOT EXISTS (SELECT * FROM sys.sql_logins WHERE name = '$DATABASE_USER_NAME') + CREATE LOGIN [$DATABASE_USER_NAME] WITH PASSWORD = '$DATABASE_USER_PASSWORD';" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Login [$DATABASE_USER_NAME] created successfully" +else + echo "Failed to create login [$DATABASE_USER_NAME]" + exit 1 +fi + +# Create database user +echo "Creating user [$DATABASE_USER_NAME] in database [$SQL_DATABASE_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -N -C \ + -Q "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = '$DATABASE_USER_NAME') + CREATE USER [$DATABASE_USER_NAME] FOR LOGIN [$DATABASE_USER_NAME];" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "User [$DATABASE_USER_NAME] created successfully in database [$SQL_DATABASE_NAME]" +else + echo "Failed to create user [$DATABASE_USER_NAME]" + exit 1 +fi + +# Grant permissions including DDL rights +echo "Granting permissions to [$DATABASE_USER_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -N -C \ + -Q "ALTER ROLE db_datareader ADD MEMBER [$DATABASE_USER_NAME]; + ALTER ROLE db_datawriter ADD MEMBER [$DATABASE_USER_NAME]; + ALTER ROLE db_ddladmin ADD MEMBER [$DATABASE_USER_NAME];" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Permissions granted successfully to [$DATABASE_USER_NAME]" +else + echo "Failed to grant permissions to [$DATABASE_USER_NAME]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$DATABASE_USER_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -N -C \ + -Q "SELECT SYSTEM_USER AS CurrentUser, DB_NAME() AS CurrentDatabase, GETDATE() AS CurrentTime;" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$DATABASE_USER_NAME]" +else + echo "Connection test failed with user [$DATABASE_USER_NAME]" + exit 1 +fi + +# Create table +echo "Creating test [Products] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -N -C \ + -Q "IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Activities' AND schema_id = SCHEMA_ID('dbo')) + CREATE TABLE dbo.Activities ( + -- Primary Key: UNIQUEIDENTIFIER with a default of a new sequential GUID (best for indexing) + id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(), + + -- Username field + username VARCHAR(32) NOT NULL, + + -- Description of the activity + activity VARCHAR(128) NOT NULL, + + -- Timestamp of the activity + timestamp DATETIME NOT NULL + );" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test [Activities] table created successfully" +else + echo "Failed to create test [Activities] table" + exit 1 +fi + +# Insert data +echo "Inserting test data into [Activities] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -N -C \ + -Q "INSERT INTO Activities (username, activity, timestamp) + VALUES + ('paolo', 'Go to Paris', GETDATE()), + ('paolo', 'Go to London', GETDATE()), + ('paolo', 'Go to Mexico', GETDATE());" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test data inserted successfully into [Activities] table" +else + echo "Failed to insert test data into [Activities] table" + exit 1 +fi + +# Query data +echo "Querying test data from [Activities] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -N -C \ + -Q "SELECT * FROM Activities;" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test data queried successfully from [Activities] table" +else + echo "Failed to query test data from [Activities] table" + exit 1 +fi + +# Create App Service Plan +echo "Creating App Service Plan [$APP_SERVICE_PLAN_NAME]..." +az appservice plan create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --location "$LOCATION" \ + --sku "$APP_SERVICE_PLAN_SKU" \ + --is-linux \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "App Service Plan [$APP_SERVICE_PLAN_NAME] created successfully." +else + echo "Failed to create App Service Plan [$APP_SERVICE_PLAN_NAME]." + exit 1 +fi + +# Create the web app +echo "Creating web app [$WEB_APP_NAME]..." +az webapp create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --plan "$APP_SERVICE_PLAN_NAME" \ + --name "$WEB_APP_NAME" \ + --runtime "$RUNTIME:$RUNTIME_VERSION" \ + --assign-identity \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Get Web App principal ID +PRINCIPAL_ID=$(az webapp identity show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "principalId" \ + --output tsv) + +if [ -z "$PRINCIPAL_ID" ]; then + echo "Failed to retrieve principalId for web app [$WEB_APP_NAME]" + exit 1 +fi + +# Create Key Vault +echo "Creating Key Vault [$KEY_VAULT_NAME]..." +az keyvault create \ + --name "$KEY_VAULT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --enable-rbac-authorization false \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Key Vault [$KEY_VAULT_NAME] created successfully." +else + echo "Failed to create Key Vault [$KEY_VAULT_NAME]." + exit 1 +fi + +# Assign access policy to Web App managed identity +echo "Assigning Key Vault access policy to Web App..." +az keyvault set-policy \ + --name "$KEY_VAULT_NAME" \ + --object-id "$PRINCIPAL_ID" \ + --secret-permissions get \ + --certificate-permissions get \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Key Vault access policy assigned successfully." +else + echo "Failed to assign Key Vault access policy." + exit 1 +fi + +# Build connection string +SQL_CONNECTION_STRING="Server=tcp:${SQL_SERVER_FQDN},1433;Database=${SQL_DATABASE_NAME};User ID=${DATABASE_USER_NAME};Password=${DATABASE_USER_PASSWORD};Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=30;" + +# Create secret +echo "Creating secret [$SECRET_NAME] in Key Vault..." +az keyvault secret set \ + --vault-name "$KEY_VAULT_NAME" \ + --name "$SECRET_NAME" \ + --value "$SQL_CONNECTION_STRING" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Secret [$SECRET_NAME] created successfully." +else + echo "Failed to create secret [$SECRET_NAME]." + exit 1 +fi + +# Create certificate in Key Vault +echo "Creating certificate [$CERT_NAME] in Key Vault [$KEY_VAULT_NAME]..." +az keyvault certificate create \ + --vault-name "$KEY_VAULT_NAME" \ + --name "$CERT_NAME" \ + --policy '{ + "issuerParameters": {"name": "Self"}, + "keyProperties": {"exportable": true, "keySize": 2048, "keyType": "RSA", "reuseKey": false}, + "secretProperties": {"contentType": "application/x-pkcs12"}, + "x509CertificateProperties": {"subject": "CN=sample-web-app-sql", "validityInMonths": 12} + }' \ + --only-show-errors + +if [ $? -eq 0 ]; then + echo "Certificate [$CERT_NAME] created successfully in Key Vault [$KEY_VAULT_NAME]." +else + echo "Failed to create certificate [$CERT_NAME] in Key Vault [$KEY_VAULT_NAME]." + exit 1 +fi + +# Get Key Vault URI +echo "Retrieving Key Vault URI..." +KEYVAULT_URI=$(az keyvault show \ + --name "$KEY_VAULT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "properties.vaultUri" \ + --output tsv) + +if [ -z "$KEYVAULT_URI" ]; then + echo "Failed to retrieve Key Vault URI." + exit 1 +fi +echo "Key Vault URI: [$KEYVAULT_URI]" + +# Set web app settings +# Pass Key Vault name and secret name as app settings. +# The .NET SDK will retrieve the actual connection string value from Key Vault. +echo "Setting web app settings for [$WEB_APP_NAME]..." +az webapp config appsettings set \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --settings \ + SCM_DO_BUILD_DURING_DEPLOYMENT='true' \ + ENABLE_ORYX_BUILD='true' \ + KEY_VAULT_NAME="$KEY_VAULT_NAME" \ + SECRET_NAME="$SECRET_NAME" \ + LOGIN_NAME="$LOGIN_NAME" \ + KEYVAULT_URI="$KEYVAULT_URI" \ + CERT_NAME="$CERT_NAME" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app settings for [$WEB_APP_NAME] set successfully." +else + echo "Failed to set web app settings for [$WEB_APP_NAME]." + exit 1 +fi + +if [[ $DEPLOY_APP -eq 0 ]]; then + echo "Skipping web app deployment as DEPLOY_APP flag is set to 0." + exit 0 +fi + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Get web app URL +WEB_APP_URL=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "defaultHostName" \ + --output tsv) + +# Remove the zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi diff --git a/samples/web-app-sql-database/dotnet/scripts/validate.sh b/samples/web-app-sql-database/dotnet/scripts/validate.sh new file mode 100755 index 0000000..19d5997 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/validate.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +SQL_SERVER_NAME="${PREFIX}-sqlserver-${SUFFIX}" +SQL_DATABASE_NAME='PlannerDB' +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" +SECRET_NAME="${PREFIX}-secret-${SUFFIX}" + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +az group show \ +--name "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ +--name "$WEB_APP_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "{name:name, state:state, defaultHostName:defaultHostName}" \ +--output table + +# Check Azure SQL Server +echo -e "\n[$SQL_SERVER_NAME] SQL server:\n" +az sql server show \ +--name "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure SQL Database +echo -e "\n[$SQL_DATABASE_NAME] SQL database:\n" +az sql db show \ +--name "$SQL_DATABASE_NAME" \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure Key Vault +echo -e "\n[$KEY_VAULT_NAME] Key Vault:\n" +az keyvault show \ +--name "$KEY_VAULT_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Key Vault secret +echo -e "\n[$SECRET_NAME] Key Vault secret:\n" +az keyvault secret show \ +--vault-name "$KEY_VAULT_NAME" \ +--name "$SECRET_NAME" \ +--query "{name:name, enabled:attributes.enabled, created:attributes.created}" \ +--output table + +# Print the list of resources in the resource group +echo -e "\nListing resources in resource group [$RESOURCE_GROUP_NAME]...\n" +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table diff --git a/samples/web-app-sql-database/dotnet/src/Models/Activity.cs b/samples/web-app-sql-database/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml b/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..84277d4 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id)) + { + await store.DeleteAsync(id, cancellationToken); + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml b/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..758fa51 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + await store.UpdateAsync(id, text, cancellationToken); + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated."; + } + else + { + await store.AddAsync(text, cancellationToken); + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added."; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml b/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml new file mode 100644 index 0000000..5e64f03 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml @@ -0,0 +1,2 @@ +@page "/update/{id}" +@model UpdateModel diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml.cs b/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml.cs new file mode 100644 index 0000000..47a8c42 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles GET /update/{id}: bounces to the index page with the activity to edit in the query string. +public class UpdateModel(IActivityStore store) : PageModel +{ + public async Task OnGetAsync(string id, CancellationToken cancellationToken) + { + var activity = (await store.ListAsync(cancellationToken)).FirstOrDefault(a => a.Id == id); + return activity is null + ? RedirectToPage("/Index") + : RedirectToPage("/Index", new { edit_id = activity.Id, edit_activity = activity.Text }); + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-sql-database/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-sql-database/dotnet/src/Program.cs b/samples/web-app-sql-database/dotnet/src/Program.cs new file mode 100644 index 0000000..8b8356d --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Program.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); +var startupLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("Startup"); + +// Code deployments built by Oryx export PORT (and ASPNETCORE_URLS); custom images and local runs set PORT. +var httpPort = int.Parse(Environment.GetEnvironmentVariable("PORT") ?? "8080"); +var vaultUri = Environment.GetEnvironmentVariable("KEYVAULT_URI"); +var certificateName = Environment.GetEnvironmentVariable("CERT_NAME"); + +// Serve HTTPS on 8443 with the certificate stored in Key Vault, next to the plain HTTP endpoint App Service proxies to. +builder.WebHost.ConfigureKestrel(kestrel => +{ + kestrel.ListenAnyIP(httpPort); + if (!string.IsNullOrEmpty(vaultUri) && !string.IsNullOrEmpty(certificateName)) + { + try + { + var certificate = KeyVaultCertificates.LoadServerCertificateAsync(vaultUri, certificateName, CancellationToken.None).GetAwaiter().GetResult(); + kestrel.ListenAnyIP(8443, listen => listen.UseHttps(certificate)); + startupLogger.LogInformation("HTTPS enabled on port 8443 with Key Vault certificate [{Certificate}]", certificateName); + } + catch (Exception ex) + { + startupLogger.LogError(ex, "Could not load certificate [{Certificate}] from Key Vault; HTTPS on 8443 is disabled", certificateName); + } + } +}); + +// Resolve the SQL connection (Key Vault secret first) up front so a misconfigured deployment fails at startup. +var sqlOptions = await SqlOptions.FromEnvironmentAsync(startupLogger, CancellationToken.None); + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new SqlActivityStore(sqlOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +// One log line per request, the equivalent of the gunicorn access log the Python sample produces. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +// Downloads the certificate from Key Vault and returns its properties, proving the Key Vault certificate integration works. +app.MapGet("/api/certificate", async (ILogger logger, CancellationToken cancellationToken) => +{ + if (string.IsNullOrEmpty(vaultUri) || string.IsNullOrEmpty(certificateName)) + { + return Results.Json(new { error = "KEYVAULT_URI not configured" }, statusCode: StatusCodes.Status500InternalServerError); + } + + try + { + return Results.Json(await KeyVaultCertificates.GetCertificateInfoAsync(vaultUri, certificateName, cancellationToken)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error validating certificate"); + return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status500InternalServerError); + } +}); + +app.Run(); diff --git a/samples/web-app-sql-database/dotnet/src/Services/IActivityStore.cs b/samples/web-app-sql-database/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..3b24e5f --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,21 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + Task AddAsync(string text, CancellationToken cancellationToken); + + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-sql-database/dotnet/src/Services/KeyVaultCertificates.cs b/samples/web-app-sql-database/dotnet/src/Services/KeyVaultCertificates.cs new file mode 100644 index 0000000..db60f5c --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/KeyVaultCertificates.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Azure.Identity; +using Azure.Security.KeyVault.Certificates; +using Azure.Security.KeyVault.Secrets; + +namespace VacationPlanner.Services; + +/// Key Vault certificate helpers: the TLS certificate served on port 8443 and the /api/certificate probe. +public static class KeyVaultCertificates +{ + /// + /// Downloads the certificate (public part plus private key, stored by Key Vault as a linked secret) + /// so Kestrel can serve HTTPS with it. + /// + public static async Task LoadServerCertificateAsync(string vaultUri, string certificateName, CancellationToken cancellationToken) + { + var secretClient = new SecretClient(new Uri(vaultUri), new DefaultAzureCredential()); + var secret = (await secretClient.GetSecretAsync(certificateName, cancellationToken: cancellationToken)).Value; + if (string.IsNullOrEmpty(secret.Value)) + { + throw new InvalidOperationException($"Secret [{certificateName}] has no value"); + } + + // Key Vault returns the PFX as base64, or PEM (certificate and key concatenated) as plain text. + return secret.Properties.ContentType == "application/x-pkcs12" + ? X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(secret.Value), password: null) + : X509Certificate2.CreateFromPem(secret.Value, secret.Value); + } + + /// Returns the certificate's name, subject and SHA-1 thumbprint (lowercase hex), as the Python sample does. + public static async Task GetCertificateInfoAsync(string vaultUri, string certificateName, CancellationToken cancellationToken) + { + var client = new CertificateClient(new Uri(vaultUri), new DefaultAzureCredential()); + var certificate = (await client.GetCertificateAsync(certificateName, cancellationToken)).Value; + if (certificate.Cer is null) + { + throw new InvalidOperationException($"Certificate '{certificateName}' has no public bytes (cer is None)"); + } + + if (certificate.Policy is null) + { + throw new InvalidOperationException($"Certificate '{certificateName}' has no policy"); + } + + return new + { + name = certificate.Name, + subject = certificate.Policy.Subject, + thumbprint = Convert.ToHexStringLower(SHA1.HashData(certificate.Cer)), + }; + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Services/SqlActivityStore.cs b/samples/web-app-sql-database/dotnet/src/Services/SqlActivityStore.cs new file mode 100644 index 0000000..c0ed077 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/SqlActivityStore.cs @@ -0,0 +1,134 @@ +using Azure.Core; +using Azure.Identity; +using Microsoft.Data.SqlClient; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// +/// Activities in the dbo.Activities table of an Azure SQL Database. The table is created by the +/// deployment scripts, so the store only reads and writes it. +/// +public sealed class SqlActivityStore(SqlOptions options, ILogger logger) : IActivityStore +{ + private readonly TokenCredential? _credential = options.UseAzureCredential ? new DefaultAzureCredential() : null; + + // Encrypt + TrustServerCertificate: the emulator's SQL Server container presents a self-signed + // certificate, so the connection is encrypted without validating the certificate chain. + private readonly string _connectionString = new SqlConnectionStringBuilder + { + DataSource = $"tcp:{options.Server},1433", + InitialCatalog = options.Database, + Encrypt = SqlConnectionEncryptOption.Mandatory, + TrustServerCertificate = true, + ConnectTimeout = 30, + UserID = options.UseAzureCredential ? "" : options.User ?? throw new InvalidOperationException("Username and password required when not using Azure credential"), + Password = options.UseAzureCredential ? "" : options.Password ?? throw new InvalidOperationException("Username and password required when not using Azure credential"), + }.ConnectionString; + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + // The table is provisioned by the deployment; just prove the database is reachable. + await using var connection = await OpenAsync(cancellationToken); + logger.LogInformation("Connected to SQL Database [{Database}] on [{Server}]", options.Database, options.Server); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand( + "SELECT id, activity FROM dbo.Activities WHERE username = @username ORDER BY timestamp DESC", connection); + command.Parameters.AddWithValue("@username", options.Username); + + var activities = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + activities.Add(new Activity(reader.GetGuid(0).ToString(), reader.GetString(1))); + } + + logger.LogInformation( + "Retrieved {Count} activities for user: {Username}", + activities.Count, + options.Username + ); + return activities; + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand( + """ + INSERT INTO dbo.Activities (username, activity, timestamp) + OUTPUT INSERTED.id, INSERTED.username, INSERTED.activity, INSERTED.timestamp + VALUES (@username, @activity, GETDATE()) + """, connection); + command.Parameters.AddWithValue("@username", options.Username); + command.Parameters.AddWithValue("@activity", text); + var id = await command.ExecuteScalarAsync(cancellationToken); + logger.LogInformation("Activity created: {Id}", id); + } + + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand( + "UPDATE dbo.Activities SET activity = @activity, timestamp = GETDATE() WHERE id = CAST(@id AS UNIQUEIDENTIFIER)", connection); + command.Parameters.AddWithValue("@activity", text); + command.Parameters.AddWithValue("@id", id); + var rows = await command.ExecuteNonQueryAsync(cancellationToken); + if (rows == 0) + { + logger.LogWarning("No activity found with ID: {Id}", id); + return; + } + + logger.LogInformation("Updated activity with ID: {Id}", id); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand("DELETE FROM dbo.Activities WHERE id = CAST(@id AS UNIQUEIDENTIFIER)", connection); + command.Parameters.AddWithValue("@id", id); + var rows = await command.ExecuteNonQueryAsync(cancellationToken); + if (rows == 0) + { + logger.LogWarning("No activity found with ID: {Id}", id); + return; + } + + logger.LogInformation("Deleted activity with ID: {Id}", id); + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand("SELECT 1", connection); + await command.ExecuteScalarAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "SQL Database health check failed"); + return false; + } + } + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new SqlConnection(_connectionString); + if (_credential is not null) + { + // Passwordless: present a Microsoft Entra access token for Azure SQL Database. + var token = await _credential.GetTokenAsync(new TokenRequestContext(["https://database.windows.net/.default"]), cancellationToken); + connection.AccessToken = token.Token; + } + + await connection.OpenAsync(cancellationToken); + return connection; + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Services/SqlOptions.cs b/samples/web-app-sql-database/dotnet/src/Services/SqlOptions.cs new file mode 100644 index 0000000..f1f7e62 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/SqlOptions.cs @@ -0,0 +1,74 @@ +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; + +namespace VacationPlanner.Services; + +/// +/// Connection settings, resolved like the Python sample's SqlHelper.from_env(): the connection string +/// stored in Key Vault (KEY_VAULT_NAME + SECRET_NAME) wins; otherwise SQL_* variables +/// are used, with Microsoft Entra authentication when the AZURE_* service principal variables are set. +/// +public sealed record SqlOptions(string Server, string Database, string? User, string? Password, bool UseAzureCredential, string Username) +{ + public static async Task FromEnvironmentAsync(ILogger logger, CancellationToken cancellationToken) + { + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "paolo"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("Username cannot be None or empty"); + } + + var keyVaultName = Environment.GetEnvironmentVariable("KEY_VAULT_NAME"); + var secretName = Environment.GetEnvironmentVariable("SECRET_NAME"); + if (!string.IsNullOrEmpty(keyVaultName) && !string.IsNullOrEmpty(secretName)) + { + var client = new SecretClient(new Uri($"https://{keyVaultName}.vault.azure.net"), new DefaultAzureCredential()); + logger.LogInformation("Retrieving secret [{Secret}] from Key Vault [{Vault}]...", secretName, keyVaultName); + var secret = await client.GetSecretAsync(secretName, cancellationToken: cancellationToken); + if (string.IsNullOrEmpty(secret.Value.Value)) + { + throw new InvalidOperationException($"Secret [{secretName}] in Key Vault [{keyVaultName}] has no value"); + } + + logger.LogInformation("Secret [{Secret}] retrieved successfully from Key Vault [{Vault}]", secretName, keyVaultName); + return FromConnectionString(secret.Value.Value, username); + } + + var clientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"); + var clientSecret = Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET"); + var tenantId = Environment.GetEnvironmentVariable("AZURE_TENANT_ID"); + var server = Environment.GetEnvironmentVariable("SQL_SERVER"); + var database = Environment.GetEnvironmentVariable("SQL_DATABASE"); + var user = Environment.GetEnvironmentVariable("SQL_USERNAME"); + var password = Environment.GetEnvironmentVariable("SQL_PASSWORD"); + if (string.IsNullOrEmpty(server) || string.IsNullOrEmpty(database)) + { + throw new InvalidOperationException( + "Set KEY_VAULT_NAME and SECRET_NAME, or SQL_SERVER and SQL_DATABASE (with SQL_USERNAME/SQL_PASSWORD or the AZURE_* service principal variables)."); + } + + var useAzureCredential = !string.IsNullOrEmpty(clientId) && !string.IsNullOrEmpty(clientSecret) && !string.IsNullOrEmpty(tenantId); + return new SqlOptions(server, database, user, password, useAzureCredential, username); + } + + /// Parses an ADO.NET connection string such as the one Key Vault holds (Server=tcp:host,1433;Database=…;User ID=…;Password=…). + public static SqlOptions FromConnectionString(string connectionString, string username) + { + var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split('=', 2)) + .Where(kv => kv.Length == 2) + .ToDictionary(kv => kv[0].Trim(), kv => kv[1].Trim(), StringComparer.OrdinalIgnoreCase); + + var server = (parts.GetValueOrDefault("Server") ?? "").Replace("tcp:", "").Replace(",1433", ""); + var database = parts.GetValueOrDefault("Database"); + var user = parts.GetValueOrDefault("User ID"); + var password = parts.GetValueOrDefault("Password"); + if (string.IsNullOrEmpty(server) || string.IsNullOrEmpty(database) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(password)) + { + throw new InvalidOperationException( + $"Could not parse all required parameters from connection string. Found - Server: {server.Length > 0}, Database: {database is not null}, Username: {user is not null}, Password: {password is not null}"); + } + + return new SqlOptions(server, database, user, password, UseAzureCredential: false, username); + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-sql-database/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-sql-database/dotnet/src/VacationPlanner.csproj b/samples/web-app-sql-database/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..b2632aa --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + + + + diff --git a/samples/web-app-sql-database/dotnet/src/appsettings.json b/samples/web-app-sql-database/dotnet/src/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-sql-database/dotnet/src/wwwroot/favicon.ico b/samples/web-app-sql-database/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-sql-database/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-sql-database/dotnet/src/wwwroot/style.css b/samples/web-app-sql-database/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-sql-database/dotnet/terraform/README.md b/samples/web-app-sql-database/dotnet/terraform/README.md new file mode 100644 index 0000000..fac861f --- /dev/null +++ b/samples/web-app-sql-database/dotnet/terraform/README.md @@ -0,0 +1,196 @@ +# Terraform Deployment + +This directory contains Terraform modules and a deployment script for provisioning Azure services in LocalStack for Azure. Refer to the [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md) guide for details about the sample application. + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): 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) +- [Terraform](https://developer.hashicorp.com/terraform/downloads): Infrastructure as Code tool for provisioning Azure resources +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing lstk CLI + +Deploying to LocalStack requires the `lstk` CLI, which routes Azure CLI commands to the emulator (run `lstk az start-interception` before deploying). Install it using Homebrew: + +```bash +brew install localstack/tap/lstk +``` + +or npm: + +```bash +npm install -g @localstack/lstk +``` + +Alternatively, download a pre-built binary from the [lstk releases page](https://github.com/localstack/lstk/releases). For more information, see the [lstk CLI documentation](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and the [lstk GitHub repository](https://github.com/localstack/lstk). + +## Architecture Overview + +The [main.tf](main.tf) Terraform module creates the following Azure resources: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): Logical container for all resources in the sample. +2. [Azure SQL Server](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview): Logical server hosting one or more Azure SQL Databases. +3. [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/): The `PlannerDB` database storing relational vacation activity data. +4. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The compute resource that hosts the web application. +5. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Hosts the ASP.NET Core Razor Pages single-page application (*Vacation Planner*), connected to Azure SQL Database. +6. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview): Stores the SQL connection string as a secret and a self-signed certificate for HTTPS. +7. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): (Optional) Configures automatic deployment from a public GitHub repository. + +The system implements a Vacation Planner web application that stores and retrieves activity data from Azure SQL Database. For more information, see [Azure Web App with Azure SQL Database and Azure Key Vault](../README.md). + +## 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="azure.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/) 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). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +# Set the authentication token +export LOCALSTACK_AUTH_TOKEN= + +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Navigate to the `terraform` folder: + +```bash +cd samples/web-app-sql-database/dotnet/terraform +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +SQL_SERVER_NAME="${PREFIX}-sqlserver-${SUFFIX}" +SQL_DATABASE_NAME='PlannerDB' +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" +SECRET_NAME="${PREFIX}-secret-${SUFFIX}" + +# Check resource group +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +az group show \ +--name "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure Web App +echo -e "\n[$WEB_APP_NAME] web app:\n" +az webapp show \ +--name "$WEB_APP_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--query "{name:name, state:state, defaultHostName:defaultHostName}" \ +--output table + +# Check Azure SQL Server +echo -e "\n[$SQL_SERVER_NAME] SQL server:\n" +az sql server show \ +--name "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure SQL Database +echo -e "\n[$SQL_DATABASE_NAME] SQL database:\n" +az sql db show \ +--name "$SQL_DATABASE_NAME" \ +--server "$SQL_SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Azure Key Vault +echo -e "\n[$KEY_VAULT_NAME] Key Vault:\n" +az keyvault show \ +--name "$KEY_VAULT_NAME" \ +--resource-group "$RESOURCE_GROUP_NAME" \ +--output table + +# Check Key Vault secret +echo -e "\n[$SECRET_NAME] Key Vault secret:\n" +az keyvault secret show \ +--vault-name "$KEY_VAULT_NAME" \ +--name "$SECRET_NAME" \ +--query "{name:name, enabled:attributes.enabled, created:attributes.created}" \ +--output table + +# Print the list of resources in the resource group +echo -e "\nListing resources in resource group [$RESOURCE_GROUP_NAME]...\n" +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table +``` + +## 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://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-sql-database/dotnet/terraform/deploy.sh b/samples/web-app-sql-database/dotnet/terraform/deploy.sh new file mode 100755 index 0000000..2e1ab40 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/terraform/deploy.sh @@ -0,0 +1,243 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +ADMIN_USER='sqladmin' +ADMIN_PASSWORD='P@ssw0rd1234!' +DATABASE_USER_NAME='testuser' +DATABASE_USER_PASSWORD='TestP@ssw0rd123' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +SECRET_NAME="${PREFIX}-secret-${SUFFIX}" +ZIPFILE="planner_website.zip" +DEPLOY_APP=1 + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +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" \ + -var "administrator_login=$ADMIN_USER" \ + -var "administrator_login_password=$ADMIN_PASSWORD" \ + -var "sql_database_username=$DATABASE_USER_NAME" \ + -var "sql_database_password=$DATABASE_USER_PASSWORD" \ + -var "secret_name=$SECRET_NAME" + +# 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) +WEB_APP_NAME=$(terraform output -raw web_app_name) +SQL_SERVER_NAME=$(terraform output -raw sql_server_name) +SQL_DATABASE_NAME=$(terraform output -raw sql_database_name) + +if [[ -z "$WEB_APP_NAME" || -z "$SQL_SERVER_NAME" || -z "$SQL_DATABASE_NAME" ]]; then + echo "Web App Name, SQL Server Name, or SQL Database Name is empty. Exiting." + exit 1 +fi + +# Retrieve the fullyQualifiedDomainName of the SQL server +echo "Retrieving the fullyQualifiedDomainName of the [$SQL_SERVER_NAME] SQL server..." +SQL_SERVER_FQDN=$(az sql server show \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$SQL_SERVER_FQDN" ]; then + echo "Failed to retrieve the fullyQualifiedDomainName of the SQL server" + exit 1 +fi + +# Create server-level login +echo "Creating login [$DATABASE_USER_NAME] at server level..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d master \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -C \ + -Q "IF NOT EXISTS (SELECT * FROM sys.sql_logins WHERE name = '$DATABASE_USER_NAME') + CREATE LOGIN [$DATABASE_USER_NAME] WITH PASSWORD = '$DATABASE_USER_PASSWORD';" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Login [$DATABASE_USER_NAME] created successfully" +else + echo "Failed to create login [$DATABASE_USER_NAME]" + exit 1 +fi + +# Create database user +echo "Creating user [$DATABASE_USER_NAME] in database [$SQL_DATABASE_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -C \ + -Q "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = '$DATABASE_USER_NAME') + CREATE USER [$DATABASE_USER_NAME] FOR LOGIN [$DATABASE_USER_NAME];" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "User [$DATABASE_USER_NAME] created successfully in database [$SQL_DATABASE_NAME]" +else + echo "Failed to create user [$DATABASE_USER_NAME]" + exit 1 +fi + +# Grant permissions including DDL rights +echo "Granting permissions to [$DATABASE_USER_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -C \ + -Q "ALTER ROLE db_datareader ADD MEMBER [$DATABASE_USER_NAME]; + ALTER ROLE db_datawriter ADD MEMBER [$DATABASE_USER_NAME]; + ALTER ROLE db_ddladmin ADD MEMBER [$DATABASE_USER_NAME];" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Permissions granted successfully to [$DATABASE_USER_NAME]" +else + echo "Failed to grant permissions to [$DATABASE_USER_NAME]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$DATABASE_USER_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -C \ + -Q "SELECT SYSTEM_USER AS CurrentUser, DB_NAME() AS CurrentDatabase, GETDATE() AS CurrentTime;" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$DATABASE_USER_NAME]" +else + echo "Connection test failed with user [$DATABASE_USER_NAME]" + exit 1 +fi + +# Create table +echo "Creating test [Products] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -C \ + -Q "IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Activities' AND schema_id = SCHEMA_ID('dbo')) + CREATE TABLE dbo.Activities ( + -- Primary Key: UNIQUEIDENTIFIER with a default of a new sequential GUID (best for indexing) + id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(), + + -- Username field + username VARCHAR(32) NOT NULL, + + -- Description of the activity + activity VARCHAR(128) NOT NULL, + + -- Timestamp of the activity + timestamp DATETIME NOT NULL + );" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test [Activities] table created successfully" +else + echo "Failed to create test [Activities] table" + exit 1 +fi + +# Insert data +echo "Inserting test data into [Activities] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -C \ + -Q "INSERT INTO Activities (username, activity, timestamp) + VALUES + ('paolo', 'Go to Paris', GETDATE()), + ('paolo', 'Go to London', GETDATE()), + ('paolo', 'Go to Mexico', GETDATE());" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test data inserted successfully into [Activities] table" +else + echo "Failed to insert test data into [Activities] table" + exit 1 +fi + +# Query data +echo "Querying test data from [Activities] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -C \ + -Q "SELECT * FROM Activities;" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test data queried successfully from [Activities] table" +else + echo "Failed to query test data from [Activities] table" + exit 1 +fi + +if [[ $DEPLOY_APP -eq 0 ]]; then + echo "Skipping web app deployment as DEPLOY_APP flag is set to 0." + exit 0 +fi + +# Change current directory to source folder +cd "../src" || exit + +# Remove any existing zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the web app..." +zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the web app +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi diff --git a/samples/web-app-sql-database/dotnet/terraform/main.tf b/samples/web-app-sql-database/dotnet/terraform/main.tf new file mode 100644 index 0000000..6353bc9 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/terraform/main.tf @@ -0,0 +1,204 @@ +# Local Variables +locals { + firewall_rule_name = "AllowAllIPs" + resource_group_name = "${var.prefix}-rg" + sql_server_name = "${var.prefix}-sqlserver-${var.suffix}" + app_service_plan_name = "${var.prefix}-app-service-plan-${var.suffix}" + web_app_name = "${var.prefix}-webapp-${var.suffix}" + key_vault_name = "${var.prefix}-kv-${var.suffix}" +} + +# Retrieve the current Azure client configuration +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 SQL server +resource "azurerm_mssql_server" "example" { + name = local.sql_server_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + administrator_login = var.administrator_login + administrator_login_password = var.administrator_login_password + minimum_tls_version = var.minimum_tls_version + public_network_access_enabled = var.public_network_access_enabled + outbound_network_restriction_enabled = var.outbound_network_restriction_enabled + version = var.sql_version + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Create a firewall rule +resource "azurerm_mssql_firewall_rule" "example" { + name = local.firewall_rule_name + server_id = azurerm_mssql_server.example.id + start_ip_address = var.start_ip_address + end_ip_address = var.end_ip_address +} + +# Create a database +resource "azurerm_mssql_database" "example" { + name = var.sql_database_name + server_id = azurerm_mssql_server.example.id + sku_name = var.sku.name + auto_pause_delay_in_minutes = var.auto_pause_delay + collation = var.collation + create_mode = var.create_mode + elastic_pool_id = var.elastic_pool_resource_id + max_size_gb = var.max_size_gb + min_capacity = var.min_capacity != "0" ? tonumber(var.min_capacity) : null + read_replica_count = var.high_availability_replica_count + read_scale = var.read_scale == "Enabled" ? true : false + zone_redundant = var.sql_database_zone_redundant + license_type = var.license_type + ledger_enabled = var.is_ledger_on + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Create a service plan +resource "azurerm_service_plan" "example" { + 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 + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Create a web app +resource "azurerm_linux_web_app" "example" { + name = local.web_app_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + service_plan_id = azurerm_service_plan.example.id + https_only = var.https_only + public_network_access_enabled = var.webapp_public_network_access_enabled + client_affinity_enabled = false + tags = var.tags + + identity { + type = "SystemAssigned" + } + + site_config { + always_on = var.always_on + http2_enabled = var.http2_enabled + minimum_tls_version = var.minimum_tls_version + application_stack { + dotnet_version = var.dotnet_version + } + } + + app_settings = { + SCM_DO_BUILD_DURING_DEPLOYMENT = "true" + ENABLE_ORYX_BUILD = "true" + KEY_VAULT_NAME = azurerm_key_vault.example.name + SECRET_NAME = azurerm_key_vault_secret.sql_connection_string.name + KEYVAULT_URI = azurerm_key_vault.example.vault_uri + CERT_NAME = azurerm_key_vault_certificate.example.name + LOGIN_NAME = var.login_name + } + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Create a Key Vault +resource "azurerm_key_vault" "example" { + name = local.key_vault_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + rbac_authorization_enabled = false + soft_delete_retention_days = 7 + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Grant the Web App managed identity access to Key Vault secrets and certificates +resource "azurerm_key_vault_access_policy" "web_app" { + key_vault_id = azurerm_key_vault.example.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = azurerm_linux_web_app.example.identity[0].principal_id + + secret_permissions = [ + "Get", + "List", + ] + + certificate_permissions = [ + "Get", + ] +} + +# Create a Key Vault secret for SQL connection string +resource "azurerm_key_vault_secret" "sql_connection_string" { + name = var.secret_name + value = "Server=tcp:${azurerm_mssql_server.example.fully_qualified_domain_name},1433;Database=${azurerm_mssql_database.example.name};User ID=${var.sql_database_username};Password=${var.sql_database_password};Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;" + key_vault_id = azurerm_key_vault.example.id +} + +# Create a self-signed certificate in Key Vault +resource "azurerm_key_vault_certificate" "example" { + name = var.cert_name + key_vault_id = azurerm_key_vault.example.id + + certificate_policy { + issuer_parameters { + name = "Self" + } + + key_properties { + exportable = true + key_size = 2048 + key_type = "RSA" + reuse_key = false + } + + secret_properties { + content_type = "application/x-pkcs12" + } + + x509_certificate_properties { + subject = "CN=${var.cert_subject}" + validity_in_months = 12 + + key_usage = [ + "digitalSignature", + "keyEncipherment", + ] + } + } +} diff --git a/samples/web-app-sql-database/dotnet/terraform/outputs.tf b/samples/web-app-sql-database/dotnet/terraform/outputs.tf new file mode 100644 index 0000000..7d14b48 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/terraform/outputs.tf @@ -0,0 +1,35 @@ +output "resource_group_name" { + value = local.resource_group_name +} + +output "sql_server_name" { + value = azurerm_mssql_server.example.name +} + +output "sql_database_name" { + value = azurerm_mssql_database.example.name +} + +output "app_service_plan_name" { + value = azurerm_service_plan.example.name +} + +output "web_app_name" { + value = azurerm_linux_web_app.example.name +} + +output "web_app_url" { + value = azurerm_linux_web_app.example.default_hostname +} + +output "key_vault_name" { + value = azurerm_key_vault.example.name +} + +output "key_vault_url" { + value = azurerm_key_vault.example.vault_uri +} + +output "sql_connection_string_secret_uri" { + value = azurerm_key_vault_secret.sql_connection_string.id +} \ No newline at end of file diff --git a/samples/web-app-sql-database/dotnet/terraform/providers.tf b/samples/web-app-sql-database/dotnet/terraform/providers.tf new file mode 100644 index 0000000..6682178 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/terraform/providers.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=5.1.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 = "azure.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" +} diff --git a/samples/web-app-sql-database/dotnet/terraform/terraform.tfvars b/samples/web-app-sql-database/dotnet/terraform/terraform.tfvars new file mode 100644 index 0000000..e95f069 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/terraform/terraform.tfvars @@ -0,0 +1,2 @@ +location = "westeurope" +dotnet_version = "10.0" \ No newline at end of file diff --git a/samples/web-app-sql-database/dotnet/terraform/variables.tf b/samples/web-app-sql-database/dotnet/terraform/variables.tf new file mode 100644 index 0000000..4de94d2 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/terraform/variables.tf @@ -0,0 +1,359 @@ +variable "prefix" { + description = "(Optional) Specifies the prefix for the name of the Azure resources." + type = string + default = "websql" + + 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 = null +} + +variable "administrator_login" { + description = "(Required) Specifies the administrator login for the SQL server." + type = string + default = "sqladmin" +} + +variable "administrator_login_password" { + description = "(Required) Specifies the administrator login password for the SQL server." + type = string + default = "P@ssw0rd1234!" +} + +variable "sql_version" { + description = "(Optional) Specifies the version of the SQL server." + type = string + default = "12.0" +} + +variable "public_network_access_enabled" { + description = "(Optional) Specifies whether the public network access is enabled or disabled for the SQL server." + type = bool + default = true +} + +variable "outbound_network_restriction_enabled" { + description = "(Optional) Specifies whether to restrict outbound network access for the SQL server." + type = bool + default = false +} + +variable "start_ip_address" { + description = "(Required) The starting IP address to allow through the firewall for this rule." + type = string + default = "0.0.0.0" +} + +variable "end_ip_address" { + description = "(Required) The ending IP address to allow through the firewall for this rule." + type = string + default = "255.255.255.255" +} + +variable "sql_database_name" { + description = "(Optional) Specifies the name of the SQL database." + type = string + default = "PlannerDB" +} + +variable "sku" { + description = "Specifies the SKU for the database." + type = object({ + name = string + tier = string + capacity = number + }) + default = { + name = "S0" + tier = "Standard" + capacity = 10 + } +} + +variable "auto_pause_delay" { + description = "Time in minutes after which database is automatically paused. A value of -1 means that automatic pause is disabled." + type = number + default = -1 +} + +variable "availability_zone" { + description = "Specifies the availability zone. Valid values are 1, 2, 3, or -1 for no preference." + type = number + default = -1 + validation { + condition = contains([-1, 1, 2, 3], var.availability_zone) + error_message = "Availability zone must be -1, 1, 2, or 3." + } +} + +variable "catalog_collation" { + description = "Specifies the collation of the metadata catalog." + type = string + default = "DATABASE_DEFAULT" +} + +variable "collation" { + description = "Specifies the collation of the database." + type = string + default = "SQL_Latin1_General_CP1_CI_AS" +} + +variable "create_mode" { + description = "The create mode of the database." + type = string + default = "Default" + validation { + condition = contains([ + "Default", + "Copy", + "OnlineSecondary", + "PointInTimeRestore", + "Recovery", + "Restore", + "RestoreExternalBackup", + "RestoreExternalBackupSecondary", + "RestoreLongTermRetentionBackup", + "Secondary" + ], var.create_mode) + error_message = "Invalid create mode specified." + } +} + +variable "elastic_pool_resource_id" { + description = "The ID of the elastic pool containing this database." + type = string + default = null +} + +variable "high_availability_replica_count" { + description = "The number of readonly secondary replicas associated with the database." + type = number + default = 0 +} + +variable "is_ledger_on" { + description = "Whether or not this database is a ledger database." + type = bool + default = false +} + +variable "license_type" { + description = "Specifies the license type to apply for this database." + type = string + default = null + validation { + condition = var.license_type == null || try(contains(["LicenseIncluded", "BasePrice"], var.license_type), false) + error_message = "License type must be 'LicenseIncluded' or 'BasePrice'." + } +} + +variable "min_capacity" { + description = "Minimal capacity that database will always have allocated." + type = string + default = "0" +} + +variable "read_scale" { + description = "If enabled, connections that have application intent set to readonly can be routed to a readonly secondary replica." + type = string + default = "Disabled" + validation { + condition = contains(["Enabled", "Disabled"], var.read_scale) + error_message = "Read scale must be 'Enabled' or 'Disabled'." + } +} + +variable "sql_database_zone_redundant" { + description = "Whether or not this database is zone redundant." + type = bool + default = false +} + +variable "max_size_gb" { + description = "The max size of the database in gigabytes." + type = number + default = null +} + +variable "sql_database_username" { + description = "(Required) The administrator username of the SQL Server (set at server level)." + type = string + default = "testuser" + sensitive = true +} + +variable "sql_database_password" { + description = "(Required) The administrator password of the SQL Server (set at server level)." + type = string + default = "TestP@ssw0rd123" + sensitive = true +} + +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 "dotnet_version" { + description = "(Optional) Specifies the version of .NET to run. Possible values include 8.0, 9.0 and 10.0." + type = string + default = "10.0" + + validation { + condition = contains(["8.0", "9.0", "10.0"], var.dotnet_version) + error_message = "The dotnet_version must be one of the supported versions: 8.0, 9.0, 10.0." + } +} + +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 "webapp_public_network_access_enabled" { + description = "(Optional) Specifies whether the public network access is enabled or disabled." + type = bool + default = true +} + +variable "login_name" { + description = "(Required) Specifies the login name for the application." + type = string + default = "paolo" +} + +variable "secret_name" { + description = "(Optional) Specifies the name of the Key Vault secret for the SQL connection string." + type = string + default = "sql-connection-string" +} + +variable "cert_name" { + description = "(Optional) Specifies the name of the Key Vault certificate." + type = string + default = "webapp-cert" +} + +variable "cert_subject" { + description = "(Optional) Specifies the subject of the self-signed certificate." + type = string + default = "sample-web-app-sql" +} + +variable "tags" { + description = "(Optional) Specifies the tags to be applied to the resources." + type = map(string) + default = { + environment = "test" + iac = "terraform" + } +} \ No newline at end of file diff --git a/samples/web-app-sql-database/dotnet/visio/architecture.vsdx b/samples/web-app-sql-database/dotnet/visio/architecture.vsdx new file mode 100644 index 0000000..cd408e9 Binary files /dev/null and b/samples/web-app-sql-database/dotnet/visio/architecture.vsdx differ diff --git a/samples/web-app-sql-database/python/scripts/call-web-app.sh b/samples/web-app-sql-database/python/scripts/call-web-app.sh new file mode 100755 index 0000000..272c0f9 --- /dev/null +++ b/samples/web-app-sql-database/python/scripts/call-web-app.sh @@ -0,0 +1,254 @@ +#!/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" +} + +# Distinguished names are compared after normalization: OpenSSL 3 prints "CN = value", Key Vault +# returns "CN=value" and OpenSSL 1 printed "/CN=value" - the same subject in three spellings, which +# a literal comparison reports as a mismatch. +normalize_dn() { + echo "$1" | sed -e 's#^/##' -e 's#/#, #g' -e 's/[[:space:]]*=[[:space:]]*/=/g' \ + -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' +} + +call_web_app() { + # Get the web app name + echo "Getting web app name..." + web_app_name=$(az webapp list --query '[0].name' --output tsv) + + if [ -n "$web_app_name" ]; then + echo "Web app [$web_app_name] successfully retrieved." + else + echo "Error: No web app found" + exit 1 + fi + + # Get the resource group name + echo "Getting resource group name for web app [$web_app_name]..." + resource_group_name=$(az webapp 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 web app [$web_app_name]" + exit 1 + fi + + # Get the the default host name of the web app + echo "Getting the default host name of the web app [$web_app_name]..." + app_host_name=$(az webapp show \ + --name "$web_app_name" \ + --resource-group "$resource_group_name" \ + --query 'defaultHostName' \ + --output tsv) + + if [ -n "$app_host_name" ]; then + echo "Web app default host name [$app_host_name] successfully retrieved." + else + echo "Error: No web app default host name found" + exit 1 + fi + + # Get the Docker container name + echo "Finding container name with prefix [ls-$web_app_name]..." + container_name=$(get_docker_container_name_by_prefix "ls-$web_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") + + 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 web 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 --max-time 10 http://localhost:4566/_localstack/proxy -s | jq '.proxy_port') + + if [ -n "$proxy_port" ]; then + # Call the web app via emulator proxy + echo "Calling web app [$web_app_name] via emulator..." + curl --max-time 10 --proxy "http://localhost:$proxy_port/" -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via emulator proxy port [$proxy_port] succeeded." + else + echo "Web app call via emulator proxy port [$proxy_port] failed." + fi + else + echo "Failed to retrieve LocalStack proxy port" + fi + + if [ -n "$container_ip" ]; then + # Call the web app via the container IP address + echo "Calling web app [$web_app_name] via container IP address [$container_ip]..." + curl --max-time 10 -s "http://$container_ip/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via container IP address [$container_ip] succeeded." + else + echo "Web app call via container IP address [$container_ip] failed." + fi + else + echo "Failed to retrieve container IP address" + fi + + if [ -n "$host_port" ]; then + # Call the web app via the host port + echo "Calling web app [$web_app_name] via host port [$host_port]..." + curl --max-time 10 -s "http://127.0.0.1:$host_port/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via host port [$host_port] succeeded." + else + echo "Web app call via host port [$host_port] failed." + fi + else + echo "Failed to retrieve host port" + fi + + if [ -n "$app_host_name" ]; then + # Call the web app via the default hostname + echo "Calling web app [$web_app_name] via default hostname [$app_host_name]..." + curl --max-time 10 -s "http://$app_host_name/" 1> /dev/null + + if [ $? == 0 ]; then + echo "Web app call via default hostname [$app_host_name] succeeded." + else + echo "Web app call via default hostname [$app_host_name] failed." + fi + else + echo "Failed to retrieve web app hostname" + fi + + echo "Validating certificate from Key Vault..." + if ! KV_RESPONSE=$(curl --max-time 10 -fsSk "https://$container_ip:8443/api/certificate"); then + # Only reachable when the app serves HTTPS itself (python app.py); under gunicorn the port + # is closed. Say so and skip, rather than comparing two empty thumbprints as equal. + echo "HTTPS on port 8443 is not served by this deployment; skipping the Key Vault certificate check." + return 0 + fi + KV_THUMBPRINT=$(echo "$KV_RESPONSE" | jq -r '.thumbprint') + KV_NAME=$(echo "$KV_RESPONSE" | jq -r '.name') + KV_SUBJECT=$(echo "$KV_RESPONSE" | jq -r '.subject') + + if [ -z "$KV_THUMBPRINT" ] || [ "$KV_THUMBPRINT" == "null" ]; then + echo "The certificate endpoint returned no thumbprint: $KV_RESPONSE" + exit 1 + fi + + SSL_CERT=$(echo | openssl s_client -connect "$container_ip:8443" 2>/dev/null | openssl x509) + if [ -z "$SSL_CERT" ]; then + echo "Failed to retrieve the TLS certificate served on $container_ip:8443" + exit 1 + fi + + SSL_THUMBPRINT=$(echo "$SSL_CERT" \ + | openssl x509 -fingerprint -noout -sha1 \ + | sed 's/.*=//;s/://g' \ + | tr '[:upper:]' '[:lower:]') + + if [ "$KV_THUMBPRINT" == "$SSL_THUMBPRINT" ]; then + echo "Certificate [$KV_NAME] validated: SSL cert matches Key Vault cert." + else + echo "Certificate mismatch! KV: $KV_THUMBPRINT, SSL: $SSL_THUMBPRINT" + exit 1 + fi + + SSL_SUBJECT=$(echo "$SSL_CERT" \ + | openssl x509 -noout -subject \ + | sed 's/subject=//') + + KV_SUBJECT_DN=$(normalize_dn "$KV_SUBJECT") + SSL_SUBJECT_DN=$(normalize_dn "$SSL_SUBJECT") + if grep -Fq "$KV_SUBJECT_DN" <<<"$SSL_SUBJECT_DN"; then + echo "Certificate subject [$KV_SUBJECT] matches SSL certificate." + else + echo "Certificate subject mismatch! KV: [$KV_SUBJECT_DN], SSL: [$SSL_SUBJECT_DN]" + exit 1 + fi +} + +call_web_app \ No newline at end of file diff --git a/samples/web-app-sql-database/python/terraform/README.md b/samples/web-app-sql-database/python/terraform/README.md index 037c9fd..082ff14 100644 --- a/samples/web-app-sql-database/python/terraform/README.md +++ b/samples/web-app-sql-database/python/terraform/README.md @@ -61,7 +61,7 @@ provider "azurerm" { # 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" + metadata_host="azure.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" diff --git a/samples/web-app-sql-database/python/terraform/deploy.sh b/samples/web-app-sql-database/python/terraform/deploy.sh index fa99296..c64b9e1 100755 --- a/samples/web-app-sql-database/python/terraform/deploy.sh +++ b/samples/web-app-sql-database/python/terraform/deploy.sh @@ -1,13 +1,14 @@ #!/bin/bash # Variables -PREFIX='websql' +PREFIX='local' SUFFIX='test' LOCATION='westeurope' ADMIN_USER='sqladmin' ADMIN_PASSWORD='P@ssw0rd1234!' DATABASE_USER_NAME='testuser' DATABASE_USER_PASSWORD='TestP@ssw0rd123' +SECRET_NAME="${PREFIX}-secret-${SUFFIX}" CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" ZIPFILE="planner_website.zip" DEPLOY_APP=1 @@ -27,7 +28,8 @@ terraform plan -out=tfplan \ -var "administrator_login=$ADMIN_USER" \ -var "administrator_login_password=$ADMIN_PASSWORD" \ -var "sql_database_username=$DATABASE_USER_NAME" \ - -var "sql_database_password=$DATABASE_USER_PASSWORD" + -var "sql_database_password=$DATABASE_USER_PASSWORD" \ + -var "secret_name=$SECRET_NAME" # Apply the Terraform configuration echo "Applying Terraform configuration..." diff --git a/samples/web-app-sql-database/python/terraform/providers.tf b/samples/web-app-sql-database/python/terraform/providers.tf index 25af634..6682178 100644 --- a/samples/web-app-sql-database/python/terraform/providers.tf +++ b/samples/web-app-sql-database/python/terraform/providers.tf @@ -19,7 +19,7 @@ provider "azurerm" { # 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" + metadata_host = "azure.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"