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