From 7d89adcb0385fbe4e6d90854165bea63a28b30f0 Mon Sep 17 00:00:00 2001 From: Chiel Fernhout Date: Fri, 15 Aug 2025 15:34:27 +0200 Subject: [PATCH] feat: Support existing postgres database in different existing resource group --- examples/README.md | 207 ++++++++++++++++++++++++++++++++++ main.tf | 21 +++- modules/database/main.tf | 117 ++++++++++++++++++- modules/database/outputs.tf | 28 ++++- modules/database/secrets.tf | 1 + modules/database/variables.tf | 112 ++++++++++++++++++ variables.tf | 69 ++++++++++++ 7 files changed, 547 insertions(+), 8 deletions(-) diff --git a/examples/README.md b/examples/README.md index 7aa277a..c1adb64 100644 --- a/examples/README.md +++ b/examples/README.md @@ -113,3 +113,210 @@ custom_storage_account_name = "production-${local.deployment_name}-storage-accou 3. **Check Lengths**: Verify storage account and key vault names are ≤24 characters 4. **Document**: Include comments explaining your naming convention 5. **Verify**: Check Azure portal after deployment to ensure resources have expected names and permissions work + +## Using Existing PostgreSQL Database + +The module supports using an existing PostgreSQL database instead of creating a new one. This is useful when: + +- You have a shared database server across multiple environments +- You want to use a centrally managed database instance +- The database exists in a different resource group or subscription + +### Configuration + +To use an existing database, set the following variables: + +```hcl +module "azure" { + # ... other configuration ... + + # Enable database module but use existing database + create_database = true + use_existing_database = true + + # Specify the existing database details + existing_database_resource_group_name = "my-database-rg" + existing_postgresql_server_name = "my-postgres-server" + existing_postgresql_database_name = "datafold" +} +``` + +### Important Notes + +1. **Authentication**: The module cannot access the password for existing databases. You'll need to manage authentication separately. + +2. **Resource Group**: The existing database can be in a different resource group that this module doesn't manage. + +3. **Networking**: Ensure the AKS cluster can reach the existing database (proper networking, firewall rules, etc.). + +4. **Validation**: All three existing database variables must be provided when `use_existing_database = true`. + +### Example + +```hcl +locals { + # Use existing database in production, create new in dev + use_existing_db = var.environment == "production" +} + +module "azure" { + source = "path/to/terraform-azure-datafold" + + deployment_name = "my-app-${var.environment}" + + # Database configuration + create_database = true + use_existing_database = local.use_existing_db + + # Existing database details (only used when use_existing_database = true) + existing_database_resource_group_name = local.use_existing_db ? "production-shared-db-rg" : "" + existing_postgresql_server_name = local.use_existing_db ? "prod-postgres-shared" : "" + existing_postgresql_database_name = local.use_existing_db ? "datafold_prod" : "" + + # Other configuration... +} +``` + +## VNet Peering for Cross-VNet Database Access + +When your existing PostgreSQL database is located in a different VNet (possibly in a different resource group), the module can automatically set up VNet peering and private endpoints to enable secure connectivity. + +### Scenario + +This is useful when: +- Your PostgreSQL database is in a centralized "shared services" VNet +- The database VNet is managed by a different team or in a different subscription +- You need private, secure connectivity without exposing the database publicly +- You want to maintain network isolation while enabling cross-VNet access + +### Configuration + +In addition to the basic existing database configuration, you need to specify the VNet details: + +```hcl +module "azure" { + source = "path/to/terraform-azure-datafold" + + # ... other configuration ... + + # Basic existing database configuration + create_database = true + use_existing_database = true + + existing_database_resource_group_name = "shared-services-rg" + existing_postgresql_server_name = "shared-postgres-01" + existing_postgresql_database_name = "datafold_app" + + # VNet peering configuration (required when database is in different VNet) + existing_vnet_resource_group_name = "shared-services-network-rg" + existing_vnet_name = "shared-services-vnet" + existing_database_subnet_name = "database-subnet" + + # Optional: Specify existing private DNS zone name (defaults to standard PostgreSQL private link zone) + # existing_private_dns_zone_name = "privatelink.postgres.database.azure.com" +} +``` + +### What Gets Created + +When using existing database with VNet peering, the module automatically creates: + +1. **Bidirectional VNet Peering**: + - Peering from your VNet to the existing VNet + - Peering from the existing VNet back to your VNet + +2. **Private DNS Integration**: + - Uses the existing private DNS zone that comes with the existing PostgreSQL server + - Links our VNet to the existing private DNS zone for proper name resolution + +3. **Private Endpoint**: + - Created in your VNet's private endpoint subnet + - Points to the existing PostgreSQL server + - Automatically integrated with the existing private DNS zone + +### Network Flow + +``` +Your VNet ←→ VNet Peering ←→ Existing VNet + ↓ ↓ +Private Endpoint ←---→ PostgreSQL Server + ↓ ↓ +Existing Private DNS Zone ←-------+ +(resolves to private IP) +``` + +### Prerequisites + +1. **Permissions**: You need contributor access to both resource groups: + - Your resource group (for creating resources) + - Existing VNet resource group (for creating peering) + +2. **No Overlapping CIDRs**: Your VNet CIDR must not overlap with the existing VNet CIDR + +3. **PostgreSQL Server**: Must support private endpoints (Azure PostgreSQL Flexible Server) and should already have private DNS integration configured + +### Complete Example + +```hcl +locals { + environment = "production" + + # Production uses shared database, other environments create their own + use_shared_db = local.environment == "production" +} + +module "azure" { + source = "path/to/terraform-azure-datafold" + + deployment_name = "datafold-${local.environment}" + location = "East US" + + # VNet configuration - ensure no CIDR overlap with shared services VNet + vpc_cidrs = ["10.2.0.0/16"] # Shared services uses 10.1.0.0/16 + + # Database configuration + create_database = true + use_existing_database = local.use_shared_db + + # Existing database configuration (production only) + existing_database_resource_group_name = local.use_shared_db ? "shared-services-rg" : "" + existing_postgresql_server_name = local.use_shared_db ? "prod-postgres-shared" : "" + existing_postgresql_database_name = local.use_shared_db ? "datafold_prod" : "" + + # VNet peering configuration (production only) + existing_vnet_resource_group_name = local.use_shared_db ? "shared-services-network-rg" : "" + existing_vnet_name = local.use_shared_db ? "shared-services-vnet" : "" + existing_database_subnet_name = local.use_shared_db ? "postgres-subnet" : "" + existing_private_dns_zone_name = local.use_shared_db ? "privatelink.postgres.database.azure.com" : "" + + # Other configuration... + domain_name = "datafold-${local.environment}.company.com" + # ... rest of your configuration +} + +# Access the connection details +output "postgres_connection" { + value = { + host = module.azure.postgres_host # Resolves to private endpoint when using existing DB + database = module.azure.postgres_database_name + username = module.azure.postgres_username + # Note: password not available for existing databases + } + sensitive = true +} +``` + +### Troubleshooting + +1. **VNet Peering Issues**: Ensure both VNets allow peering and have proper routing +2. **DNS Resolution**: Verify private DNS zone is linked to both VNets +3. **Private Endpoint**: Check that the endpoint has a private IP and is connected +4. **Network Security Groups**: Ensure NSGs allow PostgreSQL traffic (port 5432) + +### Outputs + +When using existing database with VNet peering, additional outputs are available: + +- `postgres_host`: Resolves to the private endpoint FQDN +- `private_endpoint_ip`: Private IP address of the database endpoint +- `vnet_peering_status`: Information about the created peering connections diff --git a/main.tf b/main.tf index feed0bd..fb7533b 100644 --- a/main.tf +++ b/main.tf @@ -207,6 +207,21 @@ module "database" { database_subnet = module.networking.database_subnet private_dns_zone_id = module.networking.database_private_dns_zone_id + # Existing database configuration + use_existing_database = var.use_existing_database + existing_resource_group_name = var.existing_database_resource_group_name + existing_postgresql_server_name = var.existing_postgresql_server_name + existing_postgresql_database_name = var.existing_postgresql_database_name + + # VNet peering and private endpoint configuration + existing_vnet_resource_group_name = var.existing_vnet_resource_group_name + existing_vnet_name = var.existing_vnet_name + existing_database_subnet_name = var.existing_database_subnet_name + existing_private_dns_zone_name = var.existing_private_dns_zone_name + our_vnet_id = module.networking.vpc.id + our_vnet_name = module.networking.vpc.name + our_private_endpoint_subnet_id = module.networking.private_endpoint_storage_subnet.id + database_username = var.database_username database_name = var.database_name database_sku = var.database_sku @@ -215,8 +230,10 @@ module "database" { postgresql_major_version = var.postgresql_major_version # Resource name overrides - postgresql_server_name_override = var.postgresql_server_name_override - postgresql_database_name_override = var.postgresql_database_name_override + postgresql_server_name_override = var.postgresql_server_name_override + postgresql_database_name_override = var.postgresql_database_name_override + postgresql_private_endpoint_name_override = var.postgresql_private_endpoint_name_override + postgresql_vnet_peering_name_prefix_override = var.postgresql_vnet_peering_name_prefix_override } module "clickhouse_backup" { diff --git a/modules/database/main.tf b/modules/database/main.tf index 240368f..ad102a0 100644 --- a/modules/database/main.tf +++ b/modules/database/main.tf @@ -1,10 +1,122 @@ +# Data blocks for existing database resources +data "azurerm_resource_group" "existing" { + count = var.use_existing_database ? 1 : 0 + name = var.existing_resource_group_name +} + +data "azurerm_postgresql_flexible_server" "existing" { + count = var.use_existing_database ? 1 : 0 + name = var.existing_postgresql_server_name + resource_group_name = data.azurerm_resource_group.existing[0].name +} + +# Note: azurerm_postgresql_flexible_server_database data source doesn't exist in provider +# We'll reference the database name directly from the variable + +# Data blocks for existing VNet and networking resources +data "azurerm_resource_group" "existing_vnet" { + count = var.use_existing_database ? 1 : 0 + name = var.existing_vnet_resource_group_name +} + +data "azurerm_virtual_network" "existing" { + count = var.use_existing_database ? 1 : 0 + name = var.existing_vnet_name + resource_group_name = data.azurerm_resource_group.existing_vnet[0].name +} + +data "azurerm_subnet" "existing_database" { + count = var.use_existing_database ? 1 : 0 + name = var.existing_database_subnet_name + virtual_network_name = data.azurerm_virtual_network.existing[0].name + resource_group_name = data.azurerm_resource_group.existing_vnet[0].name +} + +# Data block for existing private DNS zone +data "azurerm_private_dns_zone" "existing_postgresql" { + count = var.use_existing_database ? 1 : 0 + name = var.existing_private_dns_zone_name + resource_group_name = data.azurerm_resource_group.existing[0].name +} + locals { postgresql_server_name = var.postgresql_server_name_override != "" ? var.postgresql_server_name_override : "${var.deployment_name}-db-server" postgresql_database_name = var.postgresql_database_name_override != "" ? var.postgresql_database_name_override : var.database_name + postgresql_private_endpoint_name = var.postgresql_private_endpoint_name_override != "" ? var.postgresql_private_endpoint_name_override : "${var.deployment_name}-postgresql-pe" + vnet_peering_name_prefix = var.postgresql_vnet_peering_name_prefix_override != "" ? var.postgresql_vnet_peering_name_prefix_override : var.deployment_name +} + +# VNet Peering from our VNet to existing VNet +resource "azurerm_virtual_network_peering" "ours_to_existing" { + count = var.use_existing_database ? 1 : 0 + name = "${local.vnet_peering_name_prefix}-to-${var.existing_vnet_name}" + resource_group_name = var.resource_group_name + virtual_network_name = var.our_vnet_name + remote_virtual_network_id = data.azurerm_virtual_network.existing[0].id + allow_virtual_network_access = true + allow_forwarded_traffic = true + allow_gateway_transit = false + use_remote_gateways = false +} + +# VNet Peering from existing VNet to our VNet +resource "azurerm_virtual_network_peering" "existing_to_ours" { + count = var.use_existing_database ? 1 : 0 + name = "${var.existing_vnet_name}-to-${local.vnet_peering_name_prefix}" + resource_group_name = data.azurerm_resource_group.existing_vnet[0].name + virtual_network_name = data.azurerm_virtual_network.existing[0].name + remote_virtual_network_id = var.our_vnet_id + allow_virtual_network_access = true + allow_forwarded_traffic = true + allow_gateway_transit = false + use_remote_gateways = false +} + +# Note: Using existing private DNS zone instead of creating a new one + +# Link existing private DNS zone to our VNet +resource "azurerm_private_dns_zone_virtual_network_link" "postgresql_ours" { + count = var.use_existing_database ? 1 : 0 + name = "${var.deployment_name}-postgresql-dns-link-ours" + resource_group_name = data.azurerm_resource_group.existing[0].name + private_dns_zone_name = data.azurerm_private_dns_zone.existing_postgresql[0].name + virtual_network_id = var.our_vnet_id + registration_enabled = false + + tags = var.tags +} + +# Private endpoint in our VNet to connect to existing PostgreSQL server +resource "azurerm_private_endpoint" "postgresql" { + count = var.use_existing_database ? 1 : 0 + name = local.postgresql_private_endpoint_name + location = var.location + resource_group_name = var.resource_group_name + subnet_id = var.our_private_endpoint_subnet_id + + private_service_connection { + name = "${var.deployment_name}-postgresql-psc" + private_connection_resource_id = data.azurerm_postgresql_flexible_server.existing[0].id + subresource_names = ["postgresqlServer"] + is_manual_connection = false + } + + private_dns_zone_group { + name = "postgresql-dns-zone-group" + private_dns_zone_ids = [data.azurerm_private_dns_zone.existing_postgresql[0].id] + } + + tags = var.tags + + depends_on = [ + azurerm_virtual_network_peering.ours_to_existing, + azurerm_virtual_network_peering.existing_to_ours + ] } # TODO: Do not hardcode, but create variables for e.g. version, sku_name, etc. resource "azurerm_postgresql_flexible_server" "main" { + count = var.use_existing_database ? 0 : 1 name = local.postgresql_server_name resource_group_name = var.resource_group_name location = var.location @@ -13,7 +125,7 @@ resource "azurerm_postgresql_flexible_server" "main" { private_dns_zone_id = var.private_dns_zone_id public_network_access_enabled = false administrator_login = var.database_username - administrator_password = random_password.db_password.result + administrator_password = random_password.db_password[0].result auto_grow_enabled = true sku_name = var.database_sku backup_retention_days = var.database_backup_retention_days @@ -23,8 +135,9 @@ resource "azurerm_postgresql_flexible_server" "main" { } resource "azurerm_postgresql_flexible_server_database" "main" { + count = var.use_existing_database ? 0 : 1 name = local.postgresql_database_name - server_id = azurerm_postgresql_flexible_server.main.id + server_id = azurerm_postgresql_flexible_server.main[0].id collation = "en_US.utf8" charset = "utf8" diff --git a/modules/database/outputs.tf b/modules/database/outputs.tf index 2c0bc9d..3e74e48 100644 --- a/modules/database/outputs.tf +++ b/modules/database/outputs.tf @@ -1,15 +1,35 @@ output "postgres_database_name" { - value = azurerm_postgresql_flexible_server_database.main.name + value = var.use_existing_database ? var.existing_postgresql_database_name : azurerm_postgresql_flexible_server_database.main[0].name } output "postgres_password" { - value = random_password.db_password.result + value = var.use_existing_database ? "" : random_password.db_password[0].result + description = "Password is only available for newly created databases. For existing databases, use the original password." + sensitive = true } output "postgres_host" { - value = azurerm_postgresql_flexible_server.main.fqdn + value = var.use_existing_database ? ( + length(azurerm_private_endpoint.postgresql) > 0 ? + azurerm_private_endpoint.postgresql[0].private_dns_zone_configs[0].record_sets[0].fqdn : + data.azurerm_postgresql_flexible_server.existing[0].fqdn + ) : azurerm_postgresql_flexible_server.main[0].fqdn + description = "PostgreSQL server hostname. For existing databases with private endpoint, this will be the private endpoint FQDN." } output "postgres_username" { - value = azurerm_postgresql_flexible_server.main.administrator_login + value = var.use_existing_database ? data.azurerm_postgresql_flexible_server.existing[0].administrator_login : azurerm_postgresql_flexible_server.main[0].administrator_login +} + +output "private_endpoint_ip" { + value = var.use_existing_database && length(azurerm_private_endpoint.postgresql) > 0 ? azurerm_private_endpoint.postgresql[0].private_service_connection[0].private_ip_address : null + description = "Private IP address of the PostgreSQL private endpoint (only available when using existing database)" +} + +output "vnet_peering_status" { + value = var.use_existing_database ? { + ours_to_existing = length(azurerm_virtual_network_peering.ours_to_existing) > 0 ? azurerm_virtual_network_peering.ours_to_existing[0].name : null + existing_to_ours = length(azurerm_virtual_network_peering.existing_to_ours) > 0 ? azurerm_virtual_network_peering.existing_to_ours[0].name : null + } : null + description = "VNet peering information when using existing database" } \ No newline at end of file diff --git a/modules/database/secrets.tf b/modules/database/secrets.tf index ee0fb38..632ae0f 100644 --- a/modules/database/secrets.tf +++ b/modules/database/secrets.tf @@ -1,4 +1,5 @@ resource "random_password" "db_password" { + count = var.use_existing_database ? 0 : 1 length = 16 special = false } diff --git a/modules/database/variables.tf b/modules/database/variables.tf index 9d1c818..6a935dc 100644 --- a/modules/database/variables.tf +++ b/modules/database/variables.tf @@ -29,6 +29,102 @@ variable "tags" { # ┃┃┣━┫ ┃ ┣━┫┣┻┓┣━┫┗━┓┣╸ # ╺┻┛╹ ╹ ╹ ╹ ╹┗━┛╹ ╹┗━┛┗━╸ +variable "use_existing_database" { + description = "Whether to use an existing PostgreSQL database instead of creating a new one" + type = bool + default = false +} + +variable "existing_resource_group_name" { + description = "The name of the resource group containing the existing PostgreSQL database" + type = string + default = "" + + validation { + condition = var.use_existing_database == false || (var.use_existing_database == true && var.existing_resource_group_name != "") + error_message = "existing_resource_group_name must be provided when use_existing_database is true." + } +} + +variable "existing_postgresql_server_name" { + description = "The name of the existing PostgreSQL flexible server" + type = string + default = "" + + validation { + condition = var.use_existing_database == false || (var.use_existing_database == true && var.existing_postgresql_server_name != "") + error_message = "existing_postgresql_server_name must be provided when use_existing_database is true." + } +} + +variable "existing_postgresql_database_name" { + description = "The name of the existing PostgreSQL database" + type = string + default = "" + + validation { + condition = var.use_existing_database == false || (var.use_existing_database == true && var.existing_postgresql_database_name != "") + error_message = "existing_postgresql_database_name must be provided when use_existing_database is true." + } +} + +variable "existing_vnet_resource_group_name" { + description = "The name of the resource group containing the existing VNet with the PostgreSQL database" + type = string + default = "" + + validation { + condition = var.use_existing_database == false || (var.use_existing_database == true && var.existing_vnet_resource_group_name != "") + error_message = "existing_vnet_resource_group_name must be provided when use_existing_database is true." + } +} + +variable "existing_vnet_name" { + description = "The name of the existing VNet containing the PostgreSQL database" + type = string + default = "" + + validation { + condition = var.use_existing_database == false || (var.use_existing_database == true && var.existing_vnet_name != "") + error_message = "existing_vnet_name must be provided when use_existing_database is true." + } +} + +variable "existing_database_subnet_name" { + description = "The name of the subnet in the existing VNet where the PostgreSQL database is located" + type = string + default = "" + + validation { + condition = var.use_existing_database == false || (var.use_existing_database == true && var.existing_database_subnet_name != "") + error_message = "existing_database_subnet_name must be provided when use_existing_database is true." + } +} + +variable "existing_private_dns_zone_name" { + description = "The name of the existing private DNS zone for PostgreSQL (usually 'privatelink.postgres.database.azure.com')" + type = string + default = "privatelink.postgres.database.azure.com" +} + +variable "our_vnet_id" { + description = "The ID of our VNet that needs to peer with the existing VNet" + type = string + default = "" +} + +variable "our_vnet_name" { + description = "The name of our VNet for peering configuration" + type = string + default = "" +} + +variable "our_private_endpoint_subnet_id" { + description = "The ID of our subnet where the private endpoint should be created" + type = string + default = "" +} + variable "database_username" { type = string default = "datafold" @@ -45,6 +141,7 @@ variable "database_subnet" { type = object({ id = string }) + default = null } variable "database_sku" { @@ -68,6 +165,7 @@ variable "database_storage_mb" { variable "private_dns_zone_id" { type = string description = "The ID of the private DNS zone" + default = null } variable "postgresql_major_version" { @@ -90,3 +188,17 @@ variable "postgresql_database_name_override" { type = string default = "" } + +variable "postgresql_private_endpoint_name_override" { + description = "Override for the name used in resource.azurerm_private_endpoint.postgresql" + type = string + default = "" +} + +# Note: DNS zone name override removed since we use existing DNS zone + +variable "postgresql_vnet_peering_name_prefix_override" { + description = "Override for the name prefix used in VNet peering resources" + type = string + default = "" +} diff --git a/variables.tf b/variables.tf index 14fce2a..9c487a4 100644 --- a/variables.tf +++ b/variables.tf @@ -19,6 +19,12 @@ variable "resource_group_name" { default = "" } +variable "resource_group_name_override" { + description = "Override for the name used in resource.azurerm_resource_group.default" + type = string + default = "" +} + variable "resource_group_tags" { description = "The tags to apply to the resource group" type = map(string) @@ -293,6 +299,54 @@ variable "create_database" { description = "Flag to toggle PostgreSQL database creation" } +variable "use_existing_database" { + description = "Whether to use an existing PostgreSQL database instead of creating a new one. Only used when create_database is true." + type = bool + default = false +} + +variable "existing_database_resource_group_name" { + description = "The name of the resource group containing the existing PostgreSQL database" + type = string + default = "" +} + +variable "existing_postgresql_server_name" { + description = "The name of the existing PostgreSQL flexible server" + type = string + default = "" +} + +variable "existing_postgresql_database_name" { + description = "The name of the existing PostgreSQL database" + type = string + default = "" +} + +variable "existing_vnet_resource_group_name" { + description = "The name of the resource group containing the existing VNet with the PostgreSQL database" + type = string + default = "" +} + +variable "existing_vnet_name" { + description = "The name of the existing VNet containing the PostgreSQL database" + type = string + default = "" +} + +variable "existing_database_subnet_name" { + description = "The name of the subnet in the existing VNet where the PostgreSQL database is located" + type = string + default = "" +} + +variable "existing_private_dns_zone_name" { + description = "The name of the existing private DNS zone for PostgreSQL (usually 'privatelink.postgres.database.azure.com')" + type = string + default = "privatelink.postgres.database.azure.com" +} + variable "database_username" { type = string default = "datafold" @@ -459,6 +513,7 @@ variable "acme_config" { # ╹┗╸┗━╸┗━┛┗━┛┗━┛╹┗╸┗━╸┗━╸ ╹ ╹╹ ╹╹ ╹┗━╸ ┗━┛┗┛ ┗━╸╹┗╸╹┗╸╹╺┻┛┗━╸┗━┛ # Root Level Resource Overrides +# Note: resource_group_name_override is defined earlier in the file near resource_group_name variable "clickhouse_data_disk_name_override" { description = "Override for the name used in resource.azurerm_managed_disk.clickhouse_data" @@ -511,6 +566,20 @@ variable "postgresql_database_name_override" { default = "" } +variable "postgresql_private_endpoint_name_override" { + description = "Override for the name used in resource.azurerm_private_endpoint.postgresql (modules/database)" + type = string + default = "" +} + +# Note: DNS zone name override removed since we use existing DNS zone + +variable "postgresql_vnet_peering_name_prefix_override" { + description = "Override for the name prefix used in VNet peering resources (modules/database)" + type = string + default = "" +} + # Data Lake Module Overrides variable "adls_storage_account_name_override" { description = "Override for the name used in resource.azurerm_storage_account.adls (modules/data_lake)"