Skip to content

Commit 6ec2275

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 c669fdc commit 6ec2275

5 files changed

Lines changed: 777 additions & 0 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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; there is no separate "publicly accessible" switch to turn off.
49+
delegated_subnet_id = var.delegated_subnet_id
50+
private_dns_zone_id = local.private_dns_zone_id
51+
52+
administrator_login = local.password_auth_enabled ? var.username : null
53+
administrator_password = local.password_auth_enabled ? var.password : null
54+
55+
backup_retention_days = var.backup_retention_days
56+
geo_redundant_backup_enabled = var.geo_redundant_backup_enabled
57+
58+
dynamic "authentication" {
59+
for_each = var.enable_entra_authentication ? [1] : []
60+
content {
61+
active_directory_auth_enabled = true
62+
password_auth_enabled = local.password_auth_enabled
63+
tenant_id = var.tenant_id
64+
}
65+
}
66+
67+
dynamic "high_availability" {
68+
for_each = var.high_availability_enabled ? [1] : []
69+
content {
70+
mode = var.high_availability_mode
71+
}
72+
}
73+
74+
dynamic "maintenance_window" {
75+
for_each = var.maintenance_window != null ? [var.maintenance_window] : []
76+
content {
77+
day_of_week = maintenance_window.value.day_of_week
78+
start_hour = maintenance_window.value.start_hour
79+
start_minute = maintenance_window.value.start_minute
80+
}
81+
}
82+
83+
tags = var.tags
84+
85+
# Guardrail, same as the AWS postgres module: this server holds production
86+
# data. A change Azure cannot make in place fails here rather than silently
87+
# replacing the server with an empty one. A real migration is done
88+
# deliberately with this guard removed.
89+
lifecycle {
90+
prevent_destroy = true
91+
92+
# Azure hands back the zone it picked, and the standby's zone with it.
93+
# Neither can be changed without moving the server, so an unset variable
94+
# must not read as "move it back".
95+
ignore_changes = [zone, high_availability[0].standby_availability_zone]
96+
}
97+
98+
depends_on = [azurerm_private_dns_zone_virtual_network_link.this]
99+
}
100+
101+
resource "azurerm_postgresql_flexible_server_database" "this" {
102+
name = var.db_name
103+
server_id = azurerm_postgresql_flexible_server.this.id
104+
charset = "UTF8"
105+
collation = "en_US.utf8"
106+
107+
# Dropping the database drops everything in it, and Azure gives no way back.
108+
lifecycle {
109+
prevent_destroy = true
110+
}
111+
}
112+
113+
resource "azurerm_monitor_metric_alert" "cpu" {
114+
name = "${var.name}-cpu-high"
115+
resource_group_name = var.resource_group_name
116+
scopes = [azurerm_postgresql_flexible_server.this.id]
117+
description = "PostgreSQL ${var.name} CPU utilisation high"
118+
severity = 2
119+
frequency = local.alert_frequency
120+
window_size = local.alert_window_size
121+
tags = var.tags
122+
123+
criteria {
124+
metric_namespace = local.metric_namespace
125+
metric_name = "cpu_percent"
126+
aggregation = "Average"
127+
operator = "GreaterThan"
128+
threshold = var.cpu_alarm_threshold
129+
}
130+
131+
dynamic "action" {
132+
for_each = var.action_group_ids
133+
content {
134+
action_group_id = action.value
135+
}
136+
}
137+
}
138+
139+
resource "azurerm_monitor_metric_alert" "memory" {
140+
name = "${var.name}-memory-high"
141+
resource_group_name = var.resource_group_name
142+
scopes = [azurerm_postgresql_flexible_server.this.id]
143+
description = "PostgreSQL ${var.name} memory utilisation high"
144+
severity = 2
145+
frequency = local.alert_frequency
146+
window_size = local.alert_window_size
147+
tags = var.tags
148+
149+
criteria {
150+
metric_namespace = local.metric_namespace
151+
metric_name = "memory_percent"
152+
aggregation = "Average"
153+
operator = "GreaterThan"
154+
threshold = var.memory_alarm_threshold
155+
}
156+
157+
dynamic "action" {
158+
for_each = var.action_group_ids
159+
content {
160+
action_group_id = action.value
161+
}
162+
}
163+
}
164+
165+
# A full data volume wedges the writer. Auto-grow usually gets there first, but
166+
# it stops at the largest size Azure offers.
167+
resource "azurerm_monitor_metric_alert" "storage" {
168+
name = "${var.name}-storage-high"
169+
resource_group_name = var.resource_group_name
170+
scopes = [azurerm_postgresql_flexible_server.this.id]
171+
description = "PostgreSQL ${var.name} storage nearly full"
172+
severity = 1
173+
frequency = local.alert_frequency
174+
window_size = local.alert_window_size
175+
tags = var.tags
176+
177+
criteria {
178+
metric_namespace = local.metric_namespace
179+
metric_name = "storage_percent"
180+
aggregation = "Average"
181+
operator = "GreaterThan"
182+
threshold = var.storage_alarm_threshold
183+
}
184+
185+
dynamic "action" {
186+
for_each = var.action_group_ids
187+
content {
188+
action_group_id = action.value
189+
}
190+
}
191+
}
192+
193+
# A task holding a session across an external call, or a request-cancel leak,
194+
# saturates the pool and new pods then fail to start.
195+
resource "azurerm_monitor_metric_alert" "connections" {
196+
name = "${var.name}-connections-high"
197+
resource_group_name = var.resource_group_name
198+
scopes = [azurerm_postgresql_flexible_server.this.id]
199+
description = "PostgreSQL ${var.name} connection count high"
200+
severity = 2
201+
frequency = local.alert_frequency
202+
window_size = local.alert_window_size
203+
tags = var.tags
204+
205+
criteria {
206+
metric_namespace = local.metric_namespace
207+
metric_name = "active_connections"
208+
aggregation = "Average"
209+
operator = "GreaterThan"
210+
threshold = var.connections_alarm_threshold
211+
}
212+
213+
dynamic "action" {
214+
for_each = var.action_group_ids
215+
content {
216+
action_group_id = action.value
217+
}
218+
}
219+
}
220+
221+
resource "azurerm_monitor_metric_alert" "iops" {
222+
name = "${var.name}-iops-high"
223+
resource_group_name = var.resource_group_name
224+
scopes = [azurerm_postgresql_flexible_server.this.id]
225+
description = "PostgreSQL ${var.name} consuming most of its provisioned IOPS"
226+
severity = 2
227+
frequency = local.alert_frequency
228+
window_size = local.alert_window_size
229+
tags = var.tags
230+
231+
criteria {
232+
metric_namespace = local.metric_namespace
233+
metric_name = "disk_iops_consumed_percentage"
234+
aggregation = "Average"
235+
operator = "GreaterThan"
236+
threshold = var.iops_alarm_threshold
237+
}
238+
239+
dynamic "action" {
240+
for_each = var.action_group_ids
241+
content {
242+
action_group_id = action.value
243+
}
244+
}
245+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
}

0 commit comments

Comments
 (0)