diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..59cadee --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 7a37ee9..fd3780f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ @@ -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 diff --git a/README.md b/README.md index 09adbf3..842e00f 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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-` versus `vacation-planner--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//scripts` folder in order. The web app source code for each sample lives in `samples//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///scripts` folder in order. The web app source code lives in `samples///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// -├── 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 @@ -132,6 +142,8 @@ kubectl port-forward service/ 8080:80 -n 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 `` on a selected service or pod). ## Tutorials diff --git a/samples/web-app-blob-storage/dotnet/README.md b/samples/web-app-blob-storage/dotnet/README.md new file mode 100644 index 0000000..5b57046 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/README.md @@ -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 +``` diff --git a/samples/web-app-blob-storage/images/architecture.png b/samples/web-app-blob-storage/dotnet/images/architecture.png similarity index 100% rename from samples/web-app-blob-storage/images/architecture.png rename to samples/web-app-blob-storage/dotnet/images/architecture.png diff --git a/samples/web-app-managed-identity/scripts/00-variables.sh b/samples/web-app-blob-storage/dotnet/scripts/00-variables.sh similarity index 66% rename from samples/web-app-managed-identity/scripts/00-variables.sh rename to samples/web-app-blob-storage/dotnet/scripts/00-variables.sh index fdfae0c..44c882d 100755 --- a/samples/web-app-managed-identity/scripts/00-variables.sh +++ b/samples/web-app-blob-storage/dotnet/scripts/00-variables.sh @@ -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" diff --git a/samples/web-app-blob-storage/scripts/01-deploy-resources.sh b/samples/web-app-blob-storage/dotnet/scripts/01-deploy-resources.sh similarity index 100% rename from samples/web-app-blob-storage/scripts/01-deploy-resources.sh rename to samples/web-app-blob-storage/dotnet/scripts/01-deploy-resources.sh diff --git a/samples/web-app-blob-storage/dotnet/scripts/02-build-docker-image.sh b/samples/web-app-blob-storage/dotnet/scripts/02-build-docker-image.sh new file mode 100755 index 0000000..4b497d8 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/02-build-docker-image.sh @@ -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 diff --git a/samples/web-app-blob-storage/scripts/03-run-docker-container.sh b/samples/web-app-blob-storage/dotnet/scripts/03-run-docker-container.sh similarity index 100% rename from samples/web-app-blob-storage/scripts/03-run-docker-container.sh rename to samples/web-app-blob-storage/dotnet/scripts/03-run-docker-container.sh diff --git a/samples/web-app-blob-storage/scripts/04-push-docker-image.sh b/samples/web-app-blob-storage/dotnet/scripts/04-push-docker-image.sh similarity index 100% rename from samples/web-app-blob-storage/scripts/04-push-docker-image.sh rename to samples/web-app-blob-storage/dotnet/scripts/04-push-docker-image.sh diff --git a/samples/web-app-blob-storage/dotnet/scripts/05-deploy-app.sh b/samples/web-app-blob-storage/dotnet/scripts/05-deploy-app.sh new file mode 100755 index 0000000..9dc9760 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Retrieve the storage account connection string +echo "Retrieving storage account connection string for [$STORAGE_ACCOUNT_NAME]..." +AZURE_STORAGE_ACCOUNT_CONNECTION_STRING=$(az storage account show-connection-string \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "connectionString" \ + --output tsv \ + --only-show-errors) + +if [ -n "$AZURE_STORAGE_ACCOUNT_CONNECTION_STRING" ]; then + echo "Storage account connection string retrieved successfully." +else + echo "Failed to retrieve storage account connection string." + exit 1 +fi + +# Generate a stable SECRET_KEY shared by all replicas: the app derives its Data Protection key ring from it, +# so antiforgery tokens and flash messages are valid on every replica and survive pod restarts +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +SECRET_KEY=$(kubectl get secret $SECRET_NAME --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $SECRET_KEY ]]; then + SECRET_KEY=$(openssl rand -hex 32) +fi + +# Get the login server for the Azure Container Registry +echo "Getting login server for Azure Container Registry [$ACR_NAME]..." +ACR_LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +if [ -n "$ACR_LOGIN_SERVER" ]; then + echo "Login server retrieved successfully: $ACR_LOGIN_SERVER" +else + echo "Failed to retrieve login server for Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Create namespace +cat namespace.yml | +yq "(.metadata.name)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Create secret with the storage connection string and SECRET_KEY +cat secret.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.AZURE_STORAGE_ACCOUNT_CONNECTION_STRING)|="\""$(echo -n $AZURE_STORAGE_ACCOUNT_CONNECTION_STRING | base64 -w0)"\" | +yq "(.data.SECRET_KEY)|="\""$(echo -n $SECRET_KEY | base64 -w0)"\" | +kubectl apply -f - + +# Create configmap with environment variables +cat configmap.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.CONTAINER_NAME)|="\""$CONTAINER_NAME"\" | +kubectl apply -f - + +# Create deployment +cat deployment.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.spec.template.spec.containers[0].image)|="\""$FULL_IMAGE"\" | +yq "(.spec.template.spec.containers[0].imagePullPolicy)|="\""$IMAGE_PULL_POLICY"\" | +yq "(.spec.template.spec.containers[0].ports[0].containerPort)|=$PORT" | +kubectl apply -f - + +# Create service +cat service.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE + +# Wait for the rollout so a pod stuck in ImagePullBackOff or CrashLoopBackOff is reported here, not discovered later +echo "Waiting for deployment [$DEPLOYMENT_NAME] to roll out..." +if kubectl rollout status deployment/$DEPLOYMENT_NAME -n $NAMESPACE --timeout=600s; then + echo "Deployment [$DEPLOYMENT_NAME] is ready. To reach the web app, run:" + echo " kubectl port-forward service/$SERVICE_NAME 8080:80 -n $NAMESPACE" + echo "and browse to http://localhost:8080 (health: http://localhost:8080/health)." +else + echo "Deployment [$DEPLOYMENT_NAME] did not become ready. Inspect it with:" + echo " kubectl get pods -n $NAMESPACE" + echo " kubectl describe pod -n $NAMESPACE --selector app=$DEPLOYMENT_NAME" + exit 1 +fi diff --git a/samples/web-app-blob-storage/dotnet/scripts/Dockerfile b/samples/web-app-blob-storage/dotnet/scripts/Dockerfile new file mode 100644 index 0000000..d862da7 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/Dockerfile @@ -0,0 +1,28 @@ +# Build stage: restore and publish the ASP.NET Core app with the .NET SDK image. +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build + +WORKDIR /src + +# Restore first so the package cache layer is reused when only sources change. +COPY VacationPlanner.csproj ./ +RUN dotnet restore + +COPY . ./ +RUN dotnet publish -c Release -o /app/publish --no-restore + +# Runtime stage: the ASP.NET Core runtime image only, no SDK. +FROM mcr.microsoft.com/dotnet/aspnet:10.0 + +# Port Kestrel listens on; kept as a build argument (and a runtime variable) as in the Python image. +ARG PORT=8080 +ENV PORT=${PORT} + +WORKDIR /app +COPY --from=build /app/publish ./ + +# Run as the runtime image's non-root `app` user, as the Python image runs as its own unprivileged `app` user. +USER app + +EXPOSE ${PORT} + +ENTRYPOINT ["dotnet", "VacationPlanner.dll"] diff --git a/samples/web-app-blob-storage/scripts/configmap.yml b/samples/web-app-blob-storage/dotnet/scripts/configmap.yml similarity index 100% rename from samples/web-app-blob-storage/scripts/configmap.yml rename to samples/web-app-blob-storage/dotnet/scripts/configmap.yml diff --git a/samples/web-app-blob-storage/dotnet/scripts/deployment.yml b/samples/web-app-blob-storage/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..c3ef386 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/deployment.yml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vacation-planner-blob + namespace: vacation-planner-blob + labels: + app: vacation-planner-blob +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-blob + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-blob + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: vacation-planner-blob + image: .azurecr.io/vacation-planner-blob-dotnet:v1 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: CONTAINER_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-blob-config + key: CONTAINER_NAME + - name: AZURE_STORAGE_ACCOUNT_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: vacation-planner-blob-secrets + key: AZURE_STORAGE_ACCOUNT_CONNECTION_STRING + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-blob-secrets + key: SECRET_KEY + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "256Mi" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/samples/web-app-blob-storage/scripts/namespace.yml b/samples/web-app-blob-storage/dotnet/scripts/namespace.yml similarity index 100% rename from samples/web-app-blob-storage/scripts/namespace.yml rename to samples/web-app-blob-storage/dotnet/scripts/namespace.yml diff --git a/samples/web-app-blob-storage/scripts/secret.yml b/samples/web-app-blob-storage/dotnet/scripts/secret.yml similarity index 100% rename from samples/web-app-blob-storage/scripts/secret.yml rename to samples/web-app-blob-storage/dotnet/scripts/secret.yml diff --git a/samples/web-app-blob-storage/scripts/service.yml b/samples/web-app-blob-storage/dotnet/scripts/service.yml similarity index 100% rename from samples/web-app-blob-storage/scripts/service.yml rename to samples/web-app-blob-storage/dotnet/scripts/service.yml diff --git a/samples/web-app-blob-storage/dotnet/src/.dockerignore b/samples/web-app-blob-storage/dotnet/src/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/web-app-blob-storage/dotnet/src/Models/Activity.cs b/samples/web-app-blob-storage/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml b/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..b296ad4 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) + { + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted successfully."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml b/samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..afd8549 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + if (await store.UpdateAsync(id, text, cancellationToken)) + { + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated successfully."; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added successfully."; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-blob-storage/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-blob-storage/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-blob-storage/dotnet/src/Program.cs b/samples/web-app-blob-storage/dotnet/src/Program.cs new file mode 100644 index 0000000..768568c --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Program.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Listen on PORT (8080 by default), the way the Python image binds gunicorn to 0.0.0.0:${PORT}. HTTP_PORTS is the +// same setting the aspnet base image feeds through ASPNETCORE_HTTP_PORTS, so no URL override is involved. +if (Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseSetting(WebHostDefaults.HttpPortsKey, port); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storeOptions = BlobStorageOptions.FromEnvironment(); + +// SECRET_KEY is the Kubernetes Secret the Python sample signs its session cookie with. Deriving the Data Protection +// key ring from it lets all replicas validate each other's antiforgery tokens and flash cookies; without it (a local +// docker run) each process keeps its own keys. +var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY"); +if (!string.IsNullOrEmpty(secretKey)) +{ + builder.Services.AddDataProtection().DisableAutomaticKeyGeneration(); + builder.Services.Configure(options => options.XmlRepository = new SecretKeyXmlRepository(secretKey)); +} + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new BlobActivityStore(storeOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +if (string.IsNullOrEmpty(secretKey)) +{ + app.Logger.LogWarning("SECRET_KEY is not set: antiforgery tokens and flash messages are only valid on this replica."); +} + +// One log line per request, the equivalent of the access log the Python image produces (its gunicorn +// command passes --access-logfile -). Kubernetes probes show up here too, exactly as they do for Python. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-blob-storage/dotnet/src/Services/BlobActivityStore.cs b/samples/web-app-blob-storage/dotnet/src/Services/BlobActivityStore.cs new file mode 100644 index 0000000..a267f6a --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Services/BlobActivityStore.cs @@ -0,0 +1,158 @@ +using System.Text; +using Azure.Identity; +using Azure.Storage; +using Azure.Storage.Blobs; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// One blob per activity in a Blob Storage container; the blob name is the activity id and its content the text. +public sealed class BlobActivityStore : IActivityStore +{ + /// Suffix of the blobs holding the activities, one blob per activity. + private const string ActivityBlobSuffix = "-activity.txt"; + + private readonly BlobContainerClient _container; + private readonly ILogger _logger; + + public BlobActivityStore(BlobStorageOptions options, ILogger logger) + { + _logger = logger; + + // The same credential ladder as the Python sample: an explicit service principal, then a connection string, + // then the identity of the pod. + BlobServiceClient service; + if (options is { ClientId: { Length: > 0 }, ClientSecret: { Length: > 0 }, TenantId: { Length: > 0 }, AccountUrl: { Length: > 0 } }) + { + logger.LogInformation("Using ClientSecretCredential with BlobServiceClient."); + var credential = new ClientSecretCredential(options.TenantId, options.ClientId, options.ClientSecret); + service = new BlobServiceClient(new Uri(options.AccountUrl), credential); + } + else if (!string.IsNullOrEmpty(options.ConnectionString)) + { + logger.LogInformation("Using storage account connection string with BlobServiceClient."); + service = FromConnectionString(options.ConnectionString); + } + else if (!string.IsNullOrEmpty(options.AccountUrl)) + { + // DefaultAzureCredential picks up the Microsoft Entra Workload ID the webhook projects into the pod + // (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE, AZURE_AUTHORITY_HOST). + logger.LogInformation("Using DefaultAzureCredential with BlobServiceClient."); + service = new BlobServiceClient(new Uri(options.AccountUrl), new DefaultAzureCredential()); + } + else + { + throw new InvalidOperationException( + "Insufficient configuration for BlobServiceClient. Set AZURE_STORAGE_ACCOUNT_URL (workload identity) or AZURE_STORAGE_ACCOUNT_CONNECTION_STRING."); + } + + _container = service.GetBlobContainerClient(options.ContainerName); + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await _container.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + _logger.LogInformation("Container '{Container}' is ready.", _container.Name); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + // Blobs come back sorted by name, which sorts by creation timestamp: the blob name is the timestamp. + var activities = new List(); + await foreach (var blob in _container.GetBlobsAsync(cancellationToken: cancellationToken)) + { + var content = await _container.GetBlobClient(blob.Name).DownloadContentAsync(cancellationToken); + _logger.LogInformation("Found blob '{Blob}' with size {Size} bytes", blob.Name, blob.Properties.ContentLength); + activities.Add(new Activity(blob.Name, content.Value.Content.ToString())); + } + + _logger.LogInformation("Retrieved {Count} blob(s) from container '{Container}'", activities.Count, _container.Name); + return activities; + } + + public Task AddAsync(string text, CancellationToken cancellationToken) => + UploadAsync($"{DateTime.Now:yyyy-MM-dd-HH-mm-ss}{ActivityBlobSuffix}", text, cancellationToken); + + public Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + if (!IsActivityName(id)) + { + _logger.LogWarning("Invalid activity name '{Name}'.", id); + return Task.FromResult(false); + } + + return UploadAsync(id, text, cancellationToken); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + if (!IsActivityName(id)) + { + _logger.LogWarning("Invalid activity name '{Name}'.", id); + return false; + } + + // As in the Python sample, a blob that is already gone still counts as deleted. + var deleted = await _container.GetBlobClient(id).DeleteIfExistsAsync(cancellationToken: cancellationToken); + if (deleted.Value) + { + _logger.LogInformation("Deleted blob '{Blob}' from container '{Container}'", id, _container.Name); + } + else + { + _logger.LogInformation("Blob '{Blob}' did not exist: already deleted.", id); + } + + return true; + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + return await _container.ExistsAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Blob Storage health check failed"); + return false; + } + } + + private async Task UploadAsync(string name, string text, CancellationToken cancellationToken) + { + await _container.GetBlobClient(name).UploadAsync(new BinaryData(Encoding.UTF8.GetBytes(text)), overwrite: true, cancellationToken); + _logger.LogInformation("Uploaded blob '{Blob}' to container '{Container}'", name, _container.Name); + return true; + } + + /// + /// Whether the name is one of this app's activity blobs, and nothing else: a blob called + /// yyyy-MM-dd-HH-mm-ss-activity.txt directly in the container. The name arrives from a form field, so this + /// keeps it from reaching any other blob in the container. + /// + private static bool IsActivityName(string? name) => + !string.IsNullOrEmpty(name) + && name is not ("." or "..") + && !name.Contains('/') && !name.Contains('\\') + && name.EndsWith(ActivityBlobSuffix, StringComparison.Ordinal); + + /// + /// Builds the client from the connection string's explicit BlobEndpoint and shared key when they are present, + /// as the Python SDK does. The .NET parser insists on a port-less EndpointSuffix and rejects the connection + /// string the LocalStack emulator returns, whose suffix carries the gateway port (core.azure.localhost.localstack.cloud:4566). + /// + private static BlobServiceClient FromConnectionString(string connectionString) + { + var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split('=', 2)) + .Where(kv => kv.Length == 2) + .ToDictionary(kv => kv[0].Trim(), kv => kv[1].Trim(), StringComparer.OrdinalIgnoreCase); + + return parts.TryGetValue("BlobEndpoint", out var endpoint) + && parts.TryGetValue("AccountName", out var accountName) + && parts.TryGetValue("AccountKey", out var accountKey) + ? new BlobServiceClient(new Uri(endpoint), new StorageSharedKeyCredential(accountName, accountKey)) + : new BlobServiceClient(connectionString); + } +} diff --git a/samples/web-app-blob-storage/dotnet/src/Services/BlobStorageOptions.cs b/samples/web-app-blob-storage/dotnet/src/Services/BlobStorageOptions.cs new file mode 100644 index 0000000..c353256 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Services/BlobStorageOptions.cs @@ -0,0 +1,19 @@ +namespace VacationPlanner.Services; + +/// Settings read from the same environment variables the Python sample uses. +public sealed record BlobStorageOptions( + string? AccountUrl, + string? ConnectionString, + string ContainerName, + string? ClientId, + string? ClientSecret, + string? TenantId) +{ + public static BlobStorageOptions FromEnvironment() => new( + AccountUrl: Environment.GetEnvironmentVariable("AZURE_STORAGE_ACCOUNT_URL"), + ConnectionString: Environment.GetEnvironmentVariable("AZURE_STORAGE_ACCOUNT_CONNECTION_STRING"), + ContainerName: Environment.GetEnvironmentVariable("CONTAINER_NAME") ?? "activities", + ClientId: Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"), + ClientSecret: Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET"), + TenantId: Environment.GetEnvironmentVariable("AZURE_TENANT_ID")); +} diff --git a/samples/web-app-blob-storage/dotnet/src/Services/IActivityStore.cs b/samples/web-app-blob-storage/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..167993e --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,24 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection, directory) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Adds an activity and returns whether the store confirmed the write; the page flashes only then. + Task AddAsync(string text, CancellationToken cancellationToken); + + /// Updates an activity and returns whether the store reported a change, with the meaning the Python sample's driver gives it. + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + /// Deletes an activity by its store id and returns whether the store reported a deletion. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-blob-storage/dotnet/src/Services/SecretKeyXmlRepository.cs b/samples/web-app-blob-storage/dotnet/src/Services/SecretKeyXmlRepository.cs new file mode 100644 index 0000000..e0cc5bb --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Services/SecretKeyXmlRepository.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel; +using Microsoft.AspNetCore.DataProtection.Repositories; + +namespace VacationPlanner.Services; + +/// +/// A Data Protection key ring derived deterministically from SECRET_KEY, the Kubernetes Secret the Python +/// sample signs its Flask session cookie with. ASP.NET Core protects its antiforgery tokens and TempData (flash) +/// cookies with Data Protection instead of a signing key; deriving the one key of the ring from the same secret +/// lets every replica of the Deployment validate what another replica issued, which the default per-process key +/// ring cannot offer behind a load balancer. +/// +public sealed class SecretKeyXmlRepository : IXmlRepository +{ + private readonly XElement _key; + + public SecretKeyXmlRepository(string secretKey) + { + var keyMaterial = Encoding.UTF8.GetBytes(secretKey); + + // A 512-bit master key (the size Data Protection generates itself) and a stable key id, both from SECRET_KEY. + var masterKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 64, info: "VacationPlanner.DataProtection.MasterKey"u8.ToArray()); + var keyId = new Guid(HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 16, info: "VacationPlanner.DataProtection.KeyId"u8.ToArray())); + + // AES-256-CBC + HMACSHA256, the default algorithms, serialized the way the key manager itself serializes a new key. + var descriptor = new AuthenticatedEncryptorDescriptor(new AuthenticatedEncryptorConfiguration(), new Secret(masterKey)); + var serialized = descriptor.ExportToXml(); + + _key = new XElement("key", + new XAttribute("id", keyId), + new XAttribute("version", 1), + new XElement("creationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("activationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("expirationDate", new DateTimeOffset(2999, 12, 31, 0, 0, 0, TimeSpan.Zero)), + new XElement("descriptor", + new XAttribute("deserializerType", serialized.DeserializerType.AssemblyQualifiedName!), + serialized.SerializedDescriptorElement)); + } + + public IReadOnlyCollection GetAllElements() => [new XElement(_key)]; + + /// Never called: automatic key generation is disabled, so the key manager has nothing to persist. + public void StoreElement(XElement element, string friendlyName) => + throw new NotSupportedException("The key ring is derived from SECRET_KEY and cannot be modified."); +} diff --git a/samples/web-app-blob-storage/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-blob-storage/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-blob-storage/dotnet/src/VacationPlanner.csproj b/samples/web-app-blob-storage/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..835cd67 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + + diff --git a/samples/web-app-blob-storage/dotnet/src/appsettings.json b/samples/web-app-blob-storage/dotnet/src/appsettings.json new file mode 100644 index 0000000..6e5c6b2 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss " + } + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-blob-storage/src/static/favicon.ico b/samples/web-app-blob-storage/dotnet/src/wwwroot/favicon.ico similarity index 100% rename from samples/web-app-blob-storage/src/static/favicon.ico rename to samples/web-app-blob-storage/dotnet/src/wwwroot/favicon.ico diff --git a/samples/web-app-blob-storage/src/static/style.css b/samples/web-app-blob-storage/dotnet/src/wwwroot/style.css similarity index 100% rename from samples/web-app-blob-storage/src/static/style.css rename to samples/web-app-blob-storage/dotnet/src/wwwroot/style.css diff --git a/samples/web-app-blob-storage/README.md b/samples/web-app-blob-storage/python/README.md similarity index 73% rename from samples/web-app-blob-storage/README.md rename to samples/web-app-blob-storage/python/README.md index 27b51f7..a70b195 100644 --- a/samples/web-app-blob-storage/README.md +++ b/samples/web-app-blob-storage/python/README.md @@ -1,17 +1,19 @@ # Vacation Planner: Azure Blob Storage +> A .NET version of this sample lives in [../dotnet](../dotnet/README.md). + This sample demonstrates a Python Flask 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/) sample. +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/python/) 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. +- [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. +> **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 @@ -46,7 +48,7 @@ cd scripts | [`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 Flask secret key) passed to the app as environment variables. | -| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification for the web app. | +| [`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 @@ -58,3 +60,17 @@ kubectl port-forward service/vacation-planner-blob 8080:80 -n vacation-planner-b ``` 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 — gunicorn writes an access log line for every call, the probes included, because its command passes `--access-logfile -` — plus one line per blob read, uploaded or deleted and one line for every activity added, updated or deleted. The store operations are printed to stdout, so `kubectl logs` shows them interleaved with the access log. The [.NET version](../dotnet/README.md) writes the same trace, timestamped. + +```bash +kubectl logs deployment/vacation-planner-blob -n vacation-planner-blob --tail=50 +``` diff --git a/samples/web-app-blob-storage/python/images/architecture.png b/samples/web-app-blob-storage/python/images/architecture.png new file mode 100644 index 0000000..40618b4 Binary files /dev/null and b/samples/web-app-blob-storage/python/images/architecture.png differ diff --git a/samples/web-app-blob-storage/scripts/00-variables.sh b/samples/web-app-blob-storage/python/scripts/00-variables.sh similarity index 100% rename from samples/web-app-blob-storage/scripts/00-variables.sh rename to samples/web-app-blob-storage/python/scripts/00-variables.sh diff --git a/samples/web-app-blob-storage/python/scripts/01-deploy-resources.sh b/samples/web-app-blob-storage/python/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..aa4c009 --- /dev/null +++ b/samples/web-app-blob-storage/python/scripts/01-deploy-resources.sh @@ -0,0 +1,141 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Create a resource group +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]..." + + 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 1 + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists in the subscription [$SUBSCRIPTION_NAME]" +fi + +# Create the Azure Container Registry +echo "Checking if [$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$ACR_NAME] Azure Container Registry exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled "true" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created successfully." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Create the Storage Account +echo "Checking if storage account [$STORAGE_ACCOUNT_NAME] exists in the resource group [$RESOURCE_GROUP_NAME]..." +az storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating storage account [$STORAGE_ACCOUNT_NAME]..." + az storage account create \ + --name $STORAGE_ACCOUNT_NAME \ + --location "$LOCATION" \ + --resource-group $RESOURCE_GROUP_NAME \ + --sku Standard_LRS \ + --allow-blob-public-access true \ + --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 +else + echo "Storage account [$STORAGE_ACCOUNT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Get the storage account key +STORAGE_ACCOUNT_KEY=$(az storage account keys list \ + --account-name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "[0].value" \ + --output tsv) + +if [ -z "$STORAGE_ACCOUNT_KEY" ]; then + echo "Failed to retrieve storage account key." + exit 1 +fi + +# Create the blob container +echo "Checking if blob container [$CONTAINER_NAME] exists in storage account [$STORAGE_ACCOUNT_NAME]..." +EXISTS=$(az storage container exists \ + --account-name $STORAGE_ACCOUNT_NAME \ + --account-key $STORAGE_ACCOUNT_KEY \ + --name $CONTAINER_NAME \ + --query exists \ + --output tsv 2>/dev/null) + +if [[ "$EXISTS" != "true" ]]; then + echo "Creating blob container [$CONTAINER_NAME]..." + az storage container create \ + --account-name $STORAGE_ACCOUNT_NAME \ + --account-key $STORAGE_ACCOUNT_KEY \ + --name $CONTAINER_NAME \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Blob container [$CONTAINER_NAME] created successfully." + else + echo "Failed to create blob container [$CONTAINER_NAME]." + exit 1 + fi +else + echo "Blob container [$CONTAINER_NAME] already exists." +fi + +# Retrieve the storage account connection string +echo "Retrieving storage account connection string for [$STORAGE_ACCOUNT_NAME]..." +AZURE_STORAGE_ACCOUNT_CONNECTION_STRING=$(az storage account show-connection-string \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "connectionString" \ + --output tsv \ + --only-show-errors) + +if [ -n "$AZURE_STORAGE_ACCOUNT_CONNECTION_STRING" ]; then + echo "Storage account connection string retrieved successfully." +else + echo "Failed to retrieve storage account connection string." + exit 1 +fi + +export AZURE_STORAGE_ACCOUNT_CONNECTION_STRING diff --git a/samples/web-app-blob-storage/scripts/02-build-docker-image.sh b/samples/web-app-blob-storage/python/scripts/02-build-docker-image.sh similarity index 100% rename from samples/web-app-blob-storage/scripts/02-build-docker-image.sh rename to samples/web-app-blob-storage/python/scripts/02-build-docker-image.sh diff --git a/samples/web-app-managed-identity/scripts/03-run-docker-container.sh b/samples/web-app-blob-storage/python/scripts/03-run-docker-container.sh similarity index 100% rename from samples/web-app-managed-identity/scripts/03-run-docker-container.sh rename to samples/web-app-blob-storage/python/scripts/03-run-docker-container.sh diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/04-push-docker-image.sh b/samples/web-app-blob-storage/python/scripts/04-push-docker-image.sh similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/04-push-docker-image.sh rename to samples/web-app-blob-storage/python/scripts/04-push-docker-image.sh diff --git a/samples/web-app-blob-storage/scripts/05-deploy-app.sh b/samples/web-app-blob-storage/python/scripts/05-deploy-app.sh similarity index 75% rename from samples/web-app-blob-storage/scripts/05-deploy-app.sh rename to samples/web-app-blob-storage/python/scripts/05-deploy-app.sh index a49b0f2..d8aae24 100755 --- a/samples/web-app-blob-storage/scripts/05-deploy-app.sh +++ b/samples/web-app-blob-storage/python/scripts/05-deploy-app.sh @@ -20,7 +20,14 @@ else fi # Generate a stable Flask SECRET_KEY (sessions survive pod restarts) -SECRET_KEY=$(openssl rand -hex 32) +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +SECRET_KEY=$(kubectl get secret $SECRET_NAME --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $SECRET_KEY ]]; then + SECRET_KEY=$(openssl rand -hex 32) +fi # Get the login server for the Azure Container Registry echo "Getting login server for Azure Container Registry [$ACR_NAME]..." @@ -70,3 +77,7 @@ kubectl apply -f - cat service.yml | yq "(.metadata.namespace)|="\""$NAMESPACE"\" | kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE diff --git a/samples/web-app-blob-storage/scripts/Dockerfile b/samples/web-app-blob-storage/python/scripts/Dockerfile similarity index 100% rename from samples/web-app-blob-storage/scripts/Dockerfile rename to samples/web-app-blob-storage/python/scripts/Dockerfile diff --git a/samples/web-app-managed-identity/scripts/configmap.yml b/samples/web-app-blob-storage/python/scripts/configmap.yml similarity index 71% rename from samples/web-app-managed-identity/scripts/configmap.yml rename to samples/web-app-blob-storage/python/scripts/configmap.yml index 5f40ea9..21b20c5 100644 --- a/samples/web-app-managed-identity/scripts/configmap.yml +++ b/samples/web-app-blob-storage/python/scripts/configmap.yml @@ -7,6 +7,3 @@ metadata: app: vacation-planner-blob data: CONTAINER_NAME: "activities" - AZURE_CLIENT_ID: "" - AZURE_TENANT_ID: "" - AZURE_STORAGE_ACCOUNT_URL: "" diff --git a/samples/web-app-blob-storage/scripts/deployment.yml b/samples/web-app-blob-storage/python/scripts/deployment.yml similarity index 97% rename from samples/web-app-blob-storage/scripts/deployment.yml rename to samples/web-app-blob-storage/python/scripts/deployment.yml index b4cd0da..60db5e9 100644 --- a/samples/web-app-blob-storage/scripts/deployment.yml +++ b/samples/web-app-blob-storage/python/scripts/deployment.yml @@ -55,7 +55,7 @@ spec: memory: "256Mi" livenessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 15 periodSeconds: 30 @@ -63,7 +63,7 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 5 periodSeconds: 10 diff --git a/samples/web-app-managed-identity/scripts/namespace.yml b/samples/web-app-blob-storage/python/scripts/namespace.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/namespace.yml rename to samples/web-app-blob-storage/python/scripts/namespace.yml diff --git a/samples/web-app-managed-identity/scripts/secret.yml b/samples/web-app-blob-storage/python/scripts/secret.yml similarity index 90% rename from samples/web-app-managed-identity/scripts/secret.yml rename to samples/web-app-blob-storage/python/scripts/secret.yml index 6e45b31..d450796 100644 --- a/samples/web-app-managed-identity/scripts/secret.yml +++ b/samples/web-app-blob-storage/python/scripts/secret.yml @@ -8,5 +8,4 @@ metadata: type: Opaque data: AZURE_STORAGE_ACCOUNT_CONNECTION_STRING: "" - AZURE_CLIENT_SECRET: "" SECRET_KEY: "" diff --git a/samples/web-app-managed-identity/scripts/service.yml b/samples/web-app-blob-storage/python/scripts/service.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/service.yml rename to samples/web-app-blob-storage/python/scripts/service.yml diff --git a/samples/web-app-managed-identity/src/app.py b/samples/web-app-blob-storage/python/src/app.py similarity index 81% rename from samples/web-app-managed-identity/src/app.py rename to samples/web-app-blob-storage/python/src/app.py index de56959..93f11f5 100644 --- a/samples/web-app-managed-identity/src/app.py +++ b/samples/web-app-blob-storage/python/src/app.py @@ -5,7 +5,7 @@ from azure.identity import DefaultAzureCredential, ClientSecretCredential from azure.storage.blob import BlobServiceClient from azure.core.exceptions import ResourceExistsError -from flask import Flask, flash, render_template, request, redirect, url_for +from flask import Flask, flash, jsonify, render_template, request, redirect, url_for # Initialize Flask application app: Flask = Flask(__name__) @@ -23,6 +23,18 @@ debug: bool = os.environ.get("FLASK_DEBUG", "false").lower() == "true" activities: List[Tuple[str, str]] = [] +# Suffix of the blobs holding the activities, one blob per activity. +ACTIVITY_BLOB_SUFFIX: str = "-activity.txt" + +def is_activity_name(name: str | None) -> bool: + """Whether the name is one of this app's activity blobs, and nothing else. + + Every activity is a blob called YYYY-MM-DD-HH-MM-SS-activity.txt, so requiring that shape is both the + read filter and the write guard: it rejects an empty name and any name the app did not create. Names + arrive from a form field, so they are checked before they reach the container. + """ + return bool(name) and name == os.path.basename(str(name)) and str(name).endswith(ACTIVITY_BLOB_SUFFIX) + def get_environment_variables(): """Get the value of an environment variable or raise an error if not set.""" global connection_string, container_name, client_id, client_secret, tenant_id, account_url @@ -226,7 +238,11 @@ def index(): activity = request.form.get('activity', '').strip() if activity: if row_id: - # Update existing blob content in place + # Update the existing blob content in place. The name comes from the form, so it is checked + # before it reaches the container. + if not is_activity_name(row_id): + print(f"Invalid activity name '{row_id}'.") + return redirect(url_for('index')) update_blob(row_id, activity) for i, act in enumerate(activities): if act[0] == row_id: @@ -236,7 +252,7 @@ def index(): else: # Generate a unique blob name with a timestamp timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S") - name = f"{timestamp}-activity.txt" + name = f"{timestamp}{ACTIVITY_BLOB_SUFFIX}" create_blob_if_not_exists(name, activity) activities.append((name, activity)) flash('Activity added successfully.') @@ -247,14 +263,39 @@ def index(): read_blobs_from_container() return render_template('index.html', activities=activities) -@app.route('/delete/', methods=['POST']) -def delete(activity_id): - if 0 <= activity_id < len(activities): - delete_blob(activities[activity_id][0]) - activities.pop(activity_id) - flash('Activity deleted successfully.') +@app.route('/delete/', methods=['POST']) +def delete(activity_id: str): + """Delete the activity whose blob name is activity_id. + + The activity is identified by its blob name, never by its position in the rendered page: every replica + reloads the container on each GET, so the list can change between rendering a page and submitting a + delete from it, and a position would then delete whatever activity happens to sit there now. + """ + if not is_activity_name(activity_id): + print(f"Invalid activity name '{activity_id}'.") + return redirect(url_for('index')) + + delete_blob(activity_id) + for i, act in enumerate(activities): + if act[0] == activity_id: + activities.pop(i) + break + flash('Activity deleted successfully.') return redirect(url_for('index')) +@app.route('/health') +def health(): + """Liveness and readiness probe: reports whether the blob container is reachable.""" + try: + if not blob_service_client or not container_name: + raise ValueError("BlobServiceClient is not initialized.") + if not blob_service_client.get_container_client(container_name).exists(): + raise ValueError(f"Container '{container_name}' does not exist.") + return jsonify({"status": "ok"}) + except Exception as ex: + print(f"Health check failed: {ex}") + return jsonify({"status": "unavailable"}), 503 + # Initialize the application and Azure services when the module is loaded. # This ensures that the setup runs regardless of how the app is started (e.g., via 'flask run' or directly). get_environment_variables() diff --git a/samples/web-app-blob-storage/src/gunicorn.conf.py b/samples/web-app-blob-storage/python/src/gunicorn.conf.py similarity index 100% rename from samples/web-app-blob-storage/src/gunicorn.conf.py rename to samples/web-app-blob-storage/python/src/gunicorn.conf.py diff --git a/samples/web-app-blob-storage/src/requirements.txt b/samples/web-app-blob-storage/python/src/requirements.txt similarity index 100% rename from samples/web-app-blob-storage/src/requirements.txt rename to samples/web-app-blob-storage/python/src/requirements.txt diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.min.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.min.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.min.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.min.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.min.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.min.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.min.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.min.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.rtl.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.rtl.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.rtl.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.rtl.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.rtl.min.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.rtl.min.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.min.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.min.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.min.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.min.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.min.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.min.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.min.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.min.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.min.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.min.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.min.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.min.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.min.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.min.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.min.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.min.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.min.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.min.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.min.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.min.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.rtl.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.rtl.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.rtl.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.rtl.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.rtl.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.rtl.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.rtl.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.rtl.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.rtl.min.css b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.rtl.min.css similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.rtl.min.css rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.rtl.min.css diff --git a/samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.rtl.min.css.map b/samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap.rtl.min.css.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.bundle.js b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.bundle.js rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.bundle.js diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.bundle.js.map b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.bundle.js.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.bundle.js.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.bundle.js.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.bundle.min.js b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.bundle.min.js rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.bundle.min.js diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.bundle.min.js.map b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.bundle.min.js.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.esm.js b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.esm.js similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.esm.js rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.esm.js diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.esm.js.map b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.esm.js.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.esm.js.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.esm.js.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.esm.min.js b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.esm.min.js similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.esm.min.js rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.esm.min.js diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.esm.min.js.map b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.esm.min.js.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.esm.min.js.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.esm.min.js.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.js b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.js similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.js rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.js diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.js.map b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.js.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.js.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.js.map diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.min.js b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.min.js similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.min.js rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.min.js diff --git a/samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.min.js.map b/samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.min.js.map similarity index 100% rename from samples/web-app-blob-storage/src/static/bootstrap/js/bootstrap.min.js.map rename to samples/web-app-blob-storage/python/src/static/bootstrap/js/bootstrap.min.js.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/favicon.ico b/samples/web-app-blob-storage/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/favicon.ico rename to samples/web-app-blob-storage/python/src/static/favicon.ico diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/style.css b/samples/web-app-blob-storage/python/src/static/style.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/style.css rename to samples/web-app-blob-storage/python/src/static/style.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/templates/index.html b/samples/web-app-blob-storage/python/src/templates/index.html similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/templates/index.html rename to samples/web-app-blob-storage/python/src/templates/index.html diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md b/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md new file mode 100644 index 0000000..356b0ab --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md @@ -0,0 +1,76 @@ +# Vacation Planner: Azure Cosmos DB for MongoDB + +> 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` collection of the `sampledb` database on an [Azure Cosmos DB for MongoDB](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/introduction) account. + +The application talks to Cosmos DB through the MongoDB wire protocol, so it uses a standard MongoDB driver and connection string. + +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, Cosmos DB account/database/collection, 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 Cosmos DB for MongoDB](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/overview) account, the `sampledb` database, and the `activities` collection (with its shard key and indexes). | +| [`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 database 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 (database name, collection name, login name) passed to the app as environment variables. | +| [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the Cosmos DB MongoDB 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-mongodb 8080:80 -n vacation-planner-mongodb +``` + +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 Cosmos DB for MongoDB account 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 document read, inserted, updated 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-mongodb -n vacation-planner-mongodb --tail=50 +``` diff --git a/samples/web-app-cosmosdb-mongodb-api/images/architecture.png b/samples/web-app-cosmosdb-mongodb-api/dotnet/images/architecture.png similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/images/architecture.png rename to samples/web-app-cosmosdb-mongodb-api/dotnet/images/architecture.png diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/00-variables.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/00-variables.sh new file mode 100755 index 0000000..efbb92c --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/00-variables.sh @@ -0,0 +1,38 @@ +# Variables + +# Azure Resources +PREFIX='local' +SUFFIX='test' +LOCATION='italynorth' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX,,}acr${SUFFIX,,}" +ACR_SKU='Standard' +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)" + +# Cosmos DB (MongoDB API) +COSMOSDB_ACCOUNT_NAME="${PREFIX}-mongodb-${SUFFIX}" +MONGODB_API_VERSION='7.0' +COSMOSDB_DATABASE_NAME='sampledb' +COSMOSDB_COLLECTION_NAME='activities' +INDEXES='[{"key":{"keys":["_id"]}},{"key":{"keys":["username"]}},{"key":{"keys":["activity"]}},{"key":{"keys":["timestamp"]}}]' +SHARD='username' +THROUGHPUT=400 + +# Application config +LOGIN_NAME='paolo' + +# Docker Image +IMAGE_NAME="vacation-planner-mongodb-dotnet" +IMAGE_PULL_POLICY="Always" +IMAGE_TAG="v1" +PORT="8080" + +# Kubernetes +NAMESPACE="vacation-planner-mongodb" +DEPLOYMENT_NAME="vacation-planner-mongodb" +SERVICE_NAME="vacation-planner-mongodb" +CONFIGMAP_NAME="vacation-planner-mongodb-config" +SECRET_NAME="vacation-planner-mongodb-secrets" diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/01-deploy-resources.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/01-deploy-resources.sh similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/01-deploy-resources.sh rename to samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/01-deploy-resources.sh diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/02-build-docker-image.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/02-build-docker-image.sh new file mode 100755 index 0000000..4b497d8 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/02-build-docker-image.sh @@ -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 diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/03-run-docker-container.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/03-run-docker-container.sh similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/03-run-docker-container.sh rename to samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/03-run-docker-container.sh diff --git a/samples/web-app-cosmosdb-nosql-api/scripts/04-push-docker-image.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/04-push-docker-image.sh similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/04-push-docker-image.sh rename to samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/04-push-docker-image.sh diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/05-deploy-app.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/05-deploy-app.sh new file mode 100755 index 0000000..6b4350e --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,100 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Retrieve the Cosmos DB MongoDB connection string +echo "Retrieving Cosmos DB connection string for [$COSMOSDB_ACCOUNT_NAME]..." +COSMOSDB_CONNECTION_STRING=$(az cosmosdb keys list \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --type connection-strings \ + --query "connectionStrings[0].connectionString" \ + --output tsv) + +if [ -n "$COSMOSDB_CONNECTION_STRING" ]; then + echo "Cosmos DB connection string retrieved successfully." +else + echo "Failed to retrieve Cosmos DB connection string." + exit 1 +fi + +# Generate a stable SECRET_KEY shared by all replicas: the app derives its Data Protection key ring from it, +# so antiforgery tokens and flash messages are valid on every replica and survive pod restarts +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +SECRET_KEY=$(kubectl get secret $SECRET_NAME --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $SECRET_KEY ]]; then + SECRET_KEY=$(openssl rand -hex 32) +fi + +# Get the login server for the Azure Container Registry +echo "Getting login server for Azure Container Registry [$ACR_NAME]..." +ACR_LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +if [ -n "$ACR_LOGIN_SERVER" ]; then + echo "Login server retrieved successfully: $ACR_LOGIN_SERVER" +else + echo "Failed to retrieve login server for Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +# Create full image name with login server, image name, and tag +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Create namespace +cat namespace.yml | +yq "(.metadata.name)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Create secret with the Cosmos DB connection string and SECRET_KEY +cat secret.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.COSMOSDB_CONNECTION_STRING)|="\""$(echo -n $COSMOSDB_CONNECTION_STRING | base64 -w0)"\" | +yq "(.data.SECRET_KEY)|="\""$(echo -n $SECRET_KEY | base64 -w0)"\" | +kubectl apply -f - + +# Create configmap with environment variables +cat configmap.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.COSMOSDB_DATABASE_NAME)|="\""$COSMOSDB_DATABASE_NAME"\" | +yq "(.data.COSMOSDB_COLLECTION_NAME)|="\""$COSMOSDB_COLLECTION_NAME"\" | +yq "(.data.LOGIN_NAME)|="\""$LOGIN_NAME"\" | +kubectl apply -f - + +# Create deployment +cat deployment.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.spec.template.spec.containers[0].image)|="\""$FULL_IMAGE"\" | +yq "(.spec.template.spec.containers[0].imagePullPolicy)|="\""$IMAGE_PULL_POLICY"\" | +yq "(.spec.template.spec.containers[0].ports[0].containerPort)|=$PORT" | +kubectl apply -f - + +# Create service +cat service.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE + +# Wait for the rollout so a pod stuck in ImagePullBackOff or CrashLoopBackOff is reported here, not discovered later +echo "Waiting for deployment [$DEPLOYMENT_NAME] to roll out..." +if kubectl rollout status deployment/$DEPLOYMENT_NAME -n $NAMESPACE --timeout=600s; then + echo "Deployment [$DEPLOYMENT_NAME] is ready. To reach the web app, run:" + echo " kubectl port-forward service/$SERVICE_NAME 8080:80 -n $NAMESPACE" + echo "and browse to http://localhost:8080 (health: http://localhost:8080/health)." +else + echo "Deployment [$DEPLOYMENT_NAME] did not become ready. Inspect it with:" + echo " kubectl get pods -n $NAMESPACE" + echo " kubectl describe pod -n $NAMESPACE --selector app=$DEPLOYMENT_NAME" + exit 1 +fi diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/Dockerfile b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/Dockerfile new file mode 100644 index 0000000..d862da7 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/Dockerfile @@ -0,0 +1,28 @@ +# Build stage: restore and publish the ASP.NET Core app with the .NET SDK image. +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build + +WORKDIR /src + +# Restore first so the package cache layer is reused when only sources change. +COPY VacationPlanner.csproj ./ +RUN dotnet restore + +COPY . ./ +RUN dotnet publish -c Release -o /app/publish --no-restore + +# Runtime stage: the ASP.NET Core runtime image only, no SDK. +FROM mcr.microsoft.com/dotnet/aspnet:10.0 + +# Port Kestrel listens on; kept as a build argument (and a runtime variable) as in the Python image. +ARG PORT=8080 +ENV PORT=${PORT} + +WORKDIR /app +COPY --from=build /app/publish ./ + +# Run as the runtime image's non-root `app` user, as the Python image runs as its own unprivileged `app` user. +USER app + +EXPOSE ${PORT} + +ENTRYPOINT ["dotnet", "VacationPlanner.dll"] diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/configmap.yml b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/configmap.yml similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/configmap.yml rename to samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/configmap.yml diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deployment.yml b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..7d6f788 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deployment.yml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vacation-planner-mongodb + namespace: vacation-planner-mongodb + labels: + app: vacation-planner-mongodb +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-mongodb + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-mongodb + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: vacation-planner-mongodb + image: .azurecr.io/vacation-planner-mongodb-dotnet:v1 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: COSMOSDB_DATABASE_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-mongodb-config + key: COSMOSDB_DATABASE_NAME + - name: COSMOSDB_COLLECTION_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-mongodb-config + key: COSMOSDB_COLLECTION_NAME + - name: LOGIN_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-mongodb-config + key: LOGIN_NAME + - name: COSMOSDB_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: vacation-planner-mongodb-secrets + key: COSMOSDB_CONNECTION_STRING + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-mongodb-secrets + key: SECRET_KEY + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "256Mi" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/namespace.yml b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/namespace.yml similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/namespace.yml rename to samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/namespace.yml diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/secret.yml b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/secret.yml similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/secret.yml rename to samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/secret.yml diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/service.yml b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/service.yml similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/service.yml rename to samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/service.yml diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/.dockerignore b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Models/Activity.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..6272570 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) + { + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..4a8e59c --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + if (await store.UpdateAsync(id, text, cancellationToken)) + { + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated!"; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added!"; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs new file mode 100644 index 0000000..9d948f9 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Listen on PORT (8080 by default), the way the Python image binds gunicorn to 0.0.0.0:${PORT}. HTTP_PORTS is the +// same setting the aspnet base image feeds through ASPNETCORE_HTTP_PORTS, so no URL override is involved. +if (Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseSetting(WebHostDefaults.HttpPortsKey, port); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storeOptions = MongoOptions.FromEnvironment(); + +// SECRET_KEY is the Kubernetes Secret the Python sample signs its session cookie with. Deriving the Data Protection +// key ring from it lets all replicas validate each other's antiforgery tokens and flash cookies; without it (a local +// docker run) each process keeps its own keys. +var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY"); +if (!string.IsNullOrEmpty(secretKey)) +{ + builder.Services.AddDataProtection().DisableAutomaticKeyGeneration(); + builder.Services.Configure(options => options.XmlRepository = new SecretKeyXmlRepository(secretKey)); +} + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new MongoActivityStore(storeOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +if (string.IsNullOrEmpty(secretKey)) +{ + app.Logger.LogWarning("SECRET_KEY is not set: antiforgery tokens and flash messages are only valid on this replica."); +} + +// One log line per request, the equivalent of the access log the Python image produces (its gunicorn +// command passes --access-logfile -). Kubernetes probes show up here too, exactly as they do for Python. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/ActivityId.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/ActivityId.cs new file mode 100644 index 0000000..8654aaf --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/ActivityId.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VacationPlanner.Services; + +/// MD5 of username + activity + timestamp: the id scheme shared by the Vacation Planner samples. +public static class ActivityId +{ + public static string Create(string username, string activity) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"); + var hash = MD5.HashData(Encoding.UTF8.GetBytes($"{username}_{activity}_{timestamp}")); + return Convert.ToHexStringLower(hash); + } +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/IActivityStore.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..167993e --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,24 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection, directory) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Adds an activity and returns whether the store confirmed the write; the page flashes only then. + Task AddAsync(string text, CancellationToken cancellationToken); + + /// Updates an activity and returns whether the store reported a change, with the meaning the Python sample's driver gives it. + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + /// Deletes an activity by its store id and returns whether the store reported a deletion. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs new file mode 100644 index 0000000..aac64f9 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs @@ -0,0 +1,120 @@ +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Driver; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Activities as documents {_id, username, activity, timestamp} in an Azure Cosmos DB for MongoDB collection. +public sealed class MongoActivityStore : IActivityStore +{ + private readonly IMongoDatabase _database; + private readonly IMongoCollection _collection; + private readonly MongoOptions _options; + private readonly ILogger _logger; + + /// Documents are logged indented, like the Python sample's json.dumps(indent=3) output. + private static readonly JsonWriterSettings Indented = new() { Indent = true }; + + public MongoActivityStore(MongoOptions options, ILogger logger) + { + _options = options; + _logger = logger; + var client = new MongoClient(options.ConnectionString); + _database = client.GetDatabase(options.DatabaseName); + _collection = _database.GetCollection(options.CollectionName); + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + var existing = await (await _database.ListCollectionNamesAsync(cancellationToken: cancellationToken)).ToListAsync(cancellationToken); + if (existing.Contains(_options.CollectionName)) + { + _logger.LogInformation("Collection '{Collection}' already exists in database '{Database}'", _options.CollectionName, _options.DatabaseName); + return; + } + + await _database.CreateCollectionAsync(_options.CollectionName, cancellationToken: cancellationToken); + var keys = Builders.IndexKeys; + await _collection.Indexes.CreateManyAsync( + [ + new CreateIndexModel(keys.Ascending("username")), + new CreateIndexModel(keys.Ascending("activity")), + new CreateIndexModel(keys.Ascending("timestamp")), + ], cancellationToken); + _logger.LogInformation("Created collection '{Collection}' in database '{Database}'", _options.CollectionName, _options.DatabaseName); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + var filter = Builders.Filter.Eq("username", _options.Username); + var documents = await _collection.Find(filter).ToListAsync(cancellationToken); + _logger.LogInformation( + "Retrieved {Count} document(s) from collection '{Collection}': {Documents}", + documents.Count, + _options.CollectionName, + documents.ToJson(Indented) + ); + return documents.Select(d => new Activity(d["_id"].AsString, d["activity"].AsString)).ToList(); + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + var document = new BsonDocument + { + ["_id"] = ActivityId.Create(_options.Username, text), + ["username"] = _options.Username, + ["activity"] = text, + ["timestamp"] = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"), + }; + await _collection.InsertOneAsync(document, cancellationToken: cancellationToken); + _logger.LogInformation( + "Inserted document into collection '{Collection}': {Document}", + _options.CollectionName, + document.ToJson(Indented) + ); + return true; + } + + /// Reports a change only when the document was modified, as the Python sample's modified_count check does. + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + var result = await _collection.UpdateOneAsync( + Builders.Filter.Eq("_id", id), + Builders.Update.Set("activity", text), + cancellationToken: cancellationToken); + _logger.LogInformation( + "Updated {Count} document(s) with id {Id} in collection '{Collection}'", + result.ModifiedCount, + id, + _options.CollectionName + ); + return result.ModifiedCount > 0; + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + var result = await _collection.DeleteOneAsync(Builders.Filter.Eq("_id", id), cancellationToken); + _logger.LogInformation( + "Deleted {Count} document(s) with id {Id} from collection '{Collection}'", + result.DeletedCount, + id, + _options.CollectionName + ); + return true; + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await _database.RunCommandAsync(new BsonDocument("ping", 1), cancellationToken: cancellationToken); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "MongoDB health check failed"); + return false; + } + } +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoOptions.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoOptions.cs new file mode 100644 index 0000000..3f8bc4e --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoOptions.cs @@ -0,0 +1,32 @@ +namespace VacationPlanner.Services; + +/// Settings read from the same environment variables the Python sample uses. +public sealed record MongoOptions(string ConnectionString, string DatabaseName, string CollectionName, string Username) +{ + public static MongoOptions FromEnvironment() + { + var connectionString = Environment.GetEnvironmentVariable("COSMOSDB_CONNECTION_STRING") + ?? Environment.GetEnvironmentVariable("MONGODB_CONNECTION_STRING"); + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException("Missing required environment variable: COSMOSDB_CONNECTION_STRING or MONGODB_CONNECTION_STRING"); + } + + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "paolo"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("LOGIN_NAME is set to an empty value"); + } + + return new MongoOptions( + ConnectionString: connectionString, + DatabaseName: Require("COSMOSDB_DATABASE_NAME"), + CollectionName: Require("COSMOSDB_COLLECTION_NAME"), + Username: username); + } + + private static string Require(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException($"Missing required environment variable: {name}"); +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/SecretKeyXmlRepository.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/SecretKeyXmlRepository.cs new file mode 100644 index 0000000..e0cc5bb --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/SecretKeyXmlRepository.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel; +using Microsoft.AspNetCore.DataProtection.Repositories; + +namespace VacationPlanner.Services; + +/// +/// A Data Protection key ring derived deterministically from SECRET_KEY, the Kubernetes Secret the Python +/// sample signs its Flask session cookie with. ASP.NET Core protects its antiforgery tokens and TempData (flash) +/// cookies with Data Protection instead of a signing key; deriving the one key of the ring from the same secret +/// lets every replica of the Deployment validate what another replica issued, which the default per-process key +/// ring cannot offer behind a load balancer. +/// +public sealed class SecretKeyXmlRepository : IXmlRepository +{ + private readonly XElement _key; + + public SecretKeyXmlRepository(string secretKey) + { + var keyMaterial = Encoding.UTF8.GetBytes(secretKey); + + // A 512-bit master key (the size Data Protection generates itself) and a stable key id, both from SECRET_KEY. + var masterKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 64, info: "VacationPlanner.DataProtection.MasterKey"u8.ToArray()); + var keyId = new Guid(HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 16, info: "VacationPlanner.DataProtection.KeyId"u8.ToArray())); + + // AES-256-CBC + HMACSHA256, the default algorithms, serialized the way the key manager itself serializes a new key. + var descriptor = new AuthenticatedEncryptorDescriptor(new AuthenticatedEncryptorConfiguration(), new Secret(masterKey)); + var serialized = descriptor.ExportToXml(); + + _key = new XElement("key", + new XAttribute("id", keyId), + new XAttribute("version", 1), + new XElement("creationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("activationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("expirationDate", new DateTimeOffset(2999, 12, 31, 0, 0, 0, TimeSpan.Zero)), + new XElement("descriptor", + new XAttribute("deserializerType", serialized.DeserializerType.AssemblyQualifiedName!), + serialized.SerializedDescriptorElement)); + } + + public IReadOnlyCollection GetAllElements() => [new XElement(_key)]; + + /// Never called: automatic key generation is disabled, so the key manager has nothing to persist. + public void StoreElement(XElement element, string friendlyName) => + throw new NotSupportedException("The key ring is derived from SECRET_KEY and cannot be modified."); +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/VacationPlanner.csproj b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..9a89b96 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json new file mode 100644 index 0000000..6e5c6b2 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss " + } + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/favicon.ico b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/favicon.ico similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/favicon.ico rename to samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/favicon.ico diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/style.css b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/style.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/style.css rename to samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/style.css diff --git a/samples/web-app-cosmosdb-mongodb-api/README.md b/samples/web-app-cosmosdb-mongodb-api/python/README.md similarity index 74% rename from samples/web-app-cosmosdb-mongodb-api/README.md rename to samples/web-app-cosmosdb-mongodb-api/python/README.md index 46e4612..48a4cea 100644 --- a/samples/web-app-cosmosdb-mongodb-api/README.md +++ b/samples/web-app-cosmosdb-mongodb-api/python/README.md @@ -1,17 +1,19 @@ # Vacation Planner: Azure Cosmos DB for MongoDB +> A .NET version of this sample lives in [../dotnet](../dotnet/README.md). + This sample demonstrates a Python Flask 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` collection of the `sampledb` database on an [Azure Cosmos DB for MongoDB](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/introduction) account. The application talks to Cosmos DB through the MongoDB wire protocol, so it uses a standard MongoDB driver and connection string. 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. +- [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. +> **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 @@ -46,7 +48,7 @@ cd scripts | [`namespace.yml`](scripts/namespace.yml) | Creates the Kubernetes namespace. | | [`configmap.yml`](scripts/configmap.yml) | Creates the ConfigMap holding non-secret input values (database name, collection name, login name) passed to the app as environment variables. | | [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the Cosmos DB MongoDB connection string and the Flask secret key) passed to the app as environment variables. | -| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification for the web app. | +| [`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 @@ -58,3 +60,17 @@ kubectl port-forward service/vacation-planner-mongodb 8080:80 -n vacation-planne ``` 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 Cosmos DB for MongoDB account is reachable and `503` with `{"status": "unavailable"}` otherwise. + +```bash +curl http://localhost:8080/health +``` + +## Logs + +The app logs one line per request — gunicorn writes an access log line for every call, the probes included, because its command passes `--access-logfile -` — plus one line per document read, inserted, updated or deleted and one line for every activity added, updated or deleted. Every entry carries a timestamp, the logger name and the level; the Azure SDK and `urllib3` stay at warning level. The [.NET version](../dotnet/README.md) writes the same trace. + +```bash +kubectl logs deployment/vacation-planner-mongodb -n vacation-planner-mongodb --tail=50 +``` diff --git a/samples/web-app-cosmosdb-mongodb-api/python/images/architecture.png b/samples/web-app-cosmosdb-mongodb-api/python/images/architecture.png new file mode 100644 index 0000000..2fb4dca Binary files /dev/null and b/samples/web-app-cosmosdb-mongodb-api/python/images/architecture.png differ diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/00-variables.sh b/samples/web-app-cosmosdb-mongodb-api/python/scripts/00-variables.sh similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/00-variables.sh rename to samples/web-app-cosmosdb-mongodb-api/python/scripts/00-variables.sh diff --git a/samples/web-app-cosmosdb-mongodb-api/python/scripts/01-deploy-resources.sh b/samples/web-app-cosmosdb-mongodb-api/python/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..4a20921 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/python/scripts/01-deploy-resources.sh @@ -0,0 +1,162 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Create a resource group +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]..." + + 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 1 + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists in the subscription [$SUBSCRIPTION_NAME]" +fi + +# Create the Azure Container Registry +echo "Checking if [$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$ACR_NAME] Azure Container Registry exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled "true" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created successfully." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Create the Cosmos DB account (MongoDB API) +echo "Checking if Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group..." +az cosmosdb show \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] with MongoDB API..." + az cosmosdb create \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --kind MongoDB \ + --server-version "$MONGODB_API_VERSION" \ + --default-consistency-level Session \ + --locations regionName="$LOCATION" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] created successfully." + else + echo "Failed to create Cosmos DB account [$COSMOSDB_ACCOUNT_NAME]." + exit 1 + fi +else + echo "Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Create the MongoDB database +echo "Checking if MongoDB database [$COSMOSDB_DATABASE_NAME] exists in account [$COSMOSDB_ACCOUNT_NAME]..." +az cosmosdb mongodb database show \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --name "$COSMOSDB_DATABASE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating MongoDB database [$COSMOSDB_DATABASE_NAME]..." + az cosmosdb mongodb database create \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --name "$COSMOSDB_DATABASE_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "MongoDB database [$COSMOSDB_DATABASE_NAME] created successfully." + else + echo "Failed to create MongoDB database [$COSMOSDB_DATABASE_NAME]." + exit 1 + fi +else + echo "MongoDB database [$COSMOSDB_DATABASE_NAME] already exists in account [$COSMOSDB_ACCOUNT_NAME]" +fi + +# Create the MongoDB collection +echo "Checking if MongoDB collection [$COSMOSDB_COLLECTION_NAME] exists in database [$COSMOSDB_DATABASE_NAME]..." +az cosmosdb mongodb collection show \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --database-name "$COSMOSDB_DATABASE_NAME" \ + --name "$COSMOSDB_COLLECTION_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating MongoDB collection [$COSMOSDB_COLLECTION_NAME]..." + az cosmosdb mongodb collection create \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --database-name "$COSMOSDB_DATABASE_NAME" \ + --name "$COSMOSDB_COLLECTION_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --shard "$SHARD" \ + --throughput "$THROUGHPUT" \ + --idx "$INDEXES" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "MongoDB collection [$COSMOSDB_COLLECTION_NAME] created successfully." + else + echo "Failed to create MongoDB collection [$COSMOSDB_COLLECTION_NAME]." + exit 1 + fi +else + echo "MongoDB collection [$COSMOSDB_COLLECTION_NAME] already exists in database [$COSMOSDB_DATABASE_NAME]" +fi + +# Retrieve the Cosmos DB MongoDB connection string +echo "Retrieving Cosmos DB MongoDB connection string for [$COSMOSDB_ACCOUNT_NAME]..." +COSMOSDB_CONNECTION_STRING=$(az cosmosdb keys list \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --type connection-strings \ + --query "connectionStrings[0].connectionString" \ + --output tsv) + +if [ -n "$COSMOSDB_CONNECTION_STRING" ]; then + echo "Cosmos DB connection string retrieved successfully." +else + echo "Failed to retrieve Cosmos DB connection string." + exit 1 +fi + +export COSMOSDB_CONNECTION_STRING diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/02-build-docker-image.sh b/samples/web-app-cosmosdb-mongodb-api/python/scripts/02-build-docker-image.sh similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/02-build-docker-image.sh rename to samples/web-app-cosmosdb-mongodb-api/python/scripts/02-build-docker-image.sh diff --git a/samples/web-app-cosmosdb-mongodb-api/python/scripts/03-run-docker-container.sh b/samples/web-app-cosmosdb-mongodb-api/python/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..c8e39b3 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/python/scripts/03-run-docker-container.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Retrieve the Cosmos DB MongoDB connection string +echo "Retrieving Cosmos DB connection string for [$COSMOSDB_ACCOUNT_NAME]..." +COSMOSDB_CONNECTION_STRING=$(az cosmosdb keys list \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --type connection-strings \ + --query "connectionStrings[0].connectionString" \ + --output tsv) + +if [ -n "$COSMOSDB_CONNECTION_STRING" ]; then + echo "Cosmos DB connection string retrieved successfully." +else + echo "Failed to retrieve Cosmos DB connection string." + exit 1 +fi + +# --network=host so endpoints like *.localhost.localstack.cloud resolve to the +# host's loopback (where LocalStack is listening), not the container's. +docker run -it \ + --rm \ + --network=host \ + -e PORT=$PORT \ + -e COSMOSDB_CONNECTION_STRING="$COSMOSDB_CONNECTION_STRING" \ + -e COSMOSDB_DATABASE_NAME="$COSMOSDB_DATABASE_NAME" \ + -e COSMOSDB_COLLECTION_NAME="$COSMOSDB_COLLECTION_NAME" \ + -e LOGIN_NAME="$LOGIN_NAME" \ + --name "$IMAGE_NAME" \ + "$IMAGE_NAME:$IMAGE_TAG" diff --git a/samples/web-app-file-storage/scripts/04-push-docker-image.sh b/samples/web-app-cosmosdb-mongodb-api/python/scripts/04-push-docker-image.sh similarity index 100% rename from samples/web-app-file-storage/scripts/04-push-docker-image.sh rename to samples/web-app-cosmosdb-mongodb-api/python/scripts/04-push-docker-image.sh diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/05-deploy-app.sh b/samples/web-app-cosmosdb-mongodb-api/python/scripts/05-deploy-app.sh similarity index 76% rename from samples/web-app-cosmosdb-mongodb-api/scripts/05-deploy-app.sh rename to samples/web-app-cosmosdb-mongodb-api/python/scripts/05-deploy-app.sh index 161c844..aa11996 100755 --- a/samples/web-app-cosmosdb-mongodb-api/scripts/05-deploy-app.sh +++ b/samples/web-app-cosmosdb-mongodb-api/python/scripts/05-deploy-app.sh @@ -20,7 +20,14 @@ else fi # Generate a stable Flask SECRET_KEY (sessions survive pod restarts) -SECRET_KEY=$(openssl rand -hex 32) +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +SECRET_KEY=$(kubectl get secret $SECRET_NAME --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $SECRET_KEY ]]; then + SECRET_KEY=$(openssl rand -hex 32) +fi # Get the login server for the Azure Container Registry echo "Getting login server for Azure Container Registry [$ACR_NAME]..." @@ -73,3 +80,7 @@ kubectl apply -f - cat service.yml | yq "(.metadata.namespace)|="\""$NAMESPACE"\" | kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/Dockerfile b/samples/web-app-cosmosdb-mongodb-api/python/scripts/Dockerfile similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/Dockerfile rename to samples/web-app-cosmosdb-mongodb-api/python/scripts/Dockerfile diff --git a/samples/web-app-cosmosdb-mongodb-api/python/scripts/configmap.yml b/samples/web-app-cosmosdb-mongodb-api/python/scripts/configmap.yml new file mode 100644 index 0000000..ffa2b15 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/python/scripts/configmap.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-mongodb-config + namespace: vacation-planner-mongodb + labels: + app: vacation-planner-mongodb +data: + COSMOSDB_DATABASE_NAME: "sampledb" + COSMOSDB_COLLECTION_NAME: "activities" + LOGIN_NAME: "paolo" diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/deployment.yml b/samples/web-app-cosmosdb-mongodb-api/python/scripts/deployment.yml similarity index 97% rename from samples/web-app-cosmosdb-mongodb-api/scripts/deployment.yml rename to samples/web-app-cosmosdb-mongodb-api/python/scripts/deployment.yml index 6366645..148e5ca 100644 --- a/samples/web-app-cosmosdb-mongodb-api/scripts/deployment.yml +++ b/samples/web-app-cosmosdb-mongodb-api/python/scripts/deployment.yml @@ -65,7 +65,7 @@ spec: memory: "256Mi" livenessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 15 periodSeconds: 30 @@ -73,7 +73,7 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 5 periodSeconds: 10 diff --git a/samples/web-app-cosmosdb-mongodb-api/python/scripts/namespace.yml b/samples/web-app-cosmosdb-mongodb-api/python/scripts/namespace.yml new file mode 100644 index 0000000..86d5f20 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/python/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-mongodb diff --git a/samples/web-app-cosmosdb-mongodb-api/python/scripts/secret.yml b/samples/web-app-cosmosdb-mongodb-api/python/scripts/secret.yml new file mode 100644 index 0000000..19103cd --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/python/scripts/secret.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-mongodb-secrets + namespace: vacation-planner-mongodb + labels: + app: vacation-planner-mongodb +type: Opaque +data: + COSMOSDB_CONNECTION_STRING: "" + SECRET_KEY: "" diff --git a/samples/web-app-cosmosdb-mongodb-api/python/scripts/service.yml b/samples/web-app-cosmosdb-mongodb-api/python/scripts/service.yml new file mode 100644 index 0000000..d08ef11 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/python/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-mongodb + namespace: vacation-planner-mongodb + labels: + app: vacation-planner-mongodb +spec: + type: ClusterIP + selector: + app: vacation-planner-mongodb + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-cosmosdb-mongodb-api/src/app.py b/samples/web-app-cosmosdb-mongodb-api/python/src/app.py similarity index 82% rename from samples/web-app-cosmosdb-mongodb-api/src/app.py rename to samples/web-app-cosmosdb-mongodb-api/python/src/app.py index aab8e20..6610baa 100644 --- a/samples/web-app-cosmosdb-mongodb-api/src/app.py +++ b/samples/web-app-cosmosdb-mongodb-api/python/src/app.py @@ -3,7 +3,7 @@ import datetime import logging from typing import List, Tuple -from flask import Flask, flash, render_template, request, redirect, url_for +from flask import Flask, flash, jsonify, render_template, request, redirect, url_for from mongodb import MongoDbClient import hashlib @@ -108,16 +108,34 @@ def favicon(): """Serve the favicon from the static folder.""" return app.send_static_file('favicon.ico') -@app.route('/delete/', methods=['POST']) -def delete(activity_id: int): - """Handle deletion of an activity by its index.""" - if 0 <= activity_id < len(activities): +@app.route('/delete/', methods=['POST']) +def delete(activity_id: str): + """Handle deletion of an activity by its document id. + + The document id addresses the activity, never its position in the rendered page: every replica reloads + the collection on each GET, so a position would delete whatever activity happens to sit there now. + """ + if activity_id: # Delete the document from MongoDB - mongodb_client.delete_document_by_id(activities[activity_id][0]) + mongodb_client.delete_document_by_id(activity_id) + for i, act in enumerate(activities): + if act[0] == activity_id: + activities.pop(i) + break flash("Activity deleted.") return redirect(url_for('index')) +@app.route('/health') +def health(): + """Liveness and readiness probe: reports whether the MongoDB server answers a ping.""" + try: + mongodb_client.client.admin.command('ping') + return jsonify({"status": "ok"}) + except Exception as ex: + logger.warning("Health check failed: %s", ex) + return jsonify({"status": "unavailable"}), 503 + # Read debug environment variable debug = os.environ.get("DEBUG", "false").lower() == "true" diff --git a/samples/web-app-cosmosdb-mongodb-api/src/gunicorn.conf.py b/samples/web-app-cosmosdb-mongodb-api/python/src/gunicorn.conf.py similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/gunicorn.conf.py rename to samples/web-app-cosmosdb-mongodb-api/python/src/gunicorn.conf.py diff --git a/samples/web-app-cosmosdb-mongodb-api/src/mongodb.py b/samples/web-app-cosmosdb-mongodb-api/python/src/mongodb.py similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/mongodb.py rename to samples/web-app-cosmosdb-mongodb-api/python/src/mongodb.py diff --git a/samples/web-app-cosmosdb-mongodb-api/src/requirements.txt b/samples/web-app-cosmosdb-mongodb-api/python/src/requirements.txt similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/requirements.txt rename to samples/web-app-cosmosdb-mongodb-api/python/src/requirements.txt diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.min.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.min.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.min.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.min.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.min.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.min.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.min.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.rtl.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.rtl.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.rtl.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.rtl.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.rtl.min.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.rtl.min.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.min.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.min.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.min.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.min.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.min.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.min.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.rtl.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.rtl.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.min.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.min.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.min.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.min.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.min.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.min.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.rtl.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.rtl.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.min.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.min.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.min.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.min.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.min.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.min.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.min.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.rtl.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.rtl.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.rtl.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.rtl.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.rtl.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.rtl.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.rtl.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.rtl.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.rtl.min.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.rtl.min.css similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.rtl.min.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.rtl.min.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.rtl.min.css.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/css/bootstrap.rtl.min.css.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.bundle.js b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.bundle.js rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.bundle.js diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.bundle.js.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.bundle.js.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.bundle.js.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.bundle.js.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.bundle.min.js b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.bundle.min.js rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.bundle.min.js diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.bundle.min.js.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.bundle.min.js.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.esm.js b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.esm.js similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.esm.js rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.esm.js diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.esm.js.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.esm.js.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.esm.js.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.esm.js.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.esm.min.js b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.esm.min.js similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.esm.min.js rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.esm.min.js diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.esm.min.js.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.esm.min.js.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.esm.min.js.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.esm.min.js.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.js b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.js similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.js rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.js diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.js.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.js.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.js.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.js.map diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.min.js b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.min.js similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.min.js rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.min.js diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.min.js.map b/samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.min.js.map similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/static/bootstrap/js/bootstrap.min.js.map rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/bootstrap/js/bootstrap.min.js.map diff --git a/samples/web-app-file-storage/src/static/favicon.ico b/samples/web-app-cosmosdb-mongodb-api/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-file-storage/src/static/favicon.ico rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/favicon.ico diff --git a/samples/web-app-file-storage/src/static/style.css b/samples/web-app-cosmosdb-mongodb-api/python/src/static/style.css similarity index 100% rename from samples/web-app-file-storage/src/static/style.css rename to samples/web-app-cosmosdb-mongodb-api/python/src/static/style.css diff --git a/samples/web-app-blob-storage/src/templates/index.html b/samples/web-app-cosmosdb-mongodb-api/python/src/templates/index.html similarity index 99% rename from samples/web-app-blob-storage/src/templates/index.html rename to samples/web-app-cosmosdb-mongodb-api/python/src/templates/index.html index 45a7be8..efb642d 100644 --- a/samples/web-app-blob-storage/src/templates/index.html +++ b/samples/web-app-cosmosdb-mongodb-api/python/src/templates/index.html @@ -70,7 +70,7 @@

🌴 Vacation Planner

-
+ + + + + + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + + + + +
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..b012b42 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + if (await store.UpdateAsync(id, text, cancellationToken)) + { + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated."; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added."; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs new file mode 100644 index 0000000..f67b85e --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Listen on PORT (8080 by default), the way the Python image binds gunicorn to 0.0.0.0:${PORT}. HTTP_PORTS is the +// same setting the aspnet base image feeds through ASPNETCORE_HTTP_PORTS, so no URL override is involved. +if (Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseSetting(WebHostDefaults.HttpPortsKey, port); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storeOptions = CosmosOptions.FromEnvironment(); + +// SECRET_KEY is the Kubernetes Secret the Python sample signs its session cookie with. Deriving the Data Protection +// key ring from it lets all replicas validate each other's antiforgery tokens and flash cookies; without it (a local +// docker run) each process keeps its own keys. +var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY"); +if (!string.IsNullOrEmpty(secretKey)) +{ + builder.Services.AddDataProtection().DisableAutomaticKeyGeneration(); + builder.Services.Configure(options => options.XmlRepository = new SecretKeyXmlRepository(secretKey)); +} + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new CosmosActivityStore(storeOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +if (string.IsNullOrEmpty(secretKey)) +{ + app.Logger.LogWarning("SECRET_KEY is not set: antiforgery tokens and flash messages are only valid on this replica."); +} + +// One log line per request, the equivalent of the access log the Python image produces (its gunicorn +// command passes --access-logfile -). Kubernetes probes show up here too, exactly as they do for Python. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityDocument.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityDocument.cs new file mode 100644 index 0000000..c3e2552 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityDocument.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace VacationPlanner.Services; + +/// The Cosmos DB item shape shared with the Python sample: {id, username, activity, timestamp}. +public sealed class ActivityDocument +{ + [JsonProperty("id")] + public string Id { get; set; } = ""; + + [JsonProperty("username")] + public string Username { get; set; } = ""; + + [JsonProperty("activity")] + public string Activity { get; set; } = ""; + + [JsonProperty("timestamp")] + public string Timestamp { get; set; } = ""; +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityId.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityId.cs new file mode 100644 index 0000000..8654aaf --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityId.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VacationPlanner.Services; + +/// MD5 of username + activity + timestamp: the id scheme shared by the Vacation Planner samples. +public static class ActivityId +{ + public static string Create(string username, string activity) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"); + var hash = MD5.HashData(Encoding.UTF8.GetBytes($"{username}_{activity}_{timestamp}")); + return Convert.ToHexStringLower(hash); + } +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs new file mode 100644 index 0000000..40904c0 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs @@ -0,0 +1,128 @@ +using System.Net; +using Microsoft.Azure.Cosmos; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Activities as items in an Azure Cosmos DB for NoSQL container partitioned by /username. +public sealed class CosmosActivityStore : IActivityStore +{ + private readonly CosmosClient _client; + private readonly CosmosOptions _options; + private readonly ILogger _logger; + private Container? _container; + + public CosmosActivityStore(CosmosOptions options, ILogger logger) + { + _options = options; + _logger = logger; + // Gateway mode talks plain HTTPS to the account endpoint, which is what the LocalStack emulator + // exposes; Direct mode (the SDK default) needs the TCP replica endpoints of a real account. + _client = new CosmosClient(options.Endpoint, options.Key, new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + LimitToEndpoint = true, + }); + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + var database = await _client.CreateDatabaseIfNotExistsAsync(_options.DatabaseName, cancellationToken: cancellationToken); + var container = await database.Database.CreateContainerIfNotExistsAsync( + new ContainerProperties(_options.ContainerName, "/username"), throughput: 400, cancellationToken: cancellationToken); + _container = container.Container; + _logger.LogInformation("Cosmos DB database '{Database}' and container '{Container}' are ready", _options.DatabaseName, _options.ContainerName); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + var query = new QueryDefinition("SELECT * FROM c WHERE c.username = @username").WithParameter("@username", _options.Username); + var activities = new List(); + using var iterator = Container.GetItemQueryIterator(query); + while (iterator.HasMoreResults) + { + foreach (var document in await iterator.ReadNextAsync(cancellationToken)) + { + activities.Add(new Activity(document.Id, document.Activity)); + } + } + + _logger.LogInformation( + "Retrieved {Count} item(s) for user: {Username} from container '{Container}'", + activities.Count, + _options.Username, + _options.ContainerName + ); + return activities; + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + var document = new ActivityDocument + { + Id = ActivityId.Create(_options.Username, text), + Username = _options.Username, + Activity = text, + Timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"), + }; + await Container.CreateItemAsync(document, new PartitionKey(_options.Username), cancellationToken: cancellationToken); + _logger.LogInformation( + "Created item {Id} in container '{Container}': {Activity}", + document.Id, + _options.ContainerName, + text + ); + return true; + } + + /// Like the Python sample, an update of a missing item is logged and still reported as done. + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + try + { + var item = await Container.ReadItemAsync(id, new PartitionKey(_options.Username), cancellationToken: cancellationToken); + item.Resource.Activity = text; + await Container.ReplaceItemAsync(item.Resource, id, new PartitionKey(_options.Username), cancellationToken: cancellationToken); + _logger.LogInformation("Updated item {Id} in container '{Container}'", id, _options.ContainerName); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + _logger.LogWarning("Activity {Id} was not found; nothing to update", id); + } + + return true; + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Deleting item {Id} from container '{Container}'", id, _options.ContainerName); + await Container.DeleteItemAsync(id, new PartitionKey(_options.Username), cancellationToken: cancellationToken); + return true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Already gone counts as deleted: every replica serves the same container, so another replica + // may have deleted the item a moment earlier, and the user should see it disappear either way. + _logger.LogInformation("Activity {Id} was not found: already deleted.", id); + return true; + } + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await Container.ReadContainerAsync(cancellationToken: cancellationToken); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cosmos DB health check failed"); + return false; + } + } + + private Container Container => _container ?? _client.GetContainer(_options.DatabaseName, _options.ContainerName); +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosOptions.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosOptions.cs new file mode 100644 index 0000000..fe7e9b2 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosOptions.cs @@ -0,0 +1,26 @@ +namespace VacationPlanner.Services; + +/// Settings read from the same environment variables the Python sample uses. +public sealed record CosmosOptions(string Endpoint, string Key, string DatabaseName, string ContainerName, string Username) +{ + public static CosmosOptions FromEnvironment() + { + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "alex"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("LOGIN_NAME is set to an empty value"); + } + + return new CosmosOptions( + Endpoint: Require("AZURECOSMOSDB_ENDPOINT"), + Key: Require("AZURECOSMOSDB_PRIMARY_KEY"), + DatabaseName: Require("AZURECOSMOSDB_DATABASENAME"), + ContainerName: Require("AZURECOSMOSDB_CONTAINERNAME"), + Username: username); + } + + private static string Require(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException($"Missing required environment variable: {name}"); +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/IActivityStore.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..167993e --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,24 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection, directory) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Adds an activity and returns whether the store confirmed the write; the page flashes only then. + Task AddAsync(string text, CancellationToken cancellationToken); + + /// Updates an activity and returns whether the store reported a change, with the meaning the Python sample's driver gives it. + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + /// Deletes an activity by its store id and returns whether the store reported a deletion. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/SecretKeyXmlRepository.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/SecretKeyXmlRepository.cs new file mode 100644 index 0000000..e0cc5bb --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/SecretKeyXmlRepository.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel; +using Microsoft.AspNetCore.DataProtection.Repositories; + +namespace VacationPlanner.Services; + +/// +/// A Data Protection key ring derived deterministically from SECRET_KEY, the Kubernetes Secret the Python +/// sample signs its Flask session cookie with. ASP.NET Core protects its antiforgery tokens and TempData (flash) +/// cookies with Data Protection instead of a signing key; deriving the one key of the ring from the same secret +/// lets every replica of the Deployment validate what another replica issued, which the default per-process key +/// ring cannot offer behind a load balancer. +/// +public sealed class SecretKeyXmlRepository : IXmlRepository +{ + private readonly XElement _key; + + public SecretKeyXmlRepository(string secretKey) + { + var keyMaterial = Encoding.UTF8.GetBytes(secretKey); + + // A 512-bit master key (the size Data Protection generates itself) and a stable key id, both from SECRET_KEY. + var masterKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 64, info: "VacationPlanner.DataProtection.MasterKey"u8.ToArray()); + var keyId = new Guid(HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 16, info: "VacationPlanner.DataProtection.KeyId"u8.ToArray())); + + // AES-256-CBC + HMACSHA256, the default algorithms, serialized the way the key manager itself serializes a new key. + var descriptor = new AuthenticatedEncryptorDescriptor(new AuthenticatedEncryptorConfiguration(), new Secret(masterKey)); + var serialized = descriptor.ExportToXml(); + + _key = new XElement("key", + new XAttribute("id", keyId), + new XAttribute("version", 1), + new XElement("creationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("activationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("expirationDate", new DateTimeOffset(2999, 12, 31, 0, 0, 0, TimeSpan.Zero)), + new XElement("descriptor", + new XAttribute("deserializerType", serialized.DeserializerType.AssemblyQualifiedName!), + serialized.SerializedDescriptorElement)); + } + + public IReadOnlyCollection GetAllElements() => [new XElement(_key)]; + + /// Never called: automatic key generation is disabled, so the key manager has nothing to persist. + public void StoreElement(XElement element, string friendlyName) => + throw new NotSupportedException("The key ring is derived from SECRET_KEY and cannot be modified."); +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/VacationPlanner.csproj b/samples/web-app-cosmosdb-nosql-api/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..ee66710 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + + diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json b/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json new file mode 100644 index 0000000..6e5c6b2 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss " + } + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-in-cluster-postgresql/src/static/favicon.ico b/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/favicon.ico similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/favicon.ico rename to samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/favicon.ico diff --git a/samples/web-app-in-cluster-postgresql/src/static/style.css b/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/style.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/style.css rename to samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/style.css diff --git a/samples/web-app-cosmosdb-nosql-api/README.md b/samples/web-app-cosmosdb-nosql-api/python/README.md similarity index 75% rename from samples/web-app-cosmosdb-nosql-api/README.md rename to samples/web-app-cosmosdb-nosql-api/python/README.md index e75016e..aacabbb 100644 --- a/samples/web-app-cosmosdb-nosql-api/README.md +++ b/samples/web-app-cosmosdb-nosql-api/python/README.md @@ -1,19 +1,21 @@ # Vacation Planner: Azure Cosmos DB for NoSQL +> A .NET version of this sample lives in [../dotnet](../dotnet/README.md). + This sample demonstrates a Python Flask 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 the `vacationplanner` database on an [Azure Cosmos DB for NoSQL](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/) account. The application uses the native Cosmos DB NoSQL (SQL) API, with `/username` as the container's partition key. 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. +- [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. > LocalStack note: when running against the emulator, the `LOCALSTACK_URL` variable in [`00-variables.sh`](scripts/00-variables.sh) triggers installation of the LocalStack root CA so the app trusts the emulated endpoint. Set it to an empty value when targeting real Azure. -> **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. +> **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 @@ -48,7 +50,7 @@ cd scripts | [`namespace.yml`](scripts/namespace.yml) | Creates the Kubernetes namespace. | | [`configmap.yml`](scripts/configmap.yml) | Creates the ConfigMap holding non-secret input values (Cosmos DB endpoint, database, container, login name) passed to the app as environment variables. | | [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the Cosmos DB key and the Flask secret key) passed to the app as environment variables. | -| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification for the web app. | +| [`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 @@ -60,3 +62,17 @@ kubectl port-forward service/vacation-planner-nosql 8080:80 -n vacation-planner- ``` 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 Cosmos DB for NoSQL container is reachable and `503` with `{"status": "unavailable"}` otherwise. + +```bash +curl http://localhost:8080/health +``` + +## Logs + +The app logs one line per request — gunicorn writes an access log line for every call, the probes included, because its command passes `--access-logfile -` — plus one line per item read, created, updated or deleted and one line for every activity added, updated or deleted. Every entry carries a timestamp, the logger name and the level; the Azure SDK and `urllib3` stay at warning level. The [.NET version](../dotnet/README.md) writes the same trace. + +```bash +kubectl logs deployment/vacation-planner-nosql -n vacation-planner-nosql --tail=50 +``` diff --git a/samples/web-app-cosmosdb-nosql-api/python/images/architecture.png b/samples/web-app-cosmosdb-nosql-api/python/images/architecture.png new file mode 100644 index 0000000..dec8134 Binary files /dev/null and b/samples/web-app-cosmosdb-nosql-api/python/images/architecture.png differ diff --git a/samples/web-app-cosmosdb-nosql-api/scripts/00-variables.sh b/samples/web-app-cosmosdb-nosql-api/python/scripts/00-variables.sh similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/00-variables.sh rename to samples/web-app-cosmosdb-nosql-api/python/scripts/00-variables.sh diff --git a/samples/web-app-cosmosdb-nosql-api/python/scripts/01-deploy-resources.sh b/samples/web-app-cosmosdb-nosql-api/python/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..e7f2049 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/python/scripts/01-deploy-resources.sh @@ -0,0 +1,174 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Create a resource group +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]..." + + 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 1 + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists in the subscription [$SUBSCRIPTION_NAME]" +fi + +# Create the Azure Container Registry +echo "Checking if [$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$ACR_NAME] Azure Container Registry exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled "true" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created successfully." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Create the Cosmos DB account (NoSQL / SQL API - default kind GlobalDocumentDB) +echo "Checking if Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group..." +az cosmosdb show \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] with NoSQL API..." + az cosmosdb create \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --locations regionName="$LOCATION" \ + --default-consistency-level Session \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] created successfully." + else + echo "Failed to create Cosmos DB account [$COSMOSDB_ACCOUNT_NAME]." + exit 1 + fi +else + echo "Cosmos DB account [$COSMOSDB_ACCOUNT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Retrieve the document endpoint +echo "Retrieving document endpoint for Cosmos DB account [$COSMOSDB_ACCOUNT_NAME]..." +AZURECOSMOSDB_ENDPOINT=$(az cosmosdb show \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "documentEndpoint" \ + --output tsv) + +if [ -n "$AZURECOSMOSDB_ENDPOINT" ]; then + echo "Document endpoint retrieved successfully: $AZURECOSMOSDB_ENDPOINT" +else + echo "Failed to retrieve document endpoint." + exit 1 +fi + +# Create the SQL database +echo "Checking if SQL database [$AZURECOSMOSDB_DATABASENAME] exists in account [$COSMOSDB_ACCOUNT_NAME]..." +az cosmosdb sql database show \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --name "$AZURECOSMOSDB_DATABASENAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating SQL database [$AZURECOSMOSDB_DATABASENAME]..." + az cosmosdb sql database create \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --name "$AZURECOSMOSDB_DATABASENAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "SQL database [$AZURECOSMOSDB_DATABASENAME] created successfully." + else + echo "Failed to create SQL database [$AZURECOSMOSDB_DATABASENAME]." + exit 1 + fi +else + echo "SQL database [$AZURECOSMOSDB_DATABASENAME] already exists in account [$COSMOSDB_ACCOUNT_NAME]" +fi + +# Create the SQL container +echo "Checking if SQL container [$AZURECOSMOSDB_CONTAINERNAME] exists in database [$AZURECOSMOSDB_DATABASENAME]..." +az cosmosdb sql container show \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --database-name "$AZURECOSMOSDB_DATABASENAME" \ + --name "$AZURECOSMOSDB_CONTAINERNAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating SQL container [$AZURECOSMOSDB_CONTAINERNAME]..." + az cosmosdb sql container create \ + --account-name "$COSMOSDB_ACCOUNT_NAME" \ + --database-name "$AZURECOSMOSDB_DATABASENAME" \ + --name "$AZURECOSMOSDB_CONTAINERNAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --partition-key-path "$AZURECOSMOSDB_PARTITION_KEY" \ + --throughput "$THROUGHPUT" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "SQL container [$AZURECOSMOSDB_CONTAINERNAME] created successfully." + else + echo "Failed to create SQL container [$AZURECOSMOSDB_CONTAINERNAME]." + exit 1 + fi +else + echo "SQL container [$AZURECOSMOSDB_CONTAINERNAME] already exists in database [$AZURECOSMOSDB_DATABASENAME]" +fi + +# Retrieve the primary master key +echo "Retrieving primary master key for Cosmos DB account [$COSMOSDB_ACCOUNT_NAME]..." +AZURECOSMOSDB_PRIMARY_KEY=$(az cosmosdb keys list \ + --name "$COSMOSDB_ACCOUNT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "primaryMasterKey" \ + --output tsv) + +if [ -n "$AZURECOSMOSDB_PRIMARY_KEY" ]; then + echo "Primary master key retrieved successfully." +else + echo "Failed to retrieve primary master key." + exit 1 +fi + +export AZURECOSMOSDB_ENDPOINT +export AZURECOSMOSDB_PRIMARY_KEY diff --git a/samples/web-app-cosmosdb-nosql-api/scripts/02-build-docker-image.sh b/samples/web-app-cosmosdb-nosql-api/python/scripts/02-build-docker-image.sh similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/02-build-docker-image.sh rename to samples/web-app-cosmosdb-nosql-api/python/scripts/02-build-docker-image.sh diff --git a/samples/web-app-cosmosdb-nosql-api/scripts/03-run-docker-container.sh b/samples/web-app-cosmosdb-nosql-api/python/scripts/03-run-docker-container.sh similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/03-run-docker-container.sh rename to samples/web-app-cosmosdb-nosql-api/python/scripts/03-run-docker-container.sh diff --git a/samples/web-app-managed-identity/scripts/04-push-docker-image.sh b/samples/web-app-cosmosdb-nosql-api/python/scripts/04-push-docker-image.sh similarity index 100% rename from samples/web-app-managed-identity/scripts/04-push-docker-image.sh rename to samples/web-app-cosmosdb-nosql-api/python/scripts/04-push-docker-image.sh diff --git a/samples/web-app-cosmosdb-nosql-api/scripts/05-deploy-app.sh b/samples/web-app-cosmosdb-nosql-api/python/scripts/05-deploy-app.sh similarity index 83% rename from samples/web-app-cosmosdb-nosql-api/scripts/05-deploy-app.sh rename to samples/web-app-cosmosdb-nosql-api/python/scripts/05-deploy-app.sh index 1fab500..30d42fd 100755 --- a/samples/web-app-cosmosdb-nosql-api/scripts/05-deploy-app.sh +++ b/samples/web-app-cosmosdb-nosql-api/python/scripts/05-deploy-app.sh @@ -34,7 +34,14 @@ else fi # Generate a stable Flask SECRET_KEY (sessions survive pod restarts) -FLASK_SECRET_KEY=$(openssl rand -hex 32) +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +FLASK_SECRET_KEY=$(kubectl get secret $SECRET_NAME --namespace $NAMESPACE --output jsonpath='{.data.FLASK_SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $FLASK_SECRET_KEY ]]; then + FLASK_SECRET_KEY=$(openssl rand -hex 32) +fi # Get the login server for the Azure Container Registry echo "Getting login server for Azure Container Registry [$ACR_NAME]..." @@ -116,3 +123,7 @@ kubectl apply -f - cat service.yml | yq "(.metadata.namespace)|="\""$NAMESPACE"\" | kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE diff --git a/samples/web-app-cosmosdb-nosql-api/scripts/Dockerfile b/samples/web-app-cosmosdb-nosql-api/python/scripts/Dockerfile similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/Dockerfile rename to samples/web-app-cosmosdb-nosql-api/python/scripts/Dockerfile diff --git a/samples/web-app-cosmosdb-nosql-api/python/scripts/configmap.yml b/samples/web-app-cosmosdb-nosql-api/python/scripts/configmap.yml new file mode 100644 index 0000000..5ea8e75 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/python/scripts/configmap.yml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-nosql-config + namespace: vacation-planner-nosql + labels: + app: vacation-planner-nosql +data: + AZURECOSMOSDB_ENDPOINT: "" + AZURECOSMOSDB_DATABASENAME: "vacationplanner" + AZURECOSMOSDB_CONTAINERNAME: "activities" + LOGIN_NAME: "alex" diff --git a/samples/web-app-cosmosdb-nosql-api/scripts/deployment.yml b/samples/web-app-cosmosdb-nosql-api/python/scripts/deployment.yml similarity index 98% rename from samples/web-app-cosmosdb-nosql-api/scripts/deployment.yml rename to samples/web-app-cosmosdb-nosql-api/python/scripts/deployment.yml index 940c99f..07537ae 100644 --- a/samples/web-app-cosmosdb-nosql-api/scripts/deployment.yml +++ b/samples/web-app-cosmosdb-nosql-api/python/scripts/deployment.yml @@ -80,7 +80,7 @@ spec: memory: "256Mi" livenessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 15 periodSeconds: 30 @@ -88,7 +88,7 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 5 periodSeconds: 10 diff --git a/samples/web-app-cosmosdb-nosql-api/python/scripts/namespace.yml b/samples/web-app-cosmosdb-nosql-api/python/scripts/namespace.yml new file mode 100644 index 0000000..f5e8700 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/python/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-nosql diff --git a/samples/web-app-cosmosdb-nosql-api/scripts/secret.yml b/samples/web-app-cosmosdb-nosql-api/python/scripts/secret.yml similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/secret.yml rename to samples/web-app-cosmosdb-nosql-api/python/scripts/secret.yml diff --git a/samples/web-app-cosmosdb-nosql-api/python/scripts/service.yml b/samples/web-app-cosmosdb-nosql-api/python/scripts/service.yml new file mode 100644 index 0000000..d6d0e79 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/python/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-nosql + namespace: vacation-planner-nosql + labels: + app: vacation-planner-nosql +spec: + type: ClusterIP + selector: + app: vacation-planner-nosql + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-cosmosdb-nosql-api/src/app.py b/samples/web-app-cosmosdb-nosql-api/python/src/app.py similarity index 75% rename from samples/web-app-cosmosdb-nosql-api/src/app.py rename to samples/web-app-cosmosdb-nosql-api/python/src/app.py index 01ec002..1229469 100644 --- a/samples/web-app-cosmosdb-nosql-api/src/app.py +++ b/samples/web-app-cosmosdb-nosql-api/python/src/app.py @@ -2,7 +2,8 @@ import datetime import logging import hashlib -from flask import Flask, flash, render_template, request, redirect, url_for +from flask import Flask, flash, jsonify, render_template, request, redirect, url_for +from azure.cosmos import exceptions from cosmosdb_client import CosmosDbClient @@ -107,12 +108,28 @@ def index(): @app.route('/delete/', methods=['POST']) def delete(activity_id: str): logger.info(f"Deleting activity with ID: {activity_id}") - - # Direct deletion using the ID passed in the URL - get_cosmos().delete_document_by_id(activity_id, username) + + # Direct deletion using the ID passed in the URL. An activity that is already gone counts as deleted: + # every replica serves the same container, so another replica may have deleted it a moment earlier, + # and raising here would answer the user with HTTP 500 for work that is already done. + try: + get_cosmos().delete_document_by_id(activity_id, username) + except exceptions.CosmosResourceNotFoundError: + logger.warning(f"Activity {activity_id} was not found; nothing to delete") + flash('Activity deleted.') - + return redirect(url_for('index')) +@app.route('/health') +def health(): + """Liveness and readiness probe: reports whether the Cosmos DB container is reachable.""" + try: + get_cosmos().ping() + return jsonify({"status": "ok"}) + except Exception as ex: + logger.warning("Health check failed: %s", ex) + return jsonify({"status": "unavailable"}), 503 + if __name__ == '__main__': app.run(debug=True) diff --git a/samples/web-app-cosmosdb-nosql-api/src/cosmosdb_client.py b/samples/web-app-cosmosdb-nosql-api/python/src/cosmosdb_client.py similarity index 93% rename from samples/web-app-cosmosdb-nosql-api/src/cosmosdb_client.py rename to samples/web-app-cosmosdb-nosql-api/python/src/cosmosdb_client.py index 0eb19b7..65ed36a 100644 --- a/samples/web-app-cosmosdb-nosql-api/src/cosmosdb_client.py +++ b/samples/web-app-cosmosdb-nosql-api/python/src/cosmosdb_client.py @@ -23,6 +23,11 @@ def ensure_initialized(self): offer_throughput=400 ) + def ping(self): + """Read the container's properties: the cheapest round trip proving the account is reachable.""" + self.ensure_initialized() + return self.container.read() + @classmethod def from_env(cls): return cls( diff --git a/samples/web-app-cosmosdb-nosql-api/src/gunicorn.conf.py b/samples/web-app-cosmosdb-nosql-api/python/src/gunicorn.conf.py similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/gunicorn.conf.py rename to samples/web-app-cosmosdb-nosql-api/python/src/gunicorn.conf.py diff --git a/samples/web-app-cosmosdb-nosql-api/src/requirements.txt b/samples/web-app-cosmosdb-nosql-api/python/src/requirements.txt similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/requirements.txt rename to samples/web-app-cosmosdb-nosql-api/python/src/requirements.txt diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.min.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.min.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.min.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.min.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.min.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.min.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.min.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.rtl.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.rtl.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.rtl.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.rtl.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.rtl.min.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.rtl.min.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.min.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.min.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.min.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.min.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.min.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.min.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.rtl.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.rtl.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.min.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.min.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.min.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.min.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.min.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.min.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.rtl.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.rtl.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.min.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.min.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.min.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.min.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.min.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.min.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.min.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.rtl.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.rtl.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.rtl.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.rtl.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.rtl.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.rtl.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.rtl.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.rtl.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.rtl.min.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.rtl.min.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.rtl.min.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.rtl.min.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.rtl.min.css.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/css/bootstrap.rtl.min.css.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.bundle.js b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.bundle.js rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.bundle.js diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.bundle.js.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.bundle.js.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.bundle.js.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.bundle.js.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.bundle.min.js b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.bundle.min.js rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.bundle.min.js diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.bundle.min.js.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.bundle.min.js.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.esm.js b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.esm.js similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.esm.js rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.esm.js diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.esm.js.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.esm.js.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.esm.js.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.esm.js.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.esm.min.js b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.esm.min.js similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.esm.min.js rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.esm.min.js diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.esm.min.js.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.esm.min.js.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.esm.min.js.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.esm.min.js.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.js b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.js similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.js rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.js diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.js.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.js.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.js.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.js.map diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.min.js b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.min.js similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.min.js rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.min.js diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.min.js.map b/samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.min.js.map similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/bootstrap/js/bootstrap.min.js.map rename to samples/web-app-cosmosdb-nosql-api/python/src/static/bootstrap/js/bootstrap.min.js.map diff --git a/samples/web-app-managed-identity/src/static/favicon.ico b/samples/web-app-cosmosdb-nosql-api/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-managed-identity/src/static/favicon.ico rename to samples/web-app-cosmosdb-nosql-api/python/src/static/favicon.ico diff --git a/samples/web-app-managed-identity/src/static/style.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/style.css similarity index 100% rename from samples/web-app-managed-identity/src/static/style.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/style.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/templates/index.html b/samples/web-app-cosmosdb-nosql-api/python/src/templates/index.html similarity index 99% rename from samples/web-app-cosmosdb-mongodb-api/src/templates/index.html rename to samples/web-app-cosmosdb-nosql-api/python/src/templates/index.html index 45a7be8..efb642d 100644 --- a/samples/web-app-cosmosdb-mongodb-api/src/templates/index.html +++ b/samples/web-app-cosmosdb-nosql-api/python/src/templates/index.html @@ -70,7 +70,7 @@

🌴 Vacation Planner

-
+ + + + + + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + + + + + + +
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..5e2f65f --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, FileStorageOptions options, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Name of the pod serving the request, shown next to the activity count. + public string PodName => options.PodName; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + if (await store.UpdateAsync(id, text, cancellationToken)) + { + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated successfully."; + } + else + { + TempData["Flash"] = "Failed to update the activity on the file share."; + } + } + else + { + if (await store.AddAsync(text, cancellationToken)) + { + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added successfully."; + } + else + { + TempData["Flash"] = "Failed to add the activity to the file share."; + } + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-file-storage/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-file-storage/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-file-storage/dotnet/src/Program.cs b/samples/web-app-file-storage/dotnet/src/Program.cs new file mode 100644 index 0000000..0d1f87d --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Program.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Listen on PORT (8080 by default), the way the Python image binds gunicorn to 0.0.0.0:${PORT}. HTTP_PORTS is the +// same setting the aspnet base image feeds through ASPNETCORE_HTTP_PORTS, so no URL override is involved. +if (Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseSetting(WebHostDefaults.HttpPortsKey, port); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storageOptions = FileStorageOptions.FromEnvironment(); + +// SECRET_KEY is the Kubernetes Secret the Python sample signs its session cookie with. Deriving the Data Protection +// key ring from it lets all replicas validate each other's antiforgery tokens and flash cookies; without it (a local +// docker run) each process keeps its own keys. +var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY"); +if (!string.IsNullOrEmpty(secretKey)) +{ + builder.Services.AddDataProtection().DisableAutomaticKeyGeneration(); + builder.Services.Configure(options => options.XmlRepository = new SecretKeyXmlRepository(secretKey)); +} + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(storageOptions); +builder.Services.AddSingleton(sp => + new FileActivityStore(storageOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +if (string.IsNullOrEmpty(secretKey)) +{ + app.Logger.LogWarning("SECRET_KEY is not set: antiforgery tokens and flash messages are only valid on this replica."); +} + +// One log line per request, the equivalent of the access log the Python image produces (its gunicorn +// command passes --access-logfile -). Kubernetes probes show up here too, exactly as they do for Python. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-file-storage/dotnet/src/Services/FileActivityStore.cs b/samples/web-app-file-storage/dotnet/src/Services/FileActivityStore.cs new file mode 100644 index 0000000..6723cfe --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Services/FileActivityStore.cs @@ -0,0 +1,170 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// +/// One text file per activity in the directory where the Azure file share is mounted: the file name is the +/// activity id and its content the text. The app talks to the share through the file system only: no Azure SDK, +/// no connection string, no account key. Everything that makes the share reachable (SMB or NFS, pre-created or +/// provisioned on demand) is handled by the Azure Files CSI driver when it mounts the volume into the pod. +/// +public sealed class FileActivityStore(FileStorageOptions options, ILogger logger) : IActivityStore +{ + /// Suffix of the files holding the activities, one file per activity. + public const string ActivityFileSuffix = "-activity.txt"; + + private readonly string _directory = options.ActivitiesDir; + + public Task InitializeAsync(CancellationToken cancellationToken) + { + // A missing directory means the Azure file share was not mounted into the container: the app must not + // create the mount point itself and write to the container file system, where the data would be invisible + // to the other replicas and lost on restart. + if (!Directory.Exists(_directory)) + { + throw new DirectoryNotFoundException($"Activities directory '{_directory}' does not exist. Is the Azure file share mounted?"); + } + + ProbeWritable(); + logger.LogInformation("Activities directory '{Directory}' is ready.", _directory); + return Task.CompletedTask; + } + + public Task> ListAsync(CancellationToken cancellationToken) + { + // Sorted by name, which sorts by creation timestamp: the file name is the timestamp. + var names = Directory.EnumerateFiles(_directory) + .Select(path => Path.GetFileName(path)) + .Where(IsActivityName) + .Order(StringComparer.Ordinal); + + var activities = new List(); + foreach (var name in names) + { + var path = Path.Combine(_directory, name); + if (File.Exists(path)) + { + logger.LogInformation("Found activity file '{Name}' with size {Size} bytes", name, new FileInfo(path).Length); + activities.Add(new Activity(name, File.ReadAllText(path))); + } + } + + logger.LogInformation("Retrieved {Count} activity file(s) from directory '{Directory}'", activities.Count, _directory); + return Task.FromResult>(activities); + } + + public Task AddAsync(string text, CancellationToken cancellationToken) => + Task.FromResult(Write($"{DateTime.Now:yyyy-MM-dd-HH-mm-ss}{ActivityFileSuffix}", text)); + + public Task UpdateAsync(string id, string text, CancellationToken cancellationToken) => + Task.FromResult(Write(id, text)); + + /// + /// Returns whether the activity is gone from the share. A file that is already gone counts as gone: every + /// replica mounts the same share, so another replica may have deleted it a moment earlier. A missing + /// directory is not a deleted activity, though: the share itself is gone and success would hide that. + /// + public Task DeleteAsync(string id, CancellationToken cancellationToken) + { + if (!IsActivityName(id)) + { + logger.LogWarning("Invalid activity name '{Name}'.", id); + return Task.FromResult(false); + } + + try + { + var path = Path.Combine(_directory, id); + if (!File.Exists(path)) + { + if (!Directory.Exists(_directory)) + { + logger.LogError("Activities directory '{Directory}' does not exist. Is the Azure file share mounted?", _directory); + return Task.FromResult(false); + } + + logger.LogInformation("Activity file '{Name}' does not exist in directory '{Directory}': already deleted.", id, _directory); + return Task.FromResult(true); + } + + logger.LogInformation("Deleting activity file '{Name}' from directory '{Directory}'.", id, _directory); + File.Delete(path); + logger.LogInformation("Activity file '{Name}' deleted successfully.", id); + return Task.FromResult(true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogError(ex, "An error occurred while deleting the activity file '{Name}'.", id); + return Task.FromResult(false); + } + } + + public Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + if (!Directory.Exists(_directory)) + { + logger.LogWarning("Activities directory '{Directory}' does not exist. Is the Azure file share mounted?", _directory); + return Task.FromResult(false); + } + + ProbeWritable(); + return Task.FromResult(true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogWarning(ex, "Activities directory '{Directory}' is not writable.", _directory); + return Task.FromResult(false); + } + } + + /// Creates or overwrites an activity file; returns whether the activity is on the share, so the caller never reports a success that did not happen. + private bool Write(string name, string text) + { + if (!IsActivityName(name)) + { + logger.LogWarning("Invalid activity name '{Name}'.", name); + return false; + } + + try + { + logger.LogInformation("Writing activity file '{Name}' in directory '{Directory}'.", name, _directory); + File.WriteAllText(Path.Combine(_directory, name), text); + logger.LogInformation("Activity file '{Name}' written successfully.", name); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogError(ex, "An error occurred while writing the activity file '{Name}'.", name); + return false; + } + } + + /// + /// Whether the name is one of this app's activity files, and nothing else: a file called + /// yyyy-MM-dd-HH-mm-ss-activity.txt directly inside the mounted share. Names arrive from a form field, so + /// this rejects an empty name, '.' and '..', anything carrying a path separator, and any name the app did not create. + /// + private static bool IsActivityName(string? name) => + !string.IsNullOrEmpty(name) + && name is not ("." or "..") + && Path.GetFileName(name) == name + && !name.Contains('/') && !name.Contains('\\') + && name.EndsWith(ActivityFileSuffix, StringComparison.Ordinal); + + /// + /// The equivalent of the Python sample's os.access(W_OK | X_OK): the only portable proof that the mounted + /// share accepts writes from this uid is to write to it. The probe is a dot file the listing never shows. + /// Its name has to be unique per probe: every replica mounts the same share and every container runs the app + /// as PID 1, so a name derived from the process id is the same in all replicas, and one replica deleting the + /// probe while another writes it fails the liveness check of a perfectly healthy pod. + /// + private void ProbeWritable() + { + var probe = Path.Combine(_directory, $".write-probe-{Environment.MachineName}-{Guid.NewGuid():N}"); + File.WriteAllText(probe, string.Empty); + File.Delete(probe); + } +} diff --git a/samples/web-app-file-storage/dotnet/src/Services/FileStorageOptions.cs b/samples/web-app-file-storage/dotnet/src/Services/FileStorageOptions.cs new file mode 100644 index 0000000..7981eeb --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Services/FileStorageOptions.cs @@ -0,0 +1,20 @@ +namespace VacationPlanner.Services; + +/// Settings read from the same environment variables the Python sample uses. +public sealed record FileStorageOptions(string ActivitiesDir, string PodName) +{ + public static FileStorageOptions FromEnvironment() + { + // Directory where the Azure file share is mounted in the container. + var activitiesDir = Environment.GetEnvironmentVariable("ACTIVITIES_DIR") ?? "/data"; + if (string.IsNullOrWhiteSpace(activitiesDir)) + { + throw new InvalidOperationException("The ACTIVITIES_DIR environment variable is set to an empty value."); + } + + // Name of the pod serving the request, shown in the UI. All replicas mount the same file share, so an + // activity added through one pod is served by every other pod as well. + var podName = Environment.GetEnvironmentVariable("HOSTNAME") is { Length: > 0 } hostname ? hostname : Environment.MachineName; + return new FileStorageOptions(activitiesDir, podName); + } +} diff --git a/samples/web-app-file-storage/dotnet/src/Services/IActivityStore.cs b/samples/web-app-file-storage/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..167993e --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,24 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection, directory) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Adds an activity and returns whether the store confirmed the write; the page flashes only then. + Task AddAsync(string text, CancellationToken cancellationToken); + + /// Updates an activity and returns whether the store reported a change, with the meaning the Python sample's driver gives it. + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + /// Deletes an activity by its store id and returns whether the store reported a deletion. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-file-storage/dotnet/src/Services/SecretKeyXmlRepository.cs b/samples/web-app-file-storage/dotnet/src/Services/SecretKeyXmlRepository.cs new file mode 100644 index 0000000..e0cc5bb --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Services/SecretKeyXmlRepository.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel; +using Microsoft.AspNetCore.DataProtection.Repositories; + +namespace VacationPlanner.Services; + +/// +/// A Data Protection key ring derived deterministically from SECRET_KEY, the Kubernetes Secret the Python +/// sample signs its Flask session cookie with. ASP.NET Core protects its antiforgery tokens and TempData (flash) +/// cookies with Data Protection instead of a signing key; deriving the one key of the ring from the same secret +/// lets every replica of the Deployment validate what another replica issued, which the default per-process key +/// ring cannot offer behind a load balancer. +/// +public sealed class SecretKeyXmlRepository : IXmlRepository +{ + private readonly XElement _key; + + public SecretKeyXmlRepository(string secretKey) + { + var keyMaterial = Encoding.UTF8.GetBytes(secretKey); + + // A 512-bit master key (the size Data Protection generates itself) and a stable key id, both from SECRET_KEY. + var masterKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 64, info: "VacationPlanner.DataProtection.MasterKey"u8.ToArray()); + var keyId = new Guid(HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 16, info: "VacationPlanner.DataProtection.KeyId"u8.ToArray())); + + // AES-256-CBC + HMACSHA256, the default algorithms, serialized the way the key manager itself serializes a new key. + var descriptor = new AuthenticatedEncryptorDescriptor(new AuthenticatedEncryptorConfiguration(), new Secret(masterKey)); + var serialized = descriptor.ExportToXml(); + + _key = new XElement("key", + new XAttribute("id", keyId), + new XAttribute("version", 1), + new XElement("creationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("activationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("expirationDate", new DateTimeOffset(2999, 12, 31, 0, 0, 0, TimeSpan.Zero)), + new XElement("descriptor", + new XAttribute("deserializerType", serialized.DeserializerType.AssemblyQualifiedName!), + serialized.SerializedDescriptorElement)); + } + + public IReadOnlyCollection GetAllElements() => [new XElement(_key)]; + + /// Never called: automatic key generation is disabled, so the key manager has nothing to persist. + public void StoreElement(XElement element, string friendlyName) => + throw new NotSupportedException("The key ring is derived from SECRET_KEY and cannot be modified."); +} diff --git a/samples/web-app-file-storage/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-file-storage/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-file-storage/dotnet/src/VacationPlanner.csproj b/samples/web-app-file-storage/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..f1ee0c1 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,8 @@ + + + net10.0 + enable + enable + VacationPlanner + + diff --git a/samples/web-app-file-storage/dotnet/src/appsettings.json b/samples/web-app-file-storage/dotnet/src/appsettings.json new file mode 100644 index 0000000..6e5c6b2 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss " + } + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-mysql-flexible-server/src/static/favicon.ico b/samples/web-app-file-storage/dotnet/src/wwwroot/favicon.ico similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/favicon.ico rename to samples/web-app-file-storage/dotnet/src/wwwroot/favicon.ico diff --git a/samples/web-app-mysql-flexible-server/src/static/style.css b/samples/web-app-file-storage/dotnet/src/wwwroot/style.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/style.css rename to samples/web-app-file-storage/dotnet/src/wwwroot/style.css diff --git a/samples/web-app-file-storage/README.md b/samples/web-app-file-storage/python/README.md similarity index 90% rename from samples/web-app-file-storage/README.md rename to samples/web-app-file-storage/python/README.md index 5f3ff24..c5b6ded 100644 --- a/samples/web-app-file-storage/README.md +++ b/samples/web-app-file-storage/python/README.md @@ -1,5 +1,7 @@ # Vacation Planner: Azure Files +> A .NET version of this sample lives in [../dotnet](../dotnet/README.md). + This sample demonstrates a Python Flask 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 each activity as a text file on an [Azure Files](https://learn.microsoft.com/en-us/azure/storage/files/storage-files-introduction) share, mounted into every pod by the [Azure Files CSI driver](https://learn.microsoft.com/en-us/azure/aks/azure-files-csi). Unlike every other sample in this repository, the app uses **no Azure SDK at all**: no client library, no connection string, no account key, not a single line of authentication code. It calls `open()`, `os.listdir()` and `os.remove()` on a directory, and the CSI driver turns that directory into an Azure file share. That is the point of the sample, and it is what makes Azure Files the shortest path to persistence for an application that already speaks the file system. @@ -8,14 +10,14 @@ The three replicas of the deployment mount the same share at the same time (`Rea 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. +- [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. Both scripts check that the Azure Files CSI driver and the CSI snapshot controller are enabled on the cluster, and enable the `Microsoft.Storage` service endpoint on the node subnets, which an NFS share requires. They print the cluster's `storageProfile` and its storage classes when they are done. 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. +> **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 @@ -90,7 +92,7 @@ cd scripts | [`persistentvolume-nfs.yml`](scripts/persistentvolume-nfs.yml) | Creates the `PersistentVolume` bound to a pre-created NFS file share, with `protocol: nfs`, the NFS mount options, and no secret. | | [`persistentvolumeclaim.yml`](scripts/persistentvolumeclaim.yml) | Creates the `ReadWriteMany` claim the pods mount. Committed in its static shape (pre-bound by name, empty storage class); `05-deploy-app.sh` rewrites those two fields for dynamic provisioning. | | [`storageclass-nfs.yml`](scripts/storageclass-nfs.yml) | Creates the storage class that provisions an NFS file share on demand (`protocol: nfs`, `skuName: Premium_LRS`). Applied for the dynamic NFS combination only. | -| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment: three replicas mounting the same share, plus the init container that prepares and seeds it. | +| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment: three replicas mounting the same share, plus the init container that prepares and seeds it. 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 @@ -103,6 +105,20 @@ kubectl port-forward service/vacation-planner-file 8080:80 -n vacation-planner-f 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 mounted file share is reachable and `503` with `{"status": "unavailable"}` otherwise. + +```bash +curl http://localhost:8080/health +``` + +## Logs + +The app logs one line per request — gunicorn writes an access log line for every call, the probes included, because its command passes `--access-logfile -` — plus one line per activity file read, written or deleted and one line for every activity added, updated or deleted. The store operations are printed to stdout, so `kubectl logs` shows them interleaved with the access log. The [.NET version](../dotnet/README.md) writes the same trace, timestamped. + +```bash +kubectl logs deployment/vacation-planner-file -n vacation-planner-file --tail=50 +``` + ## Looking at the file share Every activity is one UTF-8 text file named `YYYY-MM-DD-HH-MM-SS-activity.txt`. From inside the cluster, on any of the three replicas and for any of the four combinations: diff --git a/samples/web-app-file-storage/python/images/architecture.png b/samples/web-app-file-storage/python/images/architecture.png new file mode 100644 index 0000000..1987668 Binary files /dev/null and b/samples/web-app-file-storage/python/images/architecture.png differ diff --git a/samples/web-app-file-storage/scripts/00-variables.sh b/samples/web-app-file-storage/python/scripts/00-variables.sh similarity index 100% rename from samples/web-app-file-storage/scripts/00-variables.sh rename to samples/web-app-file-storage/python/scripts/00-variables.sh diff --git a/samples/web-app-file-storage/python/scripts/01-deploy-resources.sh b/samples/web-app-file-storage/python/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..0a35f6c --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/01-deploy-resources.sh @@ -0,0 +1,308 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Ask how the Azure file share holding the activities should be provisioned. +# Both answers are persisted below, so 05-deploy-app.sh deploys the app for the same combination +# without asking again. Export PROVISIONING_MODE and FILE_SHARE_PROTOCOL beforehand to skip the +# menus altogether, for example in an unattended run: +# +# PROVISIONING_MODE=dynamic FILE_SHARE_PROTOCOL=nfs ./01-deploy-resources.sh +if [[ -z "$PROVISIONING_MODE" ]]; then + # Print the menu + echo "====================================" + echo "Provisioning Mode (1-3): " + echo "====================================" + options=( + "Static: use a file share created by this script" + "Dynamic: let the CSI driver create the file share" + "Quit" + ) + # Select an option + COLUMNS=0 + select option in "${options[@]}"; do + case $option in + "Static: use a file share created by this script") + PROVISIONING_MODE='static' + break + ;; + "Dynamic: let the CSI driver create the file share") + PROVISIONING_MODE='dynamic' + break + ;; + "Quit") + exit + ;; + *) echo "invalid option $REPLY" ;; + esac + done +fi + +PROVISIONING_MODE="${PROVISIONING_MODE,,}" + +if [[ "$PROVISIONING_MODE" != 'static' && "$PROVISIONING_MODE" != 'dynamic' ]]; then + echo "Invalid provisioning mode [$PROVISIONING_MODE]: expected [static] or [dynamic]" + exit 1 +fi + +# Ask which protocol the Azure file share should use. +if [[ -z "$FILE_SHARE_PROTOCOL" ]]; then + # Print the menu + echo "====================================" + echo "File Share Protocol (1-3): " + echo "====================================" + options=( + "SMB: mounted with the storage account key" + "NFS: mounted from a virtual network, no key" + "Quit" + ) + # Select an option + COLUMNS=0 + select option in "${options[@]}"; do + case $option in + "SMB: mounted with the storage account key") + FILE_SHARE_PROTOCOL='smb' + break + ;; + "NFS: mounted from a virtual network, no key") + FILE_SHARE_PROTOCOL='nfs' + break + ;; + "Quit") + exit + ;; + *) echo "invalid option $REPLY" ;; + esac + done +fi + +FILE_SHARE_PROTOCOL="${FILE_SHARE_PROTOCOL,,}" + +if [[ "$FILE_SHARE_PROTOCOL" != 'smb' && "$FILE_SHARE_PROTOCOL" != 'nfs' ]]; then + echo "Invalid file share protocol [$FILE_SHARE_PROTOCOL]: expected [smb] or [nfs]" + exit 1 +fi + +# Persist both answers so 05-deploy-app.sh deploys the app for the same combination. +# Each value is written with ${VAR:-value} syntax, so an exported environment variable still takes +# precedence when 00-variables.sh sources this file. +cat <"$DEPLOY_OPTIONS_FILE" +# Generated by 01-deploy-resources.sh - do not edit. +# An exported environment variable takes precedence over the value stored here. +PROVISIONING_MODE="\${PROVISIONING_MODE:-$PROVISIONING_MODE}" +FILE_SHARE_PROTOCOL="\${FILE_SHARE_PROTOCOL:-$FILE_SHARE_PROTOCOL}" +EOF + +if [[ $? == 0 ]]; then + echo "Deployment options saved to [$DEPLOY_OPTIONS_FILE]" +else + echo "Failed to save the deployment options to [$DEPLOY_OPTIONS_FILE]" + exit 1 +fi + +echo "Deploying the resources for [$PROVISIONING_MODE] provisioning over [${FILE_SHARE_PROTOCOL^^}]..." + +# Create a resource group +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]..." + + 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 1 + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists in the subscription [$SUBSCRIPTION_NAME]" +fi + +# Create the Azure Container Registry +echo "Checking if [$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$ACR_NAME] Azure Container Registry exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled "true" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created successfully." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# In dynamic mode there is nothing else to create: the Azure Files CSI driver creates the storage +# account and the file share in the node resource group when the PersistentVolumeClaim is bound. +if [[ "$PROVISIONING_MODE" == 'dynamic' ]]; then + echo "Dynamic provisioning selected: the Azure Files CSI driver creates the storage account and the [$FILE_SHARE_NAME] file share when the [$PERSISTENT_VOLUME_CLAIM_NAME] persistent volume claim is bound by 05-deploy-app.sh." + exit 0 +fi + +# Static provisioning: create the storage account and the file share the PersistentVolume points at. +if [[ "$FILE_SHARE_PROTOCOL" == 'smb' ]]; then + STORAGE_ACCOUNT_NAME="$SMB_STORAGE_ACCOUNT_NAME" + STORAGE_ACCOUNT_KIND="$SMB_STORAGE_ACCOUNT_KIND" + STORAGE_ACCOUNT_SKU="$SMB_STORAGE_ACCOUNT_SKU" + ENABLED_PROTOCOLS='SMB' + + # An SMB share is mounted with the account key over an encrypted connection, so secure transfer + # stays on and the account keeps its default network access. + STORAGE_ACCOUNT_EXTRA_ARGS=(--https-only true) + FILE_SHARE_EXTRA_ARGS=() +else + STORAGE_ACCOUNT_NAME="$NFS_STORAGE_ACCOUNT_NAME" + STORAGE_ACCOUNT_KIND="$NFS_STORAGE_ACCOUNT_KIND" + STORAGE_ACCOUNT_SKU="$NFS_STORAGE_ACCOUNT_SKU" + ENABLED_PROTOCOLS='NFS' + + # An NFSv4.1 mount is not encrypted in transit unless the client uses the AZNFS TLS helper, so + # secure transfer has to be off for the CSI driver to mount the share + # (https://learn.microsoft.com/en-us/azure/storage/files/files-nfs-protocol). + STORAGE_ACCOUNT_EXTRA_ARGS=(--https-only false) + + # NoRootSquash lets the init container of the deployment chown the mount point as root, which is + # how the NFS variant makes the share writable by the non-root user of the app. + FILE_SHARE_EXTRA_ARGS=(--root-squash NoRootSquash) +fi + +echo "Checking if storage account [$STORAGE_ACCOUNT_NAME] exists in the resource group [$RESOURCE_GROUP_NAME]..." +az storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating [$STORAGE_ACCOUNT_KIND] storage account [$STORAGE_ACCOUNT_NAME] with the [$STORAGE_ACCOUNT_SKU] sku..." + az storage account create \ + --name $STORAGE_ACCOUNT_NAME \ + --location "$LOCATION" \ + --resource-group $RESOURCE_GROUP_NAME \ + --kind $STORAGE_ACCOUNT_KIND \ + --sku $STORAGE_ACCOUNT_SKU \ + "${STORAGE_ACCOUNT_EXTRA_ARGS[@]}" \ + --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 +else + echo "Storage account [$STORAGE_ACCOUNT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# An NFS share has no user-based authentication: access is granted by network rules only, so the +# account must be restricted to the subnets of the AKS nodes that mount the share. The subnets are +# read from the cluster itself, so this works with either of the two cluster provisioning scripts. +# The Microsoft.Storage service endpoint on those subnets is enabled by the cluster script. +if [[ "$FILE_SHARE_PROTOCOL" == 'nfs' ]]; then + echo "Retrieving the node subnets of the [$AKS_CLUSTER_NAME] AKS cluster..." + node_subnet_ids=$(az aks show \ + --name $AKS_CLUSTER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "agentPoolProfiles[?vnetSubnetId!=null].vnetSubnetId" \ + --output tsv \ + --only-show-errors 2>/dev/null | sort --unique) + + if [[ -z "$node_subnet_ids" ]]; then + echo "WARNING: no node subnet found for the [$AKS_CLUSTER_NAME] AKS cluster: the [$STORAGE_ACCOUNT_NAME] storage account is left open to all networks." + echo "WARNING: on Azure the NFS mount will fail, because an NFS share can only be reached from a restricted network." + else + network_rules_added='true' + + for node_subnet_id in $node_subnet_ids; do + echo "Allowing access to the [$STORAGE_ACCOUNT_NAME] storage account from the [$(basename "$node_subnet_id")] subnet..." + az storage account network-rule add \ + --account-name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --subnet "$node_subnet_id" \ + --only-show-errors 1>/dev/null + + if [[ $? != 0 ]]; then + network_rules_added='false' + echo "WARNING: failed to allow access from the [$(basename "$node_subnet_id")] subnet to the [$STORAGE_ACCOUNT_NAME] storage account." + fi + done + + # The account is closed to every other network only once every subnet rule is in place: + # denying by default while a rule is missing would lock the nodes out of their own share. + if [[ "$network_rules_added" == 'true' ]]; then + echo "Restricting the [$STORAGE_ACCOUNT_NAME] storage account to the node subnets..." + az storage account update \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --default-action Deny \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "The [$STORAGE_ACCOUNT_NAME] storage account is now reachable from the node subnets only." + else + echo "WARNING: failed to restrict the [$STORAGE_ACCOUNT_NAME] storage account to the node subnets." + echo "WARNING: on Azure the NFS mount will fail, because an NFS share can only be reached from a restricted network." + fi + else + echo "WARNING: the [$STORAGE_ACCOUNT_NAME] storage account is left open to all networks, because at least one subnet rule could not be created." + echo "WARNING: on Azure the NFS mount will fail, because an NFS share can only be reached from a restricted network." + fi + fi +fi + +# Create the Azure file share. +# az storage share-rm talks to the storage resource provider (the control plane) instead of the file +# data plane, which is what makes --enabled-protocols and --root-squash available and lets the share +# be created without an account key. +echo "Checking if file share [$FILE_SHARE_NAME] exists in storage account [$STORAGE_ACCOUNT_NAME]..." +az storage share-rm show \ + --name $FILE_SHARE_NAME \ + --storage-account $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating [$ENABLED_PROTOCOLS] file share [$FILE_SHARE_NAME] with a quota of [$FILE_SHARE_QUOTA_GB] GiB..." + az storage share-rm create \ + --name $FILE_SHARE_NAME \ + --storage-account $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --enabled-protocols $ENABLED_PROTOCOLS \ + --quota $FILE_SHARE_QUOTA_GB \ + "${FILE_SHARE_EXTRA_ARGS[@]}" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "File share [$FILE_SHARE_NAME] created successfully." + else + echo "Failed to create file share [$FILE_SHARE_NAME]." + exit 1 + fi +else + echo "File share [$FILE_SHARE_NAME] already exists in the [$STORAGE_ACCOUNT_NAME] storage account" +fi + +echo "The [$FILE_SHARE_NAME] $ENABLED_PROTOCOLS file share of the [$STORAGE_ACCOUNT_NAME] storage account is ready to be mounted by the app." diff --git a/samples/web-app-file-storage/scripts/02-build-docker-image.sh b/samples/web-app-file-storage/python/scripts/02-build-docker-image.sh similarity index 100% rename from samples/web-app-file-storage/scripts/02-build-docker-image.sh rename to samples/web-app-file-storage/python/scripts/02-build-docker-image.sh diff --git a/samples/web-app-file-storage/python/scripts/03-run-docker-container.sh b/samples/web-app-file-storage/python/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..071a1a9 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/03-run-docker-container.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Local smoke test: no Azure resource is involved. In the cluster the app writes to an Azure file +# share mounted by the Azure Files CSI driver, and here it writes to a directory on the host mounted +# at the same path, which is all the app knows about its storage. +# +# Under the user's cache directory rather than /tmp: the directory has to be world-writable for the +# container's non-root user to write to it, and /tmp is shared with every other user on the machine. +# A private cache directory is also shared with the Docker daemon by default on Docker Desktop. +ACTIVITIES_HOST_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/${IMAGE_NAME}/activities" + +echo "Creating the local activities directory [$ACTIVITIES_HOST_DIR]..." +mkdir -p "$ACTIVITIES_HOST_DIR" + +if [[ $? != 0 ]]; then + echo "Failed to create the local activities directory [$ACTIVITIES_HOST_DIR]." + exit 1 +fi + +# The container runs as uid 1000, which is not necessarily the owner of a directory on the host +chmod 0777 "$ACTIVITIES_HOST_DIR" + +if [[ $? != 0 ]]; then + echo "Failed to make the local activities directory [$ACTIVITIES_HOST_DIR] writable." + exit 1 +fi + +echo "The activities are stored as text files in [$ACTIVITIES_HOST_DIR]." + +# --network=host so endpoints like *.localhost.localstack.cloud resolve to the +# host's loopback (where LocalStack is listening), not the container's. +docker run -it \ + --rm \ + --network=host \ + -e PORT=$PORT \ + -e ACTIVITIES_DIR="$ACTIVITIES_DIR" \ + -v "$ACTIVITIES_HOST_DIR:$ACTIVITIES_DIR" \ + --name "$IMAGE_NAME" \ + "$IMAGE_NAME:$IMAGE_TAG" diff --git a/samples/web-app-postgresql-flexible-server/scripts/04-push-docker-image.sh b/samples/web-app-file-storage/python/scripts/04-push-docker-image.sh similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/04-push-docker-image.sh rename to samples/web-app-file-storage/python/scripts/04-push-docker-image.sh diff --git a/samples/web-app-file-storage/scripts/05-deploy-app.sh b/samples/web-app-file-storage/python/scripts/05-deploy-app.sh similarity index 92% rename from samples/web-app-file-storage/scripts/05-deploy-app.sh rename to samples/web-app-file-storage/python/scripts/05-deploy-app.sh index 089b0e3..a4772d3 100755 --- a/samples/web-app-file-storage/scripts/05-deploy-app.sh +++ b/samples/web-app-file-storage/python/scripts/05-deploy-app.sh @@ -36,7 +36,14 @@ else fi # Generate a stable Flask SECRET_KEY (sessions survive pod restarts) -SECRET_KEY=$(openssl rand -hex 32) +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +SECRET_KEY=$(kubectl get secret $SECRET_NAME --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $SECRET_KEY ]]; then + SECRET_KEY=$(openssl rand -hex 32) +fi # Get the login server for the Azure Container Registry echo "Getting login server for Azure Container Registry [$ACR_NAME]..." @@ -193,6 +200,10 @@ yq "(.metadata.namespace)|="\""$NAMESPACE"\" | yq "(.metadata.name)|="\""$SERVICE_NAME"\" | kubectl apply -f - +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE + # Wait for the rollout, so an unattended run of the scripts fails here instead of appearing to succeed # while the pods are unable to mount the file share. echo "Waiting for the [$DEPLOYMENT_NAME] deployment to roll out..." diff --git a/samples/web-app-file-storage/scripts/Dockerfile b/samples/web-app-file-storage/python/scripts/Dockerfile similarity index 100% rename from samples/web-app-file-storage/scripts/Dockerfile rename to samples/web-app-file-storage/python/scripts/Dockerfile diff --git a/samples/web-app-file-storage/python/scripts/configmap.yml b/samples/web-app-file-storage/python/scripts/configmap.yml new file mode 100644 index 0000000..1b178c4 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/configmap.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-file-config + namespace: vacation-planner-file + labels: + app: vacation-planner-file +data: + # Directory where the Azure file share is mounted in the pods. It is the only thing the app knows + # about its storage: the app reads and writes plain files and uses no Azure SDK at all. + ACTIVITIES_DIR: "/data" diff --git a/samples/web-app-file-storage/scripts/deployment.yml b/samples/web-app-file-storage/python/scripts/deployment.yml similarity index 99% rename from samples/web-app-file-storage/scripts/deployment.yml rename to samples/web-app-file-storage/python/scripts/deployment.yml index ebac00d..0e762cb 100644 --- a/samples/web-app-file-storage/scripts/deployment.yml +++ b/samples/web-app-file-storage/python/scripts/deployment.yml @@ -149,7 +149,7 @@ spec: memory: "256Mi" livenessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 15 periodSeconds: 30 @@ -157,7 +157,7 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 5 periodSeconds: 10 diff --git a/samples/web-app-file-storage/python/scripts/namespace.yml b/samples/web-app-file-storage/python/scripts/namespace.yml new file mode 100644 index 0000000..d19adf9 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-file diff --git a/samples/web-app-file-storage/python/scripts/persistentvolume-nfs.yml b/samples/web-app-file-storage/python/scripts/persistentvolume-nfs.yml new file mode 100644 index 0000000..2c20104 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/persistentvolume-nfs.yml @@ -0,0 +1,42 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: vacation-planner-file-pv + annotations: + pv.kubernetes.io/provisioned-by: file.csi.azure.com + labels: + app: vacation-planner-file +spec: + capacity: + storage: 100Gi + accessModes: + # Every replica of the deployment mounts the same share at the same time + - ReadWriteMany + # Retain, so deleting the claim leaves the file share and its activities untouched + persistentVolumeReclaimPolicy: Retain + # No storage class on purpose: the claim in persistentvolumeclaim.yml sets storageClassName to the + # empty string and binds to this volume by name, so no provisioner ever looks at it. + mountOptions: + # Mount options recommended by the Azure Files CSI driver documentation for NFS + # (https://learn.microsoft.com/en-us/azure/aks/azure-csi-files-storage-provision): four + # connections to the share, no reserved source port, and a 30 second attribute cache. + # + # Unlike the SMB volume, there are no uid, gid or mode options here: an NFS share carries real + # POSIX ownership and ignores them. The init container of the deployment chowns the mount point + # to uid 1000 instead. + - nconnect=4 + - noresvport + - actimeo=30 + csi: + driver: file.csi.azure.com + # Must be unique per share in the cluster: {resource group}#{storage account}#{file share} + volumeHandle: "resource-group#storage-account#file-share" + volumeAttributes: + # Without this the driver mounts the share over SMB and the mount fails, because the share of a + # premium FileStorage account created with --enabled-protocols NFS speaks NFSv4.1 only. + protocol: nfs + resourceGroup: resource-group + storageAccount: storage-account + shareName: file-share + # No nodeStageSecretRef: an NFS share has no key-based authentication. Access is granted by the + # network rules of the storage account, which 01-deploy-resources.sh restricts to the node subnets. diff --git a/samples/web-app-file-storage/python/scripts/persistentvolume-smb.yml b/samples/web-app-file-storage/python/scripts/persistentvolume-smb.yml new file mode 100644 index 0000000..e8ae7f6 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/persistentvolume-smb.yml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: vacation-planner-file-pv + annotations: + pv.kubernetes.io/provisioned-by: file.csi.azure.com + labels: + app: vacation-planner-file +spec: + capacity: + storage: 100Gi + accessModes: + # Every replica of the deployment mounts the same share at the same time + - ReadWriteMany + # Retain, so deleting the claim leaves the file share and its activities untouched + persistentVolumeReclaimPolicy: Retain + # No storage class on purpose: the claim in persistentvolumeclaim.yml sets storageClassName to the + # empty string and binds to this volume by name, so no provisioner ever looks at it. + mountOptions: + # Permissions recommended by the Azure Files CSI driver documentation for a static SMB volume + # (https://learn.microsoft.com/en-us/azure/aks/azure-csi-files-storage-provision). The app runs as + # uid 1000, so the volume is mounted with that identity: the CIFS client synthesizes the ownership + # of every file and directory from these options, which is why the SMB variant needs no chown. + - dir_mode=0755 + - file_mode=0755 + - uid=1000 + - gid=1000 + - mfsymlinks + - cache=strict + - nosharesock + # Reduce the latency of the metadata-heavy directory listing the app does on every page load. + # It also caps how long a file created by one replica can stay invisible to the others. + - actimeo=30 + csi: + driver: file.csi.azure.com + # Must be unique per share in the cluster: {resource group}#{storage account}#{file share} + volumeHandle: "resource-group#storage-account#file-share" + volumeAttributes: + resourceGroup: resource-group + storageAccount: storage-account + shareName: file-share + # The driver mounts an SMB share with the storage account key, which it reads from this secret + nodeStageSecretRef: + name: vacation-planner-file-storage + namespace: vacation-planner-file diff --git a/samples/web-app-file-storage/python/scripts/persistentvolumeclaim.yml b/samples/web-app-file-storage/python/scripts/persistentvolumeclaim.yml new file mode 100644 index 0000000..ce470d8 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/persistentvolumeclaim.yml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vacation-planner-file-pvc + namespace: vacation-planner-file + labels: + app: vacation-planner-file +spec: + # Committed in the shape used by static provisioning: the claim is bound by name to the volume + # created from persistentvolume-smb.yml or persistentvolume-nfs.yml, and the empty storage class + # keeps any provisioner (including the cluster's default storage class) from stepping in. + # + # For dynamic provisioning, 05-deploy-app.sh removes volumeName and sets storageClassName to the + # class that provisions the share: the built-in azurefile-csi for SMB, or the class created from + # storageclass-nfs.yml for NFS. + accessModes: + - ReadWriteMany + storageClassName: "" + volumeName: vacation-planner-file-pv + resources: + requests: + # 100 GiB is the minimum size of a premium file share, which the NFS variants need, so all four + # combinations of the sample can share this single claim. + storage: 100Gi diff --git a/samples/web-app-file-storage/python/scripts/secret.yml b/samples/web-app-file-storage/python/scripts/secret.yml new file mode 100644 index 0000000..6afdf57 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/secret.yml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-file-secrets + namespace: vacation-planner-file + labels: + app: vacation-planner-file +type: Opaque +data: + SECRET_KEY: "" diff --git a/samples/web-app-file-storage/python/scripts/seed-configmap.yml b/samples/web-app-file-storage/python/scripts/seed-configmap.yml new file mode 100644 index 0000000..17c6b75 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/seed-configmap.yml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-file-seed + namespace: vacation-planner-file + labels: + app: vacation-planner-file +data: + # Sample activities the init container of the deployment copies into the Azure file share the first + # time the app is deployed, so the share is never empty on the first page load. + # + # Each key is the name of an activity file, in the same YYYY-MM-DD-HH-MM-SS-activity.txt format the + # app itself uses, and the value is the content of the file. The names are fixed rather than + # generated at runtime, which is what makes the seeding safe when the three replicas start at once: + # every pod would copy the same name with the same content, and the init container skips a file + # that is already there. + # + # This is the only seeding mechanism, and it covers all four combinations of the sample: it runs + # inside the cluster on the mounted share, so it works for a share the CSI driver has just created + # (whose name is not known to the host) and for an NFS share (which is not reachable over the file + # data plane REST API on Azure). + 2026-01-05-09-00-00-activity.txt: "Snorkelling at Coral Bay" + 2026-01-05-09-05-00-activity.txt: "Sunset hike to the Cape lighthouse" + 2026-01-05-09-10-00-activity.txt: "Cooking class: pasta from scratch" diff --git a/samples/web-app-file-storage/python/scripts/service.yml b/samples/web-app-file-storage/python/scripts/service.yml new file mode 100644 index 0000000..a1fbe7a --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-file + namespace: vacation-planner-file + labels: + app: vacation-planner-file +spec: + type: ClusterIP + selector: + app: vacation-planner-file + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-file-storage/python/scripts/storage-secret.yml b/samples/web-app-file-storage/python/scripts/storage-secret.yml new file mode 100644 index 0000000..35ff3b5 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/storage-secret.yml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-file-storage + namespace: vacation-planner-file + labels: + app: vacation-planner-file +type: Opaque +data: + # Credentials the Azure Files CSI driver uses to mount the SMB share, referenced by the + # nodeStageSecretRef of persistentvolume-smb.yml. The two key names are fixed by the driver. + # An SMB share is mounted with the storage account key (NTLMv2), so a static SMB volume needs this + # secret; an NFS share is not authenticated by a key at all and needs no secret. + azurestorageaccountname: "" + azurestorageaccountkey: "" diff --git a/samples/web-app-file-storage/python/scripts/storageclass-nfs.yml b/samples/web-app-file-storage/python/scripts/storageclass-nfs.yml new file mode 100644 index 0000000..ff0c940 --- /dev/null +++ b/samples/web-app-file-storage/python/scripts/storageclass-nfs.yml @@ -0,0 +1,26 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: vacation-planner-file-nfs + labels: + app: vacation-planner-file +# The Azure Files CSI driver installed on every AKS cluster +provisioner: file.csi.azure.com +parameters: + # None of the four built-in azurefile* storage classes sets this, which is the only reason this + # sample ships a storage class of its own: without it the driver provisions an SMB share. + protocol: nfs + # NFS Azure file shares are only available on SSD (premium) file shares + # (https://learn.microsoft.com/en-us/azure/storage/files/files-nfs-protocol). Premium accounts also + # accept PremiumV2_LRS, the provisioned v2 billing model. + skuName: Premium_LRS +mountOptions: + # Same options as the static NFS volume in persistentvolume-nfs.yml. Mount options belong to the + # storage class when the volume is provisioned dynamically, and to the volume when it is static. + - nconnect=4 + - noresvport + - actimeo=30 +# Delete, so removing the claim also removes the file share and the account the driver created for it +reclaimPolicy: Delete +volumeBindingMode: Immediate +allowVolumeExpansion: true diff --git a/samples/web-app-file-storage/src/app.py b/samples/web-app-file-storage/python/src/app.py similarity index 89% rename from samples/web-app-file-storage/src/app.py rename to samples/web-app-file-storage/python/src/app.py index 34cc348..658f0aa 100644 --- a/samples/web-app-file-storage/src/app.py +++ b/samples/web-app-file-storage/python/src/app.py @@ -2,7 +2,7 @@ import socket import datetime from typing import List, Tuple -from flask import Flask, flash, render_template, request, redirect, url_for +from flask import Flask, flash, jsonify, render_template, request, redirect, url_for # Initialize Flask application app: Flask = Flask(__name__) @@ -104,11 +104,18 @@ def read_activities_from_dir(): if not os.path.isfile(path): continue - # Read the file content - with open(path, "r", encoding="utf-8") as file: - content = file.read() + # Every replica mounts the same share, so another replica can delete a file between the + # listing above and this read. That is a file that is gone, not an error: skip it and keep + # reading the rest instead of failing the whole page. + try: + size = os.path.getsize(path) + with open(path, "r", encoding="utf-8") as file: + content = file.read() + except FileNotFoundError: + print(f"Activity file '{name}' disappeared while reading the directory: skipping it.") + continue - print(f"Found activity file: {name} with size {os.path.getsize(path)} bytes") + print(f"Found activity file: {name} with size {size} bytes") print(f"Content of activity file '{name}': {content}") activities.append((name, content)) except ValueError as ve: @@ -262,6 +269,19 @@ def delete(): return redirect(url_for('index')) +@app.route('/health') +def health(): + """Liveness and readiness probe: reports whether the mounted activities directory is usable.""" + try: + if not activities_dir or not os.path.isdir(activities_dir): + raise ValueError(f"Activities directory '{activities_dir}' does not exist. Is the Azure file share mounted?") + if not os.access(activities_dir, os.W_OK | os.X_OK): + raise ValueError(f"Activities directory '{activities_dir}' is not writable by uid {os.geteuid()}.") + return jsonify({"status": "ok"}) + except Exception as ex: + print(f"Health check failed: {ex}") + return jsonify({"status": "unavailable"}), 503 + # Initialize the application and the activities directory when the module is loaded. # This ensures that the setup runs regardless of how the app is started (e.g., via 'flask run' or directly). get_environment_variables() diff --git a/samples/web-app-file-storage/src/gunicorn.conf.py b/samples/web-app-file-storage/python/src/gunicorn.conf.py similarity index 100% rename from samples/web-app-file-storage/src/gunicorn.conf.py rename to samples/web-app-file-storage/python/src/gunicorn.conf.py diff --git a/samples/web-app-file-storage/src/requirements.txt b/samples/web-app-file-storage/python/src/requirements.txt similarity index 100% rename from samples/web-app-file-storage/src/requirements.txt rename to samples/web-app-file-storage/python/src/requirements.txt diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.min.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.min.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.min.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.min.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.min.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.min.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.min.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.min.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.rtl.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.rtl.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.rtl.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.rtl.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.rtl.min.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.rtl.min.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.min.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.min.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.min.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.min.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.min.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.min.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.min.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.min.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.min.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.min.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.min.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.min.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.min.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.min.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.min.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.min.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.min.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.min.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.min.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.min.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.rtl.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.rtl.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.rtl.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.rtl.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.rtl.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.rtl.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.rtl.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.rtl.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.rtl.min.css b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.rtl.min.css similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.rtl.min.css rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.rtl.min.css diff --git a/samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.rtl.min.css.map b/samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/css/bootstrap.rtl.min.css.map rename to samples/web-app-file-storage/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.bundle.js b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.bundle.js rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.bundle.js diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.bundle.js.map b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.bundle.js.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.bundle.js.map rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.bundle.js.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.bundle.min.js b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.bundle.min.js rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.bundle.min.js diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.bundle.min.js.map b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.bundle.min.js.map rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.esm.js b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.esm.js similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.esm.js rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.esm.js diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.esm.js.map b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.esm.js.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.esm.js.map rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.esm.js.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.esm.min.js b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.esm.min.js similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.esm.min.js rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.esm.min.js diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.esm.min.js.map b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.esm.min.js.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.esm.min.js.map rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.esm.min.js.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.js b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.js similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.js rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.js diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.js.map b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.js.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.js.map rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.js.map diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.min.js b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.min.js similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.min.js rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.min.js diff --git a/samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.min.js.map b/samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.min.js.map similarity index 100% rename from samples/web-app-file-storage/src/static/bootstrap/js/bootstrap.min.js.map rename to samples/web-app-file-storage/python/src/static/bootstrap/js/bootstrap.min.js.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/favicon.ico b/samples/web-app-file-storage/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/favicon.ico rename to samples/web-app-file-storage/python/src/static/favicon.ico diff --git a/samples/web-app-postgresql-flexible-server/src/static/style.css b/samples/web-app-file-storage/python/src/static/style.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/style.css rename to samples/web-app-file-storage/python/src/static/style.css diff --git a/samples/web-app-file-storage/src/templates/index.html b/samples/web-app-file-storage/python/src/templates/index.html similarity index 100% rename from samples/web-app-file-storage/src/templates/index.html rename to samples/web-app-file-storage/python/src/templates/index.html diff --git a/samples/web-app-in-cluster-postgresql/dotnet/README.md b/samples/web-app-in-cluster-postgresql/dotnet/README.md new file mode 100644 index 0000000..2013584 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/README.md @@ -0,0 +1,80 @@ +# Vacation Planner: in-cluster PostgreSQL + +> 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` table of the `PlannerDB` database on an **in-cluster PostgreSQL database** — a primary plus two streaming-replica pods deployed as a Kubernetes [StatefulSet](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/), rather than a managed service such as Azure Database for PostgreSQL flexible server. + +The database runs entirely inside the cluster: PostgreSQL 16 pods are backed by Azure managed-disk `PersistentVolumeClaim`s, and they are exposed through three `ClusterIP` services — a headless service for stable per-pod DNS, a *primary* (write) endpoint targeting the pod-0 leader, and a *read* endpoint that round-robins across all replicas. The application connects to the primary (write) endpoint using a dedicated application user (`testuser`) rather than the `postgres` superuser, and the deployment seeds the `activities` table with a handful of sample plans so the app shows data on first load. + +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 +./04-push-docker-image.sh +./05-deploy-app.sh +``` + +`05-deploy-app.sh` deploys the in-cluster PostgreSQL StatefulSet, provisions the database/user and seeds the sample data, and then deploys the app, so it requires `psql` on the host machine (it connects to the database through a `kubectl port-forward`). + +Optionally, **after** `05-deploy-app.sh` has deployed and provisioned the database, run `./03-run-docker-container.sh` for a local smoke test — it runs the container outside Kubernetes and connects to the in-cluster database through a `kubectl port-forward`. + +## Scripts and manifests + +| File | Description | +| ---- | ----------- | +| [`00-variables.sh`](scripts/00-variables.sh) | Defines the variables shared across the other scripts (resource names, image tag, in-cluster PostgreSQL credentials, StatefulSet and service 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 and the [Azure Container Registry (ACR)](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-intro). There is no managed database to create — PostgreSQL runs in-cluster and is deployed by `05-deploy-app.sh`. | +| [`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), connecting to the in-cluster database through a `kubectl port-forward` to the primary service. Run it after `05-deploy-app.sh` has deployed and provisioned the database. | +| [`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) | Deploys the in-cluster PostgreSQL StatefulSet and waits for it to become ready, then (over a `kubectl port-forward` to the primary, so it requires `psql` on the host) creates the `PlannerDB` database, the dedicated application user and its grants, and the `activities` table, which it also seeds with sample data. Finally it deploys the app to the AKS cluster using the YAML manifests below (templated with `yq`). | +| [`Dockerfile`](scripts/Dockerfile) | Builds the Docker image of the web app. | +| [`namespace.yml`](scripts/namespace.yml) | Creates the Kubernetes namespace. | +| [`statefulset.yml`](scripts/statefulset.yml) | Creates the in-cluster PostgreSQL cluster: a Secret with the superuser and replication passwords, a ConfigMap with the primary/replica init scripts, the headless / primary (write) / read `ClusterIP` services, and a 3-replica StatefulSet (one primary plus two standbys configured for streaming replication) backed by Azure managed-disk PVCs. | +| [`configmap.yml`](scripts/configmap.yml) | Creates the ConfigMap holding non-secret input values (the in-cluster PostgreSQL primary service host, database, user, login name) passed to the app as environment variables. | +| [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the application user's PostgreSQL password 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-postgres-in-cluster 8080:80 -n vacation-planner-postgres-in-cluster +``` + +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 in-cluster PostgreSQL database 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 database read and write 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-postgres-in-cluster -n vacation-planner-postgres-in-cluster --tail=50 +``` diff --git a/samples/web-app-in-cluster-postgresql/images/architecture.png b/samples/web-app-in-cluster-postgresql/dotnet/images/architecture.png similarity index 100% rename from samples/web-app-in-cluster-postgresql/images/architecture.png rename to samples/web-app-in-cluster-postgresql/dotnet/images/architecture.png diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh new file mode 100644 index 0000000..2a7562a --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh @@ -0,0 +1,51 @@ +# Variables + +# Azure Resources +PREFIX='zeus' +SUFFIX='test' +LOCATION='italynorth' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX,,}acr${SUFFIX,,}" +ACR_SKU='Standard' +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)" + +# In-cluster PostgreSQL (deployed via statefulset.yml) +PG_PORT='5432' +PG_USER_NAME='testuser' +PG_USER_PASSWORD='TestP@ssw0rd123' +PG_DATABASE_NAME='PlannerDB' + +# StatefulSet topology and write/read endpoints (see statefulset.yml). +# The app does writes, so it must target the primary (write) endpoint. +PG_STATEFULSET_NAME='pg-postgres' +PG_PRIMARY_POD='pg-postgres-0' +PG_PRIMARY_SERVICE='pg-postgres-primary' + +# Superuser bootstrap credentials. These MUST match the POSTGRES_PASSWORD in the +# pg-postgres-secret defined in statefulset.yml — keep both in sync if changed. +PG_SUPERUSER='postgres' +PG_SUPERUSER_PASSWORD='SuperStrongPass123' + +# Local port used by `kubectl port-forward` to reach the in-cluster DB from the +# host (scripts 03 and 06). +PG_LOCAL_PORT='5432' + +# Application config — must match the seed-row `username` in 06-create-test-data.sh. +# PostgreSQL `=` is case-sensitive (unlike SQL Server), so this stays lowercase. +LOGIN_NAME='paolo' + +# Docker Image +IMAGE_NAME="vacation-planner-postgres-in-cluster-dotnet" +IMAGE_PULL_POLICY="Always" +IMAGE_TAG="v1" +PORT="8080" + +# Kubernetes +NAMESPACE="vacation-planner-postgres-in-cluster" +DEPLOYMENT_NAME="vacation-planner-postgres-in-cluster" +SERVICE_NAME="vacation-planner-postgres-in-cluster" +CONFIGMAP_NAME="vacation-planner-postgres-in-cluster-config" +K8S_SECRET_NAME="vacation-planner-postgres-in-cluster-secrets" diff --git a/samples/web-app-in-cluster-postgresql/scripts/01-deploy-resources.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/01-deploy-resources.sh similarity index 83% rename from samples/web-app-in-cluster-postgresql/scripts/01-deploy-resources.sh rename to samples/web-app-in-cluster-postgresql/dotnet/scripts/01-deploy-resources.sh index 55b50b8..251369e 100755 --- a/samples/web-app-in-cluster-postgresql/scripts/01-deploy-resources.sh +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/01-deploy-resources.sh @@ -54,8 +54,8 @@ else echo "[$ACR_NAME] Azure Container Registry already exists." fi -# The PostgreSQL database now runs in-cluster as a StatefulSet (statefulset.yml), -# deployed by 05-deploy-app.sh. Database provisioning and test data are handled by -# 06-create-test-data.sh. No Azure managed PostgreSQL resource is created here. +# The PostgreSQL database now runs in-cluster as a StatefulSet (statefulset.yml). Both the StatefulSet and +# the test data are handled by 05-deploy-app.sh, which waits for the primary and seeds the activities table. +# No Azure managed PostgreSQL resource is created here. echo "Resource group and Azure Container Registry are ready." -echo "Next: build (02) and push (04) the image, deploy the app + in-cluster PostgreSQL (05), then seed data (06)." +echo "Next: build (02) and push (04) the image, then deploy the app + in-cluster PostgreSQL and seed it (05)." diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/02-build-docker-image.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/02-build-docker-image.sh new file mode 100755 index 0000000..4b497d8 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/02-build-docker-image.sh @@ -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 diff --git a/samples/web-app-in-cluster-postgresql/scripts/03-run-docker-container.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/03-run-docker-container.sh similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/03-run-docker-container.sh rename to samples/web-app-in-cluster-postgresql/dotnet/scripts/03-run-docker-container.sh diff --git a/samples/web-app-sql-database/scripts/04-push-docker-image.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/04-push-docker-image.sh similarity index 100% rename from samples/web-app-sql-database/scripts/04-push-docker-image.sh rename to samples/web-app-in-cluster-postgresql/dotnet/scripts/04-push-docker-image.sh diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/05-deploy-app.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/05-deploy-app.sh new file mode 100755 index 0000000..9373c48 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,293 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Generate a stable SECRET_KEY shared by all replicas: the app derives its Data Protection key ring from it, +# so antiforgery tokens and flash messages are valid on every replica and survive pod restarts +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +SECRET_KEY=$(kubectl get secret vacation-planner-postgres-in-cluster-secrets --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $SECRET_KEY ]]; then + SECRET_KEY=$(openssl rand -hex 32) +fi + +# Get the login server for the Azure Container Registry +echo "Getting login server for Azure Container Registry [$ACR_NAME]..." +ACR_LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +if [ -n "$ACR_LOGIN_SERVER" ]; then + echo "Login server retrieved successfully: $ACR_LOGIN_SERVER" +else + echo "Failed to retrieve login server for Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Create namespace +cat namespace.yml | +yq "(.metadata.name)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Deploy the in-cluster PostgreSQL StatefulSet (primary + streaming replicas) and its +# secret/configmap/services. The namespace is injected into every document in the file. +echo "Deploying in-cluster PostgreSQL StatefulSet [$PG_STATEFULSET_NAME]..." +cat statefulset.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Wait for the StatefulSet (primary + replicas) to be ready before deploying the app. +echo "Waiting for PostgreSQL StatefulSet [$PG_STATEFULSET_NAME] to become ready..." +kubectl rollout status "statefulset/$PG_STATEFULSET_NAME" -n "$NAMESPACE" --timeout=600s + +# --------------------------------------------------------------------------- +# Provision the application database/role and seed test data in the in-cluster +# PostgreSQL primary. Done here (before the app Deployment) so the app pods can +# authenticate as [$PG_USER_NAME] on first start. Connects over a port-forward to +# the primary (write) Service, so psql must be available on the host machine. +# --------------------------------------------------------------------------- +if ! command -v psql &>/dev/null; then + echo "psql is not installed on the host. Install the PostgreSQL client (postgresql-client) and re-run." + exit 1 +fi + +echo "Waiting for PostgreSQL primary pod [$PG_PRIMARY_POD] to be ready..." +kubectl wait --for=condition=ready "pod/$PG_PRIMARY_POD" -n "$NAMESPACE" --timeout=600s + +echo "Port-forwarding svc/$PG_PRIMARY_SERVICE to localhost:$PG_LOCAL_PORT..." +kubectl port-forward -n "$NAMESPACE" "svc/$PG_PRIMARY_SERVICE" "$PG_LOCAL_PORT:5432" & +PF_PID=$! +trap 'kill "$PF_PID" 2>/dev/null' EXIT + +echo "Waiting for PostgreSQL to accept connections on localhost:$PG_LOCAL_PORT..." +until pg_isready -h localhost -p "$PG_LOCAL_PORT" -U "$PG_SUPERUSER" &>/dev/null; do + sleep 2 +done + +# Create the application database [$PG_DATABASE_NAME]. PostgreSQL has no +# CREATE DATABASE IF NOT EXISTS, so check for existence first (CREATE DATABASE +# also cannot run inside a DO block / transaction). +echo "Creating database [$PG_DATABASE_NAME]..." +DB_EXISTS=$(PGPASSWORD="$PG_SUPERUSER_PASSWORD" psql \ + --host=localhost \ + --port="$PG_LOCAL_PORT" \ + --username="$PG_SUPERUSER" \ + --dbname=postgres \ + --no-password \ + -tAc "SELECT 1 FROM pg_database WHERE datname = '$PG_DATABASE_NAME';") + +if [ "$DB_EXISTS" != "1" ]; then + PGPASSWORD="$PG_SUPERUSER_PASSWORD" psql \ + --host=localhost \ + --port="$PG_LOCAL_PORT" \ + --username="$PG_SUPERUSER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "CREATE DATABASE \"$PG_DATABASE_NAME\";" + + if [ $? -eq 0 ]; then + echo "Database [$PG_DATABASE_NAME] created successfully" + else + echo "Failed to create database [$PG_DATABASE_NAME]" + exit 1 + fi +else + echo "Database [$PG_DATABASE_NAME] already exists" +fi + +# Create the application login [$PG_USER_NAME]. +echo "Creating login [$PG_USER_NAME]..." +PGPASSWORD="$PG_SUPERUSER_PASSWORD" psql \ + --host=localhost \ + --port="$PG_LOCAL_PORT" \ + --username="$PG_SUPERUSER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "DO \$\$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$PG_USER_NAME') THEN + CREATE ROLE \"$PG_USER_NAME\" WITH LOGIN PASSWORD '$PG_USER_PASSWORD'; + END IF; +END +\$\$;" + +if [ $? -eq 0 ]; then + echo "Login [$PG_USER_NAME] created successfully" +else + echo "Failed to create login [$PG_USER_NAME]" + exit 1 +fi + +# Grant CONNECT on the database to [$PG_USER_NAME]. +echo "Granting CONNECT on [$PG_DATABASE_NAME] to [$PG_USER_NAME]..." +PGPASSWORD="$PG_SUPERUSER_PASSWORD" psql \ + --host=localhost \ + --port="$PG_LOCAL_PORT" \ + --username="$PG_SUPERUSER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT CONNECT ON DATABASE \"$PG_DATABASE_NAME\" TO \"$PG_USER_NAME\";" + +if [ $? -eq 0 ]; then + echo "CONNECT granted successfully to [$PG_USER_NAME]" +else + echo "Failed to grant CONNECT to [$PG_USER_NAME]" + exit 1 +fi + +# Grant schema privileges to [$PG_USER_NAME]. +echo "Granting schema privileges on [$PG_DATABASE_NAME] to [$PG_USER_NAME]..." +PGPASSWORD="$PG_SUPERUSER_PASSWORD" psql \ + --host=localhost \ + --port="$PG_LOCAL_PORT" \ + --username="$PG_SUPERUSER" \ + --dbname="$PG_DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT USAGE, CREATE ON SCHEMA public TO \"$PG_USER_NAME\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"$PG_USER_NAME\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"$PG_USER_NAME\";" + +if [ $? -eq 0 ]; then + echo "Schema privileges granted successfully to [$PG_USER_NAME]" +else + echo "Failed to grant schema privileges to [$PG_USER_NAME]" + exit 1 +fi + +# Create [activities] table. The app also creates this on startup (init_schema); +# we create it here too so seeding does not race the application pods. +echo "Creating [activities] table in the [$PG_DATABASE_NAME] database..." +PGPASSWORD="$PG_USER_PASSWORD" psql \ + --host=localhost \ + --port="$PG_LOCAL_PORT" \ + --username="$PG_USER_NAME" \ + --dbname="$PG_DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username); + CREATE INDEX IF NOT EXISTS idx_activities_created_at ON activities(created_at DESC);" + +if [ $? -eq 0 ]; then + echo "[activities] table created successfully" +else + echo "Failed to create [activities] table" + exit 1 +fi + +# Insert sample data +echo "Inserting sample data into [activities] table..." +PGPASSWORD="$PG_USER_PASSWORD" psql \ + --host=localhost \ + --port="$PG_LOCAL_PORT" \ + --username="$PG_USER_NAME" \ + --dbname="$PG_DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "INSERT INTO activities (id, username, activity) VALUES + (md5('paolo_pisa_seed'), 'paolo', 'Visit the Leaning Tower in Pisa'), + (md5('paolo_volterra_seed'), 'paolo', 'Explore Etruscan walls in Volterra'), + (md5('paolo_san_gimignano_seed'), 'paolo', 'Climb Torre Grossa in San Gimignano'), + (md5('paolo_siena_seed'), 'paolo', 'Walk across Piazza del Campo in Siena'), + (md5('paolo_montalcino_seed'), 'paolo', 'Taste Brunello wine in Montalcino'), + (md5('paolo_pienza_seed'), 'paolo', 'Sample Pecorino cheese in Pienza'), + (md5('paolo_florence_seed'), 'paolo', 'Admire Michelangelo''s David in Florence'), + (md5('paolo_viareggio_beach_seed'), 'paolo', 'Relax by the beach in Viareggio'), + (md5('paolo_viareggio_promenade_seed'), 'paolo', 'Stroll along the Viareggio promenade') + ON CONFLICT (id) DO NOTHING;" + +if [ $? -eq 0 ]; then + echo "Test data inserted successfully into [activities] table" +else + echo "Failed to insert test data into [activities] table" + exit 1 +fi + +# Query data +echo "Querying test data from [activities] table..." +PGPASSWORD="$PG_USER_PASSWORD" psql \ + --host=localhost \ + --port="$PG_LOCAL_PORT" \ + --username="$PG_USER_NAME" \ + --dbname="$PG_DATABASE_NAME" \ + --no-password \ + -c "SELECT id, username, activity, created_at FROM activities;" + +if [ $? -eq 0 ]; then + echo "Test data queried successfully from [activities] table" +else + echo "Failed to query test data from [activities] table" + exit 1 +fi + +# Provisioning done — stop the port-forward before deploying the app. +kill "$PF_PID" 2>/dev/null +trap - EXIT + +# Create secret with the PostgreSQL password and the SECRET_KEY +cat secret.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.PG_PASSWORD)|="\""$(echo -n $PG_USER_PASSWORD | base64 -w0)"\" | +yq "(.data.SECRET_KEY)|="\""$(echo -n $SECRET_KEY | base64 -w0)"\" | +kubectl apply -f - + +# Create configmap with environment variables. PG_HOST is the in-cluster write +# (primary) Service; the app does writes so it must not target a read replica. +cat configmap.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.PG_HOST)|="\""$PG_PRIMARY_SERVICE"\" | +yq "(.data.PG_PORT)|="\""$PG_PORT"\" | +yq "(.data.PG_DATABASE)|="\""$PG_DATABASE_NAME"\" | +yq "(.data.PG_USER)|="\""$PG_USER_NAME"\" | +yq "(.data.LOGIN_NAME)|="\""$LOGIN_NAME"\" | +kubectl apply -f - + +# Create deployment +cat deployment.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.spec.template.spec.containers[0].image)|="\""$FULL_IMAGE"\" | +yq "(.spec.template.spec.containers[0].imagePullPolicy)|="\""$IMAGE_PULL_POLICY"\" | +yq "(.spec.template.spec.containers[0].ports[0].containerPort)|=$PORT" | +kubectl apply -f - + +# Create service +cat service.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE + +# Wait for the rollout so a pod stuck in ImagePullBackOff or CrashLoopBackOff is reported here, not discovered later +echo "Waiting for deployment [$DEPLOYMENT_NAME] to roll out..." +if kubectl rollout status deployment/$DEPLOYMENT_NAME -n $NAMESPACE --timeout=600s; then + echo "Deployment [$DEPLOYMENT_NAME] is ready. To reach the web app, run:" + echo " kubectl port-forward service/$SERVICE_NAME 8080:80 -n $NAMESPACE" + echo "and browse to http://localhost:8080 (health: http://localhost:8080/health)." +else + echo "Deployment [$DEPLOYMENT_NAME] did not become ready. Inspect it with:" + echo " kubectl get pods -n $NAMESPACE" + echo " kubectl describe pod -n $NAMESPACE --selector app=$DEPLOYMENT_NAME" + exit 1 +fi diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/Dockerfile b/samples/web-app-in-cluster-postgresql/dotnet/scripts/Dockerfile new file mode 100644 index 0000000..d862da7 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/Dockerfile @@ -0,0 +1,28 @@ +# Build stage: restore and publish the ASP.NET Core app with the .NET SDK image. +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build + +WORKDIR /src + +# Restore first so the package cache layer is reused when only sources change. +COPY VacationPlanner.csproj ./ +RUN dotnet restore + +COPY . ./ +RUN dotnet publish -c Release -o /app/publish --no-restore + +# Runtime stage: the ASP.NET Core runtime image only, no SDK. +FROM mcr.microsoft.com/dotnet/aspnet:10.0 + +# Port Kestrel listens on; kept as a build argument (and a runtime variable) as in the Python image. +ARG PORT=8080 +ENV PORT=${PORT} + +WORKDIR /app +COPY --from=build /app/publish ./ + +# Run as the runtime image's non-root `app` user, as the Python image runs as its own unprivileged `app` user. +USER app + +EXPOSE ${PORT} + +ENTRYPOINT ["dotnet", "VacationPlanner.dll"] diff --git a/samples/web-app-in-cluster-postgresql/scripts/configmap.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/configmap.yml similarity index 69% rename from samples/web-app-in-cluster-postgresql/scripts/configmap.yml rename to samples/web-app-in-cluster-postgresql/dotnet/scripts/configmap.yml index 6ca91d8..ea3bf53 100644 --- a/samples/web-app-in-cluster-postgresql/scripts/configmap.yml +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/configmap.yml @@ -1,10 +1,10 @@ apiVersion: v1 kind: ConfigMap metadata: - name: vacation-planner-postgres-config - namespace: vacation-planner-postgres + name: vacation-planner-postgres-in-cluster-config + namespace: vacation-planner-postgres-in-cluster labels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster data: # PG_HOST points at the in-cluster primary (write) Service from statefulset.yml. # Values are overwritten by 05-deploy-app.sh at apply time. diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/deployment.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..3f3164e --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/deployment.yml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vacation-planner-postgres-in-cluster + namespace: vacation-planner-postgres-in-cluster + labels: + app: vacation-planner-postgres-in-cluster +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-postgres-in-cluster + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-postgres-in-cluster + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: vacation-planner-postgres-in-cluster + image: .azurecr.io/vacation-planner-postgres-in-cluster-dotnet:v1 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: PG_HOST + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: PG_HOST + - name: PG_PORT + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: PG_PORT + - name: PG_DATABASE + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: PG_DATABASE + - name: PG_USER + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: PG_USER + - name: LOGIN_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: LOGIN_NAME + - name: DEBUG + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: DEBUG + - name: PG_PASSWORD + valueFrom: + secretKeyRef: + name: vacation-planner-postgres-in-cluster-secrets + key: PG_PASSWORD + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-postgres-in-cluster-secrets + key: SECRET_KEY + resources: + requests: + cpu: "200m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "512Mi" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/namespace.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/namespace.yml new file mode 100644 index 0000000..84d1691 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-postgres-in-cluster diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/secret.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/secret.yml new file mode 100644 index 0000000..d2662d2 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/secret.yml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-postgres-in-cluster-secrets + namespace: vacation-planner-postgres-in-cluster + labels: + app: vacation-planner-postgres-in-cluster +type: Opaque +data: + # PG_PASSWORD is the in-cluster `testuser` password; SECRET_KEY is the ASP.NET Core Data Protection + # key. Both are base64-injected by 05-deploy-app.sh at apply time. + PG_PASSWORD: "" + SECRET_KEY: "" diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/service.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/service.yml new file mode 100644 index 0000000..dbebebe --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-postgres-in-cluster + namespace: vacation-planner-postgres-in-cluster + labels: + app: vacation-planner-postgres-in-cluster +spec: + type: ClusterIP + selector: + app: vacation-planner-postgres-in-cluster + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-in-cluster-postgresql/scripts/statefulset.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/statefulset.yml similarity index 95% rename from samples/web-app-in-cluster-postgresql/scripts/statefulset.yml rename to samples/web-app-in-cluster-postgresql/dotnet/scripts/statefulset.yml index 667aac6..e2b8db0 100644 --- a/samples/web-app-in-cluster-postgresql/scripts/statefulset.yml +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/statefulset.yml @@ -2,7 +2,7 @@ apiVersion: v1 kind: Secret metadata: name: pg-postgres-secret - namespace: vacation-planner-postgres + namespace: vacation-planner-postgres-in-cluster type: Opaque stringData: POSTGRES_PASSWORD: "SuperStrongPass123" @@ -12,7 +12,7 @@ apiVersion: v1 kind: ConfigMap metadata: name: pg-postgres-scripts - namespace: vacation-planner-postgres + namespace: vacation-planner-postgres-in-cluster data: # Runs only on the primary (pod-0): the official entrypoint executes # everything in /docker-entrypoint-initdb.d only when it runs initdb, @@ -74,7 +74,7 @@ apiVersion: v1 kind: Service metadata: name: pg-postgres-hl - namespace: vacation-planner-postgres + namespace: vacation-planner-postgres-in-cluster labels: app: pg-postgres spec: @@ -91,7 +91,7 @@ apiVersion: v1 kind: Service metadata: name: pg-postgres-primary - namespace: vacation-planner-postgres + namespace: vacation-planner-postgres-in-cluster labels: app: pg-postgres role: primary @@ -110,7 +110,7 @@ apiVersion: v1 kind: Service metadata: name: pg-postgres-read - namespace: vacation-planner-postgres + namespace: vacation-planner-postgres-in-cluster labels: app: pg-postgres role: read @@ -127,7 +127,7 @@ apiVersion: apps/v1 kind: StatefulSet metadata: name: pg-postgres - namespace: vacation-planner-postgres + namespace: vacation-planner-postgres-in-cluster spec: serviceName: pg-postgres-hl replicas: 3 diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/.dockerignore b/samples/web-app-in-cluster-postgresql/dotnet/src/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Models/Activity.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Delete.cshtml b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..6272570 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) + { + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Index.cshtml b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..4a8e59c --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + if (await store.UpdateAsync(id, text, cancellationToken)) + { + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated!"; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added!"; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Program.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Program.cs new file mode 100644 index 0000000..35261e6 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Program.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Listen on PORT (8080 by default), the way the Python image binds gunicorn to 0.0.0.0:${PORT}. HTTP_PORTS is the +// same setting the aspnet base image feeds through ASPNETCORE_HTTP_PORTS, so no URL override is involved. +if (Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseSetting(WebHostDefaults.HttpPortsKey, port); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storeOptions = PostgresOptions.FromEnvironment(); + +// SECRET_KEY is the Kubernetes Secret the Python sample signs its session cookie with. Deriving the Data Protection +// key ring from it lets all replicas validate each other's antiforgery tokens and flash cookies; without it (a local +// docker run) each process keeps its own keys. +var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY"); +if (!string.IsNullOrEmpty(secretKey)) +{ + builder.Services.AddDataProtection().DisableAutomaticKeyGeneration(); + builder.Services.Configure(options => options.XmlRepository = new SecretKeyXmlRepository(secretKey)); +} + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new PostgresActivityStore(storeOptions, sp.GetRequiredService>())); +// The Python sample waits up to 30 x 2 s for the database at startup; the same values apply here. +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>(), + attempts: 30, delay: TimeSpan.FromSeconds(2))); + +var app = builder.Build(); + +if (string.IsNullOrEmpty(secretKey)) +{ + app.Logger.LogWarning("SECRET_KEY is not set: antiforgery tokens and flash messages are only valid on this replica."); +} + +// One log line per request, the equivalent of the access log the Python image produces (its gunicorn +// command passes --access-logfile -). Kubernetes probes show up here too, exactly as they do for Python. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Services/ActivityId.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/ActivityId.cs new file mode 100644 index 0000000..8654aaf --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/ActivityId.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VacationPlanner.Services; + +/// MD5 of username + activity + timestamp: the id scheme shared by the Vacation Planner samples. +public static class ActivityId +{ + public static string Create(string username, string activity) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"); + var hash = MD5.HashData(Encoding.UTF8.GetBytes($"{username}_{activity}_{timestamp}")); + return Convert.ToHexStringLower(hash); + } +} diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Services/IActivityStore.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..167993e --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,24 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection, directory) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Adds an activity and returns whether the store confirmed the write; the page flashes only then. + Task AddAsync(string text, CancellationToken cancellationToken); + + /// Updates an activity and returns whether the store reported a change, with the meaning the Python sample's driver gives it. + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + /// Deletes an activity by its store id and returns whether the store reported a deletion. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Services/PostgresActivityStore.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/PostgresActivityStore.cs new file mode 100644 index 0000000..bb0bbbb --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/PostgresActivityStore.cs @@ -0,0 +1,126 @@ +using Npgsql; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// +/// Activities in a PostgreSQL activities table. Like the Python sample, the store is low-throughput +/// and opens a fresh connection per call instead of managing a pool explicitly. +/// +public sealed class PostgresActivityStore(PostgresOptions options, ILogger logger) : IActivityStore +{ + private const string SchemaDdl = """ + CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username); + CREATE INDEX IF NOT EXISTS idx_activities_created_at ON activities(created_at DESC); + """; + + // Negotiate TLS when the server offers it, without certificate verification (libpq's "prefer", which the + // Python sample relies on): the flexible server's certificate is publicly trusted on Azure but self-signed + // under LocalStack, and the in-cluster StatefulSet offers no TLS at all. Npgsql only validates certificates + // with SslMode VerifyCA/VerifyFull. + private readonly string _connectionString = new NpgsqlConnectionStringBuilder + { + Host = options.Host, + Port = options.Port, + Username = options.User, + Password = options.Password, + Database = options.Database, + Timeout = 10, + SslMode = SslMode.Prefer, + }.ConnectionString; + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand(SchemaDdl, connection); + await command.ExecuteNonQueryAsync(cancellationToken); + logger.LogInformation("PostgreSQL schema initialized"); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand( + "SELECT id, activity FROM activities WHERE username = @username ORDER BY created_at DESC", connection); + command.Parameters.AddWithValue("username", options.Username); + + var activities = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + activities.Add(new Activity(reader.GetString(0), reader.GetString(1))); + } + + logger.LogInformation( + "Retrieved {Count} activities for user: {Username}", + activities.Count, + options.Username + ); + return activities; + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand( + "INSERT INTO activities (id, username, activity) VALUES (@id, @username, @activity) ON CONFLICT (id) DO NOTHING", + connection); + command.Parameters.AddWithValue("id", ActivityId.Create(options.Username, text)); + command.Parameters.AddWithValue("username", options.Username); + command.Parameters.AddWithValue("activity", text); + await command.ExecuteNonQueryAsync(cancellationToken); + return true; + } + + /// Reports a change when a row matched, as the Python sample's psycopg2 rowcount check does. + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("UPDATE activities SET activity = @activity WHERE id = @id", connection); + command.Parameters.AddWithValue("activity", text); + command.Parameters.AddWithValue("id", id); + return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("DELETE FROM activities WHERE id = @id", connection); + command.Parameters.AddWithValue("id", id); + if (await command.ExecuteNonQueryAsync(cancellationToken) == 0) + { + logger.LogInformation("Activity '{Id}' did not exist: already deleted.", id); + } + + return true; + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("SELECT 1", connection); + await command.ExecuteScalarAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "PostgreSQL health check failed"); + return false; + } + } + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } +} diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Services/PostgresOptions.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/PostgresOptions.cs new file mode 100644 index 0000000..cc824d0 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/PostgresOptions.cs @@ -0,0 +1,28 @@ +namespace VacationPlanner.Services; + +/// Connection settings read from the same environment variables the Python sample uses. +public sealed record PostgresOptions(string Host, int Port, string User, string Password, string Database, string Username) +{ + public static PostgresOptions FromEnvironment() + { + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "paolo"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("LOGIN_NAME cannot be empty"); + } + + return new PostgresOptions( + Host: Require("PG_HOST"), + Port: int.Parse(Environment.GetEnvironmentVariable("PG_PORT") ?? "5432"), + User: Require("PG_USER"), + Password: Require("PG_PASSWORD"), + Database: Environment.GetEnvironmentVariable("PG_DATABASE") ?? "sampledb", + Username: username); + } + + private static string Require(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException( + $"Missing required environment variable: {name}. Set PG_HOST, PG_USER, PG_PASSWORD (and optionally PG_PORT, PG_DATABASE)."); +} diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Services/SecretKeyXmlRepository.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/SecretKeyXmlRepository.cs new file mode 100644 index 0000000..e0cc5bb --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/SecretKeyXmlRepository.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel; +using Microsoft.AspNetCore.DataProtection.Repositories; + +namespace VacationPlanner.Services; + +/// +/// A Data Protection key ring derived deterministically from SECRET_KEY, the Kubernetes Secret the Python +/// sample signs its Flask session cookie with. ASP.NET Core protects its antiforgery tokens and TempData (flash) +/// cookies with Data Protection instead of a signing key; deriving the one key of the ring from the same secret +/// lets every replica of the Deployment validate what another replica issued, which the default per-process key +/// ring cannot offer behind a load balancer. +/// +public sealed class SecretKeyXmlRepository : IXmlRepository +{ + private readonly XElement _key; + + public SecretKeyXmlRepository(string secretKey) + { + var keyMaterial = Encoding.UTF8.GetBytes(secretKey); + + // A 512-bit master key (the size Data Protection generates itself) and a stable key id, both from SECRET_KEY. + var masterKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 64, info: "VacationPlanner.DataProtection.MasterKey"u8.ToArray()); + var keyId = new Guid(HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 16, info: "VacationPlanner.DataProtection.KeyId"u8.ToArray())); + + // AES-256-CBC + HMACSHA256, the default algorithms, serialized the way the key manager itself serializes a new key. + var descriptor = new AuthenticatedEncryptorDescriptor(new AuthenticatedEncryptorConfiguration(), new Secret(masterKey)); + var serialized = descriptor.ExportToXml(); + + _key = new XElement("key", + new XAttribute("id", keyId), + new XAttribute("version", 1), + new XElement("creationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("activationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("expirationDate", new DateTimeOffset(2999, 12, 31, 0, 0, 0, TimeSpan.Zero)), + new XElement("descriptor", + new XAttribute("deserializerType", serialized.DeserializerType.AssemblyQualifiedName!), + serialized.SerializedDescriptorElement)); + } + + public IReadOnlyCollection GetAllElements() => [new XElement(_key)]; + + /// Never called: automatic key generation is disabled, so the key manager has nothing to persist. + public void StoreElement(XElement element, string friendlyName) => + throw new NotSupportedException("The key ring is derived from SECRET_KEY and cannot be modified."); +} diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/VacationPlanner.csproj b/samples/web-app-in-cluster-postgresql/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..afa176e --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + diff --git a/samples/web-app-in-cluster-postgresql/dotnet/src/appsettings.json b/samples/web-app-in-cluster-postgresql/dotnet/src/appsettings.json new file mode 100644 index 0000000..6e5c6b2 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/src/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss " + } + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-sql-database/src/static/favicon.ico b/samples/web-app-in-cluster-postgresql/dotnet/src/wwwroot/favicon.ico similarity index 100% rename from samples/web-app-sql-database/src/static/favicon.ico rename to samples/web-app-in-cluster-postgresql/dotnet/src/wwwroot/favicon.ico diff --git a/samples/web-app-sql-database/src/static/style.css b/samples/web-app-in-cluster-postgresql/dotnet/src/wwwroot/style.css similarity index 100% rename from samples/web-app-sql-database/src/static/style.css rename to samples/web-app-in-cluster-postgresql/dotnet/src/wwwroot/style.css diff --git a/samples/web-app-in-cluster-postgresql/README.md b/samples/web-app-in-cluster-postgresql/python/README.md similarity index 79% rename from samples/web-app-in-cluster-postgresql/README.md rename to samples/web-app-in-cluster-postgresql/python/README.md index 1083765..4525900 100644 --- a/samples/web-app-in-cluster-postgresql/README.md +++ b/samples/web-app-in-cluster-postgresql/python/README.md @@ -1,17 +1,19 @@ # Vacation Planner: in-cluster PostgreSQL +> A .NET version of this sample lives in [../dotnet](../dotnet/README.md). + This sample demonstrates a Python Flask 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` table of the `PlannerDB` database on an **in-cluster PostgreSQL database** — a primary plus two streaming-replica pods deployed as a Kubernetes [StatefulSet](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/), rather than a managed service such as Azure Database for PostgreSQL flexible server. The database runs entirely inside the cluster: PostgreSQL 16 pods are backed by Azure managed-disk `PersistentVolumeClaim`s, and they are exposed through three `ClusterIP` services — a headless service for stable per-pod DNS, a *primary* (write) endpoint targeting the pod-0 leader, and a *read* endpoint that round-robins across all replicas. The application connects to the primary (write) endpoint using a dedicated application user (`testuser`) rather than the `postgres` superuser, and the deployment seeds the `activities` table with a handful of sample plans so the app shows data on first load. 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. +- [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. +> **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 @@ -50,7 +52,7 @@ Optionally, **after** `05-deploy-app.sh` has deployed and provisioned the databa | [`statefulset.yml`](scripts/statefulset.yml) | Creates the in-cluster PostgreSQL cluster: a Secret with the superuser and replication passwords, a ConfigMap with the primary/replica init scripts, the headless / primary (write) / read `ClusterIP` services, and a 3-replica StatefulSet (one primary plus two standbys configured for streaming replication) backed by Azure managed-disk PVCs. | | [`configmap.yml`](scripts/configmap.yml) | Creates the ConfigMap holding non-secret input values (the in-cluster PostgreSQL primary service host, database, user, login name) passed to the app as environment variables. | | [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the application user's PostgreSQL password and the Flask secret key) passed to the app as environment variables. | -| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification for the web app. | +| [`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 @@ -58,7 +60,21 @@ Optionally, **after** `05-deploy-app.sh` has deployed and provisioned the databa 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-postgres 8080:80 -n vacation-planner-postgres +kubectl port-forward service/vacation-planner-postgres-in-cluster 8080:80 -n vacation-planner-postgres-in-cluster ``` 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 in-cluster PostgreSQL database is reachable and `503` with `{"status": "unavailable"}` otherwise. + +```bash +curl http://localhost:8080/health +``` + +## Logs + +The app logs one line per request — gunicorn writes an access log line for every call, the probes included, because its command passes `--access-logfile -` — plus one line per database read and write and one line for every activity added, updated or deleted. Every entry carries a timestamp, the logger name and the level; the Azure SDK and `urllib3` stay at warning level. The [.NET version](../dotnet/README.md) writes the same trace. + +```bash +kubectl logs deployment/vacation-planner-postgres-in-cluster -n vacation-planner-postgres-in-cluster --tail=50 +``` diff --git a/samples/web-app-in-cluster-postgresql/python/images/architecture.png b/samples/web-app-in-cluster-postgresql/python/images/architecture.png new file mode 100644 index 0000000..57d91ac Binary files /dev/null and b/samples/web-app-in-cluster-postgresql/python/images/architecture.png differ diff --git a/samples/web-app-in-cluster-postgresql/scripts/00-variables.sh b/samples/web-app-in-cluster-postgresql/python/scripts/00-variables.sh similarity index 81% rename from samples/web-app-in-cluster-postgresql/scripts/00-variables.sh rename to samples/web-app-in-cluster-postgresql/python/scripts/00-variables.sh index 8899ac7..511c161 100644 --- a/samples/web-app-in-cluster-postgresql/scripts/00-variables.sh +++ b/samples/web-app-in-cluster-postgresql/python/scripts/00-variables.sh @@ -38,14 +38,14 @@ PG_LOCAL_PORT='5432' LOGIN_NAME='paolo' # Docker Image -IMAGE_NAME="vacation-planner-postgres" +IMAGE_NAME="vacation-planner-postgres-in-cluster" IMAGE_PULL_POLICY="Always" IMAGE_TAG="v1" PORT="8080" # Kubernetes -NAMESPACE="vacation-planner-postgres" -DEPLOYMENT_NAME="vacation-planner-postgres" -SERVICE_NAME="vacation-planner-postgres" -CONFIGMAP_NAME="vacation-planner-postgres-config" -K8S_SECRET_NAME="vacation-planner-postgres-secrets" +NAMESPACE="vacation-planner-postgres-in-cluster" +DEPLOYMENT_NAME="vacation-planner-postgres-in-cluster" +SERVICE_NAME="vacation-planner-postgres-in-cluster" +CONFIGMAP_NAME="vacation-planner-postgres-in-cluster-config" +K8S_SECRET_NAME="vacation-planner-postgres-in-cluster-secrets" diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/01-deploy-resources.sh b/samples/web-app-in-cluster-postgresql/python/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..251369e --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/python/scripts/01-deploy-resources.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Create a resource group +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 "Creating resource group [$RESOURCE_GROUP_NAME]..." + az group create \ + --name $RESOURCE_GROUP_NAME \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created." + else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists." +fi + +# Create the Azure Container Registry +echo "Checking if [$ACR_NAME] Azure Container Registry exists..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled "true" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists." +fi + +# The PostgreSQL database now runs in-cluster as a StatefulSet (statefulset.yml). Both the StatefulSet and +# the test data are handled by 05-deploy-app.sh, which waits for the primary and seeds the activities table. +# No Azure managed PostgreSQL resource is created here. +echo "Resource group and Azure Container Registry are ready." +echo "Next: build (02) and push (04) the image, then deploy the app + in-cluster PostgreSQL and seed it (05)." diff --git a/samples/web-app-in-cluster-postgresql/scripts/02-build-docker-image.sh b/samples/web-app-in-cluster-postgresql/python/scripts/02-build-docker-image.sh similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/02-build-docker-image.sh rename to samples/web-app-in-cluster-postgresql/python/scripts/02-build-docker-image.sh diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/03-run-docker-container.sh b/samples/web-app-in-cluster-postgresql/python/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..f995b89 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/python/scripts/03-run-docker-container.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# The database runs in-cluster (statefulset.yml). Reach it from the host by +# port-forwarding the primary (write) Service to localhost:$PG_LOCAL_PORT. +# Requires 05-deploy-app.sh (deploys the DB) and 06-create-test-data.sh +# (creates PlannerDB + testuser) to have run first. +echo "Port-forwarding svc/$PG_PRIMARY_SERVICE to localhost:$PG_LOCAL_PORT..." +kubectl port-forward -n "$NAMESPACE" "svc/$PG_PRIMARY_SERVICE" "$PG_LOCAL_PORT:5432" & +PF_PID=$! +trap 'kill "$PF_PID" 2>/dev/null' EXIT + +# Wait for the forwarded port to accept connections. +echo "Waiting for PostgreSQL to accept connections on localhost:$PG_LOCAL_PORT..." +until pg_isready -h localhost -p "$PG_LOCAL_PORT" -U "$PG_USER_NAME" &>/dev/null; do + sleep 2 +done + +# --network=host so the container reaches the port-forward on the host's loopback. +docker run -it \ + --rm \ + --network=host \ + -e PORT=$PORT \ + -e PG_HOST="localhost" \ + -e PG_PORT="$PG_LOCAL_PORT" \ + -e PG_DATABASE="$PG_DATABASE_NAME" \ + -e PG_USER="$PG_USER_NAME" \ + -e PG_PASSWORD="$PG_USER_PASSWORD" \ + -e LOGIN_NAME="$LOGIN_NAME" \ + --name "$IMAGE_NAME" \ + "$IMAGE_NAME:$IMAGE_TAG" diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/04-push-docker-image.sh b/samples/web-app-in-cluster-postgresql/python/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/python/scripts/04-push-docker-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Login to ACR +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name $ACR_NAME + +# Retrieve ACR login server. Each container image needs to be tagged with the loginServer name of the registry. +ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --query loginServer --output tsv) + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Tag the local image with the loginServer of ACR +docker tag ${IMAGE_NAME,,}:$IMAGE_TAG $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$IMAGE_NAME] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$IMAGE_NAME] as [$FULL_IMAGE]." + exit 1 +fi + +# Push the container image to ACR +docker push $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + exit 1 +fi diff --git a/samples/web-app-in-cluster-postgresql/scripts/05-deploy-app.sh b/samples/web-app-in-cluster-postgresql/python/scripts/05-deploy-app.sh similarity index 92% rename from samples/web-app-in-cluster-postgresql/scripts/05-deploy-app.sh rename to samples/web-app-in-cluster-postgresql/python/scripts/05-deploy-app.sh index dbacbb2..1f7cefe 100755 --- a/samples/web-app-in-cluster-postgresql/scripts/05-deploy-app.sh +++ b/samples/web-app-in-cluster-postgresql/python/scripts/05-deploy-app.sh @@ -7,7 +7,14 @@ source ./00-variables.sh cd "$CURRENT_DIR" || exit # Generate a stable Flask SECRET_KEY (sessions survive pod restarts) -FLASK_SECRET_KEY=$(openssl rand -hex 32) +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +FLASK_SECRET_KEY=$(kubectl get secret vacation-planner-postgres-in-cluster-secrets --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $FLASK_SECRET_KEY ]]; then + FLASK_SECRET_KEY=$(openssl rand -hex 32) +fi # Get the login server for the Azure Container Registry echo "Getting login server for Azure Container Registry [$ACR_NAME]..." @@ -266,3 +273,7 @@ kubectl apply -f - cat service.yml | yq "(.metadata.namespace)|="\""$NAMESPACE"\" | kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE diff --git a/samples/web-app-in-cluster-postgresql/scripts/Dockerfile b/samples/web-app-in-cluster-postgresql/python/scripts/Dockerfile similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/Dockerfile rename to samples/web-app-in-cluster-postgresql/python/scripts/Dockerfile diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/configmap.yml b/samples/web-app-in-cluster-postgresql/python/scripts/configmap.yml new file mode 100644 index 0000000..ea3bf53 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/python/scripts/configmap.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-postgres-in-cluster-config + namespace: vacation-planner-postgres-in-cluster + labels: + app: vacation-planner-postgres-in-cluster +data: + # PG_HOST points at the in-cluster primary (write) Service from statefulset.yml. + # Values are overwritten by 05-deploy-app.sh at apply time. + PG_HOST: "pg-postgres-primary" + PG_PORT: "5432" + PG_DATABASE: "PlannerDB" + PG_USER: "testuser" + LOGIN_NAME: "paolo" + DEBUG: "false" diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml b/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml new file mode 100644 index 0000000..43ecbce --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vacation-planner-postgres-in-cluster + namespace: vacation-planner-postgres-in-cluster + labels: + app: vacation-planner-postgres-in-cluster +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-postgres-in-cluster + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-postgres-in-cluster + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: vacation-planner-postgres-in-cluster + image: .azurecr.io/vacation-planner-postgres-in-cluster:v1 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: PG_HOST + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: PG_HOST + - name: PG_PORT + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: PG_PORT + - name: PG_DATABASE + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: PG_DATABASE + - name: PG_USER + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: PG_USER + - name: LOGIN_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: LOGIN_NAME + - name: DEBUG + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-in-cluster-config + key: DEBUG + - name: PG_PASSWORD + valueFrom: + secretKeyRef: + name: vacation-planner-postgres-in-cluster-secrets + key: PG_PASSWORD + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-postgres-in-cluster-secrets + key: SECRET_KEY + resources: + requests: + cpu: "200m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "512Mi" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/namespace.yml b/samples/web-app-in-cluster-postgresql/python/scripts/namespace.yml new file mode 100644 index 0000000..84d1691 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/python/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-postgres-in-cluster diff --git a/samples/web-app-in-cluster-postgresql/scripts/secret.yml b/samples/web-app-in-cluster-postgresql/python/scripts/secret.yml similarity index 63% rename from samples/web-app-in-cluster-postgresql/scripts/secret.yml rename to samples/web-app-in-cluster-postgresql/python/scripts/secret.yml index 8e05228..5bb515e 100644 --- a/samples/web-app-in-cluster-postgresql/scripts/secret.yml +++ b/samples/web-app-in-cluster-postgresql/python/scripts/secret.yml @@ -1,10 +1,10 @@ apiVersion: v1 kind: Secret metadata: - name: vacation-planner-postgres-secrets - namespace: vacation-planner-postgres + name: vacation-planner-postgres-in-cluster-secrets + namespace: vacation-planner-postgres-in-cluster labels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster type: Opaque data: # PG_PASSWORD is the in-cluster `testuser` password; SECRET_KEY is the Flask session diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/service.yml b/samples/web-app-in-cluster-postgresql/python/scripts/service.yml new file mode 100644 index 0000000..dbebebe --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/python/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-postgres-in-cluster + namespace: vacation-planner-postgres-in-cluster + labels: + app: vacation-planner-postgres-in-cluster +spec: + type: ClusterIP + selector: + app: vacation-planner-postgres-in-cluster + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/statefulset.yml b/samples/web-app-in-cluster-postgresql/python/scripts/statefulset.yml new file mode 100644 index 0000000..e2b8db0 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/python/scripts/statefulset.yml @@ -0,0 +1,224 @@ +apiVersion: v1 +kind: Secret +metadata: + name: pg-postgres-secret + namespace: vacation-planner-postgres-in-cluster +type: Opaque +stringData: + POSTGRES_PASSWORD: "SuperStrongPass123" + REPL_PASSWORD: "ReplStrongPass123" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: pg-postgres-scripts + namespace: vacation-planner-postgres-in-cluster +data: + # Runs only on the primary (pod-0): the official entrypoint executes + # everything in /docker-entrypoint-initdb.d only when it runs initdb, + # which happens solely on a fresh primary. Replicas get their data via + # pg_basebackup, so their PGDATA is already populated and these are skipped. + primary-init.sh: | + #!/bin/bash + set -euo pipefail + ORD="${HOSTNAME##*-}" + if [ "$ORD" != "0" ]; then + echo "not primary, skipping primary-init" + exit 0 + fi + echo "configuring primary for streaming replication" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <> "$PGDATA/pg_hba.conf" + echo "primary-init done (wal_level applied on the real server start)" + + # Runs as an initContainer on every pod. No-op on the primary and on any + # pod whose data dir is already initialized. On a fresh standby it clones + # the primary with pg_basebackup and wires up primary_conninfo. + replica-init.sh: | + #!/bin/bash + set -euo pipefail + ORD="${HOSTNAME##*-}" + if [ "$ORD" = "0" ]; then + echo "primary, no basebackup needed" + exit 0 + fi + if [ -s "$PGDATA/PG_VERSION" ]; then + echo "data dir already initialized, skipping basebackup" + exit 0 + fi + echo "waiting for primary pg-postgres-0 to accept connections..." + until pg_isready -h pg-postgres-0.pg-postgres-hl -p 5432 -U "$POSTGRES_USER"; do + sleep 2 + done + echo "cloning primary via pg_basebackup..." + export PGPASSWORD="$REPL_PASSWORD" + pg_basebackup -h pg-postgres-0.pg-postgres-hl -p 5432 -U replicator \ + -D "$PGDATA" -Fp -Xs -R -P + # -R already wrote standby.signal + a password-less primary_conninfo. + # Append a complete primary_conninfo (last value wins) so the walreceiver + # can authenticate. + cat >> "$PGDATA/postgresql.auto.conf" <", methods=["POST"]) -def delete(activity_id: int): - if 0 <= activity_id < len(activities): - db_client.delete_activity(activities[activity_id][0]) +@app.route("/delete/", methods=["POST"]) +def delete(activity_id: str): + """Delete the activity with this id. + + The row id addresses the activity, never its position in the rendered page: every replica reloads the + table on each GET, so the list can change between rendering a page and submitting a delete from it, and + a position would then delete whatever activity happens to sit there now. + """ + if activity_id: + db_client.delete_activity(activity_id) flash("Activity deleted.") return redirect(url_for("index")) +@app.route("/health") +def health(): + """Liveness and readiness probe: reports whether PostgreSQL answers SELECT 1.""" + try: + db_client.ping() + return jsonify({"status": "ok"}) + except Exception as exc: + logger.warning("Health check failed: %s", exc) + return jsonify({"status": "unavailable"}), 503 + + debug = os.environ.get("DEBUG", "false").lower() == "true" reload_activities() diff --git a/samples/web-app-postgresql-flexible-server/src/database.py b/samples/web-app-in-cluster-postgresql/python/src/database.py similarity index 94% rename from samples/web-app-postgresql-flexible-server/src/database.py rename to samples/web-app-in-cluster-postgresql/python/src/database.py index 515a6f3..d3082c0 100644 --- a/samples/web-app-postgresql-flexible-server/src/database.py +++ b/samples/web-app-in-cluster-postgresql/python/src/database.py @@ -80,6 +80,16 @@ def _connect(self): connect_timeout=10, ) + def ping(self) -> None: + """Open a connection and run SELECT 1; raises when the server is unreachable.""" + conn = self._connect() + try: + with conn.cursor() as cur: + cur.execute("SELECT 1") + cur.fetchone() + finally: + conn.close() + def init_schema(self, retries: int = 30, delay: float = 2.0) -> None: """Wait for PostgreSQL to accept connections, then create the activities table.""" last_err: Exception | None = None diff --git a/samples/web-app-in-cluster-postgresql/src/gunicorn.conf.py b/samples/web-app-in-cluster-postgresql/python/src/gunicorn.conf.py similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/gunicorn.conf.py rename to samples/web-app-in-cluster-postgresql/python/src/gunicorn.conf.py diff --git a/samples/web-app-in-cluster-postgresql/src/requirements.txt b/samples/web-app-in-cluster-postgresql/python/src/requirements.txt similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/requirements.txt rename to samples/web-app-in-cluster-postgresql/python/src/requirements.txt diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.min.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.min.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.min.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.min.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.min.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.min.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.min.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.min.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.rtl.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.rtl.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.rtl.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.rtl.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.rtl.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.rtl.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.rtl.min.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.rtl.min.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.min.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.min.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.min.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.min.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.min.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.min.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.rtl.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.rtl.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.min.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.min.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.min.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.min.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.min.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.min.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.rtl.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.rtl.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.min.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.min.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.min.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.min.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.min.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.min.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.min.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.min.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.rtl.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.rtl.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.rtl.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.rtl.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.rtl.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.rtl.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.rtl.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.rtl.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.rtl.min.css b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.rtl.min.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.rtl.min.css rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.rtl.min.css diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.rtl.min.css.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/css/bootstrap.rtl.min.css.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.bundle.js b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.bundle.js rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.bundle.js diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.bundle.js.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.bundle.js.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.bundle.js.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.bundle.js.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.bundle.min.js b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.bundle.min.js rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.bundle.min.js diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.bundle.min.js.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.bundle.min.js.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.esm.js b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.esm.js similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.esm.js rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.esm.js diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.esm.js.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.esm.js.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.esm.js.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.esm.js.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.esm.min.js b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.esm.min.js similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.esm.min.js rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.esm.min.js diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.esm.min.js.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.esm.min.js.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.esm.min.js.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.esm.min.js.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.js b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.js similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.js rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.js diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.js.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.js.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.js.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.js.map diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.min.js b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.min.js similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.min.js rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.min.js diff --git a/samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.min.js.map b/samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.min.js.map similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/bootstrap/js/bootstrap.min.js.map rename to samples/web-app-in-cluster-postgresql/python/src/static/bootstrap/js/bootstrap.min.js.map diff --git a/samples/web-app-in-cluster-postgresql/python/src/static/favicon.ico b/samples/web-app-in-cluster-postgresql/python/src/static/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-in-cluster-postgresql/python/src/static/favicon.ico differ diff --git a/samples/web-app-in-cluster-postgresql/python/src/static/style.css b/samples/web-app-in-cluster-postgresql/python/src/static/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/python/src/static/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-in-cluster-postgresql/src/templates/index.html b/samples/web-app-in-cluster-postgresql/python/src/templates/index.html similarity index 99% rename from samples/web-app-in-cluster-postgresql/src/templates/index.html rename to samples/web-app-in-cluster-postgresql/python/src/templates/index.html index 45a7be8..efb642d 100644 --- a/samples/web-app-in-cluster-postgresql/src/templates/index.html +++ b/samples/web-app-in-cluster-postgresql/python/src/templates/index.html @@ -70,7 +70,7 @@

🌴 Vacation Planner

-
+ + + + + + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + + + + +
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..afd8549 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + if (await store.UpdateAsync(id, text, cancellationToken)) + { + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated successfully."; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added successfully."; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-managed-identity/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-managed-identity/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-managed-identity/dotnet/src/Program.cs b/samples/web-app-managed-identity/dotnet/src/Program.cs new file mode 100644 index 0000000..768568c --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Program.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Listen on PORT (8080 by default), the way the Python image binds gunicorn to 0.0.0.0:${PORT}. HTTP_PORTS is the +// same setting the aspnet base image feeds through ASPNETCORE_HTTP_PORTS, so no URL override is involved. +if (Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseSetting(WebHostDefaults.HttpPortsKey, port); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storeOptions = BlobStorageOptions.FromEnvironment(); + +// SECRET_KEY is the Kubernetes Secret the Python sample signs its session cookie with. Deriving the Data Protection +// key ring from it lets all replicas validate each other's antiforgery tokens and flash cookies; without it (a local +// docker run) each process keeps its own keys. +var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY"); +if (!string.IsNullOrEmpty(secretKey)) +{ + builder.Services.AddDataProtection().DisableAutomaticKeyGeneration(); + builder.Services.Configure(options => options.XmlRepository = new SecretKeyXmlRepository(secretKey)); +} + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new BlobActivityStore(storeOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +if (string.IsNullOrEmpty(secretKey)) +{ + app.Logger.LogWarning("SECRET_KEY is not set: antiforgery tokens and flash messages are only valid on this replica."); +} + +// One log line per request, the equivalent of the access log the Python image produces (its gunicorn +// command passes --access-logfile -). Kubernetes probes show up here too, exactly as they do for Python. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs b/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs new file mode 100644 index 0000000..a267f6a --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs @@ -0,0 +1,158 @@ +using System.Text; +using Azure.Identity; +using Azure.Storage; +using Azure.Storage.Blobs; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// One blob per activity in a Blob Storage container; the blob name is the activity id and its content the text. +public sealed class BlobActivityStore : IActivityStore +{ + /// Suffix of the blobs holding the activities, one blob per activity. + private const string ActivityBlobSuffix = "-activity.txt"; + + private readonly BlobContainerClient _container; + private readonly ILogger _logger; + + public BlobActivityStore(BlobStorageOptions options, ILogger logger) + { + _logger = logger; + + // The same credential ladder as the Python sample: an explicit service principal, then a connection string, + // then the identity of the pod. + BlobServiceClient service; + if (options is { ClientId: { Length: > 0 }, ClientSecret: { Length: > 0 }, TenantId: { Length: > 0 }, AccountUrl: { Length: > 0 } }) + { + logger.LogInformation("Using ClientSecretCredential with BlobServiceClient."); + var credential = new ClientSecretCredential(options.TenantId, options.ClientId, options.ClientSecret); + service = new BlobServiceClient(new Uri(options.AccountUrl), credential); + } + else if (!string.IsNullOrEmpty(options.ConnectionString)) + { + logger.LogInformation("Using storage account connection string with BlobServiceClient."); + service = FromConnectionString(options.ConnectionString); + } + else if (!string.IsNullOrEmpty(options.AccountUrl)) + { + // DefaultAzureCredential picks up the Microsoft Entra Workload ID the webhook projects into the pod + // (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE, AZURE_AUTHORITY_HOST). + logger.LogInformation("Using DefaultAzureCredential with BlobServiceClient."); + service = new BlobServiceClient(new Uri(options.AccountUrl), new DefaultAzureCredential()); + } + else + { + throw new InvalidOperationException( + "Insufficient configuration for BlobServiceClient. Set AZURE_STORAGE_ACCOUNT_URL (workload identity) or AZURE_STORAGE_ACCOUNT_CONNECTION_STRING."); + } + + _container = service.GetBlobContainerClient(options.ContainerName); + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await _container.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + _logger.LogInformation("Container '{Container}' is ready.", _container.Name); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + // Blobs come back sorted by name, which sorts by creation timestamp: the blob name is the timestamp. + var activities = new List(); + await foreach (var blob in _container.GetBlobsAsync(cancellationToken: cancellationToken)) + { + var content = await _container.GetBlobClient(blob.Name).DownloadContentAsync(cancellationToken); + _logger.LogInformation("Found blob '{Blob}' with size {Size} bytes", blob.Name, blob.Properties.ContentLength); + activities.Add(new Activity(blob.Name, content.Value.Content.ToString())); + } + + _logger.LogInformation("Retrieved {Count} blob(s) from container '{Container}'", activities.Count, _container.Name); + return activities; + } + + public Task AddAsync(string text, CancellationToken cancellationToken) => + UploadAsync($"{DateTime.Now:yyyy-MM-dd-HH-mm-ss}{ActivityBlobSuffix}", text, cancellationToken); + + public Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + if (!IsActivityName(id)) + { + _logger.LogWarning("Invalid activity name '{Name}'.", id); + return Task.FromResult(false); + } + + return UploadAsync(id, text, cancellationToken); + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + if (!IsActivityName(id)) + { + _logger.LogWarning("Invalid activity name '{Name}'.", id); + return false; + } + + // As in the Python sample, a blob that is already gone still counts as deleted. + var deleted = await _container.GetBlobClient(id).DeleteIfExistsAsync(cancellationToken: cancellationToken); + if (deleted.Value) + { + _logger.LogInformation("Deleted blob '{Blob}' from container '{Container}'", id, _container.Name); + } + else + { + _logger.LogInformation("Blob '{Blob}' did not exist: already deleted.", id); + } + + return true; + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + return await _container.ExistsAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Blob Storage health check failed"); + return false; + } + } + + private async Task UploadAsync(string name, string text, CancellationToken cancellationToken) + { + await _container.GetBlobClient(name).UploadAsync(new BinaryData(Encoding.UTF8.GetBytes(text)), overwrite: true, cancellationToken); + _logger.LogInformation("Uploaded blob '{Blob}' to container '{Container}'", name, _container.Name); + return true; + } + + /// + /// Whether the name is one of this app's activity blobs, and nothing else: a blob called + /// yyyy-MM-dd-HH-mm-ss-activity.txt directly in the container. The name arrives from a form field, so this + /// keeps it from reaching any other blob in the container. + /// + private static bool IsActivityName(string? name) => + !string.IsNullOrEmpty(name) + && name is not ("." or "..") + && !name.Contains('/') && !name.Contains('\\') + && name.EndsWith(ActivityBlobSuffix, StringComparison.Ordinal); + + /// + /// Builds the client from the connection string's explicit BlobEndpoint and shared key when they are present, + /// as the Python SDK does. The .NET parser insists on a port-less EndpointSuffix and rejects the connection + /// string the LocalStack emulator returns, whose suffix carries the gateway port (core.azure.localhost.localstack.cloud:4566). + /// + private static BlobServiceClient FromConnectionString(string connectionString) + { + var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split('=', 2)) + .Where(kv => kv.Length == 2) + .ToDictionary(kv => kv[0].Trim(), kv => kv[1].Trim(), StringComparer.OrdinalIgnoreCase); + + return parts.TryGetValue("BlobEndpoint", out var endpoint) + && parts.TryGetValue("AccountName", out var accountName) + && parts.TryGetValue("AccountKey", out var accountKey) + ? new BlobServiceClient(new Uri(endpoint), new StorageSharedKeyCredential(accountName, accountKey)) + : new BlobServiceClient(connectionString); + } +} diff --git a/samples/web-app-managed-identity/dotnet/src/Services/BlobStorageOptions.cs b/samples/web-app-managed-identity/dotnet/src/Services/BlobStorageOptions.cs new file mode 100644 index 0000000..c353256 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/BlobStorageOptions.cs @@ -0,0 +1,19 @@ +namespace VacationPlanner.Services; + +/// Settings read from the same environment variables the Python sample uses. +public sealed record BlobStorageOptions( + string? AccountUrl, + string? ConnectionString, + string ContainerName, + string? ClientId, + string? ClientSecret, + string? TenantId) +{ + public static BlobStorageOptions FromEnvironment() => new( + AccountUrl: Environment.GetEnvironmentVariable("AZURE_STORAGE_ACCOUNT_URL"), + ConnectionString: Environment.GetEnvironmentVariable("AZURE_STORAGE_ACCOUNT_CONNECTION_STRING"), + ContainerName: Environment.GetEnvironmentVariable("CONTAINER_NAME") ?? "activities", + ClientId: Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"), + ClientSecret: Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET"), + TenantId: Environment.GetEnvironmentVariable("AZURE_TENANT_ID")); +} diff --git a/samples/web-app-managed-identity/dotnet/src/Services/IActivityStore.cs b/samples/web-app-managed-identity/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..167993e --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,24 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection, directory) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Adds an activity and returns whether the store confirmed the write; the page flashes only then. + Task AddAsync(string text, CancellationToken cancellationToken); + + /// Updates an activity and returns whether the store reported a change, with the meaning the Python sample's driver gives it. + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + /// Deletes an activity by its store id and returns whether the store reported a deletion. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-managed-identity/dotnet/src/Services/SecretKeyXmlRepository.cs b/samples/web-app-managed-identity/dotnet/src/Services/SecretKeyXmlRepository.cs new file mode 100644 index 0000000..e0cc5bb --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/SecretKeyXmlRepository.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel; +using Microsoft.AspNetCore.DataProtection.Repositories; + +namespace VacationPlanner.Services; + +/// +/// A Data Protection key ring derived deterministically from SECRET_KEY, the Kubernetes Secret the Python +/// sample signs its Flask session cookie with. ASP.NET Core protects its antiforgery tokens and TempData (flash) +/// cookies with Data Protection instead of a signing key; deriving the one key of the ring from the same secret +/// lets every replica of the Deployment validate what another replica issued, which the default per-process key +/// ring cannot offer behind a load balancer. +/// +public sealed class SecretKeyXmlRepository : IXmlRepository +{ + private readonly XElement _key; + + public SecretKeyXmlRepository(string secretKey) + { + var keyMaterial = Encoding.UTF8.GetBytes(secretKey); + + // A 512-bit master key (the size Data Protection generates itself) and a stable key id, both from SECRET_KEY. + var masterKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 64, info: "VacationPlanner.DataProtection.MasterKey"u8.ToArray()); + var keyId = new Guid(HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 16, info: "VacationPlanner.DataProtection.KeyId"u8.ToArray())); + + // AES-256-CBC + HMACSHA256, the default algorithms, serialized the way the key manager itself serializes a new key. + var descriptor = new AuthenticatedEncryptorDescriptor(new AuthenticatedEncryptorConfiguration(), new Secret(masterKey)); + var serialized = descriptor.ExportToXml(); + + _key = new XElement("key", + new XAttribute("id", keyId), + new XAttribute("version", 1), + new XElement("creationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("activationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("expirationDate", new DateTimeOffset(2999, 12, 31, 0, 0, 0, TimeSpan.Zero)), + new XElement("descriptor", + new XAttribute("deserializerType", serialized.DeserializerType.AssemblyQualifiedName!), + serialized.SerializedDescriptorElement)); + } + + public IReadOnlyCollection GetAllElements() => [new XElement(_key)]; + + /// Never called: automatic key generation is disabled, so the key manager has nothing to persist. + public void StoreElement(XElement element, string friendlyName) => + throw new NotSupportedException("The key ring is derived from SECRET_KEY and cannot be modified."); +} diff --git a/samples/web-app-managed-identity/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-managed-identity/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-managed-identity/dotnet/src/VacationPlanner.csproj b/samples/web-app-managed-identity/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..835cd67 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + + diff --git a/samples/web-app-managed-identity/dotnet/src/appsettings.json b/samples/web-app-managed-identity/dotnet/src/appsettings.json new file mode 100644 index 0000000..6e5c6b2 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss " + } + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-managed-identity/dotnet/src/wwwroot/favicon.ico b/samples/web-app-managed-identity/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-managed-identity/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-managed-identity/dotnet/src/wwwroot/style.css b/samples/web-app-managed-identity/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-managed-identity/README.md b/samples/web-app-managed-identity/python/README.md similarity index 73% rename from samples/web-app-managed-identity/README.md rename to samples/web-app-managed-identity/python/README.md index 3c4f172..2bd9ea8 100644 --- a/samples/web-app-managed-identity/README.md +++ b/samples/web-app-managed-identity/python/README.md @@ -1,19 +1,21 @@ # Vacation Planner: Azure Blob Storage with Microsoft Entra Workload ID +> A .NET version of this sample lives in [../dotnet](../dotnet/README.md). + This sample demonstrates a Python Flask 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. -Unlike the [`web-app-blob-storage`](../web-app-blob-storage/) sample, which uses a connection string, this sample authenticates to the storage account without any secret, using [Microsoft Entra Workload ID](https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview). A [user-assigned managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) is federated with a Kubernetes service account, so the pod obtains Microsoft Entra tokens through the cluster's OIDC issuer and accesses the storage account with its RBAC role assignment. +Unlike the [`web-app-blob-storage`](../../web-app-blob-storage/python/) sample, which uses a connection string, this sample authenticates to the storage account without any secret, using [Microsoft Entra Workload ID](https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview). A [user-assigned managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) is federated with a Kubernetes service account, so the pod obtains Microsoft Entra tokens through the cluster's OIDC issuer and accesses the storage account with its RBAC role assignment. Optionally, when `DEPLOY_GATEWAY="true"` in [`00-variables.sh`](scripts/00-variables.sh), the sample also exposes the app on a public hostname through the [Gateway API](https://gateway-api.sigs.k8s.io/), with an A record created in an [Azure DNS](https://learn.microsoft.com/en-us/azure/dns/dns-overview) zone and a TLS certificate issued via [cert-manager](https://cert-manager.io/). 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. +- [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. Both scripts enable the OIDC issuer and workload identity that this sample relies on. If you enable the Gateway path, also install the [Gateway API](https://gateway-api.sigs.k8s.io/) and [cert-manager](https://cert-manager.io/) add-ons from the root `scripts/` folder. 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. +> **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 @@ -48,7 +50,7 @@ cd scripts | [`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, storage account URL, managed identity client ID, tenant ID) passed to the app as environment variables. | | [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the Flask secret key, and the optional connection string / client secret fallback) passed to the app as environment variables. | -| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification and the workload-identity service account reference. | +| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification and the workload-identity service account reference. 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. | | [`issuer.yml`](scripts/issuer.yml) | (Gateway path) cert-manager Issuer that solves the ACME HTTP-01 challenge through a Gateway API HTTPRoute. | | [`gateway.yml`](scripts/gateway.yml) | (Gateway path) Gateway API Gateway that exposes the app on the configured public hostname. | @@ -59,9 +61,23 @@ cd scripts By default 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 +kubectl port-forward service/vacation-planner-identity 8080:80 -n vacation-planner-identity ``` 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 +``` + If you deployed the Gateway path (`DEPLOY_GATEWAY="true"`), the app is instead reachable directly at the public hostname configured in [`00-variables.sh`](scripts/00-variables.sh) (`https://.`), with no port-forward required. + +## Logs + +The app logs one line per request — gunicorn writes an access log line for every call, the probes included, because its command passes `--access-logfile -` — plus one line per blob read, uploaded or deleted and one line for every activity added, updated or deleted. The store operations are printed to stdout, so `kubectl logs` shows them interleaved with the access log. The [.NET version](../dotnet/README.md) writes the same trace, timestamped. + +```bash +kubectl logs deployment/vacation-planner-identity -n vacation-planner-identity --tail=50 +``` diff --git a/samples/web-app-managed-identity/python/images/architecture.png b/samples/web-app-managed-identity/python/images/architecture.png new file mode 100644 index 0000000..188f5da Binary files /dev/null and b/samples/web-app-managed-identity/python/images/architecture.png differ diff --git a/samples/web-app-managed-identity/python/scripts/00-variables.sh b/samples/web-app-managed-identity/python/scripts/00-variables.sh new file mode 100755 index 0000000..1241639 --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/00-variables.sh @@ -0,0 +1,45 @@ +# Variables + +# Azure Resources +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" + +# Registration address of the Let's Encrypt account used by the Gateway TLS certificate: expiry notices +# go here. Export ACME_EMAIL to use your own address. +ACME_EMAIL="${ACME_EMAIL:-admin@example.com}" + +# Storage Account +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +CONTAINER_NAME='activities' + +# Docker Image +IMAGE_NAME="vacation-planner-identity" +IMAGE_PULL_POLICY="Always" +IMAGE_TAG="v1" +PORT="8080" + +# Kubernetes +NAME="vacation-planner-identity" +NAMESPACE="vacation-planner-identity" +DEPLOYMENT_NAME="vacation-planner-identity" +SERVICE_NAME="vacation-planner-identity" +CONFIGMAP_NAME="vacation-planner-identity-config" +SECRET_NAME="vacation-planner-identity-secrets" +SERVICE_ACCOUNT_NAME="vacation-planner-identity-sa" +DEPLOY_GATEWAY="false" diff --git a/samples/web-app-managed-identity/python/scripts/01-deploy-resources.sh b/samples/web-app-managed-identity/python/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..f954dfc --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/01-deploy-resources.sh @@ -0,0 +1,242 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Create a resource group +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]..." + + 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 1 + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists in the subscription [$SUBSCRIPTION_NAME]" +fi + +# Create the Azure Container Registry +echo "Checking if [$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$ACR_NAME] Azure Container Registry exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled "true" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created successfully." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Create the Storage Account +echo "Checking if storage account [$STORAGE_ACCOUNT_NAME] exists in the resource group [$RESOURCE_GROUP_NAME]..." +az storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating storage account [$STORAGE_ACCOUNT_NAME]..." + az storage account create \ + --name $STORAGE_ACCOUNT_NAME \ + --location "$LOCATION" \ + --resource-group $RESOURCE_GROUP_NAME \ + --sku Standard_LRS \ + --allow-blob-public-access true \ + --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 +else + echo "Storage account [$STORAGE_ACCOUNT_NAME] already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Get the storage account key +STORAGE_ACCOUNT_KEY=$(az storage account keys list \ + --account-name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "[0].value" \ + --output tsv) + +if [ -z "$STORAGE_ACCOUNT_KEY" ]; then + echo "Failed to retrieve storage account key." + exit 1 +fi + +# Get the storage account resource ID +STORAGE_ACCOUNT_RESOURCE_ID=$(az storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "id" \ + --output tsv \ + --only-show-errors) + +if [ -n "$STORAGE_ACCOUNT_RESOURCE_ID" ]; then + echo "Storage account resource ID retrieved successfully: $STORAGE_ACCOUNT_RESOURCE_ID" +else + echo "Failed to retrieve storage account resource ID." + exit 1 +fi + +# Create the blob container +echo "Checking if blob container [$CONTAINER_NAME] exists in storage account [$STORAGE_ACCOUNT_NAME]..." +EXISTS=$(az storage container exists \ + --account-name $STORAGE_ACCOUNT_NAME \ + --account-key $STORAGE_ACCOUNT_KEY \ + --name $CONTAINER_NAME \ + --query exists \ + --output tsv 2>/dev/null) + +if [[ "$EXISTS" != "true" ]]; then + echo "Creating blob container [$CONTAINER_NAME]..." + az storage container create \ + --account-name $STORAGE_ACCOUNT_NAME \ + --account-key $STORAGE_ACCOUNT_KEY \ + --name $CONTAINER_NAME \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Blob container [$CONTAINER_NAME] created successfully." + else + echo "Failed to create blob container [$CONTAINER_NAME]." + exit 1 + fi +else + echo "Blob container [$CONTAINER_NAME] already exists." +fi + +# Check if the user-assigned managed identity already exists +echo "Checking if [$MANAGED_IDENTITY_NAME] user-assigned managed identity actually exists in the [$RESOURCE_GROUP_NAME] resource group..." + +az identity show \ + --name $MANAGED_IDENTITY_NAME \ + --resource-group $RESOURCE_GROUP_NAME &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [$MANAGED_IDENTITY_NAME] user-assigned managed identity actually exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "Creating [$MANAGED_IDENTITY_NAME] user-assigned managed identity in the [$RESOURCE_GROUP_NAME] resource group..." + + # Create the user-assigned managed identity + az identity create \ + --name $MANAGED_IDENTITY_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --location $LOCATION \ + --subscription $SUBSCRIPTION_ID 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$MANAGED_IDENTITY_NAME] user-assigned managed identity successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$MANAGED_IDENTITY_NAME] user-assigned managed identity in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "[$MANAGED_IDENTITY_NAME] user-assigned managed identity already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Retrieve the clientId of the user-assigned managed identity +echo "Retrieving clientId for [$MANAGED_IDENTITY_NAME] managed identity..." +CLIENT_ID=$(az identity show \ + --name $MANAGED_IDENTITY_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query clientId \ + --output tsv) + +if [[ -n $CLIENT_ID ]]; then + echo "[$CLIENT_ID] clientId for the [$MANAGED_IDENTITY_NAME] managed identity successfully retrieved" +else + echo "Failed to retrieve clientId for the [$MANAGED_IDENTITY_NAME] managed identity" + exit +fi + +# Retrieve the principalId of the user-assigned managed identity +echo "Retrieving principalId for [$MANAGED_IDENTITY_NAME] managed identity..." +PRINCIPAL_ID=$(az identity show \ + --name $MANAGED_IDENTITY_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query principalId \ + --output tsv) + +if [[ -n $PRINCIPAL_ID ]]; then + echo "[$PRINCIPAL_ID] principalId for the [$MANAGED_IDENTITY_NAME] managed identity successfully retrieved" +else + echo "Failed to retrieve principalId for the [$MANAGED_IDENTITY_NAME] managed identity" + exit +fi + +# Assign the Storage Blob Data Contributor role to the managed identity with the storage account as scope +ROLE="Storage Blob Data Contributor" +SCOPE_ID="$STORAGE_ACCOUNT_RESOURCE_ID" +SCOPE_NAME="$STORAGE_ACCOUNT_NAME" +SCOPE_TYPE="storage account" +echo "Checking if the [$MANAGED_IDENTITY_NAME] managed identity has the [$ROLE] role assignment on the [$SCOPE_NAME] $SCOPE_TYPE..." +current=$(az role assignment list \ + --assignee "$PRINCIPAL_ID" \ + --scope "$SCOPE_ID" \ + --query "[?roleDefinitionName=='$ROLE'].roleDefinitionName" \ + --output tsv 2>/dev/null) + +if [[ $current == "$ROLE" ]]; then + echo "Managed identity [$MANAGED_IDENTITY_NAME] already has the [$ROLE] role assignment on the [$SCOPE_NAME] $SCOPE_TYPE" +else + echo "Managed identity [$MANAGED_IDENTITY_NAME] does not have the [$ROLE] role assignment on the [$SCOPE_NAME] $SCOPE_TYPE" + echo "Creating role assignment: assigning [$ROLE] role to managed identity [$MANAGED_IDENTITY_NAME] on the [$SCOPE_NAME] $SCOPE_TYPE..." + ATTEMPT=1 + RETRY_COUNT=5 + SLEEP=3 + while [ $ATTEMPT -le $RETRY_COUNT ]; do + echo "Attempt $ATTEMPT of $RETRY_COUNT to assign role..." + az role assignment create \ + --assignee "$PRINCIPAL_ID" \ + --role "$ROLE" \ + --scope "$SCOPE_ID" 1>/dev/null + + if [[ $? == 0 ]]; then + break + else + if [ $ATTEMPT -lt $RETRY_COUNT ]; then + echo "Role assignment failed. Waiting [$SLEEP] seconds before retry..." + sleep $SLEEP + fi + ATTEMPT=$((ATTEMPT + 1)) + fi + done + + if [[ $? == 0 ]]; then + echo "Successfully assigned [$ROLE] role to managed identity [$MANAGED_IDENTITY_NAME] on the [$SCOPE_NAME] $SCOPE_TYPE" + else + echo "Failed to assign [$ROLE] role to managed identity [$MANAGED_IDENTITY_NAME] on the [$SCOPE_NAME] $SCOPE_TYPE" + exit 1 + fi +fi \ No newline at end of file diff --git a/samples/web-app-managed-identity/scripts/02-build-docker-image.sh b/samples/web-app-managed-identity/python/scripts/02-build-docker-image.sh similarity index 100% rename from samples/web-app-managed-identity/scripts/02-build-docker-image.sh rename to samples/web-app-managed-identity/python/scripts/02-build-docker-image.sh diff --git a/samples/web-app-managed-identity/python/scripts/03-run-docker-container.sh b/samples/web-app-managed-identity/python/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..36aae71 --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/03-run-docker-container.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Retrieve the storage account connection string +echo "Retrieving storage account connection string for [$STORAGE_ACCOUNT_NAME]..." +AZURE_STORAGE_ACCOUNT_CONNECTION_STRING=$(az storage account show-connection-string \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "connectionString" \ + --output tsv \ + --only-show-errors) + +if [ -n "$AZURE_STORAGE_ACCOUNT_CONNECTION_STRING" ]; then + echo "Storage account connection string retrieved successfully." +else + echo "Failed to retrieve storage account connection string." + exit 1 +fi + +# --network=host so endpoints like *.localhost.localstack.cloud resolve to the +# host's loopback (where LocalStack is listening), not the container's. +docker run -it \ + --rm \ + --network=host \ + -e PORT=$PORT \ + -e AZURE_STORAGE_ACCOUNT_CONNECTION_STRING="$AZURE_STORAGE_ACCOUNT_CONNECTION_STRING" \ + -e CONTAINER_NAME="$CONTAINER_NAME" \ + --name "$IMAGE_NAME" \ + "$IMAGE_NAME:$IMAGE_TAG" diff --git a/samples/web-app-managed-identity/python/scripts/04-push-docker-image.sh b/samples/web-app-managed-identity/python/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/04-push-docker-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Login to ACR +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name $ACR_NAME + +# Retrieve ACR login server. Each container image needs to be tagged with the loginServer name of the registry. +ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --query loginServer --output tsv) + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Tag the local image with the loginServer of ACR +docker tag ${IMAGE_NAME,,}:$IMAGE_TAG $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$IMAGE_NAME] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$IMAGE_NAME] as [$FULL_IMAGE]." + exit 1 +fi + +# Push the container image to ACR +docker push $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + exit 1 +fi diff --git a/samples/web-app-managed-identity/scripts/05-deploy-app.sh b/samples/web-app-managed-identity/python/scripts/05-deploy-app.sh similarity index 83% rename from samples/web-app-managed-identity/scripts/05-deploy-app.sh rename to samples/web-app-managed-identity/python/scripts/05-deploy-app.sh index 0bc1f70..7ad88a3 100755 --- a/samples/web-app-managed-identity/scripts/05-deploy-app.sh +++ b/samples/web-app-managed-identity/python/scripts/05-deploy-app.sh @@ -4,7 +4,14 @@ source ./00-variables.sh # Generate a stable Flask SECRET_KEY (sessions survive pod restarts) -SECRET_KEY=$(openssl rand -hex 32) +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +SECRET_KEY=$(kubectl get secret $SECRET_NAME --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $SECRET_KEY ]]; then + SECRET_KEY=$(openssl rand -hex 32) +fi # Optional client secret for the ClientSecretCredential auth path. # Leave empty when using Microsoft Entra Workload ID (the recommended option). @@ -91,15 +98,33 @@ echo "Service Account YAML manifest" echo "-----------------------------" kubectl get sa $SERVICE_ACCOUNT_NAME -n $NAMESPACE -o yaml -# Check if the federated identity credential already exists +# Check whether the federated identity credential already exists and still points at this namespace and +# service account: both are part of its subject, so a credential left behind by a deployment in another +# namespace (or with another service account) has to be recreated, or the token exchange fails and every +# pod crashes at startup with an authentication error. echo "Checking if [$FEDERATED_IDENTITY_NAME] federated identity credential actually exists in the [$RESOURCE_GROUP_NAME] resource group..." -az identity federated-credential show \ +EXPECTED_SUBJECT="system:serviceaccount:$NAMESPACE:$SERVICE_ACCOUNT_NAME" +CURRENT_SUBJECT="$(az identity federated-credential show \ --name $FEDERATED_IDENTITY_NAME \ --resource-group $RESOURCE_GROUP_NAME \ - --identity-name $MANAGED_IDENTITY_NAME &>/dev/null + --identity-name $MANAGED_IDENTITY_NAME \ + --query subject \ + --output tsv 2>/dev/null)" + +if [[ -n $CURRENT_SUBJECT && $CURRENT_SUBJECT != $EXPECTED_SUBJECT ]]; then + echo "[$FEDERATED_IDENTITY_NAME] federated identity credential points at [$CURRENT_SUBJECT] instead of [$EXPECTED_SUBJECT]: deleting it" + + az identity federated-credential delete \ + --name $FEDERATED_IDENTITY_NAME \ + --identity-name $MANAGED_IDENTITY_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --yes 1>/dev/null -if [[ $? != 0 ]]; then + CURRENT_SUBJECT="" +fi + +if [[ -z $CURRENT_SUBJECT ]]; then echo "No [$FEDERATED_IDENTITY_NAME] federated identity credential actually exists in the [$RESOURCE_GROUP_NAME] resource group" # Get the OIDC Issuer URL @@ -123,7 +148,7 @@ if [[ $? != 0 ]]; then --identity-name $MANAGED_IDENTITY_NAME \ --resource-group $RESOURCE_GROUP_NAME \ --issuer $OIDC_ISSUER_URL \ - --subject system:serviceaccount:$NAMESPACE:$SERVICE_ACCOUNT_NAME 1>/dev/null + --subject $EXPECTED_SUBJECT 1>/dev/null if [[ $? == 0 ]]; then echo "[$FEDERATED_IDENTITY_NAME] federated identity credential successfully created in the [$RESOURCE_GROUP_NAME] resource group" @@ -132,7 +157,7 @@ if [[ $? != 0 ]]; then exit fi else - echo "[$FEDERATED_IDENTITY_NAME] federated identity credential already exists in the [$RESOURCE_GROUP_NAME] resource group" + echo "[$FEDERATED_IDENTITY_NAME] federated identity credential already exists in the [$RESOURCE_GROUP_NAME] resource group and points at [$EXPECTED_SUBJECT]" fi # Create secret with the storage connection string, client secret and Flask secret key @@ -168,6 +193,7 @@ kubectl apply -f - if [[ $DEPLOY_GATEWAY == "true" ]]; then # Create Issuer for Gateway API HTTP-01 solver (before gateway to avoid race condition) cat issuer.yml | + yq "(.spec.acme.email)|=\"$ACME_EMAIL\"" | yq "(.spec.acme.solvers[0].http01.gatewayHTTPRoute.parentRefs[0].name)|=\"$NAME\"" | yq "(.spec.acme.solvers[0].http01.gatewayHTTPRoute.parentRefs[0].namespace)|=\"$NAMESPACE\"" | kubectl apply -f - @@ -187,6 +213,10 @@ cat service.yml | yq "(.metadata.namespace)|="\""$NAMESPACE"\" | kubectl apply -f - +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE + if [[ $DEPLOY_GATEWAY == "true" ]]; then # Create gateway cat gateway.yml | diff --git a/samples/web-app-managed-identity/scripts/Dockerfile b/samples/web-app-managed-identity/python/scripts/Dockerfile similarity index 100% rename from samples/web-app-managed-identity/scripts/Dockerfile rename to samples/web-app-managed-identity/python/scripts/Dockerfile diff --git a/samples/web-app-managed-identity/python/scripts/configmap.yml b/samples/web-app-managed-identity/python/scripts/configmap.yml new file mode 100644 index 0000000..4ba042e --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/configmap.yml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-identity-config + namespace: vacation-planner-identity + labels: + app: vacation-planner-identity +data: + CONTAINER_NAME: "activities" + AZURE_CLIENT_ID: "" + AZURE_TENANT_ID: "" + AZURE_STORAGE_ACCOUNT_URL: "" diff --git a/samples/web-app-managed-identity/scripts/deployment.yml b/samples/web-app-managed-identity/python/scripts/deployment.yml similarity index 73% rename from samples/web-app-managed-identity/scripts/deployment.yml rename to samples/web-app-managed-identity/python/scripts/deployment.yml index ec50219..cfd13a4 100644 --- a/samples/web-app-managed-identity/scripts/deployment.yml +++ b/samples/web-app-managed-identity/python/scripts/deployment.yml @@ -1,15 +1,15 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: vacation-planner-blob - namespace: vacation-planner-blob + name: vacation-planner-identity + namespace: vacation-planner-identity labels: - app: vacation-planner-blob + app: vacation-planner-identity spec: replicas: 3 selector: matchLabels: - app: vacation-planner-blob + app: vacation-planner-identity strategy: rollingUpdate: maxSurge: 1 @@ -19,28 +19,28 @@ spec: template: metadata: labels: - app: vacation-planner-blob + app: vacation-planner-identity azure.workload.identity/use: "true" spec: - serviceAccountName: vacation-planner-blob-sa + serviceAccountName: vacation-planner-identity-sa topologySpreadConstraints: - maxSkew: 1 topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: - app: vacation-planner-blob + app: vacation-planner-identity - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: - app: vacation-planner-blob + app: vacation-planner-identity nodeSelector: kubernetes.io/os: linux containers: - - name: vacation-planner-blob - image: .azurecr.io/vacation-planner-blob:v1 + - name: vacation-planner-identity + image: .azurecr.io/vacation-planner-identity:v1 imagePullPolicy: Always ports: - name: http @@ -49,17 +49,17 @@ spec: - name: CONTAINER_NAME valueFrom: configMapKeyRef: - name: vacation-planner-blob-config + name: vacation-planner-identity-config key: CONTAINER_NAME - name: AZURE_STORAGE_ACCOUNT_URL valueFrom: configMapKeyRef: - name: vacation-planner-blob-config + name: vacation-planner-identity-config key: AZURE_STORAGE_ACCOUNT_URL - name: SECRET_KEY valueFrom: secretKeyRef: - name: vacation-planner-blob-secrets + name: vacation-planner-identity-secrets key: SECRET_KEY resources: requests: @@ -70,7 +70,7 @@ spec: memory: "256Mi" livenessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 15 periodSeconds: 30 @@ -78,7 +78,7 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 5 periodSeconds: 10 diff --git a/samples/web-app-managed-identity/python/scripts/gateway.yml b/samples/web-app-managed-identity/python/scripts/gateway.yml new file mode 100644 index 0000000..58a7edc --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/gateway.yml @@ -0,0 +1,30 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-1.1.0 + cert-manager.io/issuer: vacation-planner-identity-issuer + name: vacation-planner-identity +spec: + gatewayClassName: nginx + listeners: + - hostname: planner.local.babosbird.com + name: http + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: All + - hostname: planner.local.babosbird.com + name: https + port: 443 + protocol: HTTPS + allowedRoutes: + namespaces: + from: All + tls: + mode: Terminate + certificateRefs: + - group: "" + kind: Secret + name: vacation-planner-identity-tls-secret \ No newline at end of file diff --git a/samples/web-app-managed-identity/python/scripts/httproute.yml b/samples/web-app-managed-identity/python/scripts/httproute.yml new file mode 100644 index 0000000..8792ed5 --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/httproute.yml @@ -0,0 +1,21 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-1.1.0 + name: vacation-planner-identity +spec: + hostnames: + - planner.local.babosbird.com + parentRefs: + - name: vacation-planner-identity + port: 443 + sectionName: https + rules: + - backendRefs: + - name: vacation-planner-identity + port: 80 + matches: + - path: + type: PathPrefix + value: / \ No newline at end of file diff --git a/samples/web-app-managed-identity/python/scripts/issuer.yml b/samples/web-app-managed-identity/python/scripts/issuer.yml new file mode 100644 index 0000000..9868db8 --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/issuer.yml @@ -0,0 +1,26 @@ +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: vacation-planner-identity-issuer + namespace: vacation-planner-identity +spec: + acme: + # Registration address for the Let's Encrypt account: expiry notices go here. + # 05-deploy-app.sh replaces this with $ACME_EMAIL from 00-variables.sh. + email: admin@example.com + privateKeySecretRef: + name: vacation-planner-identity-issuer + server: https://acme-v02.api.letsencrypt.org/directory + solvers: + - http01: + gatewayHTTPRoute: + parentRefs: + - name: vacation-planner-identity + namespace: vacation-planner-identity + kind: Gateway + group: gateway.networking.k8s.io + sectionName: http + podTemplate: + spec: + nodeSelector: + kubernetes.io/os: linux diff --git a/samples/web-app-managed-identity/python/scripts/namespace.yml b/samples/web-app-managed-identity/python/scripts/namespace.yml new file mode 100644 index 0000000..8c604ef --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-identity diff --git a/samples/web-app-managed-identity/python/scripts/secret.yml b/samples/web-app-managed-identity/python/scripts/secret.yml new file mode 100644 index 0000000..9c94609 --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/secret.yml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-identity-secrets + namespace: vacation-planner-identity + labels: + app: vacation-planner-identity +type: Opaque +data: + AZURE_STORAGE_ACCOUNT_CONNECTION_STRING: "" + AZURE_CLIENT_SECRET: "" + SECRET_KEY: "" diff --git a/samples/web-app-managed-identity/python/scripts/service.yml b/samples/web-app-managed-identity/python/scripts/service.yml new file mode 100644 index 0000000..c5f4783 --- /dev/null +++ b/samples/web-app-managed-identity/python/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-identity + namespace: vacation-planner-identity + labels: + app: vacation-planner-identity +spec: + type: ClusterIP + selector: + app: vacation-planner-identity + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-blob-storage/src/app.py b/samples/web-app-managed-identity/python/src/app.py similarity index 81% rename from samples/web-app-blob-storage/src/app.py rename to samples/web-app-managed-identity/python/src/app.py index de56959..93f11f5 100644 --- a/samples/web-app-blob-storage/src/app.py +++ b/samples/web-app-managed-identity/python/src/app.py @@ -5,7 +5,7 @@ from azure.identity import DefaultAzureCredential, ClientSecretCredential from azure.storage.blob import BlobServiceClient from azure.core.exceptions import ResourceExistsError -from flask import Flask, flash, render_template, request, redirect, url_for +from flask import Flask, flash, jsonify, render_template, request, redirect, url_for # Initialize Flask application app: Flask = Flask(__name__) @@ -23,6 +23,18 @@ debug: bool = os.environ.get("FLASK_DEBUG", "false").lower() == "true" activities: List[Tuple[str, str]] = [] +# Suffix of the blobs holding the activities, one blob per activity. +ACTIVITY_BLOB_SUFFIX: str = "-activity.txt" + +def is_activity_name(name: str | None) -> bool: + """Whether the name is one of this app's activity blobs, and nothing else. + + Every activity is a blob called YYYY-MM-DD-HH-MM-SS-activity.txt, so requiring that shape is both the + read filter and the write guard: it rejects an empty name and any name the app did not create. Names + arrive from a form field, so they are checked before they reach the container. + """ + return bool(name) and name == os.path.basename(str(name)) and str(name).endswith(ACTIVITY_BLOB_SUFFIX) + def get_environment_variables(): """Get the value of an environment variable or raise an error if not set.""" global connection_string, container_name, client_id, client_secret, tenant_id, account_url @@ -226,7 +238,11 @@ def index(): activity = request.form.get('activity', '').strip() if activity: if row_id: - # Update existing blob content in place + # Update the existing blob content in place. The name comes from the form, so it is checked + # before it reaches the container. + if not is_activity_name(row_id): + print(f"Invalid activity name '{row_id}'.") + return redirect(url_for('index')) update_blob(row_id, activity) for i, act in enumerate(activities): if act[0] == row_id: @@ -236,7 +252,7 @@ def index(): else: # Generate a unique blob name with a timestamp timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S") - name = f"{timestamp}-activity.txt" + name = f"{timestamp}{ACTIVITY_BLOB_SUFFIX}" create_blob_if_not_exists(name, activity) activities.append((name, activity)) flash('Activity added successfully.') @@ -247,14 +263,39 @@ def index(): read_blobs_from_container() return render_template('index.html', activities=activities) -@app.route('/delete/', methods=['POST']) -def delete(activity_id): - if 0 <= activity_id < len(activities): - delete_blob(activities[activity_id][0]) - activities.pop(activity_id) - flash('Activity deleted successfully.') +@app.route('/delete/', methods=['POST']) +def delete(activity_id: str): + """Delete the activity whose blob name is activity_id. + + The activity is identified by its blob name, never by its position in the rendered page: every replica + reloads the container on each GET, so the list can change between rendering a page and submitting a + delete from it, and a position would then delete whatever activity happens to sit there now. + """ + if not is_activity_name(activity_id): + print(f"Invalid activity name '{activity_id}'.") + return redirect(url_for('index')) + + delete_blob(activity_id) + for i, act in enumerate(activities): + if act[0] == activity_id: + activities.pop(i) + break + flash('Activity deleted successfully.') return redirect(url_for('index')) +@app.route('/health') +def health(): + """Liveness and readiness probe: reports whether the blob container is reachable.""" + try: + if not blob_service_client or not container_name: + raise ValueError("BlobServiceClient is not initialized.") + if not blob_service_client.get_container_client(container_name).exists(): + raise ValueError(f"Container '{container_name}' does not exist.") + return jsonify({"status": "ok"}) + except Exception as ex: + print(f"Health check failed: {ex}") + return jsonify({"status": "unavailable"}), 503 + # Initialize the application and Azure services when the module is loaded. # This ensures that the setup runs regardless of how the app is started (e.g., via 'flask run' or directly). get_environment_variables() diff --git a/samples/web-app-managed-identity/src/gunicorn.conf.py b/samples/web-app-managed-identity/python/src/gunicorn.conf.py similarity index 100% rename from samples/web-app-managed-identity/src/gunicorn.conf.py rename to samples/web-app-managed-identity/python/src/gunicorn.conf.py diff --git a/samples/web-app-managed-identity/src/requirements.txt b/samples/web-app-managed-identity/python/src/requirements.txt similarity index 100% rename from samples/web-app-managed-identity/src/requirements.txt rename to samples/web-app-managed-identity/python/src/requirements.txt diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.min.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.min.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.min.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.min.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.min.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.min.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.min.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.min.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.rtl.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.rtl.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.rtl.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.rtl.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.rtl.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.rtl.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.rtl.min.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.rtl.min.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.min.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.min.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.min.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.min.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.min.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.min.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.rtl.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.rtl.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.min.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.min.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.min.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.min.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.min.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.min.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.rtl.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.rtl.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.min.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.min.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.min.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.min.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.min.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.min.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.min.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.min.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.rtl.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.rtl.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.rtl.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.rtl.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.rtl.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.rtl.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.rtl.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.rtl.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.rtl.min.css b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.rtl.min.css similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.rtl.min.css rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.rtl.min.css diff --git a/samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.rtl.min.css.map b/samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/css/bootstrap.rtl.min.css.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.bundle.js b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.bundle.js rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.bundle.js diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.bundle.js.map b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.bundle.js.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.bundle.js.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.bundle.js.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.bundle.min.js b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.bundle.min.js rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.bundle.min.js diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.bundle.min.js.map b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.bundle.min.js.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.esm.js b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.esm.js similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.esm.js rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.esm.js diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.esm.js.map b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.esm.js.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.esm.js.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.esm.js.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.esm.min.js b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.esm.min.js similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.esm.min.js rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.esm.min.js diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.esm.min.js.map b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.esm.min.js.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.esm.min.js.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.esm.min.js.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.js b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.js similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.js rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.js diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.js.map b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.js.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.js.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.js.map diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.min.js b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.min.js similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.min.js rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.min.js diff --git a/samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.min.js.map b/samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.min.js.map similarity index 100% rename from samples/web-app-managed-identity/src/static/bootstrap/js/bootstrap.min.js.map rename to samples/web-app-managed-identity/python/src/static/bootstrap/js/bootstrap.min.js.map diff --git a/samples/web-app-managed-identity/python/src/static/favicon.ico b/samples/web-app-managed-identity/python/src/static/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-managed-identity/python/src/static/favicon.ico differ diff --git a/samples/web-app-managed-identity/python/src/static/style.css b/samples/web-app-managed-identity/python/src/static/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-managed-identity/python/src/static/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-managed-identity/src/templates/index.html b/samples/web-app-managed-identity/python/src/templates/index.html similarity index 99% rename from samples/web-app-managed-identity/src/templates/index.html rename to samples/web-app-managed-identity/python/src/templates/index.html index 45a7be8..efb642d 100644 --- a/samples/web-app-managed-identity/src/templates/index.html +++ b/samples/web-app-managed-identity/python/src/templates/index.html @@ -70,7 +70,7 @@

🌴 Vacation Planner

-
+ + + + + + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + + + + +
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..4a8e59c --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + if (await store.UpdateAsync(id, text, cancellationToken)) + { + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated!"; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added!"; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Program.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Program.cs new file mode 100644 index 0000000..04e5caf --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Program.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Listen on PORT (8080 by default), the way the Python image binds gunicorn to 0.0.0.0:${PORT}. HTTP_PORTS is the +// same setting the aspnet base image feeds through ASPNETCORE_HTTP_PORTS, so no URL override is involved. +if (Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseSetting(WebHostDefaults.HttpPortsKey, port); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storeOptions = MySqlOptions.FromEnvironment(); + +// SECRET_KEY is the Kubernetes Secret the Python sample signs its session cookie with. Deriving the Data Protection +// key ring from it lets all replicas validate each other's antiforgery tokens and flash cookies; without it (a local +// docker run) each process keeps its own keys. +var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY"); +if (!string.IsNullOrEmpty(secretKey)) +{ + builder.Services.AddDataProtection().DisableAutomaticKeyGeneration(); + builder.Services.Configure(options => options.XmlRepository = new SecretKeyXmlRepository(secretKey)); +} + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new MySqlActivityStore(storeOptions, sp.GetRequiredService>())); +// The Python sample waits up to 30 x 2 s for the database at startup; the same values apply here. +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>(), + attempts: 30, delay: TimeSpan.FromSeconds(2))); + +var app = builder.Build(); + +if (string.IsNullOrEmpty(secretKey)) +{ + app.Logger.LogWarning("SECRET_KEY is not set: antiforgery tokens and flash messages are only valid on this replica."); +} + +// One log line per request, the equivalent of the access log the Python image produces (its gunicorn +// command passes --access-logfile -). Kubernetes probes show up here too, exactly as they do for Python. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/ActivityId.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/ActivityId.cs new file mode 100644 index 0000000..8654aaf --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/ActivityId.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VacationPlanner.Services; + +/// MD5 of username + activity + timestamp: the id scheme shared by the Vacation Planner samples. +public static class ActivityId +{ + public static string Create(string username, string activity) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"); + var hash = MD5.HashData(Encoding.UTF8.GetBytes($"{username}_{activity}_{timestamp}")); + return Convert.ToHexStringLower(hash); + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/IActivityStore.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..167993e --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,24 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection, directory) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Adds an activity and returns whether the store confirmed the write; the page flashes only then. + Task AddAsync(string text, CancellationToken cancellationToken); + + /// Updates an activity and returns whether the store reported a change, with the meaning the Python sample's driver gives it. + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + /// Deletes an activity by its store id and returns whether the store reported a deletion. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlActivityStore.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlActivityStore.cs new file mode 100644 index 0000000..e94ef4c --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlActivityStore.cs @@ -0,0 +1,131 @@ +using MySqlConnector; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// +/// Activities in a MySQL activities table. Like the Python sample, the store is low-throughput +/// and opens a fresh connection per call instead of managing a pool explicitly. +/// +public sealed class MySqlActivityStore(MySqlOptions options, ILogger logger) : IActivityStore +{ + // Single statement on purpose: MySQL has no CREATE INDEX IF NOT EXISTS, so the indexes are declared + // inline and the whole DDL stays idempotent. `id` is VARCHAR(32) because the ids are MD5 hex digests. + private const string SchemaDdl = """ + CREATE TABLE IF NOT EXISTS activities ( + id VARCHAR(32) NOT NULL, + username VARCHAR(255) NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_activities_username (username), + INDEX idx_activities_created_at (created_at DESC) + ) + """; + + // Azure MySQL Flexible Server defaults to require_secure_transport=ON (the LocalStack emulator mirrors + // this), so the connection must use TLS or the server rejects it. The server certificate is publicly + // trusted on Azure but self-signed under LocalStack, so TLS is enabled without certificate verification + // (MySqlSslMode.Required) and the same code path works against both targets. MYSQL_SSL=false disables it. + // UseAffectedRows makes UPDATE report changed rows rather than matched rows, as PyMySQL does by default. + private readonly string _connectionString = new MySqlConnectionStringBuilder + { + Server = options.Host, + Port = (uint)options.Port, + UserID = options.User, + Password = options.Password, + Database = options.Database, + CharacterSet = "utf8mb4", + ConnectionTimeout = 10, + SslMode = options.SslEnabled ? MySqlSslMode.Required : MySqlSslMode.Disabled, + UseAffectedRows = true, + }.ConnectionString; + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand(SchemaDdl, connection); + await command.ExecuteNonQueryAsync(cancellationToken); + logger.LogInformation("MySQL schema initialized"); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand( + "SELECT id, activity FROM activities WHERE username = @username ORDER BY created_at DESC", connection); + command.Parameters.AddWithValue("@username", options.Username); + + var activities = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + activities.Add(new Activity(reader.GetString(0), reader.GetString(1))); + } + + logger.LogInformation( + "Retrieved {Count} activities for user: {Username}", + activities.Count, + options.Username + ); + return activities; + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand( + "INSERT IGNORE INTO activities (id, username, activity) VALUES (@id, @username, @activity)", connection); + command.Parameters.AddWithValue("@id", ActivityId.Create(options.Username, text)); + command.Parameters.AddWithValue("@username", options.Username); + command.Parameters.AddWithValue("@activity", text); + await command.ExecuteNonQueryAsync(cancellationToken); + return true; + } + + /// Reports a change only when a row actually changed, as the Python sample's PyMySQL rowcount check does. + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand("UPDATE activities SET activity = @activity WHERE id = @id", connection); + command.Parameters.AddWithValue("@activity", text); + command.Parameters.AddWithValue("@id", id); + return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand("DELETE FROM activities WHERE id = @id", connection); + command.Parameters.AddWithValue("@id", id); + if (await command.ExecuteNonQueryAsync(cancellationToken) == 0) + { + logger.LogInformation("Activity '{Id}' did not exist: already deleted.", id); + } + + return true; + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new MySqlCommand("SELECT 1", connection); + await command.ExecuteScalarAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "MySQL health check failed"); + return false; + } + } + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new MySqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlOptions.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlOptions.cs new file mode 100644 index 0000000..355d924 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlOptions.cs @@ -0,0 +1,30 @@ +namespace VacationPlanner.Services; + +/// Connection settings read from the same environment variables the Python sample uses. +public sealed record MySqlOptions(string Host, int Port, string User, string Password, string Database, bool SslEnabled, string Username) +{ + public static MySqlOptions FromEnvironment() + { + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "paolo"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("LOGIN_NAME cannot be empty"); + } + + var ssl = (Environment.GetEnvironmentVariable("MYSQL_SSL") ?? "true").ToLowerInvariant(); + return new MySqlOptions( + Host: Require("MYSQL_HOST"), + Port: int.Parse(Environment.GetEnvironmentVariable("MYSQL_PORT") ?? "3306"), + User: Require("MYSQL_USER"), + Password: Require("MYSQL_PASSWORD"), + Database: Environment.GetEnvironmentVariable("MYSQL_DATABASE") ?? "sampledb", + SslEnabled: ssl is "true" or "1" or "yes", + Username: username); + } + + private static string Require(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException( + $"Missing required environment variable: {name}. Set MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD (and optionally MYSQL_PORT, MYSQL_DATABASE, MYSQL_SSL)."); +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/SecretKeyXmlRepository.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/SecretKeyXmlRepository.cs new file mode 100644 index 0000000..e0cc5bb --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/SecretKeyXmlRepository.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel; +using Microsoft.AspNetCore.DataProtection.Repositories; + +namespace VacationPlanner.Services; + +/// +/// A Data Protection key ring derived deterministically from SECRET_KEY, the Kubernetes Secret the Python +/// sample signs its Flask session cookie with. ASP.NET Core protects its antiforgery tokens and TempData (flash) +/// cookies with Data Protection instead of a signing key; deriving the one key of the ring from the same secret +/// lets every replica of the Deployment validate what another replica issued, which the default per-process key +/// ring cannot offer behind a load balancer. +/// +public sealed class SecretKeyXmlRepository : IXmlRepository +{ + private readonly XElement _key; + + public SecretKeyXmlRepository(string secretKey) + { + var keyMaterial = Encoding.UTF8.GetBytes(secretKey); + + // A 512-bit master key (the size Data Protection generates itself) and a stable key id, both from SECRET_KEY. + var masterKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 64, info: "VacationPlanner.DataProtection.MasterKey"u8.ToArray()); + var keyId = new Guid(HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 16, info: "VacationPlanner.DataProtection.KeyId"u8.ToArray())); + + // AES-256-CBC + HMACSHA256, the default algorithms, serialized the way the key manager itself serializes a new key. + var descriptor = new AuthenticatedEncryptorDescriptor(new AuthenticatedEncryptorConfiguration(), new Secret(masterKey)); + var serialized = descriptor.ExportToXml(); + + _key = new XElement("key", + new XAttribute("id", keyId), + new XAttribute("version", 1), + new XElement("creationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("activationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("expirationDate", new DateTimeOffset(2999, 12, 31, 0, 0, 0, TimeSpan.Zero)), + new XElement("descriptor", + new XAttribute("deserializerType", serialized.DeserializerType.AssemblyQualifiedName!), + serialized.SerializedDescriptorElement)); + } + + public IReadOnlyCollection GetAllElements() => [new XElement(_key)]; + + /// Never called: automatic key generation is disabled, so the key manager has nothing to persist. + public void StoreElement(XElement element, string friendlyName) => + throw new NotSupportedException("The key ring is derived from SECRET_KEY and cannot be modified."); +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/VacationPlanner.csproj b/samples/web-app-mysql-flexible-server/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..5034f28 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/appsettings.json b/samples/web-app-mysql-flexible-server/dotnet/src/appsettings.json new file mode 100644 index 0000000..6e5c6b2 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss " + } + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/favicon.ico b/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/style.css b/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-mysql-flexible-server/README.md b/samples/web-app-mysql-flexible-server/python/README.md similarity index 76% rename from samples/web-app-mysql-flexible-server/README.md rename to samples/web-app-mysql-flexible-server/python/README.md index faf8cca..5c7c527 100644 --- a/samples/web-app-mysql-flexible-server/README.md +++ b/samples/web-app-mysql-flexible-server/python/README.md @@ -1,19 +1,21 @@ # Vacation Planner: Azure Database for MySQL flexible server +> A .NET version of this sample lives in [../dotnet](../dotnet/README.md). + This sample demonstrates a Python Flask 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` table of the `plannerdb` database on an [Azure Database for MySQL flexible server](https://learn.microsoft.com/en-us/azure/mysql/flexible-server/overview). The application connects to MySQL using a dedicated application user (rather than the server admin) over TLS, and the deployment scripts seed the `activities` table with a handful of sample plans so the app shows data on first load. 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. +- [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. The `01-deploy-resources.sh` script needs the `mysql` client installed on the host (for example `sudo apt install -y mysql-client`) to bootstrap the application user, schema, and seed data. 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. +> **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 @@ -48,7 +50,7 @@ cd scripts | [`namespace.yml`](scripts/namespace.yml) | Creates the Kubernetes namespace. | | [`configmap.yml`](scripts/configmap.yml) | Creates the ConfigMap holding non-secret input values (MySQL host, port, database, user, TLS flag, login name) passed to the app as environment variables. | | [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the MySQL password and the Flask secret key) passed to the app as environment variables. | -| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification for the web app. | +| [`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 @@ -60,3 +62,17 @@ kubectl port-forward service/vacation-planner-mysql 8080:80 -n vacation-planner- ``` 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 MySQL flexible server is reachable and `503` with `{"status": "unavailable"}` otherwise. + +```bash +curl http://localhost:8080/health +``` + +## Logs + +The app logs one line per request — gunicorn writes an access log line for every call, the probes included, because its command passes `--access-logfile -` — plus one line per database read and write and one line for every activity added, updated or deleted. Every entry carries a timestamp, the logger name and the level; the Azure SDK and `urllib3` stay at warning level. The [.NET version](../dotnet/README.md) writes the same trace. + +```bash +kubectl logs deployment/vacation-planner-mysql -n vacation-planner-mysql --tail=50 +``` diff --git a/samples/web-app-mysql-flexible-server/python/images/architecture.png b/samples/web-app-mysql-flexible-server/python/images/architecture.png new file mode 100644 index 0000000..432dd12 Binary files /dev/null and b/samples/web-app-mysql-flexible-server/python/images/architecture.png differ diff --git a/samples/web-app-mysql-flexible-server/scripts/00-variables.sh b/samples/web-app-mysql-flexible-server/python/scripts/00-variables.sh similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/00-variables.sh rename to samples/web-app-mysql-flexible-server/python/scripts/00-variables.sh diff --git a/samples/web-app-mysql-flexible-server/python/scripts/01-deploy-resources.sh b/samples/web-app-mysql-flexible-server/python/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..ca56172 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/python/scripts/01-deploy-resources.sh @@ -0,0 +1,273 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Create a resource group +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 "Creating resource group [$RESOURCE_GROUP_NAME]..." + az group create \ + --name $RESOURCE_GROUP_NAME \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created." + else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists." +fi + +# Create the Azure Container Registry +echo "Checking if [$ACR_NAME] Azure Container Registry exists..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled "true" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists." +fi + +# Create the Azure Database for MySQL flexible server +echo "Checking if MySQL flexible server [$MYSQL_SERVER_NAME] exists..." +az mysql flexible-server show \ + --name "$MYSQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating MySQL flexible server [$MYSQL_SERVER_NAME]..." + az mysql flexible-server create \ + --name "$MYSQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --tier "$MYSQL_SKU_TIER" \ + --sku-name "$MYSQL_SKU_NAME" \ + --version "$MYSQL_VERSION" \ + --storage-size "$MYSQL_STORAGE_SIZE_GB" \ + --backup-retention "$MYSQL_BACKUP_RETENTION_DAYS" \ + --geo-redundant-backup Disabled \ + --admin-user "$MYSQL_ADMIN_USER" \ + --admin-password "$MYSQL_ADMIN_PASSWORD" \ + --public-access Enabled \ + --high-availability Disabled \ + --yes \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "MySQL flexible server [$MYSQL_SERVER_NAME] created." + else + echo "Failed to create MySQL flexible server [$MYSQL_SERVER_NAME]." + exit 1 + fi +else + echo "MySQL flexible server [$MYSQL_SERVER_NAME] already exists." +fi + +# Add a permissive firewall rule (dev/test only) +echo "Ensuring firewall rule [$FIREWALL_RULE_NAME] exists on MySQL flexible server [$MYSQL_SERVER_NAME]..." +az mysql flexible-server firewall-rule create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$MYSQL_SERVER_NAME" \ + --rule-name "$FIREWALL_RULE_NAME" \ + --start-ip-address 0.0.0.0 \ + --end-ip-address 255.255.255.255 \ + --only-show-errors 1>/dev/null + +# Create the MySQL database +echo "Checking if MySQL database [$MYSQL_DATABASE_NAME] exists..." +az mysql flexible-server db show \ + --database-name "$MYSQL_DATABASE_NAME" \ + --server-name "$MYSQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating MySQL database [$MYSQL_DATABASE_NAME]..." + az mysql flexible-server db create \ + --database-name "$MYSQL_DATABASE_NAME" \ + --server-name "$MYSQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --charset utf8mb4 \ + --collation utf8mb4_unicode_ci \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "MySQL database [$MYSQL_DATABASE_NAME] created." + else + echo "Failed to create MySQL database [$MYSQL_DATABASE_NAME]." + exit 1 + fi +else + echo "MySQL database [$MYSQL_DATABASE_NAME] already exists." +fi + +# Retrieve MySQL server FQDN +MYSQL_FQDN_FULL=$(az mysql flexible-server show \ + --name "$MYSQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$MYSQL_FQDN_FULL" ]; then + echo "Failed to retrieve MySQL server FQDN." + exit 1 +fi + +# Split host:port — the LocalStack emulator embeds the dynamically allocated TCP-proxy port +# directly in fullyQualifiedDomainName, mirroring the storage / container registry emulators. +# Real Azure returns just the bare host so MYSQL_PORT stays at the value from 00-variables.sh (3306). +MYSQL_FQDN="${MYSQL_FQDN_FULL%%:*}" +if [[ "$MYSQL_FQDN_FULL" == *:* ]]; then + MYSQL_PORT="${MYSQL_FQDN_FULL##*:}" +fi +echo "MySQL host = $MYSQL_FQDN, port = $MYSQL_PORT" + +# The mysql client must be available on the host machine for the bootstrap below. +if ! command -v mysql &>/dev/null; then + echo "mysql is not installed on the host. Install the MySQL client (mysql-client) and re-run." >&2 + exit 1 +fi + +# Wait for the MySQL flexible server to accept connections. +# --ssl-mode=REQUIRED: Azure (and the LocalStack emulator) enforce require_secure_transport=ON, +# so every connection must negotiate TLS. +echo "Waiting for the [$MYSQL_SERVER_NAME] MySQL flexible server to accept connections..." +MYSQL_READY=0 +for attempt in $(seq 1 30); do + if MYSQL_PWD="$MYSQL_ADMIN_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_ADMIN_USER" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + --connect-timeout=5 \ + -e "SELECT 1;" &>/dev/null; then + MYSQL_READY=1 + echo "MySQL flexible server is accepting connections (attempt $attempt/30)" + break + fi + echo "MySQL flexible server not ready yet (attempt $attempt/30)..." + sleep 2 +done + +if [ "$MYSQL_READY" -ne 1 ]; then + echo "MySQL flexible server did not become reachable after 30 attempts. Exiting." + exit 1 +fi + +# Create the application user [$MYSQL_USER_NAME] and grant it access to the database +echo "Creating login [$MYSQL_USER_NAME] on the [$MYSQL_SERVER_NAME] MySQL flexible server..." +MYSQL_PWD="$MYSQL_ADMIN_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_ADMIN_USER" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + -e "CREATE USER IF NOT EXISTS '$MYSQL_USER_NAME'@'%' IDENTIFIED BY '$MYSQL_USER_PASSWORD'; + GRANT ALL PRIVILEGES ON \`$MYSQL_DATABASE_NAME\`.* TO '$MYSQL_USER_NAME'@'%'; + FLUSH PRIVILEGES;" + +if [ $? -eq 0 ]; then + echo "Login [$MYSQL_USER_NAME] created successfully" +else + echo "Failed to create login [$MYSQL_USER_NAME]" + exit 1 +fi + +# Create [activities] table +echo "Creating [activities] table in the [$MYSQL_DATABASE_NAME] database..." +MYSQL_PWD="$MYSQL_USER_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_USER_NAME" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + --database="$MYSQL_DATABASE_NAME" \ + -e "CREATE TABLE IF NOT EXISTS activities ( + id VARCHAR(32) NOT NULL, + username VARCHAR(255) NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_activities_username (username), + INDEX idx_activities_created_at (created_at DESC) + );" + +if [ $? -eq 0 ]; then + echo "[activities] table created successfully" +else + echo "Failed to create [activities] table" + exit 1 +fi + +# Insert sample data +echo "Inserting sample data into [activities] table..." +MYSQL_PWD="$MYSQL_USER_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_USER_NAME" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + --database="$MYSQL_DATABASE_NAME" \ + -e "INSERT IGNORE INTO activities (id, username, activity) VALUES + (MD5('paolo_pisa_seed'), 'paolo', 'Visit the Leaning Tower in Pisa'), + (MD5('paolo_volterra_seed'), 'paolo', 'Explore Etruscan walls in Volterra'), + (MD5('paolo_san_gimignano_seed'), 'paolo', 'Climb Torre Grossa in San Gimignano'), + (MD5('paolo_siena_seed'), 'paolo', 'Walk across Piazza del Campo in Siena'), + (MD5('paolo_montalcino_seed'), 'paolo', 'Taste Brunello wine in Montalcino'), + (MD5('paolo_pienza_seed'), 'paolo', 'Sample Pecorino cheese in Pienza'), + (MD5('paolo_florence_seed'), 'paolo', 'Admire Michelangelo''s David in Florence'), + (MD5('paolo_viareggio_beach_seed'), 'paolo', 'Relax by the beach in Viareggio'), + (MD5('paolo_viareggio_promenade_seed'), 'paolo', 'Stroll along the Viareggio promenade');" + +if [ $? -eq 0 ]; then + echo "Test data inserted successfully into [activities] table" +else + echo "Failed to insert test data into [activities] table" + exit 1 +fi + +# Query data +echo "Querying test data from [activities] table..." +MYSQL_PWD="$MYSQL_USER_PASSWORD" mysql \ + --host="$MYSQL_FQDN" \ + --port="$MYSQL_PORT" \ + --user="$MYSQL_USER_NAME" \ + --protocol=TCP \ + --ssl-mode=REQUIRED \ + --database="$MYSQL_DATABASE_NAME" \ + -e "SELECT id, username, activity, created_at FROM activities;" + +if [ $? -eq 0 ]; then + echo "Test data queried successfully from [activities] table" +else + echo "Failed to query test data from [activities] table" + exit 1 +fi diff --git a/samples/web-app-mysql-flexible-server/scripts/02-build-docker-image.sh b/samples/web-app-mysql-flexible-server/python/scripts/02-build-docker-image.sh similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/02-build-docker-image.sh rename to samples/web-app-mysql-flexible-server/python/scripts/02-build-docker-image.sh diff --git a/samples/web-app-mysql-flexible-server/python/scripts/03-run-docker-container.sh b/samples/web-app-mysql-flexible-server/python/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..d136b31 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/python/scripts/03-run-docker-container.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Retrieve the MySQL server FQDN +MYSQL_FQDN_FULL=$(az mysql flexible-server show \ + --name "$MYSQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$MYSQL_FQDN_FULL" ]; then + echo "Failed to retrieve MySQL server FQDN. Run 01-deploy-resources.sh first." + exit 1 +fi + +# Split host:port (LocalStack emulator embeds the dynamic TCP-proxy port in fullyQualifiedDomainName; +# real Azure returns just the bare host, so MYSQL_PORT stays at the value from 00-variables.sh (3306)). +MYSQL_FQDN="${MYSQL_FQDN_FULL%%:*}" +if [[ "$MYSQL_FQDN_FULL" == *:* ]]; then + MYSQL_PORT="${MYSQL_FQDN_FULL##*:}" +fi + +# --network=host so endpoints like *.localhost.localstack.cloud resolve to the +# host's loopback (where LocalStack is listening), not the container's. +docker run -it \ + --rm \ + --network=host \ + -e PORT=$PORT \ + -e MYSQL_HOST="$MYSQL_FQDN" \ + -e MYSQL_PORT="$MYSQL_PORT" \ + -e MYSQL_DATABASE="$MYSQL_DATABASE_NAME" \ + -e MYSQL_USER="$MYSQL_USER_NAME" \ + -e MYSQL_PASSWORD="$MYSQL_USER_PASSWORD" \ + -e MYSQL_SSL="$MYSQL_SSL" \ + -e LOGIN_NAME="$LOGIN_NAME" \ + --name "$IMAGE_NAME" \ + "$IMAGE_NAME:$IMAGE_TAG" diff --git a/samples/web-app-mysql-flexible-server/python/scripts/04-push-docker-image.sh b/samples/web-app-mysql-flexible-server/python/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/python/scripts/04-push-docker-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Login to ACR +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name $ACR_NAME + +# Retrieve ACR login server. Each container image needs to be tagged with the loginServer name of the registry. +ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --query loginServer --output tsv) + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Tag the local image with the loginServer of ACR +docker tag ${IMAGE_NAME,,}:$IMAGE_TAG $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$IMAGE_NAME] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$IMAGE_NAME] as [$FULL_IMAGE]." + exit 1 +fi + +# Push the container image to ACR +docker push $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + exit 1 +fi diff --git a/samples/web-app-mysql-flexible-server/scripts/05-deploy-app.sh b/samples/web-app-mysql-flexible-server/python/scripts/05-deploy-app.sh similarity index 76% rename from samples/web-app-mysql-flexible-server/scripts/05-deploy-app.sh rename to samples/web-app-mysql-flexible-server/python/scripts/05-deploy-app.sh index d6468cf..a1affe3 100755 --- a/samples/web-app-mysql-flexible-server/scripts/05-deploy-app.sh +++ b/samples/web-app-mysql-flexible-server/python/scripts/05-deploy-app.sh @@ -23,7 +23,14 @@ if [[ "$MYSQL_FQDN_FULL" == *:* ]]; then fi # Generate a stable Flask SECRET_KEY (sessions survive pod restarts) -FLASK_SECRET_KEY=$(openssl rand -hex 32) +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +FLASK_SECRET_KEY=$(kubectl get secret vacation-planner-mysql-secrets --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $FLASK_SECRET_KEY ]]; then + FLASK_SECRET_KEY=$(openssl rand -hex 32) +fi # Get the login server for the Azure Container Registry echo "Getting login server for Azure Container Registry [$ACR_NAME]..." @@ -78,3 +85,7 @@ kubectl apply -f - cat service.yml | yq "(.metadata.namespace)|="\""$NAMESPACE"\" | kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE diff --git a/samples/web-app-mysql-flexible-server/scripts/Dockerfile b/samples/web-app-mysql-flexible-server/python/scripts/Dockerfile similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/Dockerfile rename to samples/web-app-mysql-flexible-server/python/scripts/Dockerfile diff --git a/samples/web-app-mysql-flexible-server/python/scripts/configmap.yml b/samples/web-app-mysql-flexible-server/python/scripts/configmap.yml new file mode 100644 index 0000000..7a97f63 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/python/scripts/configmap.yml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-mysql-config + namespace: vacation-planner-mysql + labels: + app: vacation-planner-mysql +data: + MYSQL_HOST: "" + MYSQL_PORT: "3306" + MYSQL_DATABASE: "" + MYSQL_USER: "" + MYSQL_SSL: "true" + LOGIN_NAME: "paolo" + DEBUG: "false" diff --git a/samples/web-app-mysql-flexible-server/scripts/deployment.yml b/samples/web-app-mysql-flexible-server/python/scripts/deployment.yml similarity index 98% rename from samples/web-app-mysql-flexible-server/scripts/deployment.yml rename to samples/web-app-mysql-flexible-server/python/scripts/deployment.yml index 39b0f67..8aff955 100644 --- a/samples/web-app-mysql-flexible-server/scripts/deployment.yml +++ b/samples/web-app-mysql-flexible-server/python/scripts/deployment.yml @@ -85,7 +85,7 @@ spec: memory: "512Mi" livenessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 30 periodSeconds: 30 @@ -93,7 +93,7 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 10 periodSeconds: 10 diff --git a/samples/web-app-mysql-flexible-server/python/scripts/namespace.yml b/samples/web-app-mysql-flexible-server/python/scripts/namespace.yml new file mode 100644 index 0000000..92b20ac --- /dev/null +++ b/samples/web-app-mysql-flexible-server/python/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-mysql diff --git a/samples/web-app-mysql-flexible-server/python/scripts/secret.yml b/samples/web-app-mysql-flexible-server/python/scripts/secret.yml new file mode 100644 index 0000000..e21d7fe --- /dev/null +++ b/samples/web-app-mysql-flexible-server/python/scripts/secret.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-mysql-secrets + namespace: vacation-planner-mysql + labels: + app: vacation-planner-mysql +type: Opaque +data: + MYSQL_PASSWORD: "" + SECRET_KEY: "" diff --git a/samples/web-app-mysql-flexible-server/python/scripts/service.yml b/samples/web-app-mysql-flexible-server/python/scripts/service.yml new file mode 100644 index 0000000..0217de1 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/python/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-mysql + namespace: vacation-planner-mysql + labels: + app: vacation-planner-mysql +spec: + type: ClusterIP + selector: + app: vacation-planner-mysql + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-mysql-flexible-server/src/app.py b/samples/web-app-mysql-flexible-server/python/src/app.py similarity index 74% rename from samples/web-app-mysql-flexible-server/src/app.py rename to samples/web-app-mysql-flexible-server/python/src/app.py index 12b231b..485fba2 100644 --- a/samples/web-app-mysql-flexible-server/src/app.py +++ b/samples/web-app-mysql-flexible-server/python/src/app.py @@ -5,7 +5,7 @@ import os from typing import List, Tuple -from flask import Flask, flash, redirect, render_template, request, url_for +from flask import Flask, flash, jsonify, redirect, render_template, request, url_for from database import MySQLClient @@ -78,14 +78,31 @@ def favicon(): return app.send_static_file("favicon.ico") -@app.route("/delete/", methods=["POST"]) -def delete(activity_id: int): - if 0 <= activity_id < len(activities): - db_client.delete_activity(activities[activity_id][0]) +@app.route("/delete/", methods=["POST"]) +def delete(activity_id: str): + """Delete the activity with this id. + + The row id addresses the activity, never its position in the rendered page: every replica reloads the + table on each GET, so the list can change between rendering a page and submitting a delete from it, and + a position would then delete whatever activity happens to sit there now. + """ + if activity_id: + db_client.delete_activity(activity_id) flash("Activity deleted.") return redirect(url_for("index")) +@app.route("/health") +def health(): + """Liveness and readiness probe: reports whether MySQL answers SELECT 1.""" + try: + db_client.ping() + return jsonify({"status": "ok"}) + except Exception as exc: + logger.warning("Health check failed: %s", exc) + return jsonify({"status": "unavailable"}), 503 + + debug = os.environ.get("DEBUG", "false").lower() == "true" reload_activities() diff --git a/samples/web-app-mysql-flexible-server/src/database.py b/samples/web-app-mysql-flexible-server/python/src/database.py similarity index 95% rename from samples/web-app-mysql-flexible-server/src/database.py rename to samples/web-app-mysql-flexible-server/python/src/database.py index f5680cb..eb5b097 100644 --- a/samples/web-app-mysql-flexible-server/src/database.py +++ b/samples/web-app-mysql-flexible-server/python/src/database.py @@ -111,6 +111,16 @@ def _connect(self): kwargs["ssl"] = ssl_ctx return pymysql.connect(**kwargs) + def ping(self) -> None: + """Open a connection and run SELECT 1; raises when the server is unreachable.""" + conn = self._connect() + try: + with conn.cursor() as cur: + cur.execute("SELECT 1") + cur.fetchone() + finally: + conn.close() + def init_schema(self, retries: int = 30, delay: float = 2.0) -> None: """Wait for MySQL to accept connections, then create the activities table.""" last_err: Exception | None = None diff --git a/samples/web-app-mysql-flexible-server/src/gunicorn.conf.py b/samples/web-app-mysql-flexible-server/python/src/gunicorn.conf.py similarity index 100% rename from samples/web-app-mysql-flexible-server/src/gunicorn.conf.py rename to samples/web-app-mysql-flexible-server/python/src/gunicorn.conf.py diff --git a/samples/web-app-mysql-flexible-server/src/requirements.txt b/samples/web-app-mysql-flexible-server/python/src/requirements.txt similarity index 100% rename from samples/web-app-mysql-flexible-server/src/requirements.txt rename to samples/web-app-mysql-flexible-server/python/src/requirements.txt diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.min.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.min.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.min.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.min.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.min.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.min.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.min.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.min.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.min.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.min.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.min.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.min.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.min.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.min.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.min.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.min.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.min.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.min.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.min.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.min.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.min.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.min.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.min.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.min.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.min.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.min.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.min.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.min.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.min.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.min.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.min.css b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.min.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.min.css rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.min.css diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.min.css.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.min.css.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.js b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.js rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.js diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.js.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.js.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.js.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.js.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.min.js b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.min.js rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.min.js diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.min.js.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.min.js.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.esm.js b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.js similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.esm.js rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.js diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.esm.js.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.js.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.esm.js.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.js.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.esm.min.js b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.min.js similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.esm.min.js rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.min.js diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.esm.min.js.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.min.js.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.esm.min.js.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.min.js.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.js b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.js similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.js rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.js diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.js.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.js.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.js.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.js.map diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.min.js b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.min.js similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.min.js rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.min.js diff --git a/samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.min.js.map b/samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.min.js.map similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/bootstrap/js/bootstrap.min.js.map rename to samples/web-app-mysql-flexible-server/python/src/static/bootstrap/js/bootstrap.min.js.map diff --git a/samples/web-app-mysql-flexible-server/python/src/static/favicon.ico b/samples/web-app-mysql-flexible-server/python/src/static/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-mysql-flexible-server/python/src/static/favicon.ico differ diff --git a/samples/web-app-mysql-flexible-server/python/src/static/style.css b/samples/web-app-mysql-flexible-server/python/src/static/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-mysql-flexible-server/python/src/static/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-mysql-flexible-server/python/src/templates/index.html b/samples/web-app-mysql-flexible-server/python/src/templates/index.html new file mode 100644 index 0000000..efb642d --- /dev/null +++ b/samples/web-app-mysql-flexible-server/python/src/templates/index.html @@ -0,0 +1,260 @@ + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

{{ activities|length }} activit{{ 'ies' if activities|length != 1 else 'y' }} planned

+
+
+ + +
+
+ + +
+ + + + + + + + + {% for activity in activities %} + + + + + + {% else %} + + + + {% endfor %} + +
ActivityActions
{{ activity[1] }} + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-mysql-flexible-server/src/templates/index.html b/samples/web-app-mysql-flexible-server/src/templates/index.html deleted file mode 100644 index 45a7be8..0000000 --- a/samples/web-app-mysql-flexible-server/src/templates/index.html +++ /dev/null @@ -1,260 +0,0 @@ - - - - - - Vacation Planner - - - - - - - - - -
-
-

🌴 Vacation Planner

-

{{ activities|length }} activit{{ 'ies' if activities|length != 1 else 'y' }} planned

-
-
- - -
-
- - -
- - - - - - - - - {% for activity in activities %} - - - - - - {% else %} - - - - {% endfor %} - -
ActivityActions
{{ activity[1] }} - - -
- -
-
No vacation plans yet — add your first activity!
-
- - -
- -
- - -
- -
- - -
- - - - diff --git a/samples/web-app-postgresql-flexible-server/dotnet/README.md b/samples/web-app-postgresql-flexible-server/dotnet/README.md new file mode 100644 index 0000000..13700cd --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/README.md @@ -0,0 +1,76 @@ +# Vacation Planner: Azure Database for PostgreSQL flexible server + +> 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` table of the `PlannerDB` database on an [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview). + +The application connects to PostgreSQL using a dedicated application user (rather than the server admin), and the deployment scripts seed the `activities` table with a handful of sample plans so the app shows data on first load. + +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, PostgreSQL credentials, 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 Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview) and the `PlannerDB` database, a permissive firewall rule (dev/test only), a dedicated application user, and the `activities` table, which it also seeds with sample data. Requires `psql` on the host. | +| [`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 database 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 (PostgreSQL host, database, user, login name) passed to the app as environment variables. | +| [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the PostgreSQL password 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-postgres 8080:80 -n vacation-planner-postgres +``` + +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 PostgreSQL flexible server 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 database read and write 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-postgres -n vacation-planner-postgres --tail=50 +``` diff --git a/samples/web-app-postgresql-flexible-server/images/architecture.png b/samples/web-app-postgresql-flexible-server/dotnet/images/architecture.png similarity index 100% rename from samples/web-app-postgresql-flexible-server/images/architecture.png rename to samples/web-app-postgresql-flexible-server/dotnet/images/architecture.png diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/00-variables.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/00-variables.sh new file mode 100644 index 0000000..ea65004 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/00-variables.sh @@ -0,0 +1,45 @@ +# Variables + +# Azure Resources +PREFIX='local' +SUFFIX='test' +LOCATION='italynorth' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX,,}acr${SUFFIX,,}" +ACR_SKU='Standard' +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)" + +# Azure Database for PostgreSQL flexible server +PG_SERVER_NAME="${PREFIX}-pgflex-${SUFFIX}" +PG_VERSION='16' +PG_SKU_TIER='Burstable' +PG_SKU_NAME='Standard_B1ms' +PG_STORAGE_SIZE_GB=32 +PG_BACKUP_RETENTION_DAYS=7 +PG_PORT='5432' +FIREWALL_RULE_NAME='AllowAllIPs' +PG_ADMIN_USER='pgadmin' +PG_ADMIN_PASSWORD='P@ssw0rd1234!' +PG_USER_NAME='testuser' +PG_USER_PASSWORD='TestP@ssw0rd123' +PG_DATABASE_NAME='PlannerDB' + +# Application config — must match the seed-row `username` in 01-deploy-resources.sh. +# PostgreSQL `=` is case-sensitive (unlike SQL Server), so this stays lowercase. +LOGIN_NAME='paolo' + +# Docker Image +IMAGE_NAME="vacation-planner-postgres-dotnet" +IMAGE_PULL_POLICY="Always" +IMAGE_TAG="v1" +PORT="8080" + +# Kubernetes +NAMESPACE="vacation-planner-postgres" +DEPLOYMENT_NAME="vacation-planner-postgres" +SERVICE_NAME="vacation-planner-postgres" +CONFIGMAP_NAME="vacation-planner-postgres-config" +K8S_SECRET_NAME="vacation-planner-postgres-secrets" diff --git a/samples/web-app-postgresql-flexible-server/scripts/01-deploy-resources.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/01-deploy-resources.sh similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/01-deploy-resources.sh rename to samples/web-app-postgresql-flexible-server/dotnet/scripts/01-deploy-resources.sh diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/02-build-docker-image.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/02-build-docker-image.sh new file mode 100755 index 0000000..4b497d8 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/02-build-docker-image.sh @@ -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 diff --git a/samples/web-app-postgresql-flexible-server/scripts/03-run-docker-container.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/03-run-docker-container.sh similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/03-run-docker-container.sh rename to samples/web-app-postgresql-flexible-server/dotnet/scripts/03-run-docker-container.sh diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/04-push-docker-image.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/04-push-docker-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Login to ACR +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name $ACR_NAME + +# Retrieve ACR login server. Each container image needs to be tagged with the loginServer name of the registry. +ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --query loginServer --output tsv) + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Tag the local image with the loginServer of ACR +docker tag ${IMAGE_NAME,,}:$IMAGE_TAG $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$IMAGE_NAME] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$IMAGE_NAME] as [$FULL_IMAGE]." + exit 1 +fi + +# Push the container image to ACR +docker push $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + exit 1 +fi diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/05-deploy-app.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/05-deploy-app.sh new file mode 100755 index 0000000..f7a061e --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Retrieve the PostgreSQL server FQDN +PG_FQDN_FULL=$(az postgres flexible-server show \ + --name "$PG_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$PG_FQDN_FULL" ]; then + echo "Failed to retrieve PostgreSQL server FQDN. Run 01-deploy-resources.sh first." + exit 1 +fi + +# Split host:port (LocalStack emulator embeds the dynamic TCP-proxy port in fullyQualifiedDomainName; +# real Azure returns just the bare host). +PG_FQDN="${PG_FQDN_FULL%%:*}" +if [[ "$PG_FQDN_FULL" == *:* ]]; then + PG_PORT="${PG_FQDN_FULL##*:}" +fi + +# Generate a stable SECRET_KEY shared by all replicas: the app derives its Data Protection key ring from it, +# so antiforgery tokens and flash messages are valid on every replica and survive pod restarts +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +SECRET_KEY=$(kubectl get secret vacation-planner-postgres-secrets --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $SECRET_KEY ]]; then + SECRET_KEY=$(openssl rand -hex 32) +fi + +# Get the login server for the Azure Container Registry +echo "Getting login server for Azure Container Registry [$ACR_NAME]..." +ACR_LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +if [ -n "$ACR_LOGIN_SERVER" ]; then + echo "Login server retrieved successfully: $ACR_LOGIN_SERVER" +else + echo "Failed to retrieve login server for Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Create namespace +cat namespace.yml | +yq "(.metadata.name)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Create secret with the PostgreSQL password and the SECRET_KEY +cat secret.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.PG_PASSWORD)|="\""$(echo -n $PG_USER_PASSWORD | base64 -w0)"\" | +yq "(.data.SECRET_KEY)|="\""$(echo -n $SECRET_KEY | base64 -w0)"\" | +kubectl apply -f - + +# Create configmap with environment variables +cat configmap.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.PG_HOST)|="\""$PG_FQDN"\" | +yq "(.data.PG_PORT)|="\""$PG_PORT"\" | +yq "(.data.PG_DATABASE)|="\""$PG_DATABASE_NAME"\" | +yq "(.data.PG_USER)|="\""$PG_USER_NAME"\" | +yq "(.data.LOGIN_NAME)|="\""$LOGIN_NAME"\" | +kubectl apply -f - + +# Create deployment +cat deployment.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.spec.template.spec.containers[0].image)|="\""$FULL_IMAGE"\" | +yq "(.spec.template.spec.containers[0].imagePullPolicy)|="\""$IMAGE_PULL_POLICY"\" | +yq "(.spec.template.spec.containers[0].ports[0].containerPort)|=$PORT" | +kubectl apply -f - + +# Create service +cat service.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE + +# Wait for the rollout so a pod stuck in ImagePullBackOff or CrashLoopBackOff is reported here, not discovered later +echo "Waiting for deployment [$DEPLOYMENT_NAME] to roll out..." +if kubectl rollout status deployment/$DEPLOYMENT_NAME -n $NAMESPACE --timeout=600s; then + echo "Deployment [$DEPLOYMENT_NAME] is ready. To reach the web app, run:" + echo " kubectl port-forward service/$SERVICE_NAME 8080:80 -n $NAMESPACE" + echo "and browse to http://localhost:8080 (health: http://localhost:8080/health)." +else + echo "Deployment [$DEPLOYMENT_NAME] did not become ready. Inspect it with:" + echo " kubectl get pods -n $NAMESPACE" + echo " kubectl describe pod -n $NAMESPACE --selector app=$DEPLOYMENT_NAME" + exit 1 +fi diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/Dockerfile b/samples/web-app-postgresql-flexible-server/dotnet/scripts/Dockerfile new file mode 100644 index 0000000..d862da7 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/Dockerfile @@ -0,0 +1,28 @@ +# Build stage: restore and publish the ASP.NET Core app with the .NET SDK image. +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build + +WORKDIR /src + +# Restore first so the package cache layer is reused when only sources change. +COPY VacationPlanner.csproj ./ +RUN dotnet restore + +COPY . ./ +RUN dotnet publish -c Release -o /app/publish --no-restore + +# Runtime stage: the ASP.NET Core runtime image only, no SDK. +FROM mcr.microsoft.com/dotnet/aspnet:10.0 + +# Port Kestrel listens on; kept as a build argument (and a runtime variable) as in the Python image. +ARG PORT=8080 +ENV PORT=${PORT} + +WORKDIR /app +COPY --from=build /app/publish ./ + +# Run as the runtime image's non-root `app` user, as the Python image runs as its own unprivileged `app` user. +USER app + +EXPOSE ${PORT} + +ENTRYPOINT ["dotnet", "VacationPlanner.dll"] diff --git a/samples/web-app-postgresql-flexible-server/scripts/configmap.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/configmap.yml similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/configmap.yml rename to samples/web-app-postgresql-flexible-server/dotnet/scripts/configmap.yml diff --git a/samples/web-app-in-cluster-postgresql/scripts/deployment.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/deployment.yml similarity index 97% rename from samples/web-app-in-cluster-postgresql/scripts/deployment.yml rename to samples/web-app-postgresql-flexible-server/dotnet/scripts/deployment.yml index 75c88d3..1f33e69 100644 --- a/samples/web-app-in-cluster-postgresql/scripts/deployment.yml +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/deployment.yml @@ -25,7 +25,7 @@ spec: kubernetes.io/os: linux containers: - name: vacation-planner-postgres - image: .azurecr.io/vacation-planner-postgres:v1 + image: .azurecr.io/vacation-planner-postgres-dotnet:v1 imagePullPolicy: Always ports: - name: http @@ -80,7 +80,7 @@ spec: memory: "512Mi" livenessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 30 periodSeconds: 30 @@ -88,7 +88,7 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 10 periodSeconds: 10 diff --git a/samples/web-app-in-cluster-postgresql/scripts/namespace.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/namespace.yml similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/namespace.yml rename to samples/web-app-postgresql-flexible-server/dotnet/scripts/namespace.yml diff --git a/samples/web-app-postgresql-flexible-server/scripts/secret.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/secret.yml similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/secret.yml rename to samples/web-app-postgresql-flexible-server/dotnet/scripts/secret.yml diff --git a/samples/web-app-in-cluster-postgresql/scripts/service.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/service.yml similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/service.yml rename to samples/web-app-postgresql-flexible-server/dotnet/scripts/service.yml diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/.dockerignore b/samples/web-app-postgresql-flexible-server/dotnet/src/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Models/Activity.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..6272570 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) + { + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..4a8e59c --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + if (await store.UpdateAsync(id, text, cancellationToken)) + { + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated!"; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added!"; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Program.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Program.cs new file mode 100644 index 0000000..35261e6 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Program.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Listen on PORT (8080 by default), the way the Python image binds gunicorn to 0.0.0.0:${PORT}. HTTP_PORTS is the +// same setting the aspnet base image feeds through ASPNETCORE_HTTP_PORTS, so no URL override is involved. +if (Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseSetting(WebHostDefaults.HttpPortsKey, port); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storeOptions = PostgresOptions.FromEnvironment(); + +// SECRET_KEY is the Kubernetes Secret the Python sample signs its session cookie with. Deriving the Data Protection +// key ring from it lets all replicas validate each other's antiforgery tokens and flash cookies; without it (a local +// docker run) each process keeps its own keys. +var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY"); +if (!string.IsNullOrEmpty(secretKey)) +{ + builder.Services.AddDataProtection().DisableAutomaticKeyGeneration(); + builder.Services.Configure(options => options.XmlRepository = new SecretKeyXmlRepository(secretKey)); +} + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new PostgresActivityStore(storeOptions, sp.GetRequiredService>())); +// The Python sample waits up to 30 x 2 s for the database at startup; the same values apply here. +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>(), + attempts: 30, delay: TimeSpan.FromSeconds(2))); + +var app = builder.Build(); + +if (string.IsNullOrEmpty(secretKey)) +{ + app.Logger.LogWarning("SECRET_KEY is not set: antiforgery tokens and flash messages are only valid on this replica."); +} + +// One log line per request, the equivalent of the access log the Python image produces (its gunicorn +// command passes --access-logfile -). Kubernetes probes show up here too, exactly as they do for Python. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/ActivityId.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/ActivityId.cs new file mode 100644 index 0000000..8654aaf --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/ActivityId.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VacationPlanner.Services; + +/// MD5 of username + activity + timestamp: the id scheme shared by the Vacation Planner samples. +public static class ActivityId +{ + public static string Create(string username, string activity) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.ffffff"); + var hash = MD5.HashData(Encoding.UTF8.GetBytes($"{username}_{activity}_{timestamp}")); + return Convert.ToHexStringLower(hash); + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/IActivityStore.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..167993e --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,24 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection, directory) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Adds an activity and returns whether the store confirmed the write; the page flashes only then. + Task AddAsync(string text, CancellationToken cancellationToken); + + /// Updates an activity and returns whether the store reported a change, with the meaning the Python sample's driver gives it. + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + /// Deletes an activity by its store id and returns whether the store reported a deletion. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresActivityStore.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresActivityStore.cs new file mode 100644 index 0000000..bb0bbbb --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresActivityStore.cs @@ -0,0 +1,126 @@ +using Npgsql; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// +/// Activities in a PostgreSQL activities table. Like the Python sample, the store is low-throughput +/// and opens a fresh connection per call instead of managing a pool explicitly. +/// +public sealed class PostgresActivityStore(PostgresOptions options, ILogger logger) : IActivityStore +{ + private const string SchemaDdl = """ + CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username); + CREATE INDEX IF NOT EXISTS idx_activities_created_at ON activities(created_at DESC); + """; + + // Negotiate TLS when the server offers it, without certificate verification (libpq's "prefer", which the + // Python sample relies on): the flexible server's certificate is publicly trusted on Azure but self-signed + // under LocalStack, and the in-cluster StatefulSet offers no TLS at all. Npgsql only validates certificates + // with SslMode VerifyCA/VerifyFull. + private readonly string _connectionString = new NpgsqlConnectionStringBuilder + { + Host = options.Host, + Port = options.Port, + Username = options.User, + Password = options.Password, + Database = options.Database, + Timeout = 10, + SslMode = SslMode.Prefer, + }.ConnectionString; + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand(SchemaDdl, connection); + await command.ExecuteNonQueryAsync(cancellationToken); + logger.LogInformation("PostgreSQL schema initialized"); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand( + "SELECT id, activity FROM activities WHERE username = @username ORDER BY created_at DESC", connection); + command.Parameters.AddWithValue("username", options.Username); + + var activities = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + activities.Add(new Activity(reader.GetString(0), reader.GetString(1))); + } + + logger.LogInformation( + "Retrieved {Count} activities for user: {Username}", + activities.Count, + options.Username + ); + return activities; + } + + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand( + "INSERT INTO activities (id, username, activity) VALUES (@id, @username, @activity) ON CONFLICT (id) DO NOTHING", + connection); + command.Parameters.AddWithValue("id", ActivityId.Create(options.Username, text)); + command.Parameters.AddWithValue("username", options.Username); + command.Parameters.AddWithValue("activity", text); + await command.ExecuteNonQueryAsync(cancellationToken); + return true; + } + + /// Reports a change when a row matched, as the Python sample's psycopg2 rowcount check does. + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("UPDATE activities SET activity = @activity WHERE id = @id", connection); + command.Parameters.AddWithValue("activity", text); + command.Parameters.AddWithValue("id", id); + return await command.ExecuteNonQueryAsync(cancellationToken) > 0; + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("DELETE FROM activities WHERE id = @id", connection); + command.Parameters.AddWithValue("id", id); + if (await command.ExecuteNonQueryAsync(cancellationToken) == 0) + { + logger.LogInformation("Activity '{Id}' did not exist: already deleted.", id); + } + + return true; + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("SELECT 1", connection); + await command.ExecuteScalarAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "PostgreSQL health check failed"); + return false; + } + } + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresOptions.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresOptions.cs new file mode 100644 index 0000000..cc824d0 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresOptions.cs @@ -0,0 +1,28 @@ +namespace VacationPlanner.Services; + +/// Connection settings read from the same environment variables the Python sample uses. +public sealed record PostgresOptions(string Host, int Port, string User, string Password, string Database, string Username) +{ + public static PostgresOptions FromEnvironment() + { + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "paolo"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("LOGIN_NAME cannot be empty"); + } + + return new PostgresOptions( + Host: Require("PG_HOST"), + Port: int.Parse(Environment.GetEnvironmentVariable("PG_PORT") ?? "5432"), + User: Require("PG_USER"), + Password: Require("PG_PASSWORD"), + Database: Environment.GetEnvironmentVariable("PG_DATABASE") ?? "sampledb", + Username: username); + } + + private static string Require(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value + : throw new InvalidOperationException( + $"Missing required environment variable: {name}. Set PG_HOST, PG_USER, PG_PASSWORD (and optionally PG_PORT, PG_DATABASE)."); +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/SecretKeyXmlRepository.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/SecretKeyXmlRepository.cs new file mode 100644 index 0000000..e0cc5bb --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/SecretKeyXmlRepository.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel; +using Microsoft.AspNetCore.DataProtection.Repositories; + +namespace VacationPlanner.Services; + +/// +/// A Data Protection key ring derived deterministically from SECRET_KEY, the Kubernetes Secret the Python +/// sample signs its Flask session cookie with. ASP.NET Core protects its antiforgery tokens and TempData (flash) +/// cookies with Data Protection instead of a signing key; deriving the one key of the ring from the same secret +/// lets every replica of the Deployment validate what another replica issued, which the default per-process key +/// ring cannot offer behind a load balancer. +/// +public sealed class SecretKeyXmlRepository : IXmlRepository +{ + private readonly XElement _key; + + public SecretKeyXmlRepository(string secretKey) + { + var keyMaterial = Encoding.UTF8.GetBytes(secretKey); + + // A 512-bit master key (the size Data Protection generates itself) and a stable key id, both from SECRET_KEY. + var masterKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 64, info: "VacationPlanner.DataProtection.MasterKey"u8.ToArray()); + var keyId = new Guid(HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 16, info: "VacationPlanner.DataProtection.KeyId"u8.ToArray())); + + // AES-256-CBC + HMACSHA256, the default algorithms, serialized the way the key manager itself serializes a new key. + var descriptor = new AuthenticatedEncryptorDescriptor(new AuthenticatedEncryptorConfiguration(), new Secret(masterKey)); + var serialized = descriptor.ExportToXml(); + + _key = new XElement("key", + new XAttribute("id", keyId), + new XAttribute("version", 1), + new XElement("creationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("activationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("expirationDate", new DateTimeOffset(2999, 12, 31, 0, 0, 0, TimeSpan.Zero)), + new XElement("descriptor", + new XAttribute("deserializerType", serialized.DeserializerType.AssemblyQualifiedName!), + serialized.SerializedDescriptorElement)); + } + + public IReadOnlyCollection GetAllElements() => [new XElement(_key)]; + + /// Never called: automatic key generation is disabled, so the key manager has nothing to persist. + public void StoreElement(XElement element, string friendlyName) => + throw new NotSupportedException("The key ring is derived from SECRET_KEY and cannot be modified."); +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/VacationPlanner.csproj b/samples/web-app-postgresql-flexible-server/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..afa176e --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/appsettings.json b/samples/web-app-postgresql-flexible-server/dotnet/src/appsettings.json new file mode 100644 index 0000000..6e5c6b2 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss " + } + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/favicon.ico b/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/style.css b/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-postgresql-flexible-server/README.md b/samples/web-app-postgresql-flexible-server/python/README.md similarity index 75% rename from samples/web-app-postgresql-flexible-server/README.md rename to samples/web-app-postgresql-flexible-server/python/README.md index 435c76e..25819fb 100644 --- a/samples/web-app-postgresql-flexible-server/README.md +++ b/samples/web-app-postgresql-flexible-server/python/README.md @@ -1,17 +1,19 @@ # Vacation Planner: Azure Database for PostgreSQL flexible server +> A .NET version of this sample lives in [../dotnet](../dotnet/README.md). + This sample demonstrates a Python Flask 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` table of the `PlannerDB` database on an [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview). The application connects to PostgreSQL using a dedicated application user (rather than the server admin), and the deployment scripts seed the `activities` table with a handful of sample plans so the app shows data on first load. 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. +- [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. +> **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 @@ -46,7 +48,7 @@ cd scripts | [`namespace.yml`](scripts/namespace.yml) | Creates the Kubernetes namespace. | | [`configmap.yml`](scripts/configmap.yml) | Creates the ConfigMap holding non-secret input values (PostgreSQL host, database, user, login name) passed to the app as environment variables. | | [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the PostgreSQL password and the Flask secret key) passed to the app as environment variables. | -| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification for the web app. | +| [`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 @@ -58,3 +60,17 @@ kubectl port-forward service/vacation-planner-postgres 8080:80 -n vacation-plann ``` 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 PostgreSQL flexible server is reachable and `503` with `{"status": "unavailable"}` otherwise. + +```bash +curl http://localhost:8080/health +``` + +## Logs + +The app logs one line per request — gunicorn writes an access log line for every call, the probes included, because its command passes `--access-logfile -` — plus one line per database read and write and one line for every activity added, updated or deleted. Every entry carries a timestamp, the logger name and the level; the Azure SDK and `urllib3` stay at warning level. The [.NET version](../dotnet/README.md) writes the same trace. + +```bash +kubectl logs deployment/vacation-planner-postgres -n vacation-planner-postgres --tail=50 +``` diff --git a/samples/web-app-postgresql-flexible-server/python/images/architecture.png b/samples/web-app-postgresql-flexible-server/python/images/architecture.png new file mode 100644 index 0000000..f84b795 Binary files /dev/null and b/samples/web-app-postgresql-flexible-server/python/images/architecture.png differ diff --git a/samples/web-app-postgresql-flexible-server/scripts/00-variables.sh b/samples/web-app-postgresql-flexible-server/python/scripts/00-variables.sh similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/00-variables.sh rename to samples/web-app-postgresql-flexible-server/python/scripts/00-variables.sh diff --git a/samples/web-app-postgresql-flexible-server/python/scripts/01-deploy-resources.sh b/samples/web-app-postgresql-flexible-server/python/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..955cf4b --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/python/scripts/01-deploy-resources.sh @@ -0,0 +1,286 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Create a resource group +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 "Creating resource group [$RESOURCE_GROUP_NAME]..." + az group create \ + --name $RESOURCE_GROUP_NAME \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created." + else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists." +fi + +# Create the Azure Container Registry +echo "Checking if [$ACR_NAME] Azure Container Registry exists..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled "true" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists." +fi + +# Create the Azure Database for PostgreSQL flexible server +echo "Checking if PostgreSQL flexible server [$PG_SERVER_NAME] exists..." +az postgres flexible-server show \ + --name "$PG_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating PostgreSQL flexible server [$PG_SERVER_NAME]..." + az postgres flexible-server create \ + --name "$PG_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --admin-user "$PG_ADMIN_USER" \ + --admin-password "$PG_ADMIN_PASSWORD" \ + --version "$PG_VERSION" \ + --tier "$PG_SKU_TIER" \ + --sku-name "$PG_SKU_NAME" \ + --storage-size "$PG_STORAGE_SIZE_GB" \ + --backup-retention "$PG_BACKUP_RETENTION_DAYS" \ + --public-access Enabled \ + --yes \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "PostgreSQL flexible server [$PG_SERVER_NAME] created." + else + echo "Failed to create PostgreSQL flexible server [$PG_SERVER_NAME]." + exit 1 + fi +else + echo "PostgreSQL flexible server [$PG_SERVER_NAME] already exists." +fi + +# Add a permissive firewall rule (dev/test only) +echo "Ensuring firewall rule [$FIREWALL_RULE_NAME] exists on PostgreSQL flexible server [$PG_SERVER_NAME]..." +az postgres flexible-server firewall-rule create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --server-name "$PG_SERVER_NAME" \ + --name "$FIREWALL_RULE_NAME" \ + --start-ip-address 0.0.0.0 \ + --end-ip-address 255.255.255.255 \ + --only-show-errors 1>/dev/null + +# Create the PostgreSQL database +echo "Checking if PostgreSQL database [$PG_DATABASE_NAME] exists..." +az postgres flexible-server db show \ + --name "$PG_DATABASE_NAME" \ + --server-name "$PG_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating PostgreSQL database [$PG_DATABASE_NAME]..." + az postgres flexible-server db create \ + --name "$PG_DATABASE_NAME" \ + --server-name "$PG_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --charset UTF8 \ + --collation en_US.utf8 \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "PostgreSQL database [$PG_DATABASE_NAME] created." + else + echo "Failed to create PostgreSQL database [$PG_DATABASE_NAME]." + exit 1 + fi +else + echo "PostgreSQL database [$PG_DATABASE_NAME] already exists." +fi + +# Retrieve PostgreSQL server FQDN +PG_FQDN_FULL=$(az postgres flexible-server show \ + --name "$PG_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$PG_FQDN_FULL" ]; then + echo "Failed to retrieve PostgreSQL server FQDN." + exit 1 +fi + +# Split host:port — the LocalStack emulator embeds the dynamically allocated TCP-proxy port +# directly in fullyQualifiedDomainName, mirroring the storage / container registry emulators. +# Real Azure returns just the bare host so PG_PORT stays at the value from 00-variables.sh (5432). +PG_FQDN="${PG_FQDN_FULL%%:*}" +if [[ "$PG_FQDN_FULL" == *:* ]]; then + PG_PORT="${PG_FQDN_FULL##*:}" +fi +echo "PostgreSQL host = $PG_FQDN, port = $PG_PORT" + +# Create application role + grants + schema + seed data. +# psql must be available on the host machine. +if ! command -v psql &>/dev/null; then + echo "psql is not installed on the host. Install the PostgreSQL client (postgresql-client) and re-run." + exit 1 +fi + +echo "Creating login [$PG_USER_NAME] on the [$PG_SERVER_NAME] PostgreSQL flexible server..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$PG_FQDN" \ + --port="$PG_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "DO \$\$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$PG_USER_NAME') THEN + CREATE ROLE \"$PG_USER_NAME\" WITH LOGIN PASSWORD '$PG_USER_PASSWORD'; + END IF; +END +\$\$;" + +if [ $? -eq 0 ]; then + echo "Login [$PG_USER_NAME] created successfully" +else + echo "Failed to create login [$PG_USER_NAME]" + exit 1 +fi + +# Grant CONNECT on the database to [$PG_USER_NAME] +echo "Granting CONNECT on [$PG_DATABASE_NAME] to [$PG_USER_NAME]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$PG_FQDN" \ + --port="$PG_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT CONNECT ON DATABASE \"$PG_DATABASE_NAME\" TO \"$PG_USER_NAME\";" + +if [ $? -eq 0 ]; then + echo "CONNECT granted successfully to [$PG_USER_NAME]" +else + echo "Failed to grant CONNECT to [$PG_USER_NAME]" + exit 1 +fi + +# Grant schema privileges to [$PG_USER_NAME] +echo "Granting schema privileges on [$PG_DATABASE_NAME] to [$PG_USER_NAME]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$PG_FQDN" \ + --port="$PG_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname="$PG_DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT USAGE, CREATE ON SCHEMA public TO \"$PG_USER_NAME\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"$PG_USER_NAME\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"$PG_USER_NAME\";" + +if [ $? -eq 0 ]; then + echo "Schema privileges granted successfully to [$PG_USER_NAME]" +else + echo "Failed to grant schema privileges to [$PG_USER_NAME]" + exit 1 +fi + +# Create [activities] table +echo "Creating [activities] table in the [$PG_DATABASE_NAME] database..." +PGPASSWORD="$PG_USER_PASSWORD" psql \ + --host="$PG_FQDN" \ + --port="$PG_PORT" \ + --username="$PG_USER_NAME" \ + --dbname="$PG_DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + activity TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_activities_username ON activities(username); + CREATE INDEX IF NOT EXISTS idx_activities_created_at ON activities(created_at DESC);" + +if [ $? -eq 0 ]; then + echo "[activities] table created successfully" +else + echo "Failed to create [activities] table" + exit 1 +fi + +# Insert sample data +echo "Inserting sample data into [activities] table..." +PGPASSWORD="$PG_USER_PASSWORD" psql \ + --host="$PG_FQDN" \ + --port="$PG_PORT" \ + --username="$PG_USER_NAME" \ + --dbname="$PG_DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "INSERT INTO activities (id, username, activity) VALUES + (md5('paolo_pisa_seed'), 'paolo', 'Visit the Leaning Tower in Pisa'), + (md5('paolo_volterra_seed'), 'paolo', 'Explore Etruscan walls in Volterra'), + (md5('paolo_san_gimignano_seed'), 'paolo', 'Climb Torre Grossa in San Gimignano'), + (md5('paolo_siena_seed'), 'paolo', 'Walk across Piazza del Campo in Siena'), + (md5('paolo_montalcino_seed'), 'paolo', 'Taste Brunello wine in Montalcino'), + (md5('paolo_pienza_seed'), 'paolo', 'Sample Pecorino cheese in Pienza'), + (md5('paolo_florence_seed'), 'paolo', 'Admire Michelangelo''s David in Florence'), + (md5('paolo_viareggio_beach_seed'), 'paolo', 'Relax by the beach in Viareggio'), + (md5('paolo_viareggio_promenade_seed'), 'paolo', 'Stroll along the Viareggio promenade') + ON CONFLICT (id) DO NOTHING;" + +if [ $? -eq 0 ]; then + echo "Test data inserted successfully into [activities] table" +else + echo "Failed to insert test data into [activities] table" + exit 1 +fi + +# Query data +echo "Querying test data from [activities] table..." +PGPASSWORD="$PG_USER_PASSWORD" psql \ + --host="$PG_FQDN" \ + --port="$PG_PORT" \ + --username="$PG_USER_NAME" \ + --dbname="$PG_DATABASE_NAME" \ + --no-password \ + -c "SELECT id, username, activity, created_at FROM activities;" + +if [ $? -eq 0 ]; then + echo "Test data queried successfully from [activities] table" +else + echo "Failed to query test data from [activities] table" + exit 1 +fi diff --git a/samples/web-app-postgresql-flexible-server/scripts/02-build-docker-image.sh b/samples/web-app-postgresql-flexible-server/python/scripts/02-build-docker-image.sh similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/02-build-docker-image.sh rename to samples/web-app-postgresql-flexible-server/python/scripts/02-build-docker-image.sh diff --git a/samples/web-app-postgresql-flexible-server/python/scripts/03-run-docker-container.sh b/samples/web-app-postgresql-flexible-server/python/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..c73d923 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/python/scripts/03-run-docker-container.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Retrieve the PostgreSQL server FQDN +PG_FQDN_FULL=$(az postgres flexible-server show \ + --name "$PG_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$PG_FQDN_FULL" ]; then + echo "Failed to retrieve PostgreSQL server FQDN. Run 01-deploy-resources.sh first." + exit 1 +fi + +# Split host:port (LocalStack emulator embeds the dynamic TCP-proxy port in fullyQualifiedDomainName; +# real Azure returns just the bare host). +PG_FQDN="${PG_FQDN_FULL%%:*}" +if [[ "$PG_FQDN_FULL" == *:* ]]; then + PG_PORT="${PG_FQDN_FULL##*:}" +fi + +# --network=host so endpoints like *.localhost.localstack.cloud resolve to the +# host's loopback (where LocalStack is listening), not the container's. +docker run -it \ + --rm \ + --network=host \ + -e PORT=$PORT \ + -e PG_HOST="$PG_FQDN" \ + -e PG_PORT="$PG_PORT" \ + -e PG_DATABASE="$PG_DATABASE_NAME" \ + -e PG_USER="$PG_USER_NAME" \ + -e PG_PASSWORD="$PG_USER_PASSWORD" \ + -e LOGIN_NAME="$LOGIN_NAME" \ + --name "$IMAGE_NAME" \ + "$IMAGE_NAME:$IMAGE_TAG" diff --git a/samples/web-app-postgresql-flexible-server/python/scripts/04-push-docker-image.sh b/samples/web-app-postgresql-flexible-server/python/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/python/scripts/04-push-docker-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Login to ACR +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name $ACR_NAME + +# Retrieve ACR login server. Each container image needs to be tagged with the loginServer name of the registry. +ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --query loginServer --output tsv) + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Tag the local image with the loginServer of ACR +docker tag ${IMAGE_NAME,,}:$IMAGE_TAG $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$IMAGE_NAME] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$IMAGE_NAME] as [$FULL_IMAGE]." + exit 1 +fi + +# Push the container image to ACR +docker push $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + exit 1 +fi diff --git a/samples/web-app-postgresql-flexible-server/scripts/05-deploy-app.sh b/samples/web-app-postgresql-flexible-server/python/scripts/05-deploy-app.sh similarity index 75% rename from samples/web-app-postgresql-flexible-server/scripts/05-deploy-app.sh rename to samples/web-app-postgresql-flexible-server/python/scripts/05-deploy-app.sh index fa0ccc1..f681d2f 100755 --- a/samples/web-app-postgresql-flexible-server/scripts/05-deploy-app.sh +++ b/samples/web-app-postgresql-flexible-server/python/scripts/05-deploy-app.sh @@ -23,7 +23,14 @@ if [[ "$PG_FQDN_FULL" == *:* ]]; then fi # Generate a stable Flask SECRET_KEY (sessions survive pod restarts) -FLASK_SECRET_KEY=$(openssl rand -hex 32) +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +FLASK_SECRET_KEY=$(kubectl get secret vacation-planner-postgres-secrets --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $FLASK_SECRET_KEY ]]; then + FLASK_SECRET_KEY=$(openssl rand -hex 32) +fi # Get the login server for the Azure Container Registry echo "Getting login server for Azure Container Registry [$ACR_NAME]..." @@ -77,3 +84,7 @@ kubectl apply -f - cat service.yml | yq "(.metadata.namespace)|="\""$NAMESPACE"\" | kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE diff --git a/samples/web-app-postgresql-flexible-server/scripts/Dockerfile b/samples/web-app-postgresql-flexible-server/python/scripts/Dockerfile similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/Dockerfile rename to samples/web-app-postgresql-flexible-server/python/scripts/Dockerfile diff --git a/samples/web-app-postgresql-flexible-server/python/scripts/configmap.yml b/samples/web-app-postgresql-flexible-server/python/scripts/configmap.yml new file mode 100644 index 0000000..4064c51 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/python/scripts/configmap.yml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-postgres-config + namespace: vacation-planner-postgres + labels: + app: vacation-planner-postgres +data: + PG_HOST: "" + PG_PORT: "5432" + PG_DATABASE: "" + PG_USER: "" + LOGIN_NAME: "paolo" + DEBUG: "false" diff --git a/samples/web-app-postgresql-flexible-server/scripts/deployment.yml b/samples/web-app-postgresql-flexible-server/python/scripts/deployment.yml similarity index 98% rename from samples/web-app-postgresql-flexible-server/scripts/deployment.yml rename to samples/web-app-postgresql-flexible-server/python/scripts/deployment.yml index 75c88d3..6939a25 100644 --- a/samples/web-app-postgresql-flexible-server/scripts/deployment.yml +++ b/samples/web-app-postgresql-flexible-server/python/scripts/deployment.yml @@ -80,7 +80,7 @@ spec: memory: "512Mi" livenessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 30 periodSeconds: 30 @@ -88,7 +88,7 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 10 periodSeconds: 10 diff --git a/samples/web-app-postgresql-flexible-server/scripts/namespace.yml b/samples/web-app-postgresql-flexible-server/python/scripts/namespace.yml similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/namespace.yml rename to samples/web-app-postgresql-flexible-server/python/scripts/namespace.yml diff --git a/samples/web-app-postgresql-flexible-server/python/scripts/secret.yml b/samples/web-app-postgresql-flexible-server/python/scripts/secret.yml new file mode 100644 index 0000000..13629f2 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/python/scripts/secret.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-postgres-secrets + namespace: vacation-planner-postgres + labels: + app: vacation-planner-postgres +type: Opaque +data: + PG_PASSWORD: "" + SECRET_KEY: "" diff --git a/samples/web-app-postgresql-flexible-server/scripts/service.yml b/samples/web-app-postgresql-flexible-server/python/scripts/service.yml similarity index 100% rename from samples/web-app-postgresql-flexible-server/scripts/service.yml rename to samples/web-app-postgresql-flexible-server/python/scripts/service.yml diff --git a/samples/web-app-postgresql-flexible-server/src/app.py b/samples/web-app-postgresql-flexible-server/python/src/app.py similarity index 74% rename from samples/web-app-postgresql-flexible-server/src/app.py rename to samples/web-app-postgresql-flexible-server/python/src/app.py index cd5cb23..4a6c306 100644 --- a/samples/web-app-postgresql-flexible-server/src/app.py +++ b/samples/web-app-postgresql-flexible-server/python/src/app.py @@ -5,7 +5,7 @@ import os from typing import List, Tuple -from flask import Flask, flash, redirect, render_template, request, url_for +from flask import Flask, flash, jsonify, redirect, render_template, request, url_for from database import PostgresClient @@ -78,14 +78,31 @@ def favicon(): return app.send_static_file("favicon.ico") -@app.route("/delete/", methods=["POST"]) -def delete(activity_id: int): - if 0 <= activity_id < len(activities): - db_client.delete_activity(activities[activity_id][0]) +@app.route("/delete/", methods=["POST"]) +def delete(activity_id: str): + """Delete the activity with this id. + + The row id addresses the activity, never its position in the rendered page: every replica reloads the + table on each GET, so the list can change between rendering a page and submitting a delete from it, and + a position would then delete whatever activity happens to sit there now. + """ + if activity_id: + db_client.delete_activity(activity_id) flash("Activity deleted.") return redirect(url_for("index")) +@app.route("/health") +def health(): + """Liveness and readiness probe: reports whether PostgreSQL answers SELECT 1.""" + try: + db_client.ping() + return jsonify({"status": "ok"}) + except Exception as exc: + logger.warning("Health check failed: %s", exc) + return jsonify({"status": "unavailable"}), 503 + + debug = os.environ.get("DEBUG", "false").lower() == "true" reload_activities() diff --git a/samples/web-app-in-cluster-postgresql/src/database.py b/samples/web-app-postgresql-flexible-server/python/src/database.py similarity index 94% rename from samples/web-app-in-cluster-postgresql/src/database.py rename to samples/web-app-postgresql-flexible-server/python/src/database.py index 515a6f3..d3082c0 100644 --- a/samples/web-app-in-cluster-postgresql/src/database.py +++ b/samples/web-app-postgresql-flexible-server/python/src/database.py @@ -80,6 +80,16 @@ def _connect(self): connect_timeout=10, ) + def ping(self) -> None: + """Open a connection and run SELECT 1; raises when the server is unreachable.""" + conn = self._connect() + try: + with conn.cursor() as cur: + cur.execute("SELECT 1") + cur.fetchone() + finally: + conn.close() + def init_schema(self, retries: int = 30, delay: float = 2.0) -> None: """Wait for PostgreSQL to accept connections, then create the activities table.""" last_err: Exception | None = None diff --git a/samples/web-app-postgresql-flexible-server/src/gunicorn.conf.py b/samples/web-app-postgresql-flexible-server/python/src/gunicorn.conf.py similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/gunicorn.conf.py rename to samples/web-app-postgresql-flexible-server/python/src/gunicorn.conf.py diff --git a/samples/web-app-postgresql-flexible-server/src/requirements.txt b/samples/web-app-postgresql-flexible-server/python/src/requirements.txt similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/requirements.txt rename to samples/web-app-postgresql-flexible-server/python/src/requirements.txt diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.min.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.min.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.min.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.min.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.min.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.min.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.min.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.min.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.min.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.min.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.min.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.min.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.min.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.min.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.min.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.min.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.min.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.min.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.min.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.min.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.min.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.min.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.min.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.min.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.min.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.min.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.min.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.min.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.min.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.min.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.min.css b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.min.css similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.min.css rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.min.css diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.min.css.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/css/bootstrap.rtl.min.css.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.js b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.js rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.js diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.js.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.js.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.js.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.js.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.min.js b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.min.js rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.min.js diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.min.js.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.bundle.min.js.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.esm.js b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.js similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.esm.js rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.js diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.esm.js.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.js.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.esm.js.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.js.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.esm.min.js b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.min.js similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.esm.min.js rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.min.js diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.esm.min.js.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.min.js.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.esm.min.js.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.esm.min.js.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.js b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.js similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.js rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.js diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.js.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.js.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.js.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.js.map diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.min.js b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.min.js similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.min.js rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.min.js diff --git a/samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.min.js.map b/samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.min.js.map similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/static/bootstrap/js/bootstrap.min.js.map rename to samples/web-app-postgresql-flexible-server/python/src/static/bootstrap/js/bootstrap.min.js.map diff --git a/samples/web-app-postgresql-flexible-server/python/src/static/favicon.ico b/samples/web-app-postgresql-flexible-server/python/src/static/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-postgresql-flexible-server/python/src/static/favicon.ico differ diff --git a/samples/web-app-postgresql-flexible-server/python/src/static/style.css b/samples/web-app-postgresql-flexible-server/python/src/static/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/python/src/static/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-postgresql-flexible-server/python/src/templates/index.html b/samples/web-app-postgresql-flexible-server/python/src/templates/index.html new file mode 100644 index 0000000..efb642d --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/python/src/templates/index.html @@ -0,0 +1,260 @@ + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

{{ activities|length }} activit{{ 'ies' if activities|length != 1 else 'y' }} planned

+
+
+ + +
+
+ + +
+ + + + + + + + + {% for activity in activities %} + + + + + + {% else %} + + + + {% endfor %} + +
ActivityActions
{{ activity[1] }} + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-postgresql-flexible-server/src/templates/index.html b/samples/web-app-postgresql-flexible-server/src/templates/index.html deleted file mode 100644 index 45a7be8..0000000 --- a/samples/web-app-postgresql-flexible-server/src/templates/index.html +++ /dev/null @@ -1,260 +0,0 @@ - - - - - - Vacation Planner - - - - - - - - - -
-
-

🌴 Vacation Planner

-

{{ activities|length }} activit{{ 'ies' if activities|length != 1 else 'y' }} planned

-
-
- - -
-
- - -
- - - - - - - - - {% for activity in activities %} - - - - - - {% else %} - - - - {% endfor %} - -
ActivityActions
{{ activity[1] }} - - -
- -
-
No vacation plans yet — add your first activity!
-
- - -
- -
- - -
- -
- - -
- - - - diff --git a/samples/web-app-sql-database/dotnet/README.md b/samples/web-app-sql-database/dotnet/README.md new file mode 100644 index 0000000..f2b1616 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/README.md @@ -0,0 +1,76 @@ +# Vacation Planner: Azure SQL Database + +> 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 `dbo.Activities` table of the `PlannerDB` database on an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview). + +The application connects to Azure SQL using a dedicated SQL login (rather than the server admin), and the deployment scripts seed the `Activities` table with a handful of sample plans so the app shows data on first load. + +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, SQL credentials, 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 SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) logical server and database, a permissive firewall rule (dev/test only), a dedicated SQL login and database user with the appropriate roles, and the `Activities` table, which it also seeds with sample data. Requires `sqlcmd` on the host. | +| [`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 database 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 (SQL server FQDN, database, username, login name) passed to the app as environment variables. | +| [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the SQL password 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-sql 8080:80 -n vacation-planner-sql +``` + +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 Azure SQL database 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 database read and write 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-sql -n vacation-planner-sql --tail=50 +``` diff --git a/samples/web-app-sql-database/images/architecture.png b/samples/web-app-sql-database/dotnet/images/architecture.png similarity index 100% rename from samples/web-app-sql-database/images/architecture.png rename to samples/web-app-sql-database/dotnet/images/architecture.png diff --git a/samples/web-app-sql-database/dotnet/scripts/00-variables.sh b/samples/web-app-sql-database/dotnet/scripts/00-variables.sh new file mode 100755 index 0000000..36b9722 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/00-variables.sh @@ -0,0 +1,38 @@ +# Variables + +# Azure Resources +PREFIX='local' +SUFFIX='test' +LOCATION='italynorth' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX,,}acr${SUFFIX,,}" +ACR_SKU='Standard' +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)" + +# Azure SQL +SQL_SERVER_NAME="${PREFIX}-sqlserver-${SUFFIX}" +FIREWALL_RULE_NAME='AllowAllIPs' +ADMIN_USER='sqladmin' +ADMIN_PASSWORD='P@ssw0rd1234!' +DATABASE_USER_NAME='testuser' +DATABASE_USER_PASSWORD='TestP@ssw0rd123' +SQL_DATABASE_NAME='PlannerDB' + +# Application config +LOGIN_NAME='Paolo' + +# Docker Image +IMAGE_NAME="vacation-planner-sql-dotnet" +IMAGE_PULL_POLICY="Always" +IMAGE_TAG="v1" +PORT="8080" + +# Kubernetes +NAMESPACE="vacation-planner-sql" +DEPLOYMENT_NAME="vacation-planner-sql" +SERVICE_NAME="vacation-planner-sql" +CONFIGMAP_NAME="vacation-planner-sql-config" +K8S_SECRET_NAME="vacation-planner-sql-secrets" diff --git a/samples/web-app-sql-database/scripts/01-deploy-resources.sh b/samples/web-app-sql-database/dotnet/scripts/01-deploy-resources.sh similarity index 98% rename from samples/web-app-sql-database/scripts/01-deploy-resources.sh rename to samples/web-app-sql-database/dotnet/scripts/01-deploy-resources.sh index d1da94d..a305d58 100755 --- a/samples/web-app-sql-database/scripts/01-deploy-resources.sh +++ b/samples/web-app-sql-database/dotnet/scripts/01-deploy-resources.sh @@ -204,7 +204,8 @@ sqlcmd -S "$SQL_SERVER_FQDN" \ -U "$DATABASE_USER_NAME" \ -P "$DATABASE_USER_PASSWORD" \ -N -C \ - -Q "INSERT INTO Activities (username, activity, timestamp) + -Q "IF NOT EXISTS (SELECT 1 FROM Activities) + INSERT INTO Activities (username, activity, timestamp) VALUES ('paolo', 'Visit the Leaning Tower in Pisa', GETDATE()), ('paolo', 'Explore Etruscan walls in Volterra', GETDATE()), diff --git a/samples/web-app-sql-database/dotnet/scripts/02-build-docker-image.sh b/samples/web-app-sql-database/dotnet/scripts/02-build-docker-image.sh new file mode 100755 index 0000000..4b497d8 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/02-build-docker-image.sh @@ -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 diff --git a/samples/web-app-sql-database/scripts/03-run-docker-container.sh b/samples/web-app-sql-database/dotnet/scripts/03-run-docker-container.sh similarity index 100% rename from samples/web-app-sql-database/scripts/03-run-docker-container.sh rename to samples/web-app-sql-database/dotnet/scripts/03-run-docker-container.sh diff --git a/samples/web-app-sql-database/dotnet/scripts/04-push-docker-image.sh b/samples/web-app-sql-database/dotnet/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/04-push-docker-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Login to ACR +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name $ACR_NAME + +# Retrieve ACR login server. Each container image needs to be tagged with the loginServer name of the registry. +ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --query loginServer --output tsv) + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Tag the local image with the loginServer of ACR +docker tag ${IMAGE_NAME,,}:$IMAGE_TAG $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$IMAGE_NAME] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$IMAGE_NAME] as [$FULL_IMAGE]." + exit 1 +fi + +# Push the container image to ACR +docker push $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + exit 1 +fi diff --git a/samples/web-app-sql-database/dotnet/scripts/05-deploy-app.sh b/samples/web-app-sql-database/dotnet/scripts/05-deploy-app.sh new file mode 100755 index 0000000..c9152e4 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Retrieve the SQL server FQDN +SQL_SERVER_FQDN=$(az sql server show \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$SQL_SERVER_FQDN" ]; then + echo "Failed to retrieve SQL server FQDN. Run 01-deploy-resources.sh first." + exit 1 +fi + +# Generate a stable SECRET_KEY shared by all replicas: the app derives its Data Protection key ring from it, +# so antiforgery tokens and flash messages are valid on every replica and survive pod restarts +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +SECRET_KEY=$(kubectl get secret vacation-planner-sql-secrets --namespace $NAMESPACE --output jsonpath='{.data.SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $SECRET_KEY ]]; then + SECRET_KEY=$(openssl rand -hex 32) +fi + +# Get the login server for the Azure Container Registry +echo "Getting login server for Azure Container Registry [$ACR_NAME]..." +ACR_LOGIN_SERVER=$(az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "loginServer" \ + --output tsv \ + --only-show-errors) + +if [ -n "$ACR_LOGIN_SERVER" ]; then + echo "Login server retrieved successfully: $ACR_LOGIN_SERVER" +else + echo "Failed to retrieve login server for Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Create namespace +cat namespace.yml | +yq "(.metadata.name)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Create secret with the SQL password and the SECRET_KEY +cat secret.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.SQL_PASSWORD)|="\""$(echo -n $DATABASE_USER_PASSWORD | base64 -w0)"\" | +yq "(.data.SECRET_KEY)|="\""$(echo -n $SECRET_KEY | base64 -w0)"\" | +kubectl apply -f - + +# Create configmap with environment variables +cat configmap.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.SQL_SERVER)|="\""$SQL_SERVER_FQDN"\" | +yq "(.data.SQL_DATABASE)|="\""$SQL_DATABASE_NAME"\" | +yq "(.data.SQL_USERNAME)|="\""$DATABASE_USER_NAME"\" | +yq "(.data.LOGIN_NAME)|="\""$LOGIN_NAME"\" | +kubectl apply -f - + +# Create deployment +cat deployment.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.spec.template.spec.containers[0].image)|="\""$FULL_IMAGE"\" | +yq "(.spec.template.spec.containers[0].imagePullPolicy)|="\""$IMAGE_PULL_POLICY"\" | +yq "(.spec.template.spec.containers[0].ports[0].containerPort)|=$PORT" | +kubectl apply -f - + +# Create service +cat service.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE + +# Wait for the rollout so a pod stuck in ImagePullBackOff or CrashLoopBackOff is reported here, not discovered later +echo "Waiting for deployment [$DEPLOYMENT_NAME] to roll out..." +if kubectl rollout status deployment/$DEPLOYMENT_NAME -n $NAMESPACE --timeout=600s; then + echo "Deployment [$DEPLOYMENT_NAME] is ready. To reach the web app, run:" + echo " kubectl port-forward service/$SERVICE_NAME 8080:80 -n $NAMESPACE" + echo "and browse to http://localhost:8080 (health: http://localhost:8080/health)." +else + echo "Deployment [$DEPLOYMENT_NAME] did not become ready. Inspect it with:" + echo " kubectl get pods -n $NAMESPACE" + echo " kubectl describe pod -n $NAMESPACE --selector app=$DEPLOYMENT_NAME" + exit 1 +fi diff --git a/samples/web-app-sql-database/dotnet/scripts/Dockerfile b/samples/web-app-sql-database/dotnet/scripts/Dockerfile new file mode 100644 index 0000000..d862da7 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/Dockerfile @@ -0,0 +1,28 @@ +# Build stage: restore and publish the ASP.NET Core app with the .NET SDK image. +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build + +WORKDIR /src + +# Restore first so the package cache layer is reused when only sources change. +COPY VacationPlanner.csproj ./ +RUN dotnet restore + +COPY . ./ +RUN dotnet publish -c Release -o /app/publish --no-restore + +# Runtime stage: the ASP.NET Core runtime image only, no SDK. +FROM mcr.microsoft.com/dotnet/aspnet:10.0 + +# Port Kestrel listens on; kept as a build argument (and a runtime variable) as in the Python image. +ARG PORT=8080 +ENV PORT=${PORT} + +WORKDIR /app +COPY --from=build /app/publish ./ + +# Run as the runtime image's non-root `app` user, as the Python image runs as its own unprivileged `app` user. +USER app + +EXPOSE ${PORT} + +ENTRYPOINT ["dotnet", "VacationPlanner.dll"] diff --git a/samples/web-app-sql-database/scripts/configmap.yml b/samples/web-app-sql-database/dotnet/scripts/configmap.yml similarity index 100% rename from samples/web-app-sql-database/scripts/configmap.yml rename to samples/web-app-sql-database/dotnet/scripts/configmap.yml diff --git a/samples/web-app-sql-database/dotnet/scripts/deployment.yml b/samples/web-app-sql-database/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..8679cd1 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/deployment.yml @@ -0,0 +1,86 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vacation-planner-sql + namespace: vacation-planner-sql + labels: + app: vacation-planner-sql +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-sql + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-sql + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: vacation-planner-sql + image: .azurecr.io/vacation-planner-sql-dotnet:v1 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: SQL_SERVER + valueFrom: + configMapKeyRef: + name: vacation-planner-sql-config + key: SQL_SERVER + - name: SQL_DATABASE + valueFrom: + configMapKeyRef: + name: vacation-planner-sql-config + key: SQL_DATABASE + - name: SQL_USERNAME + valueFrom: + configMapKeyRef: + name: vacation-planner-sql-config + key: SQL_USERNAME + - name: LOGIN_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-sql-config + key: LOGIN_NAME + - name: SQL_PASSWORD + valueFrom: + secretKeyRef: + name: vacation-planner-sql-secrets + key: SQL_PASSWORD + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-sql-secrets + key: SECRET_KEY + resources: + requests: + cpu: "200m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "512Mi" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/samples/web-app-sql-database/scripts/namespace.yml b/samples/web-app-sql-database/dotnet/scripts/namespace.yml similarity index 100% rename from samples/web-app-sql-database/scripts/namespace.yml rename to samples/web-app-sql-database/dotnet/scripts/namespace.yml diff --git a/samples/web-app-sql-database/dotnet/scripts/secret.yml b/samples/web-app-sql-database/dotnet/scripts/secret.yml new file mode 100644 index 0000000..6cd4fb5 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/secret.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-sql-secrets + namespace: vacation-planner-sql + labels: + app: vacation-planner-sql +type: Opaque +data: + SQL_PASSWORD: "" + SECRET_KEY: "" diff --git a/samples/web-app-sql-database/scripts/service.yml b/samples/web-app-sql-database/dotnet/scripts/service.yml similarity index 100% rename from samples/web-app-sql-database/scripts/service.yml rename to samples/web-app-sql-database/dotnet/scripts/service.yml diff --git a/samples/web-app-sql-database/dotnet/src/.dockerignore b/samples/web-app-sql-database/dotnet/src/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/web-app-sql-database/dotnet/src/Models/Activity.cs b/samples/web-app-sql-database/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Models/Activity.cs @@ -0,0 +1,4 @@ +namespace VacationPlanner.Models; + +/// A planned vacation activity: the store's identifier plus the free-text description. +public sealed record Activity(string Id, string Text); diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml b/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..6272570 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles POST /delete/{id}; the activity is addressed by its store id, never by its position in the list. +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) + { + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml b/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,265 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned

+
+
+ + +
+
+ + +
+ + + + + + + + + @foreach (var activity in Model.Activities) + { + + + + + + } + @if (Model.Activities.Count == 0) + { + + + + } + +
ActivityActions
@activity.Text + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml.cs b/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml.cs new file mode 100644 index 0000000..b012b42 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store, ILogger logger) : PageModel +{ + public IReadOnlyList Activities { get; private set; } = []; + + /// Flash messages set by the previous request (the equivalent of Flask's flash()). + public IReadOnlyList Flashes => TempData["Flash"] is string message ? [message] : []; + + [BindProperty(Name = "activity")] + public string? Activity { get; set; } + + [BindProperty(Name = "row_id")] + public string? RowId { get; set; } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + Activities = await store.ListAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + var text = Activity?.Trim(); + var id = RowId?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + if (!string.IsNullOrEmpty(id)) + { + if (await store.UpdateAsync(id, text, cancellationToken)) + { + logger.LogInformation("Activity updated: {Id}", id); + TempData["Flash"] = "Activity updated."; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + logger.LogInformation("Activity added: {Activity}", text); + TempData["Flash"] = "Activity added."; + } + } + + return RedirectToPage(); + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml b/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml new file mode 100644 index 0000000..5e64f03 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml @@ -0,0 +1,2 @@ +@page "/update/{id}" +@model UpdateModel diff --git a/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml.cs b/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml.cs new file mode 100644 index 0000000..9c09407 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// Handles GET /update/{id}: bounces to the index page with the activity to edit in the query string, as the Python sample does. +public class UpdateModel(IActivityStore store) : PageModel +{ + public async Task OnGetAsync(string id, CancellationToken cancellationToken) + { + var activity = (await store.ListAsync(cancellationToken)).FirstOrDefault(a => a.Id == id); + return activity is null + ? RedirectToPage("/Index") + : RedirectToPage("/Index", new { edit_id = activity.Id, edit_activity = activity.Text }); + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Pages/_ViewImports.cshtml b/samples/web-app-sql-database/dotnet/src/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..ec62511 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using VacationPlanner +@using VacationPlanner.Models +@namespace VacationPlanner.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/samples/web-app-sql-database/dotnet/src/Program.cs b/samples/web-app-sql-database/dotnet/src/Program.cs new file mode 100644 index 0000000..21021bf --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Program.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using VacationPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Listen on PORT (8080 by default), the way the Python image binds gunicorn to 0.0.0.0:${PORT}. HTTP_PORTS is the +// same setting the aspnet base image feeds through ASPNETCORE_HTTP_PORTS, so no URL override is involved. +if (Environment.GetEnvironmentVariable("PORT") is { Length: > 0 } port) +{ + builder.WebHost.UseSetting(WebHostDefaults.HttpPortsKey, port); +} + +// Read and validate the configuration up front so a misconfigured deployment fails at startup. +var storeOptions = SqlOptions.FromEnvironment(); + +// SECRET_KEY is the Kubernetes Secret the Python sample signs its session cookie with. Deriving the Data Protection +// key ring from it lets all replicas validate each other's antiforgery tokens and flash cookies; without it (a local +// docker run) each process keeps its own keys. +var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY"); +if (!string.IsNullOrEmpty(secretKey)) +{ + builder.Services.AddDataProtection().DisableAutomaticKeyGeneration(); + builder.Services.Configure(options => options.XmlRepository = new SecretKeyXmlRepository(secretKey)); +} + +builder.Services.AddRazorPages(); +builder.Services.AddSingleton(sp => + new SqlActivityStore(storeOptions, sp.GetRequiredService>())); +builder.Services.AddHostedService(sp => + new StoreInitializer(sp.GetRequiredService(), sp.GetRequiredService>())); + +var app = builder.Build(); + +if (string.IsNullOrEmpty(secretKey)) +{ + app.Logger.LogWarning("SECRET_KEY is not set: antiforgery tokens and flash messages are only valid on this replica."); +} + +// One log line per request, the equivalent of the access log the Python image produces (its gunicorn +// command passes --access-logfile -). Kubernetes probes show up here too, exactly as they do for Python. +var requestLogger = app.Services.GetRequiredService().CreateLogger("VacationPlanner.Requests"); +app.Use( + async (context, next) => + { + var started = Stopwatch.GetTimestamp(); + await next(); + requestLogger.LogInformation( + "{Method} {Path} -> {StatusCode} in {Elapsed:0.0}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + Stopwatch.GetElapsedTime(started).TotalMilliseconds + ); + } +); + +app.UseStaticFiles(); +app.MapRazorPages(); + +app.MapGet("/health", async (IActivityStore store, CancellationToken cancellationToken) => + await store.IsHealthyAsync(cancellationToken) + ? Results.Json(new { status = "ok" }) + : Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable)); + +app.Run(); diff --git a/samples/web-app-sql-database/dotnet/src/Services/IActivityStore.cs b/samples/web-app-sql-database/dotnet/src/Services/IActivityStore.cs new file mode 100644 index 0000000..167993e --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/IActivityStore.cs @@ -0,0 +1,24 @@ +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// Persistence for the planner's activities. Every call goes to the backing store; nothing is cached in-process. +public interface IActivityStore +{ + /// Creates whatever the store needs (container, table, collection, directory) before the first request. + Task InitializeAsync(CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Adds an activity and returns whether the store confirmed the write; the page flashes only then. + Task AddAsync(string text, CancellationToken cancellationToken); + + /// Updates an activity and returns whether the store reported a change, with the meaning the Python sample's driver gives it. + Task UpdateAsync(string id, string text, CancellationToken cancellationToken); + + /// Deletes an activity by its store id and returns whether the store reported a deletion. + Task DeleteAsync(string id, CancellationToken cancellationToken); + + /// Cheap connectivity probe used by GET /health. + Task IsHealthyAsync(CancellationToken cancellationToken); +} diff --git a/samples/web-app-sql-database/dotnet/src/Services/SecretKeyXmlRepository.cs b/samples/web-app-sql-database/dotnet/src/Services/SecretKeyXmlRepository.cs new file mode 100644 index 0000000..e0cc5bb --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/SecretKeyXmlRepository.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel; +using Microsoft.AspNetCore.DataProtection.Repositories; + +namespace VacationPlanner.Services; + +/// +/// A Data Protection key ring derived deterministically from SECRET_KEY, the Kubernetes Secret the Python +/// sample signs its Flask session cookie with. ASP.NET Core protects its antiforgery tokens and TempData (flash) +/// cookies with Data Protection instead of a signing key; deriving the one key of the ring from the same secret +/// lets every replica of the Deployment validate what another replica issued, which the default per-process key +/// ring cannot offer behind a load balancer. +/// +public sealed class SecretKeyXmlRepository : IXmlRepository +{ + private readonly XElement _key; + + public SecretKeyXmlRepository(string secretKey) + { + var keyMaterial = Encoding.UTF8.GetBytes(secretKey); + + // A 512-bit master key (the size Data Protection generates itself) and a stable key id, both from SECRET_KEY. + var masterKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 64, info: "VacationPlanner.DataProtection.MasterKey"u8.ToArray()); + var keyId = new Guid(HKDF.DeriveKey(HashAlgorithmName.SHA256, keyMaterial, 16, info: "VacationPlanner.DataProtection.KeyId"u8.ToArray())); + + // AES-256-CBC + HMACSHA256, the default algorithms, serialized the way the key manager itself serializes a new key. + var descriptor = new AuthenticatedEncryptorDescriptor(new AuthenticatedEncryptorConfiguration(), new Secret(masterKey)); + var serialized = descriptor.ExportToXml(); + + _key = new XElement("key", + new XAttribute("id", keyId), + new XAttribute("version", 1), + new XElement("creationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("activationDate", new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new XElement("expirationDate", new DateTimeOffset(2999, 12, 31, 0, 0, 0, TimeSpan.Zero)), + new XElement("descriptor", + new XAttribute("deserializerType", serialized.DeserializerType.AssemblyQualifiedName!), + serialized.SerializedDescriptorElement)); + } + + public IReadOnlyCollection GetAllElements() => [new XElement(_key)]; + + /// Never called: automatic key generation is disabled, so the key manager has nothing to persist. + public void StoreElement(XElement element, string friendlyName) => + throw new NotSupportedException("The key ring is derived from SECRET_KEY and cannot be modified."); +} diff --git a/samples/web-app-sql-database/dotnet/src/Services/SqlActivityStore.cs b/samples/web-app-sql-database/dotnet/src/Services/SqlActivityStore.cs new file mode 100644 index 0000000..c528b2e --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/SqlActivityStore.cs @@ -0,0 +1,142 @@ +using Azure.Core; +using Azure.Identity; +using Microsoft.Data.SqlClient; +using VacationPlanner.Models; + +namespace VacationPlanner.Services; + +/// +/// Activities in the dbo.Activities table of an Azure SQL Database. The table is created by the +/// deployment scripts, so the store only reads and writes it. +/// +public sealed class SqlActivityStore(SqlOptions options, ILogger logger) : IActivityStore +{ + private readonly TokenCredential? _credential = options.UseAzureCredential ? new DefaultAzureCredential() : null; + + // Encrypt + TrustServerCertificate, like the Python sample's ODBC connection string: the emulator's SQL Server + // presents a self-signed certificate, so the connection is encrypted without validating the certificate chain. + private readonly string _connectionString = new SqlConnectionStringBuilder + { + DataSource = $"tcp:{options.Server},1433", + InitialCatalog = options.Database, + Encrypt = SqlConnectionEncryptOption.Mandatory, + TrustServerCertificate = true, + ConnectTimeout = 30, + UserID = options.UseAzureCredential ? "" : options.User!, + Password = options.UseAzureCredential ? "" : options.Password!, + }.ConnectionString; + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + // The table is provisioned by the deployment scripts; just prove the database is reachable. + await using var connection = await OpenAsync(cancellationToken); + logger.LogInformation("Connected to SQL Database [{Database}] on [{Server}]", options.Database, options.Server); + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand( + "SELECT id, activity FROM dbo.Activities WHERE username = @username ORDER BY timestamp DESC", connection); + command.Parameters.AddWithValue("@username", options.Username); + + var activities = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + activities.Add(new Activity(reader.GetGuid(0).ToString(), reader.GetString(1))); + } + + logger.LogInformation( + "Retrieved {Count} activities for user: {Username}", + activities.Count, + options.Username + ); + return activities; + } + + /// Reports success when the INSERT returned the new row, as the Python sample's OUTPUT INSERTED check does. + public async Task AddAsync(string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand( + """ + INSERT INTO dbo.Activities (username, activity, timestamp) + OUTPUT INSERTED.id + VALUES (@username, @activity, GETDATE()) + """, connection); + command.Parameters.AddWithValue("@username", options.Username); + command.Parameters.AddWithValue("@activity", text); + var id = await command.ExecuteScalarAsync(cancellationToken); + logger.LogInformation("Activity created: {Id}", id); + return id is not null; + } + + public async Task UpdateAsync(string id, string text, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand( + "UPDATE dbo.Activities SET activity = @activity, timestamp = GETDATE() WHERE id = CAST(@id AS UNIQUEIDENTIFIER)", connection); + command.Parameters.AddWithValue("@activity", text); + command.Parameters.AddWithValue("@id", id); + var rows = await command.ExecuteNonQueryAsync(cancellationToken); + if (rows == 0) + { + logger.LogWarning("No activity found with ID: {Id}", id); + } + else + { + logger.LogInformation("Updated activity with ID: {Id}", id); + } + + return rows > 0; + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand("DELETE FROM dbo.Activities WHERE id = CAST(@id AS UNIQUEIDENTIFIER)", connection); + command.Parameters.AddWithValue("@id", id); + var rows = await command.ExecuteNonQueryAsync(cancellationToken); + if (rows == 0) + { + logger.LogWarning("No activity found with ID: {Id}", id); + } + else + { + logger.LogInformation("Deleted activity with ID: {Id}", id); + } + + return rows > 0; + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await OpenAsync(cancellationToken); + await using var command = new SqlCommand("SELECT 1", connection); + await command.ExecuteScalarAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "SQL Database health check failed"); + return false; + } + } + + private async Task OpenAsync(CancellationToken cancellationToken) + { + var connection = new SqlConnection(_connectionString); + if (_credential is not null) + { + // Passwordless: present a Microsoft Entra access token for Azure SQL Database. + var token = await _credential.GetTokenAsync(new TokenRequestContext(["https://database.windows.net/.default"]), cancellationToken); + connection.AccessToken = token.Token; + } + + await connection.OpenAsync(cancellationToken); + return connection; + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Services/SqlOptions.cs b/samples/web-app-sql-database/dotnet/src/Services/SqlOptions.cs new file mode 100644 index 0000000..69e361f --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/SqlOptions.cs @@ -0,0 +1,34 @@ +namespace VacationPlanner.Services; + +/// +/// Connection settings read from the same environment variables the Python sample uses: SQL_SERVER and +/// SQL_DATABASE, with either SQL_USERNAME/SQL_PASSWORD (SQL authentication) or the +/// AZURE_CLIENT_ID/AZURE_CLIENT_SECRET/AZURE_TENANT_ID service principal (Microsoft Entra token). +/// +public sealed record SqlOptions(string Server, string Database, string? User, string? Password, bool UseAzureCredential, string Username) +{ + public static SqlOptions FromEnvironment() + { + var username = Environment.GetEnvironmentVariable("LOGIN_NAME") ?? "paolo"; + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("LOGIN_NAME is set to an empty value"); + } + + var server = Environment.GetEnvironmentVariable("SQL_SERVER"); + var database = Environment.GetEnvironmentVariable("SQL_DATABASE"); + var user = Environment.GetEnvironmentVariable("SQL_USERNAME"); + var password = Environment.GetEnvironmentVariable("SQL_PASSWORD"); + var useAzureCredential = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID") is { Length: > 0 } + && Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET") is { Length: > 0 } + && Environment.GetEnvironmentVariable("AZURE_TENANT_ID") is { Length: > 0 }; + + if (string.IsNullOrEmpty(server) || string.IsNullOrEmpty(database) || (!useAzureCredential && (string.IsNullOrEmpty(user) || string.IsNullOrEmpty(password)))) + { + throw new InvalidOperationException( + "Set SQL_SERVER and SQL_DATABASE, with SQL_USERNAME and SQL_PASSWORD or the AZURE_CLIENT_ID, AZURE_CLIENT_SECRET and AZURE_TENANT_ID service principal variables."); + } + + return new SqlOptions(server, database, user, password, useAzureCredential, username); + } +} diff --git a/samples/web-app-sql-database/dotnet/src/Services/StoreInitializer.cs b/samples/web-app-sql-database/dotnet/src/Services/StoreInitializer.cs new file mode 100644 index 0000000..c74eb50 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/Services/StoreInitializer.cs @@ -0,0 +1,33 @@ +namespace VacationPlanner.Services; + +/// +/// Runs at startup with a bounded retry, so the app fails fast +/// (and the container exits) when the backing service never becomes reachable. +/// +public sealed class StoreInitializer( + IActivityStore store, + ILogger logger, + int attempts = 1, + TimeSpan delay = default) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await store.InitializeAsync(cancellationToken); + logger.LogInformation("Activity store initialized after {Attempts} attempt(s).", attempt); + return; + } + catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Activity store not ready (attempt {Attempt}/{Attempts}); retrying in {Delay}s.", + attempt, attempts, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/samples/web-app-sql-database/dotnet/src/VacationPlanner.csproj b/samples/web-app-sql-database/dotnet/src/VacationPlanner.csproj new file mode 100644 index 0000000..f99d0de --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/VacationPlanner.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + VacationPlanner + + + + + + diff --git a/samples/web-app-sql-database/dotnet/src/appsettings.json b/samples/web-app-sql-database/dotnet/src/appsettings.json new file mode 100644 index 0000000..6e5c6b2 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss " + } + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-sql-database/dotnet/src/wwwroot/favicon.ico b/samples/web-app-sql-database/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-sql-database/dotnet/src/wwwroot/favicon.ico differ diff --git a/samples/web-app-sql-database/dotnet/src/wwwroot/style.css b/samples/web-app-sql-database/dotnet/src/wwwroot/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-sql-database/dotnet/src/wwwroot/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-sql-database/README.md b/samples/web-app-sql-database/python/README.md similarity index 75% rename from samples/web-app-sql-database/README.md rename to samples/web-app-sql-database/python/README.md index fc419ac..8927b34 100644 --- a/samples/web-app-sql-database/README.md +++ b/samples/web-app-sql-database/python/README.md @@ -1,17 +1,19 @@ # Vacation Planner: Azure SQL Database +> A .NET version of this sample lives in [../dotnet](../dotnet/README.md). + This sample demonstrates a Python Flask 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 `dbo.Activities` table of the `PlannerDB` database on an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview). The application connects to Azure SQL using a dedicated SQL login (rather than the server admin), and the deployment scripts seed the `Activities` table with a handful of sample plans so the app shows data on first load. 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. +- [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. +> **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 @@ -46,7 +48,7 @@ cd scripts | [`namespace.yml`](scripts/namespace.yml) | Creates the Kubernetes namespace. | | [`configmap.yml`](scripts/configmap.yml) | Creates the ConfigMap holding non-secret input values (SQL server FQDN, database, username, login name) passed to the app as environment variables. | | [`secret.yml`](scripts/secret.yml) | Creates the Secret holding sensitive values (the SQL password and the Flask secret key) passed to the app as environment variables. | -| [`deployment.yml`](scripts/deployment.yml) | Creates the Kubernetes Deployment, including the pod specification for the web app. | +| [`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 @@ -58,3 +60,17 @@ kubectl port-forward service/vacation-planner-sql 8080:80 -n vacation-planner-sq ``` 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 Azure SQL database is reachable and `503` with `{"status": "unavailable"}` otherwise. + +```bash +curl http://localhost:8080/health +``` + +## Logs + +The app logs one line per request — gunicorn writes an access log line for every call, the probes included, because its command passes `--access-logfile -` — plus one line per database read and write and one line for every activity added, updated or deleted. Every entry carries a timestamp, the logger name and the level; the Azure SDK and `urllib3` stay at warning level. The [.NET version](../dotnet/README.md) writes the same trace. + +```bash +kubectl logs deployment/vacation-planner-sql -n vacation-planner-sql --tail=50 +``` diff --git a/samples/web-app-sql-database/python/images/architecture.png b/samples/web-app-sql-database/python/images/architecture.png new file mode 100644 index 0000000..4a0eee0 Binary files /dev/null and b/samples/web-app-sql-database/python/images/architecture.png differ diff --git a/samples/web-app-sql-database/scripts/00-variables.sh b/samples/web-app-sql-database/python/scripts/00-variables.sh similarity index 100% rename from samples/web-app-sql-database/scripts/00-variables.sh rename to samples/web-app-sql-database/python/scripts/00-variables.sh diff --git a/samples/web-app-sql-database/python/scripts/01-deploy-resources.sh b/samples/web-app-sql-database/python/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..a305d58 --- /dev/null +++ b/samples/web-app-sql-database/python/scripts/01-deploy-resources.sh @@ -0,0 +1,248 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Create a resource group +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 "Creating resource group [$RESOURCE_GROUP_NAME]..." + az group create \ + --name $RESOURCE_GROUP_NAME \ + --location "$LOCATION" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "Resource group [$RESOURCE_GROUP_NAME] created." + else + echo "Failed to create resource group [$RESOURCE_GROUP_NAME]." + exit 1 + fi +else + echo "Resource group [$RESOURCE_GROUP_NAME] already exists." +fi + +# Create the Azure Container Registry +echo "Checking if [$ACR_NAME] Azure Container Registry exists..." +az acr show \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating Azure Container Registry [$ACR_NAME]..." + az acr create \ + --name "$ACR_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --sku "$ACR_SKU" \ + --admin-enabled "true" \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Azure Container Registry [$ACR_NAME] created." + else + echo "Failed to create Azure Container Registry [$ACR_NAME]." + exit 1 + fi +else + echo "[$ACR_NAME] Azure Container Registry already exists." +fi + +# Create the Azure SQL Server +echo "Checking if SQL server [$SQL_SERVER_NAME] exists..." +az sql server show \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating SQL server [$SQL_SERVER_NAME]..." + az sql server create \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --admin-user "$ADMIN_USER" \ + --admin-password "$ADMIN_PASSWORD" \ + --minimal-tls-version 1.2 \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "SQL server [$SQL_SERVER_NAME] created." + else + echo "Failed to create SQL server [$SQL_SERVER_NAME]." + exit 1 + fi +else + echo "SQL server [$SQL_SERVER_NAME] already exists." +fi + +# Add a permissive firewall rule (dev/test only) +echo "Ensuring firewall rule [$FIREWALL_RULE_NAME] exists on SQL server [$SQL_SERVER_NAME]..." +az sql server firewall-rule create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --server "$SQL_SERVER_NAME" \ + --name "$FIREWALL_RULE_NAME" \ + --start-ip-address 0.0.0.0 \ + --end-ip-address 255.255.255.255 \ + --only-show-errors 1>/dev/null + +# Create the SQL Database +echo "Checking if SQL database [$SQL_DATABASE_NAME] exists..." +az sql db show \ + --name "$SQL_DATABASE_NAME" \ + --server "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "Creating SQL database [$SQL_DATABASE_NAME]..." + az sql db create \ + --name "$SQL_DATABASE_NAME" \ + --server "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --service-objective S0 \ + --compute-model Provisioned \ + --zone-redundant false \ + --only-show-errors 1>/dev/null + + if [ $? -eq 0 ]; then + echo "SQL database [$SQL_DATABASE_NAME] created." + else + echo "Failed to create SQL database [$SQL_DATABASE_NAME]." + exit 1 + fi +else + echo "SQL database [$SQL_DATABASE_NAME] already exists." +fi + +# Retrieve SQL Server FQDN +SQL_SERVER_FQDN=$(az sql server show \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$SQL_SERVER_FQDN" ]; then + echo "Failed to retrieve SQL server FQDN." + exit 1 +fi +echo "SQL server FQDN: $SQL_SERVER_FQDN" + +# Create a SQL login + database user + grant roles + create the Activities table. +# sqlcmd must be available on the host machine. +if ! command -v sqlcmd &>/dev/null; then + echo "sqlcmd is not installed on the host. Install Microsoft sqlcmd tools (mssql-tools / go-sqlcmd) and re-run." + exit 1 +fi + +echo "Creating SQL login [$DATABASE_USER_NAME] on server [$SQL_SERVER_FQDN]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -d master \ + -Q "IF NOT EXISTS (SELECT name FROM sys.sql_logins WHERE name = '$DATABASE_USER_NAME') CREATE LOGIN [$DATABASE_USER_NAME] WITH PASSWORD = '$DATABASE_USER_PASSWORD';" + +if [ $? -eq 0 ]; then + echo "Login [$DATABASE_USER_NAME] created successfully" +else + echo "Failed to create login [$DATABASE_USER_NAME]" + exit 1 +fi + +echo "Creating database user [$DATABASE_USER_NAME] in database [$SQL_DATABASE_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -d "$SQL_DATABASE_NAME" \ + -Q "IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = '$DATABASE_USER_NAME') CREATE USER [$DATABASE_USER_NAME] FOR LOGIN [$DATABASE_USER_NAME];" + +if [ $? -eq 0 ]; then + echo "User [$DATABASE_USER_NAME] created successfully in database [$SQL_DATABASE_NAME]" +else + echo "Failed to create user [$DATABASE_USER_NAME]" + exit 1 +fi + +echo "Granting roles db_datareader, db_datawriter, db_ddladmin to [$DATABASE_USER_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -d "$SQL_DATABASE_NAME" \ + -Q "ALTER ROLE db_datareader ADD MEMBER [$DATABASE_USER_NAME]; ALTER ROLE db_datawriter ADD MEMBER [$DATABASE_USER_NAME]; ALTER ROLE db_ddladmin ADD MEMBER [$DATABASE_USER_NAME];" + +if [ $? -eq 0 ]; then + echo "Permissions granted successfully to [$DATABASE_USER_NAME]" +else + echo "Failed to grant permissions to [$DATABASE_USER_NAME]" + exit 1 +fi + +echo "Creating table dbo.Activities in database [$SQL_DATABASE_NAME]..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -U "$ADMIN_USER" \ + -P "$ADMIN_PASSWORD" \ + -d "$SQL_DATABASE_NAME" \ + -Q "IF NOT EXISTS (SELECT * FROM sysobjects WHERE name = 'Activities' AND xtype = 'U') CREATE TABLE dbo.Activities (id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(), username VARCHAR(32) NOT NULL, activity VARCHAR(128) NOT NULL, timestamp DATETIME NOT NULL);" + +if [ $? -eq 0 ]; then + echo "Test [Activities] table created successfully" +else + echo "Failed to create test [Activities] table" + exit 1 +fi + +# Insert data +echo "Inserting test data into [Activities] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -N -C \ + -Q "IF NOT EXISTS (SELECT 1 FROM Activities) + INSERT INTO Activities (username, activity, timestamp) + VALUES + ('paolo', 'Visit the Leaning Tower in Pisa', GETDATE()), + ('paolo', 'Explore Etruscan walls in Volterra', GETDATE()), + ('paolo', 'Climb Torre Grossa in San Gimignano', GETDATE()), + ('paolo', 'Walk across Piazza del Campo in Siena', GETDATE()), + ('paolo', 'Taste Brunello wine in Montalcino', GETDATE()), + ('paolo', 'Sample Pecorino cheese in Pienza', GETDATE()), + ('paolo', 'Admire Michelangelo''s David in Florence', GETDATE()), + ('paolo', 'Relax by the beach in Viareggio', GETDATE()), + ('paolo', 'Stroll along the Viareggio promenade', GETDATE());" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test data inserted successfully into [Activities] table" +else + echo "Failed to insert test data into [Activities] table" + exit 1 +fi + +# Query data +echo "Querying test data from [Activities] table..." +sqlcmd -S "$SQL_SERVER_FQDN" \ + -d "$SQL_DATABASE_NAME" \ + -U "$DATABASE_USER_NAME" \ + -P "$DATABASE_USER_PASSWORD" \ + -N -C \ + -Q "SELECT + id, + CAST(username AS VARCHAR(8)) AS username, + CAST(activity AS VARCHAR(50)) AS activity, + timestamp + FROM Activities;" \ + -V 1 + +if [ $? -eq 0 ]; then + echo "Test data queried successfully from [Activities] table" +else + echo "Failed to query test data from [Activities] table" + exit 1 +fi \ No newline at end of file diff --git a/samples/web-app-sql-database/scripts/02-build-docker-image.sh b/samples/web-app-sql-database/python/scripts/02-build-docker-image.sh similarity index 100% rename from samples/web-app-sql-database/scripts/02-build-docker-image.sh rename to samples/web-app-sql-database/python/scripts/02-build-docker-image.sh diff --git a/samples/web-app-sql-database/python/scripts/03-run-docker-container.sh b/samples/web-app-sql-database/python/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..8fd863b --- /dev/null +++ b/samples/web-app-sql-database/python/scripts/03-run-docker-container.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Retrieve the SQL server FQDN +SQL_SERVER_FQDN=$(az sql server show \ + --name "$SQL_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$SQL_SERVER_FQDN" ]; then + echo "Failed to retrieve SQL server FQDN. Run 01-deploy-resources.sh first." + exit 1 +fi + +# --network=host so endpoints like *.localhost.localstack.cloud resolve to the +# host's loopback (where LocalStack is listening), not the container's. +docker run -it \ + --rm \ + --network=host \ + -e PORT=$PORT \ + -e SQL_SERVER="$SQL_SERVER_FQDN" \ + -e SQL_DATABASE="$SQL_DATABASE_NAME" \ + -e SQL_USERNAME="$DATABASE_USER_NAME" \ + -e SQL_PASSWORD="$DATABASE_USER_PASSWORD" \ + -e LOGIN_NAME="$LOGIN_NAME" \ + --name "$IMAGE_NAME" \ + "$IMAGE_NAME:$IMAGE_TAG" diff --git a/samples/web-app-sql-database/python/scripts/04-push-docker-image.sh b/samples/web-app-sql-database/python/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-sql-database/python/scripts/04-push-docker-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Login to ACR +echo "Logging into Azure Container Registry [$ACR_NAME]..." +az acr login --name $ACR_NAME + +# Retrieve ACR login server. Each container image needs to be tagged with the loginServer name of the registry. +ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --query loginServer --output tsv) + +if [ $? -eq 0 ]; then + echo "Logged into Azure Container Registry [$ACR_NAME] successfully." +else + echo "Failed to log into Azure Container Registry [$ACR_NAME]." + exit 1 +fi + +FULL_IMAGE="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + +# Tag the local image with the loginServer of ACR +docker tag ${IMAGE_NAME,,}:$IMAGE_TAG $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$IMAGE_NAME] tagged as [$FULL_IMAGE] successfully." +else + echo "Failed to tag Docker image [$IMAGE_NAME] as [$FULL_IMAGE]." + exit 1 +fi + +# Push the container image to ACR +docker push $ACR_LOGIN_SERVER/${IMAGE_NAME,,}:$IMAGE_TAG + +if [ $? -eq 0 ]; then + echo "Docker image [$FULL_IMAGE] pushed to ACR successfully." +else + echo "Failed to push Docker image [$FULL_IMAGE] to ACR." + exit 1 +fi diff --git a/samples/web-app-sql-database/scripts/05-deploy-app.sh b/samples/web-app-sql-database/python/scripts/05-deploy-app.sh similarity index 73% rename from samples/web-app-sql-database/scripts/05-deploy-app.sh rename to samples/web-app-sql-database/python/scripts/05-deploy-app.sh index f8909de..db2c91b 100755 --- a/samples/web-app-sql-database/scripts/05-deploy-app.sh +++ b/samples/web-app-sql-database/python/scripts/05-deploy-app.sh @@ -16,7 +16,14 @@ if [ -z "$SQL_SERVER_FQDN" ]; then fi # Generate a stable Flask SECRET_KEY (sessions survive pod restarts) -FLASK_SECRET_KEY=$(openssl rand -hex 32) +# Reuse the key already stored in the Secret, when there is one. A new key on every run would leave the +# running pods signing with the old one, so their sessions, flash messages and antiforgery tokens break +# across replicas until every pod has restarted. +FLASK_SECRET_KEY=$(kubectl get secret vacation-planner-sql-secrets --namespace $NAMESPACE --output jsonpath='{.data.FLASK_SECRET_KEY}' 2>/dev/null | base64 --decode 2>/dev/null) + +if [[ -z $FLASK_SECRET_KEY ]]; then + FLASK_SECRET_KEY=$(openssl rand -hex 32) +fi # Get the login server for the Azure Container Registry echo "Getting login server for Azure Container Registry [$ACR_NAME]..." @@ -69,3 +76,7 @@ kubectl apply -f - cat service.yml | yq "(.metadata.namespace)|="\""$NAMESPACE"\" | kubectl apply -f - + +# Roll the pods so a re-push of the same image tag actually takes effect: the pod template is unchanged, +# so kubectl apply reports no change and leaves the running pods on the image they started with. +kubectl rollout restart deployment/$DEPLOYMENT_NAME --namespace $NAMESPACE diff --git a/samples/web-app-sql-database/scripts/Dockerfile b/samples/web-app-sql-database/python/scripts/Dockerfile similarity index 100% rename from samples/web-app-sql-database/scripts/Dockerfile rename to samples/web-app-sql-database/python/scripts/Dockerfile diff --git a/samples/web-app-sql-database/python/scripts/configmap.yml b/samples/web-app-sql-database/python/scripts/configmap.yml new file mode 100644 index 0000000..e7a7a87 --- /dev/null +++ b/samples/web-app-sql-database/python/scripts/configmap.yml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-sql-config + namespace: vacation-planner-sql + labels: + app: vacation-planner-sql +data: + SQL_SERVER: "" + SQL_DATABASE: "" + SQL_USERNAME: "" + LOGIN_NAME: "Paolo" diff --git a/samples/web-app-sql-database/scripts/deployment.yml b/samples/web-app-sql-database/python/scripts/deployment.yml similarity index 97% rename from samples/web-app-sql-database/scripts/deployment.yml rename to samples/web-app-sql-database/python/scripts/deployment.yml index 8054def..ed2e16f 100644 --- a/samples/web-app-sql-database/scripts/deployment.yml +++ b/samples/web-app-sql-database/python/scripts/deployment.yml @@ -70,7 +70,7 @@ spec: memory: "512Mi" livenessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 30 periodSeconds: 30 @@ -78,7 +78,7 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: / + path: /health port: http initialDelaySeconds: 10 periodSeconds: 10 diff --git a/samples/web-app-sql-database/python/scripts/namespace.yml b/samples/web-app-sql-database/python/scripts/namespace.yml new file mode 100644 index 0000000..ea0aca4 --- /dev/null +++ b/samples/web-app-sql-database/python/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-sql diff --git a/samples/web-app-sql-database/scripts/secret.yml b/samples/web-app-sql-database/python/scripts/secret.yml similarity index 100% rename from samples/web-app-sql-database/scripts/secret.yml rename to samples/web-app-sql-database/python/scripts/secret.yml diff --git a/samples/web-app-sql-database/python/scripts/service.yml b/samples/web-app-sql-database/python/scripts/service.yml new file mode 100644 index 0000000..31fa2e6 --- /dev/null +++ b/samples/web-app-sql-database/python/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-sql + namespace: vacation-planner-sql + labels: + app: vacation-planner-sql +spec: + type: ClusterIP + selector: + app: vacation-planner-sql + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-sql-database/src/activities.py b/samples/web-app-sql-database/python/src/activities.py similarity index 100% rename from samples/web-app-sql-database/src/activities.py rename to samples/web-app-sql-database/python/src/activities.py diff --git a/samples/web-app-sql-database/src/app.py b/samples/web-app-sql-database/python/src/app.py similarity index 76% rename from samples/web-app-sql-database/src/app.py rename to samples/web-app-sql-database/python/src/app.py index eccb8e2..37d3be9 100644 --- a/samples/web-app-sql-database/src/app.py +++ b/samples/web-app-sql-database/python/src/app.py @@ -4,7 +4,7 @@ from typing import List, Tuple from activities import ActivitiesHelper -from flask import Flask, flash, redirect, render_template, request, url_for +from flask import Flask, flash, jsonify, redirect, render_template, request, url_for # Initialize Flask application app: Flask = Flask(__name__) @@ -106,39 +106,53 @@ def favicon(): """Serve the favicon from the static folder.""" return app.send_static_file('favicon.ico') -@app.route('/delete/', methods=['POST']) -def delete(activity_id: int): - """Handle deletion of an activity by its index in the list.""" +@app.route('/delete/', methods=['POST']) +def delete(activity_id: str): + """Handle deletion of an activity by its database id. + + The id addresses the activity, never its position in the rendered page: every replica reloads the table + on each GET, so a position would delete whatever activity happens to sit there now. + """ try: - if 0 <= activity_id < len(activities): - db_activity_id = activities[activity_id][0] + if activity_id: # Delete the activity from SQL Database - rows_deleted = activities_helper.delete_activity_by_id(db_activity_id) - + rows_deleted = activities_helper.delete_activity_by_id(activity_id) + if rows_deleted > 0: flash('Activity deleted.') - logger.info(f"Activity deleted: {db_activity_id}") + logger.info(f"Activity deleted: {activity_id}") else: - logger.warning(f"No activity found with ID: {db_activity_id}") + logger.warning(f"No activity found with ID: {activity_id}") except (ConnectionError, ValueError) as e: logger.error("Error deleting activity: %s", e) return redirect(url_for('index')) -@app.route('/update/', methods=['GET']) -def update(activity_id: int): - """Handle updating of an activity by its index in the list.""" +@app.route('/update/', methods=['GET']) +def update(activity_id: str): + """Open the edit form for the activity with this database id.""" try: - if 0 <= activity_id < len(activities): - db_activity_id = activities[activity_id][0] - activity_text = activities[activity_id][1] - # Redirect to index with edit parameters - return redirect(url_for('index', edit_id=db_activity_id, edit_activity=activity_text)) + if activity_id: + # Read the current text from the database, so the form does not depend on this replica's + # in-process list, which another replica may have made stale. + for stored_id, activity_text in read_activities_from_db(username): + if stored_id == activity_id: + # Redirect to index with edit parameters + return redirect(url_for('index', edit_id=activity_id, edit_activity=activity_text)) + logger.warning(f"No activity found with ID: {activity_id}") except (ConnectionError, ValueError) as e: logger.error("Error preparing activity for update: %s", e) return redirect(url_for('index')) +@app.route('/health') +def health(): + """Liveness and readiness probe: reports whether Azure SQL Database answers SELECT 1.""" + if activities_helper and activities_helper.test_connection(): + return jsonify({"status": "ok"}) + logger.warning("Health check failed: the SQL Database connection test did not succeed") + return jsonify({"status": "unavailable"}), 503 + # Read debug environment variable debug = os.environ.get("DEBUG", "false").lower() == "true" diff --git a/samples/web-app-sql-database/src/database.py b/samples/web-app-sql-database/python/src/database.py similarity index 100% rename from samples/web-app-sql-database/src/database.py rename to samples/web-app-sql-database/python/src/database.py diff --git a/samples/web-app-sql-database/src/gunicorn.conf.py b/samples/web-app-sql-database/python/src/gunicorn.conf.py similarity index 100% rename from samples/web-app-sql-database/src/gunicorn.conf.py rename to samples/web-app-sql-database/python/src/gunicorn.conf.py diff --git a/samples/web-app-sql-database/src/requirements.txt b/samples/web-app-sql-database/python/src/requirements.txt similarity index 100% rename from samples/web-app-sql-database/src/requirements.txt rename to samples/web-app-sql-database/python/src/requirements.txt diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.min.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.min.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.min.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.min.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.min.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.min.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.min.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.min.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.rtl.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.rtl.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.rtl.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.rtl.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.rtl.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.rtl.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.rtl.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.rtl.min.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.rtl.min.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.min.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.min.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.min.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.min.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.min.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.min.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.min.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.rtl.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.rtl.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.min.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.min.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.min.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.min.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.min.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.min.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.min.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.rtl.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.rtl.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.min.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.min.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.min.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.min.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.min.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.min.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.min.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.min.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.rtl.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.rtl.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.rtl.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.rtl.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.rtl.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.rtl.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.rtl.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.rtl.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.rtl.min.css b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.rtl.min.css similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.rtl.min.css rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.rtl.min.css diff --git a/samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.rtl.min.css.map b/samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/css/bootstrap.rtl.min.css.map rename to samples/web-app-sql-database/python/src/static/bootstrap/css/bootstrap.rtl.min.css.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.bundle.js b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.bundle.js rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.bundle.js diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.bundle.js.map b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.bundle.js.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.bundle.js.map rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.bundle.js.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.bundle.min.js b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.bundle.min.js similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.bundle.min.js rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.bundle.min.js diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.bundle.min.js.map b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.bundle.min.js.map rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.bundle.min.js.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.esm.js b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.esm.js similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.esm.js rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.esm.js diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.esm.js.map b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.esm.js.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.esm.js.map rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.esm.js.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.esm.min.js b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.esm.min.js similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.esm.min.js rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.esm.min.js diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.esm.min.js.map b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.esm.min.js.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.esm.min.js.map rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.esm.min.js.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.js b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.js similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.js rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.js diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.js.map b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.js.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.js.map rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.js.map diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.min.js b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.min.js similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.min.js rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.min.js diff --git a/samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.min.js.map b/samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.min.js.map similarity index 100% rename from samples/web-app-sql-database/src/static/bootstrap/js/bootstrap.min.js.map rename to samples/web-app-sql-database/python/src/static/bootstrap/js/bootstrap.min.js.map diff --git a/samples/web-app-sql-database/python/src/static/favicon.ico b/samples/web-app-sql-database/python/src/static/favicon.ico new file mode 100644 index 0000000..5b1d5cf Binary files /dev/null and b/samples/web-app-sql-database/python/src/static/favicon.ico differ diff --git a/samples/web-app-sql-database/python/src/static/style.css b/samples/web-app-sql-database/python/src/static/style.css new file mode 100644 index 0000000..67508fa --- /dev/null +++ b/samples/web-app-sql-database/python/src/static/style.css @@ -0,0 +1,341 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --teal-50: #f0fafb; + --teal-100: #d0f0f5; + --teal-500: #0e9db0; + --teal-600: #0e6ba8; + --teal-700: #0a5a8e; + --teal-800: #074d78; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; + --white: #ffffff; + --bg: #f0f8ff; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -2px rgba(0,0,0,.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -4px rgba(0,0,0,.06); + --radius: 12px; + --toast-bg: #111827; + --toast-fg: #ffffff; +} + +html[data-theme="dark"] { + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-700: #e2e8f0; + --gray-900: #f8fafc; + --white: #1e293b; + --bg: #0a1929; + --teal-50: #0e2a38; + --teal-700: #7dd3e8; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); + --shadow: 0 4px 6px -1px rgba(0,0,0,.5), 0 2px 4px -2px rgba(0,0,0,.4); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.6), 0 4px 6px -4px rgba(0,0,0,.4); + --toast-bg: #334155; + --toast-fg: #f8fafc; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--gray-900); + min-height: 100vh; + transition: background 0.2s, color 0.2s; +} + +/* ── Header ─────────────────────────────────────────── */ +header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.5rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + box-shadow: var(--shadow-lg); +} + +.header-left h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; } +.header-left p { font-size: 0.85rem; opacity: 0.8; margin-top: 2px; } + +.header-right { display: flex; align-items: center; gap: 0.6rem; } + +#btn-dark-mode { + background: rgba(255,255,255,.15); + color: #ffffff; + border: 1.5px solid rgba(255,255,255,.3); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +#btn-dark-mode:hover { background: rgba(255,255,255,.25); transform: translateY(-1px); } + +#btn-add { + background: #ffffff; + color: var(--teal-700); + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s, transform 0.1s; + white-space: nowrap; +} +#btn-add:hover { background: var(--teal-50); transform: translateY(-1px); } + +/* ── Content area ────────────────────────────────────── */ +.content { + max-width: 820px; + margin: 2rem auto; + padding: 0 1.5rem 3rem; +} + +/* ── Table ───────────────────────────────────────────── */ +#activity-table { + width: 100%; + border-collapse: collapse; + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: background 0.2s; +} + +#activity-table thead tr { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; +} + +#activity-table th { + padding: 0.85rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-align: left; +} + +#activity-table th.col-actions { text-align: center; } + +#activity-table td { + padding: 0.75rem 1.1rem; + font-size: 0.93rem; + color: var(--gray-900); + border-bottom: 1px solid var(--gray-200); + transition: background 0.15s, color 0.2s, border-color 0.2s; +} + +#activity-table tbody tr:last-child td { border-bottom: none; } +#activity-table tbody tr:hover td { background: var(--teal-50); } + +.col-btn { + width: 1px; + text-align: center; + padding-left: 0.3rem !important; + padding-right: 0.3rem !important; + white-space: nowrap; +} + +#activity-table td.col-btn:last-child { padding-right: 0.6rem !important; } + +/* ── Row action buttons ──────────────────────────────── */ +.btn-edit, .btn-delete { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.55rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s, transform 0.1s; + white-space: nowrap; + width: 90px; + justify-content: center; +} + +.btn-edit { + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); +} + +.btn-edit:hover { + background: var(--teal-50); + transform: translateY(-1px); +} + +.btn-delete { + border: none; + background: var(--teal-600); + color: #ffffff; +} + +.btn-delete:hover { + background: var(--teal-700); + transform: translateY(-1px); +} + +/* ── Empty cell ──────────────────────────────────────── */ +.empty-cell { + text-align: center; + color: var(--gray-400) !important; + font-style: italic; + padding: 3rem 1rem !important; +} + +/* ── Modal overlay ───────────────────────────────────── */ +#overlay, #delete-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 100; + align-items: center; + justify-content: center; + padding: 1rem; +} +#overlay.open, #delete-overlay.open { display: flex; } + +.modal { + background: var(--white); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 460px; + overflow: hidden; + transition: background 0.2s; +} + +.modal-header { + background: linear-gradient(135deg, var(--teal-800) 0%, var(--teal-600) 100%); + color: #ffffff; + padding: 1.1rem 1.4rem; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-header h2 { font-size: 1rem; font-weight: 600; } + +#btn-close-modal, #btn-close-delete-modal { + background: none; + border: none; + color: rgba(255,255,255,.8); + cursor: pointer; + font-size: 1.4rem; + line-height: 1; + padding: 2px; + transition: color 0.15s; +} +#btn-close-modal:hover, #btn-close-delete-modal:hover { color: #ffffff; } + +.modal-body { + padding: 1.4rem; + color: var(--gray-700); + font-size: 0.93rem; + line-height: 1.5; + transition: color 0.2s; +} + +.modal form { + padding: 1.4rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field label { font-size: 0.82rem; font-weight: 600; color: var(--gray-700); } + +.field input { + padding: 0.55rem 0.8rem; + border: 1.5px solid var(--gray-200); + border-radius: 7px; + font-size: 0.9rem; + font-family: inherit; + color: var(--gray-900); + background: var(--white); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s, background 0.2s, color 0.2s; +} +.field input:focus { + border-color: var(--teal-500); + box-shadow: 0 0 0 3px rgba(14,109,168,.15); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0 1.4rem 1.4rem; +} + +.btn-secondary { + padding: 0.55rem 1.1rem; + border-radius: 7px; + border: 1.5px solid var(--teal-700); + background: var(--white); + color: var(--teal-700); + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--teal-50); } + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 7px; + border: none; + background: var(--teal-600); + color: #ffffff; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--teal-700); } + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--toast-bg); + color: var(--toast-fg); + padding: 0.65rem 1.1rem; + border-radius: 8px; + font-size: 0.85rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; +} +#toast.show { opacity: 1; transform: none; } + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 600px) { + header { padding: 1.2rem 1rem; } + .content { padding: 1rem 0.75rem 3rem; } + .col-btn { white-space: nowrap; } + .btn-edit, .btn-delete { width: auto; padding: 0.55rem 0.5rem; } +} + diff --git a/samples/web-app-sql-database/python/src/templates/index.html b/samples/web-app-sql-database/python/src/templates/index.html new file mode 100644 index 0000000..efb642d --- /dev/null +++ b/samples/web-app-sql-database/python/src/templates/index.html @@ -0,0 +1,260 @@ + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

{{ activities|length }} activit{{ 'ies' if activities|length != 1 else 'y' }} planned

+
+
+ + +
+
+ + +
+ + + + + + + + + {% for activity in activities %} + + + + + + {% else %} + + + + {% endfor %} + +
ActivityActions
{{ activity[1] }} + + +
+ +
+
No vacation plans yet — add your first activity!
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + diff --git a/samples/web-app-sql-database/src/templates/index.html b/samples/web-app-sql-database/src/templates/index.html deleted file mode 100644 index 45a7be8..0000000 --- a/samples/web-app-sql-database/src/templates/index.html +++ /dev/null @@ -1,260 +0,0 @@ - - - - - - Vacation Planner - - - - - - - - - -
-
-

🌴 Vacation Planner

-

{{ activities|length }} activit{{ 'ies' if activities|length != 1 else 'y' }} planned

-
-
- - -
-
- - -
- - - - - - - - - {% for activity in activities %} - - - - - - {% else %} - - - - {% endfor %} - -
ActivityActions
{{ activity[1] }} - - -
- -
-
No vacation plans yet — add your first activity!
-
- - -
- -
- - -
- -
- - -
- - - -