Add the Azure Files sample and make the repository public-ready - #23
Merged
Conversation
Adds samples/web-app-file-storage, a Vacation Planner variant that stores each activity as a text file on an Azure Files share mounted by the Azure Files CSI driver. The app carries no Azure SDK: it calls open(), os.listdir() and os.remove() on a directory, and the driver turns that directory into an Azure file share. One set of scripts covers four combinations, chosen with two menus in 01-deploy-resources.sh and persisted to .deploy-options.env so 05-deploy-app.sh never asks twice (exporting PROVISIONING_MODE and FILE_SHARE_PROTOCOL skips the menus for an unattended run): * static SMB: a StorageV2 account, a PersistentVolume with uid/gid mount options, and a Secret with the account key the driver mounts with * static NFS: a premium FileStorage account restricted to the node subnets, with protocol: nfs and no Secret * dynamic SMB: a claim on the built-in azurefile-csi storage class * dynamic NFS: a claim on a storage class of its own, since none of the built-in ones sets protocol: nfs An init container prepares and seeds the share for all four: it chowns the mount point on NFS, where the share carries real POSIX ownership and fsGroup cannot help (the file.csi.azure.com CSIDriver declares fsGroupPolicy: ReadWriteOnceWithFSType and the volume is ReadWriteMany), and copies the sample activities that are missing. Both cluster provisioning scripts now check that the Azure Files CSI driver and the CSI snapshot controller are enabled, enable the Microsoft.Storage service endpoint on the node subnets that an NFS share requires, and print the cluster's storage profile and storage classes. Also renames web-app-storage-account to web-app-blob-storage, so the two blob-backed samples are told apart by what they store rather than by an ambiguous folder name, fixes the sample list in the root README (the MySQL sample was missing and PostgreSQL read "Azure Database DB for"), and removes a duplicate architecture.png committed under a malformed path in the PostgreSQL sample. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The local smoke test mounted its activities directory from /tmp, which the app then rejected as not writable: the container runs as uid 1000 and the directory has to be world-writable, which is a poor fit for a directory shared with every other user on the machine (and not shared with the Docker daemon on every setup). It now lives under the user's cache directory. Also records what the teardown leaves behind: the reclaim policy deletes the file share the CSI driver created, but not the storage account it created for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a new Vacation Planner sample that persists activities as plain files on an Azure Files share mounted into pods via the Azure Files CSI driver, and updates the repo to better cover/storage-driver scenarios (including provisioning-script checks for Azure Files CSI + snapshot controller). It also renames the blob-storage sample directory for clarity and updates top-level documentation accordingly.
Changes:
- Add
samples/web-app-file-storage, supporting SMB/NFS and static/dynamic provisioning paths, with Kubernetes manifests and deployment scripts. - Update AKS provisioning scripts to ensure Azure Files CSI + snapshot controller are enabled and to enable
Microsoft.Storageservice endpoints on node subnets. - Rename/introduce
samples/web-app-blob-storageand update README references.
Reviewed changes
Copilot reviewed 36 out of 139 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/01-user-assigned-managed-identity.sh | Ensures Microsoft.Storage endpoint on subnets; enables Azure Files CSI + snapshot controller; prints storage profile/classes |
| scripts/01-system-assigned-managed-identity.sh | Same as user-assigned script updates for storage endpoint + drivers + diagnostics |
| samples/web-app-managed-identity/README.md | Updates sample cross-reference to renamed blob sample path |
| samples/web-app-file-storage/src/app.py | Flask app persisting activities as files on a mounted share |
| samples/web-app-file-storage/src/gunicorn.conf.py | Gunicorn worker signal hooks to avoid noisy tracebacks |
| samples/web-app-file-storage/src/requirements.txt | Python dependencies for the file-storage app |
| samples/web-app-file-storage/src/templates/index.html | UI template (pod name header, add/edit/delete) |
| samples/web-app-file-storage/src/static/style.css | UI styling for the file-storage sample |
| samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.css | Vendor bootstrap reboot CSS asset |
| samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.min.css | Vendor bootstrap reboot CSS asset (minified) |
| samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css | Vendor bootstrap reboot CSS asset (RTL) |
| samples/web-app-file-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css | Vendor bootstrap reboot CSS asset (RTL minified) |
| samples/web-app-file-storage/scripts/00-variables.sh | Shared variables, including persisted deploy option file |
| samples/web-app-file-storage/scripts/01-deploy-resources.sh | Interactive/static-vs-dynamic and SMB-vs-NFS provisioning logic; creates required Azure resources |
| samples/web-app-file-storage/scripts/02-build-docker-image.sh | Builds the sample image |
| samples/web-app-file-storage/scripts/03-run-docker-container.sh | Local smoke test using host-mounted directory |
| samples/web-app-file-storage/scripts/04-push-docker-image.sh | Tags/pushes image to ACR |
| samples/web-app-file-storage/scripts/05-deploy-app.sh | Applies manifests with yq, selects static/dynamic + SMB/NFS paths, waits for rollout |
| samples/web-app-file-storage/scripts/Dockerfile | Builds runtime image pinned to uid/gid 1000 for share permissions |
| samples/web-app-file-storage/scripts/namespace.yml | Namespace manifest |
| samples/web-app-file-storage/scripts/configmap.yml | ConfigMap for mount path env var |
| samples/web-app-file-storage/scripts/seed-configmap.yml | Seed activities for init-container copy |
| samples/web-app-file-storage/scripts/secret.yml | Secret placeholder for Flask SECRET_KEY |
| samples/web-app-file-storage/scripts/storage-secret.yml | Secret placeholder for SMB account key (static SMB only) |
| samples/web-app-file-storage/scripts/storageclass-nfs.yml | StorageClass for dynamic NFS provisioning (protocol: nfs) |
| samples/web-app-file-storage/scripts/persistentvolumeclaim.yml | RWX PVC (static shape, rewritten for dynamic) |
| samples/web-app-file-storage/scripts/persistentvolume-smb.yml | Static SMB PV with mount options + secret ref |
| samples/web-app-file-storage/scripts/persistentvolume-nfs.yml | Static NFS PV with protocol attribute + mount options |
| samples/web-app-file-storage/scripts/deployment.yml | Deployment with init container that prepares/seeds the share |
| samples/web-app-file-storage/scripts/service.yml | ClusterIP Service for the app |
| samples/web-app-file-storage/README.md | Sample documentation covering 4-mode matrix + operational notes |
| samples/web-app-blob-storage/src/app.py | Blob-backed Flask app (renamed sample) |
| samples/web-app-blob-storage/src/gunicorn.conf.py | Gunicorn worker signal hooks |
| samples/web-app-blob-storage/src/requirements.txt | Python dependencies for blob sample |
| samples/web-app-blob-storage/src/templates/index.html | UI template for blob sample |
| samples/web-app-blob-storage/src/static/style.css | UI styling for blob sample |
| samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.css | Vendor bootstrap reboot CSS asset |
| samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.min.css | Vendor bootstrap reboot CSS asset (minified) |
| samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.css | Vendor bootstrap reboot CSS asset (RTL) |
| samples/web-app-blob-storage/src/static/bootstrap/css/bootstrap-reboot.rtl.min.css | Vendor bootstrap reboot CSS asset (RTL minified) |
| samples/web-app-blob-storage/scripts/00-variables.sh | Shared variables for blob sample scripts |
| samples/web-app-blob-storage/scripts/01-deploy-resources.sh | Creates RG/ACR/storage account/container and exports connection string |
| samples/web-app-blob-storage/scripts/02-build-docker-image.sh | Builds the blob sample image |
| samples/web-app-blob-storage/scripts/03-run-docker-container.sh | Local run wiring via env vars |
| samples/web-app-blob-storage/scripts/04-push-docker-image.sh | Tags/pushes blob sample image to ACR |
| samples/web-app-blob-storage/scripts/05-deploy-app.sh | Deploys blob sample manifests with yq |
| samples/web-app-blob-storage/scripts/Dockerfile | Builds blob sample runtime image |
| samples/web-app-blob-storage/scripts/namespace.yml | Namespace manifest |
| samples/web-app-blob-storage/scripts/configmap.yml | ConfigMap for container name |
| samples/web-app-blob-storage/scripts/secret.yml | Secret for connection string + Flask SECRET_KEY |
| samples/web-app-blob-storage/scripts/deployment.yml | Deployment wiring env vars/secrets |
| samples/web-app-blob-storage/scripts/service.yml | ClusterIP Service for blob sample |
| samples/web-app-blob-storage/README.md | Blob sample documentation under new directory name |
| README.md | Updates sample list/table to include MySQL + new Azure Files sample and rename blob sample |
| .gitignore | Ignores generated .deploy-options.env file |
Suppressed comments (4)
scripts/01-user-assigned-managed-identity.sh:703
- This
exitis reached after a failure path but does not specify a non-zero status, so the script may exit successfully despite the AKS update failing. Useexit 1for error termination.
scripts/01-system-assigned-managed-identity.sh:643 - This failure path uses
exitwithout an explicit non-zero status, which can make the script succeed even whenaz aks updatefails. Useexit 1.
samples/web-app-file-storage/src/app.py:159 - Same issue as
write_activity:delete_activityallows./..which can escape the activities directory and delete unintended files. Tighten validation to reject dot-paths and alternate separators.
samples/web-app-file-storage/src/app.py:172 delete_activityswallows most exceptions, so the handler can still remove the entry fromactivitiesand flash success even when the delete failed. Re-raise unexpected errors after logging (keeping FileNotFound as a harmless no-op if desired).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
New diagrams for the Azure Files sample and for the two blob-backed samples, which until now shared a single generic one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repository is about to be made public and had no LICENSE, no security policy and no CI at all. * LICENSE: MIT, matching the sibling localstack-samples/localstack-azure-samples repository. * SECURITY.md: what these samples are (demo code with throwaway demo credentials, security trade-offs chosen for clarity) and how to report a vulnerability privately through GitHub. Enabling private vulnerability reporting in the repository settings is a prerequisite for that link to work. * .github/workflows/lint_workflows.yml: actionlint and zizmor over the repository's own workflows. * .github/workflows/pre-commit.yml plus .pre-commit-config.yaml: gitleaks for secret scanning and zizmor, the same two hooks the sibling repository uses. All three are copied from that sibling repository, including its SHA-pinned action references. Verified locally: pre-commit run --all-files passes (gitleaks reports no hardcoded secrets across the repository, so no allowlist is needed) and actionlint accepts both workflows. shellcheck, markdownlint and terraform fmt hooks are deliberately left out: the ~60 shell scripts here are written in a house style that those tools flag in the hundreds, which would turn CI red on day one. Bringing that corpus into line is separate work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repository never said how to point the Azure CLI at the emulator: the root README only offered "point the Azure CLI at the emulator endpoint (typically http://localhost:4566)", which is not something a reader can act on. The wrapper that used to do it, azlocal, is deprecated in favour of `lstk az`. * Root README: a "Run against LocalStack" section with the lstk install options, `localstack start`, `lstk az start-interception` and `lstk az stop-interception`, plus lstk in the prerequisites. Links point at the Azure CLI interception docs, the lstk CLI docs and the lstk repository. * Every other README (9 samples, 13 tutorials): a one-line pointer to that section, with the relative path computed per file. All 22 links were resolved against the file system, and the anchor matches the heading. * tutorials/terraform/tags-labels-taints/providers.tf: the one remaining literal azlocal reference, in a comment about instance discovery, now names `lstk az start-interception`. Also unifies the author email on paolo.salvatori@localstack.cloud: the cert-manager issuer of the managed-identity sample registered its Let's Encrypt account with a personal gmail address. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six findings from the Copilot review of #23, all verified. Failure paths reported success. `exit` with no status exits with the status of the last command, which on these paths is the `echo` that just announced the failure. Both provisioning scripts now use `exit 1` on every failure path: the two spots the review flagged in each file, and the 21 and 18 pre-existing ones, which have the same defect. The only bare `exit` left in each is the menu's `"Quit")` branch, where exiting 0 is what is meant. The app reported successes that never happened. `write_activity` and `delete_activity` now return whether the share actually changed, and the routes only touch the in-memory list and flash success when it did. Against a read-only share the app now says "Failed to add the activity to the file share." instead of claiming success and showing a row that is not on the share. `delete_activity` separates a file that is already gone (success: another replica may have deleted it, and the UI should converge) from a missing activities directory (failure: the share is not mounted). Deleting by row position could delete the wrong activity. Three replicas share one file share and the list is reloaded on every request, so the position is not stable between rendering a page and submitting a delete from it. `POST /delete` now takes the activity's file name, which the template already carried in `data-id` and now posts as a hidden field. Activity names were only implicitly validated. `os.path.basename(name) != name` did reject every name with a separator, and `..` resolved to a directory that both `open(..., "w")` and `os.remove` refuse, so nothing could escape the share. It was still an implicit guard: `is_activity_name()` now requires the shape this app creates, which rejects the empty name, `.`, `..`, both separators, and any file the app did not write. The init container assumed the activities directory existed. It chowned and seeded it directly, so pointing `ACTIVITIES_DIR` at a subdirectory of the mount, which the app documents as supported, aborted the init container before the app could start. It now runs `mkdir -p` first. Verified by running the manifest's own init script against a root-owned share with `ACTIVITIES_DIR=/data/activities`: it creates the directory, chowns it and the seeded files to 1000:1000, and is idempotent on re-run; the same script without the `mkdir` fails with `chown: cannot access '/data/activities': No such file or directory` and exits 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Two things, both prerequisites for making this repository public.
A sample for Azure Files. LocalStack for Azure now installs the Azure Files CSI driver on an emulated AKS cluster (localstack/localstack-pro#8194), and nothing here exercised it: all eight Vacation Planner samples persist to a database, Cosmos DB or blob storage, and the repository had exactly one
PersistentVolumeClaimanywhere.Public-repo readiness. The repository had no license, no security policy and no CI at all, and it never actually said how to point the Azure CLI at the emulator: the README only offered "point the Azure CLI at the emulator endpoint (typically
http://localhost:4566)". The wrapper that used to do that,azlocal, is deprecated in favour oflstk az.Fixes SMF-841
Changes
A new sample,
samples/web-app-file-storage. The same Vacation Planner app, storing each activity as a text file on an Azure Files share mounted into every pod. Its point is that the app uses no Azure SDK at all: where the blob sibling importsazure-storage-bloband threads a connection string through a Secret, this one callsopen(),os.listdir()andos.remove()on a directory, and the CSI driver turns that directory into an Azure file share. Three replicas mount the same share (ReadWriteMany) and the page header names the pod that served it, so that is visible in the UI.One set of scripts covers four combinations, chosen with two menus in
01-deploy-resources.shand persisted toscripts/.deploy-options.envso05-deploy-app.shnever asks twice. ExportingPROVISIONING_MODEandFILE_SHARE_PROTOCOLskips the menus for an unattended run:StorageV2account and share created by the script, aPersistentVolumewith the documenteduid=1000,gid=1000mount options, and a Secret with the account key (the driver mounts SMB with the key only)azurefile-csiclassFileStorageaccount restricted to the node subnets,--https-only false,protocol: nfs, no Secretprotocol: nfsSeven of the eleven manifests are shared by all four combinations. The two
PersistentVolumefiles are deliberately separate rather than one file plusyqsurgery, becauseyq's|=creates a missing path and would graft a half-formednodeStageSecretRefonto the NFS volume.An init container prepares and seeds the share for all four combinations. On NFS it takes ownership of the mount point, because an NFS share carries real POSIX ownership, arrives owned by
root, and ignores theuid/gidmount options that solve this for SMB.securityContext.fsGroupis not an alternative: thefile.csi.azure.comCSIDriver declaresfsGroupPolicy: ReadWriteOnceWithFSTypeand this volume isReadWriteMany, so the kubelet skips the fsGroup ownership change entirely (verified on a live cluster, and called out in a manifest comment).Both cluster provisioning scripts now check that the Azure Files CSI driver and the CSI snapshot controller are enabled (
az aks createonly has--disable-*flags for them, so--enable-file-driverexists solely onaz aks update, and the update runs only when needed), enable theMicrosoft.Storageservice endpoint on the node subnets that an NFS share requires, and print the cluster's storage profile and storage classes when they finish. Every failure path now exits non-zero:exitwith no status exits with the status of the precedingecho, so these scripts used to announce a failure and then report success.lstk azinstead ofazlocal. The root README gains a "Run against LocalStack" section (installlstk, start the emulator,lstk az start-interception,lstk az stop-interception), and each of the other 22 READMEs gains a one-line pointer to it. The single literalazlocalreference, a comment in the Terraform tutorial'sproviders.tf, now nameslstk az.Public-repo files and CI. MIT
LICENSEandSECURITY.md, plus.github/workflows/lint_workflows.yml(actionlint + zizmor) and.github/workflows/pre-commit.ymlwith.pre-commit-config.yaml(gitleaks + zizmor), all matching the publiclocalstack-samples/localstack-azure-samplesrepository including its SHA-pinned actions.Renames
web-app-storage-accounttoweb-app-blob-storage, so the two blob-backed samples are told apart by what they store. Internal names are unchanged. New architecture diagrams for the blob, file and managed-identity samples. The root README sample list was missing the MySQL sample and read "Azure Database DB for PostgreSQL". A duplicatearchitecture.pngcommitted under a malformed path in the PostgreSQL sample is removed.Testing
All four combinations, end to end against the emulator, on one cluster created by
01-user-assigned-managed-identity.sh, each with01→02→04→05and a full teardown in between. Per combination: the claim isBound, 3/3 replicas ready with no restarts,/datais a realcifs vers=3.1.1ornfs4 vers=4.1mount carrying the documented options, the seeded activities render, an activity added over HTTP through one pod lands on the share as a file, and both other replicas serve it. Static SMB is cross-checked withaz storage file list, which sees the same files over the file data plane, and both NFS combinations show/dataas1000:1000thanks to the init container.The ACR pull path works: the k3d nodes resolve
localacrtest.azurecr.azure.localhost.localstack.cloud:4566through CoreDNS and validate it against the public*.azurecr.azure.localhost.localstack.cloudcertificate. No insecure-registry configuration and nok3d image importwere needed.Review fixes re-verified after the Copilot review: the four provisioning combinations again, the delete-by-name route in-cluster, a subdirectory
ACTIVITIES_DIR(which the init container now creates), the write and delete failure paths against a read-only share, and the rejected activity names.pre-commit run --all-files(gitleaks + zizmor) andactionlintare clean.Known emulator issue, not caused by this PR: on a cluster whose node pool is added after creation, which is what these scripts do, the emulator patches the new node's
/etc/resolv.confafter the kubelet has already startedcsi-azurefile-nodeon it. That pod ishostNetwork, so it keeps the pre-patch resolver and Azure Files mounts on that node fail withcould not resolve addressuntilkubectl rollout restart daemonset/csi-azurefile-node -n kube-system. Fixing it belongs in the emulator, and it is written up there; no workaround is baked into this sample.