diff --git a/.github/workflows/run-samples.yml b/.github/workflows/run-samples.yml index 05f9b4a..fe50b5f 100644 --- a/.github/workflows/run-samples.yml +++ b/.github/workflows/run-samples.yml @@ -140,8 +140,9 @@ jobs: # unixodbc-dev & libsnappy-dev: required for Python database drivers (pyodbc, pymongo). # default-mysql-client: provides the `mysql` CLI used by the MySQL flexible-server sample # to create the application user and seed the database schema in the local emulator. - # postgresql-client: provides the `psql` CLI used by the PostgreSQL flexible-server sample - # to create the application role and seed the database schema in the local emulator. + # postgresql-client: provides the `psql` CLI used by the PostgreSQL flexible-server and the + # App Configuration samples to create the application role and seed the database schema + # in the local emulator. run: | sudo apt-get update sudo apt-get install -y jq zip unixodbc-dev libsnappy-dev default-mysql-client postgresql-client diff --git a/README.md b/README.md index a82c529..71db00d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Each sample is a self-contained project with its own README, Azure CLI scripts a | Web App and Managed Identities ([Python](./samples/web-app-managed-identity/python/README.md), [.NET](./samples/web-app-managed-identity/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing each activity as a blob in an `activities` container and accessing Blob Storage through a user-assigned or system-assigned managed identity rather than connection strings. | | Web App and SQL Database ([Python](./samples/web-app-sql-database/python/README.md), [.NET](./samples/web-app-sql-database/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing activities in an Azure SQL Database. The connection string and the HTTPS certificate are read from Azure Key Vault, and an API endpoint verifies the Key Vault certificate. | | Web App and PostgreSQL Database ([Python](./samples/web-app-postgresql-flexible-server/python/README.md), [.NET](./samples/web-app-postgresql-flexible-server/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing activities in an Azure Database for PostgreSQL flexible server injected into a virtual network (delegated subnet and private DNS zone), with the database and application user created by the deploy scripts. | +| Web App, App Configuration and Key Vault ([Python](./samples/web-app-app-configuration/python/README.md), [.NET](./samples/web-app-app-configuration/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing activities in an Azure Database for PostgreSQL flexible server and reading its connection settings from an Azure App Configuration store whose secrets are Key Vault references, authenticating to both stores with a user-assigned managed identity through private endpoints. | | Web App and MySQL Database ([Python](./samples/web-app-mysql-flexible-server/python/README.md), [.NET](./samples/web-app-mysql-flexible-server/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App, storing activities in an Azure Database for MySQL flexible server injected into a virtual network, using TLS-only connections, with the database and application user created by the deploy scripts. | | Web App with Custom Docker Image ([Python](./samples/web-app-custom-image/python/README.md), [.NET](./samples/web-app-custom-image/dotnet/README.md)) | A web app that runs a custom container image built locally and pushed to Azure Container Registry; the web app pulls it with a managed identity (AcrPull) through a VNet-integrated network and reports its image and host name on `/api/status`. | | [ACI and Blob Storage (Python)](./samples/aci-blob-storage/python/README.md) | A containerized Flask web app on Azure Container Instances, with its image in Azure Container Registry, its secrets in Key Vault and its data in Blob Storage. | diff --git a/run-samples.sh b/run-samples.sh index 9009023..62c9f25 100755 --- a/run-samples.sh +++ b/run-samples.sh @@ -47,6 +47,8 @@ SAMPLES=( "samples/web-app-mysql-flexible-server/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-postgresql-flexible-server/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-postgresql-flexible-server/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-app-configuration/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-app-configuration/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-custom-image/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-custom-image/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/aci-blob-storage/python|bash scripts/deploy.sh|bash scripts/validate.sh" @@ -72,6 +74,8 @@ TERRAFORM_SAMPLES=( "samples/web-app-mysql-flexible-server/dotnet/terraform|bash deploy.sh" "samples/web-app-postgresql-flexible-server/python/terraform|bash deploy.sh" "samples/web-app-postgresql-flexible-server/dotnet/terraform|bash deploy.sh" + "samples/web-app-app-configuration/python/terraform|bash deploy.sh" + "samples/web-app-app-configuration/dotnet/terraform|bash deploy.sh" "samples/aci-blob-storage/python/terraform|bash deploy.sh" "samples/container-apps-blob-storage/python/terraform|bash deploy.sh" "samples/url-shortener/python/terraform|bash deploy.sh|bash ../scripts/validate.sh" @@ -94,6 +98,8 @@ BICEP_SAMPLES=( "samples/web-app-mysql-flexible-server/dotnet/bicep|bash deploy.sh" "samples/web-app-postgresql-flexible-server/python/bicep|bash deploy.sh" "samples/web-app-postgresql-flexible-server/dotnet/bicep|bash deploy.sh" + "samples/web-app-app-configuration/python/bicep|bash deploy.sh" + "samples/web-app-app-configuration/dotnet/bicep|bash deploy.sh" "samples/aci-blob-storage/python/bicep|bash deploy.sh" "samples/container-apps-blob-storage/python/bicep|bash deploy.sh" "samples/url-shortener/python/bicep|bash deploy.sh|bash ../scripts/validate.sh" diff --git a/samples/web-app-app-configuration/dotnet/README.md b/samples/web-app-app-configuration/dotnet/README.md new file mode 100644 index 0000000..ee4a152 --- /dev/null +++ b/samples/web-app-app-configuration/dotnet/README.md @@ -0,0 +1,186 @@ +# Azure Web App with Azure App Configuration and Azure Key Vault + +This sample demonstrates an ASP.NET Core Razor Pages single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan 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), reached through a [Private Endpoint](https://learn.microsoft.com/azure/private-link/private-endpoint-overview). What sets this permutation apart from the [PostgreSQL sample](../../web-app-postgresql-flexible-server/dotnet/README.md) it derives from is where the app reads its connection settings from: the host, port and database name live in an [Azure App Configuration](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview) store, the application role name and password live in [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) and reach the app through App Configuration [Key Vault references](https://learn.microsoft.com/en-us/azure/azure-app-configuration/use-key-vault-references-dotnet-core), and the app authenticates to both stores with a [user-assigned managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) authorized through Azure RBAC. No connection string, access key or database credential is stored in the Web App's app settings. + +## Architecture + +![Architecture Diagram](./images/architecture.png) + +The web app enables users to plan and manage vacation activities; all data is persisted in PostgreSQL. The solution is composed of the following Azure resources: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): A logical container scoping all resources in this sample. +2. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview): Hosts two subnets: + - *app-subnet*: Delegated to `Microsoft.Web/serverFarms` for regional VNet integration of the Web App. + - *pe-subnet*: Hosts the three Private Endpoints to the PostgreSQL flexible server, the App Configuration store and the Key Vault. +3. [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) `privatelink.postgres.database.azure.com`, `privatelink.azconfig.io` and `privatelink.vaultcore.azure.net`, each linked to the VNet with a `link-to-vnet` virtual network link. The DNS zone group (`default`) of each Private Endpoint registers the `A` record of its target, so the Web App resolves the three services to private IP addresses. +4. [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview): `-postgres-pe-` (group `postgresqlServer`), `-appconfig-pe-` (group `configurationStores`) and `-keyvault-pe-` (group `vault`). +5. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview): Deterministic outbound connectivity for the Web App subnet. +6. [Azure Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview): One NSG per subnet. +7. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview): Centralizes the diagnostic logs and metrics of every resource, the store and the vault included. +8. [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): Public-access server hosting the `PlannerDB` database. Burstable `Standard_B1ms`, version 16, 32 GiB storage, 7-day backup retention, HA disabled. A permissive firewall rule (`0.0.0.0` to `255.255.255.255`) lets the deploy machine run the post-create psql bootstrap; the Web App itself reaches the server through the Private Endpoint. +9. [PostgreSQL database](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers) `PlannerDB`: Created at provisioning time; the post-deploy psql step creates the `activities` table and seeds the demo rows. +10. [Azure App Configuration store](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview) `-appconfig-` (Standard tier): Holds the five connection settings as key-values, see [Configuration design](#configuration-design). Access keys stay enabled (the default) because the deployment scripts and templates write the key-values with them; the Web App never uses them. +11. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) `-keyvault-` with the [Azure RBAC permission model](https://learn.microsoft.com/en-us/azure/key-vault/general/rbac-guide): Holds the secrets `pg-user` and `pg-password` with the credentials of the PostgreSQL application role. +12. [User-assigned managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) `-identity-`, assigned to the Web App, with two [role assignments](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles): [App Configuration Data Reader](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/integration#app-configuration-data-reader) on the store and [Key Vault Secrets User](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-secrets-user) on the vault. +13. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans): The underlying compute tier that hosts the web application. +14. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview): Runs the ASP.NET Core *Vacation Planner* app with regional VNet integration into *app-subnet* and the user-assigned managed identity. Its app settings hold the store endpoint (`Endpoints__AppConfiguration`), the identity client id (`AZURE_CLIENT_ID`), `LOGIN_NAME`, `WEBSITES_PORT` and the Oryx build flags. No `PG_*` app setting exists: the app connects to PostgreSQL with the dedicated application role (`testuser`) it reads from the store, and the server-admin login is never used at runtime. +15. [App Service Source Control](https://learn.microsoft.com/en-us/rest/api/appservice/web-apps/create-or-update-source-control?view=rest-appservice-2024-11-01): *(Optional)* Configures continuous deployment from a public GitHub repository. + +The deploy scripts and templates follow the same pattern as the sibling [`web-app-postgresql-flexible-server`](../../web-app-postgresql-flexible-server/dotnet/) sample: after provisioning, they (i) connect as the server admin via the public endpoint and the firewall rule, (ii) create the application role `testuser` with its own password, (iii) grant minimum schema privileges on `PlannerDB`, (iv) create the `activities` table and (v) seed the sample rows. The difference is step (vi): the role name and password are not written onto the Web App's app settings; they are stored in Key Vault and referenced from the App Configuration store at provisioning time, and the app loads them from there. + +### Configuration design + +The application needs five PostgreSQL connection settings. They are stored by sensitivity: the three connection details are key-values in the App Configuration store, and the two credentials are secrets in the Key Vault, exposed to the application through Key Vault references in the same store. + +| Setting | Stored in | Key or secret name | Content type | +| ------------- | ------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------- | +| `PG_HOST` | App Configuration key-value | `PG_HOST` | none | +| `PG_PORT` | App Configuration key-value | `PG_PORT` | none | +| `PG_DATABASE` | App Configuration key-value | `PG_DATABASE` | none | +| `PG_USER` | Key Vault secret, referenced from the store | `PG_USER` referencing `pg-user` | `application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8` | +| `PG_PASSWORD` | Key Vault secret, referenced from the store | `PG_PASSWORD` referencing `pg-password` | `application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8` | + +**How the settings are stored.** The key-values are named like the environment variables the application reads, carry no label, and hold plain values: `PG_HOST` and `PG_PORT` come from the fully qualified domain name of the PostgreSQL flexible server, `PG_DATABASE` is `PlannerDB`. `PG_USER` and `PG_PASSWORD` are Key Vault references: key-values whose content type is `application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8` and whose value is the identifier of a secret, `{"uri":"https://.vault.azure.net/secrets/pg-user"}`. The identifier is versionless, so a reference always follows the latest version of its secret. The store never holds the secret values; they live only in the vault, as the secrets `pg-user` and `pg-password` (Key Vault secret names allow only alphanumerics and hyphens). Whoever reads the store sees where every setting comes from, and only an identity with read access to the vault can turn the two references into values. + +**How the application reads them.** The Web App receives two app settings: `Endpoints__AppConfiguration`, the store endpoint, and `AZURE_CLIENT_ID`, the client id of its user-assigned managed identity. At startup the application loads the `PG_*` keys with the [Azure App Configuration .NET provider](https://learn.microsoft.com/en-us/azure/azure-app-configuration/reference-dotnet-provider) (`Microsoft.Azure.AppConfiguration.AspNetCore`) and one `DefaultAzureCredential`: on App Service the credential is the user-assigned managed identity selected by `AZURE_CLIENT_ID`, on a developer machine it is the signed-in Azure CLI user. The provider reads the key-values from the store (the identity holds App Configuration Data Reader on it), recognizes the two Key Vault references by their content type and fetches the secrets from the vault with the same credential (the identity holds Key Vault Secrets User on it), so the application ends up with five plain settings. Both calls travel through the Private Endpoints of the store and the vault, resolved by the Private DNS Zones linked to the VNet. See `src/Services/AppConfigurationSettings.cs`: + +```csharp +var credential = new DefaultAzureCredential(); +var settings = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.Connect(new Uri(endpoint), credential) + .Select("PG_*") + .ConfigureKeyVault(keyVault => keyVault.SetCredential(credential)) + .ConfigureStartupOptions(startup => startup.Timeout = TimeSpan.FromSeconds(60)); + }) + .Build(); +builder.Configuration.AddConfiguration(settings); +``` + +The load is retried for a few minutes, so a role assignment that has not propagated yet produces log lines rather than a crash loop, and the application logs which keys it loaded and how many Key Vault references it resolved, never the values. The settings are read once at startup; a changed key-value or secret takes effect when the app restarts. The application reads the endpoint as `Endpoints:AppConfiguration`, which the .NET configuration system maps from the `Endpoints__AppConfiguration` app setting; the Python variant reads the same app setting, so both languages share one name. + +On Azure the store endpoint is `https://.azconfig.io` and the vault URI is `https://.vault.azure.net/`; on the emulator they are `https://.azure.localhost.localstack.cloud:4566` and `https://.vault.azure.localhost.localstack.cloud:4566`. The scripts and templates read both values back from the service and never assemble them from a name, so the same code runs on both targets. + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/) +- [Npgsql](https://www.npgsql.org/doc/) +- [Microsoft.Azure.AppConfiguration.AspNetCore](https://www.nuget.org/packages/Microsoft.Azure.AppConfiguration.AspNetCore) and [Azure.Identity](https://www.nuget.org/packages/Azure.Identity) (restored from `src/VacationPlanner.csproj`) +- [PostgreSQL client tools](https://www.postgresql.org/download/) (`psql`), required by the deploy scripts to create the application role and seed data +- [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep), if you plan to install the sample via Bicep +- [Terraform](https://developer.hashicorp.com/terraform/downloads), if you plan to install the sample via Terraform + +## Deployment + +Set up the Azure emulator using the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN`. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain yours. Pull and start the emulator: + +```bash +docker pull localstack/localstack-azure + +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure localstack start -d +localstack wait -t 60 + +# Route all Azure CLI calls to the LocalStack Azure emulator +lstk az start-interception +``` + +Deploy the application using one of these methods: + +- [Azure CLI Deployment](./scripts/README.md) +- [Bicep Deployment](./bicep/README.md) +- [Terraform Deployment](./terraform/README.md) + +All three variants provision the same topology: a VNet whose *pe-subnet* hosts three Private Endpoints, to a public-access PostgreSQL flexible server, to the App Configuration store and to the Key Vault, each with its Private DNS Zone linked to the VNet; the store seeded with the three plain key-values and the two Key Vault references; the vault seeded with the two secrets; the user-assigned managed identity with its two role assignments; and the Web App configured with the store endpoint and the identity client id only. + +The store and the vault have globally unique names: on Azure, export your own `SUFFIX` (or `PREFIX`) before running a variant. When you are done, delete the resource group and purge the soft-deleted store and vault (`az appconfig purge`, `az keyvault purge`) so that their names are released. + +> **Note** +> When you deploy the application to LocalStack for Azure for the first time, the initialization process pulls and builds Docker images (LocalStack itself plus the `postgres:18` backing container for the flexible-server emulator). This is a one-time operation; subsequent deployments are much faster. + +## Test + +1. Retrieve the port published and mapped to port 80 by the Docker container hosting the emulated Web App. +2. Open a web browser and navigate to `http://localhost:`. +3. If the deployment was successful, you will see the *Vacation Planner* UI with the seeded activities and can add, edit, and remove activities. + +![Vacation Planner UI](./images/vacation-planner.png) + +You can use the `scripts/call-web-app.sh` Bash script to call the web app from outside the emulator. The script demonstrates four call paths: + +1. **Through the LocalStack for Azure emulator** via the default hostname. +2. **Via localhost and host port** mapped to the container's port `80`. +3. **Via container IP address** on port `80`. +4. **Via the default hostname** `.azurewebsites.azure.localhost.localstack.cloud:4566`. + +To inspect the configuration the app runs with, list the key-values of the store with their content types (the two Key Vault references stand out), the secret names in the vault (never the values), and the app settings of the Web App, which must contain no `PG_*` entry: + +```bash +az appconfig kv list --name local-appconfig-test \ + --query "[].{Key:key,ContentType:contentType,Label:label}" --output table +az keyvault secret list --vault-name local-keyvault-test --query "[].name" --output tsv +az webapp config appsettings list --name local-webapp-test --resource-group local-rg \ + --query "[?starts_with(name, 'PG_')].name" --output tsv +``` + +The projection uses `--query` rather than `--fields`: `--fields` makes the CLI request only those fields from the service, and the CLI then fails to build a Key Vault reference whose value was not returned. `scripts/validate.sh` runs these checks, and fails if a `PG_*` app setting exists. The app itself reports its configuration source at startup; on the emulator read it with `docker logs` on the container whose name starts with `ls-local-webapp-test`: + +```text +Loaded 5 settings from App Configuration https://local-appconfig-test.azure.localhost.localstack.cloud:4566 (PG_DATABASE, PG_HOST, PG_PASSWORD, PG_PORT, PG_USER); 2 Key Vault references resolved (PG_PASSWORD, PG_USER) +PostgreSQL schema initialized +``` + +## Troubleshooting + +- **The app logs `App Configuration load failed ... 403` or `Forbidden` and restarts.** The identity's role assignments have not propagated yet (up to 10 minutes on Azure), or one of them is missing. Check `az role assignment list --assignee --all` for App Configuration Data Reader on the store and Key Vault Secrets User on the vault; the app keeps retrying and App Service restarts it until the load succeeds. +- **`az keyvault create` or `az appconfig create` fails because the name exists in a deleted state.** A previous run soft-deleted the resource. The scripts recover it automatically; to start from scratch, purge it: `az keyvault purge --name --location ` and `az appconfig purge --name --location --yes`. +- **`KeyVaultReferenceException` or `No key vault credential or secret resolver callback configured`.** The provider found a Key Vault reference but has no credential for Key Vault. The sample passes the same credential with `keyvault_credential=credential` (Python) or `ConfigureKeyVault(kv => kv.SetCredential(credential))` (.NET); keep that call. +- **`The setting Endpoints__AppConfiguration was not found.`** The Web App has no store endpoint app setting. Set it to the value of `az appconfig show --name --query endpoint --output tsv`. +- **The app stops with a message about a literal `@Microsoft.AppConfiguration(...)` value.** The Web App holds an App Service configuration reference as an app setting and the platform did not resolve it (LocalStack for Azure hands app settings to the container verbatim). This sample expects the plain store endpoint in `Endpoints__AppConfiguration` and loads the settings itself. +- **`az keyvault secret set` keeps failing with `Forbidden` on Azure.** The deploying principal has no Key Vault Secrets Officer assignment on the vault (the script could not resolve its object id) or the assignment is still propagating. Assign the role and re-run; the scripts are idempotent. + +## PostgreSQL Tooling + +You can use [pgAdmin](https://www.pgadmin.org/) to explore and manage the deployed database. Connect using: + +| Field | Value | +| -------- | --------------------------------------------------------------------------- | +| Host | `localhost` | +| Port | (see `docker ps` for the host-mapped port of the backing `postgres:18` container) | +| Database | `PlannerDB` | +| Username | `testuser` *(or `pgadmin` for admin operations)* | +| Password | `TestP@ssw0rd123` *(or `P@ssw0rd1234!` for the admin)* | + +The application role credentials are the values of the `pg-user` and `pg-password` secrets; read them with `az keyvault secret show --vault-name local-keyvault-test --name pg-password --query value --output tsv` when you override the defaults. Or use [psql](https://www.postgresql.org/docs/current/app-psql.html): + +```bash +PGPASSWORD='TestP@ssw0rd123' psql -h localhost -p -U testuser -d PlannerDB +PlannerDB=> SELECT id, username, activity, created_at FROM activities; +``` + +## References + +- [Azure Web Apps Documentation](https://learn.microsoft.com/en-us/azure/app-service/) +- [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/) +- [Quickstart: Deploy an ASP.NET web app to Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/quickstart-dotnetcore?pivots=development-environment-cli) +- [Npgsql documentation](https://www.npgsql.org/doc/) +- [Quickstart: Create an ASP.NET Core app with Azure App Configuration](https://learn.microsoft.com/en-us/azure/azure-app-configuration/quickstart-aspnet-core-app?tabs=entra-id) +- [Azure App Configuration .NET provider](https://learn.microsoft.com/en-us/azure/azure-app-configuration/reference-dotnet-provider?tabs=entra-id) +- [Tutorial: Use Key Vault references in an ASP.NET Core app](https://learn.microsoft.com/en-us/azure/azure-app-configuration/use-key-vault-references-dotnet-core) +- [Microsoft.Extensions.Configuration.AzureAppConfiguration namespace](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.configuration.azureappconfiguration) +- [Access Azure App Configuration using Microsoft Entra ID](https://learn.microsoft.com/en-us/azure/azure-app-configuration/concept-enable-rbac) +- [Use managed identities to access App Configuration](https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-integrate-azure-managed-service-identity) +- [Use App Configuration references for App Service and Azure Functions](https://learn.microsoft.com/en-us/azure/app-service/app-service-configuration-references) +- [Use Key Vault references as app settings in Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/app-service-key-vault-references) +- [Use private endpoints for Azure App Configuration](https://learn.microsoft.com/en-us/azure/azure-app-configuration/concept-private-endpoint) +- [Integrate Key Vault with Azure Private Link](https://learn.microsoft.com/en-us/azure/key-vault/general/private-link-service) +- [Provide access to Key Vault keys, certificates, and secrets with Azure RBAC](https://learn.microsoft.com/en-us/azure/key-vault/general/rbac-guide) +- [Azure App Configuration best practices](https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-best-practices) +- [Naming rules and restrictions for Azure resources](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules) +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-app-configuration/dotnet/bicep/README.md b/samples/web-app-app-configuration/dotnet/bicep/README.md new file mode 100644 index 0000000..e4c77e7 --- /dev/null +++ b/samples/web-app-app-configuration/dotnet/bicep/README.md @@ -0,0 +1,144 @@ +# Bicep Deployment + +This directory contains the Bicep template and a deployment script for provisioning the sample's Azure resources. For details about the sample application, see [Azure Web App with Azure App Configuration and Azure Key Vault](../README.md). + +## Prerequisites + +- [LocalStack for Azure](https://docs.localstack.cloud/azure/) +- [Visual Studio Code](https://code.visualstudio.com/) + [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep) +- [Docker](https://docs.docker.com/get-docker/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) + [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [PostgreSQL client (`psql`)](https://www.postgresql.org/download/) +- [`jq`](https://jqlang.org/) + +```bash +brew install localstack/tap/lstk # or: npm install -g @localstack/lstk +``` + +## Architecture Overview + +The [`deploy.sh`](deploy.sh) script creates the resource group while the Bicep modules create: + +1. [Azure Virtual Network](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview) (`virtual-network.bicep`) with two subnets: + - *app-subnet*: delegated to `Microsoft.Web/serverFarms` for the Web App's regional VNet integration. + - *pe-subnet*: hosts the three Private Endpoints. +2. [User-assigned managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) (`managed-identity.bicep`) `-identity-`. +3. [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview) (`postgresql-flexible-server.bicep`): public-access mode, Burstable `Standard_B1ms`, version 16, 32 GiB, HA disabled, the permissive firewall rule for the post-deploy psql bootstrap and the `PlannerDB` [database](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers) (UTF8 / `en_US.utf8`). +4. [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) (`key-vault.bicep`) `-keyvault-` with `enableRbacAuthorization: true`, soft delete (7 days) and no purge protection; the two `Microsoft.KeyVault/vaults/secrets` children `pg-user` and `pg-password` created from the `pgAppUser` and `@secure() pgAppPassword` parameters; the [Key Vault Secrets User](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-secrets-user) role assignment for the identity; an optional Key Vault Secrets Officer assignment for the deploying principal (`deployerPrincipalId`); and its diagnostic settings. The module outputs the versionless secret identifiers, assembled from the vault URI plus `secrets/` the way the Azure CLI variant does (the emulator does not return the `secretUri` property of the secret resource today, see the module comments). +5. [Azure App Configuration store](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview) (`app-configuration.bicep`) `-appconfig-`, Standard SKU, with `disableLocalAuth: false` and public network access enabled; the five `Microsoft.AppConfiguration/configurationStores/keyValues` children, created in a loop over the `keyValues` array `main.bicep` assembles from the PostgreSQL and Key Vault module outputs: `PG_HOST`, `PG_PORT` and `PG_DATABASE` as plain values and `PG_USER` and `PG_PASSWORD` with the content type `application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8` and the value `{"uri":""}`; the [App Configuration Data Reader](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/integration#app-configuration-data-reader) role assignment for the identity; and its diagnostic settings. +6. Three [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) (`private-dns-zone.bicep`), `privatelink.postgres.database.azure.com`, `privatelink.azconfig.io` and `privatelink.vaultcore.azure.net`, each linked to the VNet as `link-to-vnet`. +7. Three [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (`private-endpoint.bicep`), groups `postgresqlServer`, `configurationStores` and `vault`, each with a `default` DNS zone group. +8. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview) and one [Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview) per subnet (inside `virtual-network.bicep`). +9. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) (`log-analytics.bicep`). +10. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans) (`app-service-plan.bicep`). +11. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) (`web-app.bicep`) with regional VNet integration into *app-subnet*, the user-assigned identity, and the app settings `Endpoints__AppConfiguration` (the store endpoint) and `AZURE_CLIENT_ID` (the identity client id) next to `LOGIN_NAME`, `WEBSITES_PORT` and the Oryx build flags. The template sets **no** `PG_*` app setting: the app loads the five settings from the store. + +Every configurable value of a module is a parameter with a default (SKUs, network access, soft-delete retention, role definition ids, principal types, diagnostic categories, Private DNS zone link names, zone group names), and every value `main.bicep` passes is one of its own parameters with a default (secret names, key names, the Key Vault reference content type, the default PostgreSQL port, Private DNS zone names, private endpoint group ids, the subnet delegation). No resource definition carries a literal, so a deployment can be adapted by overriding parameters in `main.bicepparam` instead of editing the modules. + +Two remarks about the store's key-values: + +- The `keyValues` children are written through Azure Resource Manager, which authorizes them with the store's [Azure Resource Manager authentication mode](https://learn.microsoft.com/en-us/azure/azure-app-configuration/quickstart-deployment-overview). The store keeps the default `Local` mode and access keys enabled, so the deploying principal only needs Contributor. If you disable access keys, switch the store to `Pass-through` and grant the deploying principal App Configuration Data Owner, otherwise the key-value writes fail on Azure. +- The Key Vault reference values are built with string interpolation (`'{"uri":"${uri}"}'`) rather than `string({ uri: uri })`: the result is the same JSON on Azure, while the LocalStack emulator's template engine renders `string()` of an object as a Python dictionary today. + +## Configuration + +Update [`main.bicepparam`](main.bicepparam) before deploying. The defaults are: + +```bicep +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'dotnetcore' +param runtimeVersion = '10.0' +param databaseName = 'PlannerDB' +param username = 'paolo' + +param pgAdminLogin = 'pgadmin' +param pgAdminPassword = readEnvironmentVariable('PG_ADMIN_PASSWORD', '') +param pgVersion = '16' +param pgSkuTier = 'Burstable' +param pgSkuName = 'Standard_B1ms' +param pgStorageSizeGB = 32 +param pgBackupRetentionDays = 7 + +param pgAppUser = 'testuser' +param pgAppPassword = readEnvironmentVariable('PG_APP_PASSWORD', '') + +param appConfigurationSku = 'Standard' +param keyVaultSkuName = 'standard' +``` + +`pgAdminPassword` and `pgAppPassword` are the secure parameters that carry the two passwords; `deploy.sh` reads them from the `PG_ADMIN_PASSWORD` and `PG_APP_PASSWORD` env vars (with development defaults) and passes them on the command line together with `prefix`, `suffix`, `location`, `pgAdminLogin`, `pgAppUser`, `deployerPrincipalId` and `deployerPrincipalType`. Override for non-dev deployments. The store and vault names are globally unique on Azure, so export your own `SUFFIX`. + +## Deployment + +```bash +# default values +bash deploy.sh + +# your own suffix and secrets +SUFFIX='' \ +PG_ADMIN_PASSWORD='' \ +PG_APP_PASSWORD='' \ +bash deploy.sh + +# preview the changes of a re-deployment instead of validating the template +USE_WHAT_IF=1 bash deploy.sh +``` + +The script will: + +1. Ensure the resource group exists. +2. Recover a soft-deleted App Configuration store or Key Vault of the same name, so a re-deployment after a cleanup does not fail on a reserved name. +3. Resolve the object id of the deploying principal (skipped with a warning when Microsoft Graph cannot resolve it). +4. Validate `main.bicep` (or run a what-if with `USE_WHAT_IF=1`). +5. Deploy the template, passing the parameters above. +6. Check that the store holds the five expected key-values. +7. Wait for the `PlannerDB` database to accept connections, then use `psql` (connected via the public endpoint and the firewall rule) to create the `testuser` role, the `activities` table and the demo rows. The role name and password are the values already stored in Key Vault; nothing is written to the Web App's app settings. +8. Print the key-values with their content types and the Web App's app settings. +9. Zip the application source under `../src` and deploy it. + +`deploy.sh` accepts the same environment overrides as the Azure CLI variant: `PREFIX`, `SUFFIX`, `LOCATION`, `PG_ADMIN_USER`, `PG_ADMIN_PASSWORD`, `PG_APP_USER`, `PG_APP_PASSWORD`, `DEPLOY_APP`, plus `VALIDATE_TEMPLATE` and `USE_WHAT_IF`. + +### Alternative: seeding the key-values from deploy.sh + +The template creates the key-values itself, which keeps the whole topology in one deployment. If the `keyValues` children ever prove unreliable on one of the targets (for example because access keys are disabled and the store's Azure Resource Manager authentication mode is still `Local`), the same key-values can be seeded from `deploy.sh` after the deployment, the way the Azure CLI variant does: + +1. Pass an empty array as the `keyValues` parameter of the `appConfiguration` module in `main.bicep` (and drop the `appConfigurationKeyValues` variable). Never keep both: two owners of the same key-value drift on every deployment. +2. After the deployment, read the outputs `appConfigurationName`, `keyVaultUri`, `postgresFqdn` and `databaseName` and run, once per key, `az appconfig kv set --name --key PG_HOST --value --yes` (and `PG_PORT`, `PG_DATABASE`) and `az appconfig kv set-keyvault --name --key PG_USER --secret-identifier /secrets/pg-user --yes` (and `PG_PASSWORD`), probing each key with `az appconfig kv show` first so the step stays idempotent. +3. The seeded key-values must match the template's exactly (keys, no label, content types, values), so `validate.sh` and the app behave the same. The commands use the data plane: they need access keys enabled (the default) or `--auth-mode login` with App Configuration Data Owner. + +This alternative was not needed during the development of the sample: the template path works on the emulator and on Azure. + +## Verification + +```bash +PGPASSWORD='TestP@ssw0rd123' psql -h -p -U testuser -d PlannerDB \ + -c "SELECT id, username, activity, created_at FROM activities;" + +az appconfig kv list --name local-appconfig-test \ + --query "[].{Key:key,ContentType:contentType,Label:label}" --output table +az keyvault secret list --vault-name local-keyvault-test --query "[].name" --output tsv +az webapp config appsettings list --name local-webapp-test --resource-group local-rg \ + --query "[?starts_with(name, 'PG_')].name" --output tsv +bash ../scripts/validate.sh +``` + +`` is `5432` in real Azure, or the port suffix of the server's FQDN in LocalStack: + +```bash +az postgres flexible-server show \ + --resource-group local-rg --name local-pgflex-test \ + --query fullyQualifiedDomainName --output tsv +``` + +## Related Documentation + +- [Microsoft.AppConfiguration configurationStores/keyValues (Bicep)](https://learn.microsoft.com/en-us/azure/templates/microsoft.appconfiguration/configurationstores/keyvalues) +- [Microsoft.KeyVault vaults/secrets (Bicep)](https://learn.microsoft.com/en-us/azure/templates/microsoft.keyvault/vaults/secrets) +- [Deployment overview: roles and Azure Resource Manager authentication mode](https://learn.microsoft.com/en-us/azure/azure-app-configuration/quickstart-deployment-overview) +- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/) +- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/) +- [lstk GitHub repository](https://github.com/localstack/lstk) diff --git a/samples/web-app-app-configuration/dotnet/bicep/deploy.sh b/samples/web-app-app-configuration/dotnet/bicep/deploy.sh new file mode 100755 index 0000000..15bb236 --- /dev/null +++ b/samples/web-app-app-configuration/dotnet/bicep/deploy.sh @@ -0,0 +1,507 @@ +#!/bin/bash + +# Variables +# Every default below can be overridden through the environment, for example +# SUFFIX= PG_APP_PASSWORD= bash deploy.sh +# The App Configuration store and the Key Vault have globally unique names on Azure, so a run against a +# real subscription normally needs its own SUFFIX (or PREFIX). +PREFIX="${PREFIX:-local}" +SUFFIX="${SUFFIX:-test}" +LOCATION="${LOCATION:-westeurope}" +TEMPLATE="main.bicep" +PARAMETERS="main.bicepparam" +RESOURCE_GROUP_NAME="${PREFIX}-rg" +APP_CONFIG_NAME="${PREFIX}-appconfig-${SUFFIX}" +KEY_VAULT_NAME="${PREFIX}-keyvault-${SUFFIX}" +VALIDATE_TEMPLATE="${VALIDATE_TEMPLATE:-1}" +USE_WHAT_IF="${USE_WHAT_IF:-0}" +SUBSCRIPTION_NAME=$(az account show --query name --output tsv) +PG_ADMIN_USER="${PG_ADMIN_USER:-pgadmin}" +PG_ADMIN_PASSWORD="${PG_ADMIN_PASSWORD:-P@ssw0rd1234!}" +PG_APP_USER="${PG_APP_USER:-testuser}" +PG_APP_PASSWORD="${PG_APP_PASSWORD:-TestP@ssw0rd123}" +DEPLOY_APP="${DEPLOY_APP:-1}" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIPFILE="planner_website.zip" + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Validates if the resource group exists in the subscription, if not creates it +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]..." + + # Create the resource group + 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 + +# Recover the App Configuration store and the key vault if a previous run left them soft-deleted. +# Deleting them only soft-deletes them and their names stay reserved for the retention period, so the +# template deployment below would fail to create them again. Recovering restores them with their contents. +DELETED_APP_CONFIG_LOCATION=$(az appconfig list-deleted \ + --query "[?name=='$APP_CONFIG_NAME'].location | [0]" \ + --output tsv \ + --only-show-errors 2>/dev/null) + +if [[ -n $DELETED_APP_CONFIG_LOCATION ]]; then + echo "[$APP_CONFIG_NAME] App Configuration store exists in a soft-deleted state in [$DELETED_APP_CONFIG_LOCATION]" + echo "Recovering the [$APP_CONFIG_NAME] App Configuration store..." + + az appconfig recover \ + --name "$APP_CONFIG_NAME" \ + --location "$DELETED_APP_CONFIG_LOCATION" \ + --yes \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$APP_CONFIG_NAME] App Configuration store successfully recovered" + else + echo "Failed to recover the soft-deleted [$APP_CONFIG_NAME] App Configuration store" + echo "Purge it and re-run this script:" + echo " az appconfig purge --name $APP_CONFIG_NAME --location $DELETED_APP_CONFIG_LOCATION --yes" + exit 1 + fi +fi + +# The vault's own location, not $LOCATION: a soft-deleted vault stays in the region it was deleted in. +DELETED_KEY_VAULT_LOCATION=$(az keyvault list-deleted \ + --query "[?name=='$KEY_VAULT_NAME'].properties.location | [0]" \ + --output tsv \ + --only-show-errors 2>/dev/null) + +if [[ -n $DELETED_KEY_VAULT_LOCATION ]]; then + echo "[$KEY_VAULT_NAME] key vault exists in a soft-deleted state in [$DELETED_KEY_VAULT_LOCATION]" + echo "Recovering the [$KEY_VAULT_NAME] key vault..." + + az keyvault recover \ + --name "$KEY_VAULT_NAME" \ + --location "$DELETED_KEY_VAULT_LOCATION" \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[$KEY_VAULT_NAME] key vault successfully recovered" + else + echo "Failed to recover the soft-deleted [$KEY_VAULT_NAME] key vault" + echo "Purge it and re-run this script:" + echo " az keyvault purge --name $KEY_VAULT_NAME --location $DELETED_KEY_VAULT_LOCATION" + exit 1 + fi +fi + +# Resolve the object id of the deploying principal. The template grants it Key Vault Secrets Officer on the +# vault, the role a user or pipeline needs to manage the two secrets by hand after the deployment (the +# template itself writes them through Azure Resource Manager). The lookup goes through Microsoft Graph; +# when it fails the assignment is skipped and the deployment still succeeds. +ACCOUNT_USER_TYPE=$(az account show --query user.type --output tsv --only-show-errors) +ACCOUNT_USER_NAME=$(az account show --query user.name --output tsv --only-show-errors) + +if [[ $ACCOUNT_USER_TYPE == "user" ]]; then + DEPLOYER_PRINCIPAL_TYPE="User" + DEPLOYER_OBJECT_ID=$(az ad signed-in-user show --query id --output tsv --only-show-errors 2>/dev/null) +else + DEPLOYER_PRINCIPAL_TYPE="ServicePrincipal" + DEPLOYER_OBJECT_ID=$(az ad sp show --id "$ACCOUNT_USER_NAME" --query id --output tsv --only-show-errors 2>/dev/null) +fi + +# Microsoft Graph lookups need directory permissions a pipeline principal often lacks. Fall back to the oid +# claim of the CLI's own access token, which identifies the same principal without any Graph call. +if [[ -z $DEPLOYER_OBJECT_ID ]]; then + DEPLOYER_OBJECT_ID=$(az account get-access-token --query accessToken --output tsv --only-show-errors 2>/dev/null | + cut -d. -f2 | tr '_-' '/+' | awk '{ pad = length($0) % 4; if (pad == 2) $0 = $0 "=="; else if (pad == 3) $0 = $0 "="; print }' | + base64 -d 2>/dev/null | jq -r '.oid // empty' 2>/dev/null) +fi + +if [[ -n $DEPLOYER_OBJECT_ID ]]; then + echo "Deploying principal [$ACCOUNT_USER_NAME] ($DEPLOYER_PRINCIPAL_TYPE) has object id [$DEPLOYER_OBJECT_ID]" +else + echo "WARNING: could not resolve the object id of the deploying principal [$ACCOUNT_USER_NAME]; the Key Vault Secrets Officer assignment is skipped" + DEPLOYER_OBJECT_ID="" +fi + +# Validates the Bicep template +if [[ $VALIDATE_TEMPLATE == 1 ]]; then + if [[ $USE_WHAT_IF == 1 ]]; then + # Execute a deployment What-If operation at resource group scope. + echo "Previewing changes deployed by Bicep template [$TEMPLATE]..." + az deployment group what-if \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --template-file "$TEMPLATE" \ + --parameters "$PARAMETERS" \ + --parameters location="$LOCATION" \ + prefix="$PREFIX" \ + suffix="$SUFFIX" \ + pgAdminLogin="$PG_ADMIN_USER" \ + pgAdminPassword="$PG_ADMIN_PASSWORD" \ + pgAppUser="$PG_APP_USER" \ + pgAppPassword="$PG_APP_PASSWORD" \ + deployerPrincipalId="$DEPLOYER_OBJECT_ID" \ + deployerPrincipalType="$DEPLOYER_PRINCIPAL_TYPE" \ + --only-show-errors + + if [[ $? == 0 ]]; then + echo "Bicep template [$TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$TEMPLATE]" + exit 1 + fi + else + # Validate the Bicep template + echo "Validating Bicep template [$TEMPLATE]..." + output=$(az deployment group validate \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --template-file "$TEMPLATE" \ + --parameters "$PARAMETERS" \ + --parameters location="$LOCATION" \ + prefix="$PREFIX" \ + suffix="$SUFFIX" \ + pgAdminLogin="$PG_ADMIN_USER" \ + pgAdminPassword="$PG_ADMIN_PASSWORD" \ + pgAppUser="$PG_APP_USER" \ + pgAppPassword="$PG_APP_PASSWORD" \ + deployerPrincipalId="$DEPLOYER_OBJECT_ID" \ + deployerPrincipalType="$DEPLOYER_PRINCIPAL_TYPE" \ + --only-show-errors) + + if [[ $? == 0 ]]; then + echo "Bicep template [$TEMPLATE] validation succeeded" + else + echo "Failed to validate Bicep template [$TEMPLATE]" + echo "$output" + exit 1 + fi + fi +fi + +# Deploy the Bicep template +echo "Deploying Bicep template [$TEMPLATE]..." +if DEPLOYMENT_OUTPUTS=$(az deployment group create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors \ + --template-file "$TEMPLATE" \ + --parameters "$PARAMETERS" \ + --parameters location="$LOCATION" \ + prefix="$PREFIX" \ + suffix="$SUFFIX" \ + pgAdminLogin="$PG_ADMIN_USER" \ + pgAdminPassword="$PG_ADMIN_PASSWORD" \ + pgAppUser="$PG_APP_USER" \ + pgAppPassword="$PG_APP_PASSWORD" \ + deployerPrincipalId="$DEPLOYER_OBJECT_ID" \ + deployerPrincipalType="$DEPLOYER_PRINCIPAL_TYPE" \ + --query 'properties.outputs' -o json); then + # Extract only the JSON portion (everything from first { to the end) + DEPLOYMENT_JSON=$(echo "$DEPLOYMENT_OUTPUTS" | sed -n '/{/,$ p') + echo "Bicep template [$TEMPLATE] deployed successfully. Outputs:" + echo "$DEPLOYMENT_JSON" | jq . + WEB_APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.webAppName.value') + POSTGRES_SERVER_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.postgresServerName.value') + POSTGRES_FQDN_FULL=$(echo "$DEPLOYMENT_JSON" | jq -r '.postgresFqdn.value') + DATABASE_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.databaseName.value') + APP_CONFIG_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.appConfigurationName.value') + APP_CONFIG_ENDPOINT=$(echo "$DEPLOYMENT_JSON" | jq -r '.appConfigurationEndpoint.value') + KEY_VAULT_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.keyVaultName.value') + echo "Deployment details:" + echo "Web App Name: $WEB_APP_NAME" + echo "PostgreSQL Server Name: $POSTGRES_SERVER_NAME" + echo "PostgreSQL FQDN: $POSTGRES_FQDN_FULL" + echo "Database Name: $DATABASE_NAME" + echo "App Configuration Store: $APP_CONFIG_NAME ($APP_CONFIG_ENDPOINT)" + echo "Key Vault: $KEY_VAULT_NAME" +else + echo "Failed to deploy Bicep template [$TEMPLATE]" + exit 1 +fi + +if [[ -z "$WEB_APP_NAME" || -z "$POSTGRES_SERVER_NAME" || -z "$APP_CONFIG_NAME" || -z "$KEY_VAULT_NAME" ]]; then + echo "Web App Name, PostgreSQL Server Name, App Configuration Store or Key Vault name is empty. Exiting." + exit 1 +fi + +# Verify that the template seeded the five key-values. A failure inside a nested deployment would otherwise +# surface only later, as a web app that cannot load its configuration. +echo "Checking the key-values seeded into the [$APP_CONFIG_NAME] App Configuration store..." +SEEDED_KEYS=$(az appconfig kv list \ + --name "$APP_CONFIG_NAME" \ + --query "[?key=='PG_HOST' || key=='PG_PORT' || key=='PG_DATABASE' || key=='PG_USER' || key=='PG_PASSWORD'].key" \ + --output tsv \ + --only-show-errors | sort | tr '\n' ' ') + +if [[ $SEEDED_KEYS == "PG_DATABASE PG_HOST PG_PASSWORD PG_PORT PG_USER " ]]; then + echo "The [$APP_CONFIG_NAME] App Configuration store holds the five expected key-values" +else + echo "The [$APP_CONFIG_NAME] App Configuration store does not hold the five expected key-values (found: $SEEDED_KEYS)" + echo "Inspect the deployment operations: az deployment operation group list --resource-group $RESOURCE_GROUP_NAME --name appConfiguration" + 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 the port defaults to 5432. +POSTGRES_FQDN="${POSTGRES_FQDN_FULL%%:*}" +if [[ "$POSTGRES_FQDN_FULL" == *:* ]]; then + POSTGRES_PORT="${POSTGRES_FQDN_FULL##*:}" +else + POSTGRES_PORT=5432 +fi +echo "PostgreSQL host = $POSTGRES_FQDN, port = $POSTGRES_PORT" + +# Wait until the application database accepts connections. The database is created by the template, and the +# server can take a moment to expose it after the deployment returns. +echo "Waiting for the [$DATABASE_NAME] database on [$POSTGRES_FQDN:$POSTGRES_PORT] to accept connections..." +DATABASE_READY=0 +for attempt in $(seq 1 30); do + if PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + --quiet \ + -c "SELECT 1;" 1>/dev/null 2>&1; then + DATABASE_READY=1 + break + fi + + if [ "$attempt" -lt 30 ]; then + echo "Attempt $attempt of 30: the [$DATABASE_NAME] database is not ready yet; retrying in 5 seconds..." + sleep 5 + fi +done + +if [ $DATABASE_READY -eq 1 ]; then + echo "The [$DATABASE_NAME] database accepts connections" +else + echo "The [$DATABASE_NAME] database did not become ready in time" + exit 1 +fi + +# Create application role [$PG_APP_USER] on the PostgreSQL flexible server. Its name and password are +# already in Key Vault (pg-user, pg-password) and referenced from the App Configuration store; this step +# creates the role the web app logs in with. +echo "Creating login [$PG_APP_USER] on the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_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_APP_USER') THEN + CREATE ROLE \"$PG_APP_USER\" WITH LOGIN PASSWORD '$PG_APP_PASSWORD'; + END IF; +END +\$\$;" + +if [ $? -eq 0 ]; then + echo "Login [$PG_APP_USER] created successfully" +else + echo "Failed to create login [$PG_APP_USER]" + exit 1 +fi + +# Grant CONNECT on the database to [$PG_APP_USER] +echo "Granting CONNECT on [$DATABASE_NAME] to [$PG_APP_USER]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname=postgres \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT CONNECT ON DATABASE \"$DATABASE_NAME\" TO \"$PG_APP_USER\";" + +if [ $? -eq 0 ]; then + echo "CONNECT granted successfully to [$PG_APP_USER]" +else + echo "Failed to grant CONNECT to [$PG_APP_USER]" + exit 1 +fi + +# Grant schema privileges to [$PG_APP_USER] +echo "Granting schema privileges on [$DATABASE_NAME] to [$PG_APP_USER]..." +PGPASSWORD="$PG_ADMIN_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_ADMIN_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + --set=ON_ERROR_STOP=on \ + -c "GRANT USAGE, CREATE ON SCHEMA public TO \"$PG_APP_USER\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"$PG_APP_USER\"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"$PG_APP_USER\";" + +if [ $? -eq 0 ]; then + echo "Schema privileges granted successfully to [$PG_APP_USER]" +else + echo "Failed to grant schema privileges to [$PG_APP_USER]" + exit 1 +fi + +# Test connection +echo "Testing connection with user [$PG_APP_USER]..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + -c "SELECT current_user, current_database(), now();" + +if [ $? -eq 0 ]; then + echo "Connection test successful with user [$PG_APP_USER]" +else + echo "Connection test failed with user [$PG_APP_USER]" + exit 1 +fi + +# Create [activities] table +echo "Creating [activities] table in the [$DATABASE_NAME] database..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$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_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$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 "Sample data inserted successfully into [activities] table" +else + echo "Failed to insert sample data into [activities] table" + exit 1 +fi + +# Query sample data +echo "Querying sample data from [activities] table..." +PGPASSWORD="$PG_APP_PASSWORD" psql \ + --host="$POSTGRES_FQDN" \ + --port="$POSTGRES_PORT" \ + --username="$PG_APP_USER" \ + --dbname="$DATABASE_NAME" \ + --no-password \ + -c "SELECT * FROM activities;" + +if [ $? -eq 0 ]; then + echo "Sample data queried successfully from [activities] table" +else + echo "Failed to query sample data from [activities] table" + exit 1 +fi + +# Print the key-values of the App Configuration store: PG_USER and PG_PASSWORD carry the Key Vault +# reference content type, the other three are plain values. The projection is done client-side with +# --query rather than with --fields: --fields makes the CLI request only those fields from the service, +# and the CLI then fails to build a Key Vault reference whose value was not returned. +echo "Key-values in the [$APP_CONFIG_NAME] App Configuration store:" +az appconfig kv list \ + --name "$APP_CONFIG_NAME" \ + --query "[].{Key:key,ContentType:contentType,Label:label}" \ + --output table \ + --only-show-errors + +# Print the application settings of the web app: no PG_* setting, only the store endpoint and the +# identity client id next to the platform settings. +echo "Retrieving application settings for web app [$WEB_APP_NAME]..." +az webapp config appsettings list \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --query "[].{Name:name,Value:value}" \ + --output table \ + --only-show-errors + +if [[ $DEPLOY_APP == 1 ]]; then + # Change current directory to source folder + cd "../src" || exit + + # Remove any existing zip package of the web app + if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" + fi + + # Create the zip package of the web app + echo "Creating zip package of the web app..." + zip -r "$ZIPFILE" . -x "bin/*" "obj/*" "publish/*" "*.zip" + + # Deploy the web app + echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." + az webapp deploy \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + + if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] deployment started successfully." + else + echo "Failed to deploy web app [$WEB_APP_NAME]." + exit 1 + fi + + # Remove the zip package of the web app + if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" + fi + + cd "$CURRENT_DIR" || exit +else + echo "Skipping the deployment of the web app code (DEPLOY_APP=$DEPLOY_APP)" +fi + +# Print the list of resources in the resource group +echo "Listing resources in resource group [$RESOURCE_GROUP_NAME]..." +az resource list --resource-group "$RESOURCE_GROUP_NAME" --output table diff --git a/samples/web-app-app-configuration/dotnet/bicep/main.bicep b/samples/web-app-app-configuration/dotnet/bicep/main.bicep new file mode 100644 index 0000000..6d7d847 --- /dev/null +++ b/samples/web-app-app-configuration/dotnet/bicep/main.bicep @@ -0,0 +1,537 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the prefix for the name of the Azure resources.') +@minLength(2) +param prefix string = take(uniqueString(resourceGroup().id), 4) + +@description('Specifies the suffix for the name of the Azure resources.') +@minLength(2) +param suffix string = take(uniqueString(resourceGroup().id), 4) + +@description('Specifies the location for all resources.') +param location string = resourceGroup().location + +@description('Specifies the tier name for the hosting plan.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' + 'PremiumV2' + 'Premium0V3' + 'PremiumV3' + 'PremiumMV3' +]) +param skuTier string = 'Standard' + +@description('Specifies the SKU name for the hosting plan.') +param skuName string = 'S1' + +@description('Specifies the kind of the hosting plan.') +@allowed(['app','linux']) +param appServicePlanKind string = 'linux' + +@description('Specifies whether the hosting plan is reserved.') +param reserved bool = true + +@description('Specifies whether the hosting plan is zone redundant.') +param zoneRedundant bool = false + +@description('Specifies the language runtime used by the Azure Web App.') +@allowed(['dotnet','dotnetcore','python','java','node']) +param runtimeName string + +@description('Specifies the target language version used by the Azure Web App.') +param runtimeVersion string + +@description('Specifies the kind of the web app resource.') +param webAppKind string = 'app,linux' + +@description('Specifies whether HTTPS is enforced for the Azure Web App.') +param httpsOnly bool = false + +@description('Specifies the minimum TLS version for the Azure Web App.') +@allowed(['1.2','1.3']) +param minTlsVersion string = '1.2' + +@description('Specifies whether the public network access is enabled or disabled') +@allowed(['Enabled','Disabled']) +param publicNetworkAccess string = 'Enabled' + +@description('Specifies the optional Git Repo URL.') +param repoUrl string = ' ' + +@description('Specifies the username for the application (used to scope activities).') +param username string = 'paolo' + +@description('Specifies the port the application listens on inside its container (WEBSITES_PORT).') +param websitesPort int = 8000 + +// +// PostgreSQL flexible server +// +@description('Administrator login for the PostgreSQL flexible server. Only used by the post-deploy psql bootstrap; the Web App never authenticates with this account.') +param pgAdminLogin string = 'pgadmin' + +@description('Administrator login password for the PostgreSQL flexible server.') +@secure() +param pgAdminPassword string + +@description('PostgreSQL major version.') +@allowed(['13','14','15','16','17']) +param pgVersion string = '16' + +@description('Compute tier for the PostgreSQL flexible server.') +@allowed(['Burstable','GeneralPurpose','MemoryOptimized']) +param pgSkuTier string = 'Burstable' + +@description('Compute SKU name for the PostgreSQL flexible server.') +param pgSkuName string = 'Standard_B1ms' + +@description('Storage size in GB for the PostgreSQL flexible server.') +@minValue(32) +@maxValue(16384) +param pgStorageSizeGB int = 32 + +@description('Backup retention in days for the PostgreSQL flexible server.') +@minValue(7) +@maxValue(35) +param pgBackupRetentionDays int = 7 + +@description('Name of the application database to create on the PostgreSQL flexible server.') +param databaseName string = 'PlannerDB' + +@description('Name of the PostgreSQL application role the Web App connects with. Stored in Key Vault as the pg-user secret; the role itself is created by the post-deploy psql bootstrap.') +param pgAppUser string = 'testuser' + +@description('Password of the PostgreSQL application role. Stored in Key Vault as the pg-password secret.') +@secure() +param pgAppPassword string + +@description('Name of the Key Vault secret holding the PostgreSQL application role name (alphanumerics and hyphens only).') +param pgUserSecretName string = 'pg-user' + +@description('Name of the Key Vault secret holding the PostgreSQL application role password (alphanumerics and hyphens only).') +param pgPasswordSecretName string = 'pg-password' + +@description('Key of the App Configuration key-value holding the PostgreSQL host name. The application reads this key.') +param pgHostKeyName string = 'PG_HOST' + +@description('Key of the App Configuration key-value holding the PostgreSQL port. The application reads this key.') +param pgPortKeyName string = 'PG_PORT' + +@description('Key of the App Configuration key-value holding the PostgreSQL database name. The application reads this key.') +param pgDatabaseKeyName string = 'PG_DATABASE' + +@description('Key of the App Configuration Key Vault reference to the application role name secret. The application reads this key.') +param pgUserKeyName string = 'PG_USER' + +@description('Key of the App Configuration Key Vault reference to the application role password secret. The application reads this key.') +// The value is the name of a key-value, not a credential; the linter reacts to the word in the parameter name. +#disable-next-line secure-secrets-in-params +param pgPasswordKeyName string = 'PG_PASSWORD' + +@description('PostgreSQL port stored in the PG_PORT key-value when the server fully qualified domain name carries none (Azure); the emulator name carries the port of its TCP proxy.') +param pgDefaultPort string = '5432' + +@description('Content type that marks an App Configuration key-value as a Key Vault reference.') +param keyVaultReferenceContentType string = 'application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8' + +// +// App Configuration and Key Vault +// +@description('Specifies the name of the App Configuration store (globally unique).') +param appConfigurationName string = '' + +@description('Specifies the SKU of the App Configuration store.') +@allowed(['Developer','Standard','Premium']) +param appConfigurationSku string = 'Standard' + +@description('Specifies the name of the key vault (globally unique).') +param keyVaultName string = '' + +@description('Specifies the SKU of the key vault.') +@allowed(['standard','premium']) +param keyVaultSkuName string = 'standard' + +@description('Specifies the name of the user-assigned managed identity of the Web App.') +param managedIdentityName string = '' + +@description('Specifies the object id of the deploying principal, granted Key Vault Secrets Officer on the vault (empty skips the assignment).') +param deployerPrincipalId string = '' + +@description('Specifies the principal type of the deploying principal.') +@allowed(['User','ServicePrincipal','Group']) +param deployerPrincipalType string = 'User' + +// +// Networking +// +@description('Specifies the name of the virtual network.') +param virtualNetworkName string = '' + +@description('Specifies the address prefixes of the virtual network.') +param virtualNetworkAddressPrefixes string = '10.0.0.0/8' + +@description('Specifies the name of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetName string = 'app-subnet' + +@description('Specifies the address prefix of the subnet used by the Web App for the regional virtual network integration.') +param webAppSubnetAddressPrefix string = '10.0.0.0/24' + +@description('Specifies the name of the network security group associated to the subnet hosting the Web App.') +param webAppSubnetNsgName string = '' + +@description('Specifies the name of the subnet that hosts the private endpoints to PostgreSQL, App Configuration and Key Vault.') +param peSubnetName string = 'pe-subnet' + +@description('Specifies the address prefix of the private-endpoint subnet.') +param peSubnetAddressPrefix string = '10.0.1.0/24' + +@description('Specifies the name of the NSG associated to the private-endpoint subnet.') +param peSubnetNsgName string = '' + +@description('Specifies the service the Web App subnet is delegated to, required for regional virtual network integration.') +param webAppSubnetDelegationServiceName string = 'Microsoft.Web/serverfarms' + +@description('Specifies the name of the public IP prefix of the Azure NAT Gateway.') +param natGatewayPublicIpPrefixName string = '' + +@description('Specifies the length of the Public IP Prefix.') +@minValue(28) +@maxValue(32) +param natGatewayPublicIpPrefixLength int = 31 + +@description('Specifies the name of the Azure NAT Gateway.') +param natGatewayName string = '' + +@description('Specifies a list of availability zones denoting the zone in which Nat Gateway should be deployed.') +param natGatewayZones array = [] + +@description('Specifies the idle timeout in minutes for the Azure NAT Gateway.') +param natGatewayIdleTimeoutMins int = 30 + +@description('Specifies the name of the private endpoint targeting the PostgreSQL flexible server.') +param postgresPrivateEndpointName string = '' + +@description('Specifies the name of the private endpoint targeting the App Configuration store.') +param appConfigurationPrivateEndpointName string = '' + +@description('Specifies the name of the private endpoint targeting the key vault.') +param keyVaultPrivateEndpointName string = '' + +@description('Specifies the private DNS zone of the PostgreSQL flexible server private endpoint.') +param postgresPrivateDnsZoneName string = 'privatelink.postgres.database.azure.com' + +@description('Specifies the private DNS zone of the App Configuration private endpoint.') +param appConfigurationPrivateDnsZoneName string = 'privatelink.azconfig.io' + +@description('Specifies the private DNS zone of the Key Vault private endpoint.') +param keyVaultPrivateDnsZoneName string = 'privatelink.vaultcore.azure.net' + +@description('Specifies the group ids (sub-resources) of the PostgreSQL flexible server private endpoint.') +param postgresPrivateEndpointGroupIds array = [ + 'postgresqlServer' +] + +@description('Specifies the group ids (sub-resources) of the App Configuration private endpoint.') +param appConfigurationPrivateEndpointGroupIds array = [ + 'configurationStores' +] + +@description('Specifies the group ids (sub-resources) of the Key Vault private endpoint.') +param keyVaultPrivateEndpointGroupIds array = [ + 'vault' +] + +// +// Observability +// +@description('Specifies the name of the Azure Log Analytics resource.') +param logAnalyticsName string = '' + +@description('Specifies the service tier of the workspace.') +@allowed(['Free','Standalone','PerNode','PerGB2018']) +param logAnalyticsSku string = 'PerNode' + +@description('Specifies the workspace data retention in days.') +param logAnalyticsRetentionInDays int = 60 + +@description('Specifies the tags to be applied to the resources.') +param tags object = { + environment: 'test' + iac: 'bicep' +} + +//******************************************** +// Variables +//******************************************** +var webAppName = '${prefix}-webapp-${suffix}' +var appServicePlanName = '${prefix}-app-service-plan-${suffix}' +var pgServerName = '${prefix}-pgflex-${suffix}' + +// The PostgreSQL flexible-server emulator embeds the LS-side TCP-proxy port directly in +// fullyQualifiedDomainName (e.g. ".postgres.database.localhost.localstack.cloud:4515"). +// Real Azure returns just the bare host on 5432. Split on `:` so the store always gets the +// right host + port without any post-deploy shell logic. +var pgFqdnParts = split(postgresqlServer.outputs.fqdn, ':') +var pgHost = pgFqdnParts[0] +var pgPort = length(pgFqdnParts) > 1 ? pgFqdnParts[1] : pgDefaultPort + +// The five settings the web app used to receive as app settings, seeded into the App Configuration store: +// three plain key-values and two Key Vault references to the secrets created by the key-vault module. +// A Key Vault reference value is the JSON document {"uri":""}. It is written with string +// interpolation rather than string({ uri: ... }): the result is identical on Azure, while the LocalStack +// emulator's template engine renders string() of an object as a Python dictionary instead of JSON today. +var appConfigurationKeyValues = [ + { + key: pgHostKeyName + value: pgHost + contentType: '' + } + { + key: pgPortKeyName + value: pgPort + contentType: '' + } + { + key: pgDatabaseKeyName + value: postgresqlServer.outputs.databaseName + contentType: '' + } + { + key: pgUserKeyName + value: '{"uri":"${keyVault.outputs.secretUris.pgUser}"}' + contentType: keyVaultReferenceContentType + } + { + key: pgPasswordKeyName + value: '{"uri":"${keyVault.outputs.secretUris.pgPassword}"}' + contentType: keyVaultReferenceContentType + } +] + +//******************************************** +// Modules and Resources +//******************************************** +module workspace 'modules/log-analytics.bicep' = { + name: 'workspace' + params: { + name: empty(logAnalyticsName) ? toLower('${prefix}-log-analytics-${suffix}') : logAnalyticsName + location: location + tags: tags + sku: logAnalyticsSku + retentionInDays: logAnalyticsRetentionInDays + } +} + +module network 'modules/virtual-network.bicep' = { + name: 'network' + params: { + virtualNetworkName: empty(virtualNetworkName) ? toLower('${prefix}-vnet-${suffix}') : virtualNetworkName + virtualNetworkAddressPrefixes: virtualNetworkAddressPrefixes + webAppSubnetName: webAppSubnetName + webAppSubnetAddressPrefix: webAppSubnetAddressPrefix + webAppSubnetNsgName: empty(webAppSubnetNsgName) ? toLower('${prefix}-webapp-subnet-nsg-${suffix}') : webAppSubnetNsgName + peSubnetName: peSubnetName + peSubnetAddressPrefix: peSubnetAddressPrefix + peSubnetNsgName: empty(peSubnetNsgName) ? toLower('${prefix}-pe-subnet-nsg-${suffix}') : peSubnetNsgName + natGatewayName: empty(natGatewayName) ? toLower('${prefix}-nat-gateway-${suffix}') : natGatewayName + natGatewayZones: natGatewayZones + natGatewayPublicIpPrefixName: empty(natGatewayPublicIpPrefixName) ? toLower('${prefix}-nat-gateway-pip-prefix-${suffix}') : natGatewayPublicIpPrefixName + natGatewayPublicIpPrefixLength: natGatewayPublicIpPrefixLength + natGatewayIdleTimeoutMins: natGatewayIdleTimeoutMins + delegationServiceName: webAppSubnetDelegationServiceName + workspaceId: workspace.outputs.id + location: location + tags: tags + } +} + +module managedIdentity 'modules/managed-identity.bicep' = { + name: 'managedIdentity' + params: { + name: empty(managedIdentityName) ? toLower('${prefix}-identity-${suffix}') : managedIdentityName + location: location + tags: tags + } +} + +module postgresqlServer 'modules/postgresql-flexible-server.bicep' = { + name: 'postgresqlServer' + params: { + name: pgServerName + location: location + administratorLogin: pgAdminLogin + administratorLoginPassword: pgAdminPassword + version: pgVersion + skuTier: pgSkuTier + skuName: pgSkuName + storageSizeGB: pgStorageSizeGB + backupRetentionDays: pgBackupRetentionDays + databaseName: databaseName + workspaceId: workspace.outputs.id + tags: tags + } +} + +module keyVault 'modules/key-vault.bicep' = { + name: 'keyVault' + params: { + name: empty(keyVaultName) ? toLower('${prefix}-keyvault-${suffix}') : keyVaultName + location: location + skuName: keyVaultSkuName + pgUserSecretName: pgUserSecretName + pgAppUser: pgAppUser + pgPasswordSecretName: pgPasswordSecretName + pgAppPassword: pgAppPassword + identityPrincipalId: managedIdentity.outputs.principalId + deployerPrincipalId: deployerPrincipalId + deployerPrincipalType: deployerPrincipalType + workspaceId: workspace.outputs.id + tags: tags + } +} + +module appConfiguration 'modules/app-configuration.bicep' = { + name: 'appConfiguration' + params: { + name: empty(appConfigurationName) ? toLower('${prefix}-appconfig-${suffix}') : appConfigurationName + location: location + skuName: appConfigurationSku + keyValues: appConfigurationKeyValues + dataReaderPrincipalId: managedIdentity.outputs.principalId + workspaceId: workspace.outputs.id + tags: tags + } +} + +module postgresPrivateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'postgresPrivateDnsZone' + params: { + name: postgresPrivateDnsZoneName + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module postgresPrivateEndpoint 'modules/private-endpoint.bicep' = { + name: 'postgresPrivateEndpoint' + params: { + name: empty(postgresPrivateEndpointName) + ? toLower('${prefix}-postgres-pe-${suffix}') + : postgresPrivateEndpointName + privateLinkServiceId: postgresqlServer.outputs.id + privateDnsZoneId: postgresPrivateDnsZone.outputs.id + subnetId: network.outputs.peSubnetId + groupIds: postgresPrivateEndpointGroupIds + location: location + tags: tags + } +} + +module appConfigurationPrivateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'appConfigurationPrivateDnsZone' + params: { + name: appConfigurationPrivateDnsZoneName + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module appConfigurationPrivateEndpoint 'modules/private-endpoint.bicep' = { + name: 'appConfigurationPrivateEndpoint' + params: { + name: empty(appConfigurationPrivateEndpointName) + ? toLower('${prefix}-appconfig-pe-${suffix}') + : appConfigurationPrivateEndpointName + privateLinkServiceId: appConfiguration.outputs.id + privateDnsZoneId: appConfigurationPrivateDnsZone.outputs.id + subnetId: network.outputs.peSubnetId + groupIds: appConfigurationPrivateEndpointGroupIds + location: location + tags: tags + } +} + +module keyVaultPrivateDnsZone 'modules/private-dns-zone.bicep' = { + name: 'keyVaultPrivateDnsZone' + params: { + name: keyVaultPrivateDnsZoneName + vnetId: network.outputs.virtualNetworkId + tags: tags + } +} + +module keyVaultPrivateEndpoint 'modules/private-endpoint.bicep' = { + name: 'keyVaultPrivateEndpoint' + params: { + name: empty(keyVaultPrivateEndpointName) + ? toLower('${prefix}-keyvault-pe-${suffix}') + : keyVaultPrivateEndpointName + privateLinkServiceId: keyVault.outputs.id + privateDnsZoneId: keyVaultPrivateDnsZone.outputs.id + subnetId: network.outputs.peSubnetId + groupIds: keyVaultPrivateEndpointGroupIds + location: location + tags: tags + } +} + +module appServicePlan 'modules/app-service-plan.bicep' = { + name: 'appServicePlan' + params: { + name: appServicePlanName + location: location + skuName: skuName + skuTier: skuTier + kind: appServicePlanKind + reserved: reserved + zoneRedundant: zoneRedundant + workspaceId: workspace.outputs.id + tags: tags + } +} + +// The web app is created after the store (with its key-values and role assignment) and the vault (with its +// secrets and role assignment): the endpoint and client id it receives come from the store and identity +// modules, and the store module itself depends on the vault module through the secret URIs. +module webApp 'modules/web-app.bicep' = { + name: webAppName + params: { + name: webAppName + location: location + kind: webAppKind + httpsOnly: httpsOnly + runtimeName: runtimeName + runtimeVersion: runtimeVersion + minTlsVersion: minTlsVersion + publicNetworkAccess: publicNetworkAccess + repoUrl: repoUrl + virtualNetworkName: network.outputs.virtualNetworkName + subnetName: network.outputs.webAppSubnetName + hostingPlanName: appServicePlan.outputs.name + managedIdentityId: managedIdentity.outputs.id + managedIdentityClientId: managedIdentity.outputs.clientId + appConfigurationEndpoint: appConfiguration.outputs.endpoint + username: username + websitesPort: websitesPort + workspaceId: workspace.outputs.id + tags: tags + } +} + +//******************************************** +// Outputs +//******************************************** +output webAppName string = webApp.outputs.name +output webAppDefaultHostName string = webApp.outputs.defaultHostName +output postgresServerName string = postgresqlServer.outputs.name +output postgresFqdn string = postgresqlServer.outputs.fqdn +output databaseName string = postgresqlServer.outputs.databaseName +output appConfigurationName string = appConfiguration.outputs.name +output appConfigurationEndpoint string = appConfiguration.outputs.endpoint +output keyVaultName string = keyVault.outputs.name +output keyVaultUri string = keyVault.outputs.vaultUri +output managedIdentityName string = managedIdentity.outputs.name +output managedIdentityClientId string = managedIdentity.outputs.clientId diff --git a/samples/web-app-app-configuration/dotnet/bicep/main.bicepparam b/samples/web-app-app-configuration/dotnet/bicep/main.bicepparam new file mode 100644 index 0000000..b2e8e55 --- /dev/null +++ b/samples/web-app-app-configuration/dotnet/bicep/main.bicepparam @@ -0,0 +1,27 @@ +using 'main.bicep' + +param prefix = 'local' +param suffix = 'test' +param runtimeName = 'dotnetcore' +param runtimeVersion = '10.0' +param databaseName = 'PlannerDB' +param username = 'paolo' + +// PostgreSQL flexible server +param pgAdminLogin = 'pgadmin' +// Passwords are supplied at deploy time via the PG_ADMIN_PASSWORD and PG_APP_PASSWORD env vars +// (see deploy.sh, which passes them as --parameters pgAdminPassword=... pgAppPassword=...). Do not commit them here. +param pgAdminPassword = readEnvironmentVariable('PG_ADMIN_PASSWORD', '') +param pgVersion = '16' +param pgSkuTier = 'Burstable' +param pgSkuName = 'Standard_B1ms' +param pgStorageSizeGB = 32 +param pgBackupRetentionDays = 7 + +// Application role stored in Key Vault (pg-user and pg-password secrets) and referenced from App Configuration +param pgAppUser = 'testuser' +param pgAppPassword = readEnvironmentVariable('PG_APP_PASSWORD', '') + +// App Configuration store and key vault +param appConfigurationSku = 'Standard' +param keyVaultSkuName = 'standard' diff --git a/samples/web-app-app-configuration/dotnet/bicep/modules/app-configuration.bicep b/samples/web-app-app-configuration/dotnet/bicep/modules/app-configuration.bicep new file mode 100644 index 0000000..e270893 --- /dev/null +++ b/samples/web-app-app-configuration/dotnet/bicep/modules/app-configuration.bicep @@ -0,0 +1,170 @@ +//******************************************** +// Parameters +//******************************************** +@description('Specifies the name of the App Configuration store. Globally unique, 5 to 50 alphanumerics and hyphens.') +@minLength(5) +@maxLength(50) +param name string + +@description('Specifies the location.') +param location string = resourceGroup().location + +@description('Specifies the SKU of the App Configuration store. Private endpoints need Developer, Standard or Premium.') +@allowed([ + 'Developer' + 'Standard' + 'Premium' +]) +param skuName string = 'Standard' + +@description('Specifies for how many days a deleted App Configuration store stays recoverable.') +@minValue(1) +@maxValue(7) +param softDeleteRetentionInDays int = 7 + +@description('Specifies whether the access keys of the store are disabled. The keyValues children are written through Azure Resource Manager, which in the default Local authentication mode relies on the access keys, so they stay enabled.') +param disableLocalAuth bool = false + +@description('Specifies whether purge protection is enabled for the store. Off so that a deleted store can be purged and its name reused.') +param enablePurgeProtection bool = false + +@description('Specifies whether the store accepts requests from public networks. The deployment seeds the store from outside the virtual network; the web app reaches it through its private endpoint.') +@allowed([ + 'Enabled' + 'Disabled' +]) +param publicNetworkAccess string = 'Enabled' + +@description('Specifies the key-values to seed: objects with key, value and contentType (empty for a plain value).') +param keyValues array + +@description('Specifies the principal id of the managed identity that reads the store (App Configuration Data Reader).') +param dataReaderPrincipalId string + +@description('Specifies the principal type of the managed identity that reads the store.') +@allowed([ + 'User' + 'ServicePrincipal' + 'Group' +]) +param dataReaderPrincipalType string = 'ServicePrincipal' + +@description('Specifies the id of the built-in role assigned to the reading identity: App Configuration Data Reader by default.') +param dataReaderRoleDefinitionId string = '516239f1-63e1-4d78-a4de-a74fb236a071' + +@description('Specifies the resource id of the Log Analytics workspace.') +param workspaceId string + +@description('Specifies the name of the diagnostic settings.') +param diagnosticSettingsName string = 'default' + +@description('Specifies the log categories enabled by the diagnostic settings.') +param logCategories array = [ + 'HttpRequest' + 'Audit' +] + +@description('Specifies the metric categories enabled by the diagnostic settings.') +param metricCategories array = [ + 'AllMetrics' +] + +@description('Specifies whether the retention policy of the diagnostic settings is enabled.') +param retentionPolicyEnabled bool = true + +@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') +param retentionPolicyDays int = 0 + +@description('Specifies the resource tags.') +param tags object + +//******************************************** +// Variables +//******************************************** + +// App Configuration Data Reader: Microsoft.AppConfiguration/configurationStores/*/read data actions. +var dataReaderRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', dataReaderRoleDefinitionId) +var logs = [ + for category in logCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: retentionPolicyEnabled + days: retentionPolicyDays + } + } +] +var metrics = [ + for category in metricCategories: { + category: category + enabled: true + retentionPolicy: { + enabled: retentionPolicyEnabled + days: retentionPolicyDays + } + } +] + +//******************************************** +// Resources +//******************************************** + +resource configurationStore 'Microsoft.AppConfiguration/configurationStores@2024-06-01' = { + name: name + location: location + tags: tags + sku: { + name: skuName + } + properties: { + disableLocalAuth: disableLocalAuth + enablePurgeProtection: enablePurgeProtection + softDeleteRetentionInDays: softDeleteRetentionInDays + publicNetworkAccess: publicNetworkAccess + } +} + +// The key-values, seeded through the ARM child resource. The child name is the key (no label is used, so +// no '$