Skip to content

Commit 21e8fbb

Browse files
committed
feat(terraform): add the Azure postgres module
Mirrors deployment/terraform/modules/aws/postgres: a private database server, one database on it, and the same five alerts, silent until a caller supplies somewhere to send them. The server always joins a delegated subnet, so it has no public endpoint. That also means it resolves only through a private DNS zone, which the module creates and links to the virtual network unless the caller supplies one. Where the interface has to differ from the AWS module: - Storage comes off a fixed ladder of sizes rather than an arbitrary GiB count, so an off-ladder value is rejected here instead of at apply. Azure offers auto-grow as a switch with no ceiling, replacing max_storage_gb. - Memory and storage alerts are inverted. Azure publishes percent used where CloudWatch publishes bytes free, so the free-storage floor of 15% becomes a storage_percent ceiling of 85. IOPS alerts against the provisioned limit, which is more useful than an absolute count. - Zone-redundant high availability replaces multi-AZ, and Azure does not offer it on burstable SKUs. The module rejects that combination rather than letting apply fail. - Backups cannot be turned off; the retention floor is one day, not zero. Entra ID authentication is the analogue of RDS IAM auth, and is what will let a workload identity reach the database without a password once the aks module lands. prevent_destroy guards the server and the database, matching AWS.
1 parent 51337dc commit 21e8fbb

5 files changed

Lines changed: 1013 additions & 0 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
locals {
2+
create_private_dns_zone = var.private_dns_zone_id == null
3+
private_dns_zone_id = local.create_private_dns_zone ? azurerm_private_dns_zone.this[0].id : var.private_dns_zone_id
4+
5+
password_auth_enabled = !var.entra_authentication_only
6+
7+
# Alerts share one evaluation shape, chosen to match the AWS modules: sample
8+
# every 5 minutes over a 15-minute window, so a single spike does not page.
9+
alert_frequency = "PT5M"
10+
alert_window_size = "PT15M"
11+
metric_namespace = "Microsoft.DBforPostgreSQL/flexibleServers"
12+
}
13+
14+
# A server joined to a virtual network resolves only through a private DNS
15+
# zone, and Azure requires the zone name to end in this suffix.
16+
resource "azurerm_private_dns_zone" "this" {
17+
count = local.create_private_dns_zone ? 1 : 0
18+
19+
name = "${var.name}.private.postgres.database.azure.com"
20+
resource_group_name = var.resource_group_name
21+
tags = var.tags
22+
}
23+
24+
resource "azurerm_private_dns_zone_virtual_network_link" "this" {
25+
count = local.create_private_dns_zone ? 1 : 0
26+
27+
name = "${var.name}-dns-link"
28+
resource_group_name = var.resource_group_name
29+
private_dns_zone_name = azurerm_private_dns_zone.this[0].name
30+
virtual_network_id = var.virtual_network_id
31+
registration_enabled = false
32+
tags = var.tags
33+
}
34+
35+
resource "azurerm_postgresql_flexible_server" "this" {
36+
name = var.name
37+
resource_group_name = var.resource_group_name
38+
location = var.location
39+
version = var.engine_version
40+
sku_name = var.sku_name
41+
zone = var.zone
42+
43+
storage_mb = var.storage_gb * 1024
44+
storage_tier = var.storage_tier
45+
auto_grow_enabled = var.auto_grow_enabled
46+
47+
# Joining the delegated subnet is what keeps the server off the public
48+
# internet. Stating it outright as well means the server never depends on
49+
# Azure defaulting the flag the way we expect.
50+
public_network_access_enabled = false
51+
delegated_subnet_id = var.delegated_subnet_id
52+
private_dns_zone_id = local.private_dns_zone_id
53+
54+
administrator_login = local.password_auth_enabled ? var.username : null
55+
administrator_password = local.password_auth_enabled ? var.password : null
56+
57+
backup_retention_days = var.backup_retention_days
58+
geo_redundant_backup_enabled = var.geo_redundant_backup_enabled
59+
60+
dynamic "authentication" {
61+
for_each = var.enable_entra_authentication ? [1] : []
62+
content {
63+
active_directory_auth_enabled = true
64+
password_auth_enabled = local.password_auth_enabled
65+
tenant_id = var.tenant_id
66+
}
67+
}
68+
69+
dynamic "high_availability" {
70+
for_each = var.high_availability_enabled ? [1] : []
71+
content {
72+
mode = var.high_availability_mode
73+
}
74+
}
75+
76+
dynamic "maintenance_window" {
77+
for_each = var.maintenance_window != null ? [var.maintenance_window] : []
78+
content {
79+
day_of_week = maintenance_window.value.day_of_week
80+
start_hour = maintenance_window.value.start_hour
81+
start_minute = maintenance_window.value.start_minute
82+
}
83+
}
84+
85+
tags = var.tags
86+
87+
# Guardrail, same as the AWS postgres module: this server holds production
88+
# data. A change Azure cannot make in place fails here rather than silently
89+
# replacing the server with an empty one. A real migration is done
90+
# deliberately with this guard removed.
91+
lifecycle {
92+
prevent_destroy = true
93+
94+
# Azure hands back the zone it picked, and the standby's zone with it.
95+
# Neither can be changed without moving the server, so an unset variable
96+
# must not read as "move it back".
97+
ignore_changes = [zone, high_availability[0].standby_availability_zone]
98+
}
99+
100+
depends_on = [azurerm_private_dns_zone_virtual_network_link.this]
101+
}
102+
103+
# Without this an Entra-only server has no administrator: password logins are
104+
# off and no Entra principal has been granted access, so nobody can connect to
105+
# bootstrap the roles a workload identity needs.
106+
resource "azurerm_postgresql_flexible_server_active_directory_administrator" "this" {
107+
count = var.enable_entra_authentication && var.entra_administrator_object_id != null ? 1 : 0
108+
109+
server_name = azurerm_postgresql_flexible_server.this.name
110+
resource_group_name = var.resource_group_name
111+
tenant_id = var.tenant_id
112+
object_id = var.entra_administrator_object_id
113+
principal_name = var.entra_administrator_principal_name
114+
principal_type = var.entra_administrator_principal_type
115+
}
116+
117+
resource "azurerm_postgresql_flexible_server_database" "this" {
118+
name = var.db_name
119+
server_id = azurerm_postgresql_flexible_server.this.id
120+
charset = "UTF8"
121+
collation = "en_US.utf8"
122+
123+
# Dropping the database drops everything in it, and Azure gives no way back.
124+
lifecycle {
125+
prevent_destroy = true
126+
}
127+
}
128+
129+
resource "azurerm_monitor_metric_alert" "cpu" {
130+
name = "${var.name}-cpu-high"
131+
resource_group_name = var.resource_group_name
132+
scopes = [azurerm_postgresql_flexible_server.this.id]
133+
description = "PostgreSQL ${var.name} CPU utilisation high"
134+
severity = 2
135+
frequency = local.alert_frequency
136+
window_size = local.alert_window_size
137+
tags = var.tags
138+
139+
criteria {
140+
metric_namespace = local.metric_namespace
141+
metric_name = "cpu_percent"
142+
aggregation = "Average"
143+
operator = "GreaterThan"
144+
threshold = var.cpu_alarm_threshold
145+
}
146+
147+
dynamic "action" {
148+
for_each = var.action_group_ids
149+
content {
150+
action_group_id = action.value
151+
}
152+
}
153+
}
154+
155+
resource "azurerm_monitor_metric_alert" "memory" {
156+
name = "${var.name}-memory-high"
157+
resource_group_name = var.resource_group_name
158+
scopes = [azurerm_postgresql_flexible_server.this.id]
159+
description = "PostgreSQL ${var.name} memory utilisation high"
160+
severity = 2
161+
frequency = local.alert_frequency
162+
window_size = local.alert_window_size
163+
tags = var.tags
164+
165+
criteria {
166+
metric_namespace = local.metric_namespace
167+
metric_name = "memory_percent"
168+
aggregation = "Average"
169+
operator = "GreaterThan"
170+
threshold = var.memory_alarm_threshold
171+
}
172+
173+
dynamic "action" {
174+
for_each = var.action_group_ids
175+
content {
176+
action_group_id = action.value
177+
}
178+
}
179+
}
180+
181+
# A full data volume wedges the writer. Auto-grow usually gets there first, but
182+
# it stops at the largest size Azure offers.
183+
resource "azurerm_monitor_metric_alert" "storage" {
184+
name = "${var.name}-storage-high"
185+
resource_group_name = var.resource_group_name
186+
scopes = [azurerm_postgresql_flexible_server.this.id]
187+
description = "PostgreSQL ${var.name} storage nearly full"
188+
severity = 1
189+
frequency = local.alert_frequency
190+
window_size = local.alert_window_size
191+
tags = var.tags
192+
193+
criteria {
194+
metric_namespace = local.metric_namespace
195+
metric_name = "storage_percent"
196+
aggregation = "Average"
197+
operator = "GreaterThan"
198+
threshold = var.storage_alarm_threshold
199+
}
200+
201+
dynamic "action" {
202+
for_each = var.action_group_ids
203+
content {
204+
action_group_id = action.value
205+
}
206+
}
207+
}
208+
209+
# A task holding a session across an external call, or a request-cancel leak,
210+
# saturates the pool and new pods then fail to start.
211+
resource "azurerm_monitor_metric_alert" "connections" {
212+
name = "${var.name}-connections-high"
213+
resource_group_name = var.resource_group_name
214+
scopes = [azurerm_postgresql_flexible_server.this.id]
215+
description = "PostgreSQL ${var.name} connection count high"
216+
severity = 2
217+
frequency = local.alert_frequency
218+
window_size = local.alert_window_size
219+
tags = var.tags
220+
221+
criteria {
222+
metric_namespace = local.metric_namespace
223+
metric_name = "active_connections"
224+
aggregation = "Average"
225+
operator = "GreaterThan"
226+
threshold = var.connections_alarm_threshold
227+
}
228+
229+
dynamic "action" {
230+
for_each = var.action_group_ids
231+
content {
232+
action_group_id = action.value
233+
}
234+
}
235+
}
236+
237+
resource "azurerm_monitor_metric_alert" "iops" {
238+
name = "${var.name}-iops-high"
239+
resource_group_name = var.resource_group_name
240+
scopes = [azurerm_postgresql_flexible_server.this.id]
241+
description = "PostgreSQL ${var.name} consuming most of its provisioned IOPS"
242+
severity = 2
243+
frequency = local.alert_frequency
244+
window_size = local.alert_window_size
245+
tags = var.tags
246+
247+
criteria {
248+
metric_namespace = local.metric_namespace
249+
metric_name = "disk_iops_consumed_percentage"
250+
aggregation = "Average"
251+
operator = "GreaterThan"
252+
threshold = var.iops_alarm_threshold
253+
}
254+
255+
dynamic "action" {
256+
for_each = var.action_group_ids
257+
content {
258+
action_group_id = action.value
259+
}
260+
}
261+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
output "server_id" {
2+
description = "Resource ID of the flexible server"
3+
value = azurerm_postgresql_flexible_server.this.id
4+
}
5+
6+
output "server_name" {
7+
description = "Name of the flexible server"
8+
value = azurerm_postgresql_flexible_server.this.name
9+
}
10+
11+
output "fqdn" {
12+
description = "Private hostname of the server. Resolves only from networks linked to the private DNS zone."
13+
value = azurerm_postgresql_flexible_server.this.fqdn
14+
}
15+
16+
output "port" {
17+
description = "Port the server listens on"
18+
value = 5432
19+
}
20+
21+
output "db_name" {
22+
description = "Name of the database created on the server"
23+
value = azurerm_postgresql_flexible_server_database.this.name
24+
}
25+
26+
output "username" {
27+
description = "Administrator login, null when password authentication is off"
28+
value = azurerm_postgresql_flexible_server.this.administrator_login
29+
sensitive = true
30+
}
31+
32+
output "private_dns_zone_id" {
33+
description = "Resource ID of the private DNS zone the server resolves through"
34+
value = local.private_dns_zone_id
35+
}
36+
37+
output "entra_administrator_object_id" {
38+
description = "Object ID of the Entra database administrator, null when none was configured"
39+
value = try(azurerm_postgresql_flexible_server_active_directory_administrator.this[0].object_id, null)
40+
}

0 commit comments

Comments
 (0)