Add the web-app-app-configuration sample and create the Azure Bastion host in the cluster scripts - #31
Conversation
A permutation of web-app-postgresql-flexible-server that changes only where the application reads its configuration from. The non-secret connection settings are plain key-values in an Azure App Configuration store, the credentials are Key Vault secrets exposed through Key Vault references, and the Azure App Configuration Kubernetes Provider — installed as the Microsoft.AppConfiguration cluster extension — reads the store with Microsoft Entra Workload ID and materialises a ConfigMap and a Secret that the pods consume with envFrom. The application keeps no Azure SDK and no credential of its own: the provider resolves everything before the pod starts. The only source change against the sample it derives from is one startup log line for the refresh sentinel. Both languages were validated end to end against the LocalStack Azure emulator and against real Azure: the extension installs, the provider reconciles, the generated Secret resolves its Key Vault references, and bumping the sentinel propagates to the ConfigMap and reaches the pods after a rollout restart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…et for Both cluster scripts carved out an AzureBastionSubnet and then never put anything in it, so the subnet sat empty and reaching a node still meant exposing SSH. They now also create the Standard SKU static public IP that Azure Bastion requires and the bastion host itself, named $prefix-bastion-ip-$suffix and $prefix-bastion-$suffix. Both steps follow the existing conventions of the scripts: check whether the resource exists, create it only when it does not, and fail loudly if the creation fails. Re-running either script leaves an existing bastion alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
paolosalvatori
left a comment
There was a problem hiding this comment.
Review
Head reviewed: 518b49d (merge base e514317). 100 changed files, of which 48 are vendored Bootstrap assets and binaries.
Verdict: Request changes. GitHub rejects REQUEST_CHANGES on your own PR, so this is submitted as a COMMENT; the verdict above is the substantive one.
Two HIGH findings: the Bastion blocks call az network bastion, which lives in a CLI extension the scripts never install, so both cluster-creation scripts abort before az aks create on a machine without it; and the Python sample's database.py leaks a PostgreSQL connection on every call, exhausting the 35 user connections of the configured Standard_B1ms server. Everything else is in very good shape, and the four MEDIUM findings all have one-line fixes.
1. Rules applied
Rules come from localstack/localstack-pro at origin/main (e05f7259d7). They are path-scoped to localstack-pro-azure/**, so in this repository they are applied as guidance rather than as a hard gate.
localstack-pro-azure/CLAUDE.md.claude/rules/azure/common/:coding-style.md,hooks.md,patterns.md,security.md,testing.md.claude/rules/azure/python/:cloud-pipeline.md,coding-style.md,hooks.md,patterns.md,security.md,testing.md.claude/rules/azure/scripts/:coding-style.md,hooks.md,patterns.md,security.md,testing.md(this is the*.sh/*.batsmapping; there is noshell/directory)
Not read, because the diff contains no matching files: bicep/, terraform/, emulator/. No expected rule file was missing or unreadable.
2. Existing comments
Two reviews from copilot-pull-request-reviewer[bot], both reading "Copilot encountered an error and was unable to review this pull request." No inline review comments and no issue comments, so nothing here duplicates earlier feedback.
3. Findings
All six are posted inline. Index:
| Severity | File | Lines | Finding |
|---|---|---|---|
| HIGH | scripts/01-system-assigned-managed-identity.sh |
455 | az network bastion needs the bastion CLI extension, never installed |
| HIGH | scripts/01-user-assigned-managed-identity.sh |
456 | same |
| HIGH | .../python/src/database.py |
113-114 | psycopg2 connections opened per call and never closed |
| MEDIUM | scripts/01-system-assigned-managed-identity.sh |
503-504 | Bastion SKU unpinned, CLI default is Standard |
| MEDIUM | scripts/01-user-assigned-managed-identity.sh |
504-505 | same |
| MEDIUM | .../{python,dotnet}/scripts/01-deploy-resources.sh |
647-649 | secret_identifier's exit 1 runs in a subshell |
| MEDIUM | .../{python,dotnet}/scripts/01-deploy-resources.sh |
501-509 | seed rows hardcode paolo instead of $LOGIN_NAME |
| MEDIUM | .../{python,dotnet}/scripts/03-run-docker-container.sh |
37-44 | resolve_setting's exit 1 runs in a subshell |
4. Clean files
No material findings in:
README.md(root): the Bastion bullet and the new sample row are accurate, andyq,kubectlandpsql, which the new scripts rely on, are already in the prerequisites.samples/web-app-app-configuration/{python,dotnet}/README.md: accurate and correctly differentiated per flavour..../{python,dotnet}/scripts/00-variables.sh,02-build-docker-image.sh,04-push-docker-image.sh(the last is byte-identical to the sibling samples')..../{python,dotnet}/scripts/05-deploy-app.sh: the federated-credential subject check and recreate, and the non-terminal handling of aFailedfirst reconcile, are both right..../{python,dotnet}/scripts/Dockerfileand all five manifests: the/healthprobe contract on port 8080 is satisfied by both apps..../python/src/:app.py,gunicorn.conf.py,requirements.txt,templates/index.html..../dotnet/src/:Program.cs,Models/Activity.cs, all sixServices/*.cs,Pages/*,appsettings.json,VacationPlanner.csproj,.dockerignore.- Vendored front-end assets (18
.css, 22.map, 6.jsunderpython/src/static/bootstrap/), the twoarchitecture.pngand the twofavicon.ico: third-party or binary, not reviewed line by line.
Two things worth calling out as done right, because they are the usual failure points here:
- Identity handling matches the rule exactly. Role assignments use
PRINCIPAL_IDfromaz identity show --query principalId(01-deploy-resources.shL770, L773), andCLIENT_IDis used only for the service account'sazure.workload.identity/client-idannotation (05-deploy-app.shL69). No grant on the client id, and no defensive double grant. - The Bastion prerequisites are all correct against the spec:
AzureBastionSubnetby exact name,/24where the minimum is/26, and a Standard-SKU, Static, same-region public IP.
Out of scope
00-variables.sh hardcodes PG_ADMIN_PASSWORD and PG_USER_PASSWORD, and 01-deploy-resources.sh opens the PostgreSQL firewall to 0.0.0.0-255.255.255.255, which against a real subscription means a publicly reachable server with a password committed to git. This is not introduced here: web-app-postgresql-flexible-server and the other samples already use the identical values and the identical AllowAllIPs rule, and the cluster scripts hardcode windows_admin_password the same way. Worth a repository-wide decision rather than a change in this PR.
Notes
01-deploy-resources.sh(958 lines) and05-deploy-app.sh(271 lines) are byte-identical between thepythonanddotnetflavours, which is why three findings are posted twice. The existing samples do the same, so this is treated as repository convention rather than a finding.- Azure Bastion is emulated in localstack-pro (
services/network/apis/bastion_hosts.py), and the emulator validates exactly what the script provides: anAzureBastionSubnetreference and a Standard-SKU public IP. TheazCLI sendssku: Standardexplicitly when--skuis omitted, so there is no Basic/Standard divergence between the emulator and Azure arising from this script.
az network bastion lives in the bastion CLI extension rather than in azure-cli core, and neither script installed it. On a machine without the extension the existence check failed silently behind &>/dev/null, the create that follows failed the same way, and the script exited before reaching az aks create, which left every sample in this repository undeployable. Both scripts now add the extension the way this repository installs every other one, with --upgrade so a plain add cannot fail when it is already present. The bastion SKU was also left to the CLI default, which is Standard, so the tier followed whatever the operator's CLI version happened to default to. It is pinned to Basic: that covers the browser-based SSH and RDP the README describes this host is for, and Bastion bills hourly from the moment it is deployed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
database.py wrapped psycopg2 connections in `with`, which ends the transaction but leaves the connection open, so every call depended on the garbage collector to close its socket. A new _connection() context manager closes it explicitly in a finally block, which removes that dependency and also covers the case where a retained traceback keeps the frame, and with it the connection, alive. secret_identifier and resolve_setting both call exit 1 when they cannot read a value, but every call site is a command substitution, so the exit only left the subshell and the script carried on with an empty variable. The failure then surfaced several steps later pointing at the wrong resource. Every call site now propagates it with || exit 1. The seed rows repeated the literal paolo nine times even though 00-variables.sh defines LOGIN_NAME for exactly that value and its comment says the two must match. Changing LOGIN_NAME produced a working deployment with a silently empty activity list; the rows now use the variable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What this adds
A new sample,
web-app-app-configuration, in Python and .NET. It is a permutation ofweb-app-postgresql-flexible-serverthat changes only where the application reads its configuration from:Microsoft.AppConfigurationcluster extension, reads the store with Microsoft Entra Workload ID and materialises a ConfigMap and a Secret;envFrom.The application keeps no Azure SDK and no credential of its own —
requirements.txthas noazure-*package andVacationPlanner.csprojreferences onlyNpgsql. The provider resolves everything before the pod starts. The only source change against the sample it derives from is one startup log line for the refresh sentinel.There is no
configmap.ymland nosecret.yml: the provider owns both objects. New manifests areserviceaccount.ymlandappconfigurationprovider.yml.An Azure Bastion host in both cluster scripts.
scripts/01-system-assigned-managed-identity.shandscripts/01-user-assigned-managed-identity.shalready carved out anAzureBastionSubnetand then never put anything in it. They now also create the Standard SKU static public IP that Azure Bastion requires and the bastion host itself,$prefix-bastion-ip-$suffixand$prefix-bastion-$suffix, following the existing check-then-create conventions of those scripts.Fixes SMF-889
Testing
Everything below was run, not reasoned about.
The sample, all four cells pass.
In each cell:
01–05complete cleanly; the extension reportsSucceededwithcurrentVersion 2.6.7; the provider reachesstatus.phase: Complete; the generated ConfigMap holds the six plain key-values and the generated Secret holdsPG_USER,PG_PASSWORDandSECRET_KEYresolved from Key Vault references;GET /healthreturns{"status": "ok"}; the seeded activities render; and bumping theCONFIG_VERSIONsentinel propagates to the ConfigMap within the refresh interval and reaches the pods afterkubectl rollout restart. Re-running01changes nothing.Two behaviours only the Azure runs exercise, both handled by the scripts: the Key Vault data plane refuses the first
setSecretuntil theKey Vault Secrets Officerassignment propagates, and the provider's first reconcile fails withAADSTS70025until Entra propagates the freshly created federated credential. On AzurePG_HOSTis the bare*.postgres.database.azure.comwithPG_PORT5432; on the emulator it is the emulator host with the dynamic proxy port. The samehost:portsplit in01covers both.The cluster scripts, both verified on the emulator and on Azure.
SucceededinAzureBastionSubnet, skipped on a re-run.Succeeded, Standard SKU, inAzureBastionSubnet.Succeededwith aSystemAssignedidentity, OIDC issuer and workload identity enabled,systemandusernode poolsSucceeded, bastionSucceeded. All Azure test resources were deleted afterwards.The only error in either Azure run is the scripts' final statement, a
kubectl get storageclassdisplay that the service principal used for testing is not authorised for because those clusters use Azure RBAC for Kubernetes. It is cosmetic and unrelated to these changes.Notes for reviewers
PREFIX,SUFFIX,LOCATION,RESOURCE_GROUP_NAME,AKS_CLUSTER_NAME,ACR_NAME). The App Configuration store, the key vault, the container registry and the PostgreSQL server all take globally unique names, so a run against a real subscription needs its own suffix. The defaults match the cluster the repository scripts create, so nothing has to be set for the emulator.Settings:Coloris not a legal ConfigMap or Secret data key, on Azure as much as on the emulator. Both READMEs explain this and point at theconfigMapDataJSON escape hatch.replicaDiscoveryEnabled: falsein the custom resource: the store has no geo-replicas and the emulator's in-cluster DNS serves no SRV records for the discovery lookups.--versionis passed toaz k8s-extension create: the Azure CLI refuses it unless the auto-upgrade mode isnone, and both targets install their current release and report it ascurrentVersion.web-app-postgresql-flexible-serverandweb-app-managed-identityare untouched.🤖 Generated with Claude Code