From ceff059933a072718d614e4c5ba05304cabb7bd0 Mon Sep 17 00:00:00 2001 From: Lazar Kanelov Date: Sat, 21 Feb 2026 17:15:26 +0200 Subject: [PATCH] Add Web App + PostgreSQL Flexible Server sample Add a new sample demonstrating an Azure Web App backed by PostgreSQL Flexible Server. Includes a Flask notes app with full-text search, deployed via App Service with Terraform, Bicep, and ARM (scripts) deployment options. Rename sample from postgresql-flexible-server to web-app-postgresql-database to follow repo conventions. Also fix hardcoded storage connection string in function-app-storage-http/dotnet that broke on LocalStack. --- README.md | 3 +- run-samples.sh | 3 + .../dotnet/scripts/deploy.sh | 10 +- .../python/README.md | 206 ++++++++ .../python/bicep/README.md | 124 +++++ .../python/bicep/deploy.sh | 56 +++ .../python/bicep/main.bicep | 111 +++++ .../python/bicep/main.bicepparam | 13 + .../python/docker-compose.yml | 73 +++ .../python/scripts/README.md | 94 ++++ .../python/scripts/deploy.sh | 279 +++++++++++ .../python/scripts/validate.sh | 109 +++++ .../python/src/notes-app/Dockerfile | 10 + .../python/src/notes-app/app.py | 456 ++++++++++++++++++ .../python/src/notes-app/requirements.txt | 3 + .../python/src/sdk-dotnet/Dockerfile | 11 + .../python/src/sdk-dotnet/Program.cs | 338 +++++++++++++ .../python/src/sdk-dotnet/SampleApp.csproj | 16 + .../python/src/sdk-python/Dockerfile | 9 + .../python/src/sdk-python/demo.py | 361 ++++++++++++++ .../python/src/sdk-python/requirements.txt | 5 + .../python/terraform/README.md | 151 ++++++ .../python/terraform/deploy.sh | 49 ++ .../python/terraform/main.tf | 97 ++++ .../python/terraform/outputs.tf | 43 ++ .../python/terraform/providers.tf | 30 ++ .../python/terraform/variables.tf | 80 +++ 27 files changed, 2737 insertions(+), 3 deletions(-) create mode 100644 samples/web-app-postgresql-database/python/README.md create mode 100644 samples/web-app-postgresql-database/python/bicep/README.md create mode 100755 samples/web-app-postgresql-database/python/bicep/deploy.sh create mode 100644 samples/web-app-postgresql-database/python/bicep/main.bicep create mode 100644 samples/web-app-postgresql-database/python/bicep/main.bicepparam create mode 100644 samples/web-app-postgresql-database/python/docker-compose.yml create mode 100644 samples/web-app-postgresql-database/python/scripts/README.md create mode 100755 samples/web-app-postgresql-database/python/scripts/deploy.sh create mode 100755 samples/web-app-postgresql-database/python/scripts/validate.sh create mode 100644 samples/web-app-postgresql-database/python/src/notes-app/Dockerfile create mode 100644 samples/web-app-postgresql-database/python/src/notes-app/app.py create mode 100644 samples/web-app-postgresql-database/python/src/notes-app/requirements.txt create mode 100644 samples/web-app-postgresql-database/python/src/sdk-dotnet/Dockerfile create mode 100644 samples/web-app-postgresql-database/python/src/sdk-dotnet/Program.cs create mode 100644 samples/web-app-postgresql-database/python/src/sdk-dotnet/SampleApp.csproj create mode 100644 samples/web-app-postgresql-database/python/src/sdk-python/Dockerfile create mode 100644 samples/web-app-postgresql-database/python/src/sdk-python/demo.py create mode 100644 samples/web-app-postgresql-database/python/src/sdk-python/requirements.txt create mode 100644 samples/web-app-postgresql-database/python/terraform/README.md create mode 100755 samples/web-app-postgresql-database/python/terraform/deploy.sh create mode 100644 samples/web-app-postgresql-database/python/terraform/main.tf create mode 100644 samples/web-app-postgresql-database/python/terraform/outputs.tf create mode 100644 samples/web-app-postgresql-database/python/terraform/providers.tf create mode 100644 samples/web-app-postgresql-database/python/terraform/variables.tf diff --git a/README.md b/README.md index 5539981..6ac3d85 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ This repository contains comprehensive sample projects demonstrating how to deve | [Function App and Storage](./samples/function-app-storage-http/dotnet/README.md) | Azure Functions App using Blob, Queue, and Table Storage | | [Function App and Front Door](./samples/function-app-front-door/python/README.md) | Azure Functions App exposed via Front Door | | [Function App and Managed Identities](./samples/function-app-managed-identity/python/README.md) | Azure Function App using Managed Identities | -| [Web App and CosmosDB for MongoDB API ](./samples/web-app-cosmosdb-mongodb-api/python/README.md) | Azure Web App using CosmosDB for MongoDB API | +| [Web App and PostgreSQL Database](./samples/web-app-postgresql-database/python/README.md) | Azure Web App using PostgreSQL Flexible Server | +| [Web App and CosmosDB for MongoDB API ](./samples/web-app-cosmosdb-mongodb-api/python/README.md) | Azure Web App using CosmosDB for MongoDB API | | [Web App and CosmosDB for NoSQL API ](./samples/web-app-cosmosdb-nosql-api/python/README.md) | Azure Web App using CosmosDB for NoSQL API | | [Web App and Managed Identities](./samples/web-app-managed-identity/python/README.md) | Azure Web App using Managed Identities | | [Web App and SQL Database ](./samples/web-app-sql-database/python/README.md) | Azure Web App using SQL Database | diff --git a/run-samples.sh b/run-samples.sh index 08e33cb..3bfa8bf 100644 --- a/run-samples.sh +++ b/run-samples.sh @@ -81,6 +81,7 @@ SAMPLES=( "samples/web-app-cosmosdb-mongodb-api/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-managed-identity/python|bash scripts/user-assigned.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-sql-database/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/get-web-app-url.sh" + "samples/web-app-postgresql-database/python|bash scripts/deploy.sh|bash scripts/validate.sh" ) # 3a. Define Terraform Samples @@ -90,6 +91,7 @@ TERRAFORM_SAMPLES=( "samples/web-app-cosmosdb-mongodb-api/python/terraform|bash deploy.sh" "samples/web-app-managed-identity/python/terraform|bash deploy.sh" "samples/web-app-sql-database/python/terraform|bash deploy.sh" + "samples/web-app-postgresql-database/python/terraform|bash deploy.sh" ) # 3b. Define Bicep Samples @@ -99,6 +101,7 @@ BICEP_SAMPLES=( "samples/function-app-storage-http/dotnet/bicep|bash deploy.sh" "samples/web-app-cosmosdb-mongodb-api/python/bicep|bash deploy.sh" "samples/web-app-managed-identity/python/bicep|bash deploy.sh" + "samples/web-app-postgresql-database/python/bicep|bash deploy.sh" ) # 4. Calculate Shard diff --git a/samples/function-app-storage-http/dotnet/scripts/deploy.sh b/samples/function-app-storage-http/dotnet/scripts/deploy.sh index 49e5b0c..9f10022 100644 --- a/samples/function-app-storage-http/dotnet/scripts/deploy.sh +++ b/samples/function-app-storage-http/dotnet/scripts/deploy.sh @@ -117,8 +117,14 @@ else exit 1 fi -# Construct the storage connection string for LocalStack -STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=$STORAGE_ACCOUNT_NAME;AccountKey=$STORAGE_ACCOUNT_KEY;EndpointSuffix=core.windows.net" +# Retrieve the storage connection string from the CLI so it uses the correct +# endpoints for the current environment (LocalStack or Azure). +echo "Getting storage connection string for [$STORAGE_ACCOUNT_NAME]..." +STORAGE_CONNECTION_STRING=$($AZ storage account show-connection-string \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query connectionString \ + --output tsv) # Set function app settings echo "Setting function app settings for [$FUNCTION_APP_NAME]..." diff --git a/samples/web-app-postgresql-database/python/README.md b/samples/web-app-postgresql-database/python/README.md new file mode 100644 index 0000000..02c900d --- /dev/null +++ b/samples/web-app-postgresql-database/python/README.md @@ -0,0 +1,206 @@ +# Azure Database for PostgreSQL Flexible Server + +This sample demonstrates a Notes web application with full-text search, powered by [Azure Database for PostgreSQL Flexible Server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview). The app is a Python Flask single-page application that creates, searches, and deletes notes stored in the `notes` table within the `sampledb` database on a PostgreSQL Flexible Server instance. The sample also includes Python and C# Azure SDK management demos that exercise server, database, configuration, and firewall rule operations. + +## Architecture + +The following diagram illustrates the architecture of the solution: + +```mermaid +graph TB + subgraph IaC["Infrastructure as Code"] + CLI["Azure CLI
(azlocal)"] + Bicep["Bicep
(main.bicep)"] + TF["Terraform
(main.tf)"] + end + + subgraph LS["LocalStack for Azure"] + MGMT["Management API
(ARM / REST)"] + PG_SERVER["PostgreSQL Flexible Server"] + subgraph DBs["Databases"] + SAMPLEDB["sampledb"] + ANALYTICSDB["analyticsdb"] + end + FW["Firewall Rules
allow-all · corporate · vpn"] + end + + subgraph DC["Docker Compose Containers"] + NOTES["Notes App
(Flask · port 5001)"] + SDK_PY["Python SDK Demo
(azure-mgmt-postgresql)"] + SDK_CS["C# SDK Demo
(Azure.ResourceManager)"] + end + + USER((User
Browser)) + + CLI -->|provision| MGMT + Bicep -->|provision| MGMT + TF -->|provision| MGMT + MGMT --> PG_SERVER + PG_SERVER --> DBs + PG_SERVER --> FW + + USER -->|"http://localhost:5001"| NOTES + NOTES <-->|"psycopg2 · CRUD + full-text search"| SAMPLEDB + + SDK_PY -->|"management plane
list servers · configs · DBs · rules"| MGMT + SDK_PY -->|"psycopg2 · direct query"| SAMPLEDB + SDK_PY -->|"POST results to UI"| NOTES + + SDK_CS -->|"management plane
ArmClient · list · get"| MGMT + SDK_CS -->|"Npgsql · direct query"| SAMPLEDB + SDK_CS -->|"POST results to UI"| NOTES + + USER -.->|"trigger SDK demo"| NOTES + NOTES -.->|"trigger signal"| SDK_PY + NOTES -.->|"trigger signal"| SDK_CS +``` + +- **Azure Database for PostgreSQL Flexible Server**: Managed PostgreSQL database server hosting the `sampledb` and `analyticsdb` databases +- **Notes App (Flask)**: Python Flask web application with full-text search using PostgreSQL `tsvector` +- **Python SDK Demo**: Demonstrates `azure-mgmt-postgresqlflexibleservers` management operations (list servers, get/update configurations, list databases, manage firewall rules, check name availability) +- **C# SDK Demo**: Demonstrates `Azure.ResourceManager.PostgreSql` management operations from .NET + +## Prerequisites + +- [Azure Subscription](https://azure.microsoft.com/free/) +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [Python 3.12+](https://www.python.org/downloads/) +- [Docker](https://docs.docker.com/get-docker/) +- [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` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and set it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure-alpha). To pull the image, execute: + +```bash +docker pull localstack/localstack-azure-alpha +``` + +Start the LocalStack Azure emulator by running: + +```bash +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure-alpha localstack start +``` + +Deploy the application to LocalStack for Azure using one of these methods: + +- [Azure CLI Deployment](./scripts/README.md) +- [Bicep Deployment](./bicep/README.md) +- [Terraform Deployment](./terraform/README.md) + +All deployment methods have been fully tested against Azure and the LocalStack for Azure local emulator. + +> **Note** +> When you deploy the application to LocalStack for Azure for the first time, the initialization process involves downloading and building Docker images. This is a one-time operation—subsequent deployments will be significantly faster. Depending on your internet connection and system resources, this initial setup may take several minutes. + +## Test + +After provisioning the PostgreSQL Flexible Server infrastructure using any of the deployment methods above, launch the application containers using Docker Compose: + +```bash +cd samples/postgresql-flexible-server/python + +# Set environment variables (adjust values based on your deployment outputs) +export RESOURCE_GROUP="rg-pgflex" +export SERVER_NAME="pgflex-sample" +export PG_HOST="host.docker.internal" +export PG_PORT="5432" +export PG_USER="pgadmin" +export PG_PASSWORD="P@ssw0rd12345!" +export PG_DATABASE="sampledb" +export FLASK_PORT="5001" + +docker compose up --build +``` + +Open a web browser and navigate to `http://localhost:5001`. If the deployment was successful, you will see the Notes application with full-text search and Azure SDK demo panels. + +You can use the `validate.sh` Bash script below to verify that all Azure resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +RESOURCE_GROUP='rg-pgflex' +SERVER_NAME='pgflex-sample' +PRIMARY_DB='sampledb' +SECONDARY_DB='analyticsdb' +ENVIRONMENT=$(az account show --query environmentName --output tsv) + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Check resource group +echo "=== Resource Group ===" +$AZ group show \ + --name "$RESOURCE_GROUP" \ + --output table + +# List resources in the resource group +echo "" +echo "=== Resources ===" +$AZ resource list \ + --resource-group "$RESOURCE_GROUP" \ + --output table + +# Check PostgreSQL Flexible Server +echo "" +echo "=== PostgreSQL Flexible Server ===" +$AZ postgres flexible-server show \ + --name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --output table + +# List databases +echo "" +echo "=== Databases ===" +$AZ postgres flexible-server db list \ + --server-name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --output table + +# List firewall rules +echo "" +echo "=== Firewall Rules ===" +$AZ postgres flexible-server firewall-rule list \ + --server-name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --output table +``` + +## PostgreSQL Tooling + +You can use [pgAdmin](https://www.pgadmin.org/) to explore and manage your PostgreSQL databases. When connecting, specify `localhost` as the host and use the port published by the PostgreSQL container on the host, mapped to the internal PostgreSQL port `5432`. + +Alternatively, you can use the [psql](https://www.postgresql.org/docs/current/app-psql.html) command-line tool to interact with and administer your PostgreSQL instance, as shown below: + +```bash +~$ psql -h localhost -p 49114 -U pgadmin -d sampledb +Password for user pgadmin: +psql (16.0) +Type "help" for help. + +sampledb=# SELECT id, title, SUBSTRING(content, 1, 30) FROM notes; + id | title | substring +----+----------------+-------------------------------- + 1 | Meeting Notes | Discussed Q4 planning and bud + 2 | Shopping List | Eggs, milk, bread, butter, ch + 3 | Project Ideas | Build a notes app with full-te +(3 rows) +``` + +## References + +- [Azure Database for PostgreSQL Flexible Server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview) +- [Azure CLI — PostgreSQL Flexible Server](https://learn.microsoft.com/en-us/cli/azure/postgres/flexible-server) +- [Terraform — azurerm_postgresql_flexible_server](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/postgresql_flexible_server) +- [Azure Identity Client Library for Python](https://learn.microsoft.com/en-us/python/api/overview/azure/identity-readme?view=azure-python) +- [LocalStack for Azure](https://azure.localstack.cloud/) diff --git a/samples/web-app-postgresql-database/python/bicep/README.md b/samples/web-app-postgresql-database/python/bicep/README.md new file mode 100644 index 0000000..3d95318 --- /dev/null +++ b/samples/web-app-postgresql-database/python/bicep/README.md @@ -0,0 +1,124 @@ +# Bicep Deployment + +This directory contains the Bicep template and a deployment script for provisioning Azure Database for PostgreSQL Flexible Server resources in LocalStack for Azure. Refer to the [Azure Database for PostgreSQL Flexible Server](../README.md) guide for details about the sample application. + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://azure.localstack.cloud/): Local Azure cloud emulator for development and testing +- [Visual Studio Code](https://code.visualstudio.com/): Code editor installed on one of the [supported platforms](https://code.visualstudio.com/docs/supporting/requirements#_platforms) +- [Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep): VS Code extension for Bicep language support and IntelliSense +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [azlocal CLI](https://azure.localstack.cloud/user-guides/sdks/az/): LocalStack Azure CLI wrapper +- [Python](https://www.python.org/downloads/): Python runtime (version 3.12 or above) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing azlocal CLI + +The [deploy.sh](deploy.sh) Bash script uses the `azlocal` CLI instead of the standard Azure CLI to work with LocalStack. Install it using: + +```bash +pip install azlocal +``` + +For more information, see [Get started with the az tool on LocalStack](https://azure.localstack.cloud/user-guides/sdks/az/). + +## Architecture Overview + +The [deploy.sh](deploy.sh) script creates the [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli) for all the Azure resources, while the [main.bicep](main.bicep) Bicep module creates the following Azure resources: + +1. [Azure Database for PostgreSQL Flexible Server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): Managed PostgreSQL database server with version 16. +2. [PostgreSQL Database](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers): The `sampledb` database for the Notes application. +3. [Firewall Rule](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-firewall-rules): Allows access from all IP addresses for development. + +For more information, see [Azure Database for PostgreSQL Flexible Server](../README.md). + +## Deployment + +You can set up the Azure emulator by utilizing LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and specify it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure-alpha). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure-alpha +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure-alpha localstack start +``` + +Navigate to the `bicep` folder: + +```bash +cd samples/postgresql-flexible-server/python/bicep +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +ENVIRONMENT=$(az account show --query environmentName --output tsv) + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Check resource group +$AZ group show \ +--name rg-pgflex-bicep \ +--output table + +# List resources +$AZ resource list \ +--resource-group rg-pgflex-bicep \ +--output table + +# Check PostgreSQL Flexible Server +$AZ postgres flexible-server list \ +--resource-group rg-pgflex-bicep \ +--output table +``` + +## Cleanup + +To destroy all created resources: + +```bash +# Delete resource group and all contained resources +azlocal group delete --name rg-pgflex-bicep --yes --no-wait + +# Verify deletion +azlocal group list --output table +``` + +This will remove all Azure resources created by the Bicep deployment script. + +## Related Documentation + +- [Azure Bicep Documentation](https://docs.microsoft.com/en-us/azure/azure-resource-manager/bicep/) +- [Bicep Language Reference](https://docs.microsoft.com/en-us/azure/azure-resource-manager/bicep/bicep-functions) +- [LocalStack for Azure Documentation](https://azure.localstack.cloud/) diff --git a/samples/web-app-postgresql-database/python/bicep/deploy.sh b/samples/web-app-postgresql-database/python/bicep/deploy.sh new file mode 100755 index 0000000..3fed3b9 --- /dev/null +++ b/samples/web-app-postgresql-database/python/bicep/deploy.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# Variables +RESOURCE_GROUP="rg-pgflex-bicep" +LOCATION='westeurope' +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENVIRONMENT=$(az account show --query environmentName --output tsv) + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Create resource group +echo "Creating resource group [$RESOURCE_GROUP]..." +$AZ group create \ + --name "$RESOURCE_GROUP" \ + --location "$LOCATION" \ + --output table + +if [[ $? != 0 ]]; then + echo "Failed to create resource group. Exiting." + exit 1 +fi + +# Deploy Bicep template +echo "Deploying Bicep template..." +$AZ deployment group create \ + --resource-group "$RESOURCE_GROUP" \ + --template-file main.bicep \ + --parameters main.bicepparam \ + --output table + +if [[ $? != 0 ]]; then + echo "Bicep deployment failed. Exiting." + exit 1 +fi + +# Get deployment outputs +echo "" +echo "=== Deployment Outputs ===" +$AZ deployment group show \ + --resource-group "$RESOURCE_GROUP" \ + --name main \ + --query "properties.outputs" \ + --output json + +echo "" +echo "Bicep deployment completed successfully." diff --git a/samples/web-app-postgresql-database/python/bicep/main.bicep b/samples/web-app-postgresql-database/python/bicep/main.bicep new file mode 100644 index 0000000..562279c --- /dev/null +++ b/samples/web-app-postgresql-database/python/bicep/main.bicep @@ -0,0 +1,111 @@ +// PostgreSQL Flexible Server sample — Bicep template +// +// Deploys a PostgreSQL Flexible Server with a database and firewall rule. +// +// NOTE: Bicep deployments use the LocalStack ARM template parser, which is an +// x86-64 binary. On ARM64 machines (e.g. Apple Silicon), this requires Rosetta +// or an x86-64 Docker environment. + +// Parameters +@description('Specifies the name prefix for PostgreSQL Flexible Server resources.') +@minLength(3) +@maxLength(10) +param serverNamePrefix string = 'pgflex' + +@description('Specifies the location for all resources.') +param location string = resourceGroup().location + +@description('Specifies the administrator login name.') +param administratorLogin string = 'pgadmin' + +@description('Specifies the administrator login password.') +@secure() +param administratorLoginPassword string + +@description('Specifies the PostgreSQL version.') +@allowed([ + '13' + '14' + '15' + '16' +]) +param version string = '16' + +@description('Specifies the SKU name for the server.') +param skuName string = 'B_Standard_B1ms' + +@description('Specifies the SKU tier for the server.') +@allowed([ + 'Burstable' + 'GeneralPurpose' + 'MemoryOptimized' +]) +param skuTier string = 'Burstable' + +@description('Specifies the storage size in GB.') +param storageSizeGB int = 32 + +@description('Specifies the name of the database to create.') +param databaseName string = 'sampledb' + +@description('Specifies the firewall rule name.') +param firewallRuleName string = 'allow-all' + +@description('Specifies the start IP address for the firewall rule.') +param firewallStartIp string = '0.0.0.0' + +@description('Specifies the end IP address for the firewall rule.') +param firewallEndIp string = '255.255.255.255' + +// Variables +var serverName = '${serverNamePrefix}-${uniqueString(resourceGroup().id)}' + +// Resources +resource server 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { + name: serverName + location: location + sku: { + name: skuName + tier: skuTier + } + properties: { + administratorLogin: administratorLogin + administratorLoginPassword: administratorLoginPassword + version: version + storage: { + storageSizeGB: storageSizeGB + } + highAvailability: { + mode: 'Disabled' + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + } +} + +resource database 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2024-08-01' = { + name: databaseName + parent: server + properties: { + charset: 'UTF8' + collation: 'en_US.utf8' + } +} + +resource firewallRule 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2024-08-01' = { + name: firewallRuleName + parent: server + properties: { + startIpAddress: firewallStartIp + endIpAddress: firewallEndIp + } +} + +// Outputs +output serverName string = server.name +output serverFqdn string = server.properties.fullyQualifiedDomainName +output databaseName string = database.name +output firewallRuleName string = firewallRule.name +output serverId string = server.id diff --git a/samples/web-app-postgresql-database/python/bicep/main.bicepparam b/samples/web-app-postgresql-database/python/bicep/main.bicepparam new file mode 100644 index 0000000..9f7d3ea --- /dev/null +++ b/samples/web-app-postgresql-database/python/bicep/main.bicepparam @@ -0,0 +1,13 @@ +using 'main.bicep' + +param serverNamePrefix = 'pgflex' +param administratorLogin = 'pgadmin' +param administratorLoginPassword = 'P@ssw0rd12345!' +param version = '16' +param skuName = 'B_Standard_B1ms' +param skuTier = 'Burstable' +param storageSizeGB = 32 +param databaseName = 'sampledb' +param firewallRuleName = 'allow-all' +param firewallStartIp = '0.0.0.0' +param firewallEndIp = '255.255.255.255' diff --git a/samples/web-app-postgresql-database/python/docker-compose.yml b/samples/web-app-postgresql-database/python/docker-compose.yml new file mode 100644 index 0000000..02a747b --- /dev/null +++ b/samples/web-app-postgresql-database/python/docker-compose.yml @@ -0,0 +1,73 @@ +# PostgreSQL Flexible Server — Sample Application +# +# Brings up three application containers after infrastructure is provisioned: +# 1. notes-app — Flask GUI with full-text search (port 5001) +# 2. sdk-python — Python Azure SDK management demo +# 3. sdk-dotnet — C# Azure SDK management demo +# +# Infrastructure (server, databases, firewall rules) must be provisioned BEFORE +# running docker-compose. Use the deploy scripts in scripts/, bicep/, or terraform/ +# which handle provisioning and then launch these containers with the correct +# environment variables. +# +# Environment variables are injected by the deploy scripts via export. + +services: + notes-app: + build: ./src/notes-app + container_name: pgflex-notes-app + ports: + - "${FLASK_PORT:-5001}:5001" + environment: + - PG_HOST=${PG_HOST} + - PG_PORT=${PG_PORT:-5432} + - PG_USER=${PG_USER:-pgadmin} + - PG_PASSWORD=${PG_PASSWORD:-P@ssw0rd12345!} + - PG_DATABASE=${PG_DATABASE:-sampledb} + - PORT=5001 + restart: "no" + + sdk-python: + build: ./src/sdk-python + container_name: pgflex-sdk-python + depends_on: + - notes-app + environment: + - AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID:-00000000-0000-0000-0000-000000000000} + - AZURE_TENANT_ID=${AZURE_TENANT_ID:-00000000-0000-0000-0000-000000000000} + - AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-00000000-0000-0000-0000-000000000000} + - AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-fake-secret} + - RESOURCE_GROUP=${RESOURCE_GROUP} + - SERVER_NAME=${SERVER_NAME} + - LOCALSTACK_HOST=${LOCALSTACK_HOST:-host.docker.internal:4566} + - NOTES_APP_URL=http://notes-app:5001 + - PG_HOST=${PG_HOST} + - PG_PORT=${PG_PORT:-5432} + - PG_USER=${PG_USER:-pgadmin} + - PG_PASSWORD=${PG_PASSWORD:-P@ssw0rd12345!} + - PG_DATABASE=${PG_DATABASE:-sampledb} + # Trust LocalStack's self-signed cert + - REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + - SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + restart: unless-stopped + + sdk-dotnet: + build: ./src/sdk-dotnet + container_name: pgflex-sdk-dotnet + depends_on: + - notes-app + environment: + - AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID:-00000000-0000-0000-0000-000000000000} + - AZURE_TENANT_ID=${AZURE_TENANT_ID:-00000000-0000-0000-0000-000000000000} + - AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-00000000-0000-0000-0000-000000000000} + - AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-fake-secret} + - RESOURCE_GROUP=${RESOURCE_GROUP} + - SERVER_NAME=${SERVER_NAME} + - LOCALSTACK_HOST=${LOCALSTACK_HOST:-host.docker.internal:4566} + - NOTES_APP_URL=http://notes-app:5001 + - PG_HOST=${PG_HOST} + - PG_PORT=${PG_PORT:-5432} + - PG_USER=${PG_USER:-pgadmin} + - PG_PASSWORD=${PG_PASSWORD:-P@ssw0rd12345!} + - PG_DATABASE=${PG_DATABASE:-sampledb} + restart: unless-stopped diff --git a/samples/web-app-postgresql-database/python/scripts/README.md b/samples/web-app-postgresql-database/python/scripts/README.md new file mode 100644 index 0000000..2c5ee5e --- /dev/null +++ b/samples/web-app-postgresql-database/python/scripts/README.md @@ -0,0 +1,94 @@ +# Azure CLI Deployment + +This directory contains Azure CLI scripts for provisioning Azure Database for PostgreSQL Flexible Server resources in LocalStack for Azure. Refer to the [Azure Database for PostgreSQL Flexible Server](../README.md) guide for details about the sample application. + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://azure.localstack.cloud/): Local Azure cloud emulator for development and testing +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [azlocal CLI](https://azure.localstack.cloud/user-guides/sdks/az/): LocalStack Azure CLI wrapper +- [Python](https://www.python.org/downloads/): Python runtime (version 3.12 or above) +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing azlocal CLI + +The [deploy.sh](deploy.sh) Bash script uses the `azlocal` CLI instead of the standard Azure CLI to work with LocalStack. Install it using: + +```bash +pip install azlocal +``` + +For more information, see [Get started with the az tool on LocalStack](https://azure.localstack.cloud/user-guides/sdks/az/). + +## Architecture Overview + +The [deploy.sh](deploy.sh) script creates the following Azure resources: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): Logical container for all resources. +2. [Azure Database for PostgreSQL Flexible Server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): Managed PostgreSQL database server with version 16. +3. [PostgreSQL Databases](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers): Two databases (`sampledb` and `analyticsdb`). +4. [Firewall Rules](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-firewall-rules): Three firewall rules for development access. + +## Deployment + +You can set up the Azure emulator by utilizing LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and specify it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure-alpha). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure-alpha +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure-alpha localstack start +``` + +Navigate to the `scripts` folder: + +```bash +cd samples/postgresql-flexible-server/python/scripts +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +chmod +x validate.sh +./validate.sh +``` + +## Cleanup + +To destroy all created resources: + +```bash +# Delete resource group and all contained resources +azlocal group delete --name rg-pgflex --yes --no-wait + +# Verify deletion +azlocal group list --output table +``` + +This will remove all Azure resources created by the CLI deployment script. + +## Related Documentation + +- [Azure CLI — PostgreSQL Flexible Server](https://learn.microsoft.com/en-us/cli/azure/postgres/flexible-server) +- [LocalStack for Azure Documentation](https://azure.localstack.cloud/) diff --git a/samples/web-app-postgresql-database/python/scripts/deploy.sh b/samples/web-app-postgresql-database/python/scripts/deploy.sh new file mode 100755 index 0000000..afb06ca --- /dev/null +++ b/samples/web-app-postgresql-database/python/scripts/deploy.sh @@ -0,0 +1,279 @@ +#!/bin/bash + +# Variables +PREFIX='local' +SUFFIX='test' +RESOURCE_GROUP='rg-pgflex' +SERVER_NAME='pgflex-sample' +LOCATION='westeurope' +ADMIN_USER='pgadmin' +ADMIN_PASSWORD='P@ssw0rd12345!' +PG_VERSION='16' +STORAGE_SIZE=32 +PRIMARY_DB='sampledb' +SECONDARY_DB='analyticsdb' +APP_SERVICE_PLAN_NAME="${PREFIX}-pgflex-plan-${SUFFIX}" +APP_SERVICE_PLAN_SKU="S1" +WEB_APP_NAME="${PREFIX}-pgflex-webapp-${SUFFIX}" +RUNTIME="python" +RUNTIME_VERSION="3.13" +ZIPFILE="notes_app.zip" +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +SAMPLE_DIR="$(cd "$CURRENT_DIR/.." && pwd)" +ENVIRONMENT=$(az account show --query environmentName --output tsv) + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Create resource group +echo "Creating resource group [$RESOURCE_GROUP]..." +$AZ group create \ + --name "$RESOURCE_GROUP" \ + --location "$LOCATION" \ + --output table + +if [[ $? != 0 ]]; then + echo "Failed to create resource group. Exiting." + exit 1 +fi + +# Create PostgreSQL Flexible Server +# Note: We use 'az rest' to call the management API directly because the +# 'az postgres flexible-server create' CLI command performs a SKU availability +# pre-check that fails on LocalStack (capabilities endpoint returns no SKUs). +# Using the REST API bypasses this client-side validation. +SUBSCRIPTION_ID=$($AZ account show --query id --output tsv) +echo "Creating PostgreSQL Flexible Server [$SERVER_NAME]..." +$AZ rest \ + --method PUT \ + --url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.DBforPostgreSQL/flexibleServers/${SERVER_NAME}?api-version=2024-08-01" \ + --body "{ + \"location\": \"${LOCATION}\", + \"properties\": { + \"version\": \"${PG_VERSION}\", + \"administratorLogin\": \"${ADMIN_USER}\", + \"administratorLoginPassword\": \"${ADMIN_PASSWORD}\", + \"storage\": { \"storageSizeGB\": ${STORAGE_SIZE} }, + \"createMode\": \"Default\" + }, + \"sku\": { + \"name\": \"Standard_B1ms\", + \"tier\": \"Burstable\" + } + }" \ + --output table + +if [[ $? != 0 ]]; then + echo "Failed to create PostgreSQL Flexible Server. Exiting." + exit 1 +fi + +echo "PostgreSQL Flexible Server [$SERVER_NAME] created successfully." + +# Wait for the server to be fully ready. +# The REST API returns immediately but the PostgreSQL container may still +# be initializing (admin role creation, etc.). +echo "Waiting for server to be fully provisioned..." +for i in $(seq 1 30); do + STATE=$($AZ postgres flexible-server show \ + --name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query "state" \ + --output tsv 2>/dev/null) + if [[ "$STATE" == "Ready" ]]; then + echo "Server is ready." + break + fi + echo " Server state: ${STATE:-unknown} (attempt $i/30)" + sleep 5 +done + +# Create primary database +echo "Creating database [$PRIMARY_DB]..." +$AZ postgres flexible-server db create \ + --server-name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --database-name "$PRIMARY_DB" \ + --charset "UTF8" \ + --collation "en_US.utf8" \ + --output table + +if [[ $? != 0 ]]; then + echo "Failed to create database [$PRIMARY_DB]. Exiting." + exit 1 +fi + +# Create secondary database +echo "Creating database [$SECONDARY_DB]..." +$AZ postgres flexible-server db create \ + --server-name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --database-name "$SECONDARY_DB" \ + --charset "UTF8" \ + --collation "en_US.utf8" \ + --output table + +if [[ $? != 0 ]]; then + echo "Failed to create database [$SECONDARY_DB]. Exiting." + exit 1 +fi + +# Create firewall rules +# Note: The firewall-rule subcommand uses --name/-n for the server name +# and --rule-name/-r for the rule name (unlike db create which uses --server-name). +echo "Creating firewall rule [allow-all]..." +$AZ postgres flexible-server firewall-rule create \ + --rule-name "allow-all" \ + --name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --start-ip-address "0.0.0.0" \ + --end-ip-address "255.255.255.255" \ + --output table + +echo "Creating firewall rule [corporate-network]..." +$AZ postgres flexible-server firewall-rule create \ + --rule-name "corporate-network" \ + --name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --start-ip-address "10.0.0.1" \ + --end-ip-address "10.0.255.255" \ + --output table + +echo "Creating firewall rule [vpn-access]..." +$AZ postgres flexible-server firewall-rule create \ + --rule-name "vpn-access" \ + --name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --start-ip-address "192.168.100.1" \ + --end-ip-address "192.168.100.254" \ + --output table + +# Retrieve the FQDN of the server +echo "Retrieving the FQDN of the [$SERVER_NAME] PostgreSQL Flexible Server..." +SERVER_FQDN=$($AZ postgres flexible-server show \ + --name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query "fullyQualifiedDomainName" \ + --output tsv) + +if [ -z "$SERVER_FQDN" ]; then + echo "Failed to retrieve the FQDN of the PostgreSQL Flexible Server" + exit 1 +fi + +echo "Server FQDN: $SERVER_FQDN" + +# Create App Service Plan +echo "Creating App Service Plan [$APP_SERVICE_PLAN_NAME]..." +$AZ appservice plan create \ + --resource-group "$RESOURCE_GROUP" \ + --name "$APP_SERVICE_PLAN_NAME" \ + --location "$LOCATION" \ + --sku "$APP_SERVICE_PLAN_SKU" \ + --is-linux \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "App Service Plan [$APP_SERVICE_PLAN_NAME] created successfully." +else + echo "Failed to create App Service Plan [$APP_SERVICE_PLAN_NAME]." + exit 1 +fi + +# Create the web app +echo "Creating web app [$WEB_APP_NAME]..." +$AZ webapp create \ + --resource-group "$RESOURCE_GROUP" \ + --plan "$APP_SERVICE_PLAN_NAME" \ + --name "$WEB_APP_NAME" \ + --runtime "$RUNTIME:$RUNTIME_VERSION" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] created successfully." +else + echo "Failed to create web app [$WEB_APP_NAME]." + exit 1 +fi + +# Set web app settings with PostgreSQL connection details +echo "Setting web app settings for [$WEB_APP_NAME]..." +$AZ webapp config appsettings set \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --settings \ + SCM_DO_BUILD_DURING_DEPLOYMENT='true' \ + ENABLE_ORYX_BUILD='true' \ + PG_HOST="$SERVER_FQDN" \ + PG_USER="$ADMIN_USER" \ + PG_PASSWORD="$ADMIN_PASSWORD" \ + PG_DATABASE="$PRIMARY_DB" \ + PG_PORT="5432" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app settings for [$WEB_APP_NAME] set successfully." +else + echo "Failed to set web app settings for [$WEB_APP_NAME]." + exit 1 +fi + +# Change to the notes-app source directory +cd "$SAMPLE_DIR/src/notes-app" || exit + +# Remove any existing zip package +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Create the zip package of the web app +echo "Creating zip package of the notes app..." +zip -r "$ZIPFILE" app.py requirements.txt + +# Deploy the web app +echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." +$AZ webapp deploy \ + --resource-group "$RESOURCE_GROUP" \ + --name "$WEB_APP_NAME" \ + --src-path "$ZIPFILE" \ + --type zip \ + --async true 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Web app [$WEB_APP_NAME] deployed successfully." +else + echo "Failed to deploy web app [$WEB_APP_NAME]." + exit 1 +fi + +# Clean up zip file +if [ -f "$ZIPFILE" ]; then + rm "$ZIPFILE" +fi + +# Get web app URL +WEB_APP_URL=$($AZ webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query "defaultHostName" \ + --output tsv) + +echo "" +echo "=== Deployment Complete ===" +echo "Resource Group: $RESOURCE_GROUP" +echo "Server Name: $SERVER_NAME" +echo "Server FQDN: $SERVER_FQDN" +echo "Primary DB: $PRIMARY_DB" +echo "Secondary DB: $SECONDARY_DB" +echo "Web App Name: $WEB_APP_NAME" +echo "Web App URL: https://$WEB_APP_URL" +echo "" diff --git a/samples/web-app-postgresql-database/python/scripts/validate.sh b/samples/web-app-postgresql-database/python/scripts/validate.sh new file mode 100755 index 0000000..0ae0a7d --- /dev/null +++ b/samples/web-app-postgresql-database/python/scripts/validate.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +# Variables +RESOURCE_GROUP='rg-pgflex' +SERVER_NAME='pgflex-sample' +PRIMARY_DB='sampledb' +SECONDARY_DB='analyticsdb' +WEB_APP_NAME='local-pgflex-webapp-test' +ENVIRONMENT=$(az account show --query environmentName --output tsv) + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Check resource group +echo "=== Resource Group ===" +$AZ group show \ + --name "$RESOURCE_GROUP" \ + --output table + +# List resources in the resource group +echo "" +echo "=== Resources ===" +$AZ resource list \ + --resource-group "$RESOURCE_GROUP" \ + --output table + +# Check PostgreSQL Flexible Server +echo "" +echo "=== PostgreSQL Flexible Server ===" +$AZ postgres flexible-server show \ + --name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --output table + +# List databases +echo "" +echo "=== Databases ===" +$AZ postgres flexible-server db list \ + --server-name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --output table + +# List firewall rules +echo "" +echo "=== Firewall Rules ===" +$AZ postgres flexible-server firewall-rule list \ + --name "$SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --output table + +# Check Web App +echo "" +echo "=== Web App ===" +$AZ webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --output table + +# Get web app URL and test the deployed application +WEB_APP_URL=$($AZ webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query "defaultHostName" \ + --output tsv) + +if [ -z "$WEB_APP_URL" ]; then + echo "Failed to retrieve Web App URL." + exit 1 +fi + +echo "" +echo "=== Testing Web App ===" +echo "Web App URL: https://$WEB_APP_URL" + +# Wait for the web app to be ready +echo "Waiting for web app to be ready..." +for i in $(seq 1 30); do + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$WEB_APP_URL/" --max-time 10 2>/dev/null) + if [[ "$HTTP_CODE" == "200" ]]; then + echo "Web app is responding (HTTP $HTTP_CODE)." + break + fi + echo " Attempt $i/30: HTTP $HTTP_CODE" + sleep 5 +done + +# Test: Create a note via the API +echo "" +echo "Creating a test note..." +CREATE_RESPONSE=$(curl -s -X POST "https://$WEB_APP_URL/api/notes" \ + -H "Content-Type: application/json" \ + -d '{"title":"Test Note","content":"Deployed on LocalStack"}' \ + --max-time 10) +echo "Create response: $CREATE_RESPONSE" + +# Test: List notes via the API +echo "" +echo "Listing notes..." +LIST_RESPONSE=$(curl -s "https://$WEB_APP_URL/api/notes" --max-time 10) +echo "List response: $LIST_RESPONSE" + +echo "" +echo "=== Validation Complete ===" diff --git a/samples/web-app-postgresql-database/python/src/notes-app/Dockerfile b/samples/web-app-postgresql-database/python/src/notes-app/Dockerfile new file mode 100644 index 0000000..23162c8 --- /dev/null +++ b/samples/web-app-postgresql-database/python/src/notes-app/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5001 +CMD ["python", "app.py"] diff --git a/samples/web-app-postgresql-database/python/src/notes-app/app.py b/samples/web-app-postgresql-database/python/src/notes-app/app.py new file mode 100644 index 0000000..0c69eaa --- /dev/null +++ b/samples/web-app-postgresql-database/python/src/notes-app/app.py @@ -0,0 +1,456 @@ +""" +Notes App — Azure Database for PostgreSQL on LocalStack. + +A notes app with full-text search powered by PostgreSQL tsvector, +demonstrating Azure Database for PostgreSQL Flexible Server running on +LocalStack. Serves as the GUI centerpiece for the unified sample that +also includes Python and C# Azure SDK management demos. +""" + +import os +import sys +import time + +import psycopg2 +from flask import Flask, jsonify, request +from psycopg2.extras import RealDictCursor + +PG_USER = os.environ.get("PG_USER", "pgadmin") +PG_PASSWORD = os.environ.get("PG_PASSWORD", "P@ssw0rd12345!") +PG_DATABASE = os.environ.get("PG_DATABASE", "sampledb") +PG_HOST = os.environ.get("PG_HOST", "localhost") +PG_PORT = int(os.environ.get("PG_PORT", "5432")) + + +def get_conn(): + """Return a fresh psycopg2 connection.""" + return psycopg2.connect( + host=PG_HOST, + port=PG_PORT, + user=PG_USER, + password=PG_PASSWORD, + dbname=PG_DATABASE, + ) + + +def wait_for_pg(max_retries: int = 30, delay: float = 2.0) -> None: + """Block until PostgreSQL accepts connections.""" + for attempt in range(1, max_retries + 1): + try: + conn = get_conn() + conn.close() + print(f"PostgreSQL is ready (attempt {attempt})") + return + except Exception: + if attempt == max_retries: + raise + print(f"Waiting for PostgreSQL... (attempt {attempt}/{max_retries})") + time.sleep(delay) + + +def init_db(): + """Create the notes table if it doesn't exist.""" + conn = get_conn() + try: + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS notes ( + id SERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + search_vector tsvector GENERATED ALWAYS AS ( + to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content, '')) + ) STORED + ) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_notes_search + ON notes USING gin(search_vector) + """) + conn.commit() + cur.close() + finally: + conn.close() + + +app = Flask(__name__) + +INDEX_HTML = r""" + + + + +Notes — Azure DB for PostgreSQL on LocalStack + + + + +
+

Notes

+

Full-text search powered by Azure Database for PostgreSQL

+ Running on LocalStack +
+ +
+ + +
+

Azure SDK Management Demos

+
+
Python SDK
+
C# SDK
+
+
+ + +
+
Click ▶ Run Demo to execute the Python Azure SDK management demo.
+ +
+ + + + + +
+
+ +
+ + + Ctrl+Enter +
+ + +
+ + +
+
+ +
+ Terraform / Bicep → Azure DB for PostgreSQL Flexible Server → Flask • + Python SDK • C# SDK • + LocalStack +
+ + + +""" + + +_sdk_status: dict[str, dict] = { + "python": {"status": "pending", "log": ""}, + "dotnet": {"status": "pending", "log": ""}, +} + +_sdk_trigger: dict[str, int] = {"python": 0, "dotnet": 0} + + +@app.route("/") +def index(): + return INDEX_HTML + + +@app.route("/api/notes", methods=["GET"]) +def list_notes(): + conn = get_conn() + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute( + "SELECT id, title, content, created_at FROM notes ORDER BY created_at DESC" + ) + notes = cur.fetchall() + for n in notes: + n["created_at"] = n["created_at"].isoformat() + return jsonify(notes) + finally: + conn.close() + + +@app.route("/api/notes", methods=["POST"]) +def create_note(): + data = request.get_json(force=True) + title = (data.get("title") or "").strip() + content = (data.get("content") or "").strip() + if not title or not content: + return jsonify({"error": "Title and content are required"}), 400 + + conn = get_conn() + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute( + "INSERT INTO notes (title, content) VALUES (%s, %s) RETURNING id, title, content, created_at", + (title, content), + ) + note = cur.fetchone() + note["created_at"] = note["created_at"].isoformat() + conn.commit() + return jsonify(note), 201 + finally: + conn.close() + + +@app.route("/api/notes/", methods=["DELETE"]) +def delete_note(note_id): + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("DELETE FROM notes WHERE id = %s", (note_id,)) + conn.commit() + return "", 204 + finally: + conn.close() + + +@app.route("/api/notes/search") +def search_notes(): + query = (request.args.get("q") or "").strip() + if not query: + return list_notes() + + conn = get_conn() + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute( + """ + SELECT id, title, content, created_at, + ts_rank(search_vector, plainto_tsquery('english', %s)) AS rank + FROM notes + WHERE search_vector @@ plainto_tsquery('english', %s) + ORDER BY rank DESC, created_at DESC + """, + (query, query), + ) + notes = cur.fetchall() + for n in notes: + n["created_at"] = n["created_at"].isoformat() + n.pop("rank", None) + return jsonify(notes) + finally: + conn.close() + + +@app.route("/api/sdk-status/", methods=["GET"]) +def get_sdk_status(lang): + if lang not in _sdk_status: + return jsonify({"error": "unknown sdk"}), 404 + return jsonify(_sdk_status[lang]) + + +@app.route("/api/sdk-status/", methods=["POST"]) +def post_sdk_status(lang): + if lang not in _sdk_status: + return jsonify({"error": "unknown sdk"}), 404 + data = request.get_json(force=True) + _sdk_status[lang] = { + "status": data.get("status", "running"), + "log": data.get("log", ""), + } + return jsonify({"ok": True}) + + +@app.route("/api/sdk-trigger/", methods=["POST"]) +def trigger_sdk(lang): + if lang not in _sdk_trigger: + return jsonify({"error": "unknown sdk"}), 404 + _sdk_trigger[lang] += 1 + _sdk_status[lang] = {"status": "pending", "log": ""} + return jsonify({"ok": True, "generation": _sdk_trigger[lang]}) + + +@app.route("/api/sdk-trigger/", methods=["GET"]) +def get_sdk_trigger(lang): + if lang not in _sdk_trigger: + return jsonify({"error": "unknown sdk"}), 404 + return jsonify({"generation": _sdk_trigger[lang]}) + + +_db_initialized = False + + +@app.before_request +def _ensure_db(): + """Initialize the database on the first request (works under gunicorn).""" + global _db_initialized + if not _db_initialized: + wait_for_pg(max_retries=10, delay=3.0) + init_db() + _db_initialized = True + + +if __name__ == "__main__": + print("Waiting for PostgreSQL to accept connections...") + wait_for_pg() + + print("Initializing database schema...") + try: + init_db() + _db_initialized = True + print("Database ready.") + except Exception as exc: + print(f"ERROR: Could not initialize database: {exc}", file=sys.stderr) + sys.exit(1) + + port = int(os.environ.get("PORT", "5001")) + print(f"\n Open http://localhost:{port} in your browser\n") + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/samples/web-app-postgresql-database/python/src/notes-app/requirements.txt b/samples/web-app-postgresql-database/python/src/notes-app/requirements.txt new file mode 100644 index 0000000..6b95dee --- /dev/null +++ b/samples/web-app-postgresql-database/python/src/notes-app/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +gunicorn>=22.0 +psycopg2-binary>=2.9 diff --git a/samples/web-app-postgresql-database/python/src/sdk-dotnet/Dockerfile b/samples/web-app-postgresql-database/python/src/sdk-dotnet/Dockerfile new file mode 100644 index 0000000..879621f --- /dev/null +++ b/samples/web-app-postgresql-database/python/src/sdk-dotnet/Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY SampleApp.csproj . +RUN dotnet restore +COPY Program.cs . +RUN dotnet publish -c Release -o /app + +FROM mcr.microsoft.com/dotnet/runtime:10.0 +WORKDIR /app +COPY --from=build /app . +CMD ["dotnet", "SampleApp.dll"] diff --git a/samples/web-app-postgresql-database/python/src/sdk-dotnet/Program.cs b/samples/web-app-postgresql-database/python/src/sdk-dotnet/Program.cs new file mode 100644 index 0000000..2576445 --- /dev/null +++ b/samples/web-app-postgresql-database/python/src/sdk-dotnet/Program.cs @@ -0,0 +1,338 @@ +// C# Azure SDK Management Demo — PostgreSQL Flexible Server on LocalStack. +// +// Demonstrates Azure.ResourceManager.PostgreSql operations: +// - List servers in a resource group +// - Get server properties +// - List configurations +// - List databases +// - List firewall rules +// - Check name availability +// - Connect with Npgsql and run queries +// +// Results are posted to the notes-app UI for live display. + +using System.Net.Http.Json; +using System.Net.Security; +using System.Text; +using System.Text.Json; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Identity; +using Azure.ResourceManager; +using Azure.ResourceManager.PostgreSql.FlexibleServers; +using Azure.ResourceManager.Resources; +using Npgsql; + +// --------------------------------------------------------------------------- +// Configuration (from docker-compose environment) +// --------------------------------------------------------------------------- + +var subscriptionId = Env("AZURE_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000"); +var tenantId = Env("AZURE_TENANT_ID", "00000000-0000-0000-0000-000000000000"); +var clientId = Env("AZURE_CLIENT_ID", "00000000-0000-0000-0000-000000000000"); +var clientSecret = Env("AZURE_CLIENT_SECRET", "fake-secret"); +var resourceGroup = Env("RESOURCE_GROUP", ""); +var serverName = Env("SERVER_NAME", ""); +var localstackHost = Env("LOCALSTACK_HOST", "localhost.localstack.cloud:4566"); +var notesAppUrl = Env("NOTES_APP_URL", "http://notes-app:5001"); + +var pgHost = Env("PG_HOST", ""); +var pgPort = int.Parse(Env("PG_PORT", "5432")); +var pgUser = Env("PG_USER", "pgadmin"); +var pgPassword = Env("PG_PASSWORD", "P@ssw0rd12345!"); +var pgDatabase = Env("PG_DATABASE", "sampledb"); + +string Env(string key, string fallback) => + Environment.GetEnvironmentVariable(key) ?? fallback; + +// --------------------------------------------------------------------------- +// Logging — capture output for the notes-app UI +// --------------------------------------------------------------------------- + +var logBuf = new StringBuilder(); +int step = 0, failures = 0; +var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + +void Log(string msg = "") +{ + Console.WriteLine(msg); + logBuf.AppendLine(msg); +} + +void Report(string label, bool success, string detail = "") +{ + step++; + if (!success) failures++; + var status = success ? "PASS" : "FAIL"; + var msg = $"[{step,2}] {status}: {label}"; + if (!string.IsNullOrEmpty(detail)) msg += $" -- {detail}"; + Log(msg); +} + +async Task FlushToNotesApp(bool final = false) +{ + try + { + var payload = new { status = final ? "done" : "running", log = logBuf.ToString() }; + await http.PostAsJsonAsync($"{notesAppUrl}/api/sdk-status/dotnet", payload); + } + catch { /* notes-app may not be ready yet */ } +} + +async Task WaitForNotesApp() +{ + for (int i = 0; i < 60; i++) + { + try + { + var r = await http.GetAsync($"{notesAppUrl}/api/sdk-status/dotnet"); + if (r.IsSuccessStatusCode) return; + } + catch { /* not ready yet */ } + await Task.Delay(3000); + } + Log("WARNING: Notes app not reachable, continuing without UI reporting"); +} + +// --------------------------------------------------------------------------- +// SDK client setup +// --------------------------------------------------------------------------- + +ArmClient CreateArmClient() +{ + var baseUri = new Uri($"https://{localstackHost}"); + + // Skip SSL validation for LocalStack's self-signed certificate + var httpHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + + var credential = new ClientSecretCredential(tenantId, clientId, clientSecret, + new ClientSecretCredentialOptions + { + AuthorityHost = baseUri, + DisableInstanceDiscovery = true, + Transport = new HttpClientTransport(httpHandler), + }); + + var options = new ArmClientOptions + { + Environment = new ArmEnvironment(baseUri, baseUri.AbsoluteUri), + Transport = new HttpClientTransport(httpHandler), + }; + + return new ArmClient(credential, subscriptionId, options); +} + +// --------------------------------------------------------------------------- +// Main execution — poll for trigger, run demo on demand +// --------------------------------------------------------------------------- + +await WaitForNotesApp(); +try { await http.PostAsJsonAsync($"{notesAppUrl}/api/sdk-status/dotnet", new { status = "idle", log = "" }); } +catch { /* not ready */ } + +Console.WriteLine("C# SDK demo container ready — waiting for trigger..."); + +int lastGeneration = 0; +while (true) +{ + try + { + var triggerResp = await http.GetAsync($"{notesAppUrl}/api/sdk-trigger/dotnet"); + if (triggerResp.IsSuccessStatusCode) + { + var json = await triggerResp.Content.ReadFromJsonAsync(); + int gen = json.GetProperty("generation").GetInt32(); + if (gen > lastGeneration) + { + lastGeneration = gen; + Console.WriteLine($"Trigger received (generation={lastGeneration}), running demo..."); + await RunDemo(); + Console.WriteLine("Demo complete — waiting for next trigger..."); + } + } + } + catch { /* transient error, retry */ } + await Task.Delay(2000); +} + +async Task RunDemo() +{ + logBuf.Clear(); + step = 0; + failures = 0; + + Log(new string('=', 60)); + Log("C# Azure SDK — PostgreSQL Flexible Server Demo"); + Log(new string('=', 60)); + Log(); + await FlushToNotesApp(); + + ArmClient armClient; + try + { + armClient = CreateArmClient(); + Report("Create ARM client", true, "ArmClient ready"); + } + catch (Exception ex) + { + Report("Create ARM client", false, ex.Message); + await FlushToNotesApp(final: true); + return; + } + await FlushToNotesApp(); + + ResourceGroupResource rg; + try + { + var sub = armClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{subscriptionId}")); + var rgResponse = await sub.GetResourceGroups().GetAsync(resourceGroup); + rg = rgResponse.Value; + Report("Get resource group", true, $"name={rg.Data.Name}"); + } + catch (Exception ex) + { + Report("Get resource group", false, ex.Message); + await FlushToNotesApp(final: true); + return; + } + await FlushToNotesApp(); + + // List Servers + Log(); + Log(new string('=', 60)); + Log("List Servers in Resource Group"); + Log(new string('=', 60)); + + var servers = new List(); + await foreach (var s in rg.GetPostgreSqlFlexibleServers().GetAllAsync()) + servers.Add(s); + + Report("List servers", servers.Count >= 1, $"found {servers.Count} server(s)"); + foreach (var s in servers) + Log($" - {s.Data.Name} version={s.Data.Version} state={s.Data.State} fqdn={s.Data.FullyQualifiedDomainName}"); + + await FlushToNotesApp(); + + // Get Server Properties + Log(); + Log(new string('=', 60)); + Log("Get Server Properties"); + Log(new string('=', 60)); + + PostgreSqlFlexibleServerResource server; + try + { + var resp = await rg.GetPostgreSqlFlexibleServers().GetAsync(serverName); + server = resp.Value; + Report("Get server", server.Data.Name == serverName, $"name={server.Data.Name}"); + Report("Server version", server.Data.Version?.ToString() == "16", $"version={server.Data.Version}"); + Report("Server SKU", server.Data.Sku != null, $"sku={server.Data.Sku?.Name ?? "N/A"}"); + } + catch (Exception ex) + { + Report("Get server", false, ex.Message); + await FlushToNotesApp(final: true); + return; + } + await FlushToNotesApp(); + + // List Configurations + Log(); + Log(new string('=', 60)); + Log("List Configurations"); + Log(new string('=', 60)); + + var configs = new List(); + await foreach (var c in server.GetPostgreSqlFlexibleServerConfigurations().GetAllAsync()) + configs.Add(c); + + Report("List configurations", configs.Count > 0, $"found {configs.Count} parameter(s)"); + + var interesting = new HashSet { "max_connections", "shared_buffers", "work_mem", "log_min_duration_statement" }; + foreach (var c in configs.Where(c => interesting.Contains(c.Data.Name))) + Log($" - {c.Data.Name} = {c.Data.Value} (default: {c.Data.DefaultValue})"); + + await FlushToNotesApp(); + + // List Databases + Log(); + Log(new string('=', 60)); + Log("List Databases"); + Log(new string('=', 60)); + + var databases = new List(); + await foreach (var d in server.GetPostgreSqlFlexibleServerDatabases().GetAllAsync()) + databases.Add(d); + + var dbNames = databases.Select(d => d.Data.Name).ToList(); + Report("List databases", databases.Count >= 1, $"found: {string.Join(", ", dbNames)}"); + Report("Primary DB exists", dbNames.Contains("sampledb"), "sampledb"); + Report("Secondary DB exists", dbNames.Contains("analyticsdb"), "analyticsdb"); + await FlushToNotesApp(); + + // List Firewall Rules + Log(); + Log(new string('=', 60)); + Log("List Firewall Rules"); + Log(new string('=', 60)); + + var rules = new List(); + await foreach (var r in server.GetPostgreSqlFlexibleServerFirewallRules().GetAllAsync()) + rules.Add(r); + + var ruleNames = rules.Select(r => r.Data.Name).ToList(); + Report("List firewall rules", rules.Count >= 1, $"found: {string.Join(", ", ruleNames)}"); + + var expected = new[] { "allow-all", "corporate-network", "vpn-access" }; + var allPresent = expected.All(e => ruleNames.Contains(e)); + Report("Expected rules present", allPresent, $"expected={string.Join(",", expected)}"); + await FlushToNotesApp(); + + // Direct PostgreSQL Connection (Npgsql) + Log(); + Log(new string('=', 60)); + Log("Direct PostgreSQL Connection (Npgsql)"); + Log(new string('=', 60)); + + if (!string.IsNullOrEmpty(pgHost)) + { + try + { + var connStr = $"Host={pgHost};Port={pgPort};Username={pgUser};Password={pgPassword};Database={pgDatabase};Timeout=10"; + await using var conn = new NpgsqlConnection(connStr); + await conn.OpenAsync(); + Report("Connect to PostgreSQL", true, $"{pgHost}:{pgPort}/{pgDatabase}"); + + await using var versionCmd = new NpgsqlCommand("SELECT version()", conn); + var version = (await versionCmd.ExecuteScalarAsync())?.ToString() ?? ""; + Report("Get PG version", version.Contains("PostgreSQL"), version.Length > 60 ? version[..60] : version); + + await using var tablesCmd = new NpgsqlCommand( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'", conn); + var count = Convert.ToInt32(await tablesCmd.ExecuteScalarAsync()); + Report("Query information_schema", true, $"{count} public table(s)"); + } + catch (Exception ex) + { + Report("Connect to PostgreSQL", false, ex.Message); + } + } + else + { + Log(" PG_HOST not set, skipping direct connection test"); + } + await FlushToNotesApp(); + + // Summary + Log(); + Log(new string('=', 60)); + var passed = step - failures; + Log($"TOTAL: {passed}/{step} tests passed"); + Log(new string('=', 60)); + Log(failures == 0 ? "ALL TESTS PASSED" : $"{failures} TEST(S) FAILED"); + + await FlushToNotesApp(final: true); +} diff --git a/samples/web-app-postgresql-database/python/src/sdk-dotnet/SampleApp.csproj b/samples/web-app-postgresql-database/python/src/sdk-dotnet/SampleApp.csproj new file mode 100644 index 0000000..2daf7ff --- /dev/null +++ b/samples/web-app-postgresql-database/python/src/sdk-dotnet/SampleApp.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/samples/web-app-postgresql-database/python/src/sdk-python/Dockerfile b/samples/web-app-postgresql-database/python/src/sdk-python/Dockerfile new file mode 100644 index 0000000..3f3ac59 --- /dev/null +++ b/samples/web-app-postgresql-database/python/src/sdk-python/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.13-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY demo.py . + +CMD ["python", "-u", "demo.py"] diff --git a/samples/web-app-postgresql-database/python/src/sdk-python/demo.py b/samples/web-app-postgresql-database/python/src/sdk-python/demo.py new file mode 100644 index 0000000..cb94aaf --- /dev/null +++ b/samples/web-app-postgresql-database/python/src/sdk-python/demo.py @@ -0,0 +1,361 @@ +""" +Python Azure SDK Management Demo — PostgreSQL Flexible Server on LocalStack. + +Demonstrates azure-mgmt-postgresqlflexibleservers operations: + - List servers in a resource group + - Get server properties + - List and update configurations + - List databases + - Manage firewall rules + - Check name availability + - Connect with psycopg2 and run queries + +Results are posted to the notes-app UI for live display. +""" + +import io +import os +import time + +import psycopg2 +import requests +import urllib3 +from azure.identity import ClientSecretCredential +from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient +from azure.mgmt.resource import ResourceManagementClient + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +SUBSCRIPTION_ID = os.environ.get( + "AZURE_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000" +) +TENANT_ID = os.environ.get("AZURE_TENANT_ID", "00000000-0000-0000-0000-000000000000") +CLIENT_ID = os.environ.get("AZURE_CLIENT_ID", "00000000-0000-0000-0000-000000000000") +CLIENT_SECRET = os.environ.get("AZURE_CLIENT_SECRET", "fake-secret") +RESOURCE_GROUP = os.environ.get("RESOURCE_GROUP", "") +SERVER_NAME = os.environ.get("SERVER_NAME", "") +LOCALSTACK_HOST = os.environ.get("LOCALSTACK_HOST", "localhost.localstack.cloud:4566") +NOTES_APP_URL = os.environ.get("NOTES_APP_URL", "http://notes-app:5001") + +PG_HOST = os.environ.get("PG_HOST", "") +PG_PORT = int(os.environ.get("PG_PORT", "5432")) +PG_USER = os.environ.get("PG_USER", "pgadmin") +PG_PASSWORD = os.environ.get("PG_PASSWORD", "P@ssw0rd12345!") +PG_DATABASE = os.environ.get("PG_DATABASE", "sampledb") + + +_log_buf = io.StringIO() +step = 0 +failures = 0 + + +def log(msg: str = "") -> None: + print(msg) + _log_buf.write(msg + "\n") + + +def report(label: str, success: bool, detail: str = "") -> None: + global step, failures + step += 1 + if not success: + failures += 1 + status = "PASS" if success else "FAIL" + msg = f"[{step:>2}] {status}: {label}" + if detail: + msg += f" -- {detail}" + log(msg) + + +def flush_to_notes_app(final: bool = False) -> None: + """POST current log to the notes-app SDK status endpoint.""" + status = "done" if final else "running" + try: + requests.post( + f"{NOTES_APP_URL}/api/sdk-status/python", + json={"status": status, "log": _log_buf.getvalue()}, + timeout=5, + ) + except Exception: + pass + + +def wait_for_notes_app(max_retries: int = 60, delay: float = 3.0) -> None: + """Wait for the notes-app to be reachable.""" + for _attempt in range(1, max_retries + 1): + try: + r = requests.get(f"{NOTES_APP_URL}/api/sdk-status/python", timeout=3) + if r.status_code == 200: + return + except Exception: + pass + time.sleep(delay) + log("WARNING: Notes app not reachable, continuing without UI reporting") + + +def get_clients() -> tuple: + """Create Azure SDK clients pointed at LocalStack.""" + base_url = f"https://{LOCALSTACK_HOST}" + credential = ClientSecretCredential( + tenant_id=TENANT_ID, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + authority=f"https://{LOCALSTACK_HOST}", + disable_instance_discovery=True, + connection_verify=False, + ) + pg_client = PostgreSQLManagementClient( + credential=credential, + subscription_id=SUBSCRIPTION_ID, + base_url=base_url, + connection_verify=False, + ) + rm_client = ResourceManagementClient( + credential=credential, + subscription_id=SUBSCRIPTION_ID, + base_url=base_url, + connection_verify=False, + ) + return pg_client, rm_client + + +def demo_list_servers(pg: PostgreSQLManagementClient) -> None: + log("=" * 60) + log("List Servers in Resource Group") + log("=" * 60) + + servers = list(pg.servers.list_by_resource_group(RESOURCE_GROUP)) + report("List servers", len(servers) >= 1, f"found {len(servers)} server(s)") + + for s in servers: + log( + f" - {s.name} version={s.version} state={s.state} fqdn={s.fully_qualified_domain_name}" + ) + + +def demo_get_server(pg: PostgreSQLManagementClient) -> None: + log("") + log("=" * 60) + log("Get Server Properties") + log("=" * 60) + + server = pg.servers.get(RESOURCE_GROUP, SERVER_NAME) + report("Get server", server.name == SERVER_NAME, f"name={server.name}") + report("Server version", server.version == "16", f"version={server.version}") + report( + "Server SKU", + server.sku is not None, + f"sku={server.sku.name if server.sku else 'N/A'}", + ) + report( + "Public access enabled", + server.network is not None, + f"public_access={getattr(server.network, 'public_network_access', 'N/A')}", + ) + + +def demo_configurations(pg: PostgreSQLManagementClient) -> None: + log("") + log("=" * 60) + log("List and Update Configurations") + log("=" * 60) + + configs = list(pg.configurations.list_by_server(RESOURCE_GROUP, SERVER_NAME)) + report( + "List configurations", len(configs) > 0, f"found {len(configs)} parameter(s)" + ) + + interesting = { + "max_connections", + "shared_buffers", + "work_mem", + "log_min_duration_statement", + } + for c in configs: + if c.name in interesting: + log(f" - {c.name} = {c.value} (default: {c.default_value})") + + +def demo_databases(pg: PostgreSQLManagementClient) -> None: + log("") + log("=" * 60) + log("List Databases") + log("=" * 60) + + databases = list(pg.databases.list_by_server(RESOURCE_GROUP, SERVER_NAME)) + db_names = [d.name for d in databases] + report("List databases", len(databases) >= 1, f"found: {', '.join(db_names)}") + report("Primary DB exists", "sampledb" in db_names, "sampledb") + report("Secondary DB exists", "analyticsdb" in db_names, "analyticsdb") + + +def demo_firewall_rules(pg: PostgreSQLManagementClient) -> None: + log("") + log("=" * 60) + log("List Firewall Rules") + log("=" * 60) + + rules = list(pg.firewall_rules.list_by_server(RESOURCE_GROUP, SERVER_NAME)) + rule_names = [r.name for r in rules] + report("List firewall rules", len(rules) >= 1, f"found: {', '.join(rule_names)}") + + expected = {"allow-all", "corporate-network", "vpn-access"} + report( + "Expected rules present", + expected.issubset(set(rule_names)), + f"expected={expected}", + ) + + +def demo_name_availability(pg: PostgreSQLManagementClient) -> None: + log("") + log("=" * 60) + log("Check Name Availability") + log("=" * 60) + + from azure.mgmt.postgresqlflexibleservers.models import CheckNameAvailabilityRequest + + result = pg.name_availability.check_globally( + CheckNameAvailabilityRequest(name=SERVER_NAME) + ) + report( + "Check existing name", + result.name_available is not None, + f"name={SERVER_NAME}, available={result.name_available}", + ) + + new_name = "pgflex-avail-test-12345" + result = pg.name_availability.check_globally( + CheckNameAvailabilityRequest(name=new_name) + ) + report("New name available", result.name_available is True, f"name={new_name}") + + +def demo_psycopg2_connection() -> None: + log("") + log("=" * 60) + log("Direct PostgreSQL Connection (psycopg2)") + log("=" * 60) + + if not PG_HOST: + log(" PG_HOST not set, skipping direct connection test") + return + + try: + conn = psycopg2.connect( + host=PG_HOST, + port=PG_PORT, + user=PG_USER, + password=PG_PASSWORD, + dbname=PG_DATABASE, + connect_timeout=10, + ) + report("Connect to PostgreSQL", True, f"{PG_HOST}:{PG_PORT}/{PG_DATABASE}") + + cur = conn.cursor() + cur.execute("SELECT version()") + version = cur.fetchone()[0] + report("Get PG version", "PostgreSQL" in version, version[:60]) + + cur.execute( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'" + ) + count = cur.fetchone()[0] + report("Query information_schema", True, f"{count} public table(s)") + + cur.close() + conn.close() + except Exception as exc: + report("Connect to PostgreSQL", False, str(exc)) + + +def run_demo() -> None: + """Execute the full demo suite once.""" + global step, failures, _log_buf + step = 0 + failures = 0 + _log_buf = io.StringIO() + + log("=" * 60) + log("Python Azure SDK — PostgreSQL Flexible Server Demo") + log("=" * 60) + log("") + flush_to_notes_app() + + try: + pg, _ = get_clients() + except Exception as exc: + report("Create SDK clients", False, str(exc)) + flush_to_notes_app(final=True) + return + + report("Create SDK clients", True, "PostgreSQLManagementClient ready") + flush_to_notes_app() + + demo_list_servers(pg) + flush_to_notes_app() + + demo_get_server(pg) + flush_to_notes_app() + + demo_configurations(pg) + flush_to_notes_app() + + demo_databases(pg) + flush_to_notes_app() + + demo_firewall_rules(pg) + flush_to_notes_app() + + demo_name_availability(pg) + flush_to_notes_app() + + demo_psycopg2_connection() + flush_to_notes_app() + + log("") + log("=" * 60) + total = step + passed = total - failures + log(f"TOTAL: {passed}/{total} tests passed") + log("=" * 60) + if failures == 0: + log("ALL TESTS PASSED") + else: + log(f"{failures} TEST(S) FAILED") + + flush_to_notes_app(final=True) + + +def main() -> None: + """Poll for trigger from notes-app, run demo on demand.""" + wait_for_notes_app() + + try: + requests.post( + f"{NOTES_APP_URL}/api/sdk-status/python", + json={"status": "idle", "log": ""}, + timeout=5, + ) + except Exception: + pass + + print("Python SDK demo container ready — waiting for trigger...") + + last_generation = 0 + while True: + try: + r = requests.get(f"{NOTES_APP_URL}/api/sdk-trigger/python", timeout=5) + if r.ok: + gen = r.json().get("generation", 0) + if gen > last_generation: + last_generation = gen + print(f"Trigger received (generation={gen}), running demo...") + run_demo() + print("Demo complete — waiting for next trigger...") + except Exception: + pass + time.sleep(2) + + +if __name__ == "__main__": + main() diff --git a/samples/web-app-postgresql-database/python/src/sdk-python/requirements.txt b/samples/web-app-postgresql-database/python/src/sdk-python/requirements.txt new file mode 100644 index 0000000..28f3a5b --- /dev/null +++ b/samples/web-app-postgresql-database/python/src/sdk-python/requirements.txt @@ -0,0 +1,5 @@ +azure-identity +azure-mgmt-postgresqlflexibleservers>=2.0.0 +azure-mgmt-resource +requests +psycopg2-binary>=2.9 diff --git a/samples/web-app-postgresql-database/python/terraform/README.md b/samples/web-app-postgresql-database/python/terraform/README.md new file mode 100644 index 0000000..107272f --- /dev/null +++ b/samples/web-app-postgresql-database/python/terraform/README.md @@ -0,0 +1,151 @@ +# Terraform Deployment + +This directory contains Terraform modules and a deployment script for provisioning Azure Database for PostgreSQL Flexible Server resources in LocalStack for Azure. Refer to the [Azure Database for PostgreSQL Flexible Server](../README.md) guide for details about the sample application. + +## Prerequisites + +Before deploying this solution, ensure you have the following tools installed: + +- [LocalStack for Azure](https://azure.localstack.cloud/): Local Azure cloud emulator for development and testing +- [Visual Studio Code](https://code.visualstudio.com/): Code editor installed on one of the [supported platforms](https://code.visualstudio.com/docs/supporting/requirements#_platforms) +- [Terraform](https://developer.hashicorp.com/terraform/downloads): Infrastructure as Code tool for provisioning Azure resources +- [Python 3.12+](https://www.python.org/downloads/): Required for running the Flask web application +- [Docker](https://docs.docker.com/get-docker/): Container runtime required for LocalStack +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli): Azure command-line interface +- [azlocal CLI](https://azure.localstack.cloud/user-guides/sdks/az/): LocalStack Azure CLI wrapper +- [jq](https://jqlang.org/): JSON processor for scripting and parsing command outputs + +### Installing azlocal CLI + +The [deploy.sh](deploy.sh) Bash script uses the `azlocal` CLI instead of the standard Azure CLI to work with LocalStack. Install it using: + +```bash +pip install azlocal +``` + +For more information, see [Get started with the az tool on LocalStack](https://azure.localstack.cloud/user-guides/sdks/az/). + +## Architecture Overview + +The [main.tf](main.tf) Terraform module creates the following Azure resources: + +1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): Logical container for all resources. +2. [Azure Database for PostgreSQL Flexible Server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): Managed PostgreSQL database server with version 16. +3. [PostgreSQL Databases](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers): Two databases (`sampledb` and `analyticsdb`). +4. [Firewall Rules](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-firewall-rules): Three firewall rules (`allow-all`, `corporate-network`, `vpn-access`). + +## Configuration + +When using LocalStack for Azure, configure the `metadata_host` and `subscription_id` settings in the [Azure Provider for Terraform](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) to ensure proper connectivity: + + +```hcl +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } + + # Set the hostname of the Azure Metadata Service (for example management.azure.com) + # used to obtain the Cloud Environment when using LocalStack's Azure emulator. + # This allows the provider to correctly identify the environment and avoid making calls to the real Azure endpoints. + metadata_host="localhost.localstack.cloud:4566" + + # Set the subscription ID to a dummy value when using LocalStack's Azure emulator. + subscription_id = "00000000-0000-0000-0000-000000000000" +} +``` + +## Deployment + +You can set up the Azure emulator by utilizing LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/) to obtain your Auth Token and specify it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure-alpha). To pull the Azure Docker image, execute the following command: + +```bash +docker pull localstack/localstack-azure-alpha +``` + +Start the LocalStack Azure emulator using the localstack CLI, execute the following command: + +```bash +export LOCALSTACK_AUTH_TOKEN= +IMAGE_NAME=localstack/localstack-azure-alpha localstack start +``` + +Navigate to the `terraform` folder: + +```bash +cd samples/postgresql-flexible-server/python/terraform +``` + +Make the script executable: + +```bash +chmod +x deploy.sh +``` + +Run the deployment script: + +```bash +./deploy.sh +``` + +## Validation + +After deployment, you can use the `validate.sh` script to verify that all resources were created and configured correctly: + +```bash +#!/bin/bash + +# Variables +ENVIRONMENT=$(az account show --query environmentName --output tsv) + +# Choose the appropriate CLI based on the environment +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +# Get resource group name from Terraform output +RESOURCE_GROUP=$(terraform output -raw resource_group_name) + +# Check resource group +$AZ group show \ +--name "$RESOURCE_GROUP" \ +--output table + +# List resources +$AZ resource list \ +--resource-group "$RESOURCE_GROUP" \ +--output table + +# Check PostgreSQL Flexible Server +SERVER_NAME=$(terraform output -raw server_name) +$AZ postgres flexible-server show \ +--name "$SERVER_NAME" \ +--resource-group "$RESOURCE_GROUP" \ +--output table +``` + +## Cleanup + +To destroy all created resources: + +```bash +# Destroy Terraform-managed resources +terraform destroy -auto-approve + +# Verify deletion +azlocal group list --output table +``` + +This will remove all Azure resources created by the Terraform deployment script. + +## Related Documentation + +- [Terraform Azure Provider](https://registry.terraform.io/providers/hashicorp/azurerm/latest) +- [Terraform — azurerm_postgresql_flexible_server](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/postgresql_flexible_server) +- [LocalStack for Azure Documentation](https://azure.localstack.cloud/) diff --git a/samples/web-app-postgresql-database/python/terraform/deploy.sh b/samples/web-app-postgresql-database/python/terraform/deploy.sh new file mode 100755 index 0000000..9a17d60 --- /dev/null +++ b/samples/web-app-postgresql-database/python/terraform/deploy.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Variables +CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENVIRONMENT=$(az account show --query environmentName --output tsv) + +# Change the current directory to the script's directory +cd "$CURRENT_DIR" || exit + +if [[ $ENVIRONMENT == "LocalStack" ]]; then + echo "Using azlocal for LocalStack emulator environment." + AZ="azlocal" +else + echo "Using standard az for AzureCloud environment." + AZ="az" +fi + +echo "Initializing Terraform..." +terraform init -upgrade + +# Run terraform plan and check for errors +echo "Planning Terraform deployment..." +terraform plan -out=tfplan + +# Apply the Terraform configuration +echo "Applying Terraform configuration..." +terraform apply -auto-approve tfplan + +if [[ $? != 0 ]]; then + echo "Terraform apply failed. Exiting." + exit 1 +fi + +# Get the output values +echo "" +echo "=== Terraform Outputs ===" +terraform output + +RESOURCE_GROUP_NAME=$(terraform output -raw resource_group_name) +SERVER_NAME=$(terraform output -raw server_name) +SERVER_FQDN=$(terraform output -raw server_fqdn) +DATABASE_NAME=$(terraform output -raw database_name) + +echo "" +echo "=== Deployment Complete ===" +echo "Resource Group: $RESOURCE_GROUP_NAME" +echo "Server Name: $SERVER_NAME" +echo "Server FQDN: $SERVER_FQDN" +echo "Database: $DATABASE_NAME" diff --git a/samples/web-app-postgresql-database/python/terraform/main.tf b/samples/web-app-postgresql-database/python/terraform/main.tf new file mode 100644 index 0000000..52916c9 --- /dev/null +++ b/samples/web-app-postgresql-database/python/terraform/main.tf @@ -0,0 +1,97 @@ +############################################################################### +# PostgreSQL Flexible Server Sample - Terraform Configuration +# +# Creates a PostgreSQL Flexible Server with databases and firewall rules +# on LocalStack for Azure. +# +# Provider configuration is in providers.tf +# Variable definitions are in variables.tf +# Output definitions are in outputs.tf +############################################################################### + +resource "random_uuid" "uuid" {} + +locals { + server_name = "pgflex-${substr(random_uuid.uuid.result, 0, 6)}" +} + +############################################################################### +# Resource Group +############################################################################### + +resource "azurerm_resource_group" "rg" { + name = "rg-pgflex-${random_uuid.uuid.result}" + location = var.location + + tags = var.tags +} + +############################################################################### +# PostgreSQL Flexible Server +############################################################################### + +resource "azurerm_postgresql_flexible_server" "pg" { + name = local.server_name + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + version = var.postgresql_version + administrator_login = var.administrator_login + administrator_password = var.administrator_password + storage_mb = var.storage_mb + sku_name = var.sku_name + zone = "1" + public_network_access_enabled = var.public_network_access_enabled + + # Backup configuration + backup_retention_days = var.backup_retention_days + geo_redundant_backup_enabled = var.geo_redundant_backup_enabled + + tags = var.tags +} + +############################################################################### +# Databases +############################################################################### + +resource "azurerm_postgresql_flexible_server_database" "primary_db" { + name = var.primary_database_name + server_id = azurerm_postgresql_flexible_server.pg.id + charset = "UTF8" + collation = "en_US.utf8" +} + +# Second database to test multiple database support +resource "azurerm_postgresql_flexible_server_database" "secondary_db" { + name = var.secondary_database_name + server_id = azurerm_postgresql_flexible_server.pg.id + charset = "UTF8" + collation = "en_US.utf8" +} + +############################################################################### +# Firewall Rules +############################################################################### + +# Allow all access (development/testing) +resource "azurerm_postgresql_flexible_server_firewall_rule" "allow_all" { + name = "allow-all" + server_id = azurerm_postgresql_flexible_server.pg.id + start_ip_address = "0.0.0.0" + end_ip_address = "255.255.255.255" +} + +# Simulated corporate network access +resource "azurerm_postgresql_flexible_server_firewall_rule" "corporate" { + name = "corporate-network" + server_id = azurerm_postgresql_flexible_server.pg.id + start_ip_address = "10.0.0.1" + end_ip_address = "10.0.255.255" +} + +# Simulated VPN access +resource "azurerm_postgresql_flexible_server_firewall_rule" "vpn" { + name = "vpn-access" + server_id = azurerm_postgresql_flexible_server.pg.id + start_ip_address = "192.168.100.1" + end_ip_address = "192.168.100.254" +} diff --git a/samples/web-app-postgresql-database/python/terraform/outputs.tf b/samples/web-app-postgresql-database/python/terraform/outputs.tf new file mode 100644 index 0000000..a2921b5 --- /dev/null +++ b/samples/web-app-postgresql-database/python/terraform/outputs.tf @@ -0,0 +1,43 @@ +output "resource_group_name" { + value = azurerm_resource_group.rg.name + description = "The name of the resource group" +} + +output "server_name" { + value = azurerm_postgresql_flexible_server.pg.name + description = "The name of the PostgreSQL Flexible Server" +} + +output "server_fqdn" { + value = azurerm_postgresql_flexible_server.pg.fqdn + description = "The FQDN of the PostgreSQL server for client connections" +} + +output "server_id" { + value = azurerm_postgresql_flexible_server.pg.id + description = "The resource ID of the PostgreSQL server" +} + +output "server_version" { + value = azurerm_postgresql_flexible_server.pg.version + description = "PostgreSQL version" +} + +output "database_name" { + value = azurerm_postgresql_flexible_server_database.primary_db.name + description = "The name of the primary database" +} + +output "secondary_database_name" { + value = azurerm_postgresql_flexible_server_database.secondary_db.name + description = "The name of the secondary (analytics) database" +} + +output "firewall_rule_names" { + value = [ + azurerm_postgresql_flexible_server_firewall_rule.allow_all.name, + azurerm_postgresql_flexible_server_firewall_rule.corporate.name, + azurerm_postgresql_flexible_server_firewall_rule.vpn.name, + ] + description = "Names of all firewall rules" +} diff --git a/samples/web-app-postgresql-database/python/terraform/providers.tf b/samples/web-app-postgresql-database/python/terraform/providers.tf new file mode 100644 index 0000000..abb2cf6 --- /dev/null +++ b/samples/web-app-postgresql-database/python/terraform/providers.tf @@ -0,0 +1,30 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "=4.14.0" + } + random = { + source = "hashicorp/random" + version = ">=3.6.0" + } + } +} + +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } + + # Set the hostname of the Azure Metadata Service (for example management.azure.com) + # used to obtain the Cloud Environment when using LocalStack's Azure emulator. + # This allows the provider to correctly identify the environment and avoid making calls to the real Azure endpoints. + metadata_host = "localhost.localstack.cloud:4566" + + # Set the subscription ID to a dummy value when using LocalStack's Azure emulator. + subscription_id = "00000000-0000-0000-0000-000000000000" +} diff --git a/samples/web-app-postgresql-database/python/terraform/variables.tf b/samples/web-app-postgresql-database/python/terraform/variables.tf new file mode 100644 index 0000000..ea031ac --- /dev/null +++ b/samples/web-app-postgresql-database/python/terraform/variables.tf @@ -0,0 +1,80 @@ +variable "location" { + description = "(Required) Specifies the location for all resources." + type = string + default = "westeurope" +} + +variable "administrator_login" { + description = "(Required) Specifies the administrator login for the PostgreSQL Flexible Server." + type = string + default = "pgadmin" +} + +variable "administrator_password" { + description = "(Required) Specifies the administrator login password for the PostgreSQL Flexible Server." + type = string + default = "P@ssw0rd12345!" + sensitive = true +} + +variable "postgresql_version" { + description = "(Optional) Specifies the version of PostgreSQL to deploy." + type = string + default = "16" + + validation { + condition = contains(["13", "14", "15", "16"], var.postgresql_version) + error_message = "The postgresql_version must be one of: 13, 14, 15, 16." + } +} + +variable "sku_name" { + description = "(Optional) Specifies the SKU name for the PostgreSQL Flexible Server." + type = string + default = "B_Standard_B1ms" +} + +variable "storage_mb" { + description = "(Optional) Specifies the storage size in MB for the PostgreSQL Flexible Server." + type = number + default = 32768 +} + +variable "backup_retention_days" { + description = "(Optional) Specifies the number of days to retain backups." + type = number + default = 7 +} + +variable "geo_redundant_backup_enabled" { + description = "(Optional) Specifies whether geo-redundant backup is enabled." + type = bool + default = false +} + +variable "public_network_access_enabled" { + description = "(Optional) Specifies whether public network access is enabled." + type = bool + default = true +} + +variable "primary_database_name" { + description = "(Optional) Specifies the name of the primary database." + type = string + default = "sampledb" +} + +variable "secondary_database_name" { + description = "(Optional) Specifies the name of the secondary (analytics) database." + type = string + default = "analyticsdb" +} + +variable "tags" { + description = "(Optional) Specifies the tags to be applied to the resources." + type = map(string) + default = { + environment = "test" + iac = "terraform" + } +}