diff --git a/.github/workflows/run-samples.yml b/.github/workflows/run-samples.yml index 2786e9e..e5dae8a 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: @@ -179,16 +188,23 @@ 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 }} - 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" diff --git a/README.md b/README.md index 13f0f78..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 @@ -38,6 +41,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 +82,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..79060be --- /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..4017672 --- /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..c04db01 --- /dev/null +++ b/samples/container-apps-blob-storage/python/src/app.py @@ -0,0 +1,103 @@ +"""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 azure.core.exceptions import AzureError +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 (AzureError, RuntimeError, ValueError): + logger.exception("Error creating entry") + + return redirect(url_for("index")) + + entries = [] + try: + if guestbook_client: + entries = guestbook_client.read_entries() + except (AzureError, ValueError, KeyError): + logger.exception("Error reading entries") + + 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 (AzureError, RuntimeError, ValueError): + logger.exception("Error deleting entry") + + 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..3cb6b13 --- /dev/null +++ b/samples/container-apps-blob-storage/python/src/blob_storage_client.py @@ -0,0 +1,168 @@ +"""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 import MatchConditions +from azure.core.exceptions import ( + ResourceExistsError, + ResourceModifiedError, + ResourceNotFoundError, +) +from azure.storage.blob import BlobServiceClient + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +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. + + 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) -> 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: + downloader = blob_client.download_blob() + except ResourceNotFoundError: + logger.info("No guestbook blob found yet") + 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. + + 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: + 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() + 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") + + entry = { + "id": str(uuid.uuid4()), + "author": author.strip(), + "message": message.strip(), + "timestamp": datetime.now().isoformat(), + } + + 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. + + Returns: + Number of entries deleted (0 or 1) + """ + if not entry_id: + raise ValueError("Entry ID cannot be None or empty") + + 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: + 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) + + raise RuntimeError(f"Could not delete entry after {MAX_WRITE_ATTEMPTS} attempts") 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" + } +}