diff --git a/infrastructure/modules/psql/data.tf b/infrastructure/modules/psql/data.tf new file mode 100644 index 000000000..fa644e25f --- /dev/null +++ b/infrastructure/modules/psql/data.tf @@ -0,0 +1,19 @@ +data "azurerm_client_config" "current" {} + +data "azuread_service_principal" "current" { + object_id = data.azurerm_client_config.current.object_id +} + +data "azuread_service_principal" "terraform" { + object_id = var.terraform_sp_object_id +} + +data "azuread_group" "psql_admin_groups" { + for_each = toset(var.psql_admin_group_ids) + object_id = each.value +} + +data "azurerm_virtual_network" "psql" { + name = var.psql_network_name + resource_group_name = var.psql_network_resource_group +} diff --git a/infrastructure/modules/psql/diagnostic.tf b/infrastructure/modules/psql/diagnostic.tf new file mode 100644 index 000000000..2a7d8d496 --- /dev/null +++ b/infrastructure/modules/psql/diagnostic.tf @@ -0,0 +1,39 @@ +locals { + # Friendly -> API mapping + psql_diag_category_map_friendly = { + "PostgreSQL Server Logs" = "PostgreSQLLogs" + "PostgreSQL Query Store Wait Statistics" = "PostgreSQLFlexQueryStoreWaitStats" + "PostgreSQL Sessions data" = "PostgreSQLFlexSessions" + "PostgreSQL Query Store Runtime" = "PostgreSQLFlexQueryStoreRuntime" + "PostgreSQL Autovacuum and schema statistics"= "PostgreSQLFlexTableStats" + "PostgreSQL remaining transactions" = "PostgreSQLFlexDatabaseXacts" + } + + psql_effective_log_categories = [ + for friendly in var.psql_diagnostic_log_categories : + local.psql_diag_category_map_friendly[friendly] + ] + + psql_effective_metric_categories = distinct(var.psql_diagnostic_metrics) +} + +resource "azurerm_monitor_diagnostic_setting" "postgresql" { + count = var.psql_diagnostics_enabled ? 1 : 0 + name = "psql-diag" + target_resource_id = azurerm_postgresql_flexible_server.psql.id + log_analytics_workspace_id = var.log_analytics_workspace_id + + dynamic "enabled_log" { + for_each = toset(local.psql_effective_log_categories) + content { + category = enabled_log.value + } + } + + dynamic "enabled_metric" { + for_each = toset(local.psql_effective_metric_categories) + content { + category = enabled_metric.value + } + } +} diff --git a/infrastructure/modules/psql/main.tf b/infrastructure/modules/psql/main.tf new file mode 100644 index 000000000..d80254a92 --- /dev/null +++ b/infrastructure/modules/psql/main.tf @@ -0,0 +1,295 @@ +resource "azurerm_subnet" "psql" { + count = var.psql_enable_private_access ? 1 : 0 + name = coalesce(var.psql_subnet_name, "${var.psql_server_name}-subnet") + resource_group_name = var.psql_network_resource_group + virtual_network_name = var.psql_network_name + private_endpoint_network_policies = "Enabled" + address_prefixes = [var.psql_subnet_cidr] + service_endpoints = ["Microsoft.Storage"] + delegation { + name = "fs" + service_delegation { + name = "Microsoft.DBforPostgreSQL/flexibleServers" + actions = [ + "Microsoft.Network/virtualNetworks/subnets/join/action", + ] + } + } +} + +locals { + create_private_dns_zone = var.psql_enable_private_access && var.existing_private_dns_zone_id == null +} + +resource "azurerm_private_dns_zone" "psql" { + lifecycle { + ignore_changes = [ + tags["costcenter"], + tags["solution"], + ] + } + count = local.create_private_dns_zone ? 1 : 0 + name = "${var.psql_private_dns_zone_name}" + resource_group_name = var.psql_resource_group + + tags = { + env = "${var.environment}" + product = "${var.product}" + org = "${var.organization}" + managed = "terraform" + } +} + +resource "azurerm_private_dns_zone_virtual_network_link" "psql" { + lifecycle { + ignore_changes = [ + tags["costcenter"], + tags["solution"], + ] + } + count = local.create_private_dns_zone ? 1 : 0 + name = "${var.psql_server_name}-link" + private_dns_zone_name = azurerm_private_dns_zone.psql[0].name + resource_group_name = var.psql_resource_group + virtual_network_id = data.azurerm_virtual_network.psql.id + registration_enabled = false +} + +locals { + effective_private_dns_zone_id = coalesce( + var.existing_private_dns_zone_id, + try(azurerm_private_dns_zone.psql[0].id, null) + ) +} + +resource "azurerm_user_assigned_identity" "psql_identity" { + lifecycle { + ignore_changes = [ + tags["costcenter"], + tags["solution"], + ] + } + name = "${var.psql_server_name}-identity" + resource_group_name = var.psql_resource_group + location = var.location + + tags = { + env = "${var.environment}" + product = "${var.product}" + org = "${var.organization}" + managed = "terraform" + } +} +resource "azurerm_postgresql_flexible_server" "psql" { + lifecycle { + prevent_destroy = true + ignore_changes = [ + tags["costcenter"], + tags["solution"], + zone, + high_availability.0.standby_availability_zone, + storage_mb, # Always ignore to avoid drift after AutoGrow + ] + } + name = "${var.psql_server_name}" + resource_group_name = var.psql_resource_group + location = var.location + version = var.psql_version + delegated_subnet_id = var.psql_enable_private_access ? azurerm_subnet.psql[0].id : null + private_dns_zone_id = var.psql_enable_private_access ? local.effective_private_dns_zone_id : null + public_network_access_enabled = var.psql_enable_private_access ? false : true + backup_retention_days = var.psql_backup_retention_days + geo_redundant_backup_enabled = var.psql_geo_redundant_backup_enabled + + storage_mb = var.psql_storage_size + auto_grow_enabled = var.psql_storage_auto_grow + + sku_name = var.psql_compute_size + storage_tier = coalesce(var.psql_storage_tier, null) + depends_on = [azurerm_private_dns_zone_virtual_network_link.psql] + + authentication { + active_directory_auth_enabled = true + password_auth_enabled = false + tenant_id = data.azurerm_client_config.current.tenant_id + } + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.psql_identity.id] + } + + dynamic "high_availability" { + for_each = var.psql_high_availability_enabled == true ? [1] : [] + content { + mode = "ZoneRedundant" + } + } + + maintenance_window { + day_of_week = var.psql_maintenance_day_of_week + start_hour = var.psql_maintenance_start_hour + start_minute = var.psql_maintenance_start_minute + } + + tags = { + env = "${var.environment}" + product = "${var.product}" + org = "${var.organization}" + managed = "terraform" + } +} + +resource "azurerm_postgresql_flexible_server_configuration" "pgbouncer_enabled" { + name = "pgbouncer.enabled" + server_id = azurerm_postgresql_flexible_server.psql.id + value = var.psql_pgbouncer_enabled ? "true" : "false" +} + +resource "azurerm_postgresql_flexible_server_configuration" "pgbouncer_pool_mode" { + count = var.psql_pgbouncer_enabled ? 1 : 0 + name = "pgbouncer.pool_mode" + server_id = azurerm_postgresql_flexible_server.psql.id + value = var.psql_pgbouncer_pool_mode + + depends_on = [ + azurerm_postgresql_flexible_server_configuration.pgbouncer_enabled + ] +} + +resource "azurerm_postgresql_flexible_server_active_directory_administrator" "psql_terraform" { + resource_group_name = azurerm_postgresql_flexible_server.psql.resource_group_name + server_name = azurerm_postgresql_flexible_server.psql.name + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azuread_service_principal.terraform.object_id + principal_name = data.azuread_service_principal.terraform.display_name + principal_type = "ServicePrincipal" +} +resource "azurerm_postgresql_flexible_server_active_directory_administrator" "psql_admin" { + for_each = data.azuread_group.psql_admin_groups + server_name = azurerm_postgresql_flexible_server.psql.name + resource_group_name = azurerm_postgresql_flexible_server.psql.resource_group_name + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = each.value.object_id + principal_name = each.value.display_name + principal_type = "Group" + + depends_on = [ + azurerm_postgresql_flexible_server_active_directory_administrator.psql_terraform + ] +} + + + +resource "azurerm_management_lock" "flexible_server" { + count = var.locks_off ? 0 : 1 + depends_on = [azurerm_postgresql_flexible_server.psql] + name = "resource-lock-flexible_server" + scope = azurerm_postgresql_flexible_server.psql.id + lock_level = "CanNotDelete" + notes = "do not delete !!" +} + +resource "azurerm_postgresql_flexible_server_database" "psql" { + lifecycle { + prevent_destroy = true +} + name = var.psql_database_name + server_id = azurerm_postgresql_flexible_server.psql.id + collation = var.psql_database_collation + charset = "utf8" +} + +resource "azurerm_postgresql_flexible_server_virtual_endpoint" "psql" { + count = var.psql_enable_virtual_endpoint ? 1 : 0 + name = var.psql_virtual_endpoint_name + source_server_id = azurerm_postgresql_flexible_server.psql.id + replica_server_id = azurerm_postgresql_flexible_server.psql.id + type = "ReadWrite" +} + + +locals { + psql_extensions_value = ( + trimspace(var.psql_extensions) == "" ? + null : + join(",", distinct(compact([ + for e in split(",", lower(replace(var.psql_extensions, " ", ""))) : e + ]))) + ) +} + + +resource "azurerm_postgresql_flexible_server_configuration" "psql_extensions" { + count = local.psql_extensions_value == null ? 0 : 1 + name = "azure.extensions" + server_id = azurerm_postgresql_flexible_server.psql.id + value = local.psql_extensions_value +} + +locals { + + psql_shared_preload_libraries_value = ( + trimspace(var.psql_shared_preload_libraries) == "" ? + null : + join(",", distinct(compact([ + for e in split(",", lower(replace(var.psql_shared_preload_libraries, " ", ""))) : e + ]))) + ) +} + +resource "azurerm_postgresql_flexible_server_configuration" "psql_shared_preload_libraries" { + count = local.psql_shared_preload_libraries_value == null ? 0 : 1 + name = "shared_preload_libraries" + server_id = azurerm_postgresql_flexible_server.psql.id + value = local.psql_shared_preload_libraries_value +} + +locals { + reserved_pg_configs = [ + "azure.extensions", + "shared_preload_libraries", + "pgbouncer.enabled", + "pgbouncer.pool_mode", + ] + + effective_custom_pg_configs = { + for k, v in var.psql_custom_configurations : + k => v + if !contains(local.reserved_pg_configs, k) + } +} + +resource "azurerm_postgresql_flexible_server_configuration" "custom" { + for_each = local.effective_custom_pg_configs + + name = each.key + server_id = azurerm_postgresql_flexible_server.psql.id + value = each.value +} + + +data "azapi_resource" "psql_actual" { + count = var.psql_track_actual_storage ? 1 : 0 + type = "Microsoft.DBforPostgreSQL/flexibleServers@2023-12-01-preview" + name = azurerm_postgresql_flexible_server.psql.name + parent_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${var.psql_resource_group}" + response_export_values = ["properties.storage.sizeGb"] + depends_on = [azurerm_postgresql_flexible_server.psql] +} + +locals { + psql_actual_storage_mb = ( + var.psql_track_actual_storage && length(data.azapi_resource.psql_actual) > 0 + ? try(tonumber(data.azapi_resource.psql_actual[0].output.properties.storage.sizeGb) * 1024, azurerm_postgresql_flexible_server.psql.storage_mb) + : azurerm_postgresql_flexible_server.psql.storage_mb + ) +} + +resource "azurerm_postgresql_flexible_server_firewall_rule" "psql" { + for_each = var.psql_enable_private_access ? {} : var.psql_firewall_rules + name = each.key + server_id = azurerm_postgresql_flexible_server.psql.id + start_ip_address = each.value.start_ip + end_ip_address = each.value.end_ip +} diff --git a/infrastructure/modules/psql/output.tf b/infrastructure/modules/psql/output.tf new file mode 100644 index 000000000..ab5c0435d --- /dev/null +++ b/infrastructure/modules/psql/output.tf @@ -0,0 +1,50 @@ +output "psql_server_name" { + description = "The name of the PostgreSQL Flexible Server" + value = azurerm_postgresql_flexible_server.psql.name +} + +output "psql_server_fqdn" { + description = "The fully qualified domain name of the PostgreSQL Flexible Server" + value = azurerm_postgresql_flexible_server.psql.fqdn +} + +output "psql_server_id" { + description = "The ID of the PostgreSQL Flexible Server" + value = azurerm_postgresql_flexible_server.psql.id +} + +output "psql_identity_id" { + description = "The ID of the User Assigned Managed Identity" + value = azurerm_user_assigned_identity.psql_identity.id + sensitive = false +} + +output "psql_admin_group_object_ids" { + description = "The object IDs of the Azure AD groups used as administrators" + value = values(data.azuread_group.psql_admin_groups)[*].object_id + sensitive = false +} + +output "psql_database_name" { + description = "The name of the PostgreSQL database" + value = azurerm_postgresql_flexible_server_database.psql.name +} + +output "psql_private_dns_zone_name" { + description = "Name of private DNS zone if module created it; null if external supplied." + value = local.create_private_dns_zone ? try(azurerm_private_dns_zone.psql[0].name, null) : null +} + +output "psql_effective_private_dns_zone_id" { + description = "Effective private DNS zone ID (created or supplied). Null if private DNS is not in use." + value = var.psql_enable_private_access ? coalesce( + var.existing_private_dns_zone_id, + try(azurerm_private_dns_zone.psql[0].id, null) + ) : null +} + +output "psql_actual_storage_mb" { + description = "Observed storage size in MB (may be > initial if AutoGrow)." + value = local.psql_actual_storage_mb + depends_on = [azurerm_postgresql_flexible_server.psql] +} diff --git a/infrastructure/modules/psql/providers.tf b/infrastructure/modules/psql/providers.tf new file mode 100644 index 000000000..925abe899 --- /dev/null +++ b/infrastructure/modules/psql/providers.tf @@ -0,0 +1,20 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.50" + } + azuread = { + source = "hashicorp/azuread" + version = "~> 3.3" + } + null = { + source = "hashicorp/null" + version = "~> 3.2" + } + azapi = { + source = "azure/azapi" + version = "~> 2.5" + } + } +} diff --git a/infrastructure/modules/psql/readme.md b/infrastructure/modules/psql/readme.md new file mode 100644 index 000000000..3cd289973 --- /dev/null +++ b/infrastructure/modules/psql/readme.md @@ -0,0 +1,225 @@ +# Terraform Module for Creating a PostgreSQL Server + +## Prerequisites + +- VNET to create the subnet for PostgreSQL if using VNET integration (the module can create the subnet) +- Entra ID group(s) for PostgreSQL admin access +- A Log Analytics Workspace + +--- + +## Module Configuration + +The following is an example configuration for using the PostgreSQL Terraform module. + +Minimum required parameters: + +Private access (VNet integration): + +```hcl +module "psql" { + source = "../modules/psql" + environment = "at24" + organization = "platform" + product = "studio" + psql_server_name = "platform-olki-at24-psql" + psql_version = "16" + psql_database_name = "olki" + psql_database_collation = "nb_NO.utf8" + psql_resource_group = "altinnplatform-at24-rg" + existing_private_dns_zone_id = null + psql_network_resource_group = "altinnplatform-rg" + psql_Nntwork_name = "at-platform-vnet" + psql_subnet_name = "platform-olki-at24-subnet" + psql_subnet_cidr = "10.127.1.0/24" + psql_compute_size = "GP_Standard_D2s_v3" + psql_storage_size = 32768 + psql_admin_group_ids = ["143ed28a-6e6d-4ca0-8273-eecb9c1665ba"] #Altinn-30-Test-Operations + log_analytics_workspace_id = "/subscriptions/de41df22-8dd0-435b-98dd-6152cd371e92/resourceGroups/Altinn-rg/providers/Microsoft.OperationalInsights/workspaces/altinn-at24-law" +} +``` + +Public access (allowed IP addresses) + +```hcl +module "psql" { + source = "../modules/psql" + environment = "at24" + organization = "platform" + product = "studio" + psql_server_name = "platform-olki-at24-psql" + psql_version = "16" + psql_database_name = "olki" + psql_database_collation = "nb_NO.utf8" + psql_resource_group = "altinnplatform-at24-rg" + psql_enable_vnet_integration = false + psql_compute_size = "GP_Standard_D2s_v3" + psql_storage_size = 32768 + psql_admin_group_ids = ["143ed28a-6e6d-4ca0-8273-eecb9c1665ba"] #Altinn-30-Test-Operations + log_analytics_workspace_id = "/subscriptions/de41df22-8dd0-435b-98dd-6152cd371e92/resourceGroups/Altinn-rg/providers/Microsoft.OperationalInsights/workspaces/altinn-at24-law" +} +``` + +```hcl +module "psql" { + source = "../modules/psql" + environment = "at24" + organization = "platform" + product = "studio" + location = "norwayeast" + psql_server_ame = "platform-olki-at24-psql" + psql_enable_virtual_endpoint = true + psql_virtual_endpoint_name = "altinn-endpoint-olki-at24" + psql_version = "16" + psql_database_name = "olki" + psql_database_collation = "nb_NO.utf8" + psql_backup_retention_days = 7 + psql_resource_group = "altinnplatform-at24-rg" + psql_enable_vnet_integration = false + psql_firewall_rules = { + ai-dev-LabVM = { start_ip = "51.120.0.114", end_ip = "251.120.0.114" } + terraformVM = { start_ip = "51.13.85.174", end_ip = "51.13.85.174" } + } + psql_network_resource_group = "altinnplatform-rg" + psql_network_name = "at-platform-vnet" + psql_subnet_name = "platform-olki-at24-subnet" + psql_subnet_cidr = "10.127.1.0/24" + psql_compute_size = "GP_Standard_D2s_v3" + psql_storage_size = 32768 + psql_storage_tier = "P10" + psql_high_availability_enabled = true + psql_geo_redundant_backup_enabled = true + psql_storage_auto_grow = true + psql_admin_group_ids = ["143ed28a-6e6d-4ca0-8273-eecb9c1665ba"] #Altinn-30-Test-Operations + psql_pgbouncer_enabled = false + psql_pgbouncer_pool_mode = "transaction" + psql_extensions = "PG_TRGM,PG_STAT_STATEMENTS,PG_BUFFERCACHE,PG_CRON,PGAUDIT" + psql_shared_preload_libraries = "auto_explain,pg_cron,pg_stat_statements,pgaudit" + psql_custom_configurations = { + log_min_duration_statement = "750" + log_statement = "ddl" + log_connections = "on" + log_disconnections = "on" + log_lock_waits = "on" + log_checkpoints = "on" + log_temp_files = "0" + log_autovacuum_min_duration = "0" + autovacuum_vacuum_scale_factor = "0.05" + autovacuum_analyze_scale_factor = "0.05" + autovacuum_naptime = "30" + work_mem = "32768" + maintenance_work_mem = "262144" + effective_io_concurrency = "64" + wal_compression = "on" + "pgaudit.log" = "WRITE,DDL" + "pgaudit.log_parameter" = "on" + } + psql_maintenance_day_of_week = 2 + psql_maintenance_start_hour = 1 + psql_maintenance_start_minute = 0 + psql_track_actual_storage = true + log_analytics_workspace_id = "/subscriptions/de41df22-8dd0-435b-98dd-6152cd371e92/resourceGroups/Altinn-rg/providers/Microsoft.OperationalInsights/workspaces/altinn-at24-law" + psql_diagnostics_enabled = true + psql_diagnostic_log_categories = [ + "PostgreSQL Server Logs", + "PostgreSQL Query Store Runtime", + "PostgreSQL Query Store Wait Statistics", + "PostgreSQL Sessions data", + "PostgreSQL Autovacuum and schema statistics", + "PostgreSQL remaining transactions" + ] + psql_diagnostic_metrics = ["AllMetrics"] + locks_off = true +} +``` + +--- + +## Input Variables + +(R = Required, O = Optional) + +-**environment** (R): Environment (e.g. `at24`). +-**organization** (R): Organization (e.g. `platform`). +-**product** (R): Product/application identifier (e.g. `studio`). +-**location** (O, default: `norwayeast`): Azure region. + +-**psql_server_name** (R): PostgreSQL Flexible Server name. +-**psql_version** (R): PostgreSQL major version (e.g. `16`). +-**psql_database_name** (R): Initial database name. +-**psql_database_collation** (O, default: `nb_NO.utf8`): Database collation. +-**psql_backup_retention_days** (O, default: `7` or module default): Backup retention (7–35). + +-**psql_resource_group** (R): Resource group for the server. +-**existing_private_dns_zone_id** (O): Use an existing `*.postgres.database.azure.com` private DNS zone (skip creation/link). If not set a (servername).private.postgres.database.azure.com private dns zone will be created. +-**psql_enable_vnet_integration** (O, default: `true`): Deploy in delegated subnet (private access). If `false`, public network. + +Networking (required only when `psql_enable_vnet_integration = true` and subnet created here): +-**psql_network_resource_group** (R*): RG containing the VNet. +-**psql_network_name** (R*): Virtual Network name. +-**psql_subnet_name** (R*): Subnet name (created if absent). +-**psql_subnet_cidr** (R*): Subnet CIDR (e.g. `10.127.1.0/24`). + +Firewall (public access mode): +-**psql_firewall_rules** (O): Map of named rules `{ name = { start_ip = "...", end_ip = "..." } }`. + +Compute & storage: +-**psql_compute_size** (R): SKU (e.g. `GP_Standard_D2s_v3`). +-**psql_storage_size** (R): Storage in MB (e.g. `32768`). +-**psql_storage_tier** (O, default: `null`): Premium tier (e.g. `P10`) or null for Azure default. +-**psql_storage_auto_grow** (O, default: `true`). +-**psql_track_actual_storage** (O, default: `false`). + +Reliability: +-**psql_high_availability_enabled** (O, default: `false` or module default). +-**psql_geo_redundant_backup_enabled** (O, default: `false`/`true` per module default). + +Identity & access: +-**psql_admin_group_ids** (R): List of Entra ID (Azure AD) group object IDs for admin access. +-**locks_off** (O, default: `false`): Disable creation of management locks. + +Diagnostics: +-**log_analytics_workspace_id** (R): Target Log Analytics Workspace ID. +-**psql_diagnostics_enabled** (O, default: `true`). +-**psql_diagnostic_log_categories** (O): List of log categories (filtered to supported). +-**psql_diagnostic_metrics** (O): List of metric categories (e.g. `["AllMetrics"]`). + +PgBouncer: +-**psql_pgbouncer_enabled** (O, default: `false`). +-**psql_pgbouncer_pool_mode** (O, default: `transaction`): `session|transaction|statement`. + +Extensions & config: +-**psql_extensions** (O, default: `""`): Comma-separated list (e.g. `PG_TRGM,PG_CRON`). +-**psql_shared_preload_libraries** (O, default: `""`). +-**psql_custom_configurations** (O): Map of additional server parameters. + +Maintenance window: +-**psql_maintenance_day_of_week** (O, default: `2` = Tuesday). +-**psql_maintenance_start_hour** (O, default: `1`). +-**psql_maintenance_start_minute** (O, default: `0`). + +Endpoint (optional feature): +-**psql_enable_virtual_endpoint** (O, default: `false`): Enable outbound virtual endpoint (if module supports). +-**psql_virtual_endpoint_name** (O): Virtual endpoint name (required if enabled). + +Notes: +-Fields marked R* are conditionally required (only when VNet integration is enabled and subnet created by module). +-Provide `existing_private_dns_zone_id` to reuse a central private DNS zone; otherwise module will create one. + +--- + +## Outputs + +The module provides the following outputs: + +| Output | Description | +|-------|-------------| +| `psql_server_name` | The name of the PostgreSQL Flexible Server. | +| `psql_server_fqdn` | Fully qualified domain name of the server. | +| `psql_server_id` | The resource ID of the PostgreSQL Flexible Server. | +| `psql_identity_id` | The ID of the User Assigned Managed Identity. | +| `psql_admin_group_object_ids` | Object IDs of Entra ID groups granted admin access. | +| `psql_database_name` | Name of the created (initial) database. | +| `psql_private_dns_zone_name` | Name of private DNS zone if created by module; null if external. | +| `psql_effective_private_dns_zone_id` | ID of the private DNS zone actually in use (created or supplied); null if not using private access. | +| `psql_actual_storage_mb` | Observed storage size in MB (may exceed initial if AutoGrow enabled). | diff --git a/infrastructure/modules/psql/variables.tf b/infrastructure/modules/psql/variables.tf new file mode 100644 index 000000000..d4f8eb35f --- /dev/null +++ b/infrastructure/modules/psql/variables.tf @@ -0,0 +1,331 @@ +variable "psql_subnet_cidr" { + type = string + description = "The CIDR range to use for psql subnet" +} + +variable "organization" { + type = string + description = "The organization/service owner name" +} + +variable "environment" { + type = string + description = "The environment (ATxx/TTx/YTx)" +} + +variable "location" { + type = string + default = "norwayeast" + description = "The location where the resources will be placed." +} + +variable "psql_network_name" { + type = string + description = "psql network name" +} + +variable "psql_network_resource_group" { + type = string + description = "psql network name" +} + +variable "psql_resource_group" { + type = string + description = "psql resource group" +} + +variable "product" { + type = string + description = "Product the resource belongs to" +} + +variable "psql_compute_size" { + type = string + description = "psql compute size" +} + +variable "psql_high_availability_enabled" { + type = bool + default = true + description = "Enable psql high availability" +} + +variable "locks_off" { + type = bool + default = false + description = "If locks should be on or off" +} + +variable "psql_admin_group_ids" { + description = "A list of EntraID group ids to be used as administrators" + type = list(string) +} + +variable "psql_storage_size" { + description = "PostgreSQL storage size in MB, if auto grow is enabled this will be the initial storage size" + type = number + default = 32768 // 32 GB + validation { + condition = ( + var.psql_storage_size >= 20480 && // min 20 GB + var.psql_storage_size <= 16777216 && // max 16 TB + var.psql_storage_size % 1024 == 0 // multiple of 1024 MB + ) + error_message = "psql_storage_size must be between 20480 (20 GB) and 16777216 (16 TB), and a multiple of 1024 MB." + } +} + +variable "psql_database_name" { + description = "The name of the database" + type = string +} + +variable "log_analytics_workspace_id" { + description = "Log analytics workspace id" + type = string +} + +variable "psql_storage_auto_grow" { + type = bool + default = true + description = "Enable psql storage auto grow" +} + +variable "psql_geo_redundant_backup_enabled" { + type = bool + default = true + description = "Enable psql georedundant backup" +} + +variable "psql_database_collation" { + description = "The collation to use for the database" + type = string +} + +variable "psql_server_name" { + description = "The name of the psql server" + type = string +} + +variable "psql_pgbouncer_enabled" { + type = bool + default = false + description = "Enable pgbouncer for postgresql" +} + +variable "psql_pgbouncer_pool_mode" { + type = string + default = "transaction" + description = "pgbouncer pool mode (SESSION, TRANSACTION, STATEMENT)" +} + +variable "psql_enable_private_access" { + description = "Set to true to deploy PostgreSQL with VNet integration (private access). False for public access." + type = bool + default = true +} + +variable "psql_version" { + description = "PostgreSQL version to use" + type = string +} + +variable "psql_enable_virtual_endpoint" { + description = "Set to true to deploy PostgreSQL with virtual endpoint" + type = bool + default = false +} + +variable "psql_virtual_endpoint_name" { + description = "The name of the PostgreSQL virtual endpoint" + type = string +} + +variable "psql_maintenance_day_of_week" { + type = number + default = 2 + description = "1=Monday ... 7=Sunday (Azure spec)." +} + +variable "psql_maintenance_start_hour" { + type = number + default = 1 + description = "0–23." +} + +variable "psql_maintenance_start_minute" { + type = number + default = 0 + description = "0, 5, 10 ... 55 (increments of 5)." +} + +variable "psql_extensions" { + description = "Comma-separated PostgreSQL extensions (e.g. \"pg_trgm,pg_stat_statements\"). Empty = none." + type = string + default = "" + validation { + condition = ( + var.psql_extensions == "" || + length([ + for e in split(",", replace(var.psql_extensions, " ", "")) : + e if (length(e) > 0 && can(regex("^[A-Za-z0-9_]+$", e))) + ]) == length(split(",", replace(var.psql_extensions, " ", ""))) + ) + error_message = "psql_extensions must be comma-separated alphanumeric/underscore names." + } +} + +variable "psql_shared_preload_libraries" { + description = "Comma-separated shared_preload_libraries (e.g. \"pg_stat_statements,pg_cron\"). Empty = none." + type = string + default = "" + validation { + condition = ( + var.psql_shared_preload_libraries == "" || + length([ + for e in split(",", replace(var.psql_shared_preload_libraries, " ", "")) : + e if (length(e) > 0 && can(regex("^[A-Za-z0-9_]+$", e))) + ]) == length(split(",", replace(var.psql_shared_preload_libraries, " ", ""))) + ) + error_message = "psql_shared_preload_libraries must be comma-separated alphanumeric/underscore names." + } +} + +variable "psql_custom_configurations" { + description = "Custom PostgreSQL server configurations (name => value)." + type = map(string) + default = {} + + validation { + condition = ( + length(var.psql_custom_configurations) <= 25 + && + length([ + for k in keys(var.psql_custom_configurations) : + k if ( + can(regex("^[a-zA-Z0-9_\\.]+$", k)) + && !contains([ + "azure.extensions", + "shared_preload_libraries", + "pgbouncer.enabled", + "pgbouncer.pool_mode" + ], k) + ) + ]) == length(var.psql_custom_configurations) + && + length([ + for v in values(var.psql_custom_configurations) : + v if length(trimspace(v)) > 0 + ]) == length(var.psql_custom_configurations) + ) + error_message = "Max 25 configs. Keys must be alphanumeric/underscore/dot, not reserved (azure.extensions, shared_preload_libraries, pgbouncer.*), and values cannot be empty." + } +} + +variable "psql_track_actual_storage" { + description = "If true, expose actual grown storage size (read-only) via AzAPI. Does not change storage_mb." + type = bool + default = false +} + +variable "psql_storage_tier" { + description = "Optional storage tier for the flexible server (e.g. P4, P6, P10 ...). Null = Azure default." + type = string + default = null + validation { + condition = ( + var.psql_storage_tier == null || + contains([ + "P4","P6","P10","P15","P20","P30","P40","P50","P60","P70","P80" + ], var.psql_storage_tier) + ) + error_message = "psql_storage_tier must be one of: P4,P6,P10,P15,P20,P30,P40,P50,P60,P70,P80 or null." + } +} + +variable "psql_backup_retention_days" { + description = "Backup retention days (7–35). Defaults to 35." + type = number + default = 35 + validation { + condition = var.psql_backup_retention_days >= 7 && var.psql_backup_retention_days <= 35 + error_message = "psql_backup_retention_days must be between 7 and 35." + } +} + +variable "psql_firewall_rules" { + description = "Map of firewall rules (name => { start_ip, end_ip }). Ignored if VNet integration enabled." + type = map(object({ + start_ip = string + end_ip = string + })) + default = {} +} + +variable "psql_diagnostics_enabled" { + description = "Enable diagnostic settings for the PostgreSQL flexible server." + type = bool + default = true +} + + +variable "psql_diagnostic_log_categories" { + description = "Friendly diagnostic log categories to enable. Empty liste = ingen logger." + type = list(string) + default = [ + "PostgreSQL Server Logs", + "PostgreSQL Query Store Runtime", + "PostgreSQL Query Store Wait Statistics", + "PostgreSQL Sessions data", + "PostgreSQL Autovacuum and schema statistics", + "PostgreSQL remaining transactions" + ] + validation { + condition = length([ + for c in var.psql_diagnostic_log_categories : c + if contains([ + "PostgreSQL Server Logs", + "PostgreSQL Query Store Wait Statistics", + "PostgreSQL Sessions data", + "PostgreSQL Query Store Runtime", + "PostgreSQL Autovacuum and schema statistics", + "PostgreSQL remaining transactions" + ], c) + ]) == length(var.psql_diagnostic_log_categories) + error_message = "En eller flere oppgitte kategorier er ikke gyldige friendly navn." + } +} + +variable "psql_diagnostic_metrics" { + description = "List of metric categories to enable (typically [\"AllMetrics\"]). Empty list = disable metrics." + type = list(string) + default = ["AllMetrics"] + validation { + condition = length([for m in var.psql_diagnostic_metrics : m if m != "AllMetrics"]) == 0 + error_message = "Only 'AllMetrics' is currently supported." + } +} + +variable "psql_subnet_name" { + description = "Optional explicit name for the PostgreSQL subnet. Defaults to \"-subnet\" when null." + type = string + default = null +} + +variable "existing_private_dns_zone_id" { + type = string + default = null + description = "Set if zone already exists; module will not create zone or link." +} + +variable "psql_private_dns_zone_name" { + description = "The name of the private DNS zone to create for PostgreSQL VNet integration." + type = string + default = "privatelink.postgres.database.azure.com" +} + +variable terraform_sp_object_id { + description = "The object ID of the Service Principal used by Terraform" + type = string + default = "641fc568-3e2f-4174-a7ce-d91f50c8e6d6" +}