Skip to content

Commit 0e05f06

Browse files
authored
Merge pull request #141 from infrablocks/support-az-updates
Adding Support for updating Availability Zones
2 parents e0396ae + 3d2f999 commit 0e05f06

File tree

5 files changed

+194
-36
lines changed

5 files changed

+194
-36
lines changed

nat.tf

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
resource "aws_eip" "nat" {
2-
count = local.include_nat_gateways == "yes" ? length(var.availability_zones) : 0
2+
for_each = local.include_nat_gateways == "yes" ? toset(var.availability_zones) : toset([])
33

44
vpc = true
55

66
tags = {
7-
Name = "eip-nat-${var.component}-${var.deployment_identifier}-${element(var.availability_zones, count.index)}"
7+
Name = "eip-nat-${var.component}-${var.deployment_identifier}-${each.value}"
88
Component = var.component
99
DeploymentIdentifier = var.deployment_identifier
1010
}
1111
}
1212

1313
resource "aws_nat_gateway" "base" {
14-
count = local.include_nat_gateways == "yes" ? length(var.availability_zones) : 0
14+
for_each = local.include_nat_gateways == "yes" ? toset(var.availability_zones) : toset([])
1515

16-
allocation_id = element(aws_eip.nat.*.id, count.index)
17-
subnet_id = element(aws_subnet.public.*.id, count.index)
16+
allocation_id = aws_eip.nat[each.value].id
17+
subnet_id = aws_subnet.public[each.value].id
1818

1919
depends_on = [
2020
aws_internet_gateway.base_igw
2121
]
2222

2323
tags = {
24-
Name = "nat-${var.component}-${var.deployment_identifier}-${element(var.availability_zones, count.index)}"
24+
Name = "nat-${var.component}-${var.deployment_identifier}-${each.value}"
2525
Component = var.component
2626
DeploymentIdentifier = var.deployment_identifier
2727
}

outputs.tf

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,37 +20,37 @@ output "number_of_availability_zones" {
2020

2121
output "public_subnet_ids" {
2222
description = "The IDs of the public subnets."
23-
value = aws_subnet.public.*.id
23+
value = [for az in var.availability_zones : aws_subnet.public[az].id]
2424
}
2525

2626
output "public_subnet_cidr_blocks" {
2727
description = "The CIDRs of the public subnets."
28-
value = aws_subnet.public.*.cidr_block
28+
value = [for az in var.availability_zones : aws_subnet.public[az].cidr_block]
2929
}
3030

3131
output "public_route_table_ids" {
3232
description = "The IDs of the public route tables."
33-
value = aws_route_table.public.*.id
33+
value = [for az in var.availability_zones : aws_route_table.public[az].id]
3434
}
3535

3636
output "private_subnet_ids" {
3737
description = "The IDs of the private subnets."
38-
value = aws_subnet.private.*.id
38+
value = [for az in var.availability_zones : aws_subnet.private[az].id]
3939
}
4040

4141
output "private_subnet_cidr_blocks" {
4242
description = "The CIDRs of the private subnets."
43-
value = aws_subnet.private.*.cidr_block
43+
value = [for az in var.availability_zones : aws_subnet.private[az].cidr_block]
4444
}
4545

4646
output "private_route_table_ids" {
4747
description = "The IDs of the private route tables."
48-
value = aws_route_table.private.*.id
48+
value = [for az in var.availability_zones : aws_route_table.private[az].id]
4949
}
5050

5151
output "nat_public_ips" {
5252
description = "The EIPs attached to the NAT gateways."
53-
value = aws_eip.nat.*.public_ip
53+
value = local.include_nat_gateways == "yes" ? [for az in var.availability_zones : aws_eip.nat[az].public_ip] : []
5454
}
5555

5656
output "internet_gateway_id" {

private_subnets.tf

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,42 @@
11
resource "aws_subnet" "private" {
2+
for_each = toset(var.availability_zones)
3+
24
vpc_id = aws_vpc.base.id
3-
count = length(var.availability_zones)
4-
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + length(var.availability_zones) + local.private_subnets_offset)
5-
availability_zone = element(var.availability_zones, count.index)
5+
cidr_block = cidrsubnet(var.vpc_cidr, 8, index(var.availability_zones, each.value) + length(var.availability_zones) + local.private_subnets_offset)
6+
availability_zone = each.value
67

78
tags = {
8-
Name = "private-subnet-${var.component}-${var.deployment_identifier}-${element(var.availability_zones, count.index)}"
9+
Name = "private-subnet-${var.component}-${var.deployment_identifier}-${each.value}"
910
Component = var.component
1011
DeploymentIdentifier = var.deployment_identifier
1112
Tier = "private"
1213
}
1314
}
1415

1516
resource "aws_route_table" "private" {
17+
for_each = toset(var.availability_zones)
18+
1619
vpc_id = aws_vpc.base.id
17-
count = length(var.availability_zones)
1820

1921
tags = {
20-
Name = "private-routetable-${var.component}-${var.deployment_identifier}-${element(var.availability_zones, count.index)}"
22+
Name = "private-routetable-${var.component}-${var.deployment_identifier}-${each.value}"
2123
Component = var.component
2224
DeploymentIdentifier = var.deployment_identifier
2325
Tier = "private"
2426
}
2527
}
2628

2729
resource "aws_route" "private_internet" {
28-
count = local.include_nat_gateways == "yes" ? length(var.availability_zones) : 0
29-
route_table_id = element(aws_route_table.private.*.id, count.index)
30-
nat_gateway_id = element(aws_nat_gateway.base.*.id, count.index)
30+
for_each = local.include_nat_gateways == "yes" ? toset(var.availability_zones) : toset([])
31+
32+
route_table_id = aws_route_table.private[each.value].id
33+
nat_gateway_id = aws_nat_gateway.base[each.value].id
3134
destination_cidr_block = "0.0.0.0/0"
3235
}
3336

3437
resource "aws_route_table_association" "private" {
35-
count = length(var.availability_zones)
36-
subnet_id = element(aws_subnet.private.*.id, count.index)
37-
route_table_id = element(aws_route_table.private.*.id, count.index)
38+
for_each = toset(var.availability_zones)
39+
40+
subnet_id = aws_subnet.private[each.value].id
41+
route_table_id = aws_route_table.private[each.value].id
3842
}

public_subnets.tf

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,42 @@
11
resource "aws_subnet" "public" {
2+
for_each = toset(var.availability_zones)
3+
24
vpc_id = aws_vpc.base.id
3-
count = length(var.availability_zones)
4-
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + local.public_subnets_offset)
5-
availability_zone = element(var.availability_zones, count.index)
5+
cidr_block = cidrsubnet(var.vpc_cidr, 8, index(var.availability_zones, each.value) + local.public_subnets_offset)
6+
availability_zone = each.value
67

78
tags = {
8-
Name = "public-subnet-${var.component}-${var.deployment_identifier}-${element(var.availability_zones, count.index)}"
9+
Name = "public-subnet-${var.component}-${var.deployment_identifier}-${each.value}"
910
Component = var.component
1011
DeploymentIdentifier = var.deployment_identifier
1112
Tier = "public"
1213
}
1314
}
1415

1516
resource "aws_route_table" "public" {
17+
for_each = toset(var.availability_zones)
18+
1619
vpc_id = aws_vpc.base.id
17-
count = length(var.availability_zones)
1820

1921
tags = {
20-
Name = "public-routetable-${var.component}-${var.deployment_identifier}-${element(var.availability_zones, count.index)}"
22+
Name = "public-routetable-${var.component}-${var.deployment_identifier}-${each.value}"
2123
Component = var.component
2224
DeploymentIdentifier = var.deployment_identifier
2325
Tier = "public"
2426
}
2527
}
2628

2729
resource "aws_route" "public_internet" {
28-
count = length(var.availability_zones)
29-
route_table_id = element(aws_route_table.public.*.id, count.index)
30+
for_each = toset(var.availability_zones)
31+
32+
route_table_id = aws_route_table.public[each.value].id
3033
gateway_id = aws_internet_gateway.base_igw.id
3134
destination_cidr_block = "0.0.0.0/0"
3235
}
3336

3437
resource "aws_route_table_association" "public" {
35-
count = length(var.availability_zones)
36-
subnet_id = element(aws_subnet.public.*.id, count.index)
37-
route_table_id = element(aws_route_table.public.*.id, count.index)
38+
for_each = toset(var.availability_zones)
39+
40+
subnet_id = aws_subnet.public[each.value].id
41+
route_table_id = aws_route_table.public[each.value].id
3842
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'json'
5+
6+
describe 'availability zone addition' do
7+
let(:initial_availability_zones) do
8+
%w[eu-west-1a eu-west-1b]
9+
end
10+
11+
let(:updated_availability_zones) do
12+
%w[eu-west-1a eu-west-1b eu-west-1c]
13+
end
14+
15+
let(:component) { 'test-component' }
16+
let(:deployment_identifier) { 'test-deployment' }
17+
let(:vpc_cidr) { '10.0.0.0/16' }
18+
let(:region) { 'eu-west-1' }
19+
20+
describe 'adding a new availability zone' do
21+
it 'does not destroy existing subnets when adding a new availability zone' do
22+
# Step 1: Apply with initial set of availability zones
23+
initial_state = apply_and_get_state(initial_availability_zones)
24+
25+
# Get the initial subnet IDs
26+
initial_public_subnet_ids = get_resource_ids(initial_state, 'aws_subnet', 'public')
27+
initial_private_subnet_ids = get_resource_ids(initial_state, 'aws_subnet', 'private')
28+
initial_nat_gateway_ids = get_resource_ids(initial_state, 'aws_nat_gateway', 'base')
29+
initial_eip_ids = get_resource_ids(initial_state, 'aws_eip', 'nat')
30+
31+
# Step 2: Plan with additional availability zone
32+
plan_output = plan_with_azs(updated_availability_zones)
33+
34+
# Parse the plan output to check for destructions
35+
plan_json = JSON.parse(plan_output)
36+
37+
# Check that no existing resources are being destroyed
38+
resource_changes = plan_json['resource_changes'] || []
39+
40+
# Find any destroy actions for our existing resources
41+
destroyed_resources = resource_changes.select do |change|
42+
change['change']['actions'].include?('delete') &&
43+
(initial_public_subnet_ids.values.include?(change['change']['before']['id']) ||
44+
initial_private_subnet_ids.values.include?(change['change']['before']['id']) ||
45+
initial_nat_gateway_ids.values.include?(change['change']['before']['id']) ||
46+
initial_eip_ids.values.include?(change['change']['before']['id']))
47+
end
48+
49+
# Assert no existing resources are being destroyed
50+
expect(destroyed_resources).to be_empty,
51+
"Expected no resources to be destroyed, but found: #{destroyed_resources.map { |r| "#{r['type']}.#{r['name']}" }.join(', ')}"
52+
53+
# Check that only new resources are being created
54+
created_resources = resource_changes.select do |change|
55+
change['change']['actions'].include?('create') &&
56+
%w[aws_subnet aws_route_table aws_route_table_association aws_nat_gateway aws_eip].include?(change['type'])
57+
end
58+
59+
# We expect exactly 1 new public subnet, 1 new private subnet,
60+
# 2 new route tables, 2 new route table associations,
61+
# 1 new NAT gateway, and 1 new EIP for the new AZ
62+
expected_new_resources = {
63+
'aws_subnet' => 2, # 1 public + 1 private
64+
'aws_route_table' => 2, # 1 for public + 1 for private
65+
'aws_route_table_association' => 2, # 1 for public + 1 for private
66+
'aws_route' => 2, # 1 for public internet route + 1 for private NAT route
67+
'aws_nat_gateway' => 1,
68+
'aws_eip' => 1
69+
}
70+
71+
actual_new_resources = created_resources.group_by { |r| r['type'] }
72+
.transform_values(&:count)
73+
74+
expected_new_resources.each do |resource_type, expected_count|
75+
actual_count = actual_new_resources[resource_type] || 0
76+
expect(actual_count).to eq(expected_count),
77+
"Expected #{expected_count} new #{resource_type} resources, but found #{actual_count}"
78+
end
79+
end
80+
end
81+
82+
private
83+
84+
def apply_and_get_state(availability_zones)
85+
# Create a temporary directory for this test run
86+
test_dir = "spec/integration/test_runs/#{Time.now.to_i}"
87+
FileUtils.mkdir_p(test_dir)
88+
89+
# Write the terraform configuration
90+
File.write("#{test_dir}/main.tf", generate_terraform_config(availability_zones))
91+
92+
# Initialize and apply
93+
Dir.chdir(test_dir) do
94+
system('terraform init', out: File::NULL, err: File::NULL)
95+
system('terraform apply -auto-approve', out: File::NULL, err: File::NULL)
96+
97+
# Get the state
98+
state_output = `terraform show -json`
99+
JSON.parse(state_output)
100+
end
101+
ensure
102+
# Cleanup is handled by the test framework
103+
end
104+
105+
def plan_with_azs(availability_zones)
106+
# Update the configuration with new AZs
107+
test_dir = Dir.glob('spec/integration/test_runs/*').last
108+
File.write("#{test_dir}/main.tf", generate_terraform_config(availability_zones))
109+
110+
# Run plan and capture output
111+
Dir.chdir(test_dir) do
112+
`terraform plan -out=tfplan -json`
113+
`terraform show -json tfplan`
114+
end
115+
end
116+
117+
def generate_terraform_config(availability_zones)
118+
<<~HCL
119+
module "base_networking" {
120+
source = "../../../"
121+
122+
vpc_cidr = "#{vpc_cidr}"
123+
region = "#{region}"
124+
availability_zones = #{availability_zones.inspect}
125+
component = "#{component}"
126+
deployment_identifier = "#{deployment_identifier}"
127+
}
128+
129+
provider "aws" {
130+
region = "#{region}"
131+
}
132+
HCL
133+
end
134+
135+
def get_resource_ids(state, resource_type, resource_name)
136+
resources = state['values']['root_module']['child_modules']
137+
&.first['resources'] || []
138+
139+
resources.select do |r|
140+
r['type'] == resource_type && r['name'] == resource_name
141+
end.map do |r|
142+
# For for_each resources, use the index key (AZ name) as the key
143+
if r['index'].is_a?(String)
144+
[r['index'], r['values']['id']]
145+
else
146+
[r['index'].to_s, r['values']['id']]
147+
end
148+
end.to_h
149+
end
150+
end

0 commit comments

Comments
 (0)