Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
415d790
Move the Python samples into python/ subfolders
paolosalvatori Sep 8, 2026
6aa2661
Add GET /health to the Python samples and probe it
paolosalvatori Sep 8, 2026
dc6c9ad
Add the .NET version of web-app-blob-storage
paolosalvatori Sep 8, 2026
6bea654
Add the .NET version of web-app-managed-identity
paolosalvatori Sep 8, 2026
75d33ec
Add the .NET version of web-app-file-storage
paolosalvatori Sep 8, 2026
c4a5c92
Add the .NET version of web-app-cosmosdb-mongodb-api
paolosalvatori Sep 8, 2026
de9c1d6
Add the .NET version of web-app-cosmosdb-nosql-api
paolosalvatori Sep 8, 2026
a4bd6de
Log requests and store operations in the .NET samples
paolosalvatori Sep 8, 2026
50150b5
Add the .NET version of web-app-postgresql-flexible-server
paolosalvatori Sep 8, 2026
0bffaee
Add the .NET version of web-app-mysql-flexible-server
paolosalvatori Sep 8, 2026
5b02df6
Add the .NET version of web-app-sql-database
paolosalvatori Sep 8, 2026
9f6c0ee
Add the .NET version of web-app-in-cluster-postgresql
paolosalvatori Sep 8, 2026
e55ca2c
Link the Python and the .NET version of every sample from the README
paolosalvatori Sep 8, 2026
a6a45bb
Give each sample its own namespace and container image
paolosalvatori Sep 8, 2026
9b42ef6
Delete activities by their store id in the Python samples
paolosalvatori Sep 9, 2026
7cd8334
Keep the app secret and the SQL seed data stable across re-runs
paolosalvatori Sep 9, 2026
bbfe62f
Guard the blob name and converge a repeated delete in the .NET samples
paolosalvatori Sep 9, 2026
82070dd
Make the file-share write probe unique per replica
paolosalvatori Sep 9, 2026
a5c4c5d
Roll the pods when 05-deploy-app.sh redeploys the same image tag
paolosalvatori Sep 9, 2026
a4a6a9e
Address the review comments on the managed-identity and options code
paolosalvatori Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.sh text eol=lf
*.yml text eol=lf
*.cs text eol=lf
*.csproj text eol=lf
*.cshtml text eol=lf
Dockerfile text eol=lf
58 changes: 57 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ activemq-data/
.env
.envrc
.venv
# Deployment options generated by samples/web-app-file-storage/scripts/01-deploy-resources.sh
# Deployment options generated by samples/web-app-file-storage/*/scripts/01-deploy-resources.sh
.deploy-options.env
env/
venv/
Expand Down Expand Up @@ -208,6 +208,62 @@ tempCodeRunnerFile.py
# Ruff stuff:
.ruff_cache/

## .NET (the samples/web-app-*/dotnet projects), from
## https://github.com/github/gitignore/blob/main/Dotnet.gitignore

# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
publish/

# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/

# ASP.NET Scaffolding
ScaffoldingReadMe.txt

# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg

# Others
~$*
*~
CodeCoverage/

# MSBuild Binary and Structured Log
*.binlog

# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*

# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml

# Files that sample scripts or ad-hoc local runs drop into a scripts/ folder:
# the LocalStack CA written by web-app-cosmosdb-nosql-api/*/scripts/03-run-docker-container.sh
# and activity files written by a Vacation Planner run pointed at the scripts folder.
samples/**/scripts/.localstack-ca.crt
samples/**/scripts/*-activity.txt

# PyPI configuration file
.pypirc

Expand Down
40 changes: 26 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ It contains two kinds of content:
- An [Azure subscription](https://azure.microsoft.com/free/) (for cloud deployments) or a running [LocalStack for Azure](https://docs.localstack.cloud/azure/) instance (for local deployments).
- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) (`az`), authenticated with `az login`.
- [Docker](https://docs.docker.com/get-docker/) to build and run the container images.
- Optional: the [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0), only needed to build or run the .NET version of the web app outside Docker (the Docker build uses the SDK image).
- [kubectl](https://kubernetes.io/docs/tasks/tools/) to manage the cluster.
- [yq](https://github.com/mikefarah/yq), and (depending on the sample) `sqlcmd` or `psql` on the host machine.
- An SSH key pair at `~/.ssh/id_rsa.pub` (used to provision the AKS node pools).
Expand Down Expand Up @@ -94,31 +95,40 @@ The [scripts/](scripts/) folder also contains optional add-on installers you can

## Samples

Every sample deploys the same *Vacation Planner* web app, a small Python [Flask](https://flask.palletsprojects.com/) single-page application, and differs only in the Azure data service that persists the activity data behind it, listed in the table below. Keeping the application identical makes the comparison the point: what changes from one sample to the next is the data service, its provisioning, and how the app authenticates to it.
Every sample deploys the same *Vacation Planner* web app and differs only in the Azure data service that persists the activity data behind it, listed in the table below. Keeping the application identical makes the comparison the point: what changes from one sample to the next is the data service, its provisioning, and how the app authenticates to it.

The web app comes in two implementations with identical behaviour: a Python [Flask](https://flask.palletsprojects.com/) app and an [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/) Razor Pages app on .NET 10, each in its own subfolder of the sample (`python/` and `dotnet/`). Both read the same environment variables, use the same Kubernetes names and share the data store, so deploying one version over the other simply rolls the Deployment to the other implementation; only the container image name differs (`vacation-planner-<store>` versus `vacation-planner-<store>-dotnet`).

![Vacation Planner](images/vacation-planner.png)

To run any sample you must first create the AKS cluster with one of the two scripts above. Then pick a sample from the [samples/](samples/) folder and run the numbered scripts in its `samples/<sample>/scripts` folder in order. The web app source code for each sample lives in `samples/<sample>/src`.
To run any sample you must first create the AKS cluster with one of the two scripts above. Then pick a sample and a language from the table below and run the numbered scripts in its `samples/<sample>/<language>/scripts` folder in order. The web app source code lives in `samples/<sample>/<language>/src`.

| Sample | Description |
| ------ | ----------- |
| [web-app-sql-database](samples/web-app-sql-database/) | Stores activities in an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview), connecting with a SQL login over TDS. |
| [web-app-mysql-flexible-server](samples/web-app-mysql-flexible-server/) | Stores activities in an [Azure Database for MySQL flexible server](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/overview). |
| [web-app-postgresql-flexible-server](samples/web-app-postgresql-flexible-server/) | Stores activities in an [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview). |
| [web-app-in-cluster-postgresql](samples/web-app-in-cluster-postgresql/) | Stores activities in an in-cluster [PostgreSQL](https://www.postgresql.org/) database deployed as a Kubernetes [StatefulSet](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) (a primary with two streaming-replica standbys), instead of a managed Azure data service. |
| [web-app-cosmosdb-mongodb-api](samples/web-app-cosmosdb-mongodb-api/) | Stores activities in a collection of an [Azure Cosmos DB for MongoDB](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/introduction) account. |
| [web-app-cosmosdb-nosql-api](samples/web-app-cosmosdb-nosql-api/) | Stores activities in a container of an [Azure Cosmos DB for NoSQL](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/) account. |
| [web-app-blob-storage](samples/web-app-blob-storage/) | Stores activities in an [Azure Blob Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction) container, using a connection string. |
| [web-app-file-storage](samples/web-app-file-storage/) | Stores activities as text files on an [Azure Files](https://learn.microsoft.com/en-us/azure/storage/files/storage-files-introduction) share mounted into the pods by the [Azure Files CSI driver](https://learn.microsoft.com/en-us/azure/aks/azure-files-csi), over either SMB or NFS, with either a pre-created share or one provisioned on demand. The only sample whose app uses no Azure SDK at all. |
| [web-app-managed-identity](samples/web-app-managed-identity/) | Stores activities in an Azure Blob Storage container, authenticating with [Microsoft Entra Workload ID](https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview) (federated credential plus workload identity) instead of a secret, and optionally exposes the app through the Gateway API with a managed TLS certificate. |
| web-app-sql-database ([Python](samples/web-app-sql-database/python/README.md), [.NET](samples/web-app-sql-database/dotnet/README.md)) | Stores activities in an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview), connecting with a SQL login over TDS. |
| web-app-mysql-flexible-server ([Python](samples/web-app-mysql-flexible-server/python/README.md), [.NET](samples/web-app-mysql-flexible-server/dotnet/README.md)) | Stores activities in an [Azure Database for MySQL flexible server](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/overview). |
| web-app-postgresql-flexible-server ([Python](samples/web-app-postgresql-flexible-server/python/README.md), [.NET](samples/web-app-postgresql-flexible-server/dotnet/README.md)) | Stores activities in an [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview). |
| web-app-in-cluster-postgresql ([Python](samples/web-app-in-cluster-postgresql/python/README.md), [.NET](samples/web-app-in-cluster-postgresql/dotnet/README.md)) | Stores activities in an in-cluster [PostgreSQL](https://www.postgresql.org/) database deployed as a Kubernetes [StatefulSet](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) (a primary with two streaming-replica standbys), instead of a managed Azure data service. |
| web-app-cosmosdb-mongodb-api ([Python](samples/web-app-cosmosdb-mongodb-api/python/README.md), [.NET](samples/web-app-cosmosdb-mongodb-api/dotnet/README.md)) | Stores activities in a collection of an [Azure Cosmos DB for MongoDB](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/introduction) account. |
| web-app-cosmosdb-nosql-api ([Python](samples/web-app-cosmosdb-nosql-api/python/README.md), [.NET](samples/web-app-cosmosdb-nosql-api/dotnet/README.md)) | Stores activities in a container of an [Azure Cosmos DB for NoSQL](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/) account. |
| web-app-blob-storage ([Python](samples/web-app-blob-storage/python/README.md), [.NET](samples/web-app-blob-storage/dotnet/README.md)) | Stores activities in an [Azure Blob Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction) container, using a connection string. |
| web-app-file-storage ([Python](samples/web-app-file-storage/python/README.md), [.NET](samples/web-app-file-storage/dotnet/README.md)) | Stores activities as text files on an [Azure Files](https://learn.microsoft.com/en-us/azure/storage/files/storage-files-introduction) share mounted into the pods by the [Azure Files CSI driver](https://learn.microsoft.com/en-us/azure/aks/azure-files-csi), over either SMB or NFS, with either a pre-created share or one provisioned on demand. The only sample whose app uses no Azure SDK at all. |
| web-app-managed-identity ([Python](samples/web-app-managed-identity/python/README.md), [.NET](samples/web-app-managed-identity/dotnet/README.md)) | Stores activities in an Azure Blob Storage container, authenticating with [Microsoft Entra Workload ID](https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview) (federated credential plus workload identity) instead of a secret, and optionally exposes the app through the Gateway API with a managed TLS certificate. |

Each sample folder follows the same layout:

```
samples/<sample>/
├── README.md # sample-specific documentation
├── scripts/ # numbered deployment scripts + Kubernetes manifests
└── src/ # Flask web app source code
├── python/
│ ├── README.md # documentation of the Python version
│ ├── images/ # architecture diagram
│ ├── scripts/ # numbered deployment scripts, Dockerfile and Kubernetes manifests
│ └── src/ # Flask web app source code
└── dotnet/
├── README.md # documentation of the .NET version
├── images/ # architecture diagram
├── scripts/ # numbered deployment scripts, Dockerfile and Kubernetes manifests
└── src/ # ASP.NET Core Razor Pages web app source code
```

### Accessing the Vacation Planner web app
Expand All @@ -132,6 +142,8 @@ kubectl port-forward service/<service> 8080:80 -n <namespace>

Then browse to [http://localhost:8080](http://localhost:8080). The exact namespace and service name for each sample are documented in its own `README.md`.

Both versions of the app also expose `GET /health`, the endpoint their Kubernetes liveness and readiness probes call: it returns `{"status": "ok"}` when the data service is reachable and `503` otherwise.

Alternatively, you can use a terminal UI such as [k9s](https://k9scli.io/) to select the service and start a port-forward interactively (press `<shift-f>` on a selected service or pod).

## Tutorials
Expand Down
76 changes: 76 additions & 0 deletions samples/web-app-blob-storage/dotnet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Vacation Planner: Azure Blob Storage

> A Python version of this sample lives in [../python](../python/README.md).

This sample demonstrates a ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Kubernetes Service (AKS)](https://learn.microsoft.com/en-us/azure/aks/what-is-aks) cluster in the cloud on Azure or locally in the LocalStack emulator for Azure. The app runs in a dedicated namespace and stores activity data in the `activities` container of an [Azure Blob Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction) account.

The application authenticates to the storage account with a connection string passed in through a Kubernetes Secret. For a secret-less alternative based on [Microsoft Entra Workload ID](https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview), see the [`web-app-managed-identity`](../../web-app-managed-identity/dotnet/) sample.

Before installing the sample, make sure to create an [Azure Kubernetes Service (AKS)](https://learn.microsoft.com/en-us/azure/aks/what-is-aks) cluster by using one of the following scripts:

- [scripts/01-system-assigned-managed-identity.sh](../../../scripts/01-system-assigned-managed-identity.sh): creates the cluster using a system-assigned managed identity as its cluster identity.
- [scripts/01-user-assigned-managed-identity.sh](../../../scripts/01-user-assigned-managed-identity.sh): creates the cluster using a user-assigned managed identity as its cluster identity.

All commands below are run from this sample's `scripts/` folder.

> **Running on LocalStack?** Install the [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) and run `lstk az start-interception` to route Azure CLI calls to the emulator. See [Run against LocalStack](../../../README.md#run-against-localstack) for the full setup.

## Architecture

The following diagram illustrates the architecture of the solution:

![Architecture Diagram](./images/architecture.png)

## Deployment workflow

Run the numbered scripts in order from the `scripts/` folder:

```bash
cd scripts
./01-deploy-resources.sh
./02-build-docker-image.sh
./03-run-docker-container.sh # optional local smoke test
./04-push-docker-image.sh
./05-deploy-app.sh
```

## Scripts and manifests

| File | Description |
| ---- | ----------- |
| [`00-variables.sh`](scripts/00-variables.sh) | Defines the variables shared across the other scripts (resource names, image tag, storage account and container names, Kubernetes namespace, …). The other scripts load these values by sourcing this file. |
| [`01-deploy-resources.sh`](scripts/01-deploy-resources.sh) | Deploys the Azure resources used by this sample: the resource group, the [Azure Container Registry (ACR)](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-intro), the [Azure Blob Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction) account, and the `activities` container. |
| [`02-build-docker-image.sh`](scripts/02-build-docker-image.sh) | Builds the Docker image for the web app from the [`src/`](src/) folder. |
| [`03-run-docker-container.sh`](scripts/03-run-docker-container.sh) | Runs the web app in a local Docker container (no Kubernetes) to validate that it starts and connects to the storage account as expected. |
| [`04-push-docker-image.sh`](scripts/04-push-docker-image.sh) | Tags and pushes the Docker image to the Azure Container Registry, on Azure or in the LocalStack emulator. |
| [`05-deploy-app.sh`](scripts/05-deploy-app.sh) | Uses the YAML manifests below (templated with `yq`) to deploy the app to the AKS cluster. |
| [`Dockerfile`](scripts/Dockerfile) | Builds the Docker image of the web app. |
| [`namespace.yml`](scripts/namespace.yml) | Creates the Kubernetes namespace. |
| [`configmap.yml`](scripts/configmap.yml) | Creates the ConfigMap holding non-secret input values (blob container name, login name) passed to the app as environment variables. |
| [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the storage account connection string and the `SECRET_KEY` the app derives its Data Protection key ring from (so antiforgery tokens and flash messages are valid on every replica)) passed to the app as environment variables. |
| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification for the web app. The liveness and readiness probes call `GET /health`. |
| [`service.yml`](scripts/service.yml) | Creates the `ClusterIP` Service that exposes the web app inside the cluster. |

## Accessing the web app

The app is exposed through a `ClusterIP` service, which is only reachable from inside the cluster. Port-forward it to a local port to open it from your machine:

```bash
kubectl port-forward service/vacation-planner-blob 8080:80 -n vacation-planner-blob
```

Then browse to [http://localhost:8080](http://localhost:8080). Alternatively, use a tool such as [k9s](https://k9scli.io/) to start the port-forward interactively.

The app also exposes `GET /health`, the endpoint the liveness and readiness probes call: it returns `{"status": "ok"}` when the blob container is reachable and `503` with `{"status": "unavailable"}` otherwise.

```bash
curl http://localhost:8080/health
```

## Logs

The app logs one line per request — the `VacationPlanner.Requests` middleware is the equivalent of the gunicorn access log of the [Python version](../python/README.md), and it covers the probes too — plus one line per blob read, uploaded or deleted and one line for every activity added, updated or deleted. [`src/appsettings.json`](src/appsettings.json) keeps every entry on a single timestamped line and holds the framework categories at warning level, so the request and store lines stand out.

```bash
kubectl logs deployment/vacation-planner-blob -n vacation-planner-blob --tail=50
```
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,26 @@ PREFIX='local'
SUFFIX='test'
LOCATION='italynorth'
RESOURCE_GROUP_NAME="${PREFIX}-rg"
AKS_CLUSTER_NAME="${PREFIX}-aks-${SUFFIX}"
ACR_NAME="${PREFIX,,}acr${SUFFIX,,}"
ACR_SKU='Standard'
MANAGED_IDENTITY_NAME="${PREFIX}-app-identity-${SUFFIX}"
FEDERATED_IDENTITY_NAME="${PREFIX}-federated-identity-${SUFFIX}"
SUBSCRIPTION_NAME=$(az account show --query name --output tsv)
SUBSCRIPTION_ID=$(az account show --query id --output tsv)
TENANT_ID=$(az account show --query tenantId --output tsv)
CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)"

# DNS
DNS_ZONE_RESOURCE_GROUP_NAME="dns-rg"
DNS_ZONE_NAME="babosbird.com"
SUBDOMAIN="planner.local"

# Storage Account
STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}"
CONTAINER_NAME='activities'

# Docker Image
IMAGE_NAME="vacation-planner-blob"
IMAGE_NAME="vacation-planner-blob-dotnet"
IMAGE_PULL_POLICY="Always"
IMAGE_TAG="v1"
PORT="8080"

# Kubernetes
NAME="vacation-planner-blob"
NAMESPACE="vacation-planner-blob"
DEPLOYMENT_NAME="vacation-planner-blob"
SERVICE_NAME="vacation-planner-blob"
CONFIGMAP_NAME="vacation-planner-blob-config"
SECRET_NAME="vacation-planner-blob-secrets"
SERVICE_ACCOUNT_NAME="vacation-planner-blob-sa"
DEPLOY_GATEWAY="false"
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/bash

# Variables
source ./00-variables.sh

# Change the current directory to the script's directory
cd "$CURRENT_DIR" || exit

# Build context: the src/ folder (contains VacationPlanner.csproj, Program.cs, Pages/, Services/, wwwroot/).
# The Dockerfile lives alongside this script, so we point -f at it explicitly.
BUILD_CONTEXT="../src"

# Build the docker image
docker build \
-t $IMAGE_NAME:$IMAGE_TAG \
-f Dockerfile \
--build-arg PORT=$PORT \
$BUILD_CONTEXT
Loading