Skip to content

Add the web-app-app-configuration sample and create the Azure Bastion host in the cluster scripts - #31

Merged
paolosalvatori merged 4 commits into
mainfrom
web_app_app_configuration
Sep 16, 2026
Merged

paolosalvatori merged 4 commits into
mainfrom
web_app_app_configuration

Conversation

@paolosalvatori

@paolosalvatori paolosalvatori commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

What this adds

A new sample, web-app-app-configuration, in Python and .NET. It is 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 Azure Key Vault secrets, exposed through Key Vault references, so the store stays the single configuration source;
  • 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;
  • the pods consume both with envFrom.

The application keeps no Azure SDK and no credential of its own — requirements.txt has no azure-* package and VacationPlanner.csproj references only Npgsql. 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.yml and no secret.yml: the provider owns both objects. New manifests are serviceaccount.yml and appconfigurationprovider.yml.

An Azure Bastion host in both cluster scripts. scripts/01-system-assigned-managed-identity.sh and scripts/01-user-assigned-managed-identity.sh already carved out an AzureBastionSubnet and 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-$suffix and $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.

LocalStack emulator Azure
Python pass pass
.NET pass pass

In each cell: 0105 complete cleanly; the extension reports Succeeded with currentVersion 2.6.7; the provider reaches status.phase: Complete; the generated ConfigMap holds the six plain key-values and the generated Secret holds PG_USER, PG_PASSWORD and SECRET_KEY resolved from Key Vault references; GET /health returns {"status": "ok"}; the seeded activities render; and bumping the CONFIG_VERSION sentinel propagates to the ConfigMap within the refresh interval and reaches the pods after kubectl rollout restart. Re-running 01 changes nothing.

Two behaviours only the Azure runs exercise, both handled by the scripts: the Key Vault data plane refuses the first setSecret until the Key Vault Secrets Officer assignment propagates, and the provider's first reconcile fails with AADSTS70025 until Entra propagates the freshly created federated credential. On Azure PG_HOST is the bare *.postgres.database.azure.com with PG_PORT 5432; on the emulator it is the emulator host with the dynamic proxy port. The same host:port split in 01 covers both.

The cluster scripts, both verified on the emulator and on Azure.

  • Emulator, both scripts: bastion and public IP created, Succeeded in AzureBastionSubnet, skipped on a re-run.
  • Azure, user-assigned script: run against an existing resource group; skipped every pre-existing resource and created the bastion and its public IP, Succeeded, Standard SKU, in AzureBastionSubnet.
  • Azure, system-assigned script: run end to end into a fresh resource group. Every resource provisioned, cluster Succeeded with a SystemAssigned identity, OIDC issuer and workload identity enabled, system and user node pools Succeeded, bastion Succeeded. All Azure test resources were deleted afterwards.

The only error in either Azure run is the scripts' final statement, a kubectl get storageclass display 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

  • The sample's names are overridable from the environment (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.
  • Every App Configuration key is flat and colon-free on purpose: a key such as Settings:Color is not a legal ConfigMap or Secret data key, on Azure as much as on the emulator. Both READMEs explain this and point at the configMapData JSON escape hatch.
  • replicaDiscoveryEnabled: false in the custom resource: the store has no geo-replicas and the emulator's in-cluster DNS serves no SRV records for the discovery lookups.
  • No --version is passed to az k8s-extension create: the Azure CLI refuses it unless the auto-upgrade mode is none, and both targets install their current release and report it as currentVersion.
  • web-app-postgresql-flexible-server and web-app-managed-identity are untouched.

🤖 Generated with Claude Code

paolosalvatori and others added 2 commits September 15, 2026 16:37
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>
Copilot AI lite review requested due to automatic review settings September 15, 2026 16:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@paolosalvatori
paolosalvatori requested a balanced review from Copilot September 15, 2026 16:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@paolosalvatori paolosalvatori left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / *.bats mapping; there is no shell/ 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, and yq, kubectl and psql, 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 a Failed first reconcile, are both right.
  • .../{python,dotnet}/scripts/Dockerfile and all five manifests: the /health probe 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 six Services/*.cs, Pages/*, appsettings.json, VacationPlanner.csproj, .dockerignore.
  • Vendored front-end assets (18 .css, 22 .map, 6 .js under python/src/static/bootstrap/), the two architecture.png and the two favicon.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_ID from az identity show --query principalId (01-deploy-resources.sh L770, L773), and CLIENT_ID is used only for the service account's azure.workload.identity/client-id annotation (05-deploy-app.sh L69). No grant on the client id, and no defensive double grant.
  • The Bastion prerequisites are all correct against the spec: AzureBastionSubnet by exact name, /24 where 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) and 05-deploy-app.sh (271 lines) are byte-identical between the python and dotnet flavours, 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: an AzureBastionSubnet reference and a Standard-SKU public IP. The az CLI sends sku: Standard explicitly when --sku is omitted, so there is no Basic/Standard divergence between the emulator and Azure arising from this script.

Comment thread scripts/01-system-assigned-managed-identity.sh
Comment thread scripts/01-system-assigned-managed-identity.sh
Comment thread scripts/01-user-assigned-managed-identity.sh
Comment thread scripts/01-user-assigned-managed-identity.sh
Comment thread samples/web-app-app-configuration/python/src/database.py Outdated
Comment thread samples/web-app-app-configuration/python/scripts/01-deploy-resources.sh Outdated
Comment thread samples/web-app-app-configuration/dotnet/scripts/01-deploy-resources.sh Outdated
Comment thread samples/web-app-app-configuration/dotnet/scripts/01-deploy-resources.sh Outdated
Comment thread samples/web-app-app-configuration/python/scripts/03-run-docker-container.sh Outdated
Comment thread samples/web-app-app-configuration/dotnet/scripts/03-run-docker-container.sh Outdated
paolosalvatori and others added 2 commits September 15, 2026 19:29
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>
@paolosalvatori
paolosalvatori merged commit c28c9c7 into main Sep 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants