diff --git a/README.md b/README.md index e27c256..e7cf2c1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ This repository contains comprehensive sample projects demonstrating how to deve | [Web App and CosmosDB for NoSQL API ](./samples/web-app-cosmosdb-nosql-api/python/README.md) | Azure Web App using CosmosDB for NoSQL API | | [Web App and Managed Identities](./samples/web-app-managed-identity/python/README.md) | Azure Web App using Managed Identities | | [Web App and SQL Database ](./samples/web-app-sql-database/python/README.md) | Azure Web App using SQL Database | +| [Web App with Custom Docker Image](./samples/web-app-custom-image/python/README.md) | Azure Web App running a custom Docker image | | [ACI and Blob Storage](./samples/aci-blob-storage/python/README.md) | Azure Container Instances with ACR, Key Vault, and Blob Storage | | [Azure Service Bus with Spring Boot](./samples/servicebus/java/README.md) | Azure Service Bus used by a Spring Boot application | diff --git a/run-samples.sh b/run-samples.sh index 4311be9..b59670a 100755 --- a/run-samples.sh +++ b/run-samples.sh @@ -37,6 +37,7 @@ SAMPLES=( "samples/web-app-cosmosdb-mongodb-api/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-managed-identity/python|bash scripts/user-assigned.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-sql-database/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/get-web-app-url.sh" + "samples/web-app-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" ) diff --git a/samples/web-app-custom-image/python/README.md b/samples/web-app-custom-image/python/README.md new file mode 100644 index 0000000..6662fc6 --- /dev/null +++ b/samples/web-app-custom-image/python/README.md @@ -0,0 +1,59 @@ +# Azure Web App With Custom Docker Image + +This sample demonstrates a Python Flask web application hosted on an Azure Web App using a custom Docker image. The deployment builds the image from the local `src/Dockerfile`, creates an Azure Container Registry resource, and configures a Linux Web App to run the custom image in the LocalStack Azure emulator. + +## Architecture + +The sample creates the following Azure resources: + +1. **Azure Resource Group**: Logical container for all resources in the sample. +2. **Azure Container Registry**: Stores the custom Docker image metadata and credentials. +3. **Azure App Service Plan**: Linux plan used by the Web App. +4. **Azure Web App**: Runs the Flask application from the custom image. + +## Prerequisites + +- Docker +- Azure CLI +- azlocal CLI +- jq +- LocalStack for Azure + +## Deploy + +Start LocalStack for Azure and configure Azure CLI interception as described in the repository root README. Then run: + +```bash +cd samples/web-app-custom-image/python +bash scripts/deploy.sh +``` + +The script builds the Docker image from `src/`, creates the App Service resources, and configures the Web App to use the custom image. + +## Validate + +```bash +bash scripts/validate.sh +``` + +## Invoke The App + +```bash +bash scripts/call-web-app.sh +``` + +The app exposes: + +- `/` for the HTML page +- `/api/status` for a JSON health response + +## Local Docker Run + +You can run the same image directly with Docker: + +```bash +cd src +docker build -t vacation-planner-webapp:v1 . +docker run --rm -p 8080:80 vacation-planner-webapp:v1 +curl http://127.0.0.1:8080/api/status +``` diff --git a/samples/web-app-custom-image/python/scripts/README.md b/samples/web-app-custom-image/python/scripts/README.md new file mode 100644 index 0000000..ca16963 --- /dev/null +++ b/samples/web-app-custom-image/python/scripts/README.md @@ -0,0 +1,33 @@ +# Web App Custom Image Scripts + +These scripts deploy and validate a Python Flask application running on Azure Web App for Containers. + +## Deploy + +```bash +bash scripts/deploy.sh +``` + +The deployment script creates: + +- Resource group +- Azure Container Registry with admin credentials enabled +- Custom Docker image built from `src/Dockerfile` +- Linux App Service Plan +- Web App configured to use the custom image + +If pushing to the emulated registry is unavailable in the current LocalStack environment, the script falls back to the local Docker image tag. + +## Validate + +```bash +bash scripts/validate.sh +``` + +## Call The Web App + +```bash +bash scripts/call-web-app.sh +``` + +The call script first uses the LocalStack proxy endpoint and then, when available, calls the Docker host port mapped to the emulated Web App container. diff --git a/samples/web-app-custom-image/python/scripts/call-web-app.sh b/samples/web-app-custom-image/python/scripts/call-web-app.sh new file mode 100755 index 0000000..7747690 --- /dev/null +++ b/samples/web-app-custom-image/python/scripts/call-web-app.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-custom-image-rg" +WEB_APP_NAME="${PREFIX}-custom-image-webapp-${SUFFIX}" + +get_docker_container_name_by_prefix() { + local app_prefix="$1" + docker ps --format "{{.Names}}" | grep "^${app_prefix}" | head -1 +} + +get_docker_container_port_mapping() { + local container_name="$1" + local container_port="$2" + docker inspect -f "{{(index (index .NetworkSettings.Ports \"${container_port}/tcp\") 0).HostPort}}" "$container_name" +} + +APP_HOST_NAME=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "defaultHostName" \ + --output tsv \ + --only-show-errors) + +if [ -z "$APP_HOST_NAME" ]; then + echo "Failed to retrieve Web App hostname." + exit 1 +fi + +echo "Web App hostname: $APP_HOST_NAME" + +echo "Calling Web App using $APP_HOST_NAME..." +curl -fsS "http://$APP_HOST_NAME/api/status" +echo "" diff --git a/samples/web-app-custom-image/python/scripts/deploy.sh b/samples/web-app-custom-image/python/scripts/deploy.sh new file mode 100755 index 0000000..9e16746 --- /dev/null +++ b/samples/web-app-custom-image/python/scripts/deploy.sh @@ -0,0 +1,90 @@ +#!/bin/bash +set -euo pipefail + +# Variables +PREFIX='local' +SUFFIX='test' +LOCATION='westeurope' +RESOURCE_GROUP_NAME="${PREFIX}-custom-image-rg" +ACR_NAME="${PREFIX}customimageacr" +APP_SERVICE_PLAN_NAME="${PREFIX}-custom-image-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="B1" +WEB_APP_NAME="${PREFIX}-custom-image-webapp-${SUFFIX}" +IMAGE_NAME="custom-image-webapp" +IMAGE_TAG="v1" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" + +cd "$CURRENT_DIR" || exit + +echo "Creating resource group [$RESOURCE_GROUP_NAME]..." +az group create \ + --name "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" + +echo "Creating Azure Container Registry [$ACR_NAME]..." +az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku Basic \ + --admin-enabled true + +az acr login --name $ACR_NAME + +LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +FULL_IMAGE="${LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" +LOCAL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" + +echo "Building custom Docker image [$LOCAL_IMAGE]..." +docker build -t "$LOCAL_IMAGE" ../src/ +docker tag "$LOCAL_IMAGE" "$FULL_IMAGE" + +echo "Pushing image [$FULL_IMAGE] to ACR..." +docker push "$FULL_IMAGE" +WEBAPP_IMAGE="$FULL_IMAGE" + + +echo "Creating Linux App Service Plan [$APP_SERVICE_PLAN_NAME]..." +az appservice plan create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --location "$LOCATION" \ + --sku "$APP_SERVICE_PLAN_SKU" \ + --is-linux + +echo "Creating Web App [$WEB_APP_NAME] from custom image [$WEBAPP_IMAGE]..." + +az webapp create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --plan "$APP_SERVICE_PLAN_NAME" \ + --name "$WEB_APP_NAME" \ + --container-image-name "$WEBAPP_IMAGE" + +echo "Setting Web App container settings..." +az webapp config appsettings set \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --settings \ + WEBSITE_PORT="80" \ + WEBSITES_PORT="80" \ + APP_NAME="Custom Image" \ + IMAGE_NAME="$WEBAPP_IMAGE" + +echo "Listing resources in resource group [$RESOURCE_GROUP_NAME]..." +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table + +echo "" +echo "Deployment complete." +echo "Resource Group: $RESOURCE_GROUP_NAME" +echo "App Service Plan: $APP_SERVICE_PLAN_NAME" +echo "Web App: $WEB_APP_NAME" +echo "ACR: $ACR_NAME ($LOGIN_SERVER)" +echo "Image: $WEBAPP_IMAGE" +echo "" +echo "Run 'bash scripts/validate.sh' to verify the deployment." diff --git a/samples/web-app-custom-image/python/scripts/validate.sh b/samples/web-app-custom-image/python/scripts/validate.sh new file mode 100755 index 0000000..8d89b8c --- /dev/null +++ b/samples/web-app-custom-image/python/scripts/validate.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP_NAME="${PREFIX}-custom-image-rg" +ACR_NAME="${PREFIX}customimageacr" +APP_SERVICE_PLAN_NAME="${PREFIX}-custom-image-plan-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-custom-image-webapp-${SUFFIX}" + +echo -e "[$RESOURCE_GROUP_NAME] resource group:\n" +az group show \ + --name "$RESOURCE_GROUP_NAME" \ + --output table + +echo -e "\n[$APP_SERVICE_PLAN_NAME] App Service Plan:\n" +az appservice plan show \ + --name "$APP_SERVICE_PLAN_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +echo -e "\n[$ACR_NAME] Azure Container Registry:\n" +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table + +echo -e "\n[$WEB_APP_NAME] Web App:\n" +az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "{name:name, state:state, defaultHostName:defaultHostName, kind:kind}" \ + --output table + +echo -e "\n[$WEB_APP_NAME] app settings:\n" +az webapp config appsettings list \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "[?name=='IMAGE_NAME' || name=='WEBSITE_PORT' || name=='WEBSITES_PORT'].[name,value]" \ + --output table + +echo -e "\nResources in [$RESOURCE_GROUP_NAME]:\n" +az resource list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --output table diff --git a/samples/web-app-custom-image/python/src/.dockerignore b/samples/web-app-custom-image/python/src/.dockerignore new file mode 100644 index 0000000..8a0e9e6 --- /dev/null +++ b/samples/web-app-custom-image/python/src/.dockerignore @@ -0,0 +1,4 @@ +.git +__pycache__ +*.pyc +*.zip diff --git a/samples/web-app-custom-image/python/src/Dockerfile b/samples/web-app-custom-image/python/src/Dockerfile new file mode 100644 index 0000000..0708c28 --- /dev/null +++ b/samples/web-app-custom-image/python/src/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PORT=80 +EXPOSE 80 + +CMD ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=80"] diff --git a/samples/web-app-custom-image/python/src/app.py b/samples/web-app-custom-image/python/src/app.py new file mode 100644 index 0000000..7a4b31c --- /dev/null +++ b/samples/web-app-custom-image/python/src/app.py @@ -0,0 +1,33 @@ +import os +import socket + +from flask import Flask, jsonify, render_template + + +app = Flask(__name__) + + +@app.route("/") +def index(): + return render_template( + "index.html", + app_name=os.environ.get("APP_NAME", "Custom Image Web App"), + image_name=os.environ.get("IMAGE_NAME", "vacation-planner-webapp:v1"), + hostname=socket.gethostname(), + ) + + +@app.route("/api/status") +def status(): + return jsonify( + { + "status": "ok", + "app": os.environ.get("APP_NAME", "Custom Image Web App"), + "image": os.environ.get("IMAGE_NAME", "vacation-planner-webapp:v1"), + "hostname": socket.gethostname(), + } + ) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "80"))) diff --git a/samples/web-app-custom-image/python/src/requirements.txt b/samples/web-app-custom-image/python/src/requirements.txt new file mode 100644 index 0000000..22ac75b --- /dev/null +++ b/samples/web-app-custom-image/python/src/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.0 diff --git a/samples/web-app-custom-image/python/src/static/style.css b/samples/web-app-custom-image/python/src/static/style.css new file mode 100644 index 0000000..0764737 --- /dev/null +++ b/samples/web-app-custom-image/python/src/static/style.css @@ -0,0 +1,100 @@ +:root { + color-scheme: light; + --ink: #172033; + --muted: #53606f; + --surface: #ffffff; + --line: #d8dee7; + --accent: #0f766e; + --accent-2: #b45309; + --page: #f5f7fb; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--ink); + background: + linear-gradient(120deg, rgba(15, 118, 110, 0.12), transparent 36%), + linear-gradient(300deg, rgba(180, 83, 9, 0.12), transparent 34%), + var(--page); +} + +.shell { + width: min(1040px, calc(100% - 32px)); + margin: 0 auto; + padding: 64px 0; +} + +.hero { + padding: 48px 0 36px; +} + +.eyebrow { + margin: 0 0 12px; + color: var(--accent); + font-size: 0.84rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +h1 { + margin: 0; + max-width: 760px; + font-size: clamp(2.5rem, 6vw, 5.2rem); + line-height: 0.95; + letter-spacing: 0; +} + +.lede { + max-width: 640px; + margin: 24px 0 0; + color: var(--muted); + font-size: 1.16rem; + line-height: 1.6; +} + +.details { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-top: 32px; +} + +article { + min-height: 120px; + padding: 20px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +span { + display: block; + color: var(--muted); + font-size: 0.85rem; + font-weight: 700; +} + +strong { + display: block; + margin-top: 10px; + overflow-wrap: anywhere; + font-size: 1.05rem; + line-height: 1.35; +} + +@media (max-width: 720px) { + .shell { + padding: 36px 0; + } + + .details { + grid-template-columns: 1fr; + } +} diff --git a/samples/web-app-custom-image/python/src/templates/index.html b/samples/web-app-custom-image/python/src/templates/index.html new file mode 100644 index 0000000..c915d72 --- /dev/null +++ b/samples/web-app-custom-image/python/src/templates/index.html @@ -0,0 +1,33 @@ + + + + + + {{ app_name }} + + + +
+
+

Azure Web App for Containers

+

{{ app_name }}

+

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

+
+ +
+
+ Image + {{ image_name }} +
+
+ Host + {{ hostname }} +
+
+ Status endpoint + /api/status +
+
+
+ +