diff --git a/features/apply_with_dependent_stacks.feature b/features/apply_with_dependent_stacks.feature new file mode 100644 index 00000000..1affaf92 --- /dev/null +++ b/features/apply_with_dependent_stacks.feature @@ -0,0 +1,169 @@ +Feature: Apply command + + Background: + Given a file named "stack_master.yml" with: + """ + stacks: + us_east_1: + myapp_vpc: + template: myapp_vpc.rb + myapp_web: + template: myapp_web.rb + """ + And a directory named "parameters" + And a file named "parameters/myapp_vpc.yml" with: + """ + ip_range: 10.160.0.0/16 + """ + And a file named "parameters/myapp_web.yml" with: + """ + VpcId: + stack_output: myapp_vpc/vpc_id + """ + And a directory named "templates" + And a file named "templates/myapp_vpc.rb" with: + """ + SparkleFormation.new(:myapp_vpc) do + description "Test template" + set!('AWSTemplateFormatVersion', '2010-09-09') + + parameters.ip_range do + description 'IP CIDR' + type 'String' + end + + resources.vpc do + type 'AWS::EC2::VPC' + properties do + cidr_block ref!(:ip_range) + end + end + + outputs do + vpc_id do + description 'A VPC ID' + value ref!(:vpc) + end + end + end + """ + And a file named "templates/myapp_web.rb" with: + """ + SparkleFormation.new(:myapp_web) do + description "Test template" + set!('AWSTemplateFormatVersion', '2010-09-09') + + parameters.vpc_id do + description 'VPC ID' + type 'AWS::EC2::VPC::Id' + end + + resources.test_sg do + type 'AWS::EC2::SecurityGroup' + properties do + group_description 'Test SG' + vpc_id ref!(:vpc_id) + end + end + end + """ + + Scenario: Update a stack that does not cause a replacement + Given I stub the following stack events: + | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | + | 1 | 1 | myapp-vpc | Vpc | UPDATE_COMPLETE | AWS::EC2::VPC | 2020-10-29 00:00:00 | + | 1 | 1 | myapp-vpc | myapp-vpc | UPDATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | + And I stub the following stacks: + | stack_id | stack_name | parameters | region | outputs | + | 1 | myapp-vpc | KeyName=my-key | us-east-1 | VpcId=vpc-1111 | + And I stub the following stacks: + | stack_id | stack_name | parameters | region | + | 2 | myapp-web | VpcId=vpc-1111 | us-east-1 | + And I stub a template for the stack "myapp-vpc": + """ + { + "Description": "Test template", + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "IpRange": { + "Description": "Ip CIDR", + "Type": "String" + } + }, + "Resources": { + "Vpc": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.161.0.0/16", + "EnableDnsSupport": "true" + } + } + }, + "Outputs": { + "VpcId": { + "Value": { "Ref": "Vpc" } + } + } + } + """ + + + When I run `stack_master apply us-east-1 myapp-vpc --trace` + And the output should contain all of these lines: + | Stack diff: | + | - "EnableDnsSupport": "true" | + | Proposed change set: | + | Replace | + | Apply change set (y/n)? | + And the output should not contain all of these lines: + | A dependent stack "myapp-web" is now out of date because of this change. | + | Apply this stack now (y/n)? | + Then the exit status should be 0 + + Scenario: Update a stack that causes a replacement + Given I stub the following stack events: + | stack_id | event_id | stack_name | logical_resource_id | resource_status | resource_type | timestamp | + | 1 | 1 | myapp-vpc | Vpc | CREATE_COMPLETE | AWS::EC2::VPC | 2020-10-29 00:00:00 | + | 1 | 1 | myapp-vpc | myapp-vpc | CREATE_COMPLETE | AWS::CloudFormation::Stack | 2020-10-29 00:00:00 | + And I stub the following stacks: + | stack_id | stack_name | parameters | region | outputs | + | 1 | myapp-vpc | KeyName=my-key | us-east-1 | VpcId=vpc-1111 | + And I stub the following stacks: + | stack_id | stack_name | parameters | region | + | 2 | myapp-web | VpcId=vpc-9999 | us-east-1 | + And I stub a template for the stack "myapp-vpc": + """ + { + "Description": "Test template", + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "IpRange": { + "Description": "Ip CIDR", + "Type": "String" + } + }, + "Resources": { + "Vpc": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.161.0.0/16" + } + } + }, + "Outputs": { + "VpcId": { + "Value": { "Ref": "Vpc" } + } + } + } + """ + When I run `stack_master apply us-east-1 myapp-vpc --trace` + And the output should contain all of these lines: + | Stack diff: | + | - "CidrBlock": "10.161.0.0/16" | + | Proposed change set: | + | Replace | + | Apply change set (y/n)? | + | A dependent stack "myapp-web" is now out of date because of this change. | + | Apply this stack now (y/n)? | + Then the exit status should be 0 diff --git a/lib/stack_master.rb b/lib/stack_master.rb index 0280d925..906a4a63 100644 --- a/lib/stack_master.rb +++ b/lib/stack_master.rb @@ -31,6 +31,7 @@ module StackMaster autoload :StackDiffer, 'stack_master/stack_differ' autoload :Validator, 'stack_master/validator' + autoload :StackDependency, 'stack_master/stack_dependency' require 'stack_master/template_compilers/sparkle_formation' require 'stack_master/template_compilers/json' diff --git a/lib/stack_master/commands/apply.rb b/lib/stack_master/commands/apply.rb index 7dded974..5b166f7d 100644 --- a/lib/stack_master/commands/apply.rb +++ b/lib/stack_master/commands/apply.rb @@ -51,12 +51,17 @@ def diff_stacks StackDiffer.new(proposed_stack, stack).output_diff end + def dependencies + @dependencies ||= StackDependency.new(@stack_definition, @config) + end + def create_or_update_stack if stack_exists? update_stack else create_stack end + offer_to_run_dependent_stacks end def create_stack @@ -162,6 +167,13 @@ def ensure_valid_template_body_size! end end + def offer_to_run_dependent_stacks + dependencies.outdated_stacks.each do |stack| + next unless ask?(%Q{A dependent stack "#{stack.stack_name}" is now out of date because of this change.\nApply this stack now (y/n)?}) + self.class.new(@config, stack, @options) + end + end + extend Forwardable def_delegators :@stack_definition, :stack_name, :region end diff --git a/lib/stack_master/stack_dependency.rb b/lib/stack_master/stack_dependency.rb new file mode 100644 index 00000000..8cb3cb77 --- /dev/null +++ b/lib/stack_master/stack_dependency.rb @@ -0,0 +1,71 @@ +module StackMaster + class StackDependency + StackOutputNotFound = Class.new(StandardError) + + def initialize(stack_definition, config) + @stack_definition = stack_definition + @config = config + end + + def outdated_stacks + @config.stacks.select do |stack| + dependent_stack = Stack.find(stack.region, stack.stack_name) + next unless dependent_stack + parameters = ParameterLoader.load(stack.parameter_files) + any_stack_output_outdated?(parameters, dependent_stack) + end + end + + private + + def any_stack_output_outdated?(params, stack) + params.any? do |key, value| + stack_output_outdated?(value['stack_output'], stack, @stack_definition.stack_name) || + stack_outputs_outdated?(value['stack_outputs'], stack, @stack_definition.stack_name, key) + end + end + + def stack_output_outdated?(stack_output, stack, stack_name) + stack_output && + stack_output_is_our_stack?(stack_output, stack_name) && + outdated?(stack, stack_output.split('/').last) + end + + def stack_outputs_outdated?(stack_outputs, stack, stack_name, parameter_key) + dependent_parameter = stack_parameter(stack, parameter_key) + stack_outputs && stack_outputs.any? do |output| + index = stack_outputs.find_index(output) + this_output_value = dependent_parameter.split(',')[index] + stack_output_is_our_stack?(output, stack_name) && + output_value(output.split('/').last.camelize) != this_output_value + end + end + + def stack_output_is_our_stack?(stack_output, stack) + stack_output.gsub('_', '-') =~ %r(#{stack}/) + end + + def outdated?(dependent_stack, output_key) + stack_output = output_value(output_key.camelize) + dependent_input = stack_parameter(dependent_stack, output_key) + dependent_input != stack_output + end + + def stack_parameter(stack, key) + stack.parameters[key.camelize] + end + + def output_value(key) + output_hash = updated_stack.outputs.select { |output_type| output_type[:output_key] == key } + if output_hash && ! output_hash.empty? + output_hash.first[:output_value] + else + raise StackOutputNotFound, "Stack exists (#{updated_stack.stack_name}), but output does not: #{key}" + end + end + + def updated_stack + @stack ||= Stack.find(@stack_definition.region, @stack_definition.stack_name) + end + end +end diff --git a/spec/fixtures/parameters/myapp_web.yml b/spec/fixtures/parameters/myapp_web.yml new file mode 100644 index 00000000..8db7cd76 --- /dev/null +++ b/spec/fixtures/parameters/myapp_web.yml @@ -0,0 +1,6 @@ +vpc_id: + stack_output: myapp_vpc/vpc_id +subnet_ids: + stack_outputs: + - myapp_vpc/subnet_1 + - myapp_vpc/subnet_2 diff --git a/spec/stack_master/commands/apply_spec.rb b/spec/stack_master/commands/apply_spec.rb index ecd85c26..0a83b188 100644 --- a/spec/stack_master/commands/apply_spec.rb +++ b/spec/stack_master/commands/apply_spec.rb @@ -3,7 +3,7 @@ let(:s3) { instance_double(Aws::S3::Client) } let(:region) { 'us-east-1' } let(:stack_name) { 'myapp-vpc' } - let(:config) { double(find_stack: stack_definition) } + let(:config) { double(find_stack: stack_definition, stacks: []) } let(:notification_arn) { 'test_arn' } let(:stack_definition) { StackMaster::StackDefinition.new(base_dir: '/base_dir', region: region, stack_name: stack_name) } let(:template_body) { '{}' } diff --git a/spec/stack_master/stack_dependency_spec.rb b/spec/stack_master/stack_dependency_spec.rb new file mode 100644 index 00000000..311efa78 --- /dev/null +++ b/spec/stack_master/stack_dependency_spec.rb @@ -0,0 +1,89 @@ +RSpec.describe StackMaster::StackDependency do + let(:region) { 'us-east-1' } + let(:vpc_stack_name) { 'myapp-vpc' } + let(:vpc_stack_definition) { StackMaster::StackDefinition.new(base_dir: '/base_dir', region: region, stack_name: vpc_stack_name) } + let(:vpc_stack) do + StackMaster::Stack.new( + stack_id: '1', + stack_name: vpc_stack_name, + outputs: [ + {description: "", output_key: "VpcId", output_value: "vpc-123"}, + {description: "", output_key: "Subnet1Id", output_value: "subnet-456"}, + {description: "", output_key: "Subnet2Id", output_value: "subnet-789"}, + ] + ) + end + let(:web_stack_name) { 'myapp-web' } + let(:web_stack_definition) { StackMaster::StackDefinition.new(base_dir: '/base_dir', region: region, stack_name: web_stack_name) } + let(:web_stack) do + StackMaster::Stack.new( + stack_id: '2', + stack_name: web_stack_name, + parameters: { + "VpcId" => web_stack_vpc_id, + "SubnetIds" => web_stack_subnet_ids, + } + ) + end + let(:web_param_file) { + <<-eos + vpc_id: + stack_output: myapp-vpc/vpc_id + subnet_ids: + stack_outputs: + - myapp-vpc/subnet_1_id + - myapp-vpc/subnet_2_id + eos + } + let(:config) { double(find: vpc_stack, stacks: [vpc_stack_definition, web_stack_definition]) } + + subject { described_class.new(vpc_stack_definition, config) } + + before do + allow(File).to receive(:exists?).and_call_original + allow(File).to receive(:read).with('/base_dir/parameters/myapp_web.yml').and_return(web_param_file) + allow(StackMaster::Stack).to receive(:find).with(region, web_stack_name).and_return(web_stack) + allow(StackMaster::Stack).to receive(:find).with(region, vpc_stack_name).and_return(vpc_stack) + end + + context 'the stack exists' do + before do + expect(File).to receive(:exists?).with('/base_dir/parameters/myapp_web.yml').and_return(true) + end + + context 'when the web stacks parameters are up to date' do + let(:web_stack_vpc_id) { "vpc-123" } + let(:web_stack_subnet_ids) { "subnet-456,subnet-789" } + + it 'returns no outdated stacks' do + expect(subject.outdated_stacks).to eq [] + end + end + + context 'when the stack_output is out of date' do + let(:web_stack_vpc_id) { "vpc-321" } + let(:web_stack_subnet_ids) { "subnet-456,subnet-789" } + + it 'returns one outdated stack' do + expect(subject.outdated_stacks).to eq [web_stack_definition] + end + end + + context 'when one of the stack_outputs is out of date' do + let(:web_stack_vpc_id) { "vpc-123" } + let(:web_stack_subnet_ids) { "subnet-654,subnet-789" } + + it 'returns one outdated stack' do + expect(subject.outdated_stacks).to eq [web_stack_definition] + end + end + end + + context 'when no other stacks exist' do + let(:web_stack) { nil } + + it 'returns no outdated stacks' do + expect(subject.outdated_stacks).to eq [] + end + end +end