Skip to content

Commit 0c74f24

Browse files
committed
feat(terraform): add the Azure vnet module
First module of the Azure set, mirroring deployment/terraform/modules/aws/vpc. Azure differs from AWS in three ways that show up in the interface: - Subnets are named resources and delegation is a property of the subnet, so the public/private CIDR lists become a map keyed by purpose. PostgreSQL Flexible Server needs its own delegated subnet, and Application Gateway needs a dedicated one. - The Microsoft.Storage service endpoint on the AKS subnet plays the role the S3 gateway endpoint plays on AWS: it is what lets the storage account restrict access to the cluster. - Flow logs are opt-in rather than on by default. Azure writes them to a storage account and needs a Network Watcher in the region, so enabling them by default would either create a storage account the caller did not ask for or fail on subscriptions without a Network Watcher. Tests plan the module against a mocked provider, so they need no Azure subscription. Run 'terraform test' from the module directory.
1 parent f880306 commit 0c74f24

5 files changed

Lines changed: 533 additions & 0 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
locals {
2+
# Subnets opt in to the NAT gateway individually. A delegated database subnet
3+
# and the Application Gateway subnet must stay off it.
4+
nat_gateway_subnets = var.enable_nat_gateway ? {
5+
for key, subnet in var.subnets : key => subnet if subnet.nat_gateway
6+
} : {}
7+
}
8+
9+
resource "azurerm_virtual_network" "this" {
10+
name = "${var.name}-vnet"
11+
resource_group_name = var.resource_group_name
12+
location = var.location
13+
address_space = var.address_space
14+
tags = var.tags
15+
}
16+
17+
resource "azurerm_subnet" "this" {
18+
for_each = var.subnets
19+
20+
name = "${var.name}-${each.key}"
21+
resource_group_name = var.resource_group_name
22+
virtual_network_name = azurerm_virtual_network.this.name
23+
address_prefixes = each.value.address_prefixes
24+
service_endpoints = each.value.service_endpoints
25+
private_endpoint_network_policies = each.value.private_endpoint_network_policies
26+
27+
dynamic "delegation" {
28+
for_each = each.value.delegation != null ? [each.value.delegation] : []
29+
content {
30+
name = "delegation"
31+
service_delegation {
32+
name = delegation.value
33+
actions = each.value.delegation_actions
34+
}
35+
}
36+
}
37+
}
38+
39+
# A single public IP keeps egress on one address, so downstream allowlists stay
40+
# stable across node replacements. Standard SKU is required by NAT gateway.
41+
resource "azurerm_public_ip" "nat" {
42+
count = var.enable_nat_gateway ? 1 : 0
43+
44+
name = "${var.name}-nat-pip"
45+
resource_group_name = var.resource_group_name
46+
location = var.location
47+
allocation_method = "Static"
48+
sku = "Standard"
49+
zones = var.nat_gateway_zones
50+
tags = var.tags
51+
}
52+
53+
resource "azurerm_nat_gateway" "this" {
54+
count = var.enable_nat_gateway ? 1 : 0
55+
56+
name = "${var.name}-nat"
57+
resource_group_name = var.resource_group_name
58+
location = var.location
59+
sku_name = "Standard"
60+
idle_timeout_in_minutes = var.nat_gateway_idle_timeout_minutes
61+
zones = var.nat_gateway_zones
62+
tags = var.tags
63+
}
64+
65+
resource "azurerm_nat_gateway_public_ip_association" "this" {
66+
count = var.enable_nat_gateway ? 1 : 0
67+
68+
nat_gateway_id = azurerm_nat_gateway.this[0].id
69+
public_ip_address_id = azurerm_public_ip.nat[0].id
70+
}
71+
72+
resource "azurerm_subnet_nat_gateway_association" "this" {
73+
for_each = local.nat_gateway_subnets
74+
75+
subnet_id = azurerm_subnet.this[each.key].id
76+
nat_gateway_id = azurerm_nat_gateway.this[0].id
77+
78+
# Without this the association can be created before the gateway has its
79+
# public IP, and egress silently falls back to the default outbound path.
80+
depends_on = [azurerm_nat_gateway_public_ip_association.this]
81+
}
82+
83+
resource "azurerm_network_watcher_flow_log" "this" {
84+
count = var.enable_flow_logs ? 1 : 0
85+
86+
name = "${var.name}-vnet-flow-log"
87+
network_watcher_name = var.network_watcher_name
88+
resource_group_name = var.network_watcher_resource_group_name
89+
location = var.location
90+
target_resource_id = azurerm_virtual_network.this.id
91+
storage_account_id = var.flow_log_storage_account_id
92+
enabled = true
93+
version = 2
94+
tags = var.tags
95+
96+
retention_policy {
97+
enabled = var.flow_log_retention_days > 0
98+
days = var.flow_log_retention_days
99+
}
100+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
output "vnet_id" {
2+
description = "Resource ID of the virtual network"
3+
value = azurerm_virtual_network.this.id
4+
}
5+
6+
output "vnet_name" {
7+
description = "Name of the virtual network"
8+
value = azurerm_virtual_network.this.name
9+
}
10+
11+
output "address_space" {
12+
description = "Address space of the virtual network"
13+
value = azurerm_virtual_network.this.address_space
14+
}
15+
16+
output "subnet_ids" {
17+
description = "Subnet resource IDs, keyed by the same names as the subnets variable"
18+
value = { for key, subnet in azurerm_subnet.this : key => subnet.id }
19+
}
20+
21+
output "subnet_address_prefixes" {
22+
description = "Subnet address prefixes, keyed by the same names as the subnets variable"
23+
value = { for key, subnet in azurerm_subnet.this : key => subnet.address_prefixes }
24+
}
25+
26+
# Convenience outputs for the subnets the other modules expect. Null when the
27+
# caller replaced the default subnet map and dropped that key.
28+
output "aks_subnet_id" {
29+
description = "Resource ID of the AKS subnet"
30+
value = try(azurerm_subnet.this["aks"].id, null)
31+
}
32+
33+
output "postgres_subnet_id" {
34+
description = "Resource ID of the delegated PostgreSQL Flexible Server subnet"
35+
value = try(azurerm_subnet.this["postgres"].id, null)
36+
}
37+
38+
output "private_endpoint_subnet_id" {
39+
description = "Resource ID of the subnet that holds private endpoints"
40+
value = try(azurerm_subnet.this["private_endpoints"].id, null)
41+
}
42+
43+
output "app_gateway_subnet_id" {
44+
description = "Resource ID of the Application Gateway subnet"
45+
value = try(azurerm_subnet.this["app_gateway"].id, null)
46+
}
47+
48+
output "nat_gateway_id" {
49+
description = "Resource ID of the NAT gateway, null when disabled"
50+
value = try(azurerm_nat_gateway.this[0].id, null)
51+
}
52+
53+
output "nat_gateway_public_ips" {
54+
description = "Public IPs assigned to the NAT gateway. Egress from every attached subnet leaves from these."
55+
value = var.enable_nat_gateway ? [azurerm_public_ip.nat[0].ip_address] : []
56+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Plans the module against a mocked provider, so these run without an Azure
2+
# subscription or credentials. Run with `terraform test` from the module directory.
3+
4+
mock_provider "azurerm" {}
5+
6+
variables {
7+
name = "onyx"
8+
resource_group_name = "onyx-rg"
9+
location = "eastus"
10+
}
11+
12+
run "default_subnets" {
13+
command = plan
14+
15+
assert {
16+
condition = length(azurerm_subnet.this) == 4
17+
error_message = "The default subnet map should create four subnets."
18+
}
19+
20+
assert {
21+
condition = azurerm_subnet.this["aks"].name == "onyx-aks"
22+
error_message = "Subnet names should be prefixed with the module name."
23+
}
24+
25+
assert {
26+
condition = contains(azurerm_subnet.this["aks"].service_endpoints, "Microsoft.Storage")
27+
error_message = "The AKS subnet needs the Microsoft.Storage service endpoint so the storage account can restrict access to it."
28+
}
29+
30+
assert {
31+
condition = one(azurerm_subnet.this["postgres"].delegation).service_delegation[0].name == "Microsoft.DBforPostgreSQL/flexibleServers"
32+
error_message = "The postgres subnet must be delegated to PostgreSQL Flexible Server."
33+
}
34+
35+
assert {
36+
condition = length(azurerm_subnet.this["aks"].delegation) == 0
37+
error_message = "Only the postgres subnet should be delegated."
38+
}
39+
}
40+
41+
run "nat_gateway_attaches_only_to_opted_in_subnets" {
42+
command = plan
43+
44+
assert {
45+
condition = length(azurerm_subnet_nat_gateway_association.this) == 1
46+
error_message = "Only the AKS subnet opts into the NAT gateway by default."
47+
}
48+
49+
assert {
50+
condition = contains(keys(azurerm_subnet_nat_gateway_association.this), "aks")
51+
error_message = "The AKS subnet is the one that needs egress."
52+
}
53+
54+
assert {
55+
condition = azurerm_public_ip.nat[0].sku == "Standard"
56+
error_message = "A NAT gateway requires a Standard SKU public IP."
57+
}
58+
}
59+
60+
run "nat_gateway_can_be_disabled" {
61+
command = plan
62+
63+
variables {
64+
enable_nat_gateway = false
65+
}
66+
67+
assert {
68+
condition = length(azurerm_nat_gateway.this) == 0
69+
error_message = "enable_nat_gateway = false should create no NAT gateway."
70+
}
71+
72+
assert {
73+
condition = length(azurerm_subnet_nat_gateway_association.this) == 0
74+
error_message = "Disabling the NAT gateway must also drop its subnet associations."
75+
}
76+
}
77+
78+
run "flow_logs_off_by_default" {
79+
command = plan
80+
81+
assert {
82+
condition = length(azurerm_network_watcher_flow_log.this) == 0
83+
error_message = "Flow logs are opt-in because they need a storage account and a Network Watcher."
84+
}
85+
}
86+
87+
run "flow_logs_require_a_storage_account" {
88+
command = plan
89+
90+
variables {
91+
enable_flow_logs = true
92+
}
93+
94+
expect_failures = [var.enable_flow_logs]
95+
}
96+
97+
run "flow_logs_require_a_network_watcher_too" {
98+
command = plan
99+
100+
variables {
101+
enable_flow_logs = true
102+
flow_log_storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/onyx-rg/providers/Microsoft.Storage/storageAccounts/onyxflowlogs"
103+
}
104+
105+
expect_failures = [var.enable_flow_logs]
106+
}
107+
108+
run "flow_logs_plan_when_both_destination_and_watcher_are_set" {
109+
command = plan
110+
111+
variables {
112+
enable_flow_logs = true
113+
flow_log_storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/onyx-rg/providers/Microsoft.Storage/storageAccounts/onyxflowlogs"
114+
network_watcher_name = "NetworkWatcher_eastus"
115+
}
116+
117+
assert {
118+
condition = length(azurerm_network_watcher_flow_log.this) == 1
119+
error_message = "With a destination and a watcher the flow log should be created."
120+
}
121+
122+
assert {
123+
condition = azurerm_network_watcher_flow_log.this[0].network_watcher_name == "NetworkWatcher_eastus"
124+
error_message = "The flow log should be owned by the supplied Network Watcher."
125+
}
126+
127+
assert {
128+
condition = one(azurerm_network_watcher_flow_log.this[0].retention_policy).days == 365
129+
error_message = "Retention should default to the Azure ceiling of 365 days."
130+
}
131+
}
132+
133+
run "a_custom_subnet_map_gets_no_nat_gateway_unless_it_asks" {
134+
command = plan
135+
136+
# Opt-in: a caller writing their own map should not silently attach a NAT
137+
# gateway to a subnet that cannot carry one.
138+
variables {
139+
subnets = {
140+
aks = {
141+
address_prefixes = ["10.0.0.0/20"]
142+
}
143+
appgw = {
144+
address_prefixes = ["10.0.18.0/24"]
145+
}
146+
}
147+
}
148+
149+
assert {
150+
condition = length(azurerm_subnet_nat_gateway_association.this) == 0
151+
error_message = "A custom subnet map should attach no NAT gateway until a subnet asks for one."
152+
}
153+
}
154+
155+
run "rejects_a_blank_network_watcher_name" {
156+
command = plan
157+
158+
variables {
159+
enable_flow_logs = true
160+
flow_log_storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/onyx-rg/providers/Microsoft.Storage/storageAccounts/onyxflowlogs"
161+
network_watcher_name = " "
162+
}
163+
164+
expect_failures = [var.enable_flow_logs]
165+
}
166+
167+
run "flow_log_retention_respects_the_azure_ceiling" {
168+
command = plan
169+
170+
variables {
171+
flow_log_retention_days = 400
172+
}
173+
174+
expect_failures = [var.flow_log_retention_days]
175+
}
176+
177+
run "nat_gateway_is_regional_or_single_zone" {
178+
command = plan
179+
180+
variables {
181+
nat_gateway_zones = ["1", "2"]
182+
}
183+
184+
expect_failures = [var.nat_gateway_zones]
185+
}
186+
187+
run "subnets_need_an_address_prefix" {
188+
command = plan
189+
190+
variables {
191+
subnets = {
192+
aks = {
193+
address_prefixes = []
194+
}
195+
}
196+
}
197+
198+
expect_failures = [var.subnets]
199+
}

0 commit comments

Comments
 (0)