diff --git a/.deployment b/.deployment deleted file mode 100644 index 62783318..00000000 --- a/.deployment +++ /dev/null @@ -1,2 +0,0 @@ -[config] -SCM_DO_BUILD_DURING_DEPLOYMENT=true \ No newline at end of file diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 58e5460e..a1d7d73b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.10 +FROM mcr.microsoft.com/vscode/devcontainers/python:3.12 RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends postgresql-client \ diff --git a/.devcontainer/README.md b/.devcontainer/README.md index e98f6a87..f8610faf 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -2,9 +2,10 @@ This `.devcontainer` directory contains the configuration for a [dev container](https://docs.github.com/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers) and isn't used by the sample application. -The dev container configuration lets you open the repository in a [GitHub codespace](https://docs.github.com/codespaces/overview) or a dev container in Visual Studio Code. For your convenience, the dev container is configured with the following: +The dev container configuration lets you open therepository in a [GitHub codespace](https://docs.github.com/codespaces/overview) or a dev container in Visual Studio Code. For your convenience, the dev container is configured with the following: - Python +- Running `pip install -r requirements.txt` from the project at container start. - PostgreSQL - Redis - [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview) (so you can run `azd` commands directly). diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 983d15d3..091842a5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ "name": "python-app-service-postgresql-redis-infra", "dockerComposeFile": "docker-compose.yml", "service": "app", - "workspaceFolder": "/workspace", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "features": { "ghcr.io/azure/azure-dev/azd:latest": {} }, @@ -14,7 +14,8 @@ "ms-python.python", "ms-python.vscode-pylance", "mtxr.sqltools", - "mtxr.sqltools-driver-pg" + "mtxr.sqltools-driver-pg", + "GitHub.copilot" ], "settings": { "sqltools.connections": [ @@ -29,7 +30,6 @@ "password": "app_password" } ], - "python.pythonPath": "/usr/local/bin/python", "python.languageServer": "Pylance", "python.linting.enabled": true, "python.linting.mypyEnabled": true, @@ -51,12 +51,10 @@ } } }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. 5000 is for Flask, 8000 is for Django, and 5432 is for PostgreSQL. - "forwardPorts": [ - 8000, 5000, 5432 - ], + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "", + "postCreateCommand": "pip install -r requirements.txt", // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "vscode" } diff --git a/.env.sample b/.env.sample deleted file mode 100644 index a626555a..00000000 --- a/.env.sample +++ /dev/null @@ -1,6 +0,0 @@ -DBNAME= -DBHOST= -DBUSER= -DBPASS= -CACHELOCATION= -SECRET_KEY= \ No newline at end of file diff --git a/.env.sample.devcontainer b/.env.sample.devcontainer deleted file mode 100644 index 7e893174..00000000 --- a/.env.sample.devcontainer +++ /dev/null @@ -1,6 +0,0 @@ -DBNAME=app -DBHOST=localhost -DBUSER=app_user -DBPASS=app_password -CACHELOCATION=redis://redis:6379/0 -SECRET_KEY=secret_key diff --git a/README.md b/README.md index b380289e..1a6a47bc 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,6 @@ This project has a [dev container configuration](.devcontainer/), which makes it 1. In the codespace terminal, run the following commands: ```shell - # Install requirements - python3 -m pip install -r requirements.txt - # Create .env with environment variables - cp .env.sample.devcontainer .env # Run database migrations python3 manage.py migrate # Start the development server diff --git a/azure.yaml b/azure.yaml index 10bffe50..033038f2 100644 --- a/azure.yaml +++ b/azure.yaml @@ -13,18 +13,18 @@ hooks: postprovision: posix: shell: sh - run: echo $'\n\nApp Service app has the following settings:\n' && echo "$WEB_APP_SETTINGS" | jq -r '.[]' | sed 's/\(.*\)/\t- \1/' && echo -e $"\nSee the settings in the portal:\033[1;36m $WEB_APP_CONFIG" + run: printf '\nApp Service app has the following connection settings:\n' && printf "$CONNECTION_SETTINGS" | jq -r '.[]' | sed 's/\(.*\)/\t- \1/' && printf "\nSee the settings in the portal:\033[1;36m $WEB_APP_CONFIG\n" interactive: true continueOnError: true windows: shell: pwsh - run: Write-Host "`n`nApp Service app has the following settings:`n" $WEB_APP_SETTINGS | ConvertFrom-Json | ForEach-Object { Write-Host "\t- $_" } + run: Write-Host "`n`nApp Service app has the following connection settings:`n" $CONNECTION_SETTINGS | ConvertFrom-Json | ForEach-Object { Write-Host "\t- $_" } interactive: true continueOnError: true postdeploy: posix: shell: sh - run: echo -e $"\n\nOpen SSH session to App Service container at:\033[1;36m $WEB_APP_SSH\033[0m" && echo -e $"Stream App Service logs at:\033[1;36m $WEB_APP_LOG_STREAM" + run: printf "Open SSH session to App Service container at:\033[1;36m $WEB_APP_SSH\033[0m\nStream App Service logs at:\033[1;36m $WEB_APP_LOG_STREAM\n" interactive: true continueOnError: true windows: diff --git a/azureproject/production.py b/azureproject/production.py index eb530a6f..76b6e1f0 100644 --- a/azureproject/production.py +++ b/azureproject/production.py @@ -26,24 +26,20 @@ STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') -# Configure Postgres database based on connection string of the libpq Keyword/Value form -# https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING -conn_str = os.environ['AZURE_POSTGRESQL_CONNECTIONSTRING'] -conn_str_params = {pair.split('=')[0]: pair.split('=')[1] for pair in conn_str.split(' ')} DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'NAME': conn_str_params['dbname'], - 'HOST': conn_str_params['host'], - 'USER': conn_str_params['user'], - 'PASSWORD': conn_str_params['password'], + 'NAME': os.environ['AZURE_POSTGRESQL_NAME'], + 'HOST': os.environ['AZURE_POSTGRESQL_HOST'], + 'USER': os.environ['AZURE_POSTGRESQL_USER'], + 'PASSWORD': os.environ['AZURE_POSTGRESQL_PASSWORD'], } } CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": os.environ.get('AZURE_REDIS_CONNECTIONSTRING'), + "LOCATION": os.environ['AZURE_REDIS_CONNECTIONSTRING'], "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor", diff --git a/infra/main.bicep b/infra/main.bicep index 52b60ff3..376f2467 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -17,6 +17,8 @@ param databasePassword string @description('Django SECRET_KEY for securing signed data') param secretKey string +param principalId string = '' + var resourceToken = toLower(uniqueString(subscription().id, name, location)) var tags = { 'azd-env-name': name } @@ -35,6 +37,7 @@ module resources 'resources.bicep' = { resourceToken: resourceToken tags: tags databasePassword: databasePassword + principalId: principalId secretKey: secretKey } } @@ -42,7 +45,7 @@ module resources 'resources.bicep' = { output AZURE_LOCATION string = location output APPLICATIONINSIGHTS_CONNECTION_STRING string = resources.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING output WEB_URI string = resources.outputs.WEB_URI -output WEB_APP_SETTINGS array = resources.outputs.WEB_APP_SETTINGS +output CONNECTION_SETTINGS array = resources.outputs.CONNECTION_SETTINGS output WEB_APP_LOG_STREAM string = resources.outputs.WEB_APP_LOG_STREAM output WEB_APP_SSH string = resources.outputs.WEB_APP_SSH -output WEB_APP_CONFIG string = resources.outputs.WEB_APP_CONFIG \ No newline at end of file +output WEB_APP_CONFIG string = resources.outputs.WEB_APP_CONFIG diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 87a1b669..3186f2a5 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -1,18 +1,21 @@ { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "name": { - "value": "${AZURE_ENV_NAME}" - }, - "location": { - "value": "${AZURE_LOCATION}" - }, - "databasePassword": { - "value": "$(secretOrRandomPassword)" - }, - "secretKey": { - "value": "$(secretOrRandomPassword)" - } + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "name": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "databasePassword": { + "value": "$(secretOrRandomPassword)" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "secretKey": { + "value": "$(secretOrRandomPassword)" } } +} diff --git a/infra/resources.bicep b/infra/resources.bicep index 816a7cd5..f5fba55f 100644 --- a/infra/resources.bicep +++ b/infra/resources.bicep @@ -1,26 +1,18 @@ param name string param location string param resourceToken string +param principalId string param tags object @secure() param databasePassword string @secure() param secretKey string +var appName = '${name}-${resourceToken}' -var prefix = '${name}-${resourceToken}' +var pgServerName = '${appName}-server' -var pgServerName = '${prefix}-postgres-server' -var databaseSubnetName = 'database-subnet' -var webappSubnetName = 'webapp-subnet' - -// Added for Azure Redis Cache -var cacheServerName = '${prefix}-redisCache' -var cacheSubnetName = 'cache-subnet' -var cachePrivateEndpointName = 'cache-privateEndpoint' -var cachePvtEndpointDnsGroupName = 'cacheDnsGroup' - -resource virtualNetwork 'Microsoft.Network/virtualNetworks@2019-11-01' = { - name: '${prefix}-vnet' +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = { + name: '${appName}-vnet' location: location tags: tags properties: { @@ -31,26 +23,28 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2019-11-01' = { } subnets: [ { - name: databaseSubnetName + name: 'database-subnet' properties: { addressPrefix: '10.0.0.0/24' delegations: [ { - name: '${prefix}-subnet-delegation' + name: '${appName}-subnet-delegation' properties: { serviceName: 'Microsoft.DBforPostgreSQL/flexibleServers' } } ] + privateEndpointNetworkPolicies: 'Enabled' + privateLinkServiceNetworkPolicies: 'Enabled' } } { - name: webappSubnetName + name: 'webapp-subnet' properties: { addressPrefix: '10.0.1.0/24' delegations: [ { - name: '${prefix}-subnet-delegation-web' + name: 'dlg-appServices' properties: { serviceName: 'Microsoft.Web/serverFarms' } @@ -59,95 +53,125 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2019-11-01' = { } } { - name: cacheSubnetName + name: 'cache-subnet' properties:{ addressPrefix: '10.0.2.0/24' + privateEndpointNetworkPolicies: 'Disabled' + } + } + { + name: 'vault-subnet' + properties: { + addressPrefix: '10.0.3.0/24' + privateEndpointNetworkPolicies: 'Disabled' } } ] } - resource databaseSubnet 'subnets' existing = { - name: databaseSubnetName + resource subnetForDb 'subnets' existing = { + name: 'database-subnet' + } + resource subnetForVault 'subnets' existing = { + name: 'vault-subnet' } - resource webappSubnet 'subnets' existing = { - name: webappSubnetName + resource subnetForApp 'subnets' existing = { + name: 'webapp-subnet' } - // Added for Azure Redis Cache - resource cacheSubnet 'subnets' existing = { - name: cacheSubnetName + resource subnetForCache 'subnets' existing = { + name: 'cache-subnet' } } -resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: '${pgServerName}.private.postgres.database.azure.com' +// Resources needed to secure Key Vault behind a private endpoint +resource privateDnsZoneKeyVault 'Microsoft.Network/privateDnsZones@2020-06-01' = { + name: 'privatelink.vaultcore.azure.net' location: 'global' - tags: tags - dependsOn: [ - virtualNetwork - ] + resource vnetLink 'virtualNetworkLinks@2020-06-01' = { + location: 'global' + name: '${appName}-vaultlink' + properties: { + virtualNetwork: { + id: virtualNetwork.id + } + registrationEnabled: false + } + } +} +resource vaultPrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-04-01' = { + name: '${appName}-vault-privateEndpoint' + location: location + properties: { + subnet: { + id: virtualNetwork::subnetForVault.id + } + privateLinkServiceConnections: [ + { + name: '${appName}-vault-privateEndpoint' + properties: { + privateLinkServiceId: keyVault.id + groupIds: ['vault'] + } + } + ] + } + resource privateDnsZoneGroup 'privateDnsZoneGroups@2024-01-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'vault-config' + properties: { + privateDnsZoneId: privateDnsZoneKeyVault.id + } + } + ] + } + } } -// Added for Azure Redis Cache -resource privateDnsZoneCache 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: 'privatelink.redis.cache.windows.net' +resource privateDnsZoneDB 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: '${pgServerName}.private.postgres.database.azure.com' location: 'global' tags: tags - dependsOn:[ + dependsOn: [ virtualNetwork ] -} - -resource privateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { - parent: privateDnsZone - name: '${pgServerName}-link' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: virtualNetwork.id + resource privateDnsZoneLinkDB 'virtualNetworkLinks@2024-06-01' = { + name: '${appName}-dblink' + location: 'global' + properties: { + virtualNetwork: { + id: virtualNetwork.id + } + registrationEnabled: false } - } + } } -// Added for Azure Redis Cache -resource privateDnsZoneLinkCache 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { - parent: privateDnsZoneCache - name: 'privatelink.redis.cache.windows.net-applink' - location: 'global' - properties: { - registrationEnabled: false - virtualNetwork: { - id: virtualNetwork.id - } - } -} - - -resource cachePrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-05-01' = { - name: cachePrivateEndpointName +// Resources needed to secure Redis Cache behind a private endpoint +resource cachePrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-03-01' = { + name: '${appName}-cache-privateEndpoint' location: location properties: { subnet: { - id: virtualNetwork::cacheSubnet.id + id: virtualNetwork::subnetForCache.id } privateLinkServiceConnections: [ { - name: cachePrivateEndpointName + name: '${appName}-cache-privateEndpoint' properties: { privateLinkServiceId: redisCache.id - groupIds: [ - 'redisCache' - ] + groupIds: ['redisCache'] } } ] } - resource cachePvtEndpointDnsGroup 'privateDnsZoneGroups' = { - name: cachePvtEndpointDnsGroupName + resource privateDnsZoneGroup 'privateDnsZoneGroups' = { + name: 'default' properties: { privateDnsZoneConfigs: [ { - name: 'privatelink-redis-cache-windows-net' + name: 'cache-config' properties: { privateDnsZoneId: privateDnsZoneCache.id } @@ -156,39 +180,165 @@ resource cachePrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-05-01' = } } } +resource privateDnsZoneCache 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.redis.cache.windows.net' + location: 'global' + dependsOn: [ + virtualNetwork + ] + resource privateDnsZoneLinkCache 'virtualNetworkLinks@2020-06-01' = { + name: '${appName}-cachelink' + location: 'global' + properties: { + virtualNetwork: { + id: virtualNetwork.id + } + registrationEnabled: false + } + } +} -resource web 'Microsoft.Web/sites@2022-03-01' = { - name: '${prefix}-app-service' +// The Key Vault is used to manage SQL database and redis secrets. +// Current user has the admin permissions to configure key vault secrets, but by default doesn't have the permissions to read them. +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { + name: '${take(replace(appName, '-', ''), 17)}-vault' location: location - tags: union(tags, { 'azd-service-name': 'web' }) - kind: 'app,linux' properties: { - serverFarmId: appServicePlan.id + enableRbacAuthorization: true + tenantId: subscription().tenantId + sku: { family: 'A', name: 'standard' } + // Only allow requests from the private endpoint in the VNET. + publicNetworkAccess: 'Disabled' // To see the secret in the portal, change to 'Enabled' + networkAcls: { + defaultAction: 'Deny' // To see the secret in the portal, change to 'Allow' + bypass: 'None' + } + } +} + +// Grant the current user with key vault secret user role permissions over the key vault. This lets you inspect the secrets, such as in the portal +// If you remove this section, you can't read the key vault secrets, but the app still has access with its managed identity. +resource keyVaultSecretUserRoleRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { + scope: subscription() + name: '4633458b-17de-408a-b874-0445c86b69e6' // The built-in Key Vault Secret User role +} +resource keyVaultSecretUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-08-01-preview' = { + scope: keyVault + name: guid(resourceGroup().id, principalId, keyVaultSecretUserRoleRoleDefinition.id) + properties: { + roleDefinitionId: keyVaultSecretUserRoleRoleDefinition.id + principalId: principalId + principalType: 'User' + } +} + +resource dbserver 'Microsoft.DBforPostgreSQL/flexibleServers@2022-01-20-preview' = { + location: location + tags: tags + name: pgServerName + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + properties: { + version: '12' + administratorLogin: 'postgresadmin' + administratorLoginPassword: databasePassword + storage: { + storageSizeGB: 128 + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + network: { + delegatedSubnetResourceId: virtualNetwork::subnetForDb.id + privateDnsZoneArmResourceId: privateDnsZoneDB.id + } + highAvailability: { + mode: 'Disabled' + } + maintenanceWindow: { + customWindow: 'Disabled' + dayOfWeek: 0 + startHour: 0 + startMinute: 0 + } + } + + resource db 'databases@2024-08-01' = { + name: '${appName}-database' + } + dependsOn: [ + privateDnsZoneDB::privateDnsZoneLinkDB + ] +} + +// The Redis cache is configured to the minimum pricing tier +resource redisCache 'Microsoft.Cache/redis@2024-11-01' = { + name: '${appName}-cache' + location: location + properties: { + sku: { + name: 'Basic' + family: 'C' + capacity: 0 + } + redisConfiguration: {} + enableNonSslPort: false + redisVersion: '6' + publicNetworkAccess: 'Disabled' + } +} + +// The App Service plan is configured to the B1 pricing tier +resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: '${appName}-plan' + location: location + kind: 'linux' + properties: { + reserved: true + } + sku: { + name: 'B1' + } +} + +resource web 'Microsoft.Web/sites@2024-04-01' = { + name: appName + location: location + tags: union(tags, { 'azd-service-name': 'web' }) // Needed by AZD + properties: { siteConfig: { - alwaysOn: true - linuxFxVersion: 'PYTHON|3.11' + linuxFxVersion: 'PYTHON|3.12' // Set to Python 3.12 ftpsState: 'Disabled' appCommandLine: 'startup.sh' minTlsVersion: '1.2' } + serverFarmId: appServicePlan.id httpsOnly: true } identity: { type: 'SystemAssigned' } + + // For app setting configuration see the appsettings resource - resource appSettings 'config' = { - name: 'appsettings' + // Disable basic authentication for FTP and SCM + resource ftp 'basicPublishingCredentialsPolicies@2023-12-01' = { + name: 'ftp' properties: { - SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' - AZURE_POSTGRESQL_CONNECTIONSTRING: 'dbname=${pythonAppDatabase.name} host=${postgresServer.name}.postgres.database.azure.com port=5432 sslmode=require user=${postgresServer.properties.administratorLogin} password=${databasePassword}' - SECRET_KEY: secretKey - FLASK_DEBUG: 'False' - //Added for Azure Redis Cache - AZURE_REDIS_CONNECTIONSTRING: 'rediss://:${redisCache.listKeys().primaryKey}@${redisCache.name}.redis.cache.windows.net:6380/0' + allow: false + } + } + resource scm 'basicPublishingCredentialsPolicies@2023-12-01' = { + name: 'scm' + properties: { + allow: false } } + // Enable App Service native logs resource logs 'config' = { name: 'logs' properties: { @@ -213,15 +363,89 @@ resource web 'Microsoft.Web/sites@2022-03-01' = { } } + // Enable VNET integration resource webappVnetConfig 'networkConfig' = { name: 'virtualNetwork' properties: { - subnetResourceId: virtualNetwork::webappSubnet.id + subnetResourceId: virtualNetwork::subnetForApp.id } } dependsOn: [ virtualNetwork ] +} +// Service Connector from the app to the key vault, which generates the connection settings for the App Service app +// The application code doesn't make any direct connections to the key vault, but the setup expedites the managed identity access +// so that the cache connector can be configured with key vault references. +resource vaultConnector 'Microsoft.ServiceLinker/linkers@2024-04-01' = { + scope: web + name: 'vaultConnector' + properties: { + clientType: 'python' + targetService: { + type: 'AzureResource' + id: keyVault.id + } + authInfo: { + authType: 'systemAssignedIdentity' // Use a system-assigned managed identity. No password is used. + } + vNetSolution: { + type: 'privateLink' + } + } + dependsOn: [ + vaultPrivateEndpoint + ] +} + +// Connector to the PostgreSQL database, which generates the connection string for the App Service app +resource dbConnector 'Microsoft.ServiceLinker/linkers@2024-04-01' = { + scope: web + name: 'defaultConnector' + properties: { + targetService: { + type: 'AzureResource' + id: dbserver::db.id + } + authInfo: { + authType: 'secret' + name: 'postgresadmin' + secretInfo: { + secretType: 'rawValue' + value: databasePassword + } + } + secretStore: { + keyVaultId: keyVault.id // Configure secrets as key vault references. No secret is exposed in App Service. + } + clientType: 'django' + } +} + +// Service Connector from the app to the cache, which generates an app setting for the App Service app +resource cacheConnector 'Microsoft.ServiceLinker/linkers@2024-04-01' = { + scope: web + name: 'RedisConnector' + properties: { + clientType: 'python' + targetService: { + type: 'AzureResource' + id: resourceId('Microsoft.Cache/Redis/Databases', redisCache.name, '0') + } + authInfo: { + authType: 'accessKey' + } + secretStore: { + keyVaultId: keyVault.id // Configure secrets as key vault references. No secret is exposed in App Service. + } + vNetSolution: { + type: 'privateLink' + + } + } + dependsOn: [ + cachePrivateEndpoint + ] } resource webdiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { @@ -264,20 +488,8 @@ resource webdiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-previe } } -resource appServicePlan 'Microsoft.Web/serverfarms@2021-03-01' = { - name: '${prefix}-service-plan' - location: location - tags: tags - sku: { - name: 'B1' - } - properties: { - reserved: true - } -} - -resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-03-01-preview' = { - name: '${prefix}-workspace' +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: '${appName}-workspace' location: location tags: tags properties: any({ @@ -294,84 +506,42 @@ resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-03 module applicationInsightsResources 'appinsights.bicep' = { name: 'applicationinsights-resources' params: { - prefix: prefix + prefix: appName location: location tags: tags workspaceId: logAnalyticsWorkspace.id } } -resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-01-20-preview' = { - location: location - tags: tags - name: pgServerName - sku: { - name: 'Standard_B1ms' - tier: 'Burstable' - } - properties: { - version: '12' - administratorLogin: 'postgresadmin' - administratorLoginPassword: databasePassword - storage: { - storageSizeGB: 128 - } - backup: { - backupRetentionDays: 7 - geoRedundantBackup: 'Disabled' - } - network: { - delegatedSubnetResourceId: virtualNetwork::databaseSubnet.id - privateDnsZoneArmResourceId: privateDnsZone.id - } - highAvailability: { - mode: 'Disabled' - } - maintenanceWindow: { - customWindow: 'Disabled' - dayOfWeek: 0 - startHour: 0 - startMinute: 0 - } - } - - dependsOn: [ - privateDnsZoneLink - ] -} - -resource pythonAppDatabase 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2022-01-20-preview' = { - parent: postgresServer - name: 'pythonapp' -} +func checkAndFormatSecrets(config object) string => config.configType == 'KeyVaultSecret' ? '@Microsoft.KeyVault(SecretUri=${config.value})' : config.value -//added for Redis Cache -resource redisCache 'Microsoft.Cache/redis@2023-04-01' = { - location:location - name:cacheServerName - properties:{ - sku:{ - capacity: 1 - family:'C' - name:'Standard' - } - enableNonSslPort:false - redisVersion:'6' - publicNetworkAccess:'Disabled' - minimumTlsVersion: '1.2' +// Add the app settings, by merging them with the ones created by the service connectors +var aggregatedAppSettings = union( + reduce(vaultConnector.listConfigurations().configurations, {}, (cur, next) => union(cur, { '${next.name}': checkAndFormatSecrets(next) })), + reduce(dbConnector.listConfigurations().configurations, {}, (cur, next) => union(cur, { '${next.name}': checkAndFormatSecrets(next) })), + reduce(cacheConnector.listConfigurations().configurations, {}, (cur, next) => union(cur, { '${next.name}': checkAndFormatSecrets(next) })), + { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + FLASK_DEBUG: 'False' + SECRET_KEY: secretKey + // Add other app settings here, for example: + // 'FOO': 'BAR' } -} - -output WEB_URI string = 'https://${web.properties.defaultHostName}' -output APPLICATIONINSIGHTS_CONNECTION_STRING string = applicationInsightsResources.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING - -resource webAppSettings 'Microsoft.Web/sites/config@2022-03-01' existing = { - name: web::appSettings.name +) +resource appsettings 'Microsoft.Web/sites/config@2024-04-01' = { + name: 'appsettings' parent: web + properties: aggregatedAppSettings } +// Why is this needed? +// The service connectors automatically add necessary respective app settings to the App Service app. However, if you configure a separate +// set of app settings in a config/appsettings resource, expecting a cummulative effect, the app settings actually overwrite the ones +// created by the service connectors, and the service connectors don't recreate the app settings after the first run. This configuration +// is a workaround to ensure that the app settings are aggregated correctly and consistent across multiple deployments. -var webAppSettingsKeys = map(items(webAppSettings.list().properties), setting => setting.key) -output WEB_APP_SETTINGS array = webAppSettingsKeys +output WEB_URI string = 'https://${web.properties.defaultHostName}' +output CONNECTION_SETTINGS array = map(concat(dbConnector.listConfigurations().configurations, cacheConnector.listConfigurations().configurations, vaultConnector.listConfigurations().configurations), config => config.name) +output APPLICATIONINSIGHTS_CONNECTION_STRING string = applicationInsightsResources.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING output WEB_APP_LOG_STREAM string = format('https://portal.azure.com/#@/resource{0}/logStream', web.id) output WEB_APP_SSH string = format('https://{0}.scm.azurewebsites.net/webssh/host', web.name) -output WEB_APP_CONFIG string = format('https://portal.azure.com/#@/resource{0}/configuration', web.id) +output WEB_APP_CONFIG string = format('https://portal.azure.com/#@/resource{0}/environmentVariablesAppSettings', web.id)