Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ celerybeat.pid
.env
.env.*
!.env.example
.last_deploy*.env
.venv
env/
venv/
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last comment @lazarkanelov Can you ask Claude to create a diagram for the app? I don't pretend that you create a diagram manually, just ask Claude to create a mermaid diagram, thanks!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

| [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 |

Expand Down
3 changes: 3 additions & 0 deletions run-samples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
160 changes: 160 additions & 0 deletions samples/url-shortener/python/README.md
Original file line number Diff line number Diff line change
@@ -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/<code>` 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/*<br/>(token-protected)"]
end

subgraph functions["Function App (worker)"]
abuse["AbuseScan<br/>(Service Bus trigger)"]
qrgen["QrGenerator<br/>(queue trigger)"]
end

subgraph storage["Storage Account"]
links[("links table")]
qrjobs[["qrjobs queue"]]
qrcodes[("qrcodes container<br/>public read")]
end

kv["Key Vault<br/>link-sign-key, pg-conn"]
sb[["Service Bus<br/>link-events queue"]]
pg[("PostgreSQL<br/>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/<code>` → 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=<your_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
Comment thread
lazarkanelov marked this conversation as resolved.

- 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/)
25 changes: 25 additions & 0 deletions samples/url-shortener/python/bicep/README.md
Original file line number Diff line number Diff line change
@@ -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
```
146 changes: 146 additions & 0 deletions samples/url-shortener/python/bicep/deploy.sh
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
lazarkanelov marked this conversation as resolved.

# 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" <<EOF
POSTGRES_ADMIN_PASSWORD=$POSTGRES_ADMIN_PASSWORD
POSTGRES_HOST=$POSTGRES_HOST
POSTGRES_PORT=$POSTGRES_PORT
EOF

# 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"

# 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"
Loading