diff --git a/.gitignore b/.gitignore index f204834..0fa40a2 100644 --- a/.gitignore +++ b/.gitignore @@ -183,6 +183,7 @@ celerybeat.pid .env .env.* !.env.example +.last_deploy*.env .venv env/ venv/ diff --git a/README.md b/README.md index 504012c..13f0f78 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ This repository contains comprehensive sample projects demonstrating how to deve | [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 | | [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 | diff --git a/run-samples.sh b/run-samples.sh index e242983..42bfedd 100755 --- a/run-samples.sh +++ b/run-samples.sh @@ -43,6 +43,7 @@ SAMPLES=( "samples/web-app-postgresql-flexible-server/python|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/aci-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" ) # 1a. Define Terraform Samples @@ -59,6 +60,7 @@ TERRAFORM_SAMPLES=( "samples/web-app-mysql-flexible-server/python/terraform|bash deploy.sh" "samples/web-app-postgresql-flexible-server/python/terraform|bash deploy.sh" "samples/aci-blob-storage/python/terraform|bash deploy.sh" + "samples/url-shortener/python/terraform|bash deploy.sh|bash ../scripts/validate.sh" ) # 1b. Define Bicep Samples @@ -75,6 +77,7 @@ BICEP_SAMPLES=( "samples/web-app-mysql-flexible-server/python/bicep|bash deploy.sh" "samples/web-app-postgresql-flexible-server/python/bicep|bash deploy.sh" "samples/aci-blob-storage/python/bicep|bash deploy.sh" + "samples/url-shortener/python/bicep|bash deploy.sh|bash ../scripts/validate.sh" ) # Combine script-based, Terraform, and Bicep samples into one array diff --git a/samples/url-shortener/python/README.md b/samples/url-shortener/python/README.md new file mode 100644 index 0000000..5d6f0bb --- /dev/null +++ b/samples/url-shortener/python/README.md @@ -0,0 +1,160 @@ +# URL Shortener: Web App, Functions, Storage, Key Vault, Service Bus and PostgreSQL + +This sample demonstrates *Linklet*, a Python Flask URL shortener hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with an event-driven worker on [Azure Functions](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview). Unlike the sibling samples, which each exercise one or two services, this sample composes six Azure services into a single causal chain: every shortened link fans out through [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) and [Azure Queue Storage](https://learn.microsoft.com/en-us/azure/storage/queues/storage-queues-introduction) to background workers, and every redirect is logged to an [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview). It is intended as a realistic end-to-end workout for the LocalStack Azure emulator: a regression in any one service breaks an observable user outcome. + +## Architecture + +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 User-Assigned Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview): Shared by the web app and the worker; all Storage and Key Vault data-plane calls are credential-free through it. +3. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview) with three data planes: + - *links* [Table](https://learn.microsoft.com/en-us/azure/storage/tables/table-storage-overview): The link store (code, target URL, hit counter, signature, scan verdict, QR status). + - *qrjobs* [Queue](https://learn.microsoft.com/en-us/azure/storage/queues/storage-queues-introduction): QR-render jobs consumed by the worker's queue trigger. + - *qrcodes* [Blob container](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction) (public read): The rendered QR SVGs. +4. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) (RBAC mode): Holds the HMAC key that signs every short code and the PostgreSQL connection string; the app reads both at runtime through the managed identity. +5. [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): The `clicks` database receives one row per redirect (burstable `Standard_B1ms`, version 16). +6. [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) (Standard): The *link-events* queue carries link-created events to the abuse-scan worker. +7. [Azure Log Analytics Workspace](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/log-analytics-overview): Receives the storage account's transaction metrics through diagnostic settings. +8. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans) (Linux, B1): Hosts both compute components. +9. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): The Flask UI/API. `POST /shorten` writes the link, signs the code with the Key Vault key, sends a Service Bus event and enqueues a QR job; `GET /l/` bumps the hit counter, logs the click to PostgreSQL and redirects. +10. [Azure Function App](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview) (Python v2 model): Two event-driven workers that call back into the web app's token-protected internal API: + - *AbuseScan* ([Service Bus trigger](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus-trigger), identity-based connection): Applies a keyword heuristic and reports a `clean`/`flagged` verdict. + - *QrGenerator* ([Queue Storage trigger](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue-trigger)): Requests the QR SVG render for the short link. + +```mermaid +flowchart LR + user((User)) + + subgraph webapp["Web App (Flask)"] + shorten["POST /shorten"] + follow["GET /l/{code}"] + internal["POST /internal/*
(token-protected)"] + end + + subgraph functions["Function App (worker)"] + abuse["AbuseScan
(Service Bus trigger)"] + qrgen["QrGenerator
(queue trigger)"] + end + + subgraph storage["Storage Account"] + links[("links table")] + qrjobs[["qrjobs queue"]] + qrcodes[("qrcodes container
public read")] + end + + kv["Key Vault
link-sign-key, pg-conn"] + sb[["Service Bus
link-events queue"]] + pg[("PostgreSQL
clicks db")] + logs["Log Analytics"] + + user -->|"1: shorten URL"| shorten + shorten -.->|"read sign key"| kv + shorten -->|"write link + sig"| links + shorten -->|"link-created event"| sb + shorten -->|"QR job"| qrjobs + + sb -->|"trigger"| abuse + qrjobs -->|"trigger"| qrgen + abuse -->|"verdict"| internal + qrgen -->|"render request"| internal + internal -->|"scan / qr status"| links + internal -->|"QR SVG"| qrcodes + + user -->|"2: follow short link"| follow + follow -->|"hit counter"| links + follow -.->|"read pg-conn"| kv + follow -->|"click row"| pg + + user -.->|"3: fetch QR"| qrcodes + storage -.->|"transaction metrics"| logs +``` + +The flow of a single link: `POST /shorten` → Table Storage + Key Vault + Service Bus + Queue Storage → workers → internal API → Table Storage + Blob Storage → `GET /l/` → PostgreSQL + 302 redirect. The home page renders the link table with hit counts, signatures, scan verdicts and QR links. + +## Prerequisites + +- [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/) +- [jq](https://jqlang.org/) and `zip` +- [psql](https://www.postgresql.org/docs/current/app-psql.html) (PostgreSQL client, used by the validation script) +- [Terraform](https://developer.hashicorp.com/terraform/downloads) (for the Terraform deployment) +- [Bicep](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install) (for the Bicep deployment) +- A LocalStack account with a valid `LOCALSTACK_AUTH_TOKEN` (see the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/)) + +## Setup + +Start the LocalStack Azure emulator and route the Azure CLI to it: + +```bash +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 +lstk az start-interception +az login --service-principal -u any-app -p any-pass --tenant any-tenant +``` + +## Deployment + +### Azure CLI scripts + +```bash +bash scripts/deploy.sh +``` + +The script provisions all resources idempotently, deploys both applications from zip packages and persists the generated PostgreSQL credentials to `scripts/.last_deploy.env` for the validation script. + +### Terraform + +```bash +cd terraform +bash deploy.sh +``` + +The Terraform variant provisions the same resources declaratively and then performs the two zip deployments with the Azure CLI. It also persists the generated PostgreSQL credentials to `scripts/.last_deploy.env` for the validation script. + +### Bicep + +```bash +cd bicep +bash deploy.sh +``` + +The Bicep variant validates and deploys `main.bicep` into the resource group and then performs the two zip deployments with the Azure CLI. It also persists the generated PostgreSQL credentials to `scripts/.last_deploy.env` for the validation script. + +## Testing + +```bash +bash scripts/validate.sh +bash scripts/call-web-app.sh +``` + +`validate.sh` walks the full causal chain: web app health (Table Storage + Key Vault + PostgreSQL through the managed identity), shortening a benign and a suspicious URL, the AbuseScan verdicts arriving via the Service Bus trigger, the QR SVG rendered via the queue trigger and served from the public blob container, redirects logging click rows into PostgreSQL, hit counting in Table Storage, and the Log Analytics wiring. `call-web-app.sh` performs a quick user-level smoke test (home page, shorten, redirect). + +You can also open the web app in a browser — the URL is printed at the end of the deploy script. + +## Cleanup + +```bash +az group delete --name local-rg --yes +``` + +## LocalStack notes + +- The web app talks to Service Bus through the namespace connection string rather than a managed-identity connection: the Python Service Bus SDK enforces TLS verification and the emulator's certificate does not cover `*.servicebus.windows.net`. The connection string's endpoint is certificate-valid on both the emulator and real Azure. +- The worker's queue trigger uses a dedicated connection string with explicit `BlobEndpoint`/`QueueEndpoint`/`TableEndpoint` entries because strict .NET storage clients cannot parse an `EndpointSuffix` that carries the emulator's port. +- The PostgreSQL flexible-server emulator embeds its TCP-proxy port in the server's `fullyQualifiedDomainName`; both deployment variants split host and port so the same configuration works against real Azure (bare host, port 5432). +- The worker calls the web app's internal API over plain `http://` (`WEB_BASE_URL`), and the apps are deployed without HTTPS-only: the emulator serves `*.azurewebsites.net` with a certificate that does not cover that domain, so an `https://` call from the worker would fail TLS verification. On real Azure, switch `WEB_BASE_URL` to `https://` and enable HTTPS-only on both apps so the internal token never crosses the wire unencrypted. +- On real Azure, additionally enable *Always On* for the function app (dedicated plans idle otherwise and non-HTTP triggers stop firing), deploy the function app through a build-enabled path (Oryx remote build or a vendored `.python_packages` layout), and tighten the sample's allow-all PostgreSQL firewall rule to your own address ranges. + +## References + +- [Azure Web Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [Azure Functions Documentation](https://learn.microsoft.com/en-us/azure/azure-functions/) +- [Azure Service Bus Documentation](https://learn.microsoft.com/en-us/azure/service-bus-messaging/) +- [Azure Storage Documentation](https://learn.microsoft.com/en-us/azure/storage/) +- [Azure Key Vault Documentation](https://learn.microsoft.com/en-us/azure/key-vault/) +- [Azure Database for PostgreSQL — flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/) +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) diff --git a/samples/url-shortener/python/bicep/README.md b/samples/url-shortener/python/bicep/README.md new file mode 100644 index 0000000..89d27b0 --- /dev/null +++ b/samples/url-shortener/python/bicep/README.md @@ -0,0 +1,25 @@ +# Bicep Deployment + +This directory contains the Bicep template for the sample. For details about the sample application, see [URL Shortener](../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) with [Bicep](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [jq](https://jqlang.org/), `zip` and `openssl` + +## Deployment + +```bash +bash deploy.sh +``` + +The script creates the resource group, validates and deploys `main.bicep` (generating the PostgreSQL password, the link-signing key and the internal API token per run), and then deploys the web app and the worker from zip packages with the Azure CLI. It persists the generated PostgreSQL credentials to `../scripts/.last_deploy.env` so `scripts/validate.sh` works after a Bicep deployment. + +## Cleanup + +```bash +az group delete --name local-rg --yes +``` diff --git a/samples/url-shortener/python/bicep/deploy.sh b/samples/url-shortener/python/bicep/deploy.sh new file mode 100755 index 0000000..e8b9132 --- /dev/null +++ b/samples/url-shortener/python/bicep/deploy.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +DEPLOYMENT_NAME='url-shortener' +TEMPLATE="main.bicep" +PARAMETERS="main.bicepparam" +WEBAPP_ZIPFILE='linklet_webapp.zip' +WORKER_ZIPFILE='linklet_worker.zip' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Get the current subscription +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) + +# Check if the resource group already exists +echo "Checking if [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription..." +az group show --name $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription" + echo "Creating [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription..." + + az group create --name $RESOURCE_GROUP_NAME --location "$LOCATION" 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$RESOURCE_GROUP_NAME] resource group successfully created in the [$SUBSCRIPTION_NAME] subscription" + else + echo "Failed to create [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription" + exit 1 + fi +else + echo "[$RESOURCE_GROUP_NAME] resource group already exists in the [$SUBSCRIPTION_NAME] subscription" +fi + +# Generate the deployment secrets; main.bicepparam reads them from the environment +export POSTGRES_ADMIN_PASSWORD=$(openssl rand -hex 10) +export SIGN_KEY=$(openssl rand -hex 16) +export INTERNAL_TOKEN=$(openssl rand -hex 12) + +# Validate the Bicep template +echo "Validating the [$TEMPLATE] Bicep template..." +az deployment group validate \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $TEMPLATE \ + --parameters $PARAMETERS \ + --output none + +if [[ $? == 0 ]]; then + echo "[$TEMPLATE] Bicep template successfully validated" +else + echo "Failed to validate the [$TEMPLATE] Bicep template" + exit 1 +fi + +# Deploy the Bicep template +echo "Deploying the [$TEMPLATE] Bicep template..." +DEPLOYMENT_OUTPUTS=$(az deployment group create \ + --name $DEPLOYMENT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $TEMPLATE \ + --parameters $PARAMETERS \ + --query properties.outputs) + +if [[ $? == 0 ]]; then + echo "[$TEMPLATE] Bicep template successfully deployed" +else + echo "Failed to deploy the [$TEMPLATE] Bicep template" + exit 1 +fi + +WEB_APP_NAME=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .webAppName.value) +FUNCTION_APP_NAME=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .functionAppName.value) +WEB_APP_HOSTNAME=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .webAppHostName.value) +POSTGRES_HOST=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .postgresHost.value) +POSTGRES_PORT=$(echo "$DEPLOYMENT_OUTPUTS" | jq -r .postgresPort.value) + +if [[ -z "$WEB_APP_NAME" || -z "$FUNCTION_APP_NAME" ]]; then + echo "Web App Name or Function App Name is empty. Exiting." + exit 1 +fi + +# Persist the PostgreSQL credentials for scripts/validate.sh, matching the +# contract of scripts/deploy.sh. +cat >"$CURRENT_DIR/../scripts/.last_deploy.env" </dev/null + +if [[ $? == 0 ]]; then + echo "Web app [$WEB_APP_NAME] deployed successfully" +else + echo "Failed to deploy web app [$WEB_APP_NAME]" + exit 1 +fi +rm -f "$WEBAPP_ZIPFILE" + +# Create the zip package of the function app +cd "$CURRENT_DIR/../function" || exit +if [ -f "$WORKER_ZIPFILE" ]; then + rm "$WORKER_ZIPFILE" +fi +echo "Creating zip package of the function app..." +zip -r "$WORKER_ZIPFILE" function_app.py host.json requirements.txt + +# Deploy the function app +echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$WORKER_ZIPFILE]..." +az functionapp deploy \ + --resource-group $RESOURCE_GROUP_NAME \ + --name "$FUNCTION_APP_NAME" \ + --src-path "$WORKER_ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [[ $? == 0 ]]; then + echo "Function app [$FUNCTION_APP_NAME] deployed successfully" +else + echo "Failed to deploy function app [$FUNCTION_APP_NAME]" + exit 1 +fi +rm -f "$WORKER_ZIPFILE" + +echo "Deployment completed. Web app available at: http://$WEB_APP_HOSTNAME" diff --git a/samples/url-shortener/python/bicep/main.bicep b/samples/url-shortener/python/bicep/main.bicep new file mode 100644 index 0000000..30d1546 --- /dev/null +++ b/samples/url-shortener/python/bicep/main.bicep @@ -0,0 +1,442 @@ +//******************************************** +// Parameters +//******************************************** + +@description('Prefix applied to every resource name in this sample.') +param prefix string = 'local' + +@description('Suffix applied to every resource name in this sample.') +param suffix string = 'test' + +@description('Azure region for all resources.') +param location string = resourceGroup().location + +@description('Runtime of the web app and the worker.') +param runtimeName string = 'python' + +@description('Runtime version of the web app.') +param webRuntimeVersion string = '3.13' + +@description('Runtime version of the worker function app.') +param functionRuntimeVersion string = '3.11' + +@description('Administrator login of the PostgreSQL flexible server.') +param postgresAdminLogin string = 'linkletadmin' + +@description('Administrator password of the PostgreSQL flexible server.') +@secure() +param postgresAdminPassword string + +@description('HMAC key used by the web app to sign short codes; stored in Key Vault.') +@secure() +param signKey string + +@description('Shared token protecting the web app internal API called by the worker.') +@secure() +param internalToken string + +//******************************************** +// Variables +//******************************************** + +var storageAccountName = '${prefix}urlshortstorage${suffix}' +var keyVaultName = '${prefix}-urlshort-kv-${suffix}' +var postgresServerName = '${prefix}-urlshort-pgflex-${suffix}' +var servicebusNamespaceName = '${prefix}-urlshort-sb-ns-${suffix}' +var logAnalyticsName = '${prefix}-urlshort-log-analytics-${suffix}' +var appServicePlanName = '${prefix}-urlshort-app-service-plan-${suffix}' +var webAppName = '${prefix}-urlshort-webapp-${suffix}' +var functionAppName = '${prefix}-urlshort-functionapp-${suffix}' +var managedIdentityName = '${prefix}-urlshort-identity-${suffix}' +var linksTableName = 'links' +var qrJobsQueueName = 'qrjobs' +var qrContainerName = 'qrcodes' +var servicebusQueueName = 'link-events' +var postgresDatabaseName = 'clicks' + +//******************************************** +// Built-in role definitions +//******************************************** + +resource storageTableDataContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3' + scope: subscription() +} + +resource storageQueueDataContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '974c5e8b-45b9-4653-ba55-5f855dd0fb88' + scope: subscription() +} + +resource storageBlobDataContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + scope: subscription() +} + +resource keyVaultSecretsUserRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '4633458b-17de-408a-b874-0445c86b69e6' + scope: subscription() +} + +resource serviceBusDataSenderRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39' + scope: subscription() +} + +resource serviceBusDataReceiverRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0' + scope: subscription() +} + +//******************************************** +// Identity +//******************************************** + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: managedIdentityName + location: location +} + +//******************************************** +// Storage: table (links store), queue (QR render jobs), blob (QR images) +//******************************************** + +resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = { + name: storageAccountName + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + // The qrcodes container uses publicAccess 'Blob'; new storage accounts + // disallow anonymous access unless the account explicitly allows it. + allowBlobPublicAccess: true + } +} + +resource tableService 'Microsoft.Storage/storageAccounts/tableServices@2025-01-01' = { + parent: storageAccount + name: 'default' +} + +resource linksTable 'Microsoft.Storage/storageAccounts/tableServices/tables@2025-01-01' = { + parent: tableService + name: linksTableName +} + +resource queueService 'Microsoft.Storage/storageAccounts/queueServices@2025-01-01' = { + parent: storageAccount + name: 'default' +} + +resource qrJobsQueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2025-01-01' = { + parent: queueService + name: qrJobsQueueName +} + +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2025-01-01' = { + parent: storageAccount + name: 'default' +} + +resource qrContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-01-01' = { + parent: blobService + name: qrContainerName + properties: { + publicAccess: 'Blob' + } +} + +resource storageTableRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, managedIdentity.id, storageTableDataContributorRoleDefinition.id) + scope: storageAccount + properties: { + roleDefinitionId: storageTableDataContributorRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource storageQueueRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, managedIdentity.id, storageQueueDataContributorRoleDefinition.id) + scope: storageAccount + properties: { + roleDefinitionId: storageQueueDataContributorRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource storageBlobRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, managedIdentity.id, storageBlobDataContributorRoleDefinition.id) + scope: storageAccount + properties: { + roleDefinitionId: storageBlobDataContributorRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +//******************************************** +// Key Vault: link-signing key + PostgreSQL connection string +//******************************************** + +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: keyVaultName + location: location + properties: { + sku: { + family: 'A' + name: 'standard' + } + tenantId: subscription().tenantId + enableRbacAuthorization: true + accessPolicies: [] + } +} + +resource keyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, managedIdentity.id, keyVaultSecretsUserRoleDefinition.id) + scope: keyVault + properties: { + roleDefinitionId: keyVaultSecretsUserRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource signKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { + parent: keyVault + name: 'link-sign-key' + properties: { + value: signKey + } +} + +//******************************************** +// PostgreSQL flexible server: click-event log +//******************************************** + +resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { + name: toLower(postgresServerName) + location: location + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + properties: { + administratorLogin: postgresAdminLogin + administratorLoginPassword: postgresAdminPassword + version: '16' + createMode: 'Default' + storage: { + storageSizeGB: 32 + } + } +} + +resource postgresDatabase 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = { + parent: postgresServer + name: postgresDatabaseName +} + +resource postgresFirewallRule 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2024-08-01' = { + parent: postgresServer + name: 'AllowAllIPs' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '255.255.255.255' + } +} + +// The PostgreSQL flexible-server emulator embeds the LS-side TCP-proxy port directly in +// fullyQualifiedDomainName; real Azure returns just the bare host on 5432. +var postgresFqdnParts = split(postgresServer.properties.fullyQualifiedDomainName, ':') +var postgresHost = postgresFqdnParts[0] +var postgresPort = length(postgresFqdnParts) > 1 ? postgresFqdnParts[1] : '5432' + +resource postgresConnectionSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { + parent: keyVault + name: 'pg-conn' + properties: { + value: 'host=${postgresHost} port=${postgresPort} dbname=${postgresDatabaseName} user=${postgresAdminLogin} password=${postgresAdminPassword}' + } + dependsOn: [ + postgresDatabase + ] +} + +//******************************************** +// Service Bus: link-created events consumed by the abuse-scan worker +//******************************************** + +resource servicebusNamespace 'Microsoft.ServiceBus/namespaces@2024-01-01' = { + name: servicebusNamespaceName + location: location + sku: { + name: 'Standard' + } +} + +resource linkEventsQueue 'Microsoft.ServiceBus/namespaces/queues@2024-01-01' = { + parent: servicebusNamespace + name: servicebusQueueName +} + +resource serviceBusSenderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(servicebusNamespace.id, managedIdentity.id, serviceBusDataSenderRoleDefinition.id) + scope: servicebusNamespace + properties: { + roleDefinitionId: serviceBusDataSenderRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource serviceBusReceiverRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(servicebusNamespace.id, managedIdentity.id, serviceBusDataReceiverRoleDefinition.id) + scope: servicebusNamespace + properties: { + roleDefinitionId: serviceBusDataReceiverRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +//******************************************** +// Observability: Log Analytics workspace +//******************************************** + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: logAnalyticsName + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } +} + +resource storageDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: 'default' + scope: storageAccount + properties: { + workspaceId: logAnalyticsWorkspace.id + metrics: [ + { + category: 'Transaction' + enabled: true + } + ] + } +} + +//******************************************** +// Compute: one Linux plan hosting both the web app and the worker +//******************************************** + +resource appServicePlan 'Microsoft.Web/serverfarms@2024-11-01' = { + name: appServicePlanName + location: location + kind: 'linux' + sku: { + name: 'B1' + } + properties: { + reserved: true + } +} + +resource servicebusRootAuthorizationRule 'Microsoft.ServiceBus/namespaces/authorizationRules@2024-01-01' existing = { + parent: servicebusNamespace + name: 'RootManageSharedAccessKey' +} + +var servicebusConnectionString = servicebusRootAuthorizationRule.listKeys().primaryConnectionString +var storageAccountKey = storageAccount.listKeys().keys[0].value +var storageEndpoints = storageAccount.properties.primaryEndpoints + +resource webApp 'Microsoft.Web/sites@2024-11-01' = { + name: webAppName + location: location + kind: 'app,linux' + properties: { + httpsOnly: false + reserved: true + serverFarmId: appServicePlan.id + siteConfig: { + linuxFxVersion: toUpper('${runtimeName}|${webRuntimeVersion}') + appSettings: [ + { name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'true' } + { name: 'AZURE_CLIENT_ID', value: managedIdentity.properties.clientId } + { name: 'AZURE_TABLES_ENDPOINT', value: storageEndpoints.table } + { name: 'LINKS_TABLE', value: linksTableName } + { name: 'KEYVAULT_URL', value: keyVault.properties.vaultUri } + { name: 'QUEUE_ENDPOINT', value: storageEndpoints.queue } + { name: 'QR_JOBS_QUEUE', value: qrJobsQueueName } + { name: 'BLOB_ENDPOINT', value: storageEndpoints.blob } + { name: 'QR_CONTAINER', value: qrContainerName } + { name: 'SB_CONN', value: servicebusConnectionString } + { name: 'SB_QUEUE', value: servicebusQueueName } + { name: 'PG_SECRET_NAME', value: 'pg-conn' } + { name: 'INTERNAL_TOKEN', value: internalToken } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentity.id}': {} + } + } +} + +resource functionApp 'Microsoft.Web/sites@2024-11-01' = { + name: functionAppName + location: location + kind: 'functionapp,linux' + properties: { + httpsOnly: false + reserved: true + serverFarmId: appServicePlan.id + siteConfig: { + linuxFxVersion: toUpper('${runtimeName}|${functionRuntimeVersion}') + appSettings: [ + { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' } + { name: 'FUNCTIONS_WORKER_RUNTIME', value: runtimeName } + { name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'true' } + { name: 'ENABLE_ORYX_BUILD', value: 'true' } + { name: 'AzureWebJobsStorage', value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${storageAccountKey};BlobEndpoint=${storageEndpoints.blob};QueueEndpoint=${storageEndpoints.queue};TableEndpoint=${storageEndpoints.table}' } + { name: 'AZURE_CLIENT_ID', value: managedIdentity.properties.clientId } + { name: 'ServiceBusConnection__fullyQualifiedNamespace', value: '${servicebusNamespaceName}.servicebus.windows.net' } + { name: 'ServiceBusConnection__clientId', value: managedIdentity.properties.clientId } + { name: 'ServiceBusConnection__credential', value: 'managedidentity' } + { name: 'QrStorage', value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};AccountKey=${storageAccountKey};BlobEndpoint=${storageEndpoints.blob};QueueEndpoint=${storageEndpoints.queue};TableEndpoint=${storageEndpoints.table}' } + { name: 'WEB_BASE_URL', value: 'http://${webApp.properties.defaultHostName}' } + { name: 'INTERNAL_TOKEN', value: internalToken } + ] + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentity.id}': {} + } + } +} + +//******************************************** +// Outputs +//******************************************** + +output resourceGroupName string = resourceGroup().name +output storageAccountName string = storageAccount.name +output keyVaultName string = keyVault.name +output logAnalyticsWorkspaceName string = logAnalyticsWorkspace.name +output webAppName string = webApp.name +output functionAppName string = functionApp.name +output webAppHostName string = webApp.properties.defaultHostName +output postgresHost string = postgresHost +output postgresPort string = postgresPort diff --git a/samples/url-shortener/python/bicep/main.bicepparam b/samples/url-shortener/python/bicep/main.bicepparam new file mode 100644 index 0000000..1541d9d --- /dev/null +++ b/samples/url-shortener/python/bicep/main.bicepparam @@ -0,0 +1,12 @@ +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'python' +param webRuntimeVersion = '3.13' +param functionRuntimeVersion = '3.11' + +// Secrets are generated per run by deploy.sh and passed via the environment. +param postgresAdminPassword = readEnvironmentVariable('POSTGRES_ADMIN_PASSWORD') +param signKey = readEnvironmentVariable('SIGN_KEY') +param internalToken = readEnvironmentVariable('INTERNAL_TOKEN') diff --git a/samples/url-shortener/python/function/function_app.py b/samples/url-shortener/python/function/function_app.py new file mode 100644 index 0000000..41edb6e --- /dev/null +++ b/samples/url-shortener/python/function/function_app.py @@ -0,0 +1,45 @@ +import json +import logging +import os +import urllib.request + +import azure.functions as func + +app = func.FunctionApp() + +SUSPICIOUS = ("phishing", "malware", "casino", "ransom") + + +def call_web(path, payload): + """POST to the web app's internal API (shared-token auth).""" + url = f"{os.environ['WEB_BASE_URL'].rstrip('/')}{path}" + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "X-Internal-Token": os.environ["INTERNAL_TOKEN"], + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status + + +@app.function_name(name="AbuseScan") +@app.service_bus_queue_trigger( + arg_name="msg", queue_name="link-events", connection="ServiceBusConnection" +) +def abuse_scan(msg: func.ServiceBusMessage): + body = json.loads(msg.get_body().decode("utf-8")) + verdict = "flagged" if any(w in body["url"].lower() for w in SUSPICIOUS) else "clean" + status = call_web("/internal/scan-result", {"code": body["code"], "verdict": verdict}) + logging.info("AbuseScan: %s -> %s (web %s)", body["code"], verdict, status) + + +@app.function_name(name="QrGenerator") +@app.queue_trigger(arg_name="qmsg", queue_name="qrjobs", connection="QrStorage") +def qr_generator(qmsg: func.QueueMessage): + body = json.loads(qmsg.get_body().decode("utf-8")) + status = call_web("/internal/generate-qr", {"code": body["code"]}) + logging.info("QrGenerator: %s (web %s)", body["code"], status) diff --git a/samples/url-shortener/python/function/host.json b/samples/url-shortener/python/function/host.json new file mode 100644 index 0000000..b7e5ad1 --- /dev/null +++ b/samples/url-shortener/python/function/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/samples/url-shortener/python/function/requirements.txt b/samples/url-shortener/python/function/requirements.txt new file mode 100644 index 0000000..75db2c4 --- /dev/null +++ b/samples/url-shortener/python/function/requirements.txt @@ -0,0 +1 @@ +azure-functions diff --git a/samples/url-shortener/python/scripts/README.md b/samples/url-shortener/python/scripts/README.md new file mode 100644 index 0000000..e731c6a --- /dev/null +++ b/samples/url-shortener/python/scripts/README.md @@ -0,0 +1,19 @@ +# 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 [URL Shortener](../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/) +- [jq](https://jqlang.org/), `zip`, `openssl` and [psql](https://www.postgresql.org/docs/current/app-psql.html) + +## Scripts + +| Script | Purpose | +|--------|---------| +| `deploy.sh` | Idempotently provisions all resources and deploys the web app and the worker from zip packages. Persists the generated PostgreSQL credentials to `.last_deploy.env`. | +| `validate.sh` | Walks the full causal chain end to end and exits non-zero on any failure. | +| `call-web-app.sh` | Quick user-level smoke test: home page, shorten a URL, follow the redirect. | diff --git a/samples/url-shortener/python/scripts/call-web-app.sh b/samples/url-shortener/python/scripts/call-web-app.sh new file mode 100755 index 0000000..3e36745 --- /dev/null +++ b/samples/url-shortener/python/scripts/call-web-app.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +WEB_APP_NAME="${PREFIX}-urlshort-webapp-${SUFFIX}" + +# Retrieve the web app host name +echo "Retrieving the host name 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) + +if [[ -n "$WEB_APP_HOSTNAME" ]]; then + echo "Host name [$WEB_APP_HOSTNAME] of the [$WEB_APP_NAME] web app successfully retrieved" +else + echo "Failed to retrieve the host name of the [$WEB_APP_NAME] web app" + exit 1 +fi +WEB_APP_URL="http://$WEB_APP_HOSTNAME" + +# Call the home page +echo "Calling the [$WEB_APP_NAME] web app home page at [$WEB_APP_URL]..." +HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$WEB_APP_URL/") + +if [[ "$HTTP_STATUS" == "200" ]]; then + echo "Home page of the [$WEB_APP_NAME] web app successfully returned [$HTTP_STATUS]" +else + echo "Home page of the [$WEB_APP_NAME] web app returned [$HTTP_STATUS]" + exit 1 +fi + +# Shorten a URL and follow the redirect +echo "Shortening a URL through the [$WEB_APP_NAME] web app..." +RESPONSE=$(curl -s -X POST "$WEB_APP_URL/shorten" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://docs.localstack.cloud/azure/"}') +CODE=$(echo "$RESPONSE" | jq -r .code) + +if [[ -n "$CODE" && "$CODE" != "null" ]]; then + echo "Short link [$CODE] successfully created" +else + echo "Failed to create a short link" + exit 1 +fi + +echo "Following the short link [$WEB_APP_URL/l/$CODE]..." +REDIRECT_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$WEB_APP_URL/l/$CODE") + +if [[ "$REDIRECT_STATUS" == "302" ]]; then + echo "Short link [$CODE] successfully redirected with [$REDIRECT_STATUS]" +else + # Emulator releases without the fix from localstack/localstack-pro#8135 follow the + # redirect at the gateway instead of passing the 302 through; accept that as long + # as the click was recorded on the link. + # TODO: drop this fallback and assert the bare 302 unconditionally once the fix + # from localstack/localstack-pro#8135 ships in the released emulator image. + HITS=$(curl -s "$WEB_APP_URL/api/links/$CODE" | jq -r .hits) + if [[ "$HITS" =~ ^[0-9]+$ && "$HITS" -ge 1 ]]; then + echo "Short link [$CODE] returned [$REDIRECT_STATUS] (redirect followed by the emulator gateway) and the click was recorded (hits=$HITS)" + else + echo "Short link [$CODE] returned [$REDIRECT_STATUS] instead of a redirect and no click was recorded" + exit 1 + fi +fi diff --git a/samples/url-shortener/python/scripts/deploy.sh b/samples/url-shortener/python/scripts/deploy.sh new file mode 100755 index 0000000..36b2045 --- /dev/null +++ b/samples/url-shortener/python/scripts/deploy.sh @@ -0,0 +1,691 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +STORAGE_ACCOUNT_NAME="${PREFIX}urlshortstorage${SUFFIX}" +LINKS_TABLE_NAME='links' +QR_JOBS_QUEUE_NAME='qrjobs' +QR_CONTAINER_NAME='qrcodes' +MANAGED_IDENTITY_NAME="${PREFIX}-urlshort-identity-${SUFFIX}" +KEY_VAULT_NAME="${PREFIX}-urlshort-kv-${SUFFIX}" +SIGN_KEY_SECRET_NAME='link-sign-key' +PG_CONN_SECRET_NAME='pg-conn' +POSTGRES_SERVER_NAME="${PREFIX}-urlshort-pgflex-${SUFFIX}" +POSTGRES_ADMIN_USER='linkletadmin' +POSTGRES_DB_NAME='clicks' +SERVICEBUS_NAMESPACE_NAME="${PREFIX}-urlshort-sb-ns-${SUFFIX}" +SERVICEBUS_QUEUE_NAME='link-events' +LOG_ANALYTICS_NAME="${PREFIX}-urlshort-log-analytics-${SUFFIX}" +APP_SERVICE_PLAN_NAME="${PREFIX}-urlshort-app-service-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU='B1' +WEB_APP_NAME="${PREFIX}-urlshort-webapp-${SUFFIX}" +FUNCTION_APP_NAME="${PREFIX}-urlshort-functionapp-${SUFFIX}" +RUNTIME='python' +WEB_APP_RUNTIME_VERSION='3.13' +FUNCTIONS_VERSION='4' +WEBAPP_ZIPFILE='linklet_webapp.zip' +WORKER_ZIPFILE='linklet_worker.zip' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +STATE_FILE="$CURRENT_DIR/.last_deploy.env" + +# Get the current subscription +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) + +# Check if the resource group already exists +echo "Checking if [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription..." +az group show --name $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription" + echo "Creating [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription..." + + az group create --name $RESOURCE_GROUP_NAME --location "$LOCATION" 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$RESOURCE_GROUP_NAME] resource group successfully created in the [$SUBSCRIPTION_NAME] subscription" + else + echo "Failed to create [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription" + exit 1 + fi +else + echo "[$RESOURCE_GROUP_NAME] resource group already exists in the [$SUBSCRIPTION_NAME] subscription" +fi + +# Check if the storage account already exists +echo "Checking if [$STORAGE_ACCOUNT_NAME] storage account actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az storage account show --name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$STORAGE_ACCOUNT_NAME] storage account actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$STORAGE_ACCOUNT_NAME] storage account in the [$RESOURCE_GROUP_NAME] resource group..." + + # The qrcodes container uses public 'blob' access; new storage accounts + # disallow anonymous access unless the account explicitly allows it. + az storage account create \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location "$LOCATION" \ + --sku Standard_LRS \ + --allow-blob-public-access true 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$STORAGE_ACCOUNT_NAME] storage account successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$STORAGE_ACCOUNT_NAME] storage account in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$STORAGE_ACCOUNT_NAME] storage account already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Retrieve the storage account connection string for the data-plane bootstrap +echo "Retrieving the connection string for the [$STORAGE_ACCOUNT_NAME] storage account..." +STORAGE_CONNECTION_STRING=$(az storage account show-connection-string \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query connectionString \ + --output tsv) + +if [[ -n "$STORAGE_CONNECTION_STRING" ]]; then + echo "Connection string for the [$STORAGE_ACCOUNT_NAME] storage account successfully retrieved" +else + echo "Failed to retrieve the connection string for the [$STORAGE_ACCOUNT_NAME] storage account" + exit 1 +fi + +# Create the links table +echo "Creating [$LINKS_TABLE_NAME] table in the [$STORAGE_ACCOUNT_NAME] storage account..." +az storage table create \ + --name $LINKS_TABLE_NAME \ + --connection-string "$STORAGE_CONNECTION_STRING" 1>/dev/null + +if [[ $? == 0 ]]; then + echo "[$LINKS_TABLE_NAME] table successfully created in the [$STORAGE_ACCOUNT_NAME] storage account" +else + echo "Failed to create [$LINKS_TABLE_NAME] table in the [$STORAGE_ACCOUNT_NAME] storage account" + exit 1 +fi + +# Create the QR jobs queue +echo "Creating [$QR_JOBS_QUEUE_NAME] queue in the [$STORAGE_ACCOUNT_NAME] storage account..." +az storage queue create \ + --name $QR_JOBS_QUEUE_NAME \ + --connection-string "$STORAGE_CONNECTION_STRING" 1>/dev/null + +if [[ $? == 0 ]]; then + echo "[$QR_JOBS_QUEUE_NAME] queue successfully created in the [$STORAGE_ACCOUNT_NAME] storage account" +else + echo "Failed to create [$QR_JOBS_QUEUE_NAME] queue in the [$STORAGE_ACCOUNT_NAME] storage account" + exit 1 +fi + +# Create the QR codes blob container with public blob access +echo "Creating [$QR_CONTAINER_NAME] container in the [$STORAGE_ACCOUNT_NAME] storage account..." +az storage container create \ + --name $QR_CONTAINER_NAME \ + --public-access blob \ + --connection-string "$STORAGE_CONNECTION_STRING" 1>/dev/null + +if [[ $? == 0 ]]; then + echo "[$QR_CONTAINER_NAME] container successfully created in the [$STORAGE_ACCOUNT_NAME] storage account" +else + echo "Failed to create [$QR_CONTAINER_NAME] container in the [$STORAGE_ACCOUNT_NAME] storage account" + exit 1 +fi + +# Check if the user-assigned managed identity already exists +echo "Checking if [$MANAGED_IDENTITY_NAME] 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] managed identity actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$MANAGED_IDENTITY_NAME] managed identity in the [$RESOURCE_GROUP_NAME] resource group..." + + az identity create \ + --name $MANAGED_IDENTITY_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location "$LOCATION" 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$MANAGED_IDENTITY_NAME] managed identity successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$MANAGED_IDENTITY_NAME] managed identity in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$MANAGED_IDENTITY_NAME] managed identity already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Retrieve the identity ids +IDENTITY_ID=$(az identity show --name $MANAGED_IDENTITY_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv) +IDENTITY_CLIENT_ID=$(az identity show --name $MANAGED_IDENTITY_NAME --resource-group $RESOURCE_GROUP_NAME --query clientId --output tsv) +IDENTITY_PRINCIPAL_ID=$(az identity show --name $MANAGED_IDENTITY_NAME --resource-group $RESOURCE_GROUP_NAME --query principalId --output tsv) +STORAGE_ACCOUNT_ID=$(az storage account show --name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv) + +# Assign the storage data-plane roles to the managed identity +for ROLE in "Storage Table Data Contributor" "Storage Queue Data Contributor" "Storage Blob Data Contributor"; do + echo "Assigning [$ROLE] role to the [$MANAGED_IDENTITY_NAME] managed identity on the [$STORAGE_ACCOUNT_NAME] storage account..." + # Skip when already assigned: az role assignment create is not idempotent + # and re-runs fail with RoleAssignmentExists (Azure/azure-cli#31995). + EXISTING_ASSIGNMENT=$(az role assignment list \ + --role "$ROLE" \ + --assignee "$IDENTITY_PRINCIPAL_ID" \ + --scope "$STORAGE_ACCOUNT_ID" \ + --query "[0].id" \ + --output tsv) + if [[ -z "$EXISTING_ASSIGNMENT" ]]; then + az role assignment create \ + --role "$ROLE" \ + --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \ + --assignee-principal-type ServicePrincipal \ + --scope "$STORAGE_ACCOUNT_ID" 1>/dev/null + fi + + if [[ $? == 0 ]]; then + echo "[$ROLE] role successfully assigned to the [$MANAGED_IDENTITY_NAME] managed identity" + else + echo "Failed to assign [$ROLE] role to the [$MANAGED_IDENTITY_NAME] managed identity" + exit 1 + fi +done + +# Check if the key vault already exists +echo "Checking if [$KEY_VAULT_NAME] key vault actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az keyvault show --name $KEY_VAULT_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$KEY_VAULT_NAME] key vault actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$KEY_VAULT_NAME] key vault in the [$RESOURCE_GROUP_NAME] resource group..." + + az keyvault create \ + --name $KEY_VAULT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location "$LOCATION" \ + --enable-rbac-authorization true 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$KEY_VAULT_NAME] key vault successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$KEY_VAULT_NAME] key vault in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$KEY_VAULT_NAME] key vault already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Assign the Key Vault Secrets User role to the managed identity +KEY_VAULT_ID=$(az keyvault show --name $KEY_VAULT_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv) +echo "Assigning [Key Vault Secrets User] role to the [$MANAGED_IDENTITY_NAME] managed identity on the [$KEY_VAULT_NAME] key vault..." +# Skip when already assigned: az role assignment create is not idempotent +# and re-runs fail with RoleAssignmentExists (Azure/azure-cli#31995). +EXISTING_ASSIGNMENT=$(az role assignment list \ + --role "Key Vault Secrets User" \ + --assignee "$IDENTITY_PRINCIPAL_ID" \ + --scope "$KEY_VAULT_ID" \ + --query "[0].id" \ + --output tsv) +if [[ -z "$EXISTING_ASSIGNMENT" ]]; then + az role assignment create \ + --role "Key Vault Secrets User" \ + --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \ + --assignee-principal-type ServicePrincipal \ + --scope "$KEY_VAULT_ID" 1>/dev/null +fi + +if [[ $? == 0 ]]; then + echo "[Key Vault Secrets User] role successfully assigned to the [$MANAGED_IDENTITY_NAME] managed identity" +else + echo "Failed to assign [Key Vault Secrets User] role to the [$MANAGED_IDENTITY_NAME] managed identity" + exit 1 +fi + +# Store the link-signing key in the key vault +echo "Storing the [$SIGN_KEY_SECRET_NAME] secret in the [$KEY_VAULT_NAME] key vault..." +SIGN_KEY=$(openssl rand -hex 16) +az keyvault secret set \ + --vault-name $KEY_VAULT_NAME \ + --name $SIGN_KEY_SECRET_NAME \ + --value "$SIGN_KEY" 1>/dev/null + +if [[ $? == 0 ]]; then + echo "[$SIGN_KEY_SECRET_NAME] secret successfully stored in the [$KEY_VAULT_NAME] key vault" +else + echo "Failed to store the [$SIGN_KEY_SECRET_NAME] secret in the [$KEY_VAULT_NAME] key vault" + exit 1 +fi + +# Check if the PostgreSQL flexible server already exists +echo "Checking if [$POSTGRES_SERVER_NAME] PostgreSQL flexible server actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az postgres flexible-server show --name $POSTGRES_SERVER_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$POSTGRES_SERVER_NAME] PostgreSQL flexible server actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$POSTGRES_SERVER_NAME] PostgreSQL flexible server in the [$RESOURCE_GROUP_NAME] resource group..." + + POSTGRES_ADMIN_PASSWORD=$(openssl rand -hex 10) + az postgres flexible-server create \ + --name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location "$LOCATION" \ + --admin-user $POSTGRES_ADMIN_USER \ + --admin-password "$POSTGRES_ADMIN_PASSWORD" \ + --sku-name Standard_B1ms \ + --tier Burstable \ + --version 16 \ + --storage-size 32 \ + --public-access 0.0.0.0-255.255.255.255 \ + --yes 1>/dev/null + + if [[ $? == 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" + if [ -f "$STATE_FILE" ]; then + source "$STATE_FILE" + fi + if [[ -z "$POSTGRES_ADMIN_PASSWORD" ]]; then + echo "The [$POSTGRES_SERVER_NAME] server exists but its admin password is unknown; delete the server or provide POSTGRES_ADMIN_PASSWORD. Exiting." + exit 1 + fi +fi + +# Create the clicks database +echo "Creating [$POSTGRES_DB_NAME] database on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." +az postgres flexible-server db create \ + --server-name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $POSTGRES_DB_NAME 1>/dev/null + +if [[ $? == 0 ]]; then + echo "[$POSTGRES_DB_NAME] database successfully created on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" +else + echo "Failed to create [$POSTGRES_DB_NAME] database on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + exit 1 +fi + +# Split host:port - the LocalStack emulator embeds the dynamically allocated TCP-proxy port +# in fullyQualifiedDomainName, while real Azure returns just the bare host on 5432. +POSTGRES_FQDN=$(az postgres flexible-server show \ + --name $POSTGRES_SERVER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query fullyQualifiedDomainName \ + --output tsv) +POSTGRES_HOST="${POSTGRES_FQDN%%:*}" +if [[ "$POSTGRES_FQDN" == *:* ]]; then + POSTGRES_PORT="${POSTGRES_FQDN##*:}" +else + POSTGRES_PORT=5432 +fi +echo "PostgreSQL host = $POSTGRES_HOST, port = $POSTGRES_PORT" + +# Persist the values that validate.sh and re-runs need; written before the app +# deployments so a late failure leaves a usable state behind. +cat >"$STATE_FILE" </dev/null + +if [[ $? == 0 ]]; then + echo "[$PG_CONN_SECRET_NAME] secret successfully stored in the [$KEY_VAULT_NAME] key vault" +else + echo "Failed to store the [$PG_CONN_SECRET_NAME] secret in the [$KEY_VAULT_NAME] key vault" + exit 1 +fi + +# Check if the Service Bus namespace already exists +echo "Checking if [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az servicebus namespace show --name $SERVICEBUS_NAMESPACE_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace in the [$RESOURCE_GROUP_NAME] resource group..." + + az servicebus namespace create \ + --name $SERVICEBUS_NAMESPACE_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location "$LOCATION" \ + --sku Standard 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Create the link-events queue +echo "Creating [$SERVICEBUS_QUEUE_NAME] queue in the [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace..." +az servicebus queue create \ + --name $SERVICEBUS_QUEUE_NAME \ + --namespace-name $SERVICEBUS_NAMESPACE_NAME \ + --resource-group $RESOURCE_GROUP_NAME 1>/dev/null + +if [[ $? == 0 ]]; then + echo "[$SERVICEBUS_QUEUE_NAME] queue successfully created in the [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace" +else + echo "Failed to create [$SERVICEBUS_QUEUE_NAME] queue in the [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace" + exit 1 +fi + +# Assign the Service Bus data-plane roles to the managed identity +SERVICEBUS_NAMESPACE_ID=$(az servicebus namespace show --name $SERVICEBUS_NAMESPACE_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv) +for ROLE in "Azure Service Bus Data Sender" "Azure Service Bus Data Receiver"; do + echo "Assigning [$ROLE] role to the [$MANAGED_IDENTITY_NAME] managed identity on the [$SERVICEBUS_NAMESPACE_NAME] namespace..." + # Skip when already assigned: az role assignment create is not idempotent + # and re-runs fail with RoleAssignmentExists (Azure/azure-cli#31995). + EXISTING_ASSIGNMENT=$(az role assignment list \ + --role "$ROLE" \ + --assignee "$IDENTITY_PRINCIPAL_ID" \ + --scope "$SERVICEBUS_NAMESPACE_ID" \ + --query "[0].id" \ + --output tsv) + if [[ -z "$EXISTING_ASSIGNMENT" ]]; then + az role assignment create \ + --role "$ROLE" \ + --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \ + --assignee-principal-type ServicePrincipal \ + --scope "$SERVICEBUS_NAMESPACE_ID" 1>/dev/null + fi + + if [[ $? == 0 ]]; then + echo "[$ROLE] role successfully assigned to the [$MANAGED_IDENTITY_NAME] managed identity" + else + echo "Failed to assign [$ROLE] role to the [$MANAGED_IDENTITY_NAME] managed identity" + exit 1 + fi +done + +# Retrieve the Service Bus connection string used by the web app +echo "Retrieving the connection string for the [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace..." +SERVICEBUS_CONNECTION_STRING=$(az servicebus namespace authorization-rule keys list \ + --resource-group $RESOURCE_GROUP_NAME \ + --namespace-name $SERVICEBUS_NAMESPACE_NAME \ + --name RootManageSharedAccessKey \ + --query primaryConnectionString \ + --output tsv) + +if [[ -n "$SERVICEBUS_CONNECTION_STRING" ]]; then + echo "Connection string for the [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace successfully retrieved" +else + echo "Failed to retrieve the connection string for the [$SERVICEBUS_NAMESPACE_NAME] Service Bus namespace" + exit 1 +fi + +# Check if the Log Analytics workspace already exists +echo "Checking if [$LOG_ANALYTICS_NAME] Log Analytics workspace actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az monitor log-analytics workspace show --workspace-name $LOG_ANALYTICS_NAME --resource-group $RESOURCE_GROUP_NAME &>/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..." + + az monitor log-analytics workspace create \ + --workspace-name $LOG_ANALYTICS_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location "$LOCATION" 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 + +# Attach diagnostic settings for the storage account to the Log Analytics workspace +echo "Attaching diagnostic settings for the [$STORAGE_ACCOUNT_NAME] storage account to the [$LOG_ANALYTICS_NAME] workspace..." +LOG_ANALYTICS_ID=$(az monitor log-analytics workspace show --workspace-name $LOG_ANALYTICS_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv) +az monitor diagnostic-settings create \ + --name default \ + --resource "$STORAGE_ACCOUNT_ID" \ + --workspace "$LOG_ANALYTICS_ID" \ + --metrics '[{"category":"Transaction","enabled":true}]' \ + --output none + +if [[ $? == 0 ]]; then + echo "Diagnostic settings successfully attached to the [$STORAGE_ACCOUNT_NAME] storage account" +else + # Best-effort: validate.sh step 8 fails the run if diagnostics are missing. + echo "Failed to attach diagnostic settings to the [$STORAGE_ACCOUNT_NAME] storage account" +fi + +# Check if the app service plan already exists +echo "Checking if [$APP_SERVICE_PLAN_NAME] app service plan actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az appservice plan show --name $APP_SERVICE_PLAN_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$APP_SERVICE_PLAN_NAME] app service plan actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$APP_SERVICE_PLAN_NAME] app service plan in the [$RESOURCE_GROUP_NAME] resource group..." + + az appservice plan create \ + --name $APP_SERVICE_PLAN_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location "$LOCATION" \ + --sku $APP_SERVICE_PLAN_SKU \ + --is-linux 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$APP_SERVICE_PLAN_NAME] app service plan successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$APP_SERVICE_PLAN_NAME] app service plan in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$APP_SERVICE_PLAN_NAME] app service plan already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Check if the web app already exists +echo "Checking if [$WEB_APP_NAME] web app actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az webapp show --name $WEB_APP_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$WEB_APP_NAME] web app actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$WEB_APP_NAME] web app in the [$RESOURCE_GROUP_NAME] resource group..." + + az webapp create \ + --name $WEB_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --plan $APP_SERVICE_PLAN_NAME \ + --runtime "$RUNTIME:$WEB_APP_RUNTIME_VERSION" 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$WEB_APP_NAME] web app successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$WEB_APP_NAME] web app in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$WEB_APP_NAME] web app already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Assign the managed identity to the web app +echo "Assigning the [$MANAGED_IDENTITY_NAME] managed identity to the [$WEB_APP_NAME] web app..." +az webapp identity assign \ + --name $WEB_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --identities "$IDENTITY_ID" 1>/dev/null + +if [[ $? == 0 ]]; then + echo "[$MANAGED_IDENTITY_NAME] managed identity successfully assigned to the [$WEB_APP_NAME] web app" +else + echo "Failed to assign the [$MANAGED_IDENTITY_NAME] managed identity to the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Retrieve the storage endpoints for the app settings +TABLES_ENDPOINT=$(az storage account show --name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME --query primaryEndpoints.table --output tsv) +QUEUE_ENDPOINT=$(az storage account show --name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME --query primaryEndpoints.queue --output tsv) +BLOB_ENDPOINT=$(az storage account show --name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME --query primaryEndpoints.blob --output tsv) +KEYVAULT_URL=$(az keyvault show --name $KEY_VAULT_NAME --resource-group $RESOURCE_GROUP_NAME --query properties.vaultUri --output tsv) +INTERNAL_TOKEN=$(openssl rand -hex 12) + +# Configure the web app settings +echo "Setting app settings for the [$WEB_APP_NAME] web app..." +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="$IDENTITY_CLIENT_ID" \ + AZURE_TABLES_ENDPOINT="$TABLES_ENDPOINT" \ + LINKS_TABLE="$LINKS_TABLE_NAME" \ + KEYVAULT_URL="$KEYVAULT_URL" \ + QUEUE_ENDPOINT="$QUEUE_ENDPOINT" \ + QR_JOBS_QUEUE="$QR_JOBS_QUEUE_NAME" \ + BLOB_ENDPOINT="$BLOB_ENDPOINT" \ + QR_CONTAINER="$QR_CONTAINER_NAME" \ + SB_CONN="$SERVICEBUS_CONNECTION_STRING" \ + SB_QUEUE="$SERVICEBUS_QUEUE_NAME" \ + PG_SECRET_NAME="$PG_CONN_SECRET_NAME" \ + INTERNAL_TOKEN="$INTERNAL_TOKEN" 1>/dev/null + +if [[ $? == 0 ]]; then + echo "App settings for the [$WEB_APP_NAME] web app successfully set" +else + echo "Failed to set app settings for the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Create the zip package of the web app +cd "$CURRENT_DIR/../src" || exit +if [ -f "$WEBAPP_ZIPFILE" ]; then + rm "$WEBAPP_ZIPFILE" +fi +echo "Creating zip package of the web app..." +zip -r "$WEBAPP_ZIPFILE" app.py gunicorn.conf.py requirements.txt + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$WEBAPP_ZIPFILE]..." +az webapp deploy \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $WEB_APP_NAME \ + --src-path "$WEBAPP_ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [[ $? == 0 ]]; then + echo "Web app [$WEB_APP_NAME] deployed successfully" +else + echo "Failed to deploy web app [$WEB_APP_NAME]" + exit 1 +fi +rm -f "$WEBAPP_ZIPFILE" + +WEB_APP_HOSTNAME=$(az webapp show --name $WEB_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query defaultHostName --output tsv) + +# Check if the function app already exists +echo "Checking if [$FUNCTION_APP_NAME] function app actually exists in the [$RESOURCE_GROUP_NAME] resource group..." +az functionapp show --name $FUNCTION_APP_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$FUNCTION_APP_NAME] function app actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$FUNCTION_APP_NAME] function app in the [$RESOURCE_GROUP_NAME] resource group..." + + az functionapp create \ + --name $FUNCTION_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --plan $APP_SERVICE_PLAN_NAME \ + --storage-account $STORAGE_ACCOUNT_NAME \ + --runtime $RUNTIME \ + --runtime-version 3.11 \ + --functions-version $FUNCTIONS_VERSION \ + --os-type Linux 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$FUNCTION_APP_NAME] function app successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$FUNCTION_APP_NAME] function app in the [$RESOURCE_GROUP_NAME] resource group" + exit 1 + fi +else + echo "[$FUNCTION_APP_NAME] function app already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Assign the managed identity to the function app +echo "Assigning the [$MANAGED_IDENTITY_NAME] managed identity to the [$FUNCTION_APP_NAME] function app..." +az functionapp identity assign \ + --name $FUNCTION_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --identities "$IDENTITY_ID" 1>/dev/null + +if [[ $? == 0 ]]; then + echo "[$MANAGED_IDENTITY_NAME] managed identity successfully assigned to the [$FUNCTION_APP_NAME] function app" +else + echo "Failed to assign the [$MANAGED_IDENTITY_NAME] managed identity to the [$FUNCTION_APP_NAME] function app" + exit 1 +fi + +# Configure the function app settings: the Service Bus trigger binds with the managed +# identity (FQNS + client id), the queue trigger uses an explicit-endpoints connection +# string, and the worker calls back into the web app's internal API. +STORAGE_ACCOUNT_KEY=$(az storage account keys list --account-name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME --query "[0].value" --output tsv) +echo "Setting app settings for the [$FUNCTION_APP_NAME] function app..." +az functionapp config appsettings set \ + --name $FUNCTION_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --settings \ + FUNCTIONS_WORKER_RUNTIME="$RUNTIME" \ + SCM_DO_BUILD_DURING_DEPLOYMENT='true' \ + ENABLE_ORYX_BUILD='true' \ + AZURE_CLIENT_ID="$IDENTITY_CLIENT_ID" \ + ServiceBusConnection__fullyQualifiedNamespace="${SERVICEBUS_NAMESPACE_NAME}.servicebus.windows.net" \ + ServiceBusConnection__clientId="$IDENTITY_CLIENT_ID" \ + ServiceBusConnection__credential="managedidentity" \ + QrStorage="DefaultEndpointsProtocol=https;AccountName=${STORAGE_ACCOUNT_NAME};AccountKey=${STORAGE_ACCOUNT_KEY};BlobEndpoint=${BLOB_ENDPOINT};QueueEndpoint=${QUEUE_ENDPOINT};TableEndpoint=${TABLES_ENDPOINT}" \ + WEB_BASE_URL="http://$WEB_APP_HOSTNAME" \ + INTERNAL_TOKEN="$INTERNAL_TOKEN" 1>/dev/null + +if [[ $? == 0 ]]; then + echo "App settings for the [$FUNCTION_APP_NAME] function app successfully set" +else + echo "Failed to set app settings for the [$FUNCTION_APP_NAME] function app" + exit 1 +fi + +# Create the zip package of the function app +cd "$CURRENT_DIR/../function" || exit +if [ -f "$WORKER_ZIPFILE" ]; then + rm "$WORKER_ZIPFILE" +fi +echo "Creating zip package of the function app..." +zip -r "$WORKER_ZIPFILE" function_app.py host.json requirements.txt + +# Deploy the function app +echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$WORKER_ZIPFILE]..." +az functionapp deploy \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $FUNCTION_APP_NAME \ + --src-path "$WORKER_ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [[ $? == 0 ]]; then + echo "Function app [$FUNCTION_APP_NAME] deployed successfully" +else + echo "Failed to deploy function app [$FUNCTION_APP_NAME]" + exit 1 +fi +rm -f "$WORKER_ZIPFILE" + +echo "Deployment completed. Web app available at: http://$WEB_APP_HOSTNAME" diff --git a/samples/url-shortener/python/scripts/validate.sh b/samples/url-shortener/python/scripts/validate.sh new file mode 100755 index 0000000..e594ab3 --- /dev/null +++ b/samples/url-shortener/python/scripts/validate.sh @@ -0,0 +1,183 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +STORAGE_ACCOUNT_NAME="${PREFIX}urlshortstorage${SUFFIX}" +LOG_ANALYTICS_NAME="${PREFIX}-urlshort-log-analytics-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-urlshort-webapp-${SUFFIX}" +POSTGRES_ADMIN_USER='linkletadmin' +POSTGRES_DB_NAME='clicks' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +STATE_FILE="$CURRENT_DIR/.last_deploy.env" + +FAILED=0 + +# Load the values persisted by deploy.sh (PostgreSQL credentials and endpoint) +if [ -f "$STATE_FILE" ]; then + source "$STATE_FILE" +else + echo "State file [$STATE_FILE] not found; run scripts/deploy.sh first. Exiting." + exit 1 +fi + +# Retrieve the web app host name +WEB_APP_HOSTNAME=$(az webapp show \ + --name $WEB_APP_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query defaultHostName \ + --output tsv) + +if [[ -z "$WEB_APP_HOSTNAME" ]]; then + echo "Failed to retrieve the host name of the [$WEB_APP_NAME] web app. Exiting." + exit 1 +fi +WEB_APP_URL="http://$WEB_APP_HOSTNAME" + +# 1. Wait for the web app health endpoint: it verifies Table Storage, Key Vault and +# PostgreSQL connectivity through the managed identity. +echo "Waiting for the [$WEB_APP_NAME] web app health endpoint..." +HEALTH="" +for i in $(seq 1 60); do + HEALTH=$(curl -s -m 8 "$WEB_APP_URL/healthz" 2>/dev/null) + echo "$HEALTH" | grep -q '"status": *"ok"' && break + sleep 5 +done +echo "Health response: $HEALTH" +if echo "$HEALTH" | grep -q '"status": *"ok"'; then + echo "Health check passed" +else + echo "Health check failed" + FAILED=1 +fi + +# 2. Shorten a benign URL: writes the link to Table Storage, signs it with the Key Vault +# key, sends a Service Bus event and enqueues a QR-render job. +echo "Shortening a benign URL..." +CLEAN_RESPONSE=$(curl -s -X POST "$WEB_APP_URL/shorten" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://github.com/localstack/localstack"}') +echo "$CLEAN_RESPONSE" +CLEAN_CODE=$(echo "$CLEAN_RESPONSE" | jq -r .code) +if [[ -z "$CLEAN_CODE" || "$CLEAN_CODE" == "null" ]]; then + echo "Failed to shorten the benign URL" + FAILED=1 +fi + +# 3. Shorten a suspicious URL: the abuse-scan worker must flag it. +echo "Shortening a suspicious URL..." +FLAGGED_RESPONSE=$(curl -s -X POST "$WEB_APP_URL/shorten" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://bad.example.com/phishing/login"}') +echo "$FLAGGED_RESPONSE" +FLAGGED_CODE=$(echo "$FLAGGED_RESPONSE" | jq -r .code) + +# 4. Wait for the workers: AbuseScan consumes the Service Bus event, QrGenerator +# consumes the storage-queue job and renders the QR code into Blob Storage. +echo "Waiting for the AbuseScan and QrGenerator workers..." +SCAN="" +QR="" +for i in $(seq 1 48); do + LINK_INFO=$(curl -s "$WEB_APP_URL/api/links/$CLEAN_CODE") + SCAN=$(echo "$LINK_INFO" | jq -r .scan) + QR=$(echo "$LINK_INFO" | jq -r .qr) + [[ "$SCAN" == "clean" && "$QR" == "done" ]] && break + sleep 5 +done +echo "Benign link after processing: $LINK_INFO" +if [[ "$SCAN" == "clean" ]]; then + echo "AbuseScan verdict for the benign link is correct" +else + echo "AbuseScan did not mark the benign link as clean (scan=$SCAN)" + FAILED=1 +fi +if [[ "$QR" == "done" ]]; then + echo "QrGenerator rendered the QR code" +else + echo "QrGenerator did not render the QR code (qr=$QR)" + FAILED=1 +fi + +FLAGGED_SCAN="" +for i in $(seq 1 24); do + FLAGGED_INFO=$(curl -s "$WEB_APP_URL/api/links/$FLAGGED_CODE") + FLAGGED_SCAN=$(echo "$FLAGGED_INFO" | jq -r .scan) + [[ "$FLAGGED_SCAN" == "flagged" ]] && break + sleep 5 +done +echo "Suspicious link after processing: $FLAGGED_INFO" +if [[ "$FLAGGED_SCAN" == "flagged" ]]; then + echo "AbuseScan verdict for the suspicious link is correct" +else + echo "AbuseScan did not flag the suspicious link (scan=$FLAGGED_SCAN)" + FAILED=1 +fi + +# 5. Fetch the rendered QR code from the public blob container. +QR_URL=$(curl -s "$WEB_APP_URL/api/links/$CLEAN_CODE" | jq -r .qr_url) +echo "Fetching the QR code from [$QR_URL]..." +QR_CONTENT_TYPE=$(curl -sk -o /tmp/urlshort_qr.svg -w "%{content_type}" "$QR_URL") +echo "QR content type: $QR_CONTENT_TYPE" +if grep -q " %{redirect_url}\n" "$WEB_APP_URL/l/$CLEAN_CODE" +curl -s -o /dev/null -w "Second redirect: %{http_code} -> %{redirect_url}\n" "$WEB_APP_URL/l/$CLEAN_CODE" +sleep 3 +CLICKS=$(PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_HOST" \ + --port="$POSTGRES_PORT" \ + --username="$POSTGRES_ADMIN_USER" \ + --dbname="$POSTGRES_DB_NAME" \ + --tuples-only --no-align \ + --command="SELECT count(*) FROM clicks WHERE code='$CLEAN_CODE';" 2>&1) +echo "Clicks logged in PostgreSQL for [$CLEAN_CODE]: $CLICKS" +if [[ "$CLICKS" == "2" ]]; then + echo "Click logging into PostgreSQL works" +else + echo "Unexpected click count in PostgreSQL (got: $CLICKS)" + FAILED=1 +fi + +# 7. Verify the hit counter in Table Storage through the web app API. +HITS=$(curl -s "$WEB_APP_URL/api/links/$CLEAN_CODE" | jq -r .hits) +echo "Hits recorded in Table Storage for [$CLEAN_CODE]: $HITS" +if [[ "$HITS" == "2" ]]; then + echo "Hit counting in Table Storage works" +else + echo "Unexpected hit count in Table Storage (got: $HITS)" + FAILED=1 +fi + +# 8. Verify the observability wiring: the Log Analytics workspace exists and the +# storage account has diagnostic settings attached. +echo "Checking the [$LOG_ANALYTICS_NAME] Log Analytics workspace..." +az monitor log-analytics workspace show \ + --workspace-name $LOG_ANALYTICS_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query name \ + --output tsv || FAILED=1 +STORAGE_ACCOUNT_ID=$(az storage account show --name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv) +DIAGNOSTICS=$(az monitor diagnostic-settings list --resource "$STORAGE_ACCOUNT_ID" --query "[].name" --output tsv 2>/dev/null) +echo "Diagnostic settings on the storage account: ${DIAGNOSTICS:-}" +if [[ -z "$DIAGNOSTICS" ]]; then + echo "No diagnostic settings found on the storage account" + FAILED=1 +fi + +if [[ $FAILED == 0 ]]; then + echo "All validation checks passed" +else + echo "Some validation checks failed" +fi +exit $FAILED diff --git a/samples/url-shortener/python/src/app.py b/samples/url-shortener/python/src/app.py new file mode 100644 index 0000000..970a780 --- /dev/null +++ b/samples/url-shortener/python/src/app.py @@ -0,0 +1,323 @@ +import datetime +import hashlib +import hmac +import json +import logging +import os +import secrets +import string + +import psycopg2 +import qrcode +import qrcode.image.svg +from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError +from azure.data.tables import TableServiceClient, UpdateMode +from azure.identity import DefaultAzureCredential +from azure.keyvault.secrets import SecretClient +from azure.servicebus import ServiceBusClient, ServiceBusMessage +from azure.storage.blob import BlobServiceClient, ContentSettings +from azure.storage.queue import QueueClient, TextBase64EncodePolicy +from flask import Flask, abort, jsonify, redirect, render_template_string, request +from io import BytesIO + +app = Flask(__name__) +LOG = logging.getLogger(__name__) + +TABLE_NAME = os.environ.get("LINKS_TABLE", "links") +PARTITION = "links" +CODE_ALPHABET = string.ascii_letters + string.digits +CODE_LENGTH = 6 + +_credential = None +_table = None +_sign_key = None +_pg_conn_str = None +_sb_client = None +_queue = None + + +def credential(): + global _credential + if _credential is None: + _credential = DefaultAzureCredential() + return _credential + + +def table(): + global _table + if _table is None: + # STORAGE_CONN switches the storage data planes to connection-string auth + # (used on real Azure when the deploying principal cannot create the + # managed identity's role assignments); default is credential-free MI. + conn = os.environ.get("STORAGE_CONN") + if conn: + service = TableServiceClient.from_connection_string(conn) + else: + service = TableServiceClient( + endpoint=os.environ["AZURE_TABLES_ENDPOINT"], credential=credential() + ) + _table = service.create_table_if_not_exists(TABLE_NAME) + return _table + + +def secret_client(): + return SecretClient(vault_url=os.environ["KEYVAULT_URL"], credential=credential()) + + +def sign_key(): + global _sign_key + if _sign_key is None: + _sign_key = secret_client().get_secret("link-sign-key").value + return _sign_key + + +def sign(code): + return hmac.new(sign_key().encode(), code.encode(), hashlib.sha256).hexdigest()[:8] + + +def pg(): + """New connection per call; click volume here does not justify pooling.""" + global _pg_conn_str + if _pg_conn_str is None: + secret_name = os.environ.get("PG_SECRET_NAME", "pg-conn") + _pg_conn_str = secret_client().get_secret(secret_name).value + conn = psycopg2.connect(_pg_conn_str) + with conn.cursor() as cur: + cur.execute( + "CREATE TABLE IF NOT EXISTS clicks (" + "id serial PRIMARY KEY, code text NOT NULL, ts timestamptz DEFAULT now())" + ) + conn.commit() + return conn + + +def sb_sender(): + global _sb_client + if _sb_client is None: + conn = os.environ.get("SB_CONN") + if conn: + _sb_client = ServiceBusClient.from_connection_string(conn) + else: + _sb_client = ServiceBusClient( + fully_qualified_namespace=os.environ["SB_FQNS"], credential=credential() + ) + return _sb_client.get_queue_sender(os.environ["SB_QUEUE"]) + + +def qr_queue(): + global _queue + if _queue is None: + conn = os.environ.get("STORAGE_CONN") + if conn: + _queue = QueueClient.from_connection_string( + conn, + queue_name=os.environ.get("QR_JOBS_QUEUE", "qrjobs"), + message_encode_policy=TextBase64EncodePolicy(), + ) + else: + _queue = QueueClient( + account_url=os.environ["QUEUE_ENDPOINT"], + queue_name=os.environ.get("QR_JOBS_QUEUE", "qrjobs"), + credential=credential(), + message_encode_policy=TextBase64EncodePolicy(), + ) + return _queue + + +def qr_url(code): + return f"{os.environ['BLOB_ENDPOINT'].rstrip('/')}/{os.environ.get('QR_CONTAINER', 'qrcodes')}/{code}.svg" + + +PAGE = """ +linklet cloud + + +

linklet cloud

+

URL shortener on LocalStack for Azure: App Service, Functions, Table/Queue/Blob Storage, +Key Vault, Service Bus, PostgreSQL, Managed Identity.

+
+ + +
+ + +{% for l in links %} + + + + +{% endfor %} +
ShortTargetHitsSigScanQR
{{l.code}}{{l.url}}{{l.hits}}{{l.sig}}{{l.scan}}{% if l.qr == 'done' %}svg{% else %}{{l.qr}}{% endif %}
+""" + + +def new_code(): + return "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) + + +def entity_to_dict(e): + return { + "code": e["RowKey"], + "url": e["url"], + "hits": e.get("hits", 0), + "sig": e.get("sig", ""), + "scan": e.get("scan", "pending"), + "qr": e.get("qr", "pending"), + "qr_url": qr_url(e["RowKey"]), + } + + +@app.get("/") +def index(): + entities = table().query_entities(f"PartitionKey eq '{PARTITION}'") + links = sorted((entity_to_dict(e) for e in entities), key=lambda l: -l["hits"]) + return render_template_string(PAGE, links=links) + + +@app.post("/shorten") +def shorten(): + payload = request.get_json(silent=True) or request.form + url = (payload.get("url") or "").strip() + if not url.startswith(("http://", "https://")): + return jsonify(error="url must start with http:// or https://"), 400 + for _ in range(3): + code = new_code() + entity = { + "PartitionKey": PARTITION, + "RowKey": code, + "url": url, + "hits": 0, + "sig": sign(code), + "scan": "pending", + "qr": "pending", + "created": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + try: + table().create_entity(entity) + break + except ResourceExistsError: + continue + else: + return jsonify(error="could not allocate a unique code"), 500 + + # Fan out to the workers: abuse scan via Service Bus, QR render via Storage Queue. + with sb_sender() as sender: + sender.send_messages(ServiceBusMessage(json.dumps({"code": code, "url": url}))) + qr_queue().send_message(json.dumps({"code": code})) + + short_url = request.host_url.rstrip("/") + "/l/" + code + if request.is_json: + return jsonify(code=code, short_url=short_url, url=url, sig=entity["sig"]), 201 + return redirect("/") + + +@app.get("/l/") +def follow(code): + try: + entity = table().get_entity(PARTITION, code) + except ResourceNotFoundError: + return jsonify(error=f"unknown code {code!r}"), 404 + entity["hits"] = int(entity.get("hits", 0)) + 1 + table().update_entity(entity, mode=UpdateMode.MERGE) + try: + conn = pg() + with conn.cursor() as cur: + cur.execute("INSERT INTO clicks (code) VALUES (%s)", (code,)) + conn.commit() + conn.close() + except Exception as e: + # Analytics must not break redirects; the click is lost and logged. + LOG.warning("click logging to postgres failed for %s: %s", code, e) + return redirect(entity["url"], code=302) + + +@app.get("/api/links/") +def link_info(code): + try: + entity = table().get_entity(PARTITION, code) + except ResourceNotFoundError: + return jsonify(error=f"unknown code {code!r}"), 404 + return jsonify(entity_to_dict(entity)) + + +def require_internal_token(): + # Fail closed when INTERNAL_TOKEN is not configured. + expected = os.environ.get("INTERNAL_TOKEN") + provided = request.headers.get("X-Internal-Token", "") + if not expected or not hmac.compare_digest(provided, expected): + abort(403) + + +@app.post("/internal/scan-result") +def scan_result(): + require_internal_token() + body = request.get_json(force=True) + table().update_entity( + {"PartitionKey": PARTITION, "RowKey": body["code"], "scan": body["verdict"]}, + mode=UpdateMode.MERGE, + ) + return jsonify(ok=True) + + +@app.post("/internal/generate-qr") +def generate_qr(): + require_internal_token() + body = request.get_json(force=True) + code = body["code"] + short_url = request.host_url.rstrip("/") + "/l/" + code + image = qrcode.make(short_url, image_factory=qrcode.image.svg.SvgPathImage) + buffer = BytesIO() + image.save(buffer) + conn = os.environ.get("STORAGE_CONN") + if conn: + blob_service = BlobServiceClient.from_connection_string(conn) + else: + blob_service = BlobServiceClient(account_url=os.environ["BLOB_ENDPOINT"], credential=credential()) + blob_client = blob_service.get_blob_client(os.environ.get("QR_CONTAINER", "qrcodes"), f"{code}.svg") + blob_client.upload_blob( + buffer.getvalue(), + overwrite=True, + content_settings=ContentSettings(content_type="image/svg+xml"), + ) + table().update_entity( + {"PartitionKey": PARTITION, "RowKey": code, "qr": "done"}, + mode=UpdateMode.MERGE, + ) + return jsonify(ok=True) + + +@app.get("/healthz") +def healthz(): + components = {} + try: + table() + components["table"] = "ok" + except Exception as e: + components["table"] = f"error: {e}" + try: + sign_key() + components["keyvault"] = "ok" + except Exception as e: + components["keyvault"] = f"error: {e}" + try: + conn = pg() + with conn.cursor() as cur: + cur.execute("SELECT 1") + conn.close() + components["postgres"] = "ok" + except Exception as e: + components["postgres"] = f"error: {e}" + components["servicebus"] = ( + "configured" if (os.environ.get("SB_CONN") or os.environ.get("SB_FQNS")) else "missing config" + ) + components["qr_queue"] = "configured" if os.environ.get("QUEUE_ENDPOINT") else "missing config" + ok = all(v in ("ok", "configured") for v in components.values()) + return jsonify(status="ok" if ok else "degraded", components=components), 200 if ok else 503 diff --git a/samples/url-shortener/python/src/gunicorn.conf.py b/samples/url-shortener/python/src/gunicorn.conf.py new file mode 100644 index 0000000..cb87ebc --- /dev/null +++ b/samples/url-shortener/python/src/gunicorn.conf.py @@ -0,0 +1,18 @@ +import os + + +def worker_int(worker): + # SIGINT (Ctrl+C) default path raises SystemExit inside the worker's recv() + # loop, dumping a traceback through gunicorn's HTTP parser frames. os._exit + # short-circuits the unwind for a clean foreground stop. SIGTERM (graceful) + # is unaffected — it goes through a different code path. + os._exit(0) + + +def worker_abort(worker): + # SIGABRT is what the arbiter sends when a worker misses its heartbeat + # ([CRITICAL] WORKER TIMEOUT). The default handler does sys.exit(1), which + # unwinds through the same recv() stack as SIGINT and prints a misleading + # traceback. The WORKER TIMEOUT log line above it is the real diagnostic; + # exit at the C level to suppress the spurious trace. + os._exit(1) diff --git a/samples/url-shortener/python/src/requirements.txt b/samples/url-shortener/python/src/requirements.txt new file mode 100644 index 0000000..77891a2 --- /dev/null +++ b/samples/url-shortener/python/src/requirements.txt @@ -0,0 +1,11 @@ +Flask==3.1.3 +gunicorn==26.0.0 +azure-identity==1.25.3 +azure-core==1.41.0 +azure-data-tables==12.7.0 +azure-keyvault-secrets==4.11.0 +azure-servicebus==7.14.3 +azure-storage-queue==12.17.0 +psycopg2-binary==2.9.12 +azure-storage-blob==12.29.0 +qrcode==8.2 diff --git a/samples/url-shortener/python/terraform/README.md b/samples/url-shortener/python/terraform/README.md new file mode 100644 index 0000000..cd9d618 --- /dev/null +++ b/samples/url-shortener/python/terraform/README.md @@ -0,0 +1,28 @@ +# Terraform Deployment + +This directory contains the Terraform configuration for the sample. For details about the sample application, see [URL Shortener](../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/) +- [Terraform](https://developer.hashicorp.com/terraform/downloads) + +## Deployment + +```bash +bash deploy.sh +``` + +The script runs `terraform init`, `plan` and `apply`, attaches the storage diagnostic settings to the Log Analytics workspace, and deploys the web app and the worker from zip packages with the Azure CLI. It persists the generated PostgreSQL credentials to `../scripts/.last_deploy.env` so `scripts/validate.sh` works after a Terraform deployment. Terraform state stays local, matching the sibling samples. + +## Cleanup + +```bash +terraform destroy \ + -var "prefix=local" \ + -var "suffix=test" \ + -var "location=westeurope" +``` diff --git a/samples/url-shortener/python/terraform/deploy.sh b/samples/url-shortener/python/terraform/deploy.sh new file mode 100755 index 0000000..8d61fa1 --- /dev/null +++ b/samples/url-shortener/python/terraform/deploy.sh @@ -0,0 +1,153 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +WEBAPP_ZIPFILE="linklet_webapp.zip" +WORKER_ZIPFILE="linklet_worker.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Initialize 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) +STORAGE_ACCOUNT_NAME=$(terraform output -raw storage_account_name) +LOG_ANALYTICS_NAME=$(terraform output -raw log_analytics_workspace_name) +WEB_APP_NAME=$(terraform output -raw web_app_name) +FUNCTION_APP_NAME=$(terraform output -raw function_app_name) + +# Check if output values are empty +if [[ -z "$WEB_APP_NAME" || -z "$FUNCTION_APP_NAME" ]]; then + echo "Web App Name or Function App Name is empty. Exiting." + exit 1 +fi + +# Persist the PostgreSQL credentials for scripts/validate.sh, matching the +# contract of scripts/deploy.sh. +cat >"$CURRENT_DIR/../scripts/.last_deploy.env" </dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] deployed successfully." +else + echo "Failed to deploy web app [$WEB_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the web app +if [ -f "$WEBAPP_ZIPFILE" ]; then + rm "$WEBAPP_ZIPFILE" +fi + +# Change current directory to the function folder +cd "$CURRENT_DIR/../function" || exit + +# Remove any existing zip package of the function app +if [ -f "$WORKER_ZIPFILE" ]; then + rm "$WORKER_ZIPFILE" +fi + +# Create the zip package of the function app +echo "Creating zip package of the function app..." +zip -r "$WORKER_ZIPFILE" function_app.py host.json requirements.txt + +# Deploy the function app +echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$WORKER_ZIPFILE]..." +az functionapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$FUNCTION_APP_NAME" \ + --src-path "$WORKER_ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Function app [$FUNCTION_APP_NAME] deployed successfully." +else + echo "Failed to deploy function app [$FUNCTION_APP_NAME]." + exit 1 +fi + +# Remove the zip package of the function app +if [ -f "$WORKER_ZIPFILE" ]; then + rm "$WORKER_ZIPFILE" +fi + +WEB_APP_HOST=$(cd "$CURRENT_DIR" && terraform output -raw web_app_host) +echo "Deployment completed. Web app available at: http://$WEB_APP_HOST" diff --git a/samples/url-shortener/python/terraform/main.tf b/samples/url-shortener/python/terraform/main.tf new file mode 100644 index 0000000..572ca76 --- /dev/null +++ b/samples/url-shortener/python/terraform/main.tf @@ -0,0 +1,294 @@ +locals { + resource_group_name = "${var.prefix}-rg" + storage_account_name = "${var.prefix}urlshortstorage${var.suffix}" + key_vault_name = "${var.prefix}-urlshort-kv-${var.suffix}" + postgres_server_name = "${var.prefix}-urlshort-pgflex-${var.suffix}" + servicebus_namespace = "${var.prefix}-urlshort-sb-ns-${var.suffix}" + log_analytics_name = "${var.prefix}-urlshort-log-analytics-${var.suffix}" + app_service_plan = "${var.prefix}-urlshort-app-service-plan-${var.suffix}" + web_app_name = "${var.prefix}-urlshort-webapp-${var.suffix}" + function_app_name = "${var.prefix}-urlshort-functionapp-${var.suffix}" + identity_name = "${var.prefix}-urlshort-identity-${var.suffix}" +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "main" { + name = local.resource_group_name + location = var.location +} + +# --------------------------------------------------------------------------- +# Identity - one user-assigned identity shared by the web app and the worker; +# every storage and Key Vault data-plane access is credential-free through it. +# --------------------------------------------------------------------------- +resource "azurerm_user_assigned_identity" "main" { + name = local.identity_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location +} + +# --------------------------------------------------------------------------- +# Storage - table (links store), queue (QR render jobs), blob (QR images) +# --------------------------------------------------------------------------- +resource "azurerm_storage_account" "main" { + name = local.storage_account_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + account_tier = "Standard" + account_replication_type = "LRS" +} + +resource "azurerm_storage_table" "links" { + name = "links" + storage_account_name = azurerm_storage_account.main.name +} + +resource "azurerm_storage_queue" "qrjobs" { + name = "qrjobs" + storage_account_name = azurerm_storage_account.main.name +} + +resource "azurerm_storage_container" "qrcodes" { + name = "qrcodes" + storage_account_name = azurerm_storage_account.main.name + container_access_type = "blob" +} + +resource "azurerm_role_assignment" "table_contributor" { + scope = azurerm_storage_account.main.id + role_definition_name = "Storage Table Data Contributor" + principal_id = azurerm_user_assigned_identity.main.principal_id +} + +resource "azurerm_role_assignment" "queue_contributor" { + scope = azurerm_storage_account.main.id + role_definition_name = "Storage Queue Data Contributor" + principal_id = azurerm_user_assigned_identity.main.principal_id +} + +resource "azurerm_role_assignment" "blob_contributor" { + scope = azurerm_storage_account.main.id + role_definition_name = "Storage Blob Data Contributor" + principal_id = azurerm_user_assigned_identity.main.principal_id +} + +# --------------------------------------------------------------------------- +# Key Vault - holds the link-signing key and the PostgreSQL connection string +# --------------------------------------------------------------------------- +resource "azurerm_key_vault" "main" { + name = local.key_vault_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + rbac_authorization_enabled = true +} + +resource "random_password" "sign_key" { + length = 32 + special = false +} + +resource "random_password" "postgres" { + length = 20 + special = false +} + +resource "random_password" "internal_token" { + length = 24 + special = false +} + +resource "azurerm_key_vault_secret" "sign_key" { + name = "link-sign-key" + value = random_password.sign_key.result + key_vault_id = azurerm_key_vault.main.id +} + +resource "azurerm_role_assignment" "kv_secrets_user" { + scope = azurerm_key_vault.main.id + role_definition_name = "Key Vault Secrets User" + principal_id = azurerm_user_assigned_identity.main.principal_id +} + +# --------------------------------------------------------------------------- +# PostgreSQL flexible server - click-event log written on every redirect +# --------------------------------------------------------------------------- +resource "azurerm_postgresql_flexible_server" "main" { + name = local.postgres_server_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + version = var.postgres_version + administrator_login = "linkletadmin" + administrator_password = random_password.postgres.result + sku_name = var.postgres_sku_name + storage_mb = var.postgres_storage_mb + + lifecycle { + # Real Azure assigns an availability zone at create time; the provider + # refuses to change it afterwards, so keep whatever was assigned. + ignore_changes = [zone] + } +} + +resource "azurerm_postgresql_flexible_server_database" "clicks" { + name = "clicks" + server_id = azurerm_postgresql_flexible_server.main.id +} + +resource "azurerm_postgresql_flexible_server_firewall_rule" "allow_all" { + name = "AllowAllIPs" + server_id = azurerm_postgresql_flexible_server.main.id + start_ip_address = "0.0.0.0" + end_ip_address = "255.255.255.255" +} + +# 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 app always gets the +# right host + port without any post-apply shell logic. +locals { + pg_fqdn_parts = split(":", azurerm_postgresql_flexible_server.main.fqdn) + pg_host = local.pg_fqdn_parts[0] + pg_port = length(local.pg_fqdn_parts) > 1 ? local.pg_fqdn_parts[1] : "5432" +} + +resource "azurerm_key_vault_secret" "pg_conn" { + name = "pg-conn" + value = "host=${local.pg_host} port=${local.pg_port} dbname=${azurerm_postgresql_flexible_server_database.clicks.name} user=${azurerm_postgresql_flexible_server.main.administrator_login} password=${random_password.postgres.result}" + key_vault_id = azurerm_key_vault.main.id +} + +# --------------------------------------------------------------------------- +# Service Bus - link-created events consumed by the abuse-scan worker +# --------------------------------------------------------------------------- +resource "azurerm_servicebus_namespace" "main" { + name = local.servicebus_namespace + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + sku = var.servicebus_sku +} + +resource "azurerm_servicebus_queue" "link_events" { + name = "link-events" + namespace_id = azurerm_servicebus_namespace.main.id +} + +resource "azurerm_role_assignment" "sb_sender" { + scope = azurerm_servicebus_namespace.main.id + role_definition_name = "Azure Service Bus Data Sender" + principal_id = azurerm_user_assigned_identity.main.principal_id +} + +resource "azurerm_role_assignment" "sb_receiver" { + scope = azurerm_servicebus_namespace.main.id + role_definition_name = "Azure Service Bus Data Receiver" + principal_id = azurerm_user_assigned_identity.main.principal_id +} + +# --------------------------------------------------------------------------- +# Observability - Log Analytics workspace (diagnostic settings are attached by +# deploy.sh, mirroring the sibling samples) +# --------------------------------------------------------------------------- +resource "azurerm_log_analytics_workspace" "main" { + name = local.log_analytics_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + sku = "PerGB2018" + retention_in_days = 30 +} + +# --------------------------------------------------------------------------- +# Compute - one Linux plan hosting both the web app and the worker +# --------------------------------------------------------------------------- +resource "azurerm_service_plan" "main" { + name = local.app_service_plan + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + os_type = "Linux" + sku_name = var.app_service_plan_sku +} + +resource "azurerm_linux_web_app" "web" { + name = local.web_app_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + service_plan_id = azurerm_service_plan.main.id + + site_config { + application_stack { + python_version = "3.13" + } + } + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.main.id] + } + + app_settings = { + SCM_DO_BUILD_DURING_DEPLOYMENT = "true" + AZURE_CLIENT_ID = azurerm_user_assigned_identity.main.client_id + AZURE_TABLES_ENDPOINT = azurerm_storage_account.main.primary_table_endpoint + LINKS_TABLE = azurerm_storage_table.links.name + KEYVAULT_URL = azurerm_key_vault.main.vault_uri + QUEUE_ENDPOINT = azurerm_storage_account.main.primary_queue_endpoint + QR_JOBS_QUEUE = azurerm_storage_queue.qrjobs.name + BLOB_ENDPOINT = azurerm_storage_account.main.primary_blob_endpoint + QR_CONTAINER = azurerm_storage_container.qrcodes.name + # The python Service Bus SDK enforces TLS verification, and the emulator's + # certificate does not cover *.servicebus.windows.net - so the app uses the + # namespace connection string, whose endpoint is certificate-valid on both + # the emulator and real Azure. + SB_CONN = azurerm_servicebus_namespace.main.default_primary_connection_string + SB_QUEUE = azurerm_servicebus_queue.link_events.name + PG_SECRET_NAME = azurerm_key_vault_secret.pg_conn.name + INTERNAL_TOKEN = random_password.internal_token.result + } +} + +resource "azurerm_linux_function_app" "worker" { + name = local.function_app_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + service_plan_id = azurerm_service_plan.main.id + storage_account_name = azurerm_storage_account.main.name + storage_account_access_key = azurerm_storage_account.main.primary_access_key + functions_extension_version = "~4" + + site_config { + application_stack { + python_version = "3.11" + } + } + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.main.id] + } + + app_settings = { + FUNCTIONS_WORKER_RUNTIME = "python" + SCM_DO_BUILD_DURING_DEPLOYMENT = "true" + ENABLE_ORYX_BUILD = "true" + # The Functions host parses AzureWebJobsStorage with the strict .NET storage + # clients, which cannot parse the EndpointSuffix-with-port string the + # provider would compose on the emulator - so the provider default is + # overridden with an explicit-endpoints connection, mirroring the Bicep + # variant and the eventhubs sibling (the provider respects the override). + AzureWebJobsStorage = "DefaultEndpointsProtocol=https;AccountName=${azurerm_storage_account.main.name};AccountKey=${azurerm_storage_account.main.primary_access_key};BlobEndpoint=${azurerm_storage_account.main.primary_blob_endpoint};QueueEndpoint=${azurerm_storage_account.main.primary_queue_endpoint};TableEndpoint=${azurerm_storage_account.main.primary_table_endpoint}" + AZURE_CLIENT_ID = azurerm_user_assigned_identity.main.client_id + # Identity-based Service Bus trigger binding (same pattern as the + # function-app-service-bus sample): no connection string, MI + FQNS. + ServiceBusConnection__fullyQualifiedNamespace = "${azurerm_servicebus_namespace.main.name}.servicebus.windows.net" + ServiceBusConnection__clientId = azurerm_user_assigned_identity.main.client_id + ServiceBusConnection__credential = "managedidentity" + # Dedicated queue-trigger connection with explicit endpoints: the .NET + # Queues extension cannot parse a connection string whose EndpointSuffix + # carries the emulator's port. + QrStorage = "DefaultEndpointsProtocol=https;AccountName=${azurerm_storage_account.main.name};AccountKey=${azurerm_storage_account.main.primary_access_key};BlobEndpoint=${azurerm_storage_account.main.primary_blob_endpoint};QueueEndpoint=${azurerm_storage_account.main.primary_queue_endpoint};TableEndpoint=${azurerm_storage_account.main.primary_table_endpoint}" + WEB_BASE_URL = "http://${azurerm_linux_web_app.web.default_hostname}" + INTERNAL_TOKEN = random_password.internal_token.result + } +} diff --git a/samples/url-shortener/python/terraform/outputs.tf b/samples/url-shortener/python/terraform/outputs.tf new file mode 100644 index 0000000..f7d5dea --- /dev/null +++ b/samples/url-shortener/python/terraform/outputs.tf @@ -0,0 +1,40 @@ +output "resource_group_name" { + value = azurerm_resource_group.main.name +} + +output "storage_account_name" { + value = azurerm_storage_account.main.name +} + +output "key_vault_name" { + value = azurerm_key_vault.main.name +} + +output "log_analytics_workspace_name" { + value = azurerm_log_analytics_workspace.main.name +} + +output "web_app_name" { + value = azurerm_linux_web_app.web.name +} + +output "function_app_name" { + value = azurerm_linux_function_app.worker.name +} + +output "web_app_host" { + value = azurerm_linux_web_app.web.default_hostname +} + +output "postgres_host" { + value = local.pg_host +} + +output "postgres_port" { + value = local.pg_port +} + +output "postgres_password" { + value = random_password.postgres.result + sensitive = true +} diff --git a/samples/url-shortener/python/terraform/providers.tf b/samples/url-shortener/python/terraform/providers.tf new file mode 100644 index 0000000..b71a75f --- /dev/null +++ b/samples/url-shortener/python/terraform/providers.tf @@ -0,0 +1,30 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=4.81.0" + } + random = { + source = "hashicorp/random" + version = "=3.9.0" + } + } +} + +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } + + # Set the hostname of the Azure Metadata Service (for example management.azure.com) + # used to obtain the Cloud Environment when using LocalStack's Azure emulator. + # This allows the provider to correctly identify the environment and avoid making calls to the real Azure endpoints. + metadata_host = "localhost.localstack.cloud:4566" + + # Set the subscription ID to a dummy value when using LocalStack's Azure emulator. + subscription_id = "00000000-0000-0000-0000-000000000000" +} diff --git a/samples/url-shortener/python/terraform/terraform.tfvars b/samples/url-shortener/python/terraform/terraform.tfvars new file mode 100644 index 0000000..f1d7e7c --- /dev/null +++ b/samples/url-shortener/python/terraform/terraform.tfvars @@ -0,0 +1,3 @@ +prefix = "local" +suffix = "test" +location = "westeurope" diff --git a/samples/url-shortener/python/terraform/variables.tf b/samples/url-shortener/python/terraform/variables.tf new file mode 100644 index 0000000..db3bdca --- /dev/null +++ b/samples/url-shortener/python/terraform/variables.tf @@ -0,0 +1,47 @@ +variable "prefix" { + description = "Prefix applied to every resource name in this sample." + type = string + default = "local" +} + +variable "suffix" { + description = "Suffix applied to every resource name in this sample." + type = string + default = "test" +} + +variable "location" { + description = "Azure region for all resources." + type = string + default = "westeurope" +} + +variable "app_service_plan_sku" { + description = "SKU of the Linux App Service Plan shared by the web app and the worker." + type = string + default = "B1" +} + +variable "postgres_sku_name" { + description = "SKU of the PostgreSQL flexible server." + type = string + default = "B_Standard_B1ms" +} + +variable "postgres_version" { + description = "Major version of the PostgreSQL flexible server." + type = string + default = "16" +} + +variable "postgres_storage_mb" { + description = "Storage size of the PostgreSQL flexible server in MB." + type = number + default = 32768 +} + +variable "servicebus_sku" { + description = "SKU of the Service Bus namespace." + type = string + default = "Standard" +}