From 0d123c039306e2dbc5a95884c7d68159584935c3 Mon Sep 17 00:00:00 2001 From: Lazar Kanelov Date: Wed, 12 Aug 2026 15:10:07 +0300 Subject: [PATCH 1/5] Add an Azure Container Apps sample (guestbook on ACR + Blob Storage) A Flask guestbook deployed as a container app: the deploy script builds the image into ACR, creates a managed environment, and creates an app with external HTTP ingress, an ACA secret injected via a secretref env var, multiple-revisions mode, 1-3 replicas and an HTTP scale rule. validate.sh drives the live app through its ingress FQDN (health check, guestbook round trip against Blob Storage) and rolls out a second revision, asserting entries survive the switch. Bicep (Microsoft.App 2025-07-01) and Terraform (azurerm_container_app) variants mirror the aci-blob-storage sample's deploy.sh structure. Registered in run-samples.sh (scripts + terraform + bicep, arm64-native since the sample builds its own image and the k3d runtime images are multi-arch) and in the top-level README outline and architecture table. --- README.md | 2 + run-samples.sh | 10 +- .../python/README.md | 134 +++++++ .../python/bicep/README.md | 64 ++++ .../python/bicep/deploy.sh | 188 ++++++++++ .../python/bicep/main.bicep | 194 ++++++++++ .../python/bicep/main.bicepparam | 6 + .../python/scripts/README.md | 52 +++ .../python/scripts/cleanup.sh | 69 ++++ .../python/scripts/deploy.sh | 353 ++++++++++++++++++ .../python/scripts/validate.sh | 327 ++++++++++++++++ .../python/src/Dockerfile | 12 + .../python/src/app.py | 102 +++++ .../python/src/blob_storage_client.py | 125 +++++++ .../python/src/requirements.txt | 2 + .../python/src/templates/index.html | 68 ++++ .../python/terraform/README.md | 69 ++++ .../python/terraform/deploy.sh | 137 +++++++ .../python/terraform/main.tf | 150 ++++++++ .../python/terraform/outputs.tf | 31 ++ .../python/terraform/providers.tf | 26 ++ .../python/terraform/variables.tf | 112 ++++++ 22 files changed, 2231 insertions(+), 2 deletions(-) create mode 100644 samples/container-apps-blob-storage/python/README.md create mode 100644 samples/container-apps-blob-storage/python/bicep/README.md create mode 100644 samples/container-apps-blob-storage/python/bicep/deploy.sh create mode 100644 samples/container-apps-blob-storage/python/bicep/main.bicep create mode 100644 samples/container-apps-blob-storage/python/bicep/main.bicepparam create mode 100644 samples/container-apps-blob-storage/python/scripts/README.md create mode 100644 samples/container-apps-blob-storage/python/scripts/cleanup.sh create mode 100644 samples/container-apps-blob-storage/python/scripts/deploy.sh create mode 100644 samples/container-apps-blob-storage/python/scripts/validate.sh create mode 100644 samples/container-apps-blob-storage/python/src/Dockerfile create mode 100644 samples/container-apps-blob-storage/python/src/app.py create mode 100644 samples/container-apps-blob-storage/python/src/blob_storage_client.py create mode 100644 samples/container-apps-blob-storage/python/src/requirements.txt create mode 100644 samples/container-apps-blob-storage/python/src/templates/index.html create mode 100644 samples/container-apps-blob-storage/python/terraform/README.md create mode 100644 samples/container-apps-blob-storage/python/terraform/deploy.sh create mode 100644 samples/container-apps-blob-storage/python/terraform/main.tf create mode 100644 samples/container-apps-blob-storage/python/terraform/outputs.tf create mode 100644 samples/container-apps-blob-storage/python/terraform/providers.tf create mode 100644 samples/container-apps-blob-storage/python/terraform/variables.tf diff --git a/README.md b/README.md index 13f0f78..54e081b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ This repository contains comprehensive sample projects demonstrating how to deve | [Web App and MySQL Database ](./samples/web-app-mysql-flexible-server/python/README.md) | Azure Web App using MySQL Database | | [Web App with Custom Docker Image](./samples/web-app-custom-image/python/README.md) | Azure Web App running a custom Docker image | | [ACI and Blob Storage](./samples/aci-blob-storage/python/README.md) | Azure Container Instances with ACR, Key Vault, and Blob Storage | +| [Container Apps and Blob Storage](./samples/container-apps-blob-storage/python/README.md) | Azure Container Apps running a guestbook app from ACR with Blob Storage, secrets, revisions, replicas and scale rules | | [Azure Service Bus with Spring Boot](./samples/servicebus/java/README.md) | Azure Service Bus used by a Spring Boot application | | [URL Shortener](./samples/url-shortener/python/README.md) | URL shortener composing Web App, Functions, Storage, Key Vault, Service Bus and PostgreSQL | | [Event Hubs Fraud Detection Pipeline](./samples/eventhubs/python/README.md) | Real-time payment stream processing with Event Hubs (AMQP, Kafka and HTTPS ingestion, Capture, Schema Registry), an Event Hubs-triggered Function App, Key Vault, Storage and a Web App dashboard | @@ -78,6 +79,7 @@ container images Microsoft publishes for `amd64` alone, so there is no `arm64` i | `function-app-*` | ✅ | ✅ | built from a multi-arch `python` / `node` / `dotnet` base | | `web-app-custom-image` | ✅ | ✅ | the image the sample builds itself | | `aci-blob-storage` | ✅ | ✅ | the image the sample builds itself | +| `container-apps-blob-storage` | ✅ | ✅ | the image the sample builds itself | | `web-app-*` (code deployment) | ✅ | emulated | `mcr.microsoft.com/oryx/` | | `eventhubs` | ✅ | emulated | deploys a dashboard web app (Oryx, as above) | | `servicebus/java` | ✅ | emulated | `mcr.microsoft.com/azure-app-service/java` | diff --git a/run-samples.sh b/run-samples.sh index 42bfedd..9ebb94c 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/container-apps-blob-storage/python|bash scripts/deploy.sh|bash scripts/validate.sh" "samples/url-shortener/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" ) @@ -60,6 +61,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/container-apps-blob-storage/python/terraform|bash deploy.sh" "samples/url-shortener/python/terraform|bash deploy.sh|bash ../scripts/validate.sh" ) @@ -77,6 +79,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/container-apps-blob-storage/python/bicep|bash deploy.sh" "samples/url-shortener/python/bicep|bash deploy.sh|bash ../scripts/validate.sh" ) @@ -95,8 +98,10 @@ TOTAL=${#ALL_SAMPLES[@]} # - SQL Database is backed by mcr.microsoft.com/mssql/server — amd64-only. # The samples below avoid all of those: Function Apps get an image built from a # multi-arch base (arm64 support added in localstack-pro#8102), and the custom-image -# Web App and ACI samples run an image the sample itself builds. Their Cosmos DB, -# Service Bus, Storage and Front Door dependencies all publish arm64 manifests. +# Web App, ACI and Container Apps samples run an image the sample itself builds (the +# Container Apps k3d runtime images, rancher/k3s and k3d-proxy, are multi-arch). +# Their Cosmos DB, Service Bus, Storage and Front Door dependencies all publish +# arm64 manifests. # # "amd64-only" means *not native* — not "cannot run". The emulator never pins # --platform, so on an arm64 host Docker pulls the amd64 manifest and runs it under @@ -111,6 +116,7 @@ TOTAL=${#ALL_SAMPLES[@]} # test_deploy_zip_without_basic_auth) are still marked @only_on_amd64. ARM64_SAMPLE_DIRS=( "samples/aci-blob-storage/python" + "samples/container-apps-blob-storage/python" "samples/function-app-front-door/python" "samples/function-app-managed-identity/python" "samples/function-app-service-bus/dotnet" diff --git a/samples/container-apps-blob-storage/python/README.md b/samples/container-apps-blob-storage/python/README.md new file mode 100644 index 0000000..fa9f03e --- /dev/null +++ b/samples/container-apps-blob-storage/python/README.md @@ -0,0 +1,134 @@ +# Guestbook on Azure Container Apps + +A sample application demonstrating how to deploy a containerized Flask web app using three Azure services: + +- **Azure Blob Storage** — Stores guestbook entries as a JSON blob +- **Azure Container Registry (ACR)** — Hosts the Docker container image +- **Azure Container Apps** — Runs the containerized application behind the managed environment's HTTP ingress + +## Architecture + +```mermaid +%%{init: {"flowchart": {"nodeSpacing": 60, "rankSpacing": 80}}}%% +flowchart TB + user((User)) + + subgraph env["Container Apps Managed Environment"] + ingress["HTTP ingress
(public FQDN)"] + app["Container App: guestbook
revisions v1 / v2, 1-3 replicas"] + end + + acr["Container Registry (ACR)
guestbook:v1"] + blob[("Blob Storage
entries.json")] + + user -->|"sign / read guestbook"| ingress + ingress -->|"routes to the latest revision"| app + app -.->|"pulls image"| acr + app -->|"reads/writes entries
(secretref: storage-conn)"| blob + + style env fill:#ffffff,stroke:#999999,color:#333333 +``` + +- **Deployment flow:** The deploy script creates Storage first, then ACR, builds and pushes the container image, creates a Container Apps managed environment, and finally creates a container app that pulls from ACR. The storage connection string is stored as a Container Apps secret and injected into the container through a `secretref` environment variable. +- **At runtime:** The Flask app reads the storage connection string from its environment, connects to Blob Storage, and provides a web UI for signing and reading the guestbook. The revision that served each response is shown in the UI, so rolling out a new revision with `az containerapp update` is observable over HTTP. + +## Prerequisites + +- [LocalStack](https://docs.localstack.cloud/getting-started/installation/) +- [Docker](https://docs.docker.com/get-docker/) +- [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) +- [lstk](https://github.com/localstack/lstk) (`brew install localstack/tap/lstk` or `npm install -g @localstack/lstk`) +- [Terraform](https://developer.hashicorp.com/terraform/downloads) (optional, for Terraform deployment) + +## Quick Start + +```bash +# Start the LocalStack Azure emulator +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception + +# Deploy all services +cd samples/container-apps-blob-storage/python +bash scripts/deploy.sh + +# Validate the deployment (includes a live HTTP round trip and a revision rollout) +bash scripts/validate.sh +``` + +## Alternative Deployments + +### Bicep + +```bash +cd samples/container-apps-blob-storage/python +bash bicep/deploy.sh +``` + +### Terraform + +```bash +cd samples/container-apps-blob-storage/python +bash terraform/deploy.sh +``` + +## Cleanup + +```bash +# Removes all resources created by deploy.sh +bash scripts/cleanup.sh +``` + +## Application + +The Guestbook is a Flask web application that lets visitors sign a guestbook. Entries are stored as a single JSON blob in Azure Blob Storage, so they survive replica restarts and revision switches. + +### Endpoints + +| Route | Method | Description | +|-------|--------|-------------| +| `/` | GET | View all guestbook entries | +| `/` | POST | Sign the guestbook | +| `/delete/` | POST | Delete an entry | +| `/health` | GET | Health check (reports the serving revision) | + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `AZURE_STORAGE_CONNECTION_STRING` | Blob Storage connection string (injected via `secretref:storage-conn`) | +| `BLOB_CONTAINER_NAME` | Name of the blob container for entries | +| `APP_REVISION` | Revision label shown in the UI and `/health` (default: "v1") | + +## Scripts + +| Script | Description | +|--------|-------------| +| `scripts/deploy.sh` | Deploys Storage, ACR, the Container Apps environment, and the container app | +| `scripts/validate.sh` | Validates all resources, exercises secrets, revisions and replicas, and drives the live app over its ingress FQDN | +| `scripts/cleanup.sh` | Removes all resources created by deploy.sh | +| `bicep/deploy.sh` | Deploys all resources using a Bicep template | +| `terraform/deploy.sh` | Deploys all resources using Terraform | + +## Container Apps Features Demonstrated + +| Feature | Script | +|---------|--------| +| Managed environment create | deploy.sh | +| App create from a private registry (ACR) | deploy.sh | +| Secrets (`--secrets` + `secretref:` env var) | deploy.sh | +| External HTTP ingress + FQDN | deploy.sh | +| Multiple revisions mode + revision suffix | deploy.sh | +| Min / max replicas + HTTP scale rule | deploy.sh | +| Secret list / show | validate.sh | +| Revision list + rollout (`az containerapp update`) | validate.sh | +| Replica list | validate.sh | +| Live requests through the ingress FQDN | validate.sh | + +## References + +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/container-apps-blob-storage/python/bicep/README.md b/samples/container-apps-blob-storage/python/bicep/README.md new file mode 100644 index 0000000..11e8103 --- /dev/null +++ b/samples/container-apps-blob-storage/python/bicep/README.md @@ -0,0 +1,64 @@ +# Bicep Deployment + +This directory contains the Bicep template and a deployment script for provisioning Azure services in LocalStack for Azure. Refer to the [Container Apps Blob Storage](../README.md) guide for details about the sample application. + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): Local Azure cloud emulator for development and testing +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep): VS Code extension for Bicep language support +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) +- [jq](https://jqlang.org/): JSON processor for scripting + +### Installing lstk CLI + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +The [deploy.sh](deploy.sh) script first builds and pushes the Docker image to ACR, then the [main.bicep](main.bicep) template creates the following Azure resources: + +1. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides blob storage for guestbook entries. +2. [Azure Container Registry](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-intro): Hosts the Docker container image. +3. [Azure Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/overview): A managed environment and a container app with secrets, external HTTP ingress, and an HTTP scale rule. + +The `Microsoft.App` resources pin api-version `2025-07-01`, the version the az CLI itself uses. + +For more information on the sample application, see [Container Apps Blob Storage](../README.md). + +## Configuration + +Update the `main.bicepparam` file with your specific values: + +```bicep +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param imageName = 'guestbook' +param imageTag = 'v1' +``` + +## Deployment + +```bash +cd samples/container-apps-blob-storage/python +bash bicep/deploy.sh +``` + +## Cleanup + +```bash +bash scripts/cleanup.sh +``` + +## Related Documentation + +- [Azure Bicep Documentation](https://docs.microsoft.com/en-us/azure/azure-resource-manager/bicep/) +- [Bicep Language Reference](https://docs.microsoft.com/en-us/azure/azure-resource-manager/bicep/bicep-functions) +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/container-apps-blob-storage/python/bicep/deploy.sh b/samples/container-apps-blob-storage/python/bicep/deploy.sh new file mode 100644 index 0000000..ad713ae --- /dev/null +++ b/samples/container-apps-blob-storage/python/bicep/deploy.sh @@ -0,0 +1,188 @@ +#!/bin/bash + +# Enable verbose debugging +set -x + +# Variables +PREFIX='local' +SUFFIX='test' +TEMPLATE="main.bicep" +PARAMETERS="main.bicepparam" +RESOURCE_GROUP_NAME="$PREFIX-aca-rg" +LOCATION="eastus" +VALIDATE_TEMPLATE=1 +USE_WHAT_IF=0 +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +IMAGE_NAME="guestbook" +IMAGE_TAG="v1" + +echo "==================================================" +echo "DEBUG: Starting bicep deployment for container-apps-blob-storage" +echo "DEBUG: Resource Group: $RESOURCE_GROUP_NAME" +echo "DEBUG: Environment: $ENVIRONMENT" +echo "==================================================" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit +# Validates if the resource group exists in the subscription, if not creates it +echo "Checking if resource group [$RESOURCE_GROUP_NAME] exists in the subscription [$SUBSCRIPTION_NAME]..." +az group show --name $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No resource group [$RESOURCE_GROUP_NAME] exists in the subscription [$SUBSCRIPTION_NAME]" + echo "Creating resource group [$RESOURCE_GROUP_NAME] in the subscription [$SUBSCRIPTION_NAME]..." + + # Create the resource group + az group create \ + --name $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Resource group [$RESOURCE_GROUP_NAME] successfully created in the subscription [$SUBSCRIPTION_NAME]" + else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME] in the subscription [$SUBSCRIPTION_NAME]" + exit + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists in the subscription [$SUBSCRIPTION_NAME]" +fi + +# ============================================================================= +# Build and push the Docker image before Bicep deployment +# (Bicep creates the container app referencing the image in ACR) +# ============================================================================= + +# Create ACR first so we can push the image +ACR_NAME="${PREFIX}acaacr${SUFFIX}" +echo "Creating ACR [$ACR_NAME] for image push..." +az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku Basic \ + --admin-enabled true \ + --only-show-errors 1>/dev/null + +LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +ACR_USERNAME=$(az acr credential show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "username" \ + --output tsv \ + --only-show-errors) + +ACR_PASSWORD=$(az acr credential show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "passwords[0].value" \ + --output tsv \ + --only-show-errors) + +FULL_IMAGE="${LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +echo "Building Docker image [$IMAGE_NAME:$IMAGE_TAG]..." +docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" ../src/ + +if [[ $? != 0 ]]; then + echo "Failed to build Docker image." + exit 1 +fi + +docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "$FULL_IMAGE" + +echo "Logging in to ACR [$LOGIN_SERVER]..." +echo "$ACR_PASSWORD" | docker login "$LOGIN_SERVER" --username "$ACR_USERNAME" --password-stdin 2>/dev/null + +echo "Pushing image [$FULL_IMAGE]..." +docker push "$FULL_IMAGE" 2>/dev/null + +if [[ $? != 0 ]]; then + echo "Failed to push image to ACR." + exit 1 +fi +echo "Image pushed to ACR successfully." + +# ============================================================================= +# Validate and deploy the Bicep template +# ============================================================================= + +# Validates the Bicep template +if [[ $VALIDATE_TEMPLATE == 1 ]]; then + if [[ $USE_WHAT_IF == 1 ]]; then + # Execute a deployment What-If operation at resource group scope. + echo "Previewing changes deployed by Bicep template [$TEMPLATE]..." + az deployment group what-if \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $TEMPLATE \ + --parameters $PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + --only-show-errors + + if [[ $? == 0 ]]; then + echo "Bicep template [$TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$TEMPLATE]" + exit + fi + else + # Validate the Bicep template + echo "Validating Bicep template [$TEMPLATE]..." + output=$(az deployment group validate \ + --resource-group $RESOURCE_GROUP_NAME \ + --template-file $TEMPLATE \ + --parameters $PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + --only-show-errors) + + if [[ $? == 0 ]]; then + echo "Bicep template [$TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$TEMPLATE]" + echo "$output" + exit + fi + fi +fi + +# Deploy the Bicep template +echo "Deploying Bicep template [$TEMPLATE]..." +if DEPLOYMENT_OUTPUTS=$(az deployment group create \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors \ + --template-file $TEMPLATE \ + --parameters $PARAMETERS \ + --parameters location=$LOCATION \ + prefix=$PREFIX \ + suffix=$SUFFIX \ + --query 'properties.outputs' -o json); then + echo "Bicep template [$TEMPLATE] deployed successfully. Outputs:" + # Strip any non-JSON prefix (e.g. Bicep CLI messages) before parsing + DEPLOYMENT_JSON=$(echo "$DEPLOYMENT_OUTPUTS" | sed -n '/{/,$p') + echo "$DEPLOYMENT_JSON" | jq . + STORAGE_ACCOUNT_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.storageAccountName.value') + ACR_LOGIN_SERVER=$(echo "$DEPLOYMENT_JSON" | jq -r '.acrLoginServer.value') + ENVIRONMENT_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.environmentName.value') + APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.appName.value') + FQDN=$(echo "$DEPLOYMENT_JSON" | jq -r '.fqdn.value') + echo "Deployment details:" + echo "- storageAccountName: $STORAGE_ACCOUNT_NAME" + echo "- acrLoginServer: $ACR_LOGIN_SERVER" + echo "- environmentName: $ENVIRONMENT_NAME" + echo "- appName: $APP_NAME" + echo "- fqdn: $FQDN" +else + echo "Failed to deploy Bicep template [$TEMPLATE]" + exit 1 +fi diff --git a/samples/container-apps-blob-storage/python/bicep/main.bicep b/samples/container-apps-blob-storage/python/bicep/main.bicep new file mode 100644 index 0000000..a45aff3 --- /dev/null +++ b/samples/container-apps-blob-storage/python/bicep/main.bicep @@ -0,0 +1,194 @@ +@description('Specifies the prefix for the name of the Azure resources.') +@minLength(2) +param prefix string = take(uniqueString(resourceGroup().id), 4) + +@description('Specifies the suffix for the name of the Azure resources.') +@minLength(2) +param suffix string = take(uniqueString(resourceGroup().id), 4) + +@description('Specifies the location for all resources.') +param location string = resourceGroup().location + +@description('Specifies the sku of the Azure Storage account.') +param storageAccountSku string = 'Standard_LRS' + +@description('Specifies the name of the blob container.') +param containerName string = 'guestbook' + +@description('Specifies the SKU for the container registry.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param acrSku string = 'Basic' + +@description('Specifies the name of the container image.') +param imageName string = 'guestbook' + +@description('Specifies the tag of the container image.') +param imageTag string = 'v1' + +@description('Specifies the CPU cores allocated to the container, as a string so it stays a decimal.') +param containerCpu string = '0.5' + +@description('Specifies the memory allocated to the container.') +param containerMemory string = '1Gi' + +@description('Specifies the minimum number of replicas.') +@minValue(0) +param minReplicas int = 1 + +@description('Specifies the maximum number of replicas.') +@minValue(1) +param maxReplicas int = 3 + +@description('Specifies the revision suffix of the container app template.') +param revisionSuffix string = 'v1' + +@description('Specifies the tags to be applied to the resources.') +param tags object = { + environment: 'test' + iac: 'bicep' +} + +var storageAccountName = '${prefix}acastorage${suffix}' +var acrName = '${prefix}acaacr${suffix}' +var environmentName = '${prefix}-aca-env-${suffix}' +var appName = '${prefix}-aca-guestbook-${suffix}' + +// Storage Account +resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = { + name: storageAccountName + location: location + tags: tags + sku: { + name: storageAccountSku + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + } +} + +resource blobServices 'Microsoft.Storage/storageAccounts/blobServices@2025-01-01' = { + parent: storageAccount + name: 'default' +} + +resource blobContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-01-01' = { + parent: blobServices + name: containerName +} + +// Container Registry +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: acrName + location: location + tags: tags + sku: { + name: acrSku + } + properties: { + adminUserEnabled: true + } +} + +// Container Apps Managed Environment +resource managedEnvironment 'Microsoft.App/managedEnvironments@2025-07-01' = { + name: environmentName + location: location + tags: tags + properties: {} +} + +// Container App +resource containerApp 'Microsoft.App/containerApps@2025-07-01' = { + name: appName + location: location + tags: tags + properties: { + managedEnvironmentId: managedEnvironment.id + configuration: { + activeRevisionsMode: 'Multiple' + secrets: [ + { + name: 'storage-conn' + value: 'DefaultEndpointsProtocol=http;AccountName=${storageAccountName};AccountKey=${storageAccount.listKeys().keys[0].value};BlobEndpoint=${storageAccount.properties.primaryEndpoints.blob}' + } + { + name: 'registry-password' + value: containerRegistry.listCredentials().passwords[0].value + } + ] + registries: [ + { + server: containerRegistry.properties.loginServer + username: containerRegistry.listCredentials().username + passwordSecretRef: 'registry-password' + } + ] + ingress: { + external: true + targetPort: 8080 + transport: 'http' + allowInsecure: true + traffic: [ + { + latestRevision: true + weight: 100 + } + ] + } + } + template: { + revisionSuffix: revisionSuffix + containers: [ + { + name: imageName + image: '${containerRegistry.properties.loginServer}/${imageName}:${imageTag}' + resources: { + cpu: json(containerCpu) + memory: containerMemory + } + env: [ + { + name: 'AZURE_STORAGE_CONNECTION_STRING' + secretRef: 'storage-conn' + } + { + name: 'BLOB_CONTAINER_NAME' + value: containerName + } + { + name: 'APP_REVISION' + value: revisionSuffix + } + ] + } + ] + scale: { + minReplicas: minReplicas + maxReplicas: maxReplicas + rules: [ + { + name: 'http-scale' + http: { + metadata: { + concurrentRequests: '50' + } + } + } + ] + } + } + } +} + +output storageAccountName string = storageAccount.name +output acrName string = containerRegistry.name +output acrLoginServer string = containerRegistry.properties.loginServer +output environmentName string = managedEnvironment.name +output appName string = containerApp.name +output fqdn string = containerApp.properties.configuration.ingress.fqdn +output latestRevisionName string = containerApp.properties.latestRevisionName diff --git a/samples/container-apps-blob-storage/python/bicep/main.bicepparam b/samples/container-apps-blob-storage/python/bicep/main.bicepparam new file mode 100644 index 0000000..f7a72f8 --- /dev/null +++ b/samples/container-apps-blob-storage/python/bicep/main.bicepparam @@ -0,0 +1,6 @@ +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param imageName = 'guestbook' +param imageTag = 'v1' diff --git a/samples/container-apps-blob-storage/python/scripts/README.md b/samples/container-apps-blob-storage/python/scripts/README.md new file mode 100644 index 0000000..73a2c58 --- /dev/null +++ b/samples/container-apps-blob-storage/python/scripts/README.md @@ -0,0 +1,52 @@ +# Azure CLI Deployment + +This directory includes Bash scripts for deploying and testing the Container Apps Guestbook sample using the `lstk` CLI. Refer to the [Container Apps Blob Storage](../README.md) guide for details about the sample application. + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): Local Azure cloud emulator for development and testing +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) + +### Installing lstk CLI + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +The [deploy.sh](deploy.sh) script creates the following Azure resources using Azure CLI commands: + +1. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides blob storage for guestbook entries. +2. [Azure Container Registry](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-intro): Hosts the Docker container image for the Flask web app. +3. [Azure Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/overview): Runs the containerized Flask application behind the managed environment's HTTP ingress, with secrets, revisions and scale rules. + +For more information on the sample application, see [Container Apps Blob Storage](../README.md). + +## Deployment + +```bash +cd samples/container-apps-blob-storage/python +bash scripts/deploy.sh +``` + +## Validation + +```bash +bash scripts/validate.sh +``` + +## Cleanup + +```bash +bash scripts/cleanup.sh +``` + +## Related Documentation + +- [Azure CLI Documentation](https://docs.microsoft.com/en-us/cli/azure/) +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/container-apps-blob-storage/python/scripts/cleanup.sh b/samples/container-apps-blob-storage/python/scripts/cleanup.sh new file mode 100644 index 0000000..35564ca --- /dev/null +++ b/samples/container-apps-blob-storage/python/scripts/cleanup.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# ============================================================================= +# Container Apps Guestbook - Cleanup Script +# +# Removes all Azure resources created by deploy.sh. +# Deletes resources in reverse order to avoid dependency issues. +# ============================================================================= + +# Variables (must match deploy.sh) +PREFIX='local' +RESOURCE_GROUP_NAME="${PREFIX}-aca-rg" +ACA_APP_NAME="${PREFIX}-aca-guestbook" +ACA_ENV_NAME="${PREFIX}-aca-env" +ACR_NAME="${PREFIX}acaacr" +STORAGE_ACCOUNT_NAME="${PREFIX}acastorage" + +echo "============================================================" +echo "Cleaning up Container Apps Guestbook Resources" +echo "============================================================" +echo "" + +# 1. Delete the container app +echo "[1/5] Deleting container app [$ACA_APP_NAME]..." +az containerapp delete \ + --name "$ACA_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --yes \ + --only-show-errors 2>/dev/null && echo " Deleted: $ACA_APP_NAME" || echo " Skipped: $ACA_APP_NAME (not found)" +echo "" + +# 2. Delete the Container Apps environment +echo "[2/5] Deleting Container Apps environment [$ACA_ENV_NAME]..." +az containerapp env delete \ + --name "$ACA_ENV_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --yes \ + --only-show-errors 2>/dev/null && echo " Deleted: $ACA_ENV_NAME" || echo " Skipped: $ACA_ENV_NAME (not found)" +echo "" + +# 3. Delete ACR +echo "[3/5] Deleting ACR [$ACR_NAME]..." +az acr delete \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --yes \ + --only-show-errors 2>/dev/null && echo " Deleted: $ACR_NAME" || echo " Skipped: $ACR_NAME (not found)" +echo "" + +# 4. Delete Storage Account +echo "[4/5] Deleting Storage Account [$STORAGE_ACCOUNT_NAME]..." +az storage account delete \ + --name "$STORAGE_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --yes \ + --only-show-errors 2>/dev/null && echo " Deleted: $STORAGE_ACCOUNT_NAME" || echo " Skipped: $STORAGE_ACCOUNT_NAME (not found)" +echo "" + +# 5. Delete Resource Group +echo "[5/5] Deleting Resource Group [$RESOURCE_GROUP_NAME]..." +az group delete \ + --name "$RESOURCE_GROUP_NAME" \ + --yes \ + --only-show-errors 2>/dev/null && echo " Deleted: $RESOURCE_GROUP_NAME" || echo " Skipped: $RESOURCE_GROUP_NAME (not found)" +echo "" + +echo "============================================================" +echo "Cleanup complete." +echo "============================================================" diff --git a/samples/container-apps-blob-storage/python/scripts/deploy.sh b/samples/container-apps-blob-storage/python/scripts/deploy.sh new file mode 100644 index 0000000..3d6ca9d --- /dev/null +++ b/samples/container-apps-blob-storage/python/scripts/deploy.sh @@ -0,0 +1,353 @@ +#!/bin/bash + +# ============================================================================= +# Container Apps Guestbook - Deployment Script +# +# Deploys the Guestbook app using three Azure services: +# 1. Azure Blob Storage - Stores guestbook entries as a JSON blob +# 2. Azure Container Registry (ACR) - Hosts the Docker container image +# 3. Azure Container Apps - Runs the containerized Flask app behind the +# managed environment's HTTP ingress +# +# The storage connection string is stored as a Container Apps secret and +# injected into the container through a secretref environment variable. +# ============================================================================= + +# Variables +PREFIX='local' +LOCATION='eastus' +RESOURCE_GROUP_NAME="${PREFIX}-aca-rg" +STORAGE_ACCOUNT_NAME="${PREFIX}acastorage" +BLOB_CONTAINER_NAME="guestbook" +ACR_NAME="${PREFIX}acaacr" +ACA_ENV_NAME="${PREFIX}-aca-env" +ACA_APP_NAME="${PREFIX}-aca-guestbook" +IMAGE_NAME="guestbook" +IMAGE_TAG="v1" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit +# ============================================================================= +# Step 1: Create Resource Group +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 1: Creating resource group [$RESOURCE_GROUP_NAME]..." +echo "============================================================" +az group create \ + --name $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created successfully." +else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 +fi + +# ============================================================================= +# Step 2: Create Storage Account +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 2: Creating storage account [$STORAGE_ACCOUNT_NAME]..." +echo "============================================================" +az storage account create \ + --name $STORAGE_ACCOUNT_NAME \ + --location $LOCATION \ + --resource-group $RESOURCE_GROUP_NAME \ + --sku Standard_LRS \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Storage account [$STORAGE_ACCOUNT_NAME] created successfully." +else + echo "Failed to create storage account [$STORAGE_ACCOUNT_NAME]." + exit 1 +fi + +# ============================================================================= +# Step 3: Get Storage Account Key +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 3: Retrieving storage account key..." +echo "============================================================" +STORAGE_ACCOUNT_KEY=$(az storage account keys list \ + --account-name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "[0].value" \ + --output tsv) + +if [ -n "$STORAGE_ACCOUNT_KEY" ]; then + echo "Storage account key retrieved successfully." +else + echo "Failed to retrieve storage account key." + exit 1 +fi + +# ============================================================================= +# Step 4: Get Storage Blob Endpoint +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 4: Retrieving storage blob endpoint..." +echo "============================================================" +BLOB_ENDPOINT=$(az storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "primaryEndpoints.blob" \ + --output tsv \ + --only-show-errors) + +if [ -n "$BLOB_ENDPOINT" ]; then + echo "Blob endpoint: $BLOB_ENDPOINT" +else + echo "Failed to retrieve blob endpoint." + exit 1 +fi + +# Build the connection string using the original blob endpoint (resolvable from the host). +STORAGE_CONN_STRING="DefaultEndpointsProtocol=http;AccountName=${STORAGE_ACCOUNT_NAME};AccountKey=${STORAGE_ACCOUNT_KEY};BlobEndpoint=${BLOB_ENDPOINT}" +echo "Connection string built successfully." + +# For LocalStack, the Container Apps runtime configures the cluster with LocalStack's +# DNS server, so *.localhost.localstack.cloud resolves to the LocalStack container. +# We only need to downgrade HTTPS to HTTP (containers don't have the LS TLS cert). +if [[ $ENVIRONMENT == "LocalStack" ]]; then + CONTAINER_BLOB_ENDPOINT="${BLOB_ENDPOINT/https:\/\//http:\/\/}" + CONTAINER_CONN_STRING="DefaultEndpointsProtocol=http;AccountName=${STORAGE_ACCOUNT_NAME};AccountKey=${STORAGE_ACCOUNT_KEY};BlobEndpoint=${CONTAINER_BLOB_ENDPOINT}" + echo "Container blob endpoint: $CONTAINER_BLOB_ENDPOINT" +else + CONTAINER_CONN_STRING="$STORAGE_CONN_STRING" +fi + +# ============================================================================= +# Step 5: Create Blob Container +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 5: Creating blob container [$BLOB_CONTAINER_NAME]..." +echo "============================================================" +# Use --connection-string to ensure the correct endpoint is used +# (--account-name constructs its own hostname which may not match LocalStack's cert) +az storage container create \ + --name $BLOB_CONTAINER_NAME \ + --connection-string "$STORAGE_CONN_STRING" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Blob container [$BLOB_CONTAINER_NAME] created successfully." +else + echo "Failed to create blob container [$BLOB_CONTAINER_NAME]." + exit 1 +fi + +# ============================================================================= +# Step 6: Create Azure Container Registry (ACR) +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 6: Creating ACR [$ACR_NAME] with admin user enabled..." +echo "============================================================" +az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku Basic \ + --admin-enabled true \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "ACR [$ACR_NAME] created successfully." +else + echo "Failed to create ACR [$ACR_NAME]." + exit 1 +fi + +# ============================================================================= +# Step 7: Get ACR Login Server and Credentials +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 7: Retrieving ACR credentials..." +echo "============================================================" +LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +if [ -z "$LOGIN_SERVER" ]; then + echo "Failed to retrieve ACR login server." + exit 1 +fi +echo "ACR Login Server: $LOGIN_SERVER" + +ACR_USERNAME=$(az acr credential show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "username" \ + --output tsv \ + --only-show-errors) + +ACR_PASSWORD=$(az acr credential show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "passwords[0].value" \ + --output tsv \ + --only-show-errors) + +if [ -n "$ACR_USERNAME" ] && [ -n "$ACR_PASSWORD" ]; then + echo "ACR credentials retrieved successfully. Username: $ACR_USERNAME" +else + echo "Failed to retrieve ACR credentials." + exit 1 +fi + +# ============================================================================= +# Step 8: Build and Push Docker Image to ACR +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 8: Building and pushing Docker image to ACR..." +echo "============================================================" + +FULL_IMAGE="${LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Build the Docker image +echo "Building Docker image [$IMAGE_NAME:$IMAGE_TAG]..." +docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" ../src/ + +if [ $? -eq 0 ]; then + echo "Docker image built successfully." +else + echo "Failed to build Docker image." + exit 1 +fi + +# Tag for ACR +docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "$FULL_IMAGE" + +# Login to ACR +echo "Logging in to ACR [$LOGIN_SERVER]..." +echo "$ACR_PASSWORD" | docker login "$LOGIN_SERVER" --username "$ACR_USERNAME" --password-stdin 2>/dev/null + +if [ $? -eq 0 ]; then + echo "Logged in to ACR successfully." +else + echo "Warning: Failed to login to ACR. Will attempt push anyway." +fi + +# Push to ACR +echo "Pushing image [$FULL_IMAGE]..." +docker push "$FULL_IMAGE" 2>/dev/null + +if [ $? -eq 0 ]; then + echo "Image pushed to ACR successfully." +else + echo "Failed to push image to ACR." + exit 1 +fi + +# ============================================================================= +# Step 9: Create Container Apps Managed Environment +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 9: Creating Container Apps environment [$ACA_ENV_NAME]..." +echo "============================================================" +# --logs-destination none keeps the CLI from provisioning a Log Analytics +# workspace, which this sample does not use. +az containerapp env create \ + --name "$ACA_ENV_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --logs-destination none \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Container Apps environment [$ACA_ENV_NAME] created successfully." +else + echo "Failed to create Container Apps environment [$ACA_ENV_NAME]." + exit 1 +fi + +# ============================================================================= +# Step 10: Create Container App +# ============================================================================= +echo "" +echo "============================================================" +echo "Step 10: Creating container app [$ACA_APP_NAME]..." +echo "============================================================" +# The registry credentials are passed explicitly: the CLI only infers them from +# ARM when the server ends in ".azurecr.io", and LocalStack's loginServer is its +# own host. The storage connection string becomes the Container Apps secret +# [storage-conn], referenced from the container via a secretref env var. +az containerapp create \ + --name "$ACA_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --environment "$ACA_ENV_NAME" \ + --image "$FULL_IMAGE" \ + --container-name "$IMAGE_NAME" \ + --registry-server "$LOGIN_SERVER" \ + --registry-username "$ACR_USERNAME" \ + --registry-password "$ACR_PASSWORD" \ + --secrets storage-conn="$CONTAINER_CONN_STRING" \ + --env-vars \ + AZURE_STORAGE_CONNECTION_STRING=secretref:storage-conn \ + BLOB_CONTAINER_NAME="$BLOB_CONTAINER_NAME" \ + APP_REVISION="$IMAGE_TAG" \ + --ingress external \ + --target-port 8080 \ + --transport http \ + --allow-insecure \ + --revisions-mode multiple \ + --revision-suffix "$IMAGE_TAG" \ + --cpu 0.5 --memory 1Gi \ + --min-replicas 1 --max-replicas 3 \ + --scale-rule-name http-scale \ + --scale-rule-type http \ + --scale-rule-http-concurrency 50 \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Container app [$ACA_APP_NAME] created successfully." +else + echo "Failed to create container app [$ACA_APP_NAME]." + exit 1 +fi + +FQDN=$(az containerapp show \ + --name "$ACA_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "properties.configuration.ingress.fqdn" \ + --output tsv \ + --only-show-errors) + +# ============================================================================= +# Summary +# ============================================================================= +echo "" +echo "============================================================" +echo "Deployment Complete!" +echo "============================================================" +echo "Resource Group: $RESOURCE_GROUP_NAME" +echo "Storage Account: $STORAGE_ACCOUNT_NAME" +echo "Blob Container: $BLOB_CONTAINER_NAME" +echo "ACR: $ACR_NAME ($LOGIN_SERVER)" +echo "ACA Environment: $ACA_ENV_NAME" +echo "Container App: $ACA_APP_NAME" +echo "Image: $FULL_IMAGE" +echo "Ingress FQDN: $FQDN" +if [[ "$FQDN" == *"localhost.localstack.cloud"* ]]; then + echo "App URL: http://${FQDN}:4566/" +else + echo "App URL: https://${FQDN}/" +fi +echo "" +echo "Run 'bash scripts/validate.sh' to verify the deployment." +echo "============================================================" diff --git a/samples/container-apps-blob-storage/python/scripts/validate.sh b/samples/container-apps-blob-storage/python/scripts/validate.sh new file mode 100644 index 0000000..0310df0 --- /dev/null +++ b/samples/container-apps-blob-storage/python/scripts/validate.sh @@ -0,0 +1,327 @@ +#!/bin/bash + +# ============================================================================= +# Container Apps Guestbook - Validation Script +# +# Verifies that all Azure resources were deployed successfully and exercises +# the Container Apps surface: environment, app properties, secrets, revisions, +# replicas, the live HTTP ingress, and a revision update. +# ============================================================================= + +# Variables (must match deploy.sh) +PREFIX='local' +LOCATION='eastus' +RESOURCE_GROUP_NAME="${PREFIX}-aca-rg" +STORAGE_ACCOUNT_NAME="${PREFIX}acastorage" +BLOB_CONTAINER_NAME="guestbook" +ACR_NAME="${PREFIX}acaacr" +ACA_ENV_NAME="${PREFIX}-aca-env" +ACA_APP_NAME="${PREFIX}-aca-guestbook" + +PASS_COUNT=0 +FAIL_COUNT=0 + +check() { + local description="$1" + local command="$2" + + echo -n " Checking $description... " + eval "$command" &>/dev/null + if [ $? -eq 0 ]; then + echo "OK" + PASS_COUNT=$((PASS_COUNT + 1)) + else + echo "FAIL" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +check_output() { + local description="$1" + local command="$2" + local expected="$3" + + echo -n " Checking $description... " + OUTPUT=$(eval "$command" 2>/dev/null) + if echo "$OUTPUT" | grep -q "$expected"; then + echo "OK" + PASS_COUNT=$((PASS_COUNT + 1)) + else + echo "FAIL (expected '$expected')" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +echo "============================================================" +echo "Validating Container Apps Guestbook Deployment" +echo "============================================================" +echo "" + +# ============================================================================= +# Part 1: Infrastructure Resources +# ============================================================================= +echo "--- Part 1: Infrastructure Resources ---" +echo "" + +# 1. Resource Group +echo "[1/5] Resource Group" +check "resource group exists" "az group show --name $RESOURCE_GROUP_NAME" +echo "" + +# 2. Storage Account +echo "[2/5] Storage Account" +check "storage account exists" "az storage account show --name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME" +echo "" + +# 3. Container Registry +echo "[3/5] Container Registry" +check "ACR exists" "az acr show --name $ACR_NAME --resource-group $RESOURCE_GROUP_NAME" +echo "" + +# 4. Container Apps Environment +echo "[4/5] Container Apps Environment" +check "environment exists" "az containerapp env show --name $ACA_ENV_NAME --resource-group $RESOURCE_GROUP_NAME" +check_output "environment is provisioned" \ + "az containerapp env show --name $ACA_ENV_NAME --resource-group $RESOURCE_GROUP_NAME --query 'properties.provisioningState' --output tsv" \ + "Succeeded" +echo "" + +# 5. Container App +echo "[5/5] Container App" +check "container app exists" "az containerapp show --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME" +check_output "app is provisioned" \ + "az containerapp show --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query 'properties.provisioningState' --output tsv" \ + "Succeeded" +check_output "app is running" \ + "az containerapp show --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query 'properties.runningStatus' --output tsv" \ + "Running" +echo "" + +# ============================================================================= +# Part 2: Container App Configuration +# ============================================================================= +echo "--- Part 2: Container App Configuration ---" +echo "" + +APP_SHOW="az containerapp show --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME" + +# 6. Ingress +echo "[6] Ingress" +FQDN=$(eval "$APP_SHOW --query 'properties.configuration.ingress.fqdn' --output tsv" 2>/dev/null) +echo -n " Checking ingress FQDN is set... " +if [ -n "$FQDN" ]; then + echo "OK ($FQDN)" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "FAIL" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi +check_output "ingress is external" \ + "$APP_SHOW --query 'properties.configuration.ingress.external' --output tsv" \ + "true" +check_output "target port is 8080" \ + "$APP_SHOW --query 'properties.configuration.ingress.targetPort' --output tsv" \ + "8080" +echo "" + +# 7. Secrets +echo "[7] Secrets" +check_output "secret [storage-conn] exists" \ + "az containerapp secret list --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query '[].name' --output tsv" \ + "storage-conn" +check_output "secret value is readable" \ + "az containerapp secret show --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --secret-name storage-conn --query 'value' --output tsv" \ + "AccountName=${STORAGE_ACCOUNT_NAME}" +check_output "env var is wired to the secret" \ + "$APP_SHOW --query \"properties.template.containers[0].env[?name=='AZURE_STORAGE_CONNECTION_STRING'].secretRef | [0]\" --output tsv" \ + "storage-conn" +echo "" + +# 8. Scale +echo "[8] Scale" +check_output "min replicas is 1" \ + "$APP_SHOW --query 'properties.template.scale.minReplicas' --output tsv" \ + "1" +check_output "max replicas is 3" \ + "$APP_SHOW --query 'properties.template.scale.maxReplicas' --output tsv" \ + "3" +check_output "http scale rule is set" \ + "$APP_SHOW --query 'properties.template.scale.rules[0].name' --output tsv" \ + "http-scale" +echo "" + +# ============================================================================= +# Part 3: Revisions and Replicas +# ============================================================================= +echo "--- Part 3: Revisions and Replicas ---" +echo "" + +# 9. Revisions +echo "[9] Revisions" +check_output "revision v1 exists" \ + "az containerapp revision list --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query '[].name' --output tsv" \ + "${ACA_APP_NAME}--v1" +LATEST_REVISION=$(eval "$APP_SHOW --query 'properties.latestRevisionName' --output tsv" 2>/dev/null) +echo " Latest revision: $LATEST_REVISION" +echo "" + +# 10. Replicas +echo "[10] Replicas" +echo -n " Checking a replica is running... " +REPLICA_NAME="" +for i in $(seq 1 20); do + REPLICA_NAME=$(az containerapp replica list \ + --name "$ACA_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --revision "$LATEST_REVISION" \ + --query "[0].name" --output tsv 2>/dev/null) + if [ -n "$REPLICA_NAME" ]; then + break + fi + sleep 3 +done +if [ -n "$REPLICA_NAME" ]; then + echo "OK ($REPLICA_NAME)" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "FAIL" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi +echo "" + +# ============================================================================= +# Part 4: HTTP Ingress (live app) +# ============================================================================= +echo "--- Part 4: HTTP Ingress ---" +echo "" + +# On LocalStack the FQDN resolves to 127.0.0.1 and the ingress listens on the +# gateway port; on real Azure the app is served on 443. +if [[ "$FQDN" == *"localhost.localstack.cloud"* ]]; then + APP_URL="http://${FQDN}:4566" +else + APP_URL="https://${FQDN}" +fi +echo "App URL: $APP_URL" + +# 11. Health endpoint (wait for the app to come up; the image may still be pulling) +echo "[11] Health Endpoint" +echo -n " Waiting for /health to answer... " +HEALTH="" +for i in $(seq 1 30); do + HEALTH=$(curl -s --max-time 5 "$APP_URL/health" 2>/dev/null) + if echo "$HEALTH" | grep -q "healthy"; then + break + fi + sleep 3 +done +if echo "$HEALTH" | grep -q "healthy"; then + echo "OK" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "FAIL (no healthy response from $APP_URL/health)" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi +check_output "storage is configured in the app" \ + "curl -s --max-time 5 $APP_URL/health" \ + '"storage_configured": *true' +echo "" + +# 12. Guestbook round trip (POST an entry, read it back) +echo "[12] Guestbook Round Trip" +ENTRY_AUTHOR="validate-sh" +ENTRY_MESSAGE="Hello from validate.sh at $(date +%s)" +echo -n " Posting a guestbook entry... " +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ + -X POST "$APP_URL/" \ + --data-urlencode "author=$ENTRY_AUTHOR" \ + --data-urlencode "message=$ENTRY_MESSAGE" 2>/dev/null) +if [[ "$HTTP_CODE" == "302" || "$HTTP_CODE" == "200" ]]; then + echo "OK (HTTP $HTTP_CODE)" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "FAIL (HTTP $HTTP_CODE)" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi +check_output "entry is served back from Blob Storage" \ + "curl -s --max-time 10 $APP_URL/" \ + "$ENTRY_MESSAGE" +echo "" + +# ============================================================================= +# Part 5: Revision Update +# ============================================================================= +echo "--- Part 5: Revision Update ---" +echo "" + +# 13. Roll out a new revision (v2) with an updated APP_REVISION env var. +# The secretref is re-stated alongside the changed var so the update payload +# carries the secret wiring explicitly rather than relying on the CLI's +# read-modify-write merge of the env list. +echo "[13] Revision Update" +echo -n " Rolling out revision v2... " +az containerapp update \ + --name "$ACA_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --revision-suffix v2 \ + --set-env-vars \ + APP_REVISION=v2 \ + AZURE_STORAGE_CONNECTION_STRING=secretref:storage-conn \ + --only-show-errors 1>/dev/null 2>&1 + +if [ $? -eq 0 ]; then + echo "OK" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "FAIL" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi + +check_output "revision v2 exists" \ + "az containerapp revision list --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query '[].name' --output tsv" \ + "${ACA_APP_NAME}--v2" +check_output "latest revision is v2" \ + "$APP_SHOW --query 'properties.latestRevisionName' --output tsv" \ + "${ACA_APP_NAME}--v2" + +# The ingress routes to the latest revision; wait for v2 replicas to serve. +echo -n " Waiting for revision v2 to serve traffic... " +SERVED_REVISION="" +for i in $(seq 1 30); do + SERVED_REVISION=$(curl -s --max-time 5 "$APP_URL/health" 2>/dev/null | grep -o '"revision": *"[^"]*"') + if echo "$SERVED_REVISION" | grep -q "v2"; then + break + fi + sleep 3 +done +if echo "$SERVED_REVISION" | grep -q "v2"; then + echo "OK" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "FAIL (still serving: ${SERVED_REVISION:-no response})" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi + +check_output "entries survive the revision switch" \ + "curl -s --max-time 10 $APP_URL/" \ + "$ENTRY_MESSAGE" +echo "" + +# ============================================================================= +# Summary +# ============================================================================= +echo "============================================================" +echo "Validation Results: $PASS_COUNT passed, $FAIL_COUNT failed" +echo "============================================================" +echo "" +echo "--- App Access ---" +echo "App URL: $APP_URL" +echo "" + +if [ $FAIL_COUNT -eq 0 ]; then + echo "PASS: All checks passed. Guestbook is running on Azure Container Apps." + exit 0 +else + echo "FAIL: Some checks failed. Review the output above." + exit 1 +fi diff --git a/samples/container-apps-blob-storage/python/src/Dockerfile b/samples/container-apps-blob-storage/python/src/Dockerfile new file mode 100644 index 0000000..804ee27 --- /dev/null +++ b/samples/container-apps-blob-storage/python/src/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8080 + +CMD ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=8080"] diff --git a/samples/container-apps-blob-storage/python/src/app.py b/samples/container-apps-blob-storage/python/src/app.py new file mode 100644 index 0000000..ab20503 --- /dev/null +++ b/samples/container-apps-blob-storage/python/src/app.py @@ -0,0 +1,102 @@ +"""Flask application for a guestbook backed by Azure Blob Storage. + +This is the Azure Container Apps variant of the sample apps. +It stores guestbook entries as a JSON blob in Azure Blob Storage and runs as a +container app behind the environment's HTTP ingress. The APP_REVISION env var +is surfaced in the UI and the /health endpoint so revision switches performed +with `az containerapp update` are observable over HTTP. +""" + +import logging +import os + +from blob_storage_client import BlobGuestbookClient +from flask import Flask, jsonify, redirect, render_template, request, url_for + +app: Flask = Flask(__name__) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + +logging.getLogger("urllib3").setLevel(logging.WARNING) +logging.getLogger("azure").setLevel(logging.WARNING) +logging.getLogger("werkzeug").setLevel(logging.INFO) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +# Global state +guestbook_client: BlobGuestbookClient | None = None + +app_revision = os.environ.get("APP_REVISION", "v1") + + +@app.route("/", methods=["GET", "POST"]) +def index(): + """Handle the main page for viewing and signing the guestbook.""" + if request.method == "POST": + author = request.form.get("author") + message = request.form.get("message") + if author and message and guestbook_client: + try: + entry = guestbook_client.insert_entry(author, message) + logger.info("Entry created: %s", entry["id"]) + except (ConnectionError, ValueError) as e: + logger.error("Error creating entry: %s", e) + + return redirect(url_for("index")) + + entries = [] + try: + if guestbook_client: + entries = guestbook_client.read_entries() + except (ConnectionError, ValueError, KeyError) as e: + logger.error("Error reading entries: %s", e) + + return render_template( + "index.html", + entries=entries, + app_revision=app_revision, + ) + + +@app.route("/delete/", methods=["POST"]) +def delete(entry_id: str): + """Handle deletion of an entry by its ID.""" + try: + if guestbook_client: + deleted = guestbook_client.delete_entry_by_id(entry_id) + if deleted > 0: + logger.info("Entry deleted: %s", entry_id) + else: + logger.warning("No entry found with ID: %s", entry_id) + except (ConnectionError, ValueError) as e: + logger.error("Error deleting entry: %s", e) + + return redirect(url_for("index")) + + +@app.route("/health") +def health(): + """Health check endpoint for validation.""" + return jsonify( + { + "status": "healthy", + "revision": app_revision, + "storage_configured": guestbook_client is not None, + } + ), 200 + + +# Initialize the Blob Storage client on module load +guestbook_client = BlobGuestbookClient.from_env() + +if guestbook_client: + logger.info("Blob Storage client initialized (revision: %s)", app_revision) +else: + logger.warning("Blob Storage client not initialized. Running without persistence.") + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080) diff --git a/samples/container-apps-blob-storage/python/src/blob_storage_client.py b/samples/container-apps-blob-storage/python/src/blob_storage_client.py new file mode 100644 index 0000000..13a1181 --- /dev/null +++ b/samples/container-apps-blob-storage/python/src/blob_storage_client.py @@ -0,0 +1,125 @@ +"""Blob Storage client for guestbook entries. + +Stores all guestbook entries as a single JSON blob in Azure Blob Storage. +""" + +import json +import logging +import os +import uuid +from datetime import datetime + +from azure.core.exceptions import ResourceNotFoundError +from azure.storage.blob import BlobServiceClient + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +ENTRIES_BLOB_NAME = "entries.json" + + +class BlobGuestbookClient: + """CRUD operations for guestbook entries using Azure Blob Storage. + + All entries are stored in a single JSON blob: + entries.json + """ + + def __init__(self, connection_string: str, container_name: str): + self.blob_service = BlobServiceClient.from_connection_string(connection_string) + self.container_client = self.blob_service.get_container_client(container_name) + + @classmethod + def from_env(cls) -> "BlobGuestbookClient | None": + """Create from environment variables. + + Required env vars: + AZURE_STORAGE_CONNECTION_STRING - Blob Storage connection string + BLOB_CONTAINER_NAME - Name of the blob container + """ + connection_string = os.environ.get("AZURE_STORAGE_CONNECTION_STRING") + container_name = os.environ.get("BLOB_CONTAINER_NAME") + + if not connection_string or not container_name: + logger.warning( + "AZURE_STORAGE_CONNECTION_STRING or BLOB_CONTAINER_NAME not set. " + "Blob storage client not initialized." + ) + return None + + logger.info("Initializing Blob Storage client for container: %s", container_name) + return cls(connection_string, container_name) + + def _read_blob(self) -> list[dict[str, str]]: + """Download and parse the JSON blob. Returns [] if the blob doesn't exist.""" + try: + blob_client = self.container_client.get_blob_client(ENTRIES_BLOB_NAME) + data = blob_client.download_blob().readall() + entries = json.loads(data) + logger.info("Read %d guestbook entries", len(entries)) + return entries + except ResourceNotFoundError: + logger.info("No guestbook blob found yet") + return [] + except Exception as e: + logger.error("Error reading guestbook entries: %s", e) + return [] + + def _write_blob(self, entries: list[dict[str, str]]): + """Upload the entries list as a JSON blob (overwrite).""" + try: + blob_client = self.container_client.get_blob_client(ENTRIES_BLOB_NAME) + data = json.dumps(entries, indent=2) + blob_client.upload_blob(data, overwrite=True) + logger.info("Wrote %d guestbook entries", len(entries)) + except Exception as e: + logger.error("Error writing guestbook entries: %s", e) + raise + + def read_entries(self) -> list[dict[str, str]]: + """Read all guestbook entries, newest first.""" + entries = self._read_blob() + return sorted(entries, key=lambda e: e.get("timestamp", ""), reverse=True) + + def insert_entry(self, author: str, message: str) -> dict[str, str]: + """Insert a new guestbook entry. + + Returns: + The inserted entry with generated 'id' and 'timestamp' + """ + if not author or not author.strip(): + raise ValueError("Author cannot be None or empty") + if not message or not message.strip(): + raise ValueError("Message cannot be None or empty") + + entries = self._read_blob() + entry = { + "id": str(uuid.uuid4()), + "author": author.strip(), + "message": message.strip(), + "timestamp": datetime.now().isoformat(), + } + entries.append(entry) + + self._write_blob(entries) + logger.info("Inserted guestbook entry %s", entry["id"]) + return entry + + def delete_entry_by_id(self, entry_id: str) -> int: + """Delete an entry by its ID. + + Returns: + Number of entries deleted (0 or 1) + """ + if not entry_id: + raise ValueError("Entry ID cannot be None or empty") + + entries = self._read_blob() + new_entries = [e for e in entries if e.get("id") != entry_id] + deleted_count = len(entries) - len(new_entries) + + if deleted_count > 0: + self._write_blob(new_entries) + logger.info("Deleted guestbook entry %s", entry_id) + + return deleted_count diff --git a/samples/container-apps-blob-storage/python/src/requirements.txt b/samples/container-apps-blob-storage/python/src/requirements.txt new file mode 100644 index 0000000..9e4913b --- /dev/null +++ b/samples/container-apps-blob-storage/python/src/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.1.3 +azure-storage-blob==12.25.1 diff --git a/samples/container-apps-blob-storage/python/src/templates/index.html b/samples/container-apps-blob-storage/python/src/templates/index.html new file mode 100644 index 0000000..4d90053 --- /dev/null +++ b/samples/container-apps-blob-storage/python/src/templates/index.html @@ -0,0 +1,68 @@ + + + + + + Container Apps Guestbook + + + +
+
+

📝 Guestbook

+ Served by revision {{ app_revision }} +
+

A Flask app running on Azure Container Apps, storing entries in Azure Blob Storage.

+ +
+ + + +
+ + {% if entries %} + {% for entry in entries %} +
+
+ {{ entry.author }} + + {{ entry.timestamp[:19].replace('T', ' ') }} +
+ +
+
+
+
{{ entry.message }}
+
+ {% endfor %} + {% else %} +

No entries yet. Be the first to sign!

+ {% endif %} +
+ + diff --git a/samples/container-apps-blob-storage/python/terraform/README.md b/samples/container-apps-blob-storage/python/terraform/README.md new file mode 100644 index 0000000..1e03c8a --- /dev/null +++ b/samples/container-apps-blob-storage/python/terraform/README.md @@ -0,0 +1,69 @@ +# Terraform Deployment + +This directory contains Terraform modules and a deployment script for provisioning Azure services in LocalStack for Azure. Refer to the [Container Apps Blob Storage](../README.md) guide for details about the sample application. + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/): Local Azure cloud emulator for development and testing +- [Terraform](https://developer.hashicorp.com/terraform/downloads): Infrastructure as Code tool +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/): LocalStack command-line interface (proxies the Azure CLI via `lstk az`) + +### Installing lstk CLI + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +The [deploy.sh](deploy.sh) script first builds and pushes the Docker image to a pre-created ACR, then the [main.tf](main.tf) Terraform module creates the following Azure resources: + +1. [Azure Storage Account](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview): Provides blob storage for guestbook entries. +2. [Azure Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/overview): A managed environment ([azurerm_container_app_environment](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app_environment)) and a container app ([azurerm_container_app](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app)) with secrets, external HTTP ingress, and an HTTP scale rule. + +For more information on the sample application, see [Container Apps Blob Storage](../README.md). + +## Configuration + +When using LocalStack for Azure, configure the `metadata_host` and `subscription_id` settings in the [Azure Provider for Terraform](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs): + +```hcl +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } + metadata_host="localhost.localstack.cloud:4566" + subscription_id = "00000000-0000-0000-0000-000000000000" +} +``` + +## Deployment + +```bash +cd samples/container-apps-blob-storage/python +bash terraform/deploy.sh +``` + +## Cleanup + +```bash +bash scripts/cleanup.sh +``` + +To also clean up Terraform state: + +```bash +cd terraform +rm -rf .terraform terraform.tfstate terraform.tfstate.backup .terraform.lock.hcl tfplan +``` + +## Related Documentation + +- [Terraform Azure Provider](https://registry.terraform.io/providers/hashicorp/azurerm/latest) +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/container-apps-blob-storage/python/terraform/deploy.sh b/samples/container-apps-blob-storage/python/terraform/deploy.sh new file mode 100644 index 0000000..2e436c0 --- /dev/null +++ b/samples/container-apps-blob-storage/python/terraform/deploy.sh @@ -0,0 +1,137 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='eastus' +IMAGE_NAME='guestbook' +IMAGE_TAG='v1' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# ============================================================================= +# Build and push the Docker image before Terraform deployment +# (Terraform references the pre-created ACR as a data source) +# ============================================================================= + +# Create resource group and ACR first so we can push the image +RESOURCE_GROUP_NAME="${PREFIX}-aca-rg" +ACR_NAME="${PREFIX}acaacr${SUFFIX}" + +echo "Creating resource group [$RESOURCE_GROUP_NAME]..." +az group create \ + --name "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + +echo "Creating ACR [$ACR_NAME] for image push..." +az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku Basic \ + --admin-enabled true \ + --only-show-errors 1>/dev/null + +LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +ACR_USERNAME=$(az acr credential show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "username" \ + --output tsv \ + --only-show-errors) + +ACR_PASSWORD=$(az acr credential show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "passwords[0].value" \ + --output tsv \ + --only-show-errors) + +FULL_IMAGE="${LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +echo "Building Docker image [$IMAGE_NAME:$IMAGE_TAG]..." +docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" ../src/ + +if [[ $? != 0 ]]; then + echo "Failed to build Docker image." + exit 1 +fi + +docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "$FULL_IMAGE" + +echo "Logging in to ACR [$LOGIN_SERVER]..." +echo "$ACR_PASSWORD" | docker login "$LOGIN_SERVER" --username "$ACR_USERNAME" --password-stdin 2>/dev/null + +echo "Pushing image [$FULL_IMAGE]..." +docker push "$FULL_IMAGE" 2>/dev/null + +if [[ $? != 0 ]]; then + echo "Failed to push image to ACR." + exit 1 +fi +echo "Image pushed to ACR successfully." + +# ============================================================================= +# Terraform init, plan, and apply +# ============================================================================= + +echo "Initializing Terraform..." +terraform init -upgrade + +# Import the resource group that was pre-created for the image push +echo "Importing pre-created resource group into Terraform state..." +terraform import \ + -var "prefix=$PREFIX" \ + -var "suffix=$SUFFIX" \ + -var "location=$LOCATION" \ + -var "image_name=$IMAGE_NAME" \ + -var "image_tag=$IMAGE_TAG" \ + azurerm_resource_group.example \ + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/${RESOURCE_GROUP_NAME}" 2>/dev/null || true + +# Run terraform plan and check for errors +echo "Planning Terraform deployment..." +terraform plan -out=tfplan \ + -var "prefix=$PREFIX" \ + -var "suffix=$SUFFIX" \ + -var "location=$LOCATION" \ + -var "image_name=$IMAGE_NAME" \ + -var "image_tag=$IMAGE_TAG" + +# 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) +ACR_NAME=$(terraform output -raw acr_name) +ENVIRONMENT_NAME=$(terraform output -raw environment_name) +APP_NAME=$(terraform output -raw app_name) +FQDN=$(terraform output -raw fqdn) + +echo "" +echo "============================================================" +echo "Deployment Complete!" +echo "============================================================" +echo "Resource Group: $RESOURCE_GROUP_NAME" +echo "Storage Account: $STORAGE_ACCOUNT_NAME" +echo "ACR: $ACR_NAME" +echo "ACA Environment: $ENVIRONMENT_NAME" +echo "Container App: $APP_NAME" +echo "Ingress FQDN: $FQDN" +echo "============================================================" diff --git a/samples/container-apps-blob-storage/python/terraform/main.tf b/samples/container-apps-blob-storage/python/terraform/main.tf new file mode 100644 index 0000000..0d346d4 --- /dev/null +++ b/samples/container-apps-blob-storage/python/terraform/main.tf @@ -0,0 +1,150 @@ +# Local Variables +locals { + resource_group_name = "${var.prefix}-aca-rg" + storage_account_name = "${var.prefix}acastorage${var.suffix}" + acr_name = "${var.prefix}acaacr${var.suffix}" + environment_name = "${var.prefix}-aca-env-${var.suffix}" + app_name = "${var.prefix}-aca-guestbook-${var.suffix}" + + # Secret NAMES referenced from more than one block, kept in one place so the + # references never drift apart. + storage_conn_secret_name = "storage-conn" + registry_password_secret_name = "registry-password" +} + +# Create a resource group +resource "azurerm_resource_group" "example" { + name = local.resource_group_name + location = var.location + tags = var.tags + + lifecycle { + # deploy.sh pre-creates this resource group with `az group create` (untagged) and + # imports it into the Terraform state. Ignoring tag drift avoids an in-place + # resource-group update on the next apply, which azurerm >= 4.x issues as a PATCH + # request that the LocalStack Azure emulator does not implement yet. + ignore_changes = [tags] + } +} + +# Create a storage account +resource "azurerm_storage_account" "example" { + name = local.storage_account_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + account_replication_type = var.account_replication_type + account_kind = "StorageV2" + account_tier = var.account_tier + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Create blob container +resource "azurerm_storage_container" "example" { + name = var.blob_container_name + storage_account_id = azurerm_storage_account.example.id + container_access_type = "private" +} + +# Reference the pre-created ACR (created by deploy.sh before terraform apply) +data "azurerm_container_registry" "example" { + name = local.acr_name + resource_group_name = azurerm_resource_group.example.name +} + +# Create the Container Apps managed environment +resource "azurerm_container_app_environment" "example" { + name = local.environment_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +# Create the container app +resource "azurerm_container_app" "example" { + name = local.app_name + container_app_environment_id = azurerm_container_app_environment.example.id + resource_group_name = azurerm_resource_group.example.name + revision_mode = "Multiple" + tags = var.tags + + # The storage connection string is a Container Apps secret, referenced from + # the container below via secret_name. + secret { + name = local.storage_conn_secret_name + value = "DefaultEndpointsProtocol=http;AccountName=${azurerm_storage_account.example.name};AccountKey=${azurerm_storage_account.example.primary_access_key};BlobEndpoint=${azurerm_storage_account.example.primary_blob_endpoint}" + } + + secret { + name = local.registry_password_secret_name + value = data.azurerm_container_registry.example.admin_password + } + + registry { + server = data.azurerm_container_registry.example.login_server + username = data.azurerm_container_registry.example.admin_username + password_secret_name = local.registry_password_secret_name + } + + ingress { + external_enabled = true + target_port = 8080 + transport = "http" + allow_insecure_connections = true + + traffic_weight { + latest_revision = true + percentage = 100 + } + } + + template { + min_replicas = var.min_replicas + max_replicas = var.max_replicas + revision_suffix = var.image_tag + + container { + name = var.image_name + image = "${data.azurerm_container_registry.example.login_server}/${var.image_name}:${var.image_tag}" + cpu = var.cpu_cores + memory = var.memory + + env { + name = "AZURE_STORAGE_CONNECTION_STRING" + secret_name = local.storage_conn_secret_name + } + + env { + name = "BLOB_CONTAINER_NAME" + value = var.blob_container_name + } + + env { + name = "APP_REVISION" + value = var.image_tag + } + } + + http_scale_rule { + name = "http-scale" + concurrent_requests = "50" + } + } + + lifecycle { + ignore_changes = [ + tags + ] + } +} diff --git a/samples/container-apps-blob-storage/python/terraform/outputs.tf b/samples/container-apps-blob-storage/python/terraform/outputs.tf new file mode 100644 index 0000000..0b5557e --- /dev/null +++ b/samples/container-apps-blob-storage/python/terraform/outputs.tf @@ -0,0 +1,31 @@ +output "resource_group_name" { + value = azurerm_resource_group.example.name +} + +output "storage_account_name" { + value = azurerm_storage_account.example.name +} + +output "acr_name" { + value = data.azurerm_container_registry.example.name +} + +output "acr_login_server" { + value = data.azurerm_container_registry.example.login_server +} + +output "environment_name" { + value = azurerm_container_app_environment.example.name +} + +output "app_name" { + value = azurerm_container_app.example.name +} + +output "fqdn" { + value = azurerm_container_app.example.ingress[0].fqdn +} + +output "latest_revision_name" { + value = azurerm_container_app.example.latest_revision_name +} diff --git a/samples/container-apps-blob-storage/python/terraform/providers.tf b/samples/container-apps-blob-storage/python/terraform/providers.tf new file mode 100644 index 0000000..d01802f --- /dev/null +++ b/samples/container-apps-blob-storage/python/terraform/providers.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=4.81.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/container-apps-blob-storage/python/terraform/variables.tf b/samples/container-apps-blob-storage/python/terraform/variables.tf new file mode 100644 index 0000000..c417a31 --- /dev/null +++ b/samples/container-apps-blob-storage/python/terraform/variables.tf @@ -0,0 +1,112 @@ +variable "prefix" { + description = "(Optional) Specifies the prefix for the name of the Azure resources." + type = string + default = "local" + + validation { + condition = var.prefix == null || length(var.prefix) >= 2 + error_message = "The prefix must be at least 2 characters long." + } +} + +variable "suffix" { + description = "(Optional) Specifies the suffix for the name of the Azure resources." + type = string + default = "test" + + validation { + condition = var.suffix == null || length(var.suffix) >= 2 + error_message = "The suffix must be at least 2 characters long." + } +} + +variable "location" { + description = "(Required) Specifies the location for all resources." + type = string + default = null +} + +variable "account_replication_type" { + description = "(Optional) Specifies the replication type for the storage account." + type = string + default = "LRS" + + validation { + condition = contains([ + "LRS", + "GRS", + "RAGRS", + "ZRS", + "GZRS", + "RAGZRS" + ], var.account_replication_type) + error_message = "The account_replication_type must be one of: LRS, GRS, RAGRS, ZRS, GZRS, RAGZRS." + } +} + +variable "account_tier" { + description = "(Optional) Specifies the account tier of the storage account." + default = "Standard" + type = string + + validation { + condition = contains(["Standard", "Premium"], var.account_tier) + error_message = "The account tier of the storage account is invalid." + } +} + +variable "blob_container_name" { + description = "(Optional) Specifies the name of the blob container." + type = string + default = "guestbook" + + validation { + condition = can(regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", var.blob_container_name)) + error_message = "Container name must be lowercase alphanumeric characters or hyphens." + } +} + +variable "image_name" { + description = "(Optional) Specifies the name of the container image." + type = string + default = "guestbook" +} + +variable "image_tag" { + description = "(Optional) Specifies the tag of the container image, also used as the revision suffix." + type = string + default = "v1" +} + +variable "cpu_cores" { + description = "(Optional) Specifies the number of CPU cores for the container." + type = number + default = 0.5 +} + +variable "memory" { + description = "(Optional) Specifies the memory for the container." + type = string + default = "1Gi" +} + +variable "min_replicas" { + description = "(Optional) Specifies the minimum number of replicas." + type = number + default = 1 +} + +variable "max_replicas" { + description = "(Optional) Specifies the maximum number of replicas." + type = number + default = 3 +} + +variable "tags" { + description = "(Optional) Specifies the tags to be applied to the resources." + type = map(string) + default = { + environment = "test" + iac = "terraform" + } +} From 76503b3bc716b5cbdb8831005cefdccb5d73822d Mon Sep 17 00:00:00 2001 From: Lazar Kanelov Date: Wed, 12 Aug 2026 17:48:50 +0300 Subject: [PATCH 2/5] Address PR review: safe blob reads, ETag concurrency, AzureError handling - blob_storage_client: a failed read no longer masquerades as an empty guestbook (only ResourceNotFoundError maps to []), so a read-modify-write can no longer truncate entries.json after a read error. - blob_storage_client: writes are ETag-conditional (If-Match, or overwrite=False for the first write) with jittered-backoff retries, so concurrent replicas cannot lose each other's updates. Verified with 10 simultaneous POSTs: 10/10 entries survive (5 attempts without backoff lost 2 to retry exhaustion under the thundering herd). - app: handlers catch AzureError/RuntimeError instead of the builtin ConnectionError the Azure SDK never raises, and log full stack traces. - validate.sh/deploy.sh: match the LocalStack FQDN as a suffix. - README: link the separate AKS samples repository (review suggestion). --- README.md | 3 + .../python/scripts/deploy.sh | 2 +- .../python/scripts/validate.sh | 2 +- .../python/src/app.py | 13 +- .../python/src/blob_storage_client.py | 111 ++++++++++++------ 5 files changed, 89 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 54e081b..b98547d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ This repository contains comprehensive sample projects demonstrating how to develop and test Azure cloud applications locally using [LocalStack for Azure](https://localstack.cloud/). Each sample provides complete infrastructure-as-code templates, application code, and deployment instructions for seamless local development. +> [!NOTE] +> Azure Kubernetes Service (AKS) samples and tutorials live in a separate repository, [localstack-samples/aks-samples](https://github.com/localstack-samples/aks-samples). It covers cluster provisioning, application deployments backed by Azure data services, and standalone walkthroughs of individual AKS capabilities such as network policies, KEDA autoscaling, the Gateway API, and the Key Vault CSI driver. Everything there runs unchanged against both Azure and the emulator. + ## Prerequisites ### Required Tools diff --git a/samples/container-apps-blob-storage/python/scripts/deploy.sh b/samples/container-apps-blob-storage/python/scripts/deploy.sh index 3d6ca9d..79060be 100644 --- a/samples/container-apps-blob-storage/python/scripts/deploy.sh +++ b/samples/container-apps-blob-storage/python/scripts/deploy.sh @@ -343,7 +343,7 @@ echo "ACA Environment: $ACA_ENV_NAME" echo "Container App: $ACA_APP_NAME" echo "Image: $FULL_IMAGE" echo "Ingress FQDN: $FQDN" -if [[ "$FQDN" == *"localhost.localstack.cloud"* ]]; then +if [[ "$FQDN" == *"localhost.localstack.cloud" ]]; then echo "App URL: http://${FQDN}:4566/" else echo "App URL: https://${FQDN}/" diff --git a/samples/container-apps-blob-storage/python/scripts/validate.sh b/samples/container-apps-blob-storage/python/scripts/validate.sh index 0310df0..4017672 100644 --- a/samples/container-apps-blob-storage/python/scripts/validate.sh +++ b/samples/container-apps-blob-storage/python/scripts/validate.sh @@ -197,7 +197,7 @@ echo "" # On LocalStack the FQDN resolves to 127.0.0.1 and the ingress listens on the # gateway port; on real Azure the app is served on 443. -if [[ "$FQDN" == *"localhost.localstack.cloud"* ]]; then +if [[ "$FQDN" == *"localhost.localstack.cloud" ]]; then APP_URL="http://${FQDN}:4566" else APP_URL="https://${FQDN}" diff --git a/samples/container-apps-blob-storage/python/src/app.py b/samples/container-apps-blob-storage/python/src/app.py index ab20503..c04db01 100644 --- a/samples/container-apps-blob-storage/python/src/app.py +++ b/samples/container-apps-blob-storage/python/src/app.py @@ -10,6 +10,7 @@ import logging import os +from azure.core.exceptions import AzureError from blob_storage_client import BlobGuestbookClient from flask import Flask, jsonify, redirect, render_template, request, url_for @@ -43,8 +44,8 @@ def index(): try: entry = guestbook_client.insert_entry(author, message) logger.info("Entry created: %s", entry["id"]) - except (ConnectionError, ValueError) as e: - logger.error("Error creating entry: %s", e) + except (AzureError, RuntimeError, ValueError): + logger.exception("Error creating entry") return redirect(url_for("index")) @@ -52,8 +53,8 @@ def index(): try: if guestbook_client: entries = guestbook_client.read_entries() - except (ConnectionError, ValueError, KeyError) as e: - logger.error("Error reading entries: %s", e) + except (AzureError, ValueError, KeyError): + logger.exception("Error reading entries") return render_template( "index.html", @@ -72,8 +73,8 @@ def delete(entry_id: str): logger.info("Entry deleted: %s", entry_id) else: logger.warning("No entry found with ID: %s", entry_id) - except (ConnectionError, ValueError) as e: - logger.error("Error deleting entry: %s", e) + except (AzureError, RuntimeError, ValueError): + logger.exception("Error deleting entry") return redirect(url_for("index")) diff --git a/samples/container-apps-blob-storage/python/src/blob_storage_client.py b/samples/container-apps-blob-storage/python/src/blob_storage_client.py index 13a1181..3cb6b13 100644 --- a/samples/container-apps-blob-storage/python/src/blob_storage_client.py +++ b/samples/container-apps-blob-storage/python/src/blob_storage_client.py @@ -1,15 +1,24 @@ """Blob Storage client for guestbook entries. Stores all guestbook entries as a single JSON blob in Azure Blob Storage. +Writes use optimistic concurrency (ETag conditions), so concurrent replicas +of the container app cannot lose each other's updates. """ import json import logging import os +import random +import time import uuid from datetime import datetime -from azure.core.exceptions import ResourceNotFoundError +from azure.core import MatchConditions +from azure.core.exceptions import ( + ResourceExistsError, + ResourceModifiedError, + ResourceNotFoundError, +) from azure.storage.blob import BlobServiceClient logger = logging.getLogger(__name__) @@ -17,6 +26,17 @@ ENTRIES_BLOB_NAME = "entries.json" +# Attempts per read-modify-write before giving up; each retry re-reads the +# blob, so a retry is only consumed when another writer got in between. The +# jittered backoff below desynchronizes concurrent losers, so the bound is +# about tolerating a burst of simultaneous writers, not elapsed time. +MAX_WRITE_ATTEMPTS = 10 + + +def _backoff(attempt: int) -> None: + """Sleep briefly with jitter so concurrent writers stop colliding.""" + time.sleep(random.uniform(0.05, 0.15) * (attempt + 1)) + class BlobGuestbookClient: """CRUD operations for guestbook entries using Azure Blob Storage. @@ -50,35 +70,49 @@ def from_env(cls) -> "BlobGuestbookClient | None": logger.info("Initializing Blob Storage client for container: %s", container_name) return cls(connection_string, container_name) - def _read_blob(self) -> list[dict[str, str]]: - """Download and parse the JSON blob. Returns [] if the blob doesn't exist.""" + def _read_blob(self) -> tuple[list[dict[str, str]], str | None]: + """Download and parse the JSON blob, together with its ETag. + + Returns ([], None) only when the blob doesn't exist yet. Any other + read error propagates: treating a failed read as "no entries" would + let the next write overwrite existing entries with a truncated list. + """ + blob_client = self.container_client.get_blob_client(ENTRIES_BLOB_NAME) try: - blob_client = self.container_client.get_blob_client(ENTRIES_BLOB_NAME) - data = blob_client.download_blob().readall() - entries = json.loads(data) - logger.info("Read %d guestbook entries", len(entries)) - return entries + downloader = blob_client.download_blob() except ResourceNotFoundError: logger.info("No guestbook blob found yet") - return [] - except Exception as e: - logger.error("Error reading guestbook entries: %s", e) - return [] + return [], None + entries = json.loads(downloader.readall()) + logger.info("Read %d guestbook entries", len(entries)) + return entries, downloader.properties.etag + + def _try_write_blob(self, entries: list[dict[str, str]], etag: str | None) -> bool: + """Upload the entries list, conditional on the ETag the read observed. - def _write_blob(self, entries: list[dict[str, str]]): - """Upload the entries list as a JSON blob (overwrite).""" + Returns False when another writer changed (or created) the blob in the + meantime, so the caller can re-read and retry. + """ + blob_client = self.container_client.get_blob_client(ENTRIES_BLOB_NAME) + data = json.dumps(entries, indent=2) try: - blob_client = self.container_client.get_blob_client(ENTRIES_BLOB_NAME) - data = json.dumps(entries, indent=2) - blob_client.upload_blob(data, overwrite=True) - logger.info("Wrote %d guestbook entries", len(entries)) - except Exception as e: - logger.error("Error writing guestbook entries: %s", e) - raise + if etag is None: + blob_client.upload_blob(data, overwrite=False) + else: + blob_client.upload_blob( + data, + overwrite=True, + etag=etag, + match_condition=MatchConditions.IfNotModified, + ) + except (ResourceExistsError, ResourceModifiedError): + return False + logger.info("Wrote %d guestbook entries", len(entries)) + return True def read_entries(self) -> list[dict[str, str]]: """Read all guestbook entries, newest first.""" - entries = self._read_blob() + entries, _ = self._read_blob() return sorted(entries, key=lambda e: e.get("timestamp", ""), reverse=True) def insert_entry(self, author: str, message: str) -> dict[str, str]: @@ -92,18 +126,22 @@ def insert_entry(self, author: str, message: str) -> dict[str, str]: if not message or not message.strip(): raise ValueError("Message cannot be None or empty") - entries = self._read_blob() entry = { "id": str(uuid.uuid4()), "author": author.strip(), "message": message.strip(), "timestamp": datetime.now().isoformat(), } - entries.append(entry) - self._write_blob(entries) - logger.info("Inserted guestbook entry %s", entry["id"]) - return entry + for attempt in range(MAX_WRITE_ATTEMPTS): + entries, etag = self._read_blob() + if self._try_write_blob([*entries, entry], etag): + logger.info("Inserted guestbook entry %s", entry["id"]) + return entry + logger.info("Concurrent write detected, retrying insert") + _backoff(attempt) + + raise RuntimeError(f"Could not insert entry after {MAX_WRITE_ATTEMPTS} attempts") def delete_entry_by_id(self, entry_id: str) -> int: """Delete an entry by its ID. @@ -114,12 +152,17 @@ def delete_entry_by_id(self, entry_id: str) -> int: if not entry_id: raise ValueError("Entry ID cannot be None or empty") - entries = self._read_blob() - new_entries = [e for e in entries if e.get("id") != entry_id] - deleted_count = len(entries) - len(new_entries) + for attempt in range(MAX_WRITE_ATTEMPTS): + entries, etag = self._read_blob() + new_entries = [e for e in entries if e.get("id") != entry_id] + deleted_count = len(entries) - len(new_entries) - if deleted_count > 0: - self._write_blob(new_entries) - logger.info("Deleted guestbook entry %s", entry_id) + if deleted_count == 0: + return 0 + if self._try_write_blob(new_entries, etag): + logger.info("Deleted guestbook entry %s", entry_id) + return deleted_count + logger.info("Concurrent write detected, retrying delete") + _backoff(attempt) - return deleted_count + raise RuntimeError(f"Could not delete entry after {MAX_WRITE_ATTEMPTS} attempts") From 8f07cd10f5982a39f9e66a8f53549ecfd186aecb Mon Sep 17 00:00:00 2001 From: Lazar Kanelov Date: Wed, 12 Aug 2026 18:01:23 +0300 Subject: [PATCH 3/5] Raise the emulator readiness wait to 600s The shared "Start LocalStack" step timed out twice in a row on this PR, each time on a different, unrelated sample shard (function-app-storage-http terraform, then servicebus scripts): `localstack wait -t 300` expired while the emulator was still bootstrapping on a slow runner, with no error in the emulator log. The wait returns as soon as the emulator is healthy, so the larger budget only affects the unhappy path - same rationale as the earlier 120s -> 300s bump documented on the step. --- .github/workflows/run-samples.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run-samples.yml b/.github/workflows/run-samples.yml index 2786e9e..baf8021 100644 --- a/.github/workflows/run-samples.yml +++ b/.github/workflows/run-samples.yml @@ -179,12 +179,13 @@ jobs: # Run the emulator in detached mode using the virtual environment. # The readiness wait is generous because the architecture matrix roughly doubles # the number of concurrent jobs, and the contention for Docker pulls and runner - # I/O pushed some starts past a 120s budget. A healthy emulator still returns as - # soon as it is ready, so a larger timeout costs nothing on the happy path. + # I/O pushed some starts past a 120s budget - and, on the slowest runners, + # occasionally past 300s as well. A healthy emulator still returns as soon as + # it is ready, so a larger timeout costs nothing on the happy path. run: | source .venv/bin/activate python -m localstack_cli.cli.main start -d - python -m localstack_cli.cli.main wait -t 300 + python -m localstack_cli.cli.main wait -t 600 env: IMAGE_NAME: ${{ env.IMAGE_NAME }}:${{ env.DEFAULT_TAG }} LOCALSTACK_AUTH_TOKEN: ${{ secrets.TEST_LOCALSTACK_AUTH_TOKEN }} From 2f47f5fb4d527702d7dc0378c5ee47458279b295 Mon Sep 17 00:00:00 2001 From: Lazar Kanelov Date: Wed, 12 Aug 2026 18:22:29 +0300 Subject: [PATCH 4/5] Pin Azure CLI to 2.88.0 in CI until the emulator parses az 2.89 payloads GitHub's runner-image rollout (ubuntu22/20260720.234 -> 20260810.260) bumped the preinstalled az from 2.88.0 to 2.89.0 mid-PR. Since then `az mysql flexible-server firewall-rule create` deterministically fails against the emulator: the request body parses to a None `properties` and flexible_servers__firewall_rules__create_or_update crashes with AttributeError -> 500 (same image digest passed with az 2.88.0 minutes earlier, so the CLI version is the only variable). Pinning the CLI keeps every sample shard green until the emulator handles the new shape; the step carries the removal trigger. --- .github/workflows/run-samples.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/run-samples.yml b/.github/workflows/run-samples.yml index baf8021..d8e438a 100644 --- a/.github/workflows/run-samples.yml +++ b/.github/workflows/run-samples.yml @@ -144,6 +144,15 @@ jobs: sudo apt-get install -y jq zip unixodbc-dev libsnappy-dev default-mysql-client postgresql-client find . -name "*.sh" -exec chmod +x {} + + - name: Pin Azure CLI to 2.88.0 + # SHORTCUT: runner image ubuntu22/20260810.260 bumped the preinstalled az to + # 2.89.0, whose `az mysql flexible-server firewall-rule create` payload the + # emulator answers with a 500 (AttributeError on a None `properties` in + # mysql_flexible/apis/firewall_rules.py). Remove this pin once the released + # emulator image parses the az 2.89 request shape. Both runner arches are + # jammy, so one version string covers amd64 and arm64. + run: sudo apt-get install -y --allow-downgrades azure-cli=2.88.0-1~jammy + - name: Install Terraform uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 with: From 78e515bba40e3e61529d5ddcf470cbc766303165 Mon Sep 17 00:00:00 2001 From: Lazar Kanelov Date: Wed, 12 Aug 2026 18:31:52 +0300 Subject: [PATCH 5/5] Pass the workflow token to the emulator for GitHub API calls The Functions-image prebuild inside the emulator lists Azure/azure-functions-core-tools releases via api.github.com; anonymous requests share the runner egress IP's 60 req/h budget, which is routinely exhausted on GitHub-hosted runners (observed: 5/5 build attempts rate- limited on the arm64 shard). localstack-core's GitHubReleaseInstaller already honors GITHUB_API_TOKEN, so forward the job's ephemeral contents:read token through DOCKER_FLAGS. --- .github/workflows/run-samples.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-samples.yml b/.github/workflows/run-samples.yml index d8e438a..e5dae8a 100644 --- a/.github/workflows/run-samples.yml +++ b/.github/workflows/run-samples.yml @@ -198,7 +198,13 @@ jobs: env: IMAGE_NAME: ${{ env.IMAGE_NAME }}:${{ env.DEFAULT_TAG }} LOCALSTACK_AUTH_TOKEN: ${{ secrets.TEST_LOCALSTACK_AUTH_TOKEN }} - DOCKER_FLAGS: "-e MSSQL_ACCEPT_EULA=Y" + # GITHUB_API_TOKEN: the emulator's package installers query api.github.com + # (e.g. Azure Functions Core Tools releases when prebuilding the Functions + # image); anonymous calls share the runner egress IP's 60 req/h budget, + # which is routinely exhausted on GitHub-hosted runners. localstack-core's + # GitHubReleaseInstaller sends this token as a Bearer for the higher + # authenticated limit. The workflow token is ephemeral and contents:read. + DOCKER_FLAGS: "-e MSSQL_ACCEPT_EULA=Y -e GITHUB_API_TOKEN=${{ github.token }}" LS_LOG: "DEBUG" DISABLE_EVENTS: "1" ACTIVATE_PRO: "1"