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