From 415d79015664c24fc1dd379a613e787be3b39ee7 Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Tue, 8 Sep 2026 12:08:22 +0200 Subject: [PATCH 01/20] Move the Python samples into python/ subfolders Each samples/web-app-/ now holds its Python implementation under python/ (README, images, scripts, src) to make room for a .NET twin under dotnet/. Relative links that reached outside a sample gained one level, the root README table points at python/README.md, every Python README links to its upcoming .NET sibling, .gitignore learns the .NET build outputs and the files sample scripts drop into scripts/, and a .gitattributes pins LF line endings for scripts, manifests and .NET sources. Co-Authored-By: Claude Fable 5.1 --- .gitattributes | 6 ++ .gitignore | 58 +++++++++++++++++- README.md | 18 +++--- .../{ => python}/README.md | 10 +-- .../{ => python}/images/architecture.png | Bin .../{ => python}/scripts/00-variables.sh | 0 .../scripts/01-deploy-resources.sh | 0 .../scripts/02-build-docker-image.sh | 0 .../scripts/03-run-docker-container.sh | 0 .../scripts/04-push-docker-image.sh | 0 .../{ => python}/scripts/05-deploy-app.sh | 0 .../{ => python}/scripts/Dockerfile | 0 .../{ => python}/scripts/configmap.yml | 0 .../{ => python}/scripts/deployment.yml | 0 .../{ => python}/scripts/namespace.yml | 0 .../{ => python}/scripts/secret.yml | 0 .../{ => python}/scripts/service.yml | 0 .../{ => python}/src/app.py | 0 .../{ => python}/src/gunicorn.conf.py | 0 .../{ => python}/src/requirements.txt | 0 .../static/bootstrap/css/bootstrap-grid.css | 0 .../bootstrap/css/bootstrap-grid.css.map | 0 .../bootstrap/css/bootstrap-grid.min.css | 0 .../bootstrap/css/bootstrap-grid.min.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.css | 0 .../bootstrap/css/bootstrap-grid.rtl.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.min.css | 0 .../css/bootstrap-grid.rtl.min.css.map | 0 .../static/bootstrap/css/bootstrap-reboot.css | 0 .../bootstrap/css/bootstrap-reboot.css.map | 0 .../bootstrap/css/bootstrap-reboot.min.css | 0 .../css/bootstrap-reboot.min.css.map | 0 .../bootstrap/css/bootstrap-reboot.rtl.css | 0 .../css/bootstrap-reboot.rtl.css.map | 0 .../css/bootstrap-reboot.rtl.min.css | 0 .../css/bootstrap-reboot.rtl.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.css | 0 .../bootstrap/css/bootstrap-utilities.css.map | 0 .../bootstrap/css/bootstrap-utilities.min.css | 0 .../css/bootstrap-utilities.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.rtl.css | 0 .../css/bootstrap-utilities.rtl.css.map | 0 .../css/bootstrap-utilities.rtl.min.css | 0 .../css/bootstrap-utilities.rtl.min.css.map | 0 .../src/static/bootstrap/css/bootstrap.css | 0 .../static/bootstrap/css/bootstrap.css.map | 0 .../static/bootstrap/css/bootstrap.min.css | 0 .../bootstrap/css/bootstrap.min.css.map | 0 .../static/bootstrap/css/bootstrap.rtl.css | 0 .../bootstrap/css/bootstrap.rtl.css.map | 0 .../bootstrap/css/bootstrap.rtl.min.css | 0 .../bootstrap/css/bootstrap.rtl.min.css.map | 0 .../static/bootstrap/js/bootstrap.bundle.js | 0 .../bootstrap/js/bootstrap.bundle.js.map | 0 .../bootstrap/js/bootstrap.bundle.min.js | 0 .../bootstrap/js/bootstrap.bundle.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.esm.js | 0 .../static/bootstrap/js/bootstrap.esm.js.map | 0 .../static/bootstrap/js/bootstrap.esm.min.js | 0 .../bootstrap/js/bootstrap.esm.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.js | 0 .../src/static/bootstrap/js/bootstrap.js.map | 0 .../src/static/bootstrap/js/bootstrap.min.js | 0 .../static/bootstrap/js/bootstrap.min.js.map | 0 .../{ => python}/src/static/favicon.ico | Bin .../{ => python}/src/static/style.css | 0 .../{ => python}/src/templates/index.html | 0 .../{ => python}/README.md | 8 ++- .../{ => python}/images/architecture.png | Bin .../{ => python}/scripts/00-variables.sh | 0 .../scripts/01-deploy-resources.sh | 0 .../scripts/02-build-docker-image.sh | 0 .../scripts/03-run-docker-container.sh | 0 .../scripts/04-push-docker-image.sh | 0 .../{ => python}/scripts/05-deploy-app.sh | 0 .../{ => python}/scripts/Dockerfile | 0 .../{ => python}/scripts/configmap.yml | 0 .../{ => python}/scripts/deployment.yml | 0 .../{ => python}/scripts/namespace.yml | 0 .../{ => python}/scripts/secret.yml | 0 .../{ => python}/scripts/service.yml | 0 .../{ => python}/src/app.py | 0 .../{ => python}/src/gunicorn.conf.py | 0 .../{ => python}/src/mongodb.py | 0 .../{ => python}/src/requirements.txt | 0 .../static/bootstrap/css/bootstrap-grid.css | 0 .../bootstrap/css/bootstrap-grid.css.map | 0 .../bootstrap/css/bootstrap-grid.min.css | 0 .../bootstrap/css/bootstrap-grid.min.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.css | 0 .../bootstrap/css/bootstrap-grid.rtl.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.min.css | 0 .../css/bootstrap-grid.rtl.min.css.map | 0 .../static/bootstrap/css/bootstrap-reboot.css | 0 .../bootstrap/css/bootstrap-reboot.css.map | 0 .../bootstrap/css/bootstrap-reboot.min.css | 0 .../css/bootstrap-reboot.min.css.map | 0 .../bootstrap/css/bootstrap-reboot.rtl.css | 0 .../css/bootstrap-reboot.rtl.css.map | 0 .../css/bootstrap-reboot.rtl.min.css | 0 .../css/bootstrap-reboot.rtl.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.css | 0 .../bootstrap/css/bootstrap-utilities.css.map | 0 .../bootstrap/css/bootstrap-utilities.min.css | 0 .../css/bootstrap-utilities.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.rtl.css | 0 .../css/bootstrap-utilities.rtl.css.map | 0 .../css/bootstrap-utilities.rtl.min.css | 0 .../css/bootstrap-utilities.rtl.min.css.map | 0 .../src/static/bootstrap/css/bootstrap.css | 0 .../static/bootstrap/css/bootstrap.css.map | 0 .../static/bootstrap/css/bootstrap.min.css | 0 .../bootstrap/css/bootstrap.min.css.map | 0 .../static/bootstrap/css/bootstrap.rtl.css | 0 .../bootstrap/css/bootstrap.rtl.css.map | 0 .../bootstrap/css/bootstrap.rtl.min.css | 0 .../bootstrap/css/bootstrap.rtl.min.css.map | 0 .../static/bootstrap/js/bootstrap.bundle.js | 0 .../bootstrap/js/bootstrap.bundle.js.map | 0 .../bootstrap/js/bootstrap.bundle.min.js | 0 .../bootstrap/js/bootstrap.bundle.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.esm.js | 0 .../static/bootstrap/js/bootstrap.esm.js.map | 0 .../static/bootstrap/js/bootstrap.esm.min.js | 0 .../bootstrap/js/bootstrap.esm.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.js | 0 .../src/static/bootstrap/js/bootstrap.js.map | 0 .../src/static/bootstrap/js/bootstrap.min.js | 0 .../static/bootstrap/js/bootstrap.min.js.map | 0 .../{ => python}/src/static/favicon.ico | Bin .../{ => python}/src/static/style.css | 0 .../{ => python}/src/templates/index.html | 0 .../{ => python}/README.md | 8 ++- .../{ => python}/images/architecture.png | Bin .../{ => python}/scripts/00-variables.sh | 0 .../scripts/01-deploy-resources.sh | 0 .../scripts/02-build-docker-image.sh | 0 .../scripts/03-run-docker-container.sh | 0 .../scripts/04-push-docker-image.sh | 0 .../{ => python}/scripts/05-deploy-app.sh | 0 .../{ => python}/scripts/Dockerfile | 0 .../{ => python}/scripts/configmap.yml | 0 .../{ => python}/scripts/deployment.yml | 0 .../{ => python}/scripts/namespace.yml | 0 .../{ => python}/scripts/secret.yml | 0 .../{ => python}/scripts/service.yml | 0 .../{ => python}/src/app.py | 0 .../{ => python}/src/cosmosdb_client.py | 0 .../{ => python}/src/gunicorn.conf.py | 0 .../{ => python}/src/requirements.txt | 0 .../static/bootstrap/css/bootstrap-grid.css | 0 .../bootstrap/css/bootstrap-grid.css.map | 0 .../bootstrap/css/bootstrap-grid.min.css | 0 .../bootstrap/css/bootstrap-grid.min.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.css | 0 .../bootstrap/css/bootstrap-grid.rtl.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.min.css | 0 .../css/bootstrap-grid.rtl.min.css.map | 0 .../static/bootstrap/css/bootstrap-reboot.css | 0 .../bootstrap/css/bootstrap-reboot.css.map | 0 .../bootstrap/css/bootstrap-reboot.min.css | 0 .../css/bootstrap-reboot.min.css.map | 0 .../bootstrap/css/bootstrap-reboot.rtl.css | 0 .../css/bootstrap-reboot.rtl.css.map | 0 .../css/bootstrap-reboot.rtl.min.css | 0 .../css/bootstrap-reboot.rtl.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.css | 0 .../bootstrap/css/bootstrap-utilities.css.map | 0 .../bootstrap/css/bootstrap-utilities.min.css | 0 .../css/bootstrap-utilities.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.rtl.css | 0 .../css/bootstrap-utilities.rtl.css.map | 0 .../css/bootstrap-utilities.rtl.min.css | 0 .../css/bootstrap-utilities.rtl.min.css.map | 0 .../src/static/bootstrap/css/bootstrap.css | 0 .../static/bootstrap/css/bootstrap.css.map | 0 .../static/bootstrap/css/bootstrap.min.css | 0 .../bootstrap/css/bootstrap.min.css.map | 0 .../static/bootstrap/css/bootstrap.rtl.css | 0 .../bootstrap/css/bootstrap.rtl.css.map | 0 .../bootstrap/css/bootstrap.rtl.min.css | 0 .../bootstrap/css/bootstrap.rtl.min.css.map | 0 .../static/bootstrap/js/bootstrap.bundle.js | 0 .../bootstrap/js/bootstrap.bundle.js.map | 0 .../bootstrap/js/bootstrap.bundle.min.js | 0 .../bootstrap/js/bootstrap.bundle.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.esm.js | 0 .../static/bootstrap/js/bootstrap.esm.js.map | 0 .../static/bootstrap/js/bootstrap.esm.min.js | 0 .../bootstrap/js/bootstrap.esm.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.js | 0 .../src/static/bootstrap/js/bootstrap.js.map | 0 .../src/static/bootstrap/js/bootstrap.min.js | 0 .../static/bootstrap/js/bootstrap.min.js.map | 0 .../{ => python}/src/static/favicon.ico | Bin .../{ => python}/src/static/style.css | 0 .../{ => python}/src/templates/index.html | 0 .../{ => python}/README.md | 8 ++- .../{ => python}/images/architecture.png | Bin .../{ => python}/scripts/00-variables.sh | 0 .../scripts/01-deploy-resources.sh | 0 .../scripts/02-build-docker-image.sh | 0 .../scripts/03-run-docker-container.sh | 0 .../scripts/04-push-docker-image.sh | 0 .../{ => python}/scripts/05-deploy-app.sh | 0 .../{ => python}/scripts/Dockerfile | 0 .../{ => python}/scripts/configmap.yml | 0 .../{ => python}/scripts/deployment.yml | 0 .../{ => python}/scripts/namespace.yml | 0 .../scripts/persistentvolume-nfs.yml | 0 .../scripts/persistentvolume-smb.yml | 0 .../scripts/persistentvolumeclaim.yml | 0 .../{ => python}/scripts/secret.yml | 0 .../{ => python}/scripts/seed-configmap.yml | 0 .../{ => python}/scripts/service.yml | 0 .../{ => python}/scripts/storage-secret.yml | 0 .../{ => python}/scripts/storageclass-nfs.yml | 0 .../{ => python}/src/app.py | 0 .../{ => python}/src/gunicorn.conf.py | 0 .../{ => python}/src/requirements.txt | 0 .../static/bootstrap/css/bootstrap-grid.css | 0 .../bootstrap/css/bootstrap-grid.css.map | 0 .../bootstrap/css/bootstrap-grid.min.css | 0 .../bootstrap/css/bootstrap-grid.min.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.css | 0 .../bootstrap/css/bootstrap-grid.rtl.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.min.css | 0 .../css/bootstrap-grid.rtl.min.css.map | 0 .../static/bootstrap/css/bootstrap-reboot.css | 0 .../bootstrap/css/bootstrap-reboot.css.map | 0 .../bootstrap/css/bootstrap-reboot.min.css | 0 .../css/bootstrap-reboot.min.css.map | 0 .../bootstrap/css/bootstrap-reboot.rtl.css | 0 .../css/bootstrap-reboot.rtl.css.map | 0 .../css/bootstrap-reboot.rtl.min.css | 0 .../css/bootstrap-reboot.rtl.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.css | 0 .../bootstrap/css/bootstrap-utilities.css.map | 0 .../bootstrap/css/bootstrap-utilities.min.css | 0 .../css/bootstrap-utilities.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.rtl.css | 0 .../css/bootstrap-utilities.rtl.css.map | 0 .../css/bootstrap-utilities.rtl.min.css | 0 .../css/bootstrap-utilities.rtl.min.css.map | 0 .../src/static/bootstrap/css/bootstrap.css | 0 .../static/bootstrap/css/bootstrap.css.map | 0 .../static/bootstrap/css/bootstrap.min.css | 0 .../bootstrap/css/bootstrap.min.css.map | 0 .../static/bootstrap/css/bootstrap.rtl.css | 0 .../bootstrap/css/bootstrap.rtl.css.map | 0 .../bootstrap/css/bootstrap.rtl.min.css | 0 .../bootstrap/css/bootstrap.rtl.min.css.map | 0 .../static/bootstrap/js/bootstrap.bundle.js | 0 .../bootstrap/js/bootstrap.bundle.js.map | 0 .../bootstrap/js/bootstrap.bundle.min.js | 0 .../bootstrap/js/bootstrap.bundle.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.esm.js | 0 .../static/bootstrap/js/bootstrap.esm.js.map | 0 .../static/bootstrap/js/bootstrap.esm.min.js | 0 .../bootstrap/js/bootstrap.esm.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.js | 0 .../src/static/bootstrap/js/bootstrap.js.map | 0 .../src/static/bootstrap/js/bootstrap.min.js | 0 .../static/bootstrap/js/bootstrap.min.js.map | 0 .../{ => python}/src/static/favicon.ico | Bin .../{ => python}/src/static/style.css | 0 .../{ => python}/src/templates/index.html | 0 .../{ => python}/README.md | 8 ++- .../{ => python}/images/architecture.png | Bin .../{ => python}/scripts/00-variables.sh | 0 .../scripts/01-deploy-resources.sh | 0 .../scripts/02-build-docker-image.sh | 0 .../scripts/03-run-docker-container.sh | 0 .../scripts/04-push-docker-image.sh | 0 .../{ => python}/scripts/05-deploy-app.sh | 0 .../{ => python}/scripts/Dockerfile | 0 .../{ => python}/scripts/configmap.yml | 0 .../{ => python}/scripts/deployment.yml | 0 .../{ => python}/scripts/namespace.yml | 0 .../{ => python}/scripts/secret.yml | 0 .../{ => python}/scripts/service.yml | 0 .../{ => python}/scripts/statefulset.yml | 0 .../{ => python}/src/app.py | 0 .../{ => python}/src/database.py | 0 .../{ => python}/src/gunicorn.conf.py | 0 .../{ => python}/src/requirements.txt | 0 .../static/bootstrap/css/bootstrap-grid.css | 0 .../bootstrap/css/bootstrap-grid.css.map | 0 .../bootstrap/css/bootstrap-grid.min.css | 0 .../bootstrap/css/bootstrap-grid.min.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.css | 0 .../bootstrap/css/bootstrap-grid.rtl.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.min.css | 0 .../css/bootstrap-grid.rtl.min.css.map | 0 .../static/bootstrap/css/bootstrap-reboot.css | 0 .../bootstrap/css/bootstrap-reboot.css.map | 0 .../bootstrap/css/bootstrap-reboot.min.css | 0 .../css/bootstrap-reboot.min.css.map | 0 .../bootstrap/css/bootstrap-reboot.rtl.css | 0 .../css/bootstrap-reboot.rtl.css.map | 0 .../css/bootstrap-reboot.rtl.min.css | 0 .../css/bootstrap-reboot.rtl.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.css | 0 .../bootstrap/css/bootstrap-utilities.css.map | 0 .../bootstrap/css/bootstrap-utilities.min.css | 0 .../css/bootstrap-utilities.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.rtl.css | 0 .../css/bootstrap-utilities.rtl.css.map | 0 .../css/bootstrap-utilities.rtl.min.css | 0 .../css/bootstrap-utilities.rtl.min.css.map | 0 .../src/static/bootstrap/css/bootstrap.css | 0 .../static/bootstrap/css/bootstrap.css.map | 0 .../static/bootstrap/css/bootstrap.min.css | 0 .../bootstrap/css/bootstrap.min.css.map | 0 .../static/bootstrap/css/bootstrap.rtl.css | 0 .../bootstrap/css/bootstrap.rtl.css.map | 0 .../bootstrap/css/bootstrap.rtl.min.css | 0 .../bootstrap/css/bootstrap.rtl.min.css.map | 0 .../static/bootstrap/js/bootstrap.bundle.js | 0 .../bootstrap/js/bootstrap.bundle.js.map | 0 .../bootstrap/js/bootstrap.bundle.min.js | 0 .../bootstrap/js/bootstrap.bundle.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.esm.js | 0 .../static/bootstrap/js/bootstrap.esm.js.map | 0 .../static/bootstrap/js/bootstrap.esm.min.js | 0 .../bootstrap/js/bootstrap.esm.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.js | 0 .../src/static/bootstrap/js/bootstrap.js.map | 0 .../src/static/bootstrap/js/bootstrap.min.js | 0 .../static/bootstrap/js/bootstrap.min.js.map | 0 .../{ => python}/src/static/favicon.ico | Bin .../{ => python}/src/static/style.css | 0 .../{ => python}/src/templates/index.html | 0 .../{ => python}/README.md | 10 +-- .../{ => python}/images/architecture.png | Bin .../{ => python}/scripts/00-variables.sh | 0 .../scripts/01-deploy-resources.sh | 0 .../scripts/02-build-docker-image.sh | 0 .../scripts/03-run-docker-container.sh | 0 .../scripts/04-push-docker-image.sh | 0 .../{ => python}/scripts/05-deploy-app.sh | 0 .../{ => python}/scripts/Dockerfile | 0 .../{ => python}/scripts/configmap.yml | 0 .../{ => python}/scripts/deployment.yml | 0 .../{ => python}/scripts/gateway.yml | 0 .../{ => python}/scripts/httproute.yml | 0 .../{ => python}/scripts/issuer.yml | 0 .../{ => python}/scripts/namespace.yml | 0 .../{ => python}/scripts/secret.yml | 0 .../{ => python}/scripts/service.yml | 0 .../{ => python}/src/app.py | 0 .../{ => python}/src/gunicorn.conf.py | 0 .../{ => python}/src/requirements.txt | 0 .../static/bootstrap/css/bootstrap-grid.css | 0 .../bootstrap/css/bootstrap-grid.css.map | 0 .../bootstrap/css/bootstrap-grid.min.css | 0 .../bootstrap/css/bootstrap-grid.min.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.css | 0 .../bootstrap/css/bootstrap-grid.rtl.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.min.css | 0 .../css/bootstrap-grid.rtl.min.css.map | 0 .../static/bootstrap/css/bootstrap-reboot.css | 0 .../bootstrap/css/bootstrap-reboot.css.map | 0 .../bootstrap/css/bootstrap-reboot.min.css | 0 .../css/bootstrap-reboot.min.css.map | 0 .../bootstrap/css/bootstrap-reboot.rtl.css | 0 .../css/bootstrap-reboot.rtl.css.map | 0 .../css/bootstrap-reboot.rtl.min.css | 0 .../css/bootstrap-reboot.rtl.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.css | 0 .../bootstrap/css/bootstrap-utilities.css.map | 0 .../bootstrap/css/bootstrap-utilities.min.css | 0 .../css/bootstrap-utilities.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.rtl.css | 0 .../css/bootstrap-utilities.rtl.css.map | 0 .../css/bootstrap-utilities.rtl.min.css | 0 .../css/bootstrap-utilities.rtl.min.css.map | 0 .../src/static/bootstrap/css/bootstrap.css | 0 .../static/bootstrap/css/bootstrap.css.map | 0 .../static/bootstrap/css/bootstrap.min.css | 0 .../bootstrap/css/bootstrap.min.css.map | 0 .../static/bootstrap/css/bootstrap.rtl.css | 0 .../bootstrap/css/bootstrap.rtl.css.map | 0 .../bootstrap/css/bootstrap.rtl.min.css | 0 .../bootstrap/css/bootstrap.rtl.min.css.map | 0 .../static/bootstrap/js/bootstrap.bundle.js | 0 .../bootstrap/js/bootstrap.bundle.js.map | 0 .../bootstrap/js/bootstrap.bundle.min.js | 0 .../bootstrap/js/bootstrap.bundle.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.esm.js | 0 .../static/bootstrap/js/bootstrap.esm.js.map | 0 .../static/bootstrap/js/bootstrap.esm.min.js | 0 .../bootstrap/js/bootstrap.esm.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.js | 0 .../src/static/bootstrap/js/bootstrap.js.map | 0 .../src/static/bootstrap/js/bootstrap.min.js | 0 .../static/bootstrap/js/bootstrap.min.js.map | 0 .../{ => python}/src/static/favicon.ico | Bin .../{ => python}/src/static/style.css | 0 .../{ => python}/src/templates/index.html | 0 .../{ => python}/README.md | 8 ++- .../{ => python}/images/architecture.png | Bin .../{ => python}/scripts/00-variables.sh | 0 .../scripts/01-deploy-resources.sh | 0 .../scripts/02-build-docker-image.sh | 0 .../scripts/03-run-docker-container.sh | 0 .../scripts/04-push-docker-image.sh | 0 .../{ => python}/scripts/05-deploy-app.sh | 0 .../{ => python}/scripts/Dockerfile | 0 .../{ => python}/scripts/configmap.yml | 0 .../{ => python}/scripts/deployment.yml | 0 .../{ => python}/scripts/namespace.yml | 0 .../{ => python}/scripts/secret.yml | 0 .../{ => python}/scripts/service.yml | 0 .../{ => python}/src/app.py | 0 .../{ => python}/src/database.py | 0 .../{ => python}/src/gunicorn.conf.py | 0 .../{ => python}/src/requirements.txt | 0 .../static/bootstrap/css/bootstrap-grid.css | 0 .../bootstrap/css/bootstrap-grid.css.map | 0 .../bootstrap/css/bootstrap-grid.min.css | 0 .../bootstrap/css/bootstrap-grid.min.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.css | 0 .../bootstrap/css/bootstrap-grid.rtl.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.min.css | 0 .../css/bootstrap-grid.rtl.min.css.map | 0 .../static/bootstrap/css/bootstrap-reboot.css | 0 .../bootstrap/css/bootstrap-reboot.css.map | 0 .../bootstrap/css/bootstrap-reboot.min.css | 0 .../css/bootstrap-reboot.min.css.map | 0 .../bootstrap/css/bootstrap-reboot.rtl.css | 0 .../css/bootstrap-reboot.rtl.css.map | 0 .../css/bootstrap-reboot.rtl.min.css | 0 .../css/bootstrap-reboot.rtl.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.css | 0 .../bootstrap/css/bootstrap-utilities.css.map | 0 .../bootstrap/css/bootstrap-utilities.min.css | 0 .../css/bootstrap-utilities.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.rtl.css | 0 .../css/bootstrap-utilities.rtl.css.map | 0 .../css/bootstrap-utilities.rtl.min.css | 0 .../css/bootstrap-utilities.rtl.min.css.map | 0 .../src/static/bootstrap/css/bootstrap.css | 0 .../static/bootstrap/css/bootstrap.css.map | 0 .../static/bootstrap/css/bootstrap.min.css | 0 .../bootstrap/css/bootstrap.min.css.map | 0 .../static/bootstrap/css/bootstrap.rtl.css | 0 .../bootstrap/css/bootstrap.rtl.css.map | 0 .../bootstrap/css/bootstrap.rtl.min.css | 0 .../bootstrap/css/bootstrap.rtl.min.css.map | 0 .../static/bootstrap/js/bootstrap.bundle.js | 0 .../bootstrap/js/bootstrap.bundle.js.map | 0 .../bootstrap/js/bootstrap.bundle.min.js | 0 .../bootstrap/js/bootstrap.bundle.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.esm.js | 0 .../static/bootstrap/js/bootstrap.esm.js.map | 0 .../static/bootstrap/js/bootstrap.esm.min.js | 0 .../bootstrap/js/bootstrap.esm.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.js | 0 .../src/static/bootstrap/js/bootstrap.js.map | 0 .../src/static/bootstrap/js/bootstrap.min.js | 0 .../static/bootstrap/js/bootstrap.min.js.map | 0 .../{ => python}/src/static/favicon.ico | Bin .../{ => python}/src/static/style.css | 0 .../{ => python}/src/templates/index.html | 0 .../{ => python}/README.md | 8 ++- .../{ => python}/images/architecture.png | Bin .../{ => python}/scripts/00-variables.sh | 0 .../scripts/01-deploy-resources.sh | 0 .../scripts/02-build-docker-image.sh | 0 .../scripts/03-run-docker-container.sh | 0 .../scripts/04-push-docker-image.sh | 0 .../{ => python}/scripts/05-deploy-app.sh | 0 .../{ => python}/scripts/Dockerfile | 0 .../{ => python}/scripts/configmap.yml | 0 .../{ => python}/scripts/deployment.yml | 0 .../{ => python}/scripts/namespace.yml | 0 .../{ => python}/scripts/secret.yml | 0 .../{ => python}/scripts/service.yml | 0 .../{ => python}/src/app.py | 0 .../{ => python}/src/database.py | 0 .../{ => python}/src/gunicorn.conf.py | 0 .../{ => python}/src/requirements.txt | 0 .../static/bootstrap/css/bootstrap-grid.css | 0 .../bootstrap/css/bootstrap-grid.css.map | 0 .../bootstrap/css/bootstrap-grid.min.css | 0 .../bootstrap/css/bootstrap-grid.min.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.css | 0 .../bootstrap/css/bootstrap-grid.rtl.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.min.css | 0 .../css/bootstrap-grid.rtl.min.css.map | 0 .../static/bootstrap/css/bootstrap-reboot.css | 0 .../bootstrap/css/bootstrap-reboot.css.map | 0 .../bootstrap/css/bootstrap-reboot.min.css | 0 .../css/bootstrap-reboot.min.css.map | 0 .../bootstrap/css/bootstrap-reboot.rtl.css | 0 .../css/bootstrap-reboot.rtl.css.map | 0 .../css/bootstrap-reboot.rtl.min.css | 0 .../css/bootstrap-reboot.rtl.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.css | 0 .../bootstrap/css/bootstrap-utilities.css.map | 0 .../bootstrap/css/bootstrap-utilities.min.css | 0 .../css/bootstrap-utilities.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.rtl.css | 0 .../css/bootstrap-utilities.rtl.css.map | 0 .../css/bootstrap-utilities.rtl.min.css | 0 .../css/bootstrap-utilities.rtl.min.css.map | 0 .../src/static/bootstrap/css/bootstrap.css | 0 .../static/bootstrap/css/bootstrap.css.map | 0 .../static/bootstrap/css/bootstrap.min.css | 0 .../bootstrap/css/bootstrap.min.css.map | 0 .../static/bootstrap/css/bootstrap.rtl.css | 0 .../bootstrap/css/bootstrap.rtl.css.map | 0 .../bootstrap/css/bootstrap.rtl.min.css | 0 .../bootstrap/css/bootstrap.rtl.min.css.map | 0 .../static/bootstrap/js/bootstrap.bundle.js | 0 .../bootstrap/js/bootstrap.bundle.js.map | 0 .../bootstrap/js/bootstrap.bundle.min.js | 0 .../bootstrap/js/bootstrap.bundle.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.esm.js | 0 .../static/bootstrap/js/bootstrap.esm.js.map | 0 .../static/bootstrap/js/bootstrap.esm.min.js | 0 .../bootstrap/js/bootstrap.esm.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.js | 0 .../src/static/bootstrap/js/bootstrap.js.map | 0 .../src/static/bootstrap/js/bootstrap.min.js | 0 .../static/bootstrap/js/bootstrap.min.js.map | 0 .../{ => python}/src/static/favicon.ico | Bin .../{ => python}/src/static/style.css | 0 .../{ => python}/src/templates/index.html | 0 .../{ => python}/README.md | 8 ++- .../{ => python}/images/architecture.png | Bin .../{ => python}/scripts/00-variables.sh | 0 .../scripts/01-deploy-resources.sh | 0 .../scripts/02-build-docker-image.sh | 0 .../scripts/03-run-docker-container.sh | 0 .../scripts/04-push-docker-image.sh | 0 .../{ => python}/scripts/05-deploy-app.sh | 0 .../{ => python}/scripts/Dockerfile | 0 .../{ => python}/scripts/configmap.yml | 0 .../{ => python}/scripts/deployment.yml | 0 .../{ => python}/scripts/namespace.yml | 0 .../{ => python}/scripts/secret.yml | 0 .../{ => python}/scripts/service.yml | 0 .../{ => python}/src/activities.py | 0 .../{ => python}/src/app.py | 0 .../{ => python}/src/database.py | 0 .../{ => python}/src/gunicorn.conf.py | 0 .../{ => python}/src/requirements.txt | 0 .../static/bootstrap/css/bootstrap-grid.css | 0 .../bootstrap/css/bootstrap-grid.css.map | 0 .../bootstrap/css/bootstrap-grid.min.css | 0 .../bootstrap/css/bootstrap-grid.min.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.css | 0 .../bootstrap/css/bootstrap-grid.rtl.css.map | 0 .../bootstrap/css/bootstrap-grid.rtl.min.css | 0 .../css/bootstrap-grid.rtl.min.css.map | 0 .../static/bootstrap/css/bootstrap-reboot.css | 0 .../bootstrap/css/bootstrap-reboot.css.map | 0 .../bootstrap/css/bootstrap-reboot.min.css | 0 .../css/bootstrap-reboot.min.css.map | 0 .../bootstrap/css/bootstrap-reboot.rtl.css | 0 .../css/bootstrap-reboot.rtl.css.map | 0 .../css/bootstrap-reboot.rtl.min.css | 0 .../css/bootstrap-reboot.rtl.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.css | 0 .../bootstrap/css/bootstrap-utilities.css.map | 0 .../bootstrap/css/bootstrap-utilities.min.css | 0 .../css/bootstrap-utilities.min.css.map | 0 .../bootstrap/css/bootstrap-utilities.rtl.css | 0 .../css/bootstrap-utilities.rtl.css.map | 0 .../css/bootstrap-utilities.rtl.min.css | 0 .../css/bootstrap-utilities.rtl.min.css.map | 0 .../src/static/bootstrap/css/bootstrap.css | 0 .../static/bootstrap/css/bootstrap.css.map | 0 .../static/bootstrap/css/bootstrap.min.css | 0 .../bootstrap/css/bootstrap.min.css.map | 0 .../static/bootstrap/css/bootstrap.rtl.css | 0 .../bootstrap/css/bootstrap.rtl.css.map | 0 .../bootstrap/css/bootstrap.rtl.min.css | 0 .../bootstrap/css/bootstrap.rtl.min.css.map | 0 .../static/bootstrap/js/bootstrap.bundle.js | 0 .../bootstrap/js/bootstrap.bundle.js.map | 0 .../bootstrap/js/bootstrap.bundle.min.js | 0 .../bootstrap/js/bootstrap.bundle.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.esm.js | 0 .../static/bootstrap/js/bootstrap.esm.js.map | 0 .../static/bootstrap/js/bootstrap.esm.min.js | 0 .../bootstrap/js/bootstrap.esm.min.js.map | 0 .../src/static/bootstrap/js/bootstrap.js | 0 .../src/static/bootstrap/js/bootstrap.js.map | 0 .../src/static/bootstrap/js/bootstrap.min.js | 0 .../static/bootstrap/js/bootstrap.min.js.map | 0 .../{ => python}/src/static/favicon.ico | Bin .../{ => python}/src/static/style.css | 0 .../{ => python}/src/templates/index.html | 0 596 files changed, 119 insertions(+), 39 deletions(-) create mode 100644 .gitattributes rename samples/web-app-blob-storage/{ => python}/README.md (86%) rename samples/web-app-blob-storage/{ => python}/images/architecture.png (100%) rename samples/web-app-blob-storage/{ => python}/scripts/00-variables.sh (100%) rename samples/web-app-blob-storage/{ => python}/scripts/01-deploy-resources.sh (100%) rename samples/web-app-blob-storage/{ => python}/scripts/02-build-docker-image.sh (100%) rename samples/web-app-blob-storage/{ => python}/scripts/03-run-docker-container.sh (100%) rename samples/web-app-blob-storage/{ => python}/scripts/04-push-docker-image.sh (100%) rename samples/web-app-blob-storage/{ => python}/scripts/05-deploy-app.sh (100%) rename samples/web-app-blob-storage/{ => python}/scripts/Dockerfile (100%) rename samples/web-app-blob-storage/{ => python}/scripts/configmap.yml (100%) rename samples/web-app-blob-storage/{ => python}/scripts/deployment.yml (100%) rename samples/web-app-blob-storage/{ => python}/scripts/namespace.yml (100%) rename samples/web-app-blob-storage/{ => python}/scripts/secret.yml (100%) rename samples/web-app-blob-storage/{ => python}/scripts/service.yml (100%) rename samples/web-app-blob-storage/{ => python}/src/app.py (100%) rename samples/web-app-blob-storage/{ => python}/src/gunicorn.conf.py (100%) rename samples/web-app-blob-storage/{ => python}/src/requirements.txt (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap.min.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap.min.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.esm.js (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.esm.js.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.js (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.js.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.min.js (100%) rename samples/web-app-blob-storage/{ => python}/src/static/bootstrap/js/bootstrap.min.js.map (100%) rename samples/web-app-blob-storage/{ => python}/src/static/favicon.ico (100%) rename samples/web-app-blob-storage/{ => python}/src/static/style.css (100%) rename samples/web-app-blob-storage/{ => python}/src/templates/index.html (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/README.md (88%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/images/architecture.png (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/00-variables.sh (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/01-deploy-resources.sh (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/02-build-docker-image.sh (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/03-run-docker-container.sh (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/04-push-docker-image.sh (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/05-deploy-app.sh (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/Dockerfile (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/configmap.yml (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/deployment.yml (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/namespace.yml (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/secret.yml (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/scripts/service.yml (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/app.py (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/gunicorn.conf.py (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/mongodb.py (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/requirements.txt (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap.min.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap.min.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.esm.js (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.esm.js.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.js (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.js.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.min.js (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/bootstrap/js/bootstrap.min.js.map (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/favicon.ico (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/static/style.css (100%) rename samples/web-app-cosmosdb-mongodb-api/{ => python}/src/templates/index.html (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/README.md (88%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/images/architecture.png (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/00-variables.sh (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/01-deploy-resources.sh (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/02-build-docker-image.sh (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/03-run-docker-container.sh (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/04-push-docker-image.sh (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/05-deploy-app.sh (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/Dockerfile (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/configmap.yml (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/deployment.yml (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/namespace.yml (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/secret.yml (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/scripts/service.yml (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/app.py (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/cosmosdb_client.py (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/gunicorn.conf.py (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/requirements.txt (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap.min.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap.min.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.esm.js (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.esm.js.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.js (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.js.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.min.js (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/bootstrap/js/bootstrap.min.js.map (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/favicon.ico (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/static/style.css (100%) rename samples/web-app-cosmosdb-nosql-api/{ => python}/src/templates/index.html (100%) rename samples/web-app-file-storage/{ => python}/README.md (96%) rename samples/web-app-file-storage/{ => python}/images/architecture.png (100%) rename samples/web-app-file-storage/{ => python}/scripts/00-variables.sh (100%) rename samples/web-app-file-storage/{ => python}/scripts/01-deploy-resources.sh (100%) rename samples/web-app-file-storage/{ => python}/scripts/02-build-docker-image.sh (100%) rename samples/web-app-file-storage/{ => python}/scripts/03-run-docker-container.sh (100%) rename samples/web-app-file-storage/{ => python}/scripts/04-push-docker-image.sh (100%) rename samples/web-app-file-storage/{ => python}/scripts/05-deploy-app.sh (100%) rename samples/web-app-file-storage/{ => python}/scripts/Dockerfile (100%) rename samples/web-app-file-storage/{ => python}/scripts/configmap.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/deployment.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/namespace.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/persistentvolume-nfs.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/persistentvolume-smb.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/persistentvolumeclaim.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/secret.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/seed-configmap.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/service.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/storage-secret.yml (100%) rename samples/web-app-file-storage/{ => python}/scripts/storageclass-nfs.yml (100%) rename samples/web-app-file-storage/{ => python}/src/app.py (100%) rename samples/web-app-file-storage/{ => python}/src/gunicorn.conf.py (100%) rename samples/web-app-file-storage/{ => python}/src/requirements.txt (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap.min.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap.min.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.esm.js (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.esm.js.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.js (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.js.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.min.js (100%) rename samples/web-app-file-storage/{ => python}/src/static/bootstrap/js/bootstrap.min.js.map (100%) rename samples/web-app-file-storage/{ => python}/src/static/favicon.ico (100%) rename samples/web-app-file-storage/{ => python}/src/static/style.css (100%) rename samples/web-app-file-storage/{ => python}/src/templates/index.html (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/README.md (91%) rename samples/web-app-in-cluster-postgresql/{ => python}/images/architecture.png (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/00-variables.sh (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/01-deploy-resources.sh (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/02-build-docker-image.sh (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/03-run-docker-container.sh (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/04-push-docker-image.sh (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/05-deploy-app.sh (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/Dockerfile (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/configmap.yml (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/deployment.yml (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/namespace.yml (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/secret.yml (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/service.yml (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/scripts/statefulset.yml (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/app.py (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/database.py (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/gunicorn.conf.py (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/requirements.txt (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-grid.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-grid.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap.min.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap.min.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.esm.js (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.esm.js.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.js (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.js.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.min.js (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/bootstrap/js/bootstrap.min.js.map (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/favicon.ico (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/static/style.css (100%) rename samples/web-app-in-cluster-postgresql/{ => python}/src/templates/index.html (100%) rename samples/web-app-managed-identity/{ => python}/README.md (83%) rename samples/web-app-managed-identity/{ => python}/images/architecture.png (100%) rename samples/web-app-managed-identity/{ => python}/scripts/00-variables.sh (100%) rename samples/web-app-managed-identity/{ => python}/scripts/01-deploy-resources.sh (100%) rename samples/web-app-managed-identity/{ => python}/scripts/02-build-docker-image.sh (100%) rename samples/web-app-managed-identity/{ => python}/scripts/03-run-docker-container.sh (100%) rename samples/web-app-managed-identity/{ => python}/scripts/04-push-docker-image.sh (100%) rename samples/web-app-managed-identity/{ => python}/scripts/05-deploy-app.sh (100%) rename samples/web-app-managed-identity/{ => python}/scripts/Dockerfile (100%) rename samples/web-app-managed-identity/{ => python}/scripts/configmap.yml (100%) rename samples/web-app-managed-identity/{ => python}/scripts/deployment.yml (100%) rename samples/web-app-managed-identity/{ => python}/scripts/gateway.yml (100%) rename samples/web-app-managed-identity/{ => python}/scripts/httproute.yml (100%) rename samples/web-app-managed-identity/{ => python}/scripts/issuer.yml (100%) rename samples/web-app-managed-identity/{ => python}/scripts/namespace.yml (100%) rename samples/web-app-managed-identity/{ => python}/scripts/secret.yml (100%) rename samples/web-app-managed-identity/{ => python}/scripts/service.yml (100%) rename samples/web-app-managed-identity/{ => python}/src/app.py (100%) rename samples/web-app-managed-identity/{ => python}/src/gunicorn.conf.py (100%) rename samples/web-app-managed-identity/{ => python}/src/requirements.txt (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-grid.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-grid.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap.min.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap.min.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.esm.js (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.esm.js.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.js (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.js.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.min.js (100%) rename samples/web-app-managed-identity/{ => python}/src/static/bootstrap/js/bootstrap.min.js.map (100%) rename samples/web-app-managed-identity/{ => python}/src/static/favicon.ico (100%) rename samples/web-app-managed-identity/{ => python}/src/static/style.css (100%) rename samples/web-app-managed-identity/{ => python}/src/templates/index.html (100%) rename samples/web-app-mysql-flexible-server/{ => python}/README.md (89%) rename samples/web-app-mysql-flexible-server/{ => python}/images/architecture.png (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/00-variables.sh (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/01-deploy-resources.sh (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/02-build-docker-image.sh (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/03-run-docker-container.sh (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/04-push-docker-image.sh (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/05-deploy-app.sh (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/Dockerfile (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/configmap.yml (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/deployment.yml (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/namespace.yml (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/secret.yml (100%) rename samples/web-app-mysql-flexible-server/{ => python}/scripts/service.yml (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/app.py (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/database.py (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/gunicorn.conf.py (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/requirements.txt (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.min.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.min.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.esm.js (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.esm.js.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.js (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.js.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.min.js (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.min.js.map (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/favicon.ico (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/static/style.css (100%) rename samples/web-app-mysql-flexible-server/{ => python}/src/templates/index.html (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/README.md (88%) rename samples/web-app-postgresql-flexible-server/{ => python}/images/architecture.png (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/00-variables.sh (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/01-deploy-resources.sh (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/02-build-docker-image.sh (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/03-run-docker-container.sh (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/04-push-docker-image.sh (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/05-deploy-app.sh (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/Dockerfile (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/configmap.yml (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/deployment.yml (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/namespace.yml (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/secret.yml (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/scripts/service.yml (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/app.py (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/database.py (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/gunicorn.conf.py (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/requirements.txt (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.min.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.min.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.esm.js (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.esm.js.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.js (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.js.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.min.js (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/bootstrap/js/bootstrap.min.js.map (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/favicon.ico (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/static/style.css (100%) rename samples/web-app-postgresql-flexible-server/{ => python}/src/templates/index.html (100%) rename samples/web-app-sql-database/{ => python}/README.md (88%) rename samples/web-app-sql-database/{ => python}/images/architecture.png (100%) rename samples/web-app-sql-database/{ => python}/scripts/00-variables.sh (100%) rename samples/web-app-sql-database/{ => python}/scripts/01-deploy-resources.sh (100%) rename samples/web-app-sql-database/{ => python}/scripts/02-build-docker-image.sh (100%) rename samples/web-app-sql-database/{ => python}/scripts/03-run-docker-container.sh (100%) rename samples/web-app-sql-database/{ => python}/scripts/04-push-docker-image.sh (100%) rename samples/web-app-sql-database/{ => python}/scripts/05-deploy-app.sh (100%) rename samples/web-app-sql-database/{ => python}/scripts/Dockerfile (100%) rename samples/web-app-sql-database/{ => python}/scripts/configmap.yml (100%) rename samples/web-app-sql-database/{ => python}/scripts/deployment.yml (100%) rename samples/web-app-sql-database/{ => python}/scripts/namespace.yml (100%) rename samples/web-app-sql-database/{ => python}/scripts/secret.yml (100%) rename samples/web-app-sql-database/{ => python}/scripts/service.yml (100%) rename samples/web-app-sql-database/{ => python}/src/activities.py (100%) rename samples/web-app-sql-database/{ => python}/src/app.py (100%) rename samples/web-app-sql-database/{ => python}/src/database.py (100%) rename samples/web-app-sql-database/{ => python}/src/gunicorn.conf.py (100%) rename samples/web-app-sql-database/{ => python}/src/requirements.txt (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-grid.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-grid.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-grid.min.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-grid.rtl.min.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-reboot.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-reboot.min.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-utilities.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-utilities.min.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap-utilities.rtl.min.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap.min.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap.min.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap.rtl.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/css/bootstrap.rtl.min.css.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.bundle.js.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.bundle.min.js.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.esm.js (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.esm.js.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.esm.min.js.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.js (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.js.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.min.js (100%) rename samples/web-app-sql-database/{ => python}/src/static/bootstrap/js/bootstrap.min.js.map (100%) rename samples/web-app-sql-database/{ => python}/src/static/favicon.ico (100%) rename samples/web-app-sql-database/{ => python}/src/static/style.css (100%) rename samples/web-app-sql-database/{ => python}/src/templates/index.html (100%) 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..2db3130 100644 --- a/README.md +++ b/README.md @@ -102,15 +102,15 @@ To run any sample you must first create the AKS cluster with one of the two scri | 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](samples/web-app-sql-database/python/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](samples/web-app-mysql-flexible-server/python/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](samples/web-app-postgresql-flexible-server/python/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](samples/web-app-in-cluster-postgresql/python/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](samples/web-app-cosmosdb-mongodb-api/python/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](samples/web-app-cosmosdb-nosql-api/python/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](samples/web-app-blob-storage/python/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](samples/web-app-file-storage/python/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](samples/web-app-managed-identity/python/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: diff --git a/samples/web-app-blob-storage/README.md b/samples/web-app-blob-storage/python/README.md similarity index 86% rename from samples/web-app-blob-storage/README.md rename to samples/web-app-blob-storage/python/README.md index 27b51f7..ad0d130 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 diff --git a/samples/web-app-blob-storage/images/architecture.png b/samples/web-app-blob-storage/python/images/architecture.png similarity index 100% rename from samples/web-app-blob-storage/images/architecture.png rename to samples/web-app-blob-storage/python/images/architecture.png 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/scripts/01-deploy-resources.sh b/samples/web-app-blob-storage/python/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/python/scripts/01-deploy-resources.sh 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-blob-storage/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-blob-storage/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-blob-storage/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-blob-storage/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 100% 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 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-blob-storage/scripts/configmap.yml b/samples/web-app-blob-storage/python/scripts/configmap.yml similarity index 100% rename from samples/web-app-blob-storage/scripts/configmap.yml rename to samples/web-app-blob-storage/python/scripts/configmap.yml diff --git a/samples/web-app-blob-storage/scripts/deployment.yml b/samples/web-app-blob-storage/python/scripts/deployment.yml similarity index 100% rename from samples/web-app-blob-storage/scripts/deployment.yml rename to samples/web-app-blob-storage/python/scripts/deployment.yml diff --git a/samples/web-app-blob-storage/scripts/namespace.yml b/samples/web-app-blob-storage/python/scripts/namespace.yml similarity index 100% rename from samples/web-app-blob-storage/scripts/namespace.yml rename to samples/web-app-blob-storage/python/scripts/namespace.yml diff --git a/samples/web-app-blob-storage/scripts/secret.yml b/samples/web-app-blob-storage/python/scripts/secret.yml similarity index 100% rename from samples/web-app-blob-storage/scripts/secret.yml rename to samples/web-app-blob-storage/python/scripts/secret.yml diff --git a/samples/web-app-blob-storage/scripts/service.yml b/samples/web-app-blob-storage/python/scripts/service.yml similarity index 100% rename from samples/web-app-blob-storage/scripts/service.yml rename to samples/web-app-blob-storage/python/scripts/service.yml diff --git a/samples/web-app-blob-storage/src/app.py b/samples/web-app-blob-storage/python/src/app.py similarity index 100% rename from samples/web-app-blob-storage/src/app.py rename to samples/web-app-blob-storage/python/src/app.py 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-blob-storage/src/static/favicon.ico b/samples/web-app-blob-storage/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-blob-storage/src/static/favicon.ico rename to samples/web-app-blob-storage/python/src/static/favicon.ico diff --git a/samples/web-app-blob-storage/src/static/style.css b/samples/web-app-blob-storage/python/src/static/style.css similarity index 100% rename from samples/web-app-blob-storage/src/static/style.css rename to samples/web-app-blob-storage/python/src/static/style.css diff --git a/samples/web-app-blob-storage/src/templates/index.html b/samples/web-app-blob-storage/python/src/templates/index.html similarity index 100% rename from samples/web-app-blob-storage/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/README.md b/samples/web-app-cosmosdb-mongodb-api/python/README.md similarity index 88% rename from samples/web-app-cosmosdb-mongodb-api/README.md rename to samples/web-app-cosmosdb-mongodb-api/python/README.md index 46e4612..1d2b0ed 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 diff --git a/samples/web-app-cosmosdb-mongodb-api/images/architecture.png b/samples/web-app-cosmosdb-mongodb-api/python/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/python/images/architecture.png 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/scripts/01-deploy-resources.sh b/samples/web-app-cosmosdb-mongodb-api/python/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/python/scripts/01-deploy-resources.sh 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/scripts/03-run-docker-container.sh b/samples/web-app-cosmosdb-mongodb-api/python/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/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-cosmosdb-mongodb-api/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-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 100% 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 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/scripts/configmap.yml b/samples/web-app-cosmosdb-mongodb-api/python/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/python/scripts/configmap.yml 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 100% rename from samples/web-app-cosmosdb-mongodb-api/scripts/deployment.yml rename to samples/web-app-cosmosdb-mongodb-api/python/scripts/deployment.yml diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/namespace.yml b/samples/web-app-cosmosdb-mongodb-api/python/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/python/scripts/namespace.yml diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/secret.yml b/samples/web-app-cosmosdb-mongodb-api/python/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/python/scripts/secret.yml diff --git a/samples/web-app-cosmosdb-mongodb-api/scripts/service.yml b/samples/web-app-cosmosdb-mongodb-api/python/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/python/scripts/service.yml 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 100% rename from samples/web-app-cosmosdb-mongodb-api/src/app.py rename to samples/web-app-cosmosdb-mongodb-api/python/src/app.py 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-cosmosdb-mongodb-api/src/static/favicon.ico b/samples/web-app-cosmosdb-mongodb-api/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-cosmosdb-mongodb-api/python/src/static/favicon.ico diff --git a/samples/web-app-cosmosdb-mongodb-api/src/static/style.css b/samples/web-app-cosmosdb-mongodb-api/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-cosmosdb-mongodb-api/python/src/static/style.css diff --git a/samples/web-app-cosmosdb-mongodb-api/src/templates/index.html b/samples/web-app-cosmosdb-mongodb-api/python/src/templates/index.html similarity index 100% rename from samples/web-app-cosmosdb-mongodb-api/src/templates/index.html rename to samples/web-app-cosmosdb-mongodb-api/python/src/templates/index.html diff --git a/samples/web-app-cosmosdb-nosql-api/README.md b/samples/web-app-cosmosdb-nosql-api/python/README.md similarity index 88% rename from samples/web-app-cosmosdb-nosql-api/README.md rename to samples/web-app-cosmosdb-nosql-api/python/README.md index e75016e..1238c4d 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 diff --git a/samples/web-app-cosmosdb-nosql-api/images/architecture.png b/samples/web-app-cosmosdb-nosql-api/python/images/architecture.png similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/images/architecture.png rename to samples/web-app-cosmosdb-nosql-api/python/images/architecture.png 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/scripts/01-deploy-resources.sh b/samples/web-app-cosmosdb-nosql-api/python/scripts/01-deploy-resources.sh similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/01-deploy-resources.sh rename to samples/web-app-cosmosdb-nosql-api/python/scripts/01-deploy-resources.sh 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-cosmosdb-nosql-api/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-cosmosdb-nosql-api/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 100% 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 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/scripts/configmap.yml b/samples/web-app-cosmosdb-nosql-api/python/scripts/configmap.yml similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/configmap.yml rename to samples/web-app-cosmosdb-nosql-api/python/scripts/configmap.yml 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 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/deployment.yml rename to samples/web-app-cosmosdb-nosql-api/python/scripts/deployment.yml diff --git a/samples/web-app-cosmosdb-nosql-api/scripts/namespace.yml b/samples/web-app-cosmosdb-nosql-api/python/scripts/namespace.yml similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/namespace.yml rename to samples/web-app-cosmosdb-nosql-api/python/scripts/namespace.yml 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/scripts/service.yml b/samples/web-app-cosmosdb-nosql-api/python/scripts/service.yml similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/scripts/service.yml rename to samples/web-app-cosmosdb-nosql-api/python/scripts/service.yml 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 100% rename from samples/web-app-cosmosdb-nosql-api/src/app.py rename to samples/web-app-cosmosdb-nosql-api/python/src/app.py 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 100% 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 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-cosmosdb-nosql-api/src/static/favicon.ico b/samples/web-app-cosmosdb-nosql-api/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/favicon.ico rename to samples/web-app-cosmosdb-nosql-api/python/src/static/favicon.ico diff --git a/samples/web-app-cosmosdb-nosql-api/src/static/style.css b/samples/web-app-cosmosdb-nosql-api/python/src/static/style.css similarity index 100% rename from samples/web-app-cosmosdb-nosql-api/src/static/style.css rename to samples/web-app-cosmosdb-nosql-api/python/src/static/style.css diff --git a/samples/web-app-cosmosdb-nosql-api/src/templates/index.html b/samples/web-app-cosmosdb-nosql-api/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-cosmosdb-nosql-api/python/src/templates/index.html diff --git a/samples/web-app-file-storage/README.md b/samples/web-app-file-storage/python/README.md similarity index 96% rename from samples/web-app-file-storage/README.md rename to samples/web-app-file-storage/python/README.md index 5f3ff24..5e40d09 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 diff --git a/samples/web-app-file-storage/images/architecture.png b/samples/web-app-file-storage/python/images/architecture.png similarity index 100% rename from samples/web-app-file-storage/images/architecture.png rename to samples/web-app-file-storage/python/images/architecture.png 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/scripts/01-deploy-resources.sh b/samples/web-app-file-storage/python/scripts/01-deploy-resources.sh similarity index 100% rename from samples/web-app-file-storage/scripts/01-deploy-resources.sh rename to samples/web-app-file-storage/python/scripts/01-deploy-resources.sh 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/scripts/03-run-docker-container.sh b/samples/web-app-file-storage/python/scripts/03-run-docker-container.sh similarity index 100% rename from samples/web-app-file-storage/scripts/03-run-docker-container.sh rename to samples/web-app-file-storage/python/scripts/03-run-docker-container.sh diff --git a/samples/web-app-file-storage/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-file-storage/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 100% 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 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/scripts/configmap.yml b/samples/web-app-file-storage/python/scripts/configmap.yml similarity index 100% rename from samples/web-app-file-storage/scripts/configmap.yml rename to samples/web-app-file-storage/python/scripts/configmap.yml diff --git a/samples/web-app-file-storage/scripts/deployment.yml b/samples/web-app-file-storage/python/scripts/deployment.yml similarity index 100% rename from samples/web-app-file-storage/scripts/deployment.yml rename to samples/web-app-file-storage/python/scripts/deployment.yml diff --git a/samples/web-app-file-storage/scripts/namespace.yml b/samples/web-app-file-storage/python/scripts/namespace.yml similarity index 100% rename from samples/web-app-file-storage/scripts/namespace.yml rename to samples/web-app-file-storage/python/scripts/namespace.yml diff --git a/samples/web-app-file-storage/scripts/persistentvolume-nfs.yml b/samples/web-app-file-storage/python/scripts/persistentvolume-nfs.yml similarity index 100% rename from samples/web-app-file-storage/scripts/persistentvolume-nfs.yml rename to samples/web-app-file-storage/python/scripts/persistentvolume-nfs.yml diff --git a/samples/web-app-file-storage/scripts/persistentvolume-smb.yml b/samples/web-app-file-storage/python/scripts/persistentvolume-smb.yml similarity index 100% rename from samples/web-app-file-storage/scripts/persistentvolume-smb.yml rename to samples/web-app-file-storage/python/scripts/persistentvolume-smb.yml diff --git a/samples/web-app-file-storage/scripts/persistentvolumeclaim.yml b/samples/web-app-file-storage/python/scripts/persistentvolumeclaim.yml similarity index 100% rename from samples/web-app-file-storage/scripts/persistentvolumeclaim.yml rename to samples/web-app-file-storage/python/scripts/persistentvolumeclaim.yml diff --git a/samples/web-app-file-storage/scripts/secret.yml b/samples/web-app-file-storage/python/scripts/secret.yml similarity index 100% rename from samples/web-app-file-storage/scripts/secret.yml rename to samples/web-app-file-storage/python/scripts/secret.yml diff --git a/samples/web-app-file-storage/scripts/seed-configmap.yml b/samples/web-app-file-storage/python/scripts/seed-configmap.yml similarity index 100% rename from samples/web-app-file-storage/scripts/seed-configmap.yml rename to samples/web-app-file-storage/python/scripts/seed-configmap.yml diff --git a/samples/web-app-file-storage/scripts/service.yml b/samples/web-app-file-storage/python/scripts/service.yml similarity index 100% rename from samples/web-app-file-storage/scripts/service.yml rename to samples/web-app-file-storage/python/scripts/service.yml diff --git a/samples/web-app-file-storage/scripts/storage-secret.yml b/samples/web-app-file-storage/python/scripts/storage-secret.yml similarity index 100% rename from samples/web-app-file-storage/scripts/storage-secret.yml rename to samples/web-app-file-storage/python/scripts/storage-secret.yml diff --git a/samples/web-app-file-storage/scripts/storageclass-nfs.yml b/samples/web-app-file-storage/python/scripts/storageclass-nfs.yml similarity index 100% rename from samples/web-app-file-storage/scripts/storageclass-nfs.yml rename to samples/web-app-file-storage/python/scripts/storageclass-nfs.yml diff --git a/samples/web-app-file-storage/src/app.py b/samples/web-app-file-storage/python/src/app.py similarity index 100% rename from samples/web-app-file-storage/src/app.py rename to samples/web-app-file-storage/python/src/app.py 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-file-storage/src/static/favicon.ico b/samples/web-app-file-storage/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-file-storage/src/static/favicon.ico rename to samples/web-app-file-storage/python/src/static/favicon.ico diff --git a/samples/web-app-file-storage/src/static/style.css b/samples/web-app-file-storage/python/src/static/style.css similarity index 100% rename from samples/web-app-file-storage/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/README.md b/samples/web-app-in-cluster-postgresql/python/README.md similarity index 91% rename from samples/web-app-in-cluster-postgresql/README.md rename to samples/web-app-in-cluster-postgresql/python/README.md index 1083765..23d811f 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 diff --git a/samples/web-app-in-cluster-postgresql/images/architecture.png b/samples/web-app-in-cluster-postgresql/python/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/python/images/architecture.png 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 100% 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 diff --git a/samples/web-app-in-cluster-postgresql/scripts/01-deploy-resources.sh b/samples/web-app-in-cluster-postgresql/python/scripts/01-deploy-resources.sh similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/01-deploy-resources.sh rename to samples/web-app-in-cluster-postgresql/python/scripts/01-deploy-resources.sh 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/scripts/03-run-docker-container.sh b/samples/web-app-in-cluster-postgresql/python/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/python/scripts/03-run-docker-container.sh diff --git a/samples/web-app-in-cluster-postgresql/scripts/04-push-docker-image.sh b/samples/web-app-in-cluster-postgresql/python/scripts/04-push-docker-image.sh similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/04-push-docker-image.sh rename to samples/web-app-in-cluster-postgresql/python/scripts/04-push-docker-image.sh 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 100% 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 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/scripts/configmap.yml b/samples/web-app-in-cluster-postgresql/python/scripts/configmap.yml similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/configmap.yml rename to samples/web-app-in-cluster-postgresql/python/scripts/configmap.yml diff --git a/samples/web-app-in-cluster-postgresql/scripts/deployment.yml b/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/deployment.yml rename to samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml diff --git a/samples/web-app-in-cluster-postgresql/scripts/namespace.yml b/samples/web-app-in-cluster-postgresql/python/scripts/namespace.yml similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/namespace.yml rename to samples/web-app-in-cluster-postgresql/python/scripts/namespace.yml 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 100% rename from samples/web-app-in-cluster-postgresql/scripts/secret.yml rename to samples/web-app-in-cluster-postgresql/python/scripts/secret.yml diff --git a/samples/web-app-in-cluster-postgresql/scripts/service.yml b/samples/web-app-in-cluster-postgresql/python/scripts/service.yml similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/service.yml rename to samples/web-app-in-cluster-postgresql/python/scripts/service.yml diff --git a/samples/web-app-in-cluster-postgresql/scripts/statefulset.yml b/samples/web-app-in-cluster-postgresql/python/scripts/statefulset.yml similarity index 100% rename from samples/web-app-in-cluster-postgresql/scripts/statefulset.yml rename to samples/web-app-in-cluster-postgresql/python/scripts/statefulset.yml diff --git a/samples/web-app-in-cluster-postgresql/src/app.py b/samples/web-app-in-cluster-postgresql/python/src/app.py similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/app.py rename to samples/web-app-in-cluster-postgresql/python/src/app.py diff --git a/samples/web-app-in-cluster-postgresql/src/database.py b/samples/web-app-in-cluster-postgresql/python/src/database.py similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/database.py rename to samples/web-app-in-cluster-postgresql/python/src/database.py 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/src/static/favicon.ico b/samples/web-app-in-cluster-postgresql/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/favicon.ico rename to samples/web-app-in-cluster-postgresql/python/src/static/favicon.ico diff --git a/samples/web-app-in-cluster-postgresql/src/static/style.css b/samples/web-app-in-cluster-postgresql/python/src/static/style.css similarity index 100% rename from samples/web-app-in-cluster-postgresql/src/static/style.css rename to samples/web-app-in-cluster-postgresql/python/src/static/style.css 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 100% 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 diff --git a/samples/web-app-managed-identity/README.md b/samples/web-app-managed-identity/python/README.md similarity index 83% rename from samples/web-app-managed-identity/README.md rename to samples/web-app-managed-identity/python/README.md index 3c4f172..14f81e2 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 diff --git a/samples/web-app-managed-identity/images/architecture.png b/samples/web-app-managed-identity/python/images/architecture.png similarity index 100% rename from samples/web-app-managed-identity/images/architecture.png rename to samples/web-app-managed-identity/python/images/architecture.png diff --git a/samples/web-app-managed-identity/scripts/00-variables.sh b/samples/web-app-managed-identity/python/scripts/00-variables.sh similarity index 100% rename from samples/web-app-managed-identity/scripts/00-variables.sh rename to samples/web-app-managed-identity/python/scripts/00-variables.sh diff --git a/samples/web-app-managed-identity/scripts/01-deploy-resources.sh b/samples/web-app-managed-identity/python/scripts/01-deploy-resources.sh similarity index 100% rename from samples/web-app-managed-identity/scripts/01-deploy-resources.sh rename to samples/web-app-managed-identity/python/scripts/01-deploy-resources.sh 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/scripts/03-run-docker-container.sh b/samples/web-app-managed-identity/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-managed-identity/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-managed-identity/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-managed-identity/python/scripts/04-push-docker-image.sh 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 100% 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 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/scripts/configmap.yml b/samples/web-app-managed-identity/python/scripts/configmap.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/configmap.yml rename to samples/web-app-managed-identity/python/scripts/configmap.yml diff --git a/samples/web-app-managed-identity/scripts/deployment.yml b/samples/web-app-managed-identity/python/scripts/deployment.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/deployment.yml rename to samples/web-app-managed-identity/python/scripts/deployment.yml diff --git a/samples/web-app-managed-identity/scripts/gateway.yml b/samples/web-app-managed-identity/python/scripts/gateway.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/gateway.yml rename to samples/web-app-managed-identity/python/scripts/gateway.yml diff --git a/samples/web-app-managed-identity/scripts/httproute.yml b/samples/web-app-managed-identity/python/scripts/httproute.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/httproute.yml rename to samples/web-app-managed-identity/python/scripts/httproute.yml diff --git a/samples/web-app-managed-identity/scripts/issuer.yml b/samples/web-app-managed-identity/python/scripts/issuer.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/issuer.yml rename to samples/web-app-managed-identity/python/scripts/issuer.yml diff --git a/samples/web-app-managed-identity/scripts/namespace.yml b/samples/web-app-managed-identity/python/scripts/namespace.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/namespace.yml rename to samples/web-app-managed-identity/python/scripts/namespace.yml diff --git a/samples/web-app-managed-identity/scripts/secret.yml b/samples/web-app-managed-identity/python/scripts/secret.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/secret.yml rename to samples/web-app-managed-identity/python/scripts/secret.yml diff --git a/samples/web-app-managed-identity/scripts/service.yml b/samples/web-app-managed-identity/python/scripts/service.yml similarity index 100% rename from samples/web-app-managed-identity/scripts/service.yml rename to samples/web-app-managed-identity/python/scripts/service.yml diff --git a/samples/web-app-managed-identity/src/app.py b/samples/web-app-managed-identity/python/src/app.py similarity index 100% rename from samples/web-app-managed-identity/src/app.py rename to samples/web-app-managed-identity/python/src/app.py 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/src/static/favicon.ico b/samples/web-app-managed-identity/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-managed-identity/src/static/favicon.ico rename to samples/web-app-managed-identity/python/src/static/favicon.ico diff --git a/samples/web-app-managed-identity/src/static/style.css b/samples/web-app-managed-identity/python/src/static/style.css similarity index 100% rename from samples/web-app-managed-identity/src/static/style.css rename to samples/web-app-managed-identity/python/src/static/style.css 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 100% rename from samples/web-app-managed-identity/src/templates/index.html rename to samples/web-app-managed-identity/python/src/templates/index.html diff --git a/samples/web-app-mysql-flexible-server/README.md b/samples/web-app-mysql-flexible-server/python/README.md similarity index 89% rename from samples/web-app-mysql-flexible-server/README.md rename to samples/web-app-mysql-flexible-server/python/README.md index faf8cca..9aa8174 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 diff --git a/samples/web-app-mysql-flexible-server/images/architecture.png b/samples/web-app-mysql-flexible-server/python/images/architecture.png similarity index 100% rename from samples/web-app-mysql-flexible-server/images/architecture.png rename to samples/web-app-mysql-flexible-server/python/images/architecture.png 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/scripts/01-deploy-resources.sh b/samples/web-app-mysql-flexible-server/python/scripts/01-deploy-resources.sh similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/01-deploy-resources.sh rename to samples/web-app-mysql-flexible-server/python/scripts/01-deploy-resources.sh 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/scripts/03-run-docker-container.sh b/samples/web-app-mysql-flexible-server/python/scripts/03-run-docker-container.sh similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/03-run-docker-container.sh rename to samples/web-app-mysql-flexible-server/python/scripts/03-run-docker-container.sh diff --git a/samples/web-app-mysql-flexible-server/scripts/04-push-docker-image.sh b/samples/web-app-mysql-flexible-server/python/scripts/04-push-docker-image.sh similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/04-push-docker-image.sh rename to samples/web-app-mysql-flexible-server/python/scripts/04-push-docker-image.sh 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 100% 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 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/scripts/configmap.yml b/samples/web-app-mysql-flexible-server/python/scripts/configmap.yml similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/configmap.yml rename to samples/web-app-mysql-flexible-server/python/scripts/configmap.yml 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 100% rename from samples/web-app-mysql-flexible-server/scripts/deployment.yml rename to samples/web-app-mysql-flexible-server/python/scripts/deployment.yml diff --git a/samples/web-app-mysql-flexible-server/scripts/namespace.yml b/samples/web-app-mysql-flexible-server/python/scripts/namespace.yml similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/namespace.yml rename to samples/web-app-mysql-flexible-server/python/scripts/namespace.yml diff --git a/samples/web-app-mysql-flexible-server/scripts/secret.yml b/samples/web-app-mysql-flexible-server/python/scripts/secret.yml similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/secret.yml rename to samples/web-app-mysql-flexible-server/python/scripts/secret.yml diff --git a/samples/web-app-mysql-flexible-server/scripts/service.yml b/samples/web-app-mysql-flexible-server/python/scripts/service.yml similarity index 100% rename from samples/web-app-mysql-flexible-server/scripts/service.yml rename to samples/web-app-mysql-flexible-server/python/scripts/service.yml 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 100% rename from samples/web-app-mysql-flexible-server/src/app.py rename to samples/web-app-mysql-flexible-server/python/src/app.py 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 100% rename from samples/web-app-mysql-flexible-server/src/database.py rename to samples/web-app-mysql-flexible-server/python/src/database.py 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/src/static/favicon.ico b/samples/web-app-mysql-flexible-server/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/favicon.ico rename to samples/web-app-mysql-flexible-server/python/src/static/favicon.ico diff --git a/samples/web-app-mysql-flexible-server/src/static/style.css b/samples/web-app-mysql-flexible-server/python/src/static/style.css similarity index 100% rename from samples/web-app-mysql-flexible-server/src/static/style.css rename to samples/web-app-mysql-flexible-server/python/src/static/style.css diff --git a/samples/web-app-mysql-flexible-server/src/templates/index.html b/samples/web-app-mysql-flexible-server/python/src/templates/index.html similarity index 100% rename from samples/web-app-mysql-flexible-server/src/templates/index.html rename to samples/web-app-mysql-flexible-server/python/src/templates/index.html diff --git a/samples/web-app-postgresql-flexible-server/README.md b/samples/web-app-postgresql-flexible-server/python/README.md similarity index 88% rename from samples/web-app-postgresql-flexible-server/README.md rename to samples/web-app-postgresql-flexible-server/python/README.md index 435c76e..d83c97c 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 diff --git a/samples/web-app-postgresql-flexible-server/images/architecture.png b/samples/web-app-postgresql-flexible-server/python/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/python/images/architecture.png 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/scripts/01-deploy-resources.sh b/samples/web-app-postgresql-flexible-server/python/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/python/scripts/01-deploy-resources.sh 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/scripts/03-run-docker-container.sh b/samples/web-app-postgresql-flexible-server/python/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/python/scripts/03-run-docker-container.sh diff --git a/samples/web-app-postgresql-flexible-server/scripts/04-push-docker-image.sh b/samples/web-app-postgresql-flexible-server/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-postgresql-flexible-server/python/scripts/04-push-docker-image.sh 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 100% 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 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/scripts/configmap.yml b/samples/web-app-postgresql-flexible-server/python/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/python/scripts/configmap.yml 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 100% rename from samples/web-app-postgresql-flexible-server/scripts/deployment.yml rename to samples/web-app-postgresql-flexible-server/python/scripts/deployment.yml 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/scripts/secret.yml b/samples/web-app-postgresql-flexible-server/python/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/python/scripts/secret.yml 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 100% rename from samples/web-app-postgresql-flexible-server/src/app.py rename to samples/web-app-postgresql-flexible-server/python/src/app.py diff --git a/samples/web-app-postgresql-flexible-server/src/database.py b/samples/web-app-postgresql-flexible-server/python/src/database.py similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/database.py rename to samples/web-app-postgresql-flexible-server/python/src/database.py 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/src/static/favicon.ico b/samples/web-app-postgresql-flexible-server/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-postgresql-flexible-server/python/src/static/favicon.ico diff --git a/samples/web-app-postgresql-flexible-server/src/static/style.css b/samples/web-app-postgresql-flexible-server/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-postgresql-flexible-server/python/src/static/style.css diff --git a/samples/web-app-postgresql-flexible-server/src/templates/index.html b/samples/web-app-postgresql-flexible-server/python/src/templates/index.html similarity index 100% rename from samples/web-app-postgresql-flexible-server/src/templates/index.html rename to samples/web-app-postgresql-flexible-server/python/src/templates/index.html diff --git a/samples/web-app-sql-database/README.md b/samples/web-app-sql-database/python/README.md similarity index 88% rename from samples/web-app-sql-database/README.md rename to samples/web-app-sql-database/python/README.md index fc419ac..b1ae839 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 diff --git a/samples/web-app-sql-database/images/architecture.png b/samples/web-app-sql-database/python/images/architecture.png similarity index 100% rename from samples/web-app-sql-database/images/architecture.png rename to samples/web-app-sql-database/python/images/architecture.png 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/scripts/01-deploy-resources.sh b/samples/web-app-sql-database/python/scripts/01-deploy-resources.sh similarity index 100% rename from samples/web-app-sql-database/scripts/01-deploy-resources.sh rename to samples/web-app-sql-database/python/scripts/01-deploy-resources.sh 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/scripts/03-run-docker-container.sh b/samples/web-app-sql-database/python/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/python/scripts/03-run-docker-container.sh diff --git a/samples/web-app-sql-database/scripts/04-push-docker-image.sh b/samples/web-app-sql-database/python/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-sql-database/python/scripts/04-push-docker-image.sh 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 100% 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 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/scripts/configmap.yml b/samples/web-app-sql-database/python/scripts/configmap.yml similarity index 100% rename from samples/web-app-sql-database/scripts/configmap.yml rename to samples/web-app-sql-database/python/scripts/configmap.yml diff --git a/samples/web-app-sql-database/scripts/deployment.yml b/samples/web-app-sql-database/python/scripts/deployment.yml similarity index 100% rename from samples/web-app-sql-database/scripts/deployment.yml rename to samples/web-app-sql-database/python/scripts/deployment.yml diff --git a/samples/web-app-sql-database/scripts/namespace.yml b/samples/web-app-sql-database/python/scripts/namespace.yml similarity index 100% rename from samples/web-app-sql-database/scripts/namespace.yml rename to samples/web-app-sql-database/python/scripts/namespace.yml 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/scripts/service.yml b/samples/web-app-sql-database/python/scripts/service.yml similarity index 100% rename from samples/web-app-sql-database/scripts/service.yml rename to samples/web-app-sql-database/python/scripts/service.yml 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 100% rename from samples/web-app-sql-database/src/app.py rename to samples/web-app-sql-database/python/src/app.py 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/src/static/favicon.ico b/samples/web-app-sql-database/python/src/static/favicon.ico similarity index 100% rename from samples/web-app-sql-database/src/static/favicon.ico rename to samples/web-app-sql-database/python/src/static/favicon.ico diff --git a/samples/web-app-sql-database/src/static/style.css b/samples/web-app-sql-database/python/src/static/style.css similarity index 100% rename from samples/web-app-sql-database/src/static/style.css rename to samples/web-app-sql-database/python/src/static/style.css diff --git a/samples/web-app-sql-database/src/templates/index.html b/samples/web-app-sql-database/python/src/templates/index.html similarity index 100% rename from samples/web-app-sql-database/src/templates/index.html rename to samples/web-app-sql-database/python/src/templates/index.html From 6aa2661a994f6633710ca17fde852f848a20642f Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Tue, 8 Sep 2026 12:12:50 +0200 Subject: [PATCH 02/20] Add GET /health to the Python samples and probe it Every Python Vacation Planner gains a GET /health route that answers {"status": "ok"} when its backing store is reachable (container exists, directory writable, MongoDB ping, Cosmos container read, SELECT 1) and 503 {"status": "unavailable"} otherwise. The liveness and readiness probes of all nine Deployments call it instead of rendering the full page on every probe. Verified locally with the file-storage image: 200 on a writable mount, 503 on a missing one. Co-Authored-By: Claude Fable 5.1 --- samples/web-app-blob-storage/python/README.md | 8 +++++++- .../python/scripts/deployment.yml | 4 ++-- samples/web-app-blob-storage/python/src/app.py | 15 ++++++++++++++- .../web-app-cosmosdb-mongodb-api/python/README.md | 8 +++++++- .../python/scripts/deployment.yml | 4 ++-- .../python/src/app.py | 12 +++++++++++- .../web-app-cosmosdb-nosql-api/python/README.md | 8 +++++++- .../python/scripts/deployment.yml | 4 ++-- .../web-app-cosmosdb-nosql-api/python/src/app.py | 12 +++++++++++- .../python/src/cosmosdb_client.py | 5 +++++ samples/web-app-file-storage/python/README.md | 8 +++++++- .../python/scripts/deployment.yml | 4 ++-- samples/web-app-file-storage/python/src/app.py | 15 ++++++++++++++- .../python/README.md | 8 +++++++- .../python/scripts/deployment.yml | 4 ++-- .../python/src/app.py | 13 ++++++++++++- .../python/src/database.py | 10 ++++++++++ samples/web-app-managed-identity/python/README.md | 8 +++++++- .../python/scripts/deployment.yml | 4 ++-- .../web-app-managed-identity/python/src/app.py | 15 ++++++++++++++- .../python/README.md | 8 +++++++- .../python/scripts/deployment.yml | 4 ++-- .../python/src/app.py | 13 ++++++++++++- .../python/src/database.py | 10 ++++++++++ .../python/README.md | 8 +++++++- .../python/scripts/deployment.yml | 4 ++-- .../python/src/app.py | 13 ++++++++++++- .../python/src/database.py | 10 ++++++++++ samples/web-app-sql-database/python/README.md | 8 +++++++- .../python/scripts/deployment.yml | 4 ++-- samples/web-app-sql-database/python/src/app.py | 10 +++++++++- 31 files changed, 225 insertions(+), 36 deletions(-) diff --git a/samples/web-app-blob-storage/python/README.md b/samples/web-app-blob-storage/python/README.md index ad0d130..1b33427 100644 --- a/samples/web-app-blob-storage/python/README.md +++ b/samples/web-app-blob-storage/python/README.md @@ -48,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 @@ -60,3 +60,9 @@ 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 +``` diff --git a/samples/web-app-blob-storage/python/scripts/deployment.yml b/samples/web-app-blob-storage/python/scripts/deployment.yml index b4cd0da..60db5e9 100644 --- a/samples/web-app-blob-storage/python/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-blob-storage/python/src/app.py b/samples/web-app-blob-storage/python/src/app.py index de56959..7b7d833 100644 --- a/samples/web-app-blob-storage/python/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__) @@ -255,6 +255,19 @@ def delete(activity_id): 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-cosmosdb-mongodb-api/python/README.md b/samples/web-app-cosmosdb-mongodb-api/python/README.md index 1d2b0ed..ffa8165 100644 --- a/samples/web-app-cosmosdb-mongodb-api/python/README.md +++ b/samples/web-app-cosmosdb-mongodb-api/python/README.md @@ -48,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 @@ -60,3 +60,9 @@ 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 +``` diff --git a/samples/web-app-cosmosdb-mongodb-api/python/scripts/deployment.yml b/samples/web-app-cosmosdb-mongodb-api/python/scripts/deployment.yml index 6366645..148e5ca 100644 --- a/samples/web-app-cosmosdb-mongodb-api/python/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/src/app.py b/samples/web-app-cosmosdb-mongodb-api/python/src/app.py index aab8e20..dcc8d24 100644 --- a/samples/web-app-cosmosdb-mongodb-api/python/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 @@ -118,6 +118,16 @@ def delete(activity_id: int): 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-nosql-api/python/README.md b/samples/web-app-cosmosdb-nosql-api/python/README.md index 1238c4d..7e474d3 100644 --- a/samples/web-app-cosmosdb-nosql-api/python/README.md +++ b/samples/web-app-cosmosdb-nosql-api/python/README.md @@ -50,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 @@ -62,3 +62,9 @@ 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 +``` diff --git a/samples/web-app-cosmosdb-nosql-api/python/scripts/deployment.yml b/samples/web-app-cosmosdb-nosql-api/python/scripts/deployment.yml index 940c99f..07537ae 100644 --- a/samples/web-app-cosmosdb-nosql-api/python/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/src/app.py b/samples/web-app-cosmosdb-nosql-api/python/src/app.py index 01ec002..9ed4f92 100644 --- a/samples/web-app-cosmosdb-nosql-api/python/src/app.py +++ b/samples/web-app-cosmosdb-nosql-api/python/src/app.py @@ -2,7 +2,7 @@ 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 cosmosdb_client import CosmosDbClient @@ -114,5 +114,15 @@ def delete(activity_id: str): 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/python/src/cosmosdb_client.py b/samples/web-app-cosmosdb-nosql-api/python/src/cosmosdb_client.py index 0eb19b7..65ed36a 100644 --- a/samples/web-app-cosmosdb-nosql-api/python/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-file-storage/python/README.md b/samples/web-app-file-storage/python/README.md index 5e40d09..e9ee2db 100644 --- a/samples/web-app-file-storage/python/README.md +++ b/samples/web-app-file-storage/python/README.md @@ -92,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 @@ -105,6 +105,12 @@ 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 +``` + ## 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/scripts/deployment.yml b/samples/web-app-file-storage/python/scripts/deployment.yml index ebac00d..0e762cb 100644 --- a/samples/web-app-file-storage/python/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/src/app.py b/samples/web-app-file-storage/python/src/app.py index 34cc348..da46e6e 100644 --- a/samples/web-app-file-storage/python/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__) @@ -262,6 +262,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-in-cluster-postgresql/python/README.md b/samples/web-app-in-cluster-postgresql/python/README.md index 23d811f..75e56f1 100644 --- a/samples/web-app-in-cluster-postgresql/python/README.md +++ b/samples/web-app-in-cluster-postgresql/python/README.md @@ -52,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 @@ -64,3 +64,9 @@ 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 in-cluster PostgreSQL database is reachable and `503` with `{"status": "unavailable"}` otherwise. + +```bash +curl http://localhost:8080/health +``` diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml b/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml index 75c88d3..6939a25 100644 --- a/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml +++ b/samples/web-app-in-cluster-postgresql/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-in-cluster-postgresql/python/src/app.py b/samples/web-app-in-cluster-postgresql/python/src/app.py index cd5cb23..c5dfb9e 100644 --- a/samples/web-app-in-cluster-postgresql/python/src/app.py +++ b/samples/web-app-in-cluster-postgresql/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 @@ -86,6 +86,17 @@ def delete(activity_id: int): 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/python/src/database.py b/samples/web-app-in-cluster-postgresql/python/src/database.py index 515a6f3..d3082c0 100644 --- a/samples/web-app-in-cluster-postgresql/python/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-managed-identity/python/README.md b/samples/web-app-managed-identity/python/README.md index 14f81e2..79de032 100644 --- a/samples/web-app-managed-identity/python/README.md +++ b/samples/web-app-managed-identity/python/README.md @@ -50,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. | @@ -66,4 +66,10 @@ 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 +``` + 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. diff --git a/samples/web-app-managed-identity/python/scripts/deployment.yml b/samples/web-app-managed-identity/python/scripts/deployment.yml index ec50219..fe48043 100644 --- a/samples/web-app-managed-identity/python/scripts/deployment.yml +++ b/samples/web-app-managed-identity/python/scripts/deployment.yml @@ -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/src/app.py b/samples/web-app-managed-identity/python/src/app.py index de56959..7b7d833 100644 --- a/samples/web-app-managed-identity/python/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__) @@ -255,6 +255,19 @@ def delete(activity_id): 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-mysql-flexible-server/python/README.md b/samples/web-app-mysql-flexible-server/python/README.md index 9aa8174..6976089 100644 --- a/samples/web-app-mysql-flexible-server/python/README.md +++ b/samples/web-app-mysql-flexible-server/python/README.md @@ -50,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 @@ -62,3 +62,9 @@ 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 +``` diff --git a/samples/web-app-mysql-flexible-server/python/scripts/deployment.yml b/samples/web-app-mysql-flexible-server/python/scripts/deployment.yml index 39b0f67..8aff955 100644 --- a/samples/web-app-mysql-flexible-server/python/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/src/app.py b/samples/web-app-mysql-flexible-server/python/src/app.py index 12b231b..4f7ef11 100644 --- a/samples/web-app-mysql-flexible-server/python/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 @@ -86,6 +86,17 @@ def delete(activity_id: int): 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/python/src/database.py b/samples/web-app-mysql-flexible-server/python/src/database.py index f5680cb..eb5b097 100644 --- a/samples/web-app-mysql-flexible-server/python/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-postgresql-flexible-server/python/README.md b/samples/web-app-postgresql-flexible-server/python/README.md index d83c97c..99fe9df 100644 --- a/samples/web-app-postgresql-flexible-server/python/README.md +++ b/samples/web-app-postgresql-flexible-server/python/README.md @@ -48,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 @@ -60,3 +60,9 @@ 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 +``` diff --git a/samples/web-app-postgresql-flexible-server/python/scripts/deployment.yml b/samples/web-app-postgresql-flexible-server/python/scripts/deployment.yml index 75c88d3..6939a25 100644 --- a/samples/web-app-postgresql-flexible-server/python/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/python/src/app.py b/samples/web-app-postgresql-flexible-server/python/src/app.py index cd5cb23..c5dfb9e 100644 --- a/samples/web-app-postgresql-flexible-server/python/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 @@ -86,6 +86,17 @@ def delete(activity_id: int): 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/python/src/database.py b/samples/web-app-postgresql-flexible-server/python/src/database.py index 515a6f3..d3082c0 100644 --- a/samples/web-app-postgresql-flexible-server/python/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-sql-database/python/README.md b/samples/web-app-sql-database/python/README.md index b1ae839..9c19693 100644 --- a/samples/web-app-sql-database/python/README.md +++ b/samples/web-app-sql-database/python/README.md @@ -48,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 @@ -60,3 +60,9 @@ 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 +``` diff --git a/samples/web-app-sql-database/python/scripts/deployment.yml b/samples/web-app-sql-database/python/scripts/deployment.yml index 8054def..ed2e16f 100644 --- a/samples/web-app-sql-database/python/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/src/app.py b/samples/web-app-sql-database/python/src/app.py index eccb8e2..c48698e 100644 --- a/samples/web-app-sql-database/python/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__) @@ -139,6 +139,14 @@ def update(activity_id: int): 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" From dc6c9add0b8efe5886e766eaa17f79135d68a258 Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Tue, 8 Sep 2026 13:58:31 +0200 Subject: [PATCH 03/20] Add the .NET version of web-app-blob-storage ASP.NET Core Razor Pages port of the Vacation Planner backed by Azure Blob Storage, under samples/web-app-blob-storage/dotnet, with the same environment variables, Kubernetes names, flash wording and blob naming as the Python version and a -dotnet image name. Deletes are keyed by blob name, the Data Protection key ring is derived from SECRET_KEY so the three replicas validate each other's antiforgery tokens and flash cookies, GET /health probes the container, and the client honours the connection string's explicit BlobEndpoint, which the .NET parser otherwise rejects when the LocalStack EndpointSuffix carries a port. Verified on the emulator's AKS: rollout, port-forward and in-cluster smoke tests, then the Python image rolled over the same Deployment. Co-Authored-By: Claude Fable 5.1 --- samples/web-app-blob-storage/dotnet/README.md | 68 ++++ .../dotnet/images/architecture.png | Bin 0 -> 36963 bytes .../dotnet/scripts/00-variables.sh | 30 ++ .../dotnet/scripts/01-deploy-resources.sh | 141 ++++++++ .../dotnet/scripts/02-build-docker-image.sh | 18 + .../dotnet/scripts/03-run-docker-container.sh | 31 ++ .../dotnet/scripts/04-push-docker-image.sh | 40 ++ .../dotnet/scripts/05-deploy-app.sh | 86 +++++ .../dotnet/scripts/Dockerfile | 28 ++ .../dotnet/scripts/configmap.yml | 9 + .../dotnet/scripts/deployment.yml | 71 ++++ .../dotnet/scripts/namespace.yml | 4 + .../dotnet/scripts/secret.yml | 11 + .../dotnet/scripts/service.yml | 16 + .../dotnet/src/.dockerignore | 2 + .../dotnet/src/Models/Activity.cs | 4 + .../dotnet/src/Pages/Delete.cshtml | 2 + .../dotnet/src/Pages/Delete.cshtml.cs | 21 ++ .../dotnet/src/Pages/Index.cshtml | 265 ++++++++++++++ .../dotnet/src/Pages/Index.cshtml.cs | 47 +++ .../dotnet/src/Pages/_ViewImports.cshtml | 4 + .../dotnet/src/Program.cs | 48 +++ .../dotnet/src/Services/BlobActivityStore.cs | 123 +++++++ .../dotnet/src/Services/BlobStorageOptions.cs | 19 + .../dotnet/src/Services/IActivityStore.cs | 24 ++ .../src/Services/SecretKeyXmlRepository.cs | 49 +++ .../dotnet/src/Services/StoreInitializer.cs | 33 ++ .../dotnet/src/VacationPlanner.csproj | 12 + .../dotnet/src/appsettings.json | 9 + .../dotnet/src/wwwroot/favicon.ico | Bin 0 -> 36791 bytes .../dotnet/src/wwwroot/style.css | 341 ++++++++++++++++++ 31 files changed, 1556 insertions(+) create mode 100644 samples/web-app-blob-storage/dotnet/README.md create mode 100644 samples/web-app-blob-storage/dotnet/images/architecture.png create mode 100755 samples/web-app-blob-storage/dotnet/scripts/00-variables.sh create mode 100755 samples/web-app-blob-storage/dotnet/scripts/01-deploy-resources.sh create mode 100755 samples/web-app-blob-storage/dotnet/scripts/02-build-docker-image.sh create mode 100755 samples/web-app-blob-storage/dotnet/scripts/03-run-docker-container.sh create mode 100755 samples/web-app-blob-storage/dotnet/scripts/04-push-docker-image.sh create mode 100755 samples/web-app-blob-storage/dotnet/scripts/05-deploy-app.sh create mode 100644 samples/web-app-blob-storage/dotnet/scripts/Dockerfile create mode 100644 samples/web-app-blob-storage/dotnet/scripts/configmap.yml create mode 100644 samples/web-app-blob-storage/dotnet/scripts/deployment.yml create mode 100644 samples/web-app-blob-storage/dotnet/scripts/namespace.yml create mode 100644 samples/web-app-blob-storage/dotnet/scripts/secret.yml create mode 100644 samples/web-app-blob-storage/dotnet/scripts/service.yml create mode 100644 samples/web-app-blob-storage/dotnet/src/.dockerignore create mode 100644 samples/web-app-blob-storage/dotnet/src/Models/Activity.cs create mode 100644 samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml create mode 100644 samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml.cs create mode 100644 samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml create mode 100644 samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml.cs create mode 100644 samples/web-app-blob-storage/dotnet/src/Pages/_ViewImports.cshtml create mode 100644 samples/web-app-blob-storage/dotnet/src/Program.cs create mode 100644 samples/web-app-blob-storage/dotnet/src/Services/BlobActivityStore.cs create mode 100644 samples/web-app-blob-storage/dotnet/src/Services/BlobStorageOptions.cs create mode 100644 samples/web-app-blob-storage/dotnet/src/Services/IActivityStore.cs create mode 100644 samples/web-app-blob-storage/dotnet/src/Services/SecretKeyXmlRepository.cs create mode 100644 samples/web-app-blob-storage/dotnet/src/Services/StoreInitializer.cs create mode 100644 samples/web-app-blob-storage/dotnet/src/VacationPlanner.csproj create mode 100644 samples/web-app-blob-storage/dotnet/src/appsettings.json create mode 100644 samples/web-app-blob-storage/dotnet/src/wwwroot/favicon.ico create mode 100644 samples/web-app-blob-storage/dotnet/src/wwwroot/style.css 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..4bca8a1 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/README.md @@ -0,0 +1,68 @@ +# 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 +``` diff --git a/samples/web-app-blob-storage/dotnet/images/architecture.png b/samples/web-app-blob-storage/dotnet/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..40618b4d616a9e381ff68840dc1da589dd7cde6e GIT binary patch literal 36963 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 literal 0 HcmV?d00001 diff --git a/samples/web-app-blob-storage/dotnet/scripts/00-variables.sh b/samples/web-app-blob-storage/dotnet/scripts/00-variables.sh new file mode 100755 index 0000000..44c882d --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/00-variables.sh @@ -0,0 +1,30 @@ +# 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)" + +# Storage Account +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +CONTAINER_NAME='activities' + +# Docker Image +IMAGE_NAME="vacation-planner-blob-dotnet" +IMAGE_PULL_POLICY="Always" +IMAGE_TAG="v1" +PORT="8080" + +# Kubernetes +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" diff --git a/samples/web-app-blob-storage/dotnet/scripts/01-deploy-resources.sh b/samples/web-app-blob-storage/dotnet/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..aa4c009 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/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/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/dotnet/scripts/03-run-docker-container.sh b/samples/web-app-blob-storage/dotnet/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..36aae71 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/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-blob-storage/dotnet/scripts/04-push-docker-image.sh b/samples/web-app-blob-storage/dotnet/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-blob-storage/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-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..9e635f7 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,86 @@ +#!/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 +SECRET_KEY=$(openssl rand -hex 32) + +# 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 - + +# 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/dotnet/scripts/configmap.yml b/samples/web-app-blob-storage/dotnet/scripts/configmap.yml new file mode 100644 index 0000000..21b20c5 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/configmap.yml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-blob-config + namespace: vacation-planner-blob + labels: + app: vacation-planner-blob +data: + CONTAINER_NAME: "activities" 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/dotnet/scripts/namespace.yml b/samples/web-app-blob-storage/dotnet/scripts/namespace.yml new file mode 100644 index 0000000..9e8b556 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-blob diff --git a/samples/web-app-blob-storage/dotnet/scripts/secret.yml b/samples/web-app-blob-storage/dotnet/scripts/secret.yml new file mode 100644 index 0000000..d450796 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/secret.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-blob-secrets + namespace: vacation-planner-blob + labels: + app: vacation-planner-blob +type: Opaque +data: + AZURE_STORAGE_ACCOUNT_CONNECTION_STRING: "" + SECRET_KEY: "" diff --git a/samples/web-app-blob-storage/dotnet/scripts/service.yml b/samples/web-app-blob-storage/dotnet/scripts/service.yml new file mode 100644 index 0000000..a757f13 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-blob + namespace: vacation-planner-blob + labels: + app: vacation-planner-blob +spec: + type: ClusterIP + selector: + app: vacation-planner-blob + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http 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..2460ba5 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,21 @@ +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) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) + { + 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..c077e52 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store) : 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)) + { + TempData["Flash"] = "Activity updated successfully."; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + 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..c8a8a85 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Program.cs @@ -0,0 +1,48 @@ +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."); +} + +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..05bebdf --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/Services/BlobActivityStore.cs @@ -0,0 +1,123 @@ +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 +{ + 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); + activities.Add(new Activity(blob.Name, content.Value.Content.ToString())); + } + + return activities; + } + + public Task AddAsync(string text, CancellationToken cancellationToken) => + UploadAsync($"{DateTime.Now:yyyy-MM-dd-HH-mm-ss}-activity.txt", text, cancellationToken); + + public Task UpdateAsync(string id, string text, CancellationToken cancellationToken) => + UploadAsync(id, text, cancellationToken); + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + // 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("Blob '{Name}' 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); + return true; + } + + /// + /// 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..10f68b8 --- /dev/null +++ b/samples/web-app-blob-storage/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-blob-storage/dotnet/src/wwwroot/favicon.ico b/samples/web-app-blob-storage/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5b1d5cf37d81c073dbacd66df3f69d9240359dda GIT binary patch literal 36791 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 D Date: Tue, 8 Sep 2026 14:02:07 +0200 Subject: [PATCH 04/20] Add the .NET version of web-app-managed-identity Same ASP.NET Core Vacation Planner as web-app-blob-storage, authenticating to Blob Storage with Microsoft Entra Workload ID through DefaultAzureCredential: the pod's service account carries the federated identity, and the emulator's webhook injects the token file, the authority host and the trust bundle (SSL_CERT_FILE), so the app needs no identity-specific code. Verified on the emulator's AKS: rollout, port-forward and in-cluster smoke tests, then the Python image rolled over the same Deployment. Co-Authored-By: Claude Fable 5.1 --- .../web-app-managed-identity/dotnet/README.md | 75 ++++ .../dotnet/images/architecture.png | Bin 0 -> 41535 bytes .../dotnet/scripts/00-variables.sh | 41 +++ .../dotnet/scripts/01-deploy-resources.sh | 242 +++++++++++++ .../dotnet/scripts/02-build-docker-image.sh | 18 + .../dotnet/scripts/03-run-docker-container.sh | 31 ++ .../dotnet/scripts/04-push-docker-image.sh | 40 ++ .../dotnet/scripts/05-deploy-app.sh | 307 ++++++++++++++++ .../dotnet/scripts/Dockerfile | 28 ++ .../dotnet/scripts/configmap.yml | 12 + .../dotnet/scripts/deployment.yml | 86 +++++ .../dotnet/scripts/gateway.yml | 30 ++ .../dotnet/scripts/httproute.yml | 21 ++ .../dotnet/scripts/issuer.yml | 25 ++ .../dotnet/scripts/namespace.yml | 4 + .../dotnet/scripts/secret.yml | 12 + .../dotnet/scripts/service.yml | 16 + .../dotnet/src/.dockerignore | 2 + .../dotnet/src/Models/Activity.cs | 4 + .../dotnet/src/Pages/Delete.cshtml | 2 + .../dotnet/src/Pages/Delete.cshtml.cs | 21 ++ .../dotnet/src/Pages/Index.cshtml | 265 ++++++++++++++ .../dotnet/src/Pages/Index.cshtml.cs | 47 +++ .../dotnet/src/Pages/_ViewImports.cshtml | 4 + .../dotnet/src/Program.cs | 48 +++ .../dotnet/src/Services/BlobActivityStore.cs | 123 +++++++ .../dotnet/src/Services/BlobStorageOptions.cs | 19 + .../dotnet/src/Services/IActivityStore.cs | 24 ++ .../src/Services/SecretKeyXmlRepository.cs | 49 +++ .../dotnet/src/Services/StoreInitializer.cs | 33 ++ .../dotnet/src/VacationPlanner.csproj | 12 + .../dotnet/src/appsettings.json | 9 + .../dotnet/src/wwwroot/favicon.ico | Bin 0 -> 36791 bytes .../dotnet/src/wwwroot/style.css | 341 ++++++++++++++++++ 34 files changed, 1991 insertions(+) create mode 100644 samples/web-app-managed-identity/dotnet/README.md create mode 100644 samples/web-app-managed-identity/dotnet/images/architecture.png create mode 100755 samples/web-app-managed-identity/dotnet/scripts/00-variables.sh create mode 100755 samples/web-app-managed-identity/dotnet/scripts/01-deploy-resources.sh create mode 100755 samples/web-app-managed-identity/dotnet/scripts/02-build-docker-image.sh create mode 100755 samples/web-app-managed-identity/dotnet/scripts/03-run-docker-container.sh create mode 100755 samples/web-app-managed-identity/dotnet/scripts/04-push-docker-image.sh create mode 100755 samples/web-app-managed-identity/dotnet/scripts/05-deploy-app.sh create mode 100644 samples/web-app-managed-identity/dotnet/scripts/Dockerfile create mode 100644 samples/web-app-managed-identity/dotnet/scripts/configmap.yml create mode 100644 samples/web-app-managed-identity/dotnet/scripts/deployment.yml create mode 100644 samples/web-app-managed-identity/dotnet/scripts/gateway.yml create mode 100644 samples/web-app-managed-identity/dotnet/scripts/httproute.yml create mode 100644 samples/web-app-managed-identity/dotnet/scripts/issuer.yml create mode 100644 samples/web-app-managed-identity/dotnet/scripts/namespace.yml create mode 100644 samples/web-app-managed-identity/dotnet/scripts/secret.yml create mode 100644 samples/web-app-managed-identity/dotnet/scripts/service.yml create mode 100644 samples/web-app-managed-identity/dotnet/src/.dockerignore create mode 100644 samples/web-app-managed-identity/dotnet/src/Models/Activity.cs create mode 100644 samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml create mode 100644 samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs create mode 100644 samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml create mode 100644 samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs create mode 100644 samples/web-app-managed-identity/dotnet/src/Pages/_ViewImports.cshtml create mode 100644 samples/web-app-managed-identity/dotnet/src/Program.cs create mode 100644 samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs create mode 100644 samples/web-app-managed-identity/dotnet/src/Services/BlobStorageOptions.cs create mode 100644 samples/web-app-managed-identity/dotnet/src/Services/IActivityStore.cs create mode 100644 samples/web-app-managed-identity/dotnet/src/Services/SecretKeyXmlRepository.cs create mode 100644 samples/web-app-managed-identity/dotnet/src/Services/StoreInitializer.cs create mode 100644 samples/web-app-managed-identity/dotnet/src/VacationPlanner.csproj create mode 100644 samples/web-app-managed-identity/dotnet/src/appsettings.json create mode 100644 samples/web-app-managed-identity/dotnet/src/wwwroot/favicon.ico create mode 100644 samples/web-app-managed-identity/dotnet/src/wwwroot/style.css diff --git a/samples/web-app-managed-identity/dotnet/README.md b/samples/web-app-managed-identity/dotnet/README.md new file mode 100644 index 0000000..5739197 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/README.md @@ -0,0 +1,75 @@ +# Vacation Planner: Azure Blob Storage with Microsoft Entra Workload ID + +> 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. + +Unlike the [`web-app-blob-storage`](../../web-app-blob-storage/dotnet/) 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. + +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. + +## 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, managed identity and federated credential names, storage account, optional DNS/Gateway settings, 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 `activities` container, and the [user-assigned managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) with its role assignment on the storage account. | +| [`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) | Creates the workload-identity service account and the [federated identity credential](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation) that links it to the managed identity, then uses the YAML manifests below (templated with `yq`) to deploy the app. When `DEPLOY_GATEWAY="true"`, it also applies the Issuer, Gateway, and HTTPRoute and updates the [Azure DNS](https://learn.microsoft.com/en-us/azure/dns/dns-overview) A record. | +| [`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, 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 `SECRET_KEY` the app derives its Data Protection key ring from (so antiforgery tokens and flash messages are valid on every replica), 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. 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. | +| [`httproute.yml`](scripts/httproute.yml) | (Gateway path) Gateway API HTTPRoute that routes the hostname's traffic to the Service. | + +## Accessing the web app + +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 +``` + +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. diff --git a/samples/web-app-managed-identity/dotnet/images/architecture.png b/samples/web-app-managed-identity/dotnet/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..188f5dad127033e8dacffc62c451b088717e6b84 GIT binary patch literal 41535 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 literal 0 HcmV?d00001 diff --git a/samples/web-app-managed-identity/dotnet/scripts/00-variables.sh b/samples/web-app-managed-identity/dotnet/scripts/00-variables.sh new file mode 100755 index 0000000..64e3499 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/00-variables.sh @@ -0,0 +1,41 @@ +# 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" + +# Storage Account +STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" +CONTAINER_NAME='activities' + +# Docker Image +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-managed-identity/dotnet/scripts/01-deploy-resources.sh b/samples/web-app-managed-identity/dotnet/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..f954dfc --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/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/dotnet/scripts/02-build-docker-image.sh b/samples/web-app-managed-identity/dotnet/scripts/02-build-docker-image.sh new file mode 100755 index 0000000..4b497d8 --- /dev/null +++ b/samples/web-app-managed-identity/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-managed-identity/dotnet/scripts/03-run-docker-container.sh b/samples/web-app-managed-identity/dotnet/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..36aae71 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/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/dotnet/scripts/04-push-docker-image.sh b/samples/web-app-managed-identity/dotnet/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-managed-identity/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-managed-identity/dotnet/scripts/05-deploy-app.sh b/samples/web-app-managed-identity/dotnet/scripts/05-deploy-app.sh new file mode 100755 index 0000000..24633fd --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,307 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# 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 +SECRET_KEY=$(openssl rand -hex 32) + +# Optional client secret for the ClientSecretCredential auth path. +# Leave empty when using Microsoft Entra Workload ID (the recommended option). +AZURE_CLIENT_SECRET="${AZURE_CLIENT_SECRET:-}" + +# Get the storage account blob primary endpoint +AZURE_STORAGE_ACCOUNT_URL=$(az storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "primaryEndpoints.blob" \ + --output tsv \ + --only-show-errors) + +if [ -n "$AZURE_STORAGE_ACCOUNT_URL" ]; then + echo "Storage account blob primary endpoint retrieved successfully: $AZURE_STORAGE_ACCOUNT_URL" +else + echo "Failed to retrieve storage account blob primary endpoint." + exit 1 +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 - + +# Check if the service account already exists +RESULT=$(kubectl get sa -n $NAMESPACE -o 'jsonpath={.items[?(@.metadata.name=="'$SERVICE_ACCOUNT_NAME'")].metadata.name}') + +if [[ -n $RESULT ]]; then + echo "[$SERVICE_ACCOUNT_NAME] service account already exists" +else + # Retrieve the resource id 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 + + # Create the service account + echo "[$SERVICE_ACCOUNT_NAME] service account does not exist" + echo "Creating [$SERVICE_ACCOUNT_NAME] service account..." + cat </dev/null + +if [[ $? != 0 ]]; then + echo "No [$FEDERATED_IDENTITY_NAME] federated identity credential actually exists in the [$RESOURCE_GROUP_NAME] resource group" + + # Get the OIDC Issuer URL + OIDC_ISSUER_URL="$(az aks show \ + --only-show-errors \ + --name $AKS_CLUSTER_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query oidcIssuerProfile.issuerUrl \ + --output tsv)" + + # Show OIDC Issuer URL + if [[ -n $OIDC_ISSUER_URL ]]; then + echo "The OIDC Issuer URL of the $AKS_CLUSTER_NAME cluster is $OIDC_ISSUER_URL" + fi + + echo "Creating [$FEDERATED_IDENTITY_NAME] federated identity credential in the [$RESOURCE_GROUP_NAME] resource group..." + + # Establish the federated identity credential between the managed identity, the service account issuer, and the subject. + az identity federated-credential create \ + --name $FEDERATED_IDENTITY_NAME \ + --identity-name $MANAGED_IDENTITY_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --issuer $OIDC_ISSUER_URL \ + --subject system:serviceaccount:$NAMESPACE:$SERVICE_ACCOUNT_NAME 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$FEDERATED_IDENTITY_NAME] federated identity credential successfully created in the [$RESOURCE_GROUP_NAME] resource group" + else + echo "Failed to create [$FEDERATED_IDENTITY_NAME] federated identity credential in the [$RESOURCE_GROUP_NAME] resource group" + exit + fi +else + echo "[$FEDERATED_IDENTITY_NAME] federated identity credential already exists in the [$RESOURCE_GROUP_NAME] resource group" +fi + +# Create secret with the storage connection string, client secret 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.AZURE_CLIENT_SECRET)|="\""$(echo -n $AZURE_CLIENT_SECRET | base64 -w0)"\" | +yq "(.data.SECRET_KEY)|="\""$(echo -n $SECRET_KEY | base64 -w0)"\" | +kubectl apply -f - + +# Retrieve the clientId of the user-assigned managed identity for the configmap +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 [[ -z $CLIENT_ID ]]; then + echo "Failed to retrieve clientId for the [$MANAGED_IDENTITY_NAME] managed identity" + exit 1 +fi + +# Create configmap with environment variables +cat configmap.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.CONTAINER_NAME)|="\""$CONTAINER_NAME"\" | +yq "(.data.AZURE_CLIENT_ID)|="\""$CLIENT_ID"\" | +yq "(.data.AZURE_TENANT_ID)|="\""$TENANT_ID"\" | +yq "(.data.AZURE_STORAGE_ACCOUNT_URL)|="\""$AZURE_STORAGE_ACCOUNT_URL"\" | +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.solvers[0].http01.gatewayHTTPRoute.parentRefs[0].name)|=\"$NAME\"" | + yq "(.spec.acme.solvers[0].http01.gatewayHTTPRoute.parentRefs[0].namespace)|=\"$NAMESPACE\"" | + kubectl apply -f - +fi + +# 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.serviceAccountName)|="\""$SERVICE_ACCOUNT_NAME"\" | +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 - + +if [[ $DEPLOY_GATEWAY == "true" ]]; then + # Create gateway + cat gateway.yml | + yq "(.metadata.namespace)|="\""$NAMESPACE"\" | + yq "(.spec.listeners[0].hostname)|="\""$SUBDOMAIN.$DNS_ZONE_NAME"\" | + yq "(.spec.listeners[1].hostname)|="\""$SUBDOMAIN.$DNS_ZONE_NAME"\" | + kubectl apply -f - + + # Create httproute + cat httproute.yml | + yq "(.metadata.namespace)|="\""$NAMESPACE"\" | + yq "(.spec.hostnames[0])|="\""$SUBDOMAIN.$DNS_ZONE_NAME"\" | + kubectl apply -f - + + # Retrieve the public IP address from the gateway + echo -n "Retrieving the external IP address from the [$NAME] gateway..." + while [[ -z $PUBLIC_IP_ADDRESS ]]; do + PUBLIC_IP_ADDRESS=$(kubectl get gateway $NAME -n $NAMESPACE -o jsonpath='{.status.addresses[0].value}') + if [[ -n $PUBLIC_IP_ADDRESS ]]; then + echo '' + break + else + echo -n "." # Progress indicator + sleep 1 + fi + done + + if [[ -n $PUBLIC_IP_ADDRESS ]]; then + echo "[$PUBLIC_IP_ADDRESS] external IP address successfully retrieved from the [$NAME] gateway" + else + echo "Failed to retrieve the external IP address from the [$NAME] gateway" + exit + fi + + # Check if an A record for todolist subdomain exists in the DNS Zone + echo "Retrieving the A record for the [$SUBDOMAIN] subdomain from the [$DNS_ZONE_NAME] DNS zone..." + IPV4_ADDRESS=$(az network dns record-set a list \ + --zone-name $DNS_ZONE_NAME \ + --resource-group $DNS_ZONE_RESOURCE_GROUP_NAME \ + --query "[?name=='$SUBDOMAIN'].ARecords[].ipv4Address" \ + --output tsv \ + --only-show-errors) + + if [[ -n $IPV4_ADDRESS ]]; then + echo "An A record already exists in [$DNS_ZONE_NAME] DNS zone for the [$SUBDOMAIN] subdomain with [$IPV4_ADDRESS] IP address" + + if [[ $IPV4_ADDRESS == "$PUBLIC_IP_ADDRESS" ]]; then + echo "The [$IPV4_ADDRESS] ip address of the existing A record is equal to the ip address of the [$NAME] gateway" + echo "No additional step is required" + exit + else + echo "The [$IPV4_ADDRESS] ip address of the existing A record is different than the ip address of the [$NAME] gateway" + fi + + # Retrieving name of the record set relative to the zone + echo "Retrieving the name of the record set relative to the [$DNS_ZONE_NAME] zone..." + + RECORD_SET_NAME=$(az network dns record-set a list \ + --zone-name $DNS_ZONE_NAME \ + --resource-group $DNS_ZONE_RESOURCE_GROUP_NAME \ + --query "[?name=='$SUBDOMAIN'].name" \ + --output tsv \ + --only-show-errors 2>/dev/null) + + if [[ -n $RECORD_SET_NAME ]]; then + echo "[$RECORD_SET_NAME] record set name successfully retrieved" + else + echo "Failed to retrieve the name of the record set relative to the [$DNS_ZONE_NAME] zone" + exit + fi + + # Remove the a record + echo "Removing the A record from the record set relative to the [$DNS_ZONE_NAME] zone..." + + az network dns record-set a remove-record \ + --ipv4-address "$IPV4_ADDRESS" \ + --record-set-name "$RECORD_SET_NAME" \ + --zone-name "$DNS_ZONE_NAME" \ + --resource-group "$DNS_ZONE_RESOURCE_GROUP_NAME" \ + --only-show-errors 2>/dev/null + + if [[ $? == 0 ]]; then + echo "[$IPV4_ADDRESS] ip address successfully removed from the [$RECORD_SET_NAME] record set" + else + echo "Failed to remove the [$IPV4_ADDRESS] ip address from the [$RECORD_SET_NAME] record set" + exit + fi + fi + + # Create the a record + echo "Creating an A record in [$DNS_ZONE_NAME] DNS zone for the [$SUBDOMAIN] subdomain with [$PUBLIC_IP_ADDRESS] IP address..." + az network dns record-set a add-record \ + --zone-name "$DNS_ZONE_NAME" \ + --resource-group "$DNS_ZONE_RESOURCE_GROUP_NAME" \ + --record-set-name "$SUBDOMAIN" \ + --ipv4-address "$PUBLIC_IP_ADDRESS" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "A record for the [$SUBDOMAIN] subdomain with [$PUBLIC_IP_ADDRESS] IP address successfully created in [$DNS_ZONE_NAME] DNS zone" + else + echo "Failed to create an A record for the [$SUBDOMAIN] subdomain with [$PUBLIC_IP_ADDRESS] IP address in [$DNS_ZONE_NAME] DNS zone" + fi +fi + +# 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-managed-identity/dotnet/scripts/Dockerfile b/samples/web-app-managed-identity/dotnet/scripts/Dockerfile new file mode 100644 index 0000000..d862da7 --- /dev/null +++ b/samples/web-app-managed-identity/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-managed-identity/dotnet/scripts/configmap.yml b/samples/web-app-managed-identity/dotnet/scripts/configmap.yml new file mode 100644 index 0000000..5f40ea9 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/configmap.yml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-blob-config + namespace: vacation-planner-blob + labels: + app: vacation-planner-blob +data: + CONTAINER_NAME: "activities" + AZURE_CLIENT_ID: "" + AZURE_TENANT_ID: "" + AZURE_STORAGE_ACCOUNT_URL: "" diff --git a/samples/web-app-managed-identity/dotnet/scripts/deployment.yml b/samples/web-app-managed-identity/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..aab24d0 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/deployment.yml @@ -0,0 +1,86 @@ +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 + azure.workload.identity/use: "true" + spec: + serviceAccountName: vacation-planner-blob-sa + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app: vacation-planner-blob + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app: vacation-planner-blob + 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_URL + valueFrom: + configMapKeyRef: + name: vacation-planner-blob-config + key: AZURE_STORAGE_ACCOUNT_URL + - 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-managed-identity/dotnet/scripts/gateway.yml b/samples/web-app-managed-identity/dotnet/scripts/gateway.yml new file mode 100644 index 0000000..8cf9697 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/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-blob-issuer + name: vacation-planner-blob +spec: + gatewayClassName: nginx + listeners: + - hostname: local.echo.babosbird.com + name: http + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: All + - hostname: local.echo.babosbird.com + name: https + port: 443 + protocol: HTTPS + allowedRoutes: + namespaces: + from: All + tls: + mode: Terminate + certificateRefs: + - group: "" + kind: Secret + name: vacation-planner-blob-tls-secret \ No newline at end of file diff --git a/samples/web-app-managed-identity/dotnet/scripts/httproute.yml b/samples/web-app-managed-identity/dotnet/scripts/httproute.yml new file mode 100644 index 0000000..b2d7a36 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/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-blob +spec: + hostnames: + - planner.local.babosbird.com + parentRefs: + - name: vacation-planner-blob + port: 443 + sectionName: https + rules: + - backendRefs: + - name: vacation-planner-blob + port: 80 + matches: + - path: + type: PathPrefix + value: / \ No newline at end of file diff --git a/samples/web-app-managed-identity/dotnet/scripts/issuer.yml b/samples/web-app-managed-identity/dotnet/scripts/issuer.yml new file mode 100644 index 0000000..2d200b1 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/issuer.yml @@ -0,0 +1,25 @@ +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: vacation-planner-blob-issuer + namespace: vacation-planner-blob +spec: + acme: + # Registration address for the Let's Encrypt account: expiry notices go here + email: paolo.salvatori@localstack.cloud + privateKeySecretRef: + name: vacation-planner-blob-issuer + server: https://acme-v02.api.letsencrypt.org/directory + solvers: + - http01: + gatewayHTTPRoute: + parentRefs: + - name: vacation-planner-blob + namespace: vacation-planner-blob + kind: Gateway + group: gateway.networking.k8s.io + sectionName: vacation-planner-blob-http + podTemplate: + spec: + nodeSelector: + kubernetes.io/os: linux diff --git a/samples/web-app-managed-identity/dotnet/scripts/namespace.yml b/samples/web-app-managed-identity/dotnet/scripts/namespace.yml new file mode 100644 index 0000000..9e8b556 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-blob diff --git a/samples/web-app-managed-identity/dotnet/scripts/secret.yml b/samples/web-app-managed-identity/dotnet/scripts/secret.yml new file mode 100644 index 0000000..6e45b31 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/secret.yml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-blob-secrets + namespace: vacation-planner-blob + labels: + app: vacation-planner-blob +type: Opaque +data: + AZURE_STORAGE_ACCOUNT_CONNECTION_STRING: "" + AZURE_CLIENT_SECRET: "" + SECRET_KEY: "" diff --git a/samples/web-app-managed-identity/dotnet/scripts/service.yml b/samples/web-app-managed-identity/dotnet/scripts/service.yml new file mode 100644 index 0000000..a757f13 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-blob + namespace: vacation-planner-blob + labels: + app: vacation-planner-blob +spec: + type: ClusterIP + selector: + app: vacation-planner-blob + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-managed-identity/dotnet/src/.dockerignore b/samples/web-app-managed-identity/dotnet/src/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/web-app-managed-identity/dotnet/src/Models/Activity.cs b/samples/web-app-managed-identity/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-managed-identity/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-managed-identity/dotnet/src/Pages/Delete.cshtml b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..2460ba5 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,21 @@ +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) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) + { + TempData["Flash"] = "Activity deleted successfully."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml b/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-managed-identity/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-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..c077e52 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store) : 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)) + { + TempData["Flash"] = "Activity updated successfully."; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + 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..c8a8a85 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Program.cs @@ -0,0 +1,48 @@ +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."); +} + +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..05bebdf --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs @@ -0,0 +1,123 @@ +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 +{ + 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); + activities.Add(new Activity(blob.Name, content.Value.Content.ToString())); + } + + return activities; + } + + public Task AddAsync(string text, CancellationToken cancellationToken) => + UploadAsync($"{DateTime.Now:yyyy-MM-dd-HH-mm-ss}-activity.txt", text, cancellationToken); + + public Task UpdateAsync(string id, string text, CancellationToken cancellationToken) => + UploadAsync(id, text, cancellationToken); + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + // 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("Blob '{Name}' 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); + return true; + } + + /// + /// 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..10f68b8 --- /dev/null +++ b/samples/web-app-managed-identity/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "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 0000000000000000000000000000000000000000..5b1d5cf37d81c073dbacd66df3f69d9240359dda GIT binary patch literal 36791 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 D Date: Tue, 8 Sep 2026 14:05:11 +0200 Subject: [PATCH 05/20] Add the .NET version of web-app-file-storage ASP.NET Core Vacation Planner storing one text file per activity on an Azure Files share mounted by the CSI driver, with no Azure SDK, under samples/web-app-file-storage/dotnet. It keeps the Python behaviour: files named yyyy-MM-dd-HH-mm-ss-activity.txt, listed by name, deletes posted by file name with the same path-traversal guard, failure flashes when the share rejects a write, the serving pod shown in the header, and the app user pinned to uid/gid 1000 (the runtime image's default ubuntu account, which owns 1000, is dropped first). Verified on the emulator's AKS with a static SMB share: seeded activities listed, rollout, port-forward and in-cluster smoke tests, then the Python image rolled over the same Deployment. Co-Authored-By: Claude Fable 5.1 --- samples/web-app-file-storage/dotnet/README.md | 155 ++++++++ .../dotnet/images/architecture.png | Bin 0 -> 38170 bytes .../dotnet/scripts/00-variables.sh | 67 ++++ .../dotnet/scripts/01-deploy-resources.sh | 308 ++++++++++++++++ .../dotnet/scripts/02-build-docker-image.sh | 18 + .../dotnet/scripts/03-run-docker-container.sh | 45 +++ .../dotnet/scripts/04-push-docker-image.sh | 40 ++ .../dotnet/scripts/05-deploy-app.sh | 210 +++++++++++ .../dotnet/scripts/Dockerfile | 41 +++ .../dotnet/scripts/configmap.yml | 11 + .../dotnet/scripts/deployment.yml | 165 +++++++++ .../dotnet/scripts/namespace.yml | 4 + .../dotnet/scripts/persistentvolume-nfs.yml | 42 +++ .../dotnet/scripts/persistentvolume-smb.yml | 45 +++ .../dotnet/scripts/persistentvolumeclaim.yml | 24 ++ .../dotnet/scripts/secret.yml | 10 + .../dotnet/scripts/seed-configmap.yml | 24 ++ .../dotnet/scripts/service.yml | 16 + .../dotnet/scripts/storage-secret.yml | 15 + .../dotnet/scripts/storageclass-nfs.yml | 26 ++ .../dotnet/src/.dockerignore | 2 + .../dotnet/src/Models/Activity.cs | 4 + .../dotnet/src/Pages/Delete.cshtml | 2 + .../dotnet/src/Pages/Delete.cshtml.cs | 27 ++ .../dotnet/src/Pages/Index.cshtml | 268 ++++++++++++++ .../dotnet/src/Pages/Index.cshtml.cs | 51 +++ .../dotnet/src/Pages/_ViewImports.cshtml | 4 + .../dotnet/src/Program.cs | 49 +++ .../dotnet/src/Services/FileActivityStore.cs | 163 +++++++++ .../dotnet/src/Services/FileStorageOptions.cs | 20 + .../dotnet/src/Services/IActivityStore.cs | 24 ++ .../src/Services/SecretKeyXmlRepository.cs | 49 +++ .../dotnet/src/Services/StoreInitializer.cs | 33 ++ .../dotnet/src/VacationPlanner.csproj | 8 + .../dotnet/src/appsettings.json | 9 + .../dotnet/src/wwwroot/favicon.ico | Bin 0 -> 36791 bytes .../dotnet/src/wwwroot/style.css | 341 ++++++++++++++++++ 37 files changed, 2320 insertions(+) create mode 100644 samples/web-app-file-storage/dotnet/README.md create mode 100644 samples/web-app-file-storage/dotnet/images/architecture.png create mode 100755 samples/web-app-file-storage/dotnet/scripts/00-variables.sh create mode 100755 samples/web-app-file-storage/dotnet/scripts/01-deploy-resources.sh create mode 100755 samples/web-app-file-storage/dotnet/scripts/02-build-docker-image.sh create mode 100755 samples/web-app-file-storage/dotnet/scripts/03-run-docker-container.sh create mode 100755 samples/web-app-file-storage/dotnet/scripts/04-push-docker-image.sh create mode 100755 samples/web-app-file-storage/dotnet/scripts/05-deploy-app.sh create mode 100644 samples/web-app-file-storage/dotnet/scripts/Dockerfile create mode 100644 samples/web-app-file-storage/dotnet/scripts/configmap.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/deployment.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/namespace.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/persistentvolume-nfs.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/persistentvolume-smb.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/persistentvolumeclaim.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/secret.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/seed-configmap.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/service.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/storage-secret.yml create mode 100644 samples/web-app-file-storage/dotnet/scripts/storageclass-nfs.yml create mode 100644 samples/web-app-file-storage/dotnet/src/.dockerignore create mode 100644 samples/web-app-file-storage/dotnet/src/Models/Activity.cs create mode 100644 samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml create mode 100644 samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml.cs create mode 100644 samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml create mode 100644 samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml.cs create mode 100644 samples/web-app-file-storage/dotnet/src/Pages/_ViewImports.cshtml create mode 100644 samples/web-app-file-storage/dotnet/src/Program.cs create mode 100644 samples/web-app-file-storage/dotnet/src/Services/FileActivityStore.cs create mode 100644 samples/web-app-file-storage/dotnet/src/Services/FileStorageOptions.cs create mode 100644 samples/web-app-file-storage/dotnet/src/Services/IActivityStore.cs create mode 100644 samples/web-app-file-storage/dotnet/src/Services/SecretKeyXmlRepository.cs create mode 100644 samples/web-app-file-storage/dotnet/src/Services/StoreInitializer.cs create mode 100644 samples/web-app-file-storage/dotnet/src/VacationPlanner.csproj create mode 100644 samples/web-app-file-storage/dotnet/src/appsettings.json create mode 100644 samples/web-app-file-storage/dotnet/src/wwwroot/favicon.ico create mode 100644 samples/web-app-file-storage/dotnet/src/wwwroot/style.css diff --git a/samples/web-app-file-storage/dotnet/README.md b/samples/web-app-file-storage/dotnet/README.md new file mode 100644 index 0000000..cc32468 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/README.md @@ -0,0 +1,155 @@ +# Vacation Planner: Azure Files + +> 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 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 `File.WriteAllText`, `Directory.EnumerateFiles` and `File.Delete` 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. + +The three replicas of the deployment mount the same share at the same time (`ReadWriteMany`), so an activity added through one pod is served by all of them. The UI shows the name of the pod that served the page, which makes that visible. + +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. + +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. + +## Architecture + +The following diagram illustrates the architecture of the solution: + +![Architecture Diagram](./images/architecture.png) + +## Provisioning mode and protocol + +The first script asks two questions and remembers the answers, so the sample covers four combinations with one set of scripts: + +| | **Static**: the file share is created by `01-deploy-resources.sh` | **Dynamic**: the file share is created by the CSI driver | +| --- | --- | --- | +| **SMB** | A `StorageV2` account with a `Standard_LRS` SMB share, a `PersistentVolume` that points at it, and a Secret holding the account key for the mount. | A claim on the built-in `azurefile-csi` storage class. The driver creates the storage account and the share in the node resource group. | +| **NFS** | A premium `FileStorage` account with an NFS share, restricted to the node subnets, and a `PersistentVolume` with `protocol: nfs` and no Secret. | A claim on the `vacation-planner-file-nfs` storage class created from [`storageclass-nfs.yml`](scripts/storageclass-nfs.yml), because none of the built-in classes provisions an NFS share. | + +Static provisioning is the better default: it works unchanged on Azure, needs no premium account, and produces a share you can inspect with `az storage file list`. Dynamic provisioning is the better demonstration of the driver itself, since the driver creates the storage account and the share on its own, in the cluster's node resource group. + +Both answers are stored in `scripts/.deploy-options.env` (git-ignored), so `05-deploy-app.sh` deploys the app for the same combination without asking again. Exporting the two variables skips the menus, which is what an unattended run does: + +```bash +PROVISIONING_MODE=dynamic FILE_SHARE_PROTOCOL=nfs ./01-deploy-resources.sh +``` + +An exported variable always wins over the stored answer, so make sure the two agree: deploying a static volume for a share that was never created leaves the pods in `ContainerCreating` with a mount error. Both scripts print the combination they are working on. + +## SMB or NFS + +The two protocols are not interchangeable, and the differences are what the sample makes visible: + +| | SMB | NFS | +| --- | --- | --- | +| Authentication | The storage account key (NTLMv2), which the driver reads from a Kubernetes Secret with the fixed keys `azurestorageaccountname` and `azurestorageaccountkey`. | None. Access is granted by the network rules of the storage account, which is why the account has to be restricted to the node subnets. | +| Storage account | Any account kind, `Standard_LRS` is enough. | A premium `FileStorage` account: [NFS shares are only available on SSD file shares](https://learn.microsoft.com/en-us/azure/storage/files/files-nfs-protocol), with a 100 GiB minimum. | +| Permissions | Synthesized by the client from the `uid`, `gid`, `file_mode` and `dir_mode` mount options: no ownership is stored on the share. | Real POSIX ownership stored on the share, and the mount options above are ignored. The share arrives owned by `root`, so the init container of the deployment takes ownership of it as uid 1000. | +| Network | Reachable over the public endpoint. | Reachable only from a private endpoint or from a virtual network with the `Microsoft.Storage` service endpoint. | +| Secure transfer | Required, and on by default. | Must be off, unless the client uses the AZNFS TLS helper. | +| Interoperability | The same share is reachable over the file data plane REST API, so `az storage file list` shows the files the pods write. | Not reachable over the file data plane REST API on Azure: use `kubectl exec` to look at the share. | + +`securityContext.fsGroup` is deliberately **not** used to make the NFS share writable. The `file.csi.azure.com` CSIDriver object declares `fsGroupPolicy: ReadWriteOnceWithFSType`, so the kubelet applies `fsGroup` only to a `ReadWriteOnce` volume with a file system type. This volume is `ReadWriteMany`, so setting `fsGroup` would silently do nothing. The init container chowning the mount point is the documented way out. + +## 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 file share names, Kubernetes namespace, mount path, …). The other scripts load these values by sourcing this file. | +| [`01-deploy-resources.sh`](scripts/01-deploy-resources.sh) | Asks for the provisioning mode and the protocol, then 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), and, in static mode, the storage account and the [Azure file share](https://learn.microsoft.com/en-us/azure/storage/files/storage-files-introduction) for the chosen protocol. | +| [`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, no Azure resource) with a directory on the host mounted where the Azure file share goes, to validate that it starts and stores the activities 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, applying only the ones the chosen combination needs, and waits for the rollout to complete. | +| [`Dockerfile`](scripts/Dockerfile) | Builds the Docker image of the web app. Unlike the other samples it pins the app user to uid and gid 1000, because that identity appears in the mount options of the SMB volume and in the ownership of the NFS share. | +| [`namespace.yml`](scripts/namespace.yml) | Creates the Kubernetes namespace. | +| [`configmap.yml`](scripts/configmap.yml) | Creates the ConfigMap holding non-secret input values (the directory where the file share is mounted) passed to the app as environment variables. | +| [`seed-configmap.yml`](scripts/seed-configmap.yml) | Holds the sample activities the init container copies into the file share when they are missing, so the app is never empty on the first page load. | +| [`secret.yml`](scripts/secret.yml) | Creates the Secret holding the `SECRET_KEY` the app derives its Data Protection key ring from (so antiforgery tokens and flash messages are valid on every replica). It holds no storage credential: the app never authenticates to Azure. | +| [`storage-secret.yml`](scripts/storage-secret.yml) | Creates the Secret holding the storage account name and key the CSI driver uses to mount the SMB share. Applied for the static SMB combination only. | +| [`persistentvolume-smb.yml`](scripts/persistentvolume-smb.yml) | Creates the `PersistentVolume` bound to a pre-created SMB file share, with the account key secret and the `uid`, `gid` and mode mount options. | +| [`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. 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-file 8080:80 -n vacation-planner-file +``` + +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 +``` + +## 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: + +```bash +kubectl exec -n vacation-planner-file deployment/vacation-planner-file -- ls -l /data +``` + +An SMB share is reachable over the file data plane REST API as well, so the files the pods write can also be listed from your machine, which is a neat way to see that the volume really is an Azure file share: + +```bash +az storage file list --account-name localfilesmbtest --share-name activities --output table +``` + +The same command does not work for an NFS share on Azure, which has no REST data plane, and it needs the account key or a role assignment on the account. + +## Cleaning up + +The namespace holds the app, but the `PersistentVolume` and the storage class are cluster-scoped and survive `kubectl delete namespace`. They also have to go before switching to another combination, because the fields of a claim cannot be changed after it is created: + +```bash +kubectl delete namespace vacation-planner-file +kubectl delete pv vacation-planner-file-pv --ignore-not-found +kubectl delete storageclass vacation-planner-file-nfs --ignore-not-found +``` + +The volume is created with `persistentVolumeReclaimPolicy: Retain`, so deleting it leaves the file share and the activities in place: the same share is picked up again the next time the sample is deployed. In dynamic mode the storage class reclaim policy is `Delete`, so the share the driver created goes away with the claim, while the storage account it created in the node resource group stays behind (the driver never deletes an account). Delete it by hand, or delete the whole resource group, when you are done: + +```bash +az storage account list \ + --resource-group $(az aks show --name local-aks-test --resource-group local-rg --query nodeResourceGroup --output tsv) \ + --output table +``` + +## Running on Azure + +The sample runs unchanged against Azure, with two things to keep in mind for the NFS combinations: + +- The mount needs the storage account to be restricted to the node subnets. `01-deploy-resources.sh` reads the node subnets from the cluster and configures them for the static combination, and the CSI driver does it for the dynamic one. When it cannot, it says so with a warning instead of leaving you to discover it from a mount timeout. +- A premium file share is provisioned capacity, so the 100 GiB minimum is billed whether it holds three text files or a hundred gigabytes. + +One behaviour is worth knowing in both protocols: `actimeo=30` caches directory metadata for 30 seconds, so an activity added through one replica can take a few seconds to show up on another. That is the ordinary trade-off of a shared file system, not a bug in the sample. diff --git a/samples/web-app-file-storage/dotnet/images/architecture.png b/samples/web-app-file-storage/dotnet/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..1987668093155907fda0a64ff2912d101b089978 GIT binary patch literal 38170 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 literal 0 HcmV?d00001 diff --git a/samples/web-app-file-storage/dotnet/scripts/00-variables.sh b/samples/web-app-file-storage/dotnet/scripts/00-variables.sh new file mode 100755 index 0000000..938e702 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/scripts/00-variables.sh @@ -0,0 +1,67 @@ +# Variables + +# Azure Resources +PREFIX='local' +SUFFIX='test' +LOCATION='italynorth' +RESOURCE_GROUP_NAME="${PREFIX}-rg" +ACR_NAME="${PREFIX,,}acr${SUFFIX,,}" +ACR_SKU='Standard' +AKS_CLUSTER_NAME="${PREFIX}-aks-${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)" + +# Storage Accounts +# An SMB share needs a general-purpose v2 account, an NFS share needs a premium FileStorage account +# (NFS Azure file shares are only available on SSD file shares), and the kind of an account cannot be +# changed after creation. Each protocol therefore gets its own account, so switching protocol never +# collides with the account created for the other one. +SMB_STORAGE_ACCOUNT_NAME="${PREFIX}filesmb${SUFFIX}" +SMB_STORAGE_ACCOUNT_KIND='StorageV2' +SMB_STORAGE_ACCOUNT_SKU='Standard_LRS' +NFS_STORAGE_ACCOUNT_NAME="${PREFIX}filenfs${SUFFIX}" +NFS_STORAGE_ACCOUNT_KIND='FileStorage' +NFS_STORAGE_ACCOUNT_SKU='Premium_LRS' + +# Azure file share +FILE_SHARE_NAME='activities' +# A premium file share is provisioned storage with a 100 GiB minimum, so both protocols use the same +# size to keep a single PersistentVolumeClaim for all the combinations. +FILE_SHARE_QUOTA_GB='100' + +# Docker Image +IMAGE_NAME="vacation-planner-file-dotnet" +IMAGE_PULL_POLICY="Always" +IMAGE_TAG="v1" +PORT="8080" + +# Kubernetes +NAMESPACE="vacation-planner-file" +DEPLOYMENT_NAME="vacation-planner-file" +SERVICE_NAME="vacation-planner-file" +CONFIGMAP_NAME="vacation-planner-file-config" +SEED_CONFIGMAP_NAME="vacation-planner-file-seed" +SECRET_NAME="vacation-planner-file-secrets" +STORAGE_SECRET_NAME="vacation-planner-file-storage" +PERSISTENT_VOLUME_NAME="vacation-planner-file-pv" +PERSISTENT_VOLUME_CLAIM_NAME="vacation-planner-file-pvc" + +# Storage class used to provision the volume on demand. +# SMB uses azurefile-csi, one of the four azurefile* classes the Azure Files CSI driver installs on +# every AKS cluster. NFS needs a class of its own, because none of the built-in ones sets +# protocol: nfs, and it is created by 05-deploy-app.sh from storageclass-nfs.yml. +SMB_STORAGE_CLASS_NAME='azurefile-csi' +NFS_STORAGE_CLASS_NAME="vacation-planner-file-nfs" + +# Directory where the Azure file share is mounted in the pods +ACTIVITIES_DIR='/data' + +# Deployment options chosen interactively in 01-deploy-resources.sh, or exported beforehand for an +# unattended run. The generated file assigns each value with ${VAR:-value} syntax, so an exported +# environment variable always takes precedence over the persisted choice. +DEPLOY_OPTIONS_FILE="$CURRENT_DIR/.deploy-options.env" +[ -f "$DEPLOY_OPTIONS_FILE" ] && source "$DEPLOY_OPTIONS_FILE" +PROVISIONING_MODE="${PROVISIONING_MODE:-}" +FILE_SHARE_PROTOCOL="${FILE_SHARE_PROTOCOL:-}" diff --git a/samples/web-app-file-storage/dotnet/scripts/01-deploy-resources.sh b/samples/web-app-file-storage/dotnet/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..0a35f6c --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/scripts/02-build-docker-image.sh b/samples/web-app-file-storage/dotnet/scripts/02-build-docker-image.sh new file mode 100755 index 0000000..4b497d8 --- /dev/null +++ b/samples/web-app-file-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-file-storage/dotnet/scripts/03-run-docker-container.sh b/samples/web-app-file-storage/dotnet/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..071a1a9 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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-file-storage/dotnet/scripts/04-push-docker-image.sh b/samples/web-app-file-storage/dotnet/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-file-storage/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-file-storage/dotnet/scripts/05-deploy-app.sh b/samples/web-app-file-storage/dotnet/scripts/05-deploy-app.sh new file mode 100755 index 0000000..d8af933 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,210 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# The combination to deploy is the one chosen in 01-deploy-resources.sh, which persisted it to +# .deploy-options.env, or the one exported in the environment. It is never guessed here: deploying a +# static volume for a file share that was never created, or an NFS volume against an SMB share, leaves +# the pods stuck in ContainerCreating with a mount error. +if [[ -z "$PROVISIONING_MODE" || -z "$FILE_SHARE_PROTOCOL" ]]; then + echo "The provisioning mode and the file share protocol are not set." + echo "Run ./01-deploy-resources.sh first, or export PROVISIONING_MODE (static|dynamic) and FILE_SHARE_PROTOCOL (smb|nfs)." + exit 1 +fi + +if [[ "$PROVISIONING_MODE" != 'static' && "$PROVISIONING_MODE" != 'dynamic' ]]; then + echo "Invalid provisioning mode [$PROVISIONING_MODE]: expected [static] or [dynamic]" + exit 1 +fi + +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 + +echo "Deploying the app with [$PROVISIONING_MODE] provisioning over [${FILE_SHARE_PROTOCOL^^}]..." + +# Pick the storage account that holds the file share for the selected protocol +if [[ "$FILE_SHARE_PROTOCOL" == 'nfs' ]]; then + STORAGE_ACCOUNT_NAME="$NFS_STORAGE_ACCOUNT_NAME" +else + STORAGE_ACCOUNT_NAME="$SMB_STORAGE_ACCOUNT_NAME" +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 +SECRET_KEY=$(openssl rand -hex 32) + +# 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 SECRET_KEY. Unlike the sibling samples, no storage credential is +# passed to the app: it reads and writes files on the mounted share and never authenticates to Azure. +cat secret.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.metadata.name)|="\""$SECRET_NAME"\" | +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 "(.metadata.name)|="\""$CONFIGMAP_NAME"\" | +yq "(.data.ACTIVITIES_DIR)|="\""$ACTIVITIES_DIR"\" | +kubectl apply -f - + +# Create the configmap holding the sample activities used to seed the file share +cat seed-configmap.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.metadata.name)|="\""$SEED_CONFIGMAP_NAME"\" | +kubectl apply -f - + +if [[ "$PROVISIONING_MODE" == 'static' ]]; then + # Static provisioning: bind the claim to a volume that points at the file share created by + # 01-deploy-resources.sh. + if [[ "$FILE_SHARE_PROTOCOL" == 'smb' ]]; then + # The driver mounts an SMB share with the storage account key, so it needs the key in a secret + echo "Retrieving the key of the [$STORAGE_ACCOUNT_NAME] storage account..." + STORAGE_ACCOUNT_KEY=$(az storage account keys list \ + --account-name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "[0].value" \ + --output tsv \ + --only-show-errors) + + if [ -z "$STORAGE_ACCOUNT_KEY" ]; then + echo "Failed to retrieve the key of the [$STORAGE_ACCOUNT_NAME] storage account." + exit 1 + fi + + cat storage-secret.yml | + yq "(.metadata.namespace)|="\""$NAMESPACE"\" | + yq "(.metadata.name)|="\""$STORAGE_SECRET_NAME"\" | + yq "(.data.azurestorageaccountname)|="\""$(echo -n $STORAGE_ACCOUNT_NAME | base64 -w0)"\" | + yq "(.data.azurestorageaccountkey)|="\""$(echo -n $STORAGE_ACCOUNT_KEY | base64 -w0)"\" | + kubectl apply -f - + + # The SMB and NFS volumes are applied by two separate pipelines on purpose: yq's |= operator + # creates a missing path, so setting nodeStageSecretRef.namespace on the NFS volume would add a + # secret reference to a volume that must not have one. + cat persistentvolume-smb.yml | + yq "(.metadata.name)|="\""$PERSISTENT_VOLUME_NAME"\" | + yq "(.spec.csi.volumeHandle)|="\""${RESOURCE_GROUP_NAME}#${STORAGE_ACCOUNT_NAME}#${FILE_SHARE_NAME}"\" | + yq "(.spec.csi.volumeAttributes.resourceGroup)|="\""$RESOURCE_GROUP_NAME"\" | + yq "(.spec.csi.volumeAttributes.storageAccount)|="\""$STORAGE_ACCOUNT_NAME"\" | + yq "(.spec.csi.volumeAttributes.shareName)|="\""$FILE_SHARE_NAME"\" | + yq "(.spec.csi.nodeStageSecretRef.name)|="\""$STORAGE_SECRET_NAME"\" | + yq "(.spec.csi.nodeStageSecretRef.namespace)|="\""$NAMESPACE"\" | + kubectl apply -f - + else + cat persistentvolume-nfs.yml | + yq "(.metadata.name)|="\""$PERSISTENT_VOLUME_NAME"\" | + yq "(.spec.csi.volumeHandle)|="\""${RESOURCE_GROUP_NAME}#${STORAGE_ACCOUNT_NAME}#${FILE_SHARE_NAME}"\" | + yq "(.spec.csi.volumeAttributes.resourceGroup)|="\""$RESOURCE_GROUP_NAME"\" | + yq "(.spec.csi.volumeAttributes.storageAccount)|="\""$STORAGE_ACCOUNT_NAME"\" | + yq "(.spec.csi.volumeAttributes.shareName)|="\""$FILE_SHARE_NAME"\" | + kubectl apply -f - + fi + + # The claim is committed in its static shape, so it is applied as it is + cat persistentvolumeclaim.yml | + yq "(.metadata.namespace)|="\""$NAMESPACE"\" | + yq "(.metadata.name)|="\""$PERSISTENT_VOLUME_CLAIM_NAME"\" | + yq "(.spec.volumeName)|="\""$PERSISTENT_VOLUME_NAME"\" | + kubectl apply -f - +else + # Dynamic provisioning: the CSI driver creates the storage account and the file share when the + # claim is bound, so there is no volume to create, only a storage class to select. + if [[ "$FILE_SHARE_PROTOCOL" == 'nfs' ]]; then + # None of the built-in azurefile* classes provisions an NFS share + cat storageclass-nfs.yml | + yq "(.metadata.name)|="\""$NFS_STORAGE_CLASS_NAME"\" | + kubectl apply -f - + + STORAGE_CLASS_NAME="$NFS_STORAGE_CLASS_NAME" + else + STORAGE_CLASS_NAME="$SMB_STORAGE_CLASS_NAME" + + echo "Checking if the [$STORAGE_CLASS_NAME] storage class exists in the cluster..." + kubectl get storageclass "$STORAGE_CLASS_NAME" &>/dev/null + + if [[ $? != 0 ]]; then + echo "No [$STORAGE_CLASS_NAME] storage class exists in the cluster." + echo "It is installed with the Azure Files CSI driver: check that the driver is enabled with" + echo " az aks show --name $AKS_CLUSTER_NAME --resource-group $RESOURCE_GROUP_NAME --query storageProfile.fileCsiDriver" + exit 1 + fi + fi + + # Drop the static binding and let the storage class provision the volume + cat persistentvolumeclaim.yml | + yq "(.metadata.namespace)|="\""$NAMESPACE"\" | + yq "(.metadata.name)|="\""$PERSISTENT_VOLUME_CLAIM_NAME"\" | + yq "del(.spec.volumeName)" | + yq "(.spec.storageClassName)|="\""$STORAGE_CLASS_NAME"\" | + kubectl apply -f - +fi + +# Create deployment +cat deployment.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.metadata.name)|="\""$DEPLOYMENT_NAME"\" | +yq "(.spec.template.spec.volumes[0].persistentVolumeClaim.claimName)|="\""$PERSISTENT_VOLUME_CLAIM_NAME"\" | +yq "(.spec.template.spec.volumes[1].configMap.name)|="\""$SEED_CONFIGMAP_NAME"\" | +yq "(.spec.template.spec.initContainers[0].image)|="\""$FULL_IMAGE"\" | +yq "(.spec.template.spec.initContainers[0].imagePullPolicy)|="\""$IMAGE_PULL_POLICY"\" | +yq "(.spec.template.spec.initContainers[0].env[0].value)|="\""$FILE_SHARE_PROTOCOL"\" | +yq "(.spec.template.spec.initContainers[0].env[1].valueFrom.configMapKeyRef.name)|="\""$CONFIGMAP_NAME"\" | +yq "(.spec.template.spec.initContainers[0].volumeMounts[0].mountPath)|="\""$ACTIVITIES_DIR"\" | +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" | +yq "(.spec.template.spec.containers[0].env[0].valueFrom.configMapKeyRef.name)|="\""$CONFIGMAP_NAME"\" | +yq "(.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.name)|="\""$SECRET_NAME"\" | +yq "(.spec.template.spec.containers[0].volumeMounts[0].mountPath)|="\""$ACTIVITIES_DIR"\" | +kubectl apply -f - + +# Create service +cat service.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.metadata.name)|="\""$SERVICE_NAME"\" | +kubectl apply -f - + +# 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..." +kubectl rollout status "deployment/$DEPLOYMENT_NAME" --namespace "$NAMESPACE" --timeout=600s + +if [[ $? == 0 ]]; then + echo "The app is running. Browse to it with:" + echo " kubectl port-forward service/$SERVICE_NAME 8080:80 --namespace $NAMESPACE" +else + echo "The [$DEPLOYMENT_NAME] deployment did not roll out. Inspect the pods with:" + echo " kubectl get pods --namespace $NAMESPACE" + echo " kubectl describe pod --selector app=$DEPLOYMENT_NAME --namespace $NAMESPACE" + exit 1 +fi diff --git a/samples/web-app-file-storage/dotnet/scripts/Dockerfile b/samples/web-app-file-storage/dotnet/scripts/Dockerfile new file mode 100644 index 0000000..98f0977 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/scripts/Dockerfile @@ -0,0 +1,41 @@ +# 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 + +# The app runs as a non-root user whose uid/gid are pinned to a known value: the app writes to an Azure file +# share, and the identity that must be able to write to it appears in the mount options of the SMB volume +# (uid=1000,gid=1000) and in the ownership of the NFS share. The runtime image ships a non-root `app` user +# (uid 1654) and, being Ubuntu-based, a default `ubuntu` account that already owns uid/gid 1000: the latter is +# dropped and `app` is re-numbered instead of adding a third account. +ARG APP_USER_UID=1000 +ARG APP_USER_GID=1000 + +ENV PORT=${PORT} + +WORKDIR /app +COPY --from=build /app/publish ./ + +RUN userdel --remove ubuntu \ + && groupmod --gid ${APP_USER_GID} app \ + && usermod --uid ${APP_USER_UID} --gid ${APP_USER_GID} app \ + && chown -R app:app /app /home/app +USER app +ENV HOME=/home/app + +EXPOSE ${PORT} + +ENTRYPOINT ["dotnet", "VacationPlanner.dll"] diff --git a/samples/web-app-file-storage/dotnet/scripts/configmap.yml b/samples/web-app-file-storage/dotnet/scripts/configmap.yml new file mode 100644 index 0000000..1b178c4 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/scripts/deployment.yml b/samples/web-app-file-storage/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..1bfb957 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/scripts/deployment.yml @@ -0,0 +1,165 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vacation-planner-file + namespace: vacation-planner-file + labels: + app: vacation-planner-file +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-file + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-file + spec: + nodeSelector: + kubernetes.io/os: linux + volumes: + # The Azure file share, mounted by the Azure Files CSI driver. All three replicas mount the + # same share, which is what makes the activities they serve identical. + - name: activities + persistentVolumeClaim: + claimName: vacation-planner-file-pvc + # The sample activities the init container copies into the share when they are missing + - name: seed + configMap: + name: vacation-planner-file-seed + # Prepares the file share before the app starts, and runs as root, which is why the app + # container declares its own securityContext instead of the pod declaring it for both. + # + # On NFS it takes ownership of the mount point: an NFS share carries real POSIX ownership, + # arrives owned by root, and ignores the uid and gid mount options that solve this for SMB. + # securityContext.fsGroup is not an alternative: the file.csi.azure.com CSIDriver object + # declares fsGroupPolicy: ReadWriteOnceWithFSType, so the kubelet applies fsGroup only to a + # ReadWriteOnce volume with a file system type, and this volume is ReadWriteMany. Setting + # fsGroup here would silently do nothing. The chown is not recursive: files created by the app + # already belong to it, and a recursive chown over NFS would make every pod start slower as the + # share fills up. + # + # It then seeds the share with the sample activities, skipping the files that are already there, + # so the three replicas starting at the same time cannot conflict. + initContainers: + - name: init-file-share + image: .azurecr.io/vacation-planner-file-dotnet:v1 + imagePullPolicy: Always + securityContext: + runAsUser: 0 + runAsGroup: 0 + env: + # Set to smb or nfs by 05-deploy-app.sh. The chown must not run on an SMB mount, where it + # fails with EPERM because the CIFS client synthesizes ownership from the mount options. + - name: FILE_SHARE_PROTOCOL + value: smb + - name: ACTIVITIES_DIR + valueFrom: + configMapKeyRef: + name: vacation-planner-file-config + key: ACTIVITIES_DIR + command: + - /bin/sh + - -c + - | + set -e + + # A no-op when ACTIVITIES_DIR is the mount point itself, which is the default. It + # matters when it points at a subdirectory of the share: that directory has to exist + # before it can be chowned or seeded, and the app is not able to create it as uid 1000 + # on an NFS share owned by root. + mkdir -p "$ACTIVITIES_DIR" + + if [ "$FILE_SHARE_PROTOCOL" = "nfs" ]; then + echo "Taking ownership of $ACTIVITIES_DIR as uid 1000..." + chown 1000:1000 "$ACTIVITIES_DIR" + chmod 0755 "$ACTIVITIES_DIR" + fi + + for file in /seed/*-activity.txt; do + [ -e "$file" ] || continue + + name=$(basename "$file") + + if [ -e "$ACTIVITIES_DIR/$name" ]; then + echo "Activity file [$name] already exists in the file share, skipping it..." + continue + fi + + echo "Seeding the file share with the activity file [$name]..." + cp "$file" "$ACTIVITIES_DIR/$name" + + if [ "$FILE_SHARE_PROTOCOL" = "nfs" ]; then + chown 1000:1000 "$ACTIVITIES_DIR/$name" + fi + done + + echo "The file share is ready." + volumeMounts: + - name: activities + mountPath: /data + - name: seed + mountPath: /seed + readOnly: true + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "256Mi" + containers: + - name: vacation-planner-file + image: .azurecr.io/vacation-planner-file-dotnet:v1 + imagePullPolicy: Always + # The image already runs as this user, declared here as well because it is the identity the + # mount options of the SMB volume and the ownership of the NFS share are aligned with. + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + ports: + - name: http + containerPort: 8080 + env: + - name: ACTIVITIES_DIR + valueFrom: + configMapKeyRef: + name: vacation-planner-file-config + key: ACTIVITIES_DIR + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-file-secrets + key: SECRET_KEY + volumeMounts: + - name: activities + mountPath: /data + 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-file-storage/dotnet/scripts/namespace.yml b/samples/web-app-file-storage/dotnet/scripts/namespace.yml new file mode 100644 index 0000000..d19adf9 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-file diff --git a/samples/web-app-file-storage/dotnet/scripts/persistentvolume-nfs.yml b/samples/web-app-file-storage/dotnet/scripts/persistentvolume-nfs.yml new file mode 100644 index 0000000..2c20104 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/scripts/persistentvolume-smb.yml b/samples/web-app-file-storage/dotnet/scripts/persistentvolume-smb.yml new file mode 100644 index 0000000..e8ae7f6 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/scripts/persistentvolumeclaim.yml b/samples/web-app-file-storage/dotnet/scripts/persistentvolumeclaim.yml new file mode 100644 index 0000000..ce470d8 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/scripts/secret.yml b/samples/web-app-file-storage/dotnet/scripts/secret.yml new file mode 100644 index 0000000..6afdf57 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/scripts/seed-configmap.yml b/samples/web-app-file-storage/dotnet/scripts/seed-configmap.yml new file mode 100644 index 0000000..17c6b75 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/scripts/service.yml b/samples/web-app-file-storage/dotnet/scripts/service.yml new file mode 100644 index 0000000..a1fbe7a --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/scripts/storage-secret.yml b/samples/web-app-file-storage/dotnet/scripts/storage-secret.yml new file mode 100644 index 0000000..35ff3b5 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/scripts/storageclass-nfs.yml b/samples/web-app-file-storage/dotnet/scripts/storageclass-nfs.yml new file mode 100644 index 0000000..ff0c940 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/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/dotnet/src/.dockerignore b/samples/web-app-file-storage/dotnet/src/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/web-app-file-storage/dotnet/src/Models/Activity.cs b/samples/web-app-file-storage/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-file-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-file-storage/dotnet/src/Pages/Delete.cshtml b/samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..6023eef --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete" +@model DeleteModel diff --git a/samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..89929bc --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +/// +/// Handles POST /delete. The activity is identified by its file name, posted in the activity_id form +/// field, never by its position in the rendered page: every replica mounts the same share and reloads it on each +/// GET, so the list can change between rendering a page and submitting a delete from it. +/// +public class DeleteModel(IActivityStore store) : PageModel +{ + [BindProperty(Name = "activity_id")] + public string? ActivityId { get; set; } + + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + TempData["Flash"] = await store.DeleteAsync(ActivityId?.Trim() ?? "", cancellationToken) + ? "Activity deleted successfully." + : "Failed to delete the activity from the file share."; + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml b/samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..2ad5de1 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml @@ -0,0 +1,268 @@ +@page +@model IndexModel + + + + + + Vacation Planner + + + + + + + + + +
+
+

🌴 Vacation Planner

+

@Model.Activities.Count activit@(Model.Activities.Count != 1 ? "ies" : "y") planned@(string.IsNullOrEmpty(Model.PodName) ? "" : $" · served by {Model.PodName}")

+
+
+ + +
+
+ + +
+ + + + + + + + + @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..78018b1 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,51 @@ +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) : 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)) + { + TempData["Flash"] = await store.UpdateAsync(id, text, cancellationToken) + ? "Activity updated successfully." + : "Failed to update the activity on the file share."; + } + else + { + TempData["Flash"] = await store.AddAsync(text, cancellationToken) + ? "Activity added successfully." + : "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..f84b421 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Program.cs @@ -0,0 +1,49 @@ +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."); +} + +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..4a8df22 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/Services/FileActivityStore.cs @@ -0,0 +1,163 @@ +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)) + { + activities.Add(new Activity(name, File.ReadAllText(path))); + } + } + + 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); + } + + 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 + { + 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. + /// + private void ProbeWritable() + { + var probe = Path.Combine(_directory, $".write-probe-{Environment.ProcessId}"); + 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..10f68b8 --- /dev/null +++ b/samples/web-app-file-storage/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-file-storage/dotnet/src/wwwroot/favicon.ico b/samples/web-app-file-storage/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5b1d5cf37d81c073dbacd66df3f69d9240359dda GIT binary patch literal 36791 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 D Date: Tue, 8 Sep 2026 14:08:46 +0200 Subject: [PATCH 06/20] Add the .NET version of web-app-cosmosdb-mongodb-api ASP.NET Core Vacation Planner storing activities as {_id, username, activity, timestamp} documents in an Azure Cosmos DB for MongoDB collection through MongoDB.Driver, under samples/web-app-cosmosdb-mongodb-api/dotnet. Same connection string, database, collection and index setup as the Python version, md5 ids, deletes keyed by document id, and the update flash only when the document changed. Verified on the emulator's AKS: rollout, port-forward and in-cluster smoke tests, then the Python image rolled over the same Deployment. Co-Authored-By: Claude Fable 5.1 --- .../dotnet/README.md | 68 ++++ .../dotnet/images/architecture.png | Bin 0 -> 38698 bytes .../dotnet/scripts/00-variables.sh | 38 ++ .../dotnet/scripts/01-deploy-resources.sh | 162 +++++++++ .../dotnet/scripts/02-build-docker-image.sh | 18 + .../dotnet/scripts/03-run-docker-container.sh | 33 ++ .../dotnet/scripts/04-push-docker-image.sh | 40 ++ .../dotnet/scripts/05-deploy-app.sh | 89 +++++ .../dotnet/scripts/Dockerfile | 28 ++ .../dotnet/scripts/configmap.yml | 11 + .../dotnet/scripts/deployment.yml | 81 +++++ .../dotnet/scripts/namespace.yml | 4 + .../dotnet/scripts/secret.yml | 11 + .../dotnet/scripts/service.yml | 16 + .../dotnet/src/.dockerignore | 2 + .../dotnet/src/Models/Activity.cs | 4 + .../dotnet/src/Pages/Delete.cshtml | 2 + .../dotnet/src/Pages/Delete.cshtml.cs | 21 ++ .../dotnet/src/Pages/Index.cshtml | 265 ++++++++++++++ .../dotnet/src/Pages/Index.cshtml.cs | 47 +++ .../dotnet/src/Pages/_ViewImports.cshtml | 4 + .../dotnet/src/Program.cs | 48 +++ .../dotnet/src/Services/ActivityId.cs | 15 + .../dotnet/src/Services/IActivityStore.cs | 24 ++ .../dotnet/src/Services/MongoActivityStore.cs | 98 +++++ .../dotnet/src/Services/MongoOptions.cs | 32 ++ .../src/Services/SecretKeyXmlRepository.cs | 49 +++ .../dotnet/src/Services/StoreInitializer.cs | 33 ++ .../dotnet/src/VacationPlanner.csproj | 11 + .../dotnet/src/appsettings.json | 9 + .../dotnet/src/wwwroot/favicon.ico | Bin 0 -> 36791 bytes .../dotnet/src/wwwroot/style.css | 341 ++++++++++++++++++ 32 files changed, 1604 insertions(+) create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/README.md create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/images/architecture.png create mode 100755 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/00-variables.sh create mode 100755 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/01-deploy-resources.sh create mode 100755 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/02-build-docker-image.sh create mode 100755 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/03-run-docker-container.sh create mode 100755 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/04-push-docker-image.sh create mode 100755 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/05-deploy-app.sh create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/Dockerfile create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/configmap.yml create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/deployment.yml create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/namespace.yml create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/secret.yml create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/service.yml create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/.dockerignore create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Models/Activity.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/_ViewImports.cshtml create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/ActivityId.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/IActivityStore.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoOptions.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/SecretKeyXmlRepository.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/StoreInitializer.cs create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/VacationPlanner.csproj create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/favicon.ico create mode 100644 samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/style.css 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..e730db8 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md @@ -0,0 +1,68 @@ +# 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 +``` diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/images/architecture.png b/samples/web-app-cosmosdb-mongodb-api/dotnet/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..2fb4dca88c8729e949d07396f9eb6c43b956f8ed GIT binary patch literal 38698 zcmbTdWmH^S(=OUTf+Qh9LV^WIaEIVdaQDXDz471@2oT&|0t9Plym5!%?hZkNyW3gp z{l4d%dw+c6j{AeL=+(XEtXZ?_si$hz`k^2vj{XAw1qcK}my{4u0)Y@6fImD`MBon0 zM^p>A&^n2#I|)0O7&uu#Y)MosY)n8lCJrQQBnD;>lFv+^*-4mLxLKLGSvW{&nV6ZF zUQ~750gs_LNy>_%?4Uk>NrScb$%7rZg=QzA;Rph~#CrNe7+dsy`rR|LFS1`ipz_F< zcLvWvpoDLdB41Qo=l0UkJXIzaFAw)(y-3LnV#(1k{XfwN35NO#eUpBh$$Oz8Vv|}> zisQo@UTIQ!08!d=hc|jv05=H(oz`9tYHzRq#`_SWHp?ARe zB6%UX*Vubm?s$E{ak!4faqI5j@oxB<7@>pdEg%r+N)UJN<reTRlrw72ht^DIE~l^vv$NeRaI_$O2 zExZjhzKg19+)hd;e&0UKB`42o%%=I|(iNw1EH*J*pVZsK_A;kV%8$gz$oahMl@Xdl zZ9xDmg5DS$aGy$8Nz*$?UIz7?sGImpv7j{rEQK@*%=AqW>{zHxs{^CyE26hGMh!o; z?5}Oy33tA>mNPNX&=p%$y-;U1f&5X0&}DX~YiKtN_6?DNmEyyGbgiZlRzHksUnppB z!Bd%2syZv!-cwWQwfO8~?#6&c@iv}^FZ_|Bfmlf9oY%3-+JTWkg1&%?u_?g0#ZK{q zbA9Lwd5{^+p7?(1C2JLEm>XA~vAb!t|IL7zsj1pDTT12Z+$o`lo#TtC6qFx>7Zl9O zAGH5YMgK8Y(s9=5`0J>{3T&G
WbIV0Fb}T+9;*XgkCxKaIRTRa0J5Q}3WmLb+F| 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 literal 0 HcmV?d00001 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/dotnet/scripts/01-deploy-resources.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..4a20921 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/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/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/dotnet/scripts/03-run-docker-container.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..c8e39b3 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/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-cosmosdb-mongodb-api/dotnet/scripts/04-push-docker-image.sh b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/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-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..1efe50a --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,89 @@ +#!/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 +SECRET_KEY=$(openssl rand -hex 32) + +# 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 - + +# 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/dotnet/scripts/configmap.yml b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/configmap.yml new file mode 100644 index 0000000..ffa2b15 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/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/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/dotnet/scripts/namespace.yml b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/namespace.yml new file mode 100644 index 0000000..86d5f20 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/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/dotnet/scripts/secret.yml b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/secret.yml new file mode 100644 index 0000000..19103cd --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/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/dotnet/scripts/service.yml b/samples/web-app-cosmosdb-mongodb-api/dotnet/scripts/service.yml new file mode 100644 index 0000000..d08ef11 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/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/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..88cfe41 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,21 @@ +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) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) + { + 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..ca4aa73 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store) : 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)) + { + TempData["Flash"] = "Activity updated!"; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + 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..b3f0e5d --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs @@ -0,0 +1,48 @@ +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."); +} + +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..695f249 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs @@ -0,0 +1,98 @@ +using MongoDB.Bson; +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; + + 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); + 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); + 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); + return result.ModifiedCount > 0; + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + var result = await _collection.DeleteOneAsync(Builders.Filter.Eq("_id", id), cancellationToken); + if (result.DeletedCount == 0) + { + _logger.LogInformation("Document '{Id}' did not exist: already deleted.", id); + } + + 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..871c308 --- /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("Username cannot be None or empty"); + } + + 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..10f68b8 --- /dev/null +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/favicon.ico b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5b1d5cf37d81c073dbacd66df3f69d9240359dda GIT binary patch literal 36791 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 D Date: Tue, 8 Sep 2026 14:24:45 +0200 Subject: [PATCH 07/20] Add the .NET version of web-app-cosmosdb-nosql-api ASP.NET Core Vacation Planner storing activities as {id, username, activity, timestamp} items in an Azure Cosmos DB for NoSQL container partitioned by /username, through Microsoft.Azure.Cosmos in Gateway mode, under samples/web-app-cosmosdb-nosql-api/dotnet. Same endpoint, key, database and container variables as the Python version, md5 ids and deletes keyed by item id. Against the emulator the LocalStack root CA is mounted from the localstack-ca ConfigMap and exposed through SSL_CERT_FILE, the .NET counterpart of REQUESTS_CA_BUNDLE. Verified on the emulator's AKS: rollout, port-forward and in-cluster smoke tests. Co-Authored-By: Claude Fable 5.1 --- .../dotnet/README.md | 70 ++++ .../dotnet/images/architecture.png | Bin 0 -> 42039 bytes .../dotnet/scripts/00-variables.sh | 40 ++ .../dotnet/scripts/01-deploy-resources.sh | 174 +++++++++ .../dotnet/scripts/02-build-docker-image.sh | 18 + .../dotnet/scripts/03-run-docker-container.sh | 92 +++++ .../dotnet/scripts/04-push-docker-image.sh | 40 ++ .../dotnet/scripts/05-deploy-app.sh | 132 +++++++ .../dotnet/scripts/Dockerfile | 28 ++ .../dotnet/scripts/configmap.yml | 12 + .../dotnet/scripts/deployment.yml | 104 ++++++ .../dotnet/scripts/namespace.yml | 4 + .../dotnet/scripts/secret.yml | 11 + .../dotnet/scripts/service.yml | 16 + .../dotnet/src/.dockerignore | 2 + .../dotnet/src/Models/Activity.cs | 4 + .../dotnet/src/Pages/Delete.cshtml | 2 + .../dotnet/src/Pages/Delete.cshtml.cs | 21 ++ .../dotnet/src/Pages/Index.cshtml | 265 ++++++++++++++ .../dotnet/src/Pages/Index.cshtml.cs | 47 +++ .../dotnet/src/Pages/_ViewImports.cshtml | 4 + .../dotnet/src/Program.cs | 48 +++ .../dotnet/src/Services/ActivityDocument.cs | 19 + .../dotnet/src/Services/ActivityId.cs | 15 + .../src/Services/CosmosActivityStore.cs | 112 ++++++ .../dotnet/src/Services/CosmosOptions.cs | 26 ++ .../dotnet/src/Services/IActivityStore.cs | 24 ++ .../src/Services/SecretKeyXmlRepository.cs | 49 +++ .../dotnet/src/Services/StoreInitializer.cs | 33 ++ .../dotnet/src/VacationPlanner.csproj | 12 + .../dotnet/src/appsettings.json | 9 + .../dotnet/src/wwwroot/favicon.ico | Bin 0 -> 36791 bytes .../dotnet/src/wwwroot/style.css | 341 ++++++++++++++++++ 33 files changed, 1774 insertions(+) create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/README.md create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/images/architecture.png create mode 100755 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/00-variables.sh create mode 100755 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/01-deploy-resources.sh create mode 100755 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/02-build-docker-image.sh create mode 100755 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/03-run-docker-container.sh create mode 100755 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/04-push-docker-image.sh create mode 100755 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/05-deploy-app.sh create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/Dockerfile create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/configmap.yml create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/deployment.yml create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/namespace.yml create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/secret.yml create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/scripts/service.yml create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/.dockerignore create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Models/Activity.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/_ViewImports.cshtml create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityDocument.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/ActivityId.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosOptions.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/IActivityStore.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/SecretKeyXmlRepository.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/StoreInitializer.cs create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/VacationPlanner.csproj create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/favicon.ico create mode 100644 samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/style.css diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/README.md b/samples/web-app-cosmosdb-nosql-api/dotnet/README.md new file mode 100644 index 0000000..9475de4 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/README.md @@ -0,0 +1,70 @@ +# Vacation Planner: Azure Cosmos DB for NoSQL + +> 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 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. + +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. + +## 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/container, partition key, 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 NoSQL](https://learn.microsoft.com/en-us/azure/cosmos-db/overview) account, the `vacationplanner` database, and the `activities` container (partitioned by `/username`). | +| [`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 (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 `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-nosql 8080:80 -n vacation-planner-nosql +``` + +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 +``` diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/images/architecture.png b/samples/web-app-cosmosdb-nosql-api/dotnet/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..dec8134b34ec7f6d246de07ea6f1fecf9946a978 GIT binary patch literal 42039 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 NvXV+)%Ee8B{s&t1i2DEl literal 0 HcmV?d00001 diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/00-variables.sh b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/00-variables.sh new file mode 100755 index 0000000..3d6dea2 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/00-variables.sh @@ -0,0 +1,40 @@ +# 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 (NoSQL / SQL API) +COSMOSDB_ACCOUNT_NAME="${PREFIX}-nosql-${SUFFIX}" +AZURECOSMOSDB_DATABASENAME='vacationplanner' +AZURECOSMOSDB_CONTAINERNAME='activities' +AZURECOSMOSDB_PARTITION_KEY='/username' +THROUGHPUT=400 + +# LocalStack runtime URL. Set to empty when running against real Azure to skip +# the LocalStack root-CA install step in 03-run-docker-container.sh / 05-deploy-app.sh. +LOCALSTACK_URL='http://localhost:4566' + +# Application config +LOGIN_NAME='paolo' + +# Docker Image +IMAGE_NAME="vacation-planner-nosql-dotnet" +IMAGE_PULL_POLICY="Always" +IMAGE_TAG="v1" +PORT="8080" + +# Kubernetes +NAMESPACE="vacation-planner-nosql" +DEPLOYMENT_NAME="vacation-planner-nosql" +SERVICE_NAME="vacation-planner-nosql" +CONFIGMAP_NAME="vacation-planner-nosql-config" +SECRET_NAME="vacation-planner-nosql-secrets" diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/01-deploy-resources.sh b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..e7f2049 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/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/dotnet/scripts/02-build-docker-image.sh b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/02-build-docker-image.sh new file mode 100755 index 0000000..4b497d8 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-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-nosql-api/dotnet/scripts/03-run-docker-container.sh b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..fb0dd98 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/03-run-docker-container.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# 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 + +# 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 + +# When running against LocalStack, fetch its root CA so the container can verify +# the cosmos endpoint's TLS chain (cert is signed by LocalStack's root CA — see +# localstack-pro-azure/.../cosmos/nosql/emulator.py default_cert_store().get_or_create). +CA_MOUNT_ARGS=() +EXTRA_ENV_ARGS=() +if [ -n "$LOCALSTACK_URL" ]; then + echo "Fetching LocalStack root CA from $LOCALSTACK_URL..." + + # Fetch the target CA URL + CA_URL=$(curl -sf "$LOCALSTACK_URL/_localstack/certs" | jq -r .ca.url) + + if [ -z "$CA_URL" ] || [ "$CA_URL" = "null" ]; then + echo "Error: Failed to extract CA URL from LocalStack endpoint." >&2 + exit 1 + fi + + # Fetch the actual PEM payload + PEM_DATA=$(curl -sf "$CA_URL") + + if [ -z "$PEM_DATA" ]; then + echo "Error: Retrieved empty CA certificate payload." >&2 + exit 1 + fi + + # Write next to the script (in $CURRENT_DIR exported by 00-variables.sh). Avoid /tmp: + # on rootless / Docker Desktop / WSL2 setups, /tmp may be outside the daemon's reachable + # filesystem — in that case `-v` silently creates an empty directory at the destination + # instead of bind-mounting the file, and SSL_CERT_FILE ends up pointing at a dir. + HOST_CA_PATH="$CURRENT_DIR/.localstack-ca.crt" + # Remove the CA file on script exit (Ctrl-C, normal exit, error) so we don't leave + # it lying next to the script after the container stops. + trap 'rm -f "$HOST_CA_PATH"' EXIT + printf '%s\n' "$PEM_DATA" > "$HOST_CA_PATH" + # Container runs as a non-root `app` user; make sure it can read the bind-mounted file. + chmod 644 "$HOST_CA_PATH" + echo "LocalStack root CA written to $HOST_CA_PATH" + # Use `--mount type=bind` instead of `-v`: if the source is unreachable the daemon + # errors out instead of creating a phantom empty directory at the target. + CA_MOUNT_ARGS=(--mount "type=bind,source=$HOST_CA_PATH,target=/etc/ssl/certs/localstack.crt,readonly") + EXTRA_ENV_ARGS=(-e SSL_CERT_FILE=/etc/ssl/certs/localstack.crt) +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 AZURECOSMOSDB_ENDPOINT="$AZURECOSMOSDB_ENDPOINT" \ + -e AZURECOSMOSDB_PRIMARY_KEY="$AZURECOSMOSDB_PRIMARY_KEY" \ + -e AZURECOSMOSDB_DATABASENAME="$AZURECOSMOSDB_DATABASENAME" \ + -e AZURECOSMOSDB_CONTAINERNAME="$AZURECOSMOSDB_CONTAINERNAME" \ + -e LOGIN_NAME="$LOGIN_NAME" \ + "${EXTRA_ENV_ARGS[@]}" \ + "${CA_MOUNT_ARGS[@]}" \ + --name "$IMAGE_NAME" \ + "$IMAGE_NAME:$IMAGE_TAG" diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/04-push-docker-image.sh b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/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-cosmosdb-nosql-api/dotnet/scripts/05-deploy-app.sh b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/05-deploy-app.sh new file mode 100755 index 0000000..51507f7 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,132 @@ +#!/bin/bash + +# Variables +source ./00-variables.sh + +# 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 + +# 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 + +# 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 +SECRET_KEY=$(openssl rand -hex 32) + +# 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 the LocalStack root CA configmap so the app can verify the cosmos endpoint's +# TLS chain. The cert is signed by LocalStack's root CA at emulator startup — see +# localstack-pro-azure/.../cosmos/nosql/emulator.py default_cert_store().get_or_create. +# Skipped when LOCALSTACK_URL is empty (real-Azure mode). +if [ -n "$LOCALSTACK_URL" ]; then + echo "Fetching LocalStack root CA from $LOCALSTACK_URL..." + + # Fetch the target CA URL + CA_URL=$(curl -sf "$LOCALSTACK_URL/_localstack/certs" | jq -r .ca.url) + + if [ -z "$CA_URL" ] || [ "$CA_URL" = "null" ]; then + echo "Error: Failed to extract CA URL from LocalStack endpoint." >&2 + exit 1 + fi + + # Fetch the actual PEM payload + PEM_DATA=$(curl -sf "$CA_URL") + + if [ -z "$PEM_DATA" ]; then + echo "Error: Retrieved empty CA certificate payload." >&2 + exit 1 + fi + + # Generate and apply the configmap from a literal string (avoid issues with newlines in the PEM data when using --from-file or --from-env-file) + kubectl -n "$NAMESPACE" create configmap localstack-ca \ + --from-literal=localstack.crt="$PEM_DATA" \ + --dry-run=client -o yaml | kubectl apply -f - +fi + +# Create secret with the Cosmos DB primary key and SECRET_KEY +cat secret.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.AZURECOSMOSDB_PRIMARY_KEY)|="\""$(echo -n $AZURECOSMOSDB_PRIMARY_KEY | 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.AZURECOSMOSDB_ENDPOINT)|="\""$AZURECOSMOSDB_ENDPOINT"\" | +yq "(.data.AZURECOSMOSDB_DATABASENAME)|="\""$AZURECOSMOSDB_DATABASENAME"\" | +yq "(.data.AZURECOSMOSDB_CONTAINERNAME)|="\""$AZURECOSMOSDB_CONTAINERNAME"\" | +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 - + +# 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-nosql-api/dotnet/scripts/Dockerfile b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/Dockerfile new file mode 100644 index 0000000..d862da7 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-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-nosql-api/dotnet/scripts/configmap.yml b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/configmap.yml new file mode 100644 index 0000000..5ea8e75 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/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/dotnet/scripts/deployment.yml b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..6eec136 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/deployment.yml @@ -0,0 +1,104 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vacation-planner-nosql + namespace: vacation-planner-nosql + labels: + app: vacation-planner-nosql +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-nosql + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-nosql + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: vacation-planner-nosql + image: .azurecr.io/vacation-planner-nosql-dotnet:v1 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: AZURECOSMOSDB_ENDPOINT + valueFrom: + configMapKeyRef: + name: vacation-planner-nosql-config + key: AZURECOSMOSDB_ENDPOINT + - name: AZURECOSMOSDB_DATABASENAME + valueFrom: + configMapKeyRef: + name: vacation-planner-nosql-config + key: AZURECOSMOSDB_DATABASENAME + - name: AZURECOSMOSDB_CONTAINERNAME + valueFrom: + configMapKeyRef: + name: vacation-planner-nosql-config + key: AZURECOSMOSDB_CONTAINERNAME + - name: LOGIN_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-nosql-config + key: LOGIN_NAME + - name: AZURECOSMOSDB_PRIMARY_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-nosql-secrets + key: AZURECOSMOSDB_PRIMARY_KEY + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-nosql-secrets + key: SECRET_KEY + # Add the LocalStack root CA to the trust store .NET uses on Linux (OpenSSL honours + # SSL_CERT_FILE next to the system roots) so TLS verification of the Cosmos endpoint + # succeeds. The file is mounted from the `localstack-ca` ConfigMap created by 05-deploy-app.sh. + - name: SSL_CERT_FILE + value: /etc/ssl/certs/localstack.crt + volumeMounts: + - name: localstack-ca + mountPath: /etc/ssl/certs/localstack.crt + subPath: localstack.crt + readOnly: true + 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 + volumes: + - name: localstack-ca + configMap: + name: localstack-ca + # `optional: true` lets the Deployment apply even in real-Azure mode where + # 05-deploy-app.sh skips creating the ConfigMap. In that case drop the + # SSL_CERT_FILE env (and the volumeMount) when switching off LocalStack. + optional: true diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/namespace.yml b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/namespace.yml new file mode 100644 index 0000000..f5e8700 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/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/dotnet/scripts/secret.yml b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/secret.yml new file mode 100644 index 0000000..b2476e6 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/secret.yml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vacation-planner-nosql-secrets + namespace: vacation-planner-nosql + labels: + app: vacation-planner-nosql +type: Opaque +data: + AZURECOSMOSDB_PRIMARY_KEY: "" + SECRET_KEY: "" diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/service.yml b/samples/web-app-cosmosdb-nosql-api/dotnet/scripts/service.yml new file mode 100644 index 0000000..d6d0e79 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/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/dotnet/src/.dockerignore b/samples/web-app-cosmosdb-nosql-api/dotnet/src/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Models/Activity.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-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-nosql-api/dotnet/src/Pages/Delete.cshtml b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..88cfe41 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs @@ -0,0 +1,21 @@ +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) : PageModel +{ + public IActionResult OnGet() => RedirectToPage("/Index"); + + public async Task OnPostAsync(string id, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) + { + TempData["Flash"] = "Activity deleted."; + } + + return RedirectToPage("/Index"); + } +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-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-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..be6bc47 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Index.cshtml.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using VacationPlanner.Models; +using VacationPlanner.Services; + +namespace VacationPlanner.Pages; + +public class IndexModel(IActivityStore store) : 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)) + { + TempData["Flash"] = "Activity updated."; + } + } + else if (await store.AddAsync(text, cancellationToken)) + { + 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..92b0ad0 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs @@ -0,0 +1,48 @@ +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."); +} + +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..981b69a --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs @@ -0,0 +1,112 @@ +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)); + } + } + + 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); + 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); + } + 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 + { + await Container.DeleteItemAsync(id, new PartitionKey(_options.Username), cancellationToken: cancellationToken); + return true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + _logger.LogWarning("Activity {Id} was not found; nothing to delete", id); + return false; + } + } + + 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..4412f8f --- /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("Username cannot be empty"); + } + + 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..10f68b8 --- /dev/null +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/favicon.ico b/samples/web-app-cosmosdb-nosql-api/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5b1d5cf37d81c073dbacd66df3f69d9240359dda GIT binary patch literal 36791 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 D Date: Tue, 8 Sep 2026 14:53:49 +0200 Subject: [PATCH 08/20] Log requests and store operations in the .NET samples The Python apps write one access log line per request, because their gunicorn command passes --access-logfile -, plus a line per store operation and a line per activity added, updated or deleted. The .NET ports only logged startup and error paths, so `kubectl logs` showed almost nothing while the app was serving. Add the request-log middleware, the per-operation store lines and the per-action page lines, with the wording of the .NET samples for Azure App Services, and configure the console writer to emit single timestamped lines like the Python logging format does. Document the output in every sample README, both versions. Co-Authored-By: Claude Opus 5 (1M context) --- samples/web-app-blob-storage/dotnet/README.md | 8 +++++ .../dotnet/src/Pages/Delete.cshtml.cs | 3 +- .../dotnet/src/Pages/Index.cshtml.cs | 4 ++- .../dotnet/src/Program.cs | 19 +++++++++++ .../dotnet/src/Services/BlobActivityStore.cs | 11 +++++-- .../dotnet/src/appsettings.json | 7 ++++ samples/web-app-blob-storage/python/README.md | 8 +++++ .../dotnet/README.md | 8 +++++ .../dotnet/src/Pages/Delete.cshtml.cs | 3 +- .../dotnet/src/Pages/Index.cshtml.cs | 4 ++- .../dotnet/src/Program.cs | 19 +++++++++++ .../dotnet/src/Services/MongoActivityStore.cs | 32 ++++++++++++++++--- .../dotnet/src/appsettings.json | 7 ++++ .../python/README.md | 8 +++++ .../dotnet/README.md | 8 +++++ .../dotnet/src/Pages/Delete.cshtml.cs | 3 +- .../dotnet/src/Pages/Index.cshtml.cs | 4 ++- .../dotnet/src/Program.cs | 19 +++++++++++ .../src/Services/CosmosActivityStore.cs | 14 ++++++++ .../dotnet/src/appsettings.json | 7 ++++ .../python/README.md | 8 +++++ samples/web-app-file-storage/dotnet/README.md | 8 +++++ .../dotnet/src/Pages/Delete.cshtml.cs | 15 ++++++--- .../dotnet/src/Pages/Index.cshtml.cs | 26 +++++++++++---- .../dotnet/src/Program.cs | 19 +++++++++++ .../dotnet/src/Services/FileActivityStore.cs | 4 +++ .../dotnet/src/appsettings.json | 7 ++++ samples/web-app-file-storage/python/README.md | 8 +++++ .../python/README.md | 8 +++++ .../web-app-managed-identity/dotnet/README.md | 8 +++++ .../dotnet/src/Pages/Delete.cshtml.cs | 3 +- .../dotnet/src/Pages/Index.cshtml.cs | 4 ++- .../dotnet/src/Program.cs | 19 +++++++++++ .../dotnet/src/Services/BlobActivityStore.cs | 11 +++++-- .../dotnet/src/appsettings.json | 7 ++++ .../web-app-managed-identity/python/README.md | 8 +++++ .../python/README.md | 8 +++++ .../python/README.md | 8 +++++ samples/web-app-sql-database/python/README.md | 8 +++++ 39 files changed, 355 insertions(+), 28 deletions(-) diff --git a/samples/web-app-blob-storage/dotnet/README.md b/samples/web-app-blob-storage/dotnet/README.md index 4bca8a1..5b57046 100644 --- a/samples/web-app-blob-storage/dotnet/README.md +++ b/samples/web-app-blob-storage/dotnet/README.md @@ -66,3 +66,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml.cs index 2460ba5..b296ad4 100644 --- a/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml.cs +++ b/samples/web-app-blob-storage/dotnet/src/Pages/Delete.cshtml.cs @@ -5,7 +5,7 @@ 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) : PageModel +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel { public IActionResult OnGet() => RedirectToPage("/Index"); @@ -13,6 +13,7 @@ public async Task OnPostAsync(string id, CancellationToken cancel { if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) { + logger.LogInformation("Activity deleted: {Id}", id); TempData["Flash"] = "Activity deleted successfully."; } 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 index c077e52..afd8549 100644 --- a/samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml.cs +++ b/samples/web-app-blob-storage/dotnet/src/Pages/Index.cshtml.cs @@ -5,7 +5,7 @@ namespace VacationPlanner.Pages; -public class IndexModel(IActivityStore store) : PageModel +public class IndexModel(IActivityStore store, ILogger logger) : PageModel { public IReadOnlyList Activities { get; private set; } = []; @@ -33,11 +33,13 @@ public async Task OnPostAsync(CancellationToken cancellationToken { 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."; } } diff --git a/samples/web-app-blob-storage/dotnet/src/Program.cs b/samples/web-app-blob-storage/dotnet/src/Program.cs index c8a8a85..768568c 100644 --- a/samples/web-app-blob-storage/dotnet/src/Program.cs +++ b/samples/web-app-blob-storage/dotnet/src/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection.KeyManagement; using VacationPlanner.Services; @@ -37,6 +38,24 @@ 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(); diff --git a/samples/web-app-blob-storage/dotnet/src/Services/BlobActivityStore.cs b/samples/web-app-blob-storage/dotnet/src/Services/BlobActivityStore.cs index 05bebdf..38cfaff 100644 --- a/samples/web-app-blob-storage/dotnet/src/Services/BlobActivityStore.cs +++ b/samples/web-app-blob-storage/dotnet/src/Services/BlobActivityStore.cs @@ -59,9 +59,11 @@ public async Task> ListAsync(CancellationToken cancellat 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; } @@ -75,9 +77,13 @@ public async Task DeleteAsync(string id, CancellationToken cancellationTok { // 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) + if (deleted.Value) { - _logger.LogInformation("Blob '{Name}' did not exist: already deleted.", id); + _logger.LogInformation("Deleted blob '{Blob}' from container '{Container}'", id, _container.Name); + } + else + { + _logger.LogInformation("Blob '{Blob}' did not exist: already deleted.", id); } return true; @@ -99,6 +105,7 @@ public async Task IsHealthyAsync(CancellationToken cancellationToken) 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; } diff --git a/samples/web-app-blob-storage/dotnet/src/appsettings.json b/samples/web-app-blob-storage/dotnet/src/appsettings.json index 10f68b8..6e5c6b2 100644 --- a/samples/web-app-blob-storage/dotnet/src/appsettings.json +++ b/samples/web-app-blob-storage/dotnet/src/appsettings.json @@ -3,6 +3,13 @@ "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/python/README.md b/samples/web-app-blob-storage/python/README.md index 1b33427..a70b195 100644 --- a/samples/web-app-blob-storage/python/README.md +++ b/samples/web-app-blob-storage/python/README.md @@ -66,3 +66,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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-cosmosdb-mongodb-api/dotnet/README.md b/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md index e730db8..356b0ab 100644 --- a/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/README.md @@ -66,3 +66,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Pages/Delete.cshtml.cs index 88cfe41..6272570 100644 --- 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 @@ -5,7 +5,7 @@ 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) : PageModel +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel { public IActionResult OnGet() => RedirectToPage("/Index"); @@ -13,6 +13,7 @@ public async Task OnPostAsync(string id, CancellationToken cancel { if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) { + logger.LogInformation("Activity deleted: {Id}", id); TempData["Flash"] = "Activity deleted."; } 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 index ca4aa73..4a8e59c 100644 --- 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 @@ -5,7 +5,7 @@ namespace VacationPlanner.Pages; -public class IndexModel(IActivityStore store) : PageModel +public class IndexModel(IActivityStore store, ILogger logger) : PageModel { public IReadOnlyList Activities { get; private set; } = []; @@ -33,11 +33,13 @@ public async Task OnPostAsync(CancellationToken cancellationToken { 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!"; } } diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs index b3f0e5d..9d948f9 100644 --- a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection.KeyManagement; using VacationPlanner.Services; @@ -37,6 +38,24 @@ 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(); 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 index 695f249..aac64f9 100644 --- a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/Services/MongoActivityStore.cs @@ -1,4 +1,5 @@ using MongoDB.Bson; +using MongoDB.Bson.IO; using MongoDB.Driver; using VacationPlanner.Models; @@ -12,6 +13,9 @@ public sealed class MongoActivityStore : IActivityStore 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; @@ -45,6 +49,12 @@ public async Task> ListAsync(CancellationToken cancellat { 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(); } @@ -58,6 +68,11 @@ public async Task AddAsync(string text, CancellationToken cancellationToke ["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; } @@ -68,17 +83,24 @@ public async Task UpdateAsync(string id, string text, CancellationToken ca 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); - if (result.DeletedCount == 0) - { - _logger.LogInformation("Document '{Id}' did not exist: already deleted.", id); - } - + _logger.LogInformation( + "Deleted {Count} document(s) with id {Id} from collection '{Collection}'", + result.DeletedCount, + id, + _options.CollectionName + ); return true; } diff --git a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json index 10f68b8..6e5c6b2 100644 --- a/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json +++ b/samples/web-app-cosmosdb-mongodb-api/dotnet/src/appsettings.json @@ -3,6 +3,13 @@ "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-mongodb-api/python/README.md b/samples/web-app-cosmosdb-mongodb-api/python/README.md index ffa8165..48a4cea 100644 --- a/samples/web-app-cosmosdb-mongodb-api/python/README.md +++ b/samples/web-app-cosmosdb-mongodb-api/python/README.md @@ -66,3 +66,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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-nosql-api/dotnet/README.md b/samples/web-app-cosmosdb-nosql-api/dotnet/README.md index 9475de4..ae4af19 100644 --- a/samples/web-app-cosmosdb-nosql-api/dotnet/README.md +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/README.md @@ -68,3 +68,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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 item read, created, 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-nosql -n vacation-planner-nosql --tail=50 +``` diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs index 88cfe41..6272570 100644 --- a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Pages/Delete.cshtml.cs @@ -5,7 +5,7 @@ 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) : PageModel +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel { public IActionResult OnGet() => RedirectToPage("/Index"); @@ -13,6 +13,7 @@ public async Task OnPostAsync(string id, CancellationToken cancel { if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) { + logger.LogInformation("Activity deleted: {Id}", id); TempData["Flash"] = "Activity deleted."; } 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 index be6bc47..b012b42 100644 --- 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 @@ -5,7 +5,7 @@ namespace VacationPlanner.Pages; -public class IndexModel(IActivityStore store) : PageModel +public class IndexModel(IActivityStore store, ILogger logger) : PageModel { public IReadOnlyList Activities { get; private set; } = []; @@ -33,11 +33,13 @@ public async Task OnPostAsync(CancellationToken cancellationToken { 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."; } } diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs index 92b0ad0..f67b85e 100644 --- a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection.KeyManagement; using VacationPlanner.Services; @@ -37,6 +38,24 @@ 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(); 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 index 981b69a..d1c8a97 100644 --- a/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/Services/CosmosActivityStore.cs @@ -47,6 +47,12 @@ public async Task> ListAsync(CancellationToken cancellat } } + _logger.LogInformation( + "Retrieved {Count} item(s) for user: {Username} from container '{Container}'", + activities.Count, + _options.Username, + _options.ContainerName + ); return activities; } @@ -60,6 +66,12 @@ public async Task AddAsync(string text, CancellationToken cancellationToke 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; } @@ -71,6 +83,7 @@ public async Task UpdateAsync(string id, string text, CancellationToken ca 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) { @@ -84,6 +97,7 @@ public async Task DeleteAsync(string id, CancellationToken cancellationTok { try { + _logger.LogInformation("Deleting item {Id} from container '{Container}'", id, _options.ContainerName); await Container.DeleteItemAsync(id, new PartitionKey(_options.Username), cancellationToken: cancellationToken); return true; } diff --git a/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json b/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json index 10f68b8..6e5c6b2 100644 --- a/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json +++ b/samples/web-app-cosmosdb-nosql-api/dotnet/src/appsettings.json @@ -3,6 +3,13 @@ "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/python/README.md b/samples/web-app-cosmosdb-nosql-api/python/README.md index 7e474d3..aacabbb 100644 --- a/samples/web-app-cosmosdb-nosql-api/python/README.md +++ b/samples/web-app-cosmosdb-nosql-api/python/README.md @@ -68,3 +68,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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-file-storage/dotnet/README.md b/samples/web-app-file-storage/dotnet/README.md index cc32468..bc59a8f 100644 --- a/samples/web-app-file-storage/dotnet/README.md +++ b/samples/web-app-file-storage/dotnet/README.md @@ -111,6 +111,14 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob 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 activity file read, written 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-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/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml.cs index 89929bc..1d77ded 100644 --- a/samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml.cs +++ b/samples/web-app-file-storage/dotnet/src/Pages/Delete.cshtml.cs @@ -9,7 +9,7 @@ namespace VacationPlanner.Pages; /// field, never by its position in the rendered page: every replica mounts the same share and reloads it on each /// GET, so the list can change between rendering a page and submitting a delete from it. /// -public class DeleteModel(IActivityStore store) : PageModel +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel { [BindProperty(Name = "activity_id")] public string? ActivityId { get; set; } @@ -18,9 +18,16 @@ public class DeleteModel(IActivityStore store) : PageModel public async Task OnPostAsync(CancellationToken cancellationToken) { - TempData["Flash"] = await store.DeleteAsync(ActivityId?.Trim() ?? "", cancellationToken) - ? "Activity deleted successfully." - : "Failed to delete the activity from the file share."; + var id = ActivityId?.Trim() ?? ""; + if (await store.DeleteAsync(id, cancellationToken)) + { + logger.LogInformation("Activity deleted: {Id}", id); + TempData["Flash"] = "Activity deleted successfully."; + } + else + { + TempData["Flash"] = "Failed to delete the activity from the file share."; + } return RedirectToPage("/Index"); } 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 index 78018b1..5e2f65f 100644 --- a/samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml.cs +++ b/samples/web-app-file-storage/dotnet/src/Pages/Index.cshtml.cs @@ -5,7 +5,7 @@ namespace VacationPlanner.Pages; -public class IndexModel(IActivityStore store, FileStorageOptions options) : PageModel +public class IndexModel(IActivityStore store, FileStorageOptions options, ILogger logger) : PageModel { public IReadOnlyList Activities { get; private set; } = []; @@ -34,15 +34,27 @@ public async Task OnPostAsync(CancellationToken cancellationToken { if (!string.IsNullOrEmpty(id)) { - TempData["Flash"] = await store.UpdateAsync(id, text, cancellationToken) - ? "Activity updated successfully." - : "Failed to update the activity on the file share."; + 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 { - TempData["Flash"] = await store.AddAsync(text, cancellationToken) - ? "Activity added successfully." - : "Failed to add the activity to the file share."; + 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."; + } } } diff --git a/samples/web-app-file-storage/dotnet/src/Program.cs b/samples/web-app-file-storage/dotnet/src/Program.cs index f84b421..0d1f87d 100644 --- a/samples/web-app-file-storage/dotnet/src/Program.cs +++ b/samples/web-app-file-storage/dotnet/src/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection.KeyManagement; using VacationPlanner.Services; @@ -38,6 +39,24 @@ 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(); diff --git a/samples/web-app-file-storage/dotnet/src/Services/FileActivityStore.cs b/samples/web-app-file-storage/dotnet/src/Services/FileActivityStore.cs index 4a8df22..3086160 100644 --- a/samples/web-app-file-storage/dotnet/src/Services/FileActivityStore.cs +++ b/samples/web-app-file-storage/dotnet/src/Services/FileActivityStore.cs @@ -44,10 +44,12 @@ public Task> ListAsync(CancellationToken cancellationTok 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); } @@ -85,6 +87,7 @@ public Task DeleteAsync(string id, CancellationToken cancellationToken) 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); @@ -127,6 +130,7 @@ private bool Write(string name, string text) 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; diff --git a/samples/web-app-file-storage/dotnet/src/appsettings.json b/samples/web-app-file-storage/dotnet/src/appsettings.json index 10f68b8..6e5c6b2 100644 --- a/samples/web-app-file-storage/dotnet/src/appsettings.json +++ b/samples/web-app-file-storage/dotnet/src/appsettings.json @@ -3,6 +3,13 @@ "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-file-storage/python/README.md b/samples/web-app-file-storage/python/README.md index e9ee2db..c5b6ded 100644 --- a/samples/web-app-file-storage/python/README.md +++ b/samples/web-app-file-storage/python/README.md @@ -111,6 +111,14 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob 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-in-cluster-postgresql/python/README.md b/samples/web-app-in-cluster-postgresql/python/README.md index 75e56f1..de0ea5f 100644 --- a/samples/web-app-in-cluster-postgresql/python/README.md +++ b/samples/web-app-in-cluster-postgresql/python/README.md @@ -70,3 +70,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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-managed-identity/dotnet/README.md b/samples/web-app-managed-identity/dotnet/README.md index 5739197..82116c2 100644 --- a/samples/web-app-managed-identity/dotnet/README.md +++ b/samples/web-app-managed-identity/dotnet/README.md @@ -73,3 +73,11 @@ 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 — 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-managed-identity/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs index 2460ba5..b296ad4 100644 --- a/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Delete.cshtml.cs @@ -5,7 +5,7 @@ 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) : PageModel +public class DeleteModel(IActivityStore store, ILogger logger) : PageModel { public IActionResult OnGet() => RedirectToPage("/Index"); @@ -13,6 +13,7 @@ public async Task OnPostAsync(string id, CancellationToken cancel { if (!string.IsNullOrWhiteSpace(id) && await store.DeleteAsync(id, cancellationToken)) { + logger.LogInformation("Activity deleted: {Id}", id); TempData["Flash"] = "Activity deleted successfully."; } 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 index c077e52..afd8549 100644 --- a/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs +++ b/samples/web-app-managed-identity/dotnet/src/Pages/Index.cshtml.cs @@ -5,7 +5,7 @@ namespace VacationPlanner.Pages; -public class IndexModel(IActivityStore store) : PageModel +public class IndexModel(IActivityStore store, ILogger logger) : PageModel { public IReadOnlyList Activities { get; private set; } = []; @@ -33,11 +33,13 @@ public async Task OnPostAsync(CancellationToken cancellationToken { 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."; } } diff --git a/samples/web-app-managed-identity/dotnet/src/Program.cs b/samples/web-app-managed-identity/dotnet/src/Program.cs index c8a8a85..768568c 100644 --- a/samples/web-app-managed-identity/dotnet/src/Program.cs +++ b/samples/web-app-managed-identity/dotnet/src/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection.KeyManagement; using VacationPlanner.Services; @@ -37,6 +38,24 @@ 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(); diff --git a/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs b/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs index 05bebdf..38cfaff 100644 --- a/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs +++ b/samples/web-app-managed-identity/dotnet/src/Services/BlobActivityStore.cs @@ -59,9 +59,11 @@ public async Task> ListAsync(CancellationToken cancellat 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; } @@ -75,9 +77,13 @@ public async Task DeleteAsync(string id, CancellationToken cancellationTok { // 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) + if (deleted.Value) { - _logger.LogInformation("Blob '{Name}' did not exist: already deleted.", id); + _logger.LogInformation("Deleted blob '{Blob}' from container '{Container}'", id, _container.Name); + } + else + { + _logger.LogInformation("Blob '{Blob}' did not exist: already deleted.", id); } return true; @@ -99,6 +105,7 @@ public async Task IsHealthyAsync(CancellationToken cancellationToken) 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; } diff --git a/samples/web-app-managed-identity/dotnet/src/appsettings.json b/samples/web-app-managed-identity/dotnet/src/appsettings.json index 10f68b8..6e5c6b2 100644 --- a/samples/web-app-managed-identity/dotnet/src/appsettings.json +++ b/samples/web-app-managed-identity/dotnet/src/appsettings.json @@ -3,6 +3,13 @@ "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/python/README.md b/samples/web-app-managed-identity/python/README.md index 79de032..117adfb 100644 --- a/samples/web-app-managed-identity/python/README.md +++ b/samples/web-app-managed-identity/python/README.md @@ -73,3 +73,11 @@ 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-blob -n vacation-planner-blob --tail=50 +``` diff --git a/samples/web-app-mysql-flexible-server/python/README.md b/samples/web-app-mysql-flexible-server/python/README.md index 6976089..5c7c527 100644 --- a/samples/web-app-mysql-flexible-server/python/README.md +++ b/samples/web-app-mysql-flexible-server/python/README.md @@ -68,3 +68,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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-postgresql-flexible-server/python/README.md b/samples/web-app-postgresql-flexible-server/python/README.md index 99fe9df..25819fb 100644 --- a/samples/web-app-postgresql-flexible-server/python/README.md +++ b/samples/web-app-postgresql-flexible-server/python/README.md @@ -66,3 +66,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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-sql-database/python/README.md b/samples/web-app-sql-database/python/README.md index 9c19693..8927b34 100644 --- a/samples/web-app-sql-database/python/README.md +++ b/samples/web-app-sql-database/python/README.md @@ -66,3 +66,11 @@ The app also exposes `GET /health`, the endpoint the liveness and readiness prob ```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 +``` From 50150b5809b5411caacc68f67fe8ac81ee3b000b Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Tue, 8 Sep 2026 17:54:07 +0200 Subject: [PATCH 09/20] Add the .NET version of web-app-postgresql-flexible-server ASP.NET Core Razor Pages port of the Python Flask sample, with the same behaviour, the same environment variables and the same deployment artifacts: only the container image name carries the -dotnet suffix, so both versions roll over the same Deployment, Service, ConfigMap and Secret. Npgsql over the flexible server with SslMode=Prefer, the 30 x 2s startup retry of the Python version, activities keyed by their store id, GET /health behind both probes, and the Data Protection key ring derived from SECRET_KEY so the three replicas validate each other's antiforgery tokens and flashes. Validated on the LocalStack AKS emulator: 3/3 pods Ready through the /health probes, add/list/update/delete through a port-forward and from inside the cluster across all replicas, and the expected request, store and page log lines. Co-Authored-By: Claude Opus 5 (1M context) --- .../dotnet/README.md | 76 ++++ .../dotnet/images/architecture.png | Bin 0 -> 38251 bytes .../dotnet/scripts/00-variables.sh | 45 +++ .../dotnet/scripts/01-deploy-resources.sh | 286 +++++++++++++++ .../dotnet/scripts/02-build-docker-image.sh | 18 + .../dotnet/scripts/03-run-docker-container.sh | 38 ++ .../dotnet/scripts/04-push-docker-image.sh | 40 ++ .../dotnet/scripts/05-deploy-app.sh | 93 +++++ .../dotnet/scripts/Dockerfile | 28 ++ .../dotnet/scripts/configmap.yml | 14 + .../dotnet/scripts/deployment.yml | 96 +++++ .../dotnet/scripts/namespace.yml | 4 + .../dotnet/scripts/secret.yml | 11 + .../dotnet/scripts/service.yml | 16 + .../dotnet/src/.dockerignore | 2 + .../dotnet/src/Models/Activity.cs | 4 + .../dotnet/src/Pages/Delete.cshtml | 2 + .../dotnet/src/Pages/Delete.cshtml.cs | 22 ++ .../dotnet/src/Pages/Index.cshtml | 265 ++++++++++++++ .../dotnet/src/Pages/Index.cshtml.cs | 49 +++ .../dotnet/src/Pages/_ViewImports.cshtml | 4 + .../dotnet/src/Program.cs | 69 ++++ .../dotnet/src/Services/ActivityId.cs | 15 + .../dotnet/src/Services/IActivityStore.cs | 24 ++ .../src/Services/PostgresActivityStore.cs | 126 +++++++ .../dotnet/src/Services/PostgresOptions.cs | 28 ++ .../src/Services/SecretKeyXmlRepository.cs | 49 +++ .../dotnet/src/Services/StoreInitializer.cs | 33 ++ .../dotnet/src/VacationPlanner.csproj | 11 + .../dotnet/src/appsettings.json | 16 + .../dotnet/src/wwwroot/favicon.ico | Bin 0 -> 36791 bytes .../dotnet/src/wwwroot/style.css | 341 ++++++++++++++++++ 32 files changed, 1825 insertions(+) create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/README.md create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/images/architecture.png create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/scripts/00-variables.sh create mode 100755 samples/web-app-postgresql-flexible-server/dotnet/scripts/01-deploy-resources.sh create mode 100755 samples/web-app-postgresql-flexible-server/dotnet/scripts/02-build-docker-image.sh create mode 100755 samples/web-app-postgresql-flexible-server/dotnet/scripts/03-run-docker-container.sh create mode 100755 samples/web-app-postgresql-flexible-server/dotnet/scripts/04-push-docker-image.sh create mode 100755 samples/web-app-postgresql-flexible-server/dotnet/scripts/05-deploy-app.sh create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/scripts/Dockerfile create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/scripts/configmap.yml create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/scripts/deployment.yml create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/scripts/namespace.yml create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/scripts/secret.yml create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/scripts/service.yml create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/.dockerignore create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Models/Activity.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Pages/Index.cshtml.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Program.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Services/ActivityId.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Services/IActivityStore.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresActivityStore.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Services/PostgresOptions.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Services/SecretKeyXmlRepository.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/Services/StoreInitializer.cs create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/VacationPlanner.csproj create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/appsettings.json create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/favicon.ico create mode 100644 samples/web-app-postgresql-flexible-server/dotnet/src/wwwroot/style.css 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/dotnet/images/architecture.png b/samples/web-app-postgresql-flexible-server/dotnet/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..f84b79560706b1ad72543e879b50e1c7be1c9410 GIT binary patch literal 38251 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 literal 0 HcmV?d00001 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/dotnet/scripts/01-deploy-resources.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..955cf4b --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/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/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/dotnet/scripts/03-run-docker-container.sh b/samples/web-app-postgresql-flexible-server/dotnet/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..c73d923 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/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/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..36cf487 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,93 @@ +#!/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 +SECRET_KEY=$(openssl rand -hex 32) + +# 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 - + +# 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/dotnet/scripts/configmap.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/configmap.yml new file mode 100644 index 0000000..4064c51 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/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/dotnet/scripts/deployment.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..1f33e69 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/deployment.yml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vacation-planner-postgres + namespace: vacation-planner-postgres + labels: + app: vacation-planner-postgres +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-postgres + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-postgres + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: vacation-planner-postgres + image: .azurecr.io/vacation-planner-postgres-dotnet:v1 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: PG_HOST + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: PG_HOST + - name: PG_PORT + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: PG_PORT + - name: PG_DATABASE + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: PG_DATABASE + - name: PG_USER + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: PG_USER + - name: LOGIN_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: LOGIN_NAME + - name: DEBUG + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: DEBUG + - name: PG_PASSWORD + valueFrom: + secretKeyRef: + name: vacation-planner-postgres-secrets + key: PG_PASSWORD + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-postgres-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-postgresql-flexible-server/dotnet/scripts/namespace.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/namespace.yml new file mode 100644 index 0000000..275d4f9 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-postgres diff --git a/samples/web-app-postgresql-flexible-server/dotnet/scripts/secret.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/secret.yml new file mode 100644 index 0000000..13629f2 --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/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/dotnet/scripts/service.yml b/samples/web-app-postgresql-flexible-server/dotnet/scripts/service.yml new file mode 100644 index 0000000..ec8e47f --- /dev/null +++ b/samples/web-app-postgresql-flexible-server/dotnet/scripts/service.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vacation-planner-postgres + namespace: vacation-planner-postgres + labels: + app: vacation-planner-postgres +spec: + type: ClusterIP + selector: + app: vacation-planner-postgres + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http 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 0000000000000000000000000000000000000000..5b1d5cf37d81c073dbacd66df3f69d9240359dda GIT binary patch literal 36791 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 D Date: Tue, 8 Sep 2026 17:58:20 +0200 Subject: [PATCH 10/20] Add the .NET version of web-app-mysql-flexible-server ASP.NET Core Razor Pages port of the Python Flask sample, with the same behaviour, environment variables and deployment artifacts; only the container image name carries the -dotnet suffix, so both versions roll over the same Deployment, Service, ConfigMap and Secret. MySqlConnector with SslMode=Required when MYSQL_SSL=true and, as in PyMySQL, UseAffectedRows=true so an update that changes nothing reports no change and flashes nothing. Same 30 x 2s startup retry, activities keyed by their store id, GET /health behind both probes, and the Data Protection key ring derived from SECRET_KEY so all three replicas share antiforgery tokens and flashes. Validated on the LocalStack AKS emulator: 3/3 pods Ready through the /health probes, add/list/update/delete through a port-forward and from inside the cluster across all replicas, identical no-op-update behaviour to the Python version, and the expected request, store and page log lines. Co-Authored-By: Claude Opus 5 (1M context) --- .../dotnet/README.md | 78 ++++ .../dotnet/images/architecture.png | Bin 0 -> 38479 bytes .../dotnet/scripts/00-variables.sh | 48 +++ .../dotnet/scripts/01-deploy-resources.sh | 273 ++++++++++++++ .../dotnet/scripts/02-build-docker-image.sh | 18 + .../dotnet/scripts/03-run-docker-container.sh | 39 ++ .../dotnet/scripts/04-push-docker-image.sh | 40 ++ .../dotnet/scripts/05-deploy-app.sh | 94 +++++ .../dotnet/scripts/Dockerfile | 28 ++ .../dotnet/scripts/configmap.yml | 15 + .../dotnet/scripts/deployment.yml | 101 ++++++ .../dotnet/scripts/namespace.yml | 4 + .../dotnet/scripts/secret.yml | 11 + .../dotnet/scripts/service.yml | 16 + .../dotnet/src/.dockerignore | 2 + .../dotnet/src/Models/Activity.cs | 4 + .../dotnet/src/Pages/Delete.cshtml | 2 + .../dotnet/src/Pages/Delete.cshtml.cs | 22 ++ .../dotnet/src/Pages/Index.cshtml | 265 ++++++++++++++ .../dotnet/src/Pages/Index.cshtml.cs | 49 +++ .../dotnet/src/Pages/_ViewImports.cshtml | 4 + .../dotnet/src/Program.cs | 69 ++++ .../dotnet/src/Services/ActivityId.cs | 15 + .../dotnet/src/Services/IActivityStore.cs | 24 ++ .../dotnet/src/Services/MySqlActivityStore.cs | 131 +++++++ .../dotnet/src/Services/MySqlOptions.cs | 30 ++ .../src/Services/SecretKeyXmlRepository.cs | 49 +++ .../dotnet/src/Services/StoreInitializer.cs | 33 ++ .../dotnet/src/VacationPlanner.csproj | 11 + .../dotnet/src/appsettings.json | 16 + .../dotnet/src/wwwroot/favicon.ico | Bin 0 -> 36791 bytes .../dotnet/src/wwwroot/style.css | 341 ++++++++++++++++++ 32 files changed, 1832 insertions(+) create mode 100644 samples/web-app-mysql-flexible-server/dotnet/README.md create mode 100644 samples/web-app-mysql-flexible-server/dotnet/images/architecture.png create mode 100755 samples/web-app-mysql-flexible-server/dotnet/scripts/00-variables.sh create mode 100755 samples/web-app-mysql-flexible-server/dotnet/scripts/01-deploy-resources.sh create mode 100755 samples/web-app-mysql-flexible-server/dotnet/scripts/02-build-docker-image.sh create mode 100755 samples/web-app-mysql-flexible-server/dotnet/scripts/03-run-docker-container.sh create mode 100755 samples/web-app-mysql-flexible-server/dotnet/scripts/04-push-docker-image.sh create mode 100755 samples/web-app-mysql-flexible-server/dotnet/scripts/05-deploy-app.sh create mode 100644 samples/web-app-mysql-flexible-server/dotnet/scripts/Dockerfile create mode 100644 samples/web-app-mysql-flexible-server/dotnet/scripts/configmap.yml create mode 100644 samples/web-app-mysql-flexible-server/dotnet/scripts/deployment.yml create mode 100644 samples/web-app-mysql-flexible-server/dotnet/scripts/namespace.yml create mode 100644 samples/web-app-mysql-flexible-server/dotnet/scripts/secret.yml create mode 100644 samples/web-app-mysql-flexible-server/dotnet/scripts/service.yml create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/.dockerignore create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Models/Activity.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Pages/_ViewImports.cshtml create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Program.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Services/ActivityId.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Services/IActivityStore.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlActivityStore.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Services/MySqlOptions.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Services/SecretKeyXmlRepository.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/Services/StoreInitializer.cs create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/VacationPlanner.csproj create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/appsettings.json create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/favicon.ico create mode 100644 samples/web-app-mysql-flexible-server/dotnet/src/wwwroot/style.css diff --git a/samples/web-app-mysql-flexible-server/dotnet/README.md b/samples/web-app-mysql-flexible-server/dotnet/README.md new file mode 100644 index 0000000..00cccf7 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/README.md @@ -0,0 +1,78 @@ +# Vacation Planner: Azure Database for MySQL 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 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. + +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. + +## 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, MySQL 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 MySQL flexible server](https://learn.microsoft.com/en-us/azure/mysql/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 the `mysql` client 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 (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 `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-mysql 8080:80 -n vacation-planner-mysql +``` + +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 — 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-mysql -n vacation-planner-mysql --tail=50 +``` diff --git a/samples/web-app-mysql-flexible-server/dotnet/images/architecture.png b/samples/web-app-mysql-flexible-server/dotnet/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..432dd12a42659440171f5d4f26695b677ad4987d GIT binary patch literal 38479 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¨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 literal 0 HcmV?d00001 diff --git a/samples/web-app-mysql-flexible-server/dotnet/scripts/00-variables.sh b/samples/web-app-mysql-flexible-server/dotnet/scripts/00-variables.sh new file mode 100755 index 0000000..6ba4c8a --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/scripts/00-variables.sh @@ -0,0 +1,48 @@ +# 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 MySQL flexible server +MYSQL_SERVER_NAME="${PREFIX}-mysqlflex-${SUFFIX}" +MYSQL_VERSION='8.0.21' +MYSQL_SKU_TIER='Burstable' +MYSQL_SKU_NAME='Standard_B1ms' +MYSQL_STORAGE_SIZE_GB=32 +MYSQL_BACKUP_RETENTION_DAYS=7 +MYSQL_PORT='3306' +FIREWALL_RULE_NAME='AllowAllIPs' +MYSQL_ADMIN_USER='myadmin' +MYSQL_ADMIN_PASSWORD='P@ssw0rd1234!' +MYSQL_USER_NAME='testuser' +MYSQL_USER_PASSWORD='TestP@ssw0rd123' +MYSQL_DATABASE_NAME='plannerdb' +# Azure MySQL Flexible Server (and the LocalStack emulator) default require_secure_transport=ON, +# so the app must connect over TLS. The app enables TLS without certificate verification when +# MYSQL_SSL is truthy, which works against both LocalStack (self-signed cert) and real Azure. +MYSQL_SSL='true' + +# Application config — must match the seed-row `username` in 01-deploy-resources.sh. +LOGIN_NAME='paolo' + +# Docker Image +IMAGE_NAME="vacation-planner-mysql-dotnet" +IMAGE_PULL_POLICY="Always" +IMAGE_TAG="v1" +PORT="8080" + +# Kubernetes +NAMESPACE="vacation-planner-mysql" +DEPLOYMENT_NAME="vacation-planner-mysql" +SERVICE_NAME="vacation-planner-mysql" +CONFIGMAP_NAME="vacation-planner-mysql-config" +K8S_SECRET_NAME="vacation-planner-mysql-secrets" diff --git a/samples/web-app-mysql-flexible-server/dotnet/scripts/01-deploy-resources.sh b/samples/web-app-mysql-flexible-server/dotnet/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..ca56172 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/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/dotnet/scripts/02-build-docker-image.sh b/samples/web-app-mysql-flexible-server/dotnet/scripts/02-build-docker-image.sh new file mode 100755 index 0000000..4b497d8 --- /dev/null +++ b/samples/web-app-mysql-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-mysql-flexible-server/dotnet/scripts/03-run-docker-container.sh b/samples/web-app-mysql-flexible-server/dotnet/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..d136b31 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/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/dotnet/scripts/04-push-docker-image.sh b/samples/web-app-mysql-flexible-server/dotnet/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-mysql-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-mysql-flexible-server/dotnet/scripts/05-deploy-app.sh b/samples/web-app-mysql-flexible-server/dotnet/scripts/05-deploy-app.sh new file mode 100755 index 0000000..11fea8a --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,94 @@ +#!/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 + +# 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 +SECRET_KEY=$(openssl rand -hex 32) + +# 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 MySQL password and the SECRET_KEY +cat secret.yml | +yq "(.metadata.namespace)|="\""$NAMESPACE"\" | +yq "(.data.MYSQL_PASSWORD)|="\""$(echo -n $MYSQL_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.MYSQL_HOST)|="\""$MYSQL_FQDN"\" | +yq "(.data.MYSQL_PORT)|="\""$MYSQL_PORT"\" | +yq "(.data.MYSQL_DATABASE)|="\""$MYSQL_DATABASE_NAME"\" | +yq "(.data.MYSQL_USER)|="\""$MYSQL_USER_NAME"\" | +yq "(.data.MYSQL_SSL)|="\""$MYSQL_SSL"\" | +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 - + +# 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-mysql-flexible-server/dotnet/scripts/Dockerfile b/samples/web-app-mysql-flexible-server/dotnet/scripts/Dockerfile new file mode 100644 index 0000000..d862da7 --- /dev/null +++ b/samples/web-app-mysql-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-mysql-flexible-server/dotnet/scripts/configmap.yml b/samples/web-app-mysql-flexible-server/dotnet/scripts/configmap.yml new file mode 100644 index 0000000..7a97f63 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/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/dotnet/scripts/deployment.yml b/samples/web-app-mysql-flexible-server/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..6ee75df --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/scripts/deployment.yml @@ -0,0 +1,101 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vacation-planner-mysql + namespace: vacation-planner-mysql + labels: + app: vacation-planner-mysql +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-mysql + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-mysql + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: vacation-planner-mysql + image: .azurecr.io/vacation-planner-mysql-dotnet:v1 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: MYSQL_HOST + valueFrom: + configMapKeyRef: + name: vacation-planner-mysql-config + key: MYSQL_HOST + - name: MYSQL_PORT + valueFrom: + configMapKeyRef: + name: vacation-planner-mysql-config + key: MYSQL_PORT + - name: MYSQL_DATABASE + valueFrom: + configMapKeyRef: + name: vacation-planner-mysql-config + key: MYSQL_DATABASE + - name: MYSQL_USER + valueFrom: + configMapKeyRef: + name: vacation-planner-mysql-config + key: MYSQL_USER + - name: MYSQL_SSL + valueFrom: + configMapKeyRef: + name: vacation-planner-mysql-config + key: MYSQL_SSL + - name: LOGIN_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-mysql-config + key: LOGIN_NAME + - name: DEBUG + valueFrom: + configMapKeyRef: + name: vacation-planner-mysql-config + key: DEBUG + - name: MYSQL_PASSWORD + valueFrom: + secretKeyRef: + name: vacation-planner-mysql-secrets + key: MYSQL_PASSWORD + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-mysql-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-mysql-flexible-server/dotnet/scripts/namespace.yml b/samples/web-app-mysql-flexible-server/dotnet/scripts/namespace.yml new file mode 100644 index 0000000..92b20ac --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/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/dotnet/scripts/secret.yml b/samples/web-app-mysql-flexible-server/dotnet/scripts/secret.yml new file mode 100644 index 0000000..e21d7fe --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/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/dotnet/scripts/service.yml b/samples/web-app-mysql-flexible-server/dotnet/scripts/service.yml new file mode 100644 index 0000000..0217de1 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/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/dotnet/src/.dockerignore b/samples/web-app-mysql-flexible-server/dotnet/src/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Models/Activity.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Models/Activity.cs new file mode 100644 index 0000000..c39b073 --- /dev/null +++ b/samples/web-app-mysql-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-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml new file mode 100644 index 0000000..386fa85 --- /dev/null +++ b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml @@ -0,0 +1,2 @@ +@page "/delete/{id}" +@model DeleteModel diff --git a/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Delete.cshtml.cs new file mode 100644 index 0000000..6272570 --- /dev/null +++ b/samples/web-app-mysql-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-mysql-flexible-server/dotnet/src/Pages/Index.cshtml b/samples/web-app-mysql-flexible-server/dotnet/src/Pages/Index.cshtml new file mode 100644 index 0000000..bd617e2 --- /dev/null +++ b/samples/web-app-mysql-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-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 0000000000000000000000000000000000000000..5b1d5cf37d81c073dbacd66df3f69d9240359dda GIT binary patch literal 36791 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 D Date: Tue, 8 Sep 2026 18:02:40 +0200 Subject: [PATCH 11/20] Add the .NET version of web-app-sql-database ASP.NET Core Razor Pages port of the Python Flask sample, with the same behaviour, environment variables and deployment artifacts; only the container image name carries the -dotnet suffix, so both versions roll over the same Deployment, Service, ConfigMap and Secret. FLASK_SECRET_KEY is renamed to SECRET_KEY in the .NET copies of the Secret and the Deployment, the only deliberate contract change, because the name is language-specific. Microsoft.Data.SqlClient with Encrypt=Mandatory and TrustServerCertificate, an optional Entra token through SqlConnection.AccessToken, no DDL (the table comes from 01-deploy-resources.sh) and no startup retry, matching the Python version. Activities are addressed by their UNIQUEIDENTIFIER, /update/{id} is kept for route parity, GET /health sits behind both probes, and the Data Protection key ring is derived from SECRET_KEY so the replicas share antiforgery tokens and flashes. Validated on the LocalStack AKS emulator: 3/3 pods Ready through the /health probes, add/list/update/delete through a port-forward and from inside the cluster across all replicas, /update/{id} redirecting to the edit form, and the expected request, store and page log lines. Co-Authored-By: Claude Opus 5 (1M context) --- samples/web-app-sql-database/dotnet/README.md | 76 ++++ .../dotnet/images/architecture.png | Bin 0 -> 37713 bytes .../dotnet/scripts/00-variables.sh | 38 ++ .../dotnet/scripts/01-deploy-resources.sh | 247 +++++++++++++ .../dotnet/scripts/02-build-docker-image.sh | 18 + .../dotnet/scripts/03-run-docker-container.sh | 30 ++ .../dotnet/scripts/04-push-docker-image.sh | 40 ++ .../dotnet/scripts/05-deploy-app.sh | 85 +++++ .../dotnet/scripts/Dockerfile | 28 ++ .../dotnet/scripts/configmap.yml | 12 + .../dotnet/scripts/deployment.yml | 86 +++++ .../dotnet/scripts/namespace.yml | 4 + .../dotnet/scripts/secret.yml | 11 + .../dotnet/scripts/service.yml | 16 + .../dotnet/src/.dockerignore | 2 + .../dotnet/src/Models/Activity.cs | 4 + .../dotnet/src/Pages/Delete.cshtml | 2 + .../dotnet/src/Pages/Delete.cshtml.cs | 22 ++ .../dotnet/src/Pages/Index.cshtml | 265 ++++++++++++++ .../dotnet/src/Pages/Index.cshtml.cs | 49 +++ .../dotnet/src/Pages/Update.cshtml | 2 + .../dotnet/src/Pages/Update.cshtml.cs | 17 + .../dotnet/src/Pages/_ViewImports.cshtml | 4 + .../dotnet/src/Program.cs | 67 ++++ .../dotnet/src/Services/IActivityStore.cs | 24 ++ .../src/Services/SecretKeyXmlRepository.cs | 49 +++ .../dotnet/src/Services/SqlActivityStore.cs | 142 ++++++++ .../dotnet/src/Services/SqlOptions.cs | 34 ++ .../dotnet/src/Services/StoreInitializer.cs | 33 ++ .../dotnet/src/VacationPlanner.csproj | 12 + .../dotnet/src/appsettings.json | 16 + .../dotnet/src/wwwroot/favicon.ico | Bin 0 -> 36791 bytes .../dotnet/src/wwwroot/style.css | 341 ++++++++++++++++++ 33 files changed, 1776 insertions(+) create mode 100644 samples/web-app-sql-database/dotnet/README.md create mode 100644 samples/web-app-sql-database/dotnet/images/architecture.png create mode 100755 samples/web-app-sql-database/dotnet/scripts/00-variables.sh create mode 100755 samples/web-app-sql-database/dotnet/scripts/01-deploy-resources.sh create mode 100755 samples/web-app-sql-database/dotnet/scripts/02-build-docker-image.sh create mode 100755 samples/web-app-sql-database/dotnet/scripts/03-run-docker-container.sh create mode 100755 samples/web-app-sql-database/dotnet/scripts/04-push-docker-image.sh create mode 100755 samples/web-app-sql-database/dotnet/scripts/05-deploy-app.sh create mode 100644 samples/web-app-sql-database/dotnet/scripts/Dockerfile create mode 100644 samples/web-app-sql-database/dotnet/scripts/configmap.yml create mode 100644 samples/web-app-sql-database/dotnet/scripts/deployment.yml create mode 100644 samples/web-app-sql-database/dotnet/scripts/namespace.yml create mode 100644 samples/web-app-sql-database/dotnet/scripts/secret.yml create mode 100644 samples/web-app-sql-database/dotnet/scripts/service.yml create mode 100644 samples/web-app-sql-database/dotnet/src/.dockerignore create mode 100644 samples/web-app-sql-database/dotnet/src/Models/Activity.cs create mode 100644 samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml create mode 100644 samples/web-app-sql-database/dotnet/src/Pages/Delete.cshtml.cs create mode 100644 samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml create mode 100644 samples/web-app-sql-database/dotnet/src/Pages/Index.cshtml.cs create mode 100644 samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml create mode 100644 samples/web-app-sql-database/dotnet/src/Pages/Update.cshtml.cs create mode 100644 samples/web-app-sql-database/dotnet/src/Pages/_ViewImports.cshtml create mode 100644 samples/web-app-sql-database/dotnet/src/Program.cs create mode 100644 samples/web-app-sql-database/dotnet/src/Services/IActivityStore.cs create mode 100644 samples/web-app-sql-database/dotnet/src/Services/SecretKeyXmlRepository.cs create mode 100644 samples/web-app-sql-database/dotnet/src/Services/SqlActivityStore.cs create mode 100644 samples/web-app-sql-database/dotnet/src/Services/SqlOptions.cs create mode 100644 samples/web-app-sql-database/dotnet/src/Services/StoreInitializer.cs create mode 100644 samples/web-app-sql-database/dotnet/src/VacationPlanner.csproj create mode 100644 samples/web-app-sql-database/dotnet/src/appsettings.json create mode 100644 samples/web-app-sql-database/dotnet/src/wwwroot/favicon.ico create mode 100644 samples/web-app-sql-database/dotnet/src/wwwroot/style.css 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/dotnet/images/architecture.png b/samples/web-app-sql-database/dotnet/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..4a0eee03768eb5d60d627b37e053b308dec59b18 GIT binary patch literal 37713 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 l6!00xaW0QvFa9$*eZ=MNq)/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 "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/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/dotnet/scripts/03-run-docker-container.sh b/samples/web-app-sql-database/dotnet/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..8fd863b --- /dev/null +++ b/samples/web-app-sql-database/dotnet/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/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..009dbce --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,85 @@ +#!/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 +SECRET_KEY=$(openssl rand -hex 32) + +# 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 - + +# 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/dotnet/scripts/configmap.yml b/samples/web-app-sql-database/dotnet/scripts/configmap.yml new file mode 100644 index 0000000..e7a7a87 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/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/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/dotnet/scripts/namespace.yml b/samples/web-app-sql-database/dotnet/scripts/namespace.yml new file mode 100644 index 0000000..ea0aca4 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/scripts/namespace.yml @@ -0,0 +1,4 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: vacation-planner-sql 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/dotnet/scripts/service.yml b/samples/web-app-sql-database/dotnet/scripts/service.yml new file mode 100644 index 0000000..31fa2e6 --- /dev/null +++ b/samples/web-app-sql-database/dotnet/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/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..e15f9bb --- /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("Username cannot be None or empty"); + } + + 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 0000000000000000000000000000000000000000..5b1d5cf37d81c073dbacd66df3f69d9240359dda GIT binary patch literal 36791 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 D Date: Tue, 8 Sep 2026 18:08:31 +0200 Subject: [PATCH 12/20] Add the .NET version of web-app-in-cluster-postgresql ASP.NET Core Razor Pages port of the Python Flask sample, with the same behaviour, environment variables and deployment artifacts; only the container image name carries the -dotnet suffix, so both versions roll over the same Deployment, Service, ConfigMap and Secret, and both talk to the same PostgreSQL StatefulSet running in the cluster. Npgsql with SslMode=Prefer, which falls back to plaintext against the in-cluster server, the 30 x 2s startup retry of the Python version, activities keyed by their store id, GET /health behind both probes, and the Data Protection key ring derived from SECRET_KEY so the replicas share antiforgery tokens and flashes. 05-deploy-app.sh now waits for the rollout and prints the port-forward hint, like the other .NET samples. Validated on the LocalStack AKS emulator against the 3-replica StatefulSet: 3/3 app pods Ready through the /health probes, add/list/update/delete through a port-forward and from inside the cluster across all replicas, and the expected request, store and page log lines. Co-Authored-By: Claude Opus 5 (1M context) --- .../dotnet/README.md | 80 ++++ .../dotnet/images/architecture.png | Bin 0 -> 39396 bytes .../dotnet/scripts/00-variables.sh | 51 +++ .../dotnet/scripts/01-deploy-resources.sh | 61 ++++ .../dotnet/scripts/02-build-docker-image.sh | 18 + .../dotnet/scripts/03-run-docker-container.sh | 33 ++ .../dotnet/scripts/04-push-docker-image.sh | 40 ++ .../dotnet/scripts/05-deploy-app.sh | 282 +++++++++++++++ .../dotnet/scripts/Dockerfile | 28 ++ .../dotnet/scripts/configmap.yml | 16 + .../dotnet/scripts/deployment.yml | 96 +++++ .../dotnet/scripts/namespace.yml | 4 + .../dotnet/scripts/secret.yml | 13 + .../dotnet/scripts/service.yml | 16 + .../dotnet/scripts/statefulset.yml | 224 ++++++++++++ .../dotnet/src/.dockerignore | 2 + .../dotnet/src/Models/Activity.cs | 4 + .../dotnet/src/Pages/Delete.cshtml | 2 + .../dotnet/src/Pages/Delete.cshtml.cs | 22 ++ .../dotnet/src/Pages/Index.cshtml | 265 ++++++++++++++ .../dotnet/src/Pages/Index.cshtml.cs | 49 +++ .../dotnet/src/Pages/_ViewImports.cshtml | 4 + .../dotnet/src/Program.cs | 69 ++++ .../dotnet/src/Services/ActivityId.cs | 15 + .../dotnet/src/Services/IActivityStore.cs | 24 ++ .../src/Services/PostgresActivityStore.cs | 126 +++++++ .../dotnet/src/Services/PostgresOptions.cs | 28 ++ .../src/Services/SecretKeyXmlRepository.cs | 49 +++ .../dotnet/src/Services/StoreInitializer.cs | 33 ++ .../dotnet/src/VacationPlanner.csproj | 11 + .../dotnet/src/appsettings.json | 16 + .../dotnet/src/wwwroot/favicon.ico | Bin 0 -> 36791 bytes .../dotnet/src/wwwroot/style.css | 341 ++++++++++++++++++ 33 files changed, 2022 insertions(+) create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/README.md create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/images/architecture.png create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh create mode 100755 samples/web-app-in-cluster-postgresql/dotnet/scripts/01-deploy-resources.sh create mode 100755 samples/web-app-in-cluster-postgresql/dotnet/scripts/02-build-docker-image.sh create mode 100755 samples/web-app-in-cluster-postgresql/dotnet/scripts/03-run-docker-container.sh create mode 100755 samples/web-app-in-cluster-postgresql/dotnet/scripts/04-push-docker-image.sh create mode 100755 samples/web-app-in-cluster-postgresql/dotnet/scripts/05-deploy-app.sh create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/scripts/Dockerfile create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/scripts/configmap.yml create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/scripts/deployment.yml create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/scripts/namespace.yml create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/scripts/secret.yml create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/scripts/service.yml create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/scripts/statefulset.yml create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/.dockerignore create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Models/Activity.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Delete.cshtml create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Delete.cshtml.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Index.cshtml create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Pages/Index.cshtml.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Pages/_ViewImports.cshtml create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Program.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Services/ActivityId.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Services/IActivityStore.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Services/PostgresActivityStore.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Services/PostgresOptions.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Services/SecretKeyXmlRepository.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/Services/StoreInitializer.cs create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/VacationPlanner.csproj create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/appsettings.json create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/wwwroot/favicon.ico create mode 100644 samples/web-app-in-cluster-postgresql/dotnet/src/wwwroot/style.css 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..bc5c515 --- /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 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 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 -n vacation-planner-postgres --tail=50 +``` diff --git a/samples/web-app-in-cluster-postgresql/dotnet/images/architecture.png b/samples/web-app-in-cluster-postgresql/dotnet/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..57d91ac32dd6dd42e29b3c01705b6566913aab7f GIT binary patch literal 39396 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 CEG{Yl literal 0 HcmV?d00001 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..d896da8 --- /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-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-in-cluster-postgresql/dotnet/scripts/01-deploy-resources.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/01-deploy-resources.sh new file mode 100755 index 0000000..55b50b8 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/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), +# 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. +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)." 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/dotnet/scripts/03-run-docker-container.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/03-run-docker-container.sh new file mode 100755 index 0000000..f995b89 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/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/dotnet/scripts/04-push-docker-image.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/04-push-docker-image.sh new file mode 100755 index 0000000..a1b7518 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/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-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..1541d74 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/05-deploy-app.sh @@ -0,0 +1,282 @@ +#!/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 +SECRET_KEY=$(openssl rand -hex 32) + +# 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 - + +# 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/dotnet/scripts/configmap.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/configmap.yml new file mode 100644 index 0000000..6ca91d8 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/configmap.yml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vacation-planner-postgres-config + namespace: vacation-planner-postgres + labels: + app: vacation-planner-postgres +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/dotnet/scripts/deployment.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/deployment.yml new file mode 100644 index 0000000..1f33e69 --- /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 + namespace: vacation-planner-postgres + labels: + app: vacation-planner-postgres +spec: + replicas: 3 + selector: + matchLabels: + app: vacation-planner-postgres + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + minReadySeconds: 5 + template: + metadata: + labels: + app: vacation-planner-postgres + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: vacation-planner-postgres + image: .azurecr.io/vacation-planner-postgres-dotnet:v1 + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + env: + - name: PG_HOST + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: PG_HOST + - name: PG_PORT + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: PG_PORT + - name: PG_DATABASE + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: PG_DATABASE + - name: PG_USER + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: PG_USER + - name: LOGIN_NAME + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: LOGIN_NAME + - name: DEBUG + valueFrom: + configMapKeyRef: + name: vacation-planner-postgres-config + key: DEBUG + - name: PG_PASSWORD + valueFrom: + secretKeyRef: + name: vacation-planner-postgres-secrets + key: PG_PASSWORD + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: vacation-planner-postgres-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..275d4f9 --- /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 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..1c4775b --- /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-secrets + namespace: vacation-planner-postgres + labels: + app: vacation-planner-postgres +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..ec8e47f --- /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 + namespace: vacation-planner-postgres + labels: + app: vacation-planner-postgres +spec: + type: ClusterIP + selector: + app: vacation-planner-postgres + ports: + - name: http + protocol: TCP + port: 80 + targetPort: http diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/statefulset.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/statefulset.yml new file mode 100644 index 0000000..667aac6 --- /dev/null +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/statefulset.yml @@ -0,0 +1,224 @@ +apiVersion: v1 +kind: Secret +metadata: + name: pg-postgres-secret + namespace: vacation-planner-postgres +type: Opaque +stringData: + POSTGRES_PASSWORD: "SuperStrongPass123" + REPL_PASSWORD: "ReplStrongPass123" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: pg-postgres-scripts + namespace: vacation-planner-postgres +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" <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-in-cluster-postgresql/dotnet/src/wwwroot/favicon.ico b/samples/web-app-in-cluster-postgresql/dotnet/src/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5b1d5cf37d81c073dbacd66df3f69d9240359dda GIT binary patch literal 36791 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 D Date: Tue, 8 Sep 2026 18:08:52 +0200 Subject: [PATCH 13/20] Link the Python and the .NET version of every sample from the README The Samples table now points at both implementations, the intro explains that they behave identically and share the Kubernetes names (so a sample can be switched from one to the other in place), the layout tree shows the python/ and dotnet/ subfolders, and the prerequisites mention the optional .NET SDK, needed only to build outside Docker. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2db3130..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/python/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](samples/web-app-mysql-flexible-server/python/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](samples/web-app-postgresql-flexible-server/python/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](samples/web-app-in-cluster-postgresql/python/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](samples/web-app-cosmosdb-mongodb-api/python/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](samples/web-app-cosmosdb-nosql-api/python/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](samples/web-app-blob-storage/python/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](samples/web-app-file-storage/python/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](samples/web-app-managed-identity/python/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. | +| 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 From a6a45bb754b334d8711dba0d9cdd60ea2a1e490e Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Tue, 8 Sep 2026 19:31:18 +0200 Subject: [PATCH 14/20] Give each sample its own namespace and container image web-app-managed-identity used the namespace, Deployment, Service, ConfigMap, Secret and image of web-app-blob-storage, and web-app-in-cluster-postgresql used those of web-app-postgresql-flexible-server, so nine samples shared seven namespaces and seven image repositories. Two samples could not run at the same time, and whichever was pushed last overwrote the other's image in the registry under the same tag, so the first sample's pods then pulled the wrong app. Rename the second sample of each pair (the canonical one keeps its name): web-app-managed-identity vacation-planner-blob -> vacation-planner-identity web-app-in-cluster-postgresql vacation-planner-postgres -> vacation-planner-postgres-in-cluster in both the Python and the .NET version, covering the namespace, Deployment, Service, ConfigMap, Secret, ServiceAccount, Gateway/HTTPRoute/Issuer, StatefulSet references, image name and the README commands. Renaming the namespace also broke the workload identity of the managed-identity sample: the federated identity credential's subject embeds the namespace and the service account, and 05-deploy-app.sh only ever created the credential when it was missing, leaving a stale subject behind. It now compares the subject and recreates the credential when it no longer matches, so the rename is safe on a cluster that already ran the old version. Validated on the LocalStack AKS emulator: all four affected samples in both languages (3/3 pods Ready through /health, add/list/update/delete through a port-forward and from inside the cluster), both pairs running side by side at the same time from their own images, and a from-scratch deployment of managed-identity (namespace and credential deleted first) coming up with zero restarts. Co-Authored-By: Claude Opus 5 (1M context) --- .../dotnet/README.md | 4 +-- .../dotnet/scripts/00-variables.sh | 12 ++++---- .../dotnet/scripts/configmap.yml | 6 ++-- .../dotnet/scripts/deployment.yml | 30 +++++++++---------- .../dotnet/scripts/namespace.yml | 2 +- .../dotnet/scripts/secret.yml | 6 ++-- .../dotnet/scripts/service.yml | 8 ++--- .../dotnet/scripts/statefulset.yml | 12 ++++---- .../python/README.md | 4 +-- .../python/scripts/00-variables.sh | 12 ++++---- .../python/scripts/configmap.yml | 6 ++-- .../python/scripts/deployment.yml | 30 +++++++++---------- .../python/scripts/namespace.yml | 2 +- .../python/scripts/secret.yml | 6 ++-- .../python/scripts/service.yml | 8 ++--- .../python/scripts/statefulset.yml | 12 ++++---- .../web-app-managed-identity/dotnet/README.md | 4 +-- .../dotnet/scripts/00-variables.sh | 16 +++++----- .../dotnet/scripts/05-deploy-app.sh | 30 +++++++++++++++---- .../dotnet/scripts/configmap.yml | 6 ++-- .../dotnet/scripts/deployment.yml | 26 ++++++++-------- .../dotnet/scripts/gateway.yml | 6 ++-- .../dotnet/scripts/httproute.yml | 6 ++-- .../dotnet/scripts/issuer.yml | 12 ++++---- .../dotnet/scripts/namespace.yml | 2 +- .../dotnet/scripts/secret.yml | 6 ++-- .../dotnet/scripts/service.yml | 8 ++--- .../web-app-managed-identity/python/README.md | 4 +-- .../python/scripts/00-variables.sh | 16 +++++----- .../python/scripts/05-deploy-app.sh | 30 +++++++++++++++---- .../python/scripts/configmap.yml | 6 ++-- .../python/scripts/deployment.yml | 26 ++++++++-------- .../python/scripts/gateway.yml | 6 ++-- .../python/scripts/httproute.yml | 6 ++-- .../python/scripts/issuer.yml | 12 ++++---- .../python/scripts/namespace.yml | 2 +- .../python/scripts/secret.yml | 6 ++-- .../python/scripts/service.yml | 8 ++--- 38 files changed, 220 insertions(+), 184 deletions(-) diff --git a/samples/web-app-in-cluster-postgresql/dotnet/README.md b/samples/web-app-in-cluster-postgresql/dotnet/README.md index bc5c515..2013584 100644 --- a/samples/web-app-in-cluster-postgresql/dotnet/README.md +++ b/samples/web-app-in-cluster-postgresql/dotnet/README.md @@ -60,7 +60,7 @@ 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. @@ -76,5 +76,5 @@ curl http://localhost:8080/health 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 +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/dotnet/scripts/00-variables.sh b/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh index d896da8..2a7562a 100644 --- a/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/00-variables.sh @@ -38,14 +38,14 @@ PG_LOCAL_PORT='5432' LOGIN_NAME='paolo' # Docker Image -IMAGE_NAME="vacation-planner-postgres-dotnet" +IMAGE_NAME="vacation-planner-postgres-in-cluster-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" +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/dotnet/scripts/configmap.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/configmap.yml index 6ca91d8..ea3bf53 100644 --- a/samples/web-app-in-cluster-postgresql/dotnet/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 index 1f33e69..3f3164e 100644 --- a/samples/web-app-in-cluster-postgresql/dotnet/scripts/deployment.yml +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/deployment.yml @@ -1,15 +1,15 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: vacation-planner-postgres - namespace: vacation-planner-postgres + name: vacation-planner-postgres-in-cluster + namespace: vacation-planner-postgres-in-cluster labels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster spec: replicas: 3 selector: matchLabels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster strategy: rollingUpdate: maxSurge: 1 @@ -19,13 +19,13 @@ spec: template: metadata: labels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster spec: nodeSelector: kubernetes.io/os: linux containers: - - name: vacation-planner-postgres - image: .azurecr.io/vacation-planner-postgres-dotnet:v1 + - name: vacation-planner-postgres-in-cluster + image: .azurecr.io/vacation-planner-postgres-in-cluster-dotnet:v1 imagePullPolicy: Always ports: - name: http @@ -34,42 +34,42 @@ spec: - name: PG_HOST valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: PG_HOST - name: PG_PORT valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: PG_PORT - name: PG_DATABASE valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: PG_DATABASE - name: PG_USER valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: PG_USER - name: LOGIN_NAME valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: LOGIN_NAME - name: DEBUG valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: DEBUG - name: PG_PASSWORD valueFrom: secretKeyRef: - name: vacation-planner-postgres-secrets + name: vacation-planner-postgres-in-cluster-secrets key: PG_PASSWORD - name: SECRET_KEY valueFrom: secretKeyRef: - name: vacation-planner-postgres-secrets + name: vacation-planner-postgres-in-cluster-secrets key: SECRET_KEY resources: requests: diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/namespace.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/namespace.yml index 275d4f9..84d1691 100644 --- a/samples/web-app-in-cluster-postgresql/dotnet/scripts/namespace.yml +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/namespace.yml @@ -1,4 +1,4 @@ kind: Namespace apiVersion: v1 metadata: - name: vacation-planner-postgres + 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 index 1c4775b..d2662d2 100644 --- a/samples/web-app-in-cluster-postgresql/dotnet/scripts/secret.yml +++ b/samples/web-app-in-cluster-postgresql/dotnet/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 ASP.NET Core Data Protection diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/service.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/service.yml index ec8e47f..dbebebe 100644 --- a/samples/web-app-in-cluster-postgresql/dotnet/scripts/service.yml +++ b/samples/web-app-in-cluster-postgresql/dotnet/scripts/service.yml @@ -1,14 +1,14 @@ apiVersion: v1 kind: Service metadata: - name: vacation-planner-postgres - namespace: vacation-planner-postgres + name: vacation-planner-postgres-in-cluster + namespace: vacation-planner-postgres-in-cluster labels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster spec: type: ClusterIP selector: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster ports: - name: http protocol: TCP diff --git a/samples/web-app-in-cluster-postgresql/dotnet/scripts/statefulset.yml b/samples/web-app-in-cluster-postgresql/dotnet/scripts/statefulset.yml index 667aac6..e2b8db0 100644 --- a/samples/web-app-in-cluster-postgresql/dotnet/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/python/README.md b/samples/web-app-in-cluster-postgresql/python/README.md index de0ea5f..4525900 100644 --- a/samples/web-app-in-cluster-postgresql/python/README.md +++ b/samples/web-app-in-cluster-postgresql/python/README.md @@ -60,7 +60,7 @@ 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. @@ -76,5 +76,5 @@ curl http://localhost:8080/health 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 +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/scripts/00-variables.sh b/samples/web-app-in-cluster-postgresql/python/scripts/00-variables.sh index 8899ac7..511c161 100644 --- a/samples/web-app-in-cluster-postgresql/python/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/configmap.yml b/samples/web-app-in-cluster-postgresql/python/scripts/configmap.yml index 6ca91d8..ea3bf53 100644 --- a/samples/web-app-in-cluster-postgresql/python/scripts/configmap.yml +++ b/samples/web-app-in-cluster-postgresql/python/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/python/scripts/deployment.yml b/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml index 6939a25..43ecbce 100644 --- a/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml +++ b/samples/web-app-in-cluster-postgresql/python/scripts/deployment.yml @@ -1,15 +1,15 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: vacation-planner-postgres - namespace: vacation-planner-postgres + name: vacation-planner-postgres-in-cluster + namespace: vacation-planner-postgres-in-cluster labels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster spec: replicas: 3 selector: matchLabels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster strategy: rollingUpdate: maxSurge: 1 @@ -19,13 +19,13 @@ spec: template: metadata: labels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster spec: nodeSelector: kubernetes.io/os: linux containers: - - name: vacation-planner-postgres - image: .azurecr.io/vacation-planner-postgres:v1 + - name: vacation-planner-postgres-in-cluster + image: .azurecr.io/vacation-planner-postgres-in-cluster:v1 imagePullPolicy: Always ports: - name: http @@ -34,42 +34,42 @@ spec: - name: PG_HOST valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: PG_HOST - name: PG_PORT valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: PG_PORT - name: PG_DATABASE valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: PG_DATABASE - name: PG_USER valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: PG_USER - name: LOGIN_NAME valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: LOGIN_NAME - name: DEBUG valueFrom: configMapKeyRef: - name: vacation-planner-postgres-config + name: vacation-planner-postgres-in-cluster-config key: DEBUG - name: PG_PASSWORD valueFrom: secretKeyRef: - name: vacation-planner-postgres-secrets + name: vacation-planner-postgres-in-cluster-secrets key: PG_PASSWORD - name: SECRET_KEY valueFrom: secretKeyRef: - name: vacation-planner-postgres-secrets + name: vacation-planner-postgres-in-cluster-secrets key: SECRET_KEY resources: requests: diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/namespace.yml b/samples/web-app-in-cluster-postgresql/python/scripts/namespace.yml index 275d4f9..84d1691 100644 --- a/samples/web-app-in-cluster-postgresql/python/scripts/namespace.yml +++ b/samples/web-app-in-cluster-postgresql/python/scripts/namespace.yml @@ -1,4 +1,4 @@ kind: Namespace apiVersion: v1 metadata: - name: vacation-planner-postgres + name: vacation-planner-postgres-in-cluster diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/secret.yml b/samples/web-app-in-cluster-postgresql/python/scripts/secret.yml index 8e05228..5bb515e 100644 --- a/samples/web-app-in-cluster-postgresql/python/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 index ec8e47f..dbebebe 100644 --- a/samples/web-app-in-cluster-postgresql/python/scripts/service.yml +++ b/samples/web-app-in-cluster-postgresql/python/scripts/service.yml @@ -1,14 +1,14 @@ apiVersion: v1 kind: Service metadata: - name: vacation-planner-postgres - namespace: vacation-planner-postgres + name: vacation-planner-postgres-in-cluster + namespace: vacation-planner-postgres-in-cluster labels: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster spec: type: ClusterIP selector: - app: vacation-planner-postgres + app: vacation-planner-postgres-in-cluster ports: - name: http protocol: TCP diff --git a/samples/web-app-in-cluster-postgresql/python/scripts/statefulset.yml b/samples/web-app-in-cluster-postgresql/python/scripts/statefulset.yml index 667aac6..e2b8db0 100644 --- a/samples/web-app-in-cluster-postgresql/python/scripts/statefulset.yml +++ b/samples/web-app-in-cluster-postgresql/python/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-managed-identity/dotnet/README.md b/samples/web-app-managed-identity/dotnet/README.md index 82116c2..9d960cc 100644 --- a/samples/web-app-managed-identity/dotnet/README.md +++ b/samples/web-app-managed-identity/dotnet/README.md @@ -61,7 +61,7 @@ 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. @@ -79,5 +79,5 @@ If you deployed the Gateway path (`DEPLOY_GATEWAY="true"`), the app is instead r 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 +kubectl logs deployment/vacation-planner-identity -n vacation-planner-identity --tail=50 ``` diff --git a/samples/web-app-managed-identity/dotnet/scripts/00-variables.sh b/samples/web-app-managed-identity/dotnet/scripts/00-variables.sh index 64e3499..9ebf9f5 100755 --- a/samples/web-app-managed-identity/dotnet/scripts/00-variables.sh +++ b/samples/web-app-managed-identity/dotnet/scripts/00-variables.sh @@ -25,17 +25,17 @@ STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" CONTAINER_NAME='activities' # Docker Image -IMAGE_NAME="vacation-planner-blob-dotnet" +IMAGE_NAME="vacation-planner-identity-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" +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/dotnet/scripts/05-deploy-app.sh b/samples/web-app-managed-identity/dotnet/scripts/05-deploy-app.sh index 24633fd..a4cb4c1 100755 --- a/samples/web-app-managed-identity/dotnet/scripts/05-deploy-app.sh +++ b/samples/web-app-managed-identity/dotnet/scripts/05-deploy-app.sh @@ -92,15 +92,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 [[ $? != 0 ]]; then +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 + + 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 @@ -124,7 +142,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" @@ -133,7 +151,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 SECRET_KEY diff --git a/samples/web-app-managed-identity/dotnet/scripts/configmap.yml b/samples/web-app-managed-identity/dotnet/scripts/configmap.yml index 5f40ea9..4ba042e 100644 --- a/samples/web-app-managed-identity/dotnet/scripts/configmap.yml +++ b/samples/web-app-managed-identity/dotnet/scripts/configmap.yml @@ -1,10 +1,10 @@ apiVersion: v1 kind: ConfigMap metadata: - name: vacation-planner-blob-config - namespace: vacation-planner-blob + name: vacation-planner-identity-config + namespace: vacation-planner-identity labels: - app: vacation-planner-blob + app: vacation-planner-identity data: CONTAINER_NAME: "activities" AZURE_CLIENT_ID: "" diff --git a/samples/web-app-managed-identity/dotnet/scripts/deployment.yml b/samples/web-app-managed-identity/dotnet/scripts/deployment.yml index aab24d0..fb1f86f 100644 --- a/samples/web-app-managed-identity/dotnet/scripts/deployment.yml +++ b/samples/web-app-managed-identity/dotnet/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-dotnet:v1 + - name: vacation-planner-identity + image: .azurecr.io/vacation-planner-identity-dotnet: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: diff --git a/samples/web-app-managed-identity/dotnet/scripts/gateway.yml b/samples/web-app-managed-identity/dotnet/scripts/gateway.yml index 8cf9697..b2225ae 100644 --- a/samples/web-app-managed-identity/dotnet/scripts/gateway.yml +++ b/samples/web-app-managed-identity/dotnet/scripts/gateway.yml @@ -3,8 +3,8 @@ kind: Gateway metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-1.1.0 - cert-manager.io/issuer: vacation-planner-blob-issuer - name: vacation-planner-blob + cert-manager.io/issuer: vacation-planner-identity-issuer + name: vacation-planner-identity spec: gatewayClassName: nginx listeners: @@ -27,4 +27,4 @@ spec: certificateRefs: - group: "" kind: Secret - name: vacation-planner-blob-tls-secret \ No newline at end of file + name: vacation-planner-identity-tls-secret \ No newline at end of file diff --git a/samples/web-app-managed-identity/dotnet/scripts/httproute.yml b/samples/web-app-managed-identity/dotnet/scripts/httproute.yml index b2d7a36..8792ed5 100644 --- a/samples/web-app-managed-identity/dotnet/scripts/httproute.yml +++ b/samples/web-app-managed-identity/dotnet/scripts/httproute.yml @@ -3,17 +3,17 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-1.1.0 - name: vacation-planner-blob + name: vacation-planner-identity spec: hostnames: - planner.local.babosbird.com parentRefs: - - name: vacation-planner-blob + - name: vacation-planner-identity port: 443 sectionName: https rules: - backendRefs: - - name: vacation-planner-blob + - name: vacation-planner-identity port: 80 matches: - path: diff --git a/samples/web-app-managed-identity/dotnet/scripts/issuer.yml b/samples/web-app-managed-identity/dotnet/scripts/issuer.yml index 2d200b1..1007d10 100644 --- a/samples/web-app-managed-identity/dotnet/scripts/issuer.yml +++ b/samples/web-app-managed-identity/dotnet/scripts/issuer.yml @@ -1,24 +1,24 @@ apiVersion: cert-manager.io/v1 kind: Issuer metadata: - name: vacation-planner-blob-issuer - namespace: vacation-planner-blob + name: vacation-planner-identity-issuer + namespace: vacation-planner-identity spec: acme: # Registration address for the Let's Encrypt account: expiry notices go here email: paolo.salvatori@localstack.cloud privateKeySecretRef: - name: vacation-planner-blob-issuer + name: vacation-planner-identity-issuer server: https://acme-v02.api.letsencrypt.org/directory solvers: - http01: gatewayHTTPRoute: parentRefs: - - name: vacation-planner-blob - namespace: vacation-planner-blob + - name: vacation-planner-identity + namespace: vacation-planner-identity kind: Gateway group: gateway.networking.k8s.io - sectionName: vacation-planner-blob-http + sectionName: vacation-planner-identity-http podTemplate: spec: nodeSelector: diff --git a/samples/web-app-managed-identity/dotnet/scripts/namespace.yml b/samples/web-app-managed-identity/dotnet/scripts/namespace.yml index 9e8b556..8c604ef 100644 --- a/samples/web-app-managed-identity/dotnet/scripts/namespace.yml +++ b/samples/web-app-managed-identity/dotnet/scripts/namespace.yml @@ -1,4 +1,4 @@ kind: Namespace apiVersion: v1 metadata: - name: vacation-planner-blob + name: vacation-planner-identity diff --git a/samples/web-app-managed-identity/dotnet/scripts/secret.yml b/samples/web-app-managed-identity/dotnet/scripts/secret.yml index 6e45b31..9c94609 100644 --- a/samples/web-app-managed-identity/dotnet/scripts/secret.yml +++ b/samples/web-app-managed-identity/dotnet/scripts/secret.yml @@ -1,10 +1,10 @@ apiVersion: v1 kind: Secret metadata: - name: vacation-planner-blob-secrets - namespace: vacation-planner-blob + name: vacation-planner-identity-secrets + namespace: vacation-planner-identity labels: - app: vacation-planner-blob + app: vacation-planner-identity type: Opaque data: AZURE_STORAGE_ACCOUNT_CONNECTION_STRING: "" diff --git a/samples/web-app-managed-identity/dotnet/scripts/service.yml b/samples/web-app-managed-identity/dotnet/scripts/service.yml index a757f13..c5f4783 100644 --- a/samples/web-app-managed-identity/dotnet/scripts/service.yml +++ b/samples/web-app-managed-identity/dotnet/scripts/service.yml @@ -1,14 +1,14 @@ apiVersion: v1 kind: Service 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: type: ClusterIP selector: - app: vacation-planner-blob + app: vacation-planner-identity ports: - name: http protocol: TCP diff --git a/samples/web-app-managed-identity/python/README.md b/samples/web-app-managed-identity/python/README.md index 117adfb..2bd9ea8 100644 --- a/samples/web-app-managed-identity/python/README.md +++ b/samples/web-app-managed-identity/python/README.md @@ -61,7 +61,7 @@ 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. @@ -79,5 +79,5 @@ If you deployed the Gateway path (`DEPLOY_GATEWAY="true"`), the app is instead r 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 +kubectl logs deployment/vacation-planner-identity -n vacation-planner-identity --tail=50 ``` diff --git a/samples/web-app-managed-identity/python/scripts/00-variables.sh b/samples/web-app-managed-identity/python/scripts/00-variables.sh index fdfae0c..e590636 100755 --- a/samples/web-app-managed-identity/python/scripts/00-variables.sh +++ b/samples/web-app-managed-identity/python/scripts/00-variables.sh @@ -25,17 +25,17 @@ STORAGE_ACCOUNT_NAME="${PREFIX}storage${SUFFIX}" CONTAINER_NAME='activities' # Docker Image -IMAGE_NAME="vacation-planner-blob" +IMAGE_NAME="vacation-planner-identity" 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" +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/05-deploy-app.sh b/samples/web-app-managed-identity/python/scripts/05-deploy-app.sh index 0bc1f70..afd0635 100755 --- a/samples/web-app-managed-identity/python/scripts/05-deploy-app.sh +++ b/samples/web-app-managed-identity/python/scripts/05-deploy-app.sh @@ -91,15 +91,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 [[ $? != 0 ]]; then +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 + + 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 +141,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 +150,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 diff --git a/samples/web-app-managed-identity/python/scripts/configmap.yml b/samples/web-app-managed-identity/python/scripts/configmap.yml index 5f40ea9..4ba042e 100644 --- a/samples/web-app-managed-identity/python/scripts/configmap.yml +++ b/samples/web-app-managed-identity/python/scripts/configmap.yml @@ -1,10 +1,10 @@ apiVersion: v1 kind: ConfigMap metadata: - name: vacation-planner-blob-config - namespace: vacation-planner-blob + name: vacation-planner-identity-config + namespace: vacation-planner-identity labels: - app: vacation-planner-blob + app: vacation-planner-identity data: CONTAINER_NAME: "activities" AZURE_CLIENT_ID: "" diff --git a/samples/web-app-managed-identity/python/scripts/deployment.yml b/samples/web-app-managed-identity/python/scripts/deployment.yml index fe48043..cfd13a4 100644 --- a/samples/web-app-managed-identity/python/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: diff --git a/samples/web-app-managed-identity/python/scripts/gateway.yml b/samples/web-app-managed-identity/python/scripts/gateway.yml index 8cf9697..b2225ae 100644 --- a/samples/web-app-managed-identity/python/scripts/gateway.yml +++ b/samples/web-app-managed-identity/python/scripts/gateway.yml @@ -3,8 +3,8 @@ kind: Gateway metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-1.1.0 - cert-manager.io/issuer: vacation-planner-blob-issuer - name: vacation-planner-blob + cert-manager.io/issuer: vacation-planner-identity-issuer + name: vacation-planner-identity spec: gatewayClassName: nginx listeners: @@ -27,4 +27,4 @@ spec: certificateRefs: - group: "" kind: Secret - name: vacation-planner-blob-tls-secret \ No newline at end of file + 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 index b2d7a36..8792ed5 100644 --- a/samples/web-app-managed-identity/python/scripts/httproute.yml +++ b/samples/web-app-managed-identity/python/scripts/httproute.yml @@ -3,17 +3,17 @@ kind: HTTPRoute metadata: annotations: gateway.networking.k8s.io/generator: ingress2gateway-1.1.0 - name: vacation-planner-blob + name: vacation-planner-identity spec: hostnames: - planner.local.babosbird.com parentRefs: - - name: vacation-planner-blob + - name: vacation-planner-identity port: 443 sectionName: https rules: - backendRefs: - - name: vacation-planner-blob + - name: vacation-planner-identity port: 80 matches: - path: diff --git a/samples/web-app-managed-identity/python/scripts/issuer.yml b/samples/web-app-managed-identity/python/scripts/issuer.yml index 2d200b1..1007d10 100644 --- a/samples/web-app-managed-identity/python/scripts/issuer.yml +++ b/samples/web-app-managed-identity/python/scripts/issuer.yml @@ -1,24 +1,24 @@ apiVersion: cert-manager.io/v1 kind: Issuer metadata: - name: vacation-planner-blob-issuer - namespace: vacation-planner-blob + name: vacation-planner-identity-issuer + namespace: vacation-planner-identity spec: acme: # Registration address for the Let's Encrypt account: expiry notices go here email: paolo.salvatori@localstack.cloud privateKeySecretRef: - name: vacation-planner-blob-issuer + name: vacation-planner-identity-issuer server: https://acme-v02.api.letsencrypt.org/directory solvers: - http01: gatewayHTTPRoute: parentRefs: - - name: vacation-planner-blob - namespace: vacation-planner-blob + - name: vacation-planner-identity + namespace: vacation-planner-identity kind: Gateway group: gateway.networking.k8s.io - sectionName: vacation-planner-blob-http + sectionName: vacation-planner-identity-http podTemplate: spec: nodeSelector: diff --git a/samples/web-app-managed-identity/python/scripts/namespace.yml b/samples/web-app-managed-identity/python/scripts/namespace.yml index 9e8b556..8c604ef 100644 --- a/samples/web-app-managed-identity/python/scripts/namespace.yml +++ b/samples/web-app-managed-identity/python/scripts/namespace.yml @@ -1,4 +1,4 @@ kind: Namespace apiVersion: v1 metadata: - name: vacation-planner-blob + 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 index 6e45b31..9c94609 100644 --- a/samples/web-app-managed-identity/python/scripts/secret.yml +++ b/samples/web-app-managed-identity/python/scripts/secret.yml @@ -1,10 +1,10 @@ apiVersion: v1 kind: Secret metadata: - name: vacation-planner-blob-secrets - namespace: vacation-planner-blob + name: vacation-planner-identity-secrets + namespace: vacation-planner-identity labels: - app: vacation-planner-blob + app: vacation-planner-identity type: Opaque data: AZURE_STORAGE_ACCOUNT_CONNECTION_STRING: "" diff --git a/samples/web-app-managed-identity/python/scripts/service.yml b/samples/web-app-managed-identity/python/scripts/service.yml index a757f13..c5f4783 100644 --- a/samples/web-app-managed-identity/python/scripts/service.yml +++ b/samples/web-app-managed-identity/python/scripts/service.yml @@ -1,14 +1,14 @@ apiVersion: v1 kind: Service 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: type: ClusterIP selector: - app: vacation-planner-blob + app: vacation-planner-identity ports: - name: http protocol: TCP From 9b42ef6f83965073c3c681cfc7622ea7f40b2e70 Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Wed, 9 Sep 2026 09:11:25 +0200 Subject: [PATCH 15/20] Delete activities by their store id in the Python samples Seven of the nine Python apps deleted the activity at a position in the rendered page (POST /delete/), resolved against a module-level list that each replica fills on its own GET. With three replicas the delete lands on a replica whose list is stale, so it deletes a different activity than the one the user clicked. Caught on web-app-in-cluster-postgresql: the cross-replica run left the activity it had asked to delete in the database and removed one of the seeded rows instead. Every delete now addresses the activity by its store id, which the page already carries in the row's data-id attribute: the blob or file name for the storage samples, the document id for Cosmos DB, the row id for PostgreSQL, MySQL and SQL Database. web-app-sql-database's /update/ route reads the current text from the database instead of the in-process list for the same reason. Two more bugs found while testing: - The blob name now arrives from a form field, so blob-storage and managed-identity check its shape (yyyy-MM-dd-HH-mm-ss-activity.txt) before a delete or an in-place update reaches the container, as file-storage does for the file share. - Deleting an activity that another replica had already deleted answered HTTP 500 in web-app-cosmosdb-nosql-api and logged an error while listing the share in web-app-file-storage. Both now treat "already gone" as done and converge. Co-Authored-By: Claude Opus 5 (1M context) --- .../web-app-blob-storage/python/src/app.py | 44 +++++++++++++++---- .../python/src/templates/index.html | 2 +- .../python/src/app.py | 18 +++++--- .../python/src/templates/index.html | 2 +- .../python/src/app.py | 15 +++++-- .../web-app-file-storage/python/src/app.py | 15 +++++-- .../python/src/app.py | 14 ++++-- .../python/src/templates/index.html | 2 +- .../python/src/app.py | 44 +++++++++++++++---- .../python/src/templates/index.html | 2 +- .../python/src/app.py | 14 ++++-- .../python/src/templates/index.html | 2 +- .../python/src/app.py | 14 ++++-- .../python/src/templates/index.html | 2 +- .../web-app-sql-database/python/src/app.py | 40 ++++++++++------- .../python/src/templates/index.html | 2 +- 16 files changed, 167 insertions(+), 65 deletions(-) diff --git a/samples/web-app-blob-storage/python/src/app.py b/samples/web-app-blob-storage/python/src/app.py index 7b7d833..93f11f5 100644 --- a/samples/web-app-blob-storage/python/src/app.py +++ b/samples/web-app-blob-storage/python/src/app.py @@ -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,12 +263,24 @@ 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') diff --git a/samples/web-app-blob-storage/python/src/templates/index.html b/samples/web-app-blob-storage/python/src/templates/index.html index 45a7be8..efb642d 100644 --- a/samples/web-app-blob-storage/python/src/templates/index.html +++ b/samples/web-app-blob-storage/python/src/templates/index.html @@ -70,7 +70,7 @@

🌴 Vacation Planner

-
+ - + - + - + - + - + - +