stack_master 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/features/apply.feature +9 -2
  3. data/lib/stack_master.rb +20 -1
  4. data/lib/stack_master/aws_driver/cloud_formation.rb +15 -35
  5. data/lib/stack_master/change_set.rb +104 -0
  6. data/lib/stack_master/cli.rb +3 -0
  7. data/lib/stack_master/command.rb +29 -3
  8. data/lib/stack_master/commands/apply.rb +54 -38
  9. data/lib/stack_master/commands/validate.rb +1 -1
  10. data/lib/stack_master/config.rb +10 -1
  11. data/lib/stack_master/paged_response_accumulator.rb +29 -0
  12. data/lib/stack_master/parameter_loader.rb +3 -0
  13. data/lib/stack_master/stack.rb +7 -1
  14. data/lib/stack_master/stack_events/fetcher.rb +1 -8
  15. data/lib/stack_master/template_compiler.rb +18 -14
  16. data/lib/stack_master/template_compilers/json.rb +18 -0
  17. data/lib/stack_master/template_compilers/sparkle_formation.rb +10 -0
  18. data/lib/stack_master/test_driver/cloud_formation.rb +33 -0
  19. data/lib/stack_master/validator.rb +5 -4
  20. data/lib/stack_master/version.rb +1 -1
  21. data/spec/stack_master/change_set_spec.rb +62 -0
  22. data/spec/stack_master/command_spec.rb +21 -1
  23. data/spec/stack_master/commands/apply_spec.rb +53 -9
  24. data/spec/stack_master/commands/validate_spec.rb +1 -1
  25. data/spec/stack_master/paged_response_accumulator_spec.rb +39 -0
  26. data/spec/stack_master/stack_events/fetcher_spec.rb +8 -33
  27. data/spec/stack_master/stack_spec.rb +19 -1
  28. data/spec/stack_master/template_compiler_spec.rb +12 -39
  29. data/spec/stack_master/template_compilers/json_spec.rb +29 -0
  30. data/spec/stack_master/template_compilers/sparkle_formation_spec.rb +22 -0
  31. data/spec/stack_master/test_driver/cloud_formation_spec.rb +39 -12
  32. data/spec/stack_master/validator_spec.rb +2 -1
  33. data/spec/stack_master_spec.rb +81 -0
  34. metadata +17 -3
@@ -10,7 +10,7 @@ module StackMaster
10
10
  end
11
11
 
12
12
  def perform
13
- failed unless Validator.valid?(@stack_definition)
13
+ failed unless Validator.valid?(@stack_definition, @config)
14
14
  end
15
15
  end
16
16
  end
@@ -14,7 +14,8 @@ module StackMaster
14
14
  :base_dir,
15
15
  :stack_defaults,
16
16
  :region_defaults,
17
- :region_aliases
17
+ :region_aliases,
18
+ :template_compilers,
18
19
 
19
20
  def self.search_up_and_chdir(config_file)
20
21
  return config_file unless File.dirname(config_file) == "."
@@ -41,6 +42,7 @@ module StackMaster
41
42
  end
42
43
  @region_defaults = normalise_region_defaults(config.fetch('region_defaults', {}))
43
44
  @stacks = []
45
+ @template_compilers = default_template_compilers
44
46
  load_config
45
47
  end
46
48
 
@@ -61,6 +63,13 @@ module StackMaster
61
63
 
62
64
  private
63
65
 
66
+ def default_template_compilers
67
+ {
68
+ rb: :sparkle_formation,
69
+ json: :json,
70
+ }
71
+ end
72
+
64
73
  def load_config
65
74
  unaliased_stacks = resolve_region_aliases(@config.fetch('stacks'))
66
75
  load_stacks(unaliased_stacks)
@@ -0,0 +1,29 @@
1
+ module StackMaster
2
+ class PagedResponseAccumulator
3
+ def self.call(*args)
4
+ new(*args).call
5
+ end
6
+
7
+ def initialize(cf, method, arguments, accumulator_method)
8
+ @cf = cf
9
+ @method = method
10
+ @arguments = arguments
11
+ @accumulator_method = accumulator_method
12
+ end
13
+
14
+ def call
15
+ book = []
16
+ next_token = nil
17
+ first_response = nil
18
+ begin
19
+ response = @cf.public_send(@method, @arguments.merge(next_token: next_token))
20
+ first_response = response if first_response.nil?
21
+ next_token = response.next_token
22
+ book += response.public_send(@accumulator_method)
23
+ end while !next_token.nil?
24
+ first_response.send("#{@accumulator_method}=", book.reverse)
25
+ first_response.send(:next_token=, book.reverse)
26
+ first_response
27
+ end
28
+ end
29
+ end
@@ -1,10 +1,13 @@
1
1
  module StackMaster
2
2
  class ParameterLoader
3
3
  def self.load(parameter_files)
4
+ StackMaster.debug "Searching for parameter files..."
4
5
  parameter_files.reduce({}) do |hash, file_name|
5
6
  parameters = if File.exists?(file_name)
7
+ StackMaster.debug " #{file_name} found"
6
8
  YAML.load(File.read(file_name)) || {}
7
9
  else
10
+ StackMaster.debug " #{file_name} not found"
8
11
  {}
9
12
  end
10
13
  parameters.each do |key, value|
@@ -40,6 +40,12 @@ module StackMaster
40
40
  template_default_parameters.merge(parameters)
41
41
  end
42
42
 
43
+ def missing_parameters?
44
+ parameters_with_defaults.any? do |key, value|
45
+ value == nil
46
+ end
47
+ end
48
+
43
49
  def self.find(region, stack_name)
44
50
  cf = StackMaster.cloud_formation_driver
45
51
  cf_stack = cf.describe_stacks(stack_name: stack_name).stacks.first
@@ -67,7 +73,7 @@ module StackMaster
67
73
 
68
74
  def self.generate(stack_definition, config)
69
75
  parameter_hash = ParameterLoader.load(stack_definition.parameter_files)
70
- template_body = TemplateCompiler.compile(stack_definition.template_file_path)
76
+ template_body = TemplateCompiler.compile(config, stack_definition.template_file_path)
71
77
  parameters = ParameterResolver.resolve(config, stack_definition, parameter_hash)
72
78
  stack_policy_body = if stack_definition.stack_policy_file_path
73
79
  File.read(stack_definition.stack_policy_file_path)
@@ -31,14 +31,7 @@ module StackMaster
31
31
  end
32
32
 
33
33
  def fetch_events
34
- events = []
35
- next_token = nil
36
- begin
37
- response = cf.describe_stack_events(stack_name: @stack_name, next_token: next_token)
38
- next_token = response.next_token
39
- events += response.stack_events
40
- end while !next_token.nil?
41
- events.reverse
34
+ PagedResponseAccumulator.call(cf, :describe_stack_events, { stack_name: @stack_name }, :stack_events).stack_events
42
35
  end
43
36
  end
44
37
  end
@@ -1,21 +1,25 @@
1
1
  module StackMaster
2
2
  class TemplateCompiler
3
3
 
4
- MAX_TEMPLATE_SIZE = 51200
4
+ def self.compile(config, template_file_path)
5
+ template_compiler_for_file(template_file_path, config).compile(template_file_path)
6
+ end
7
+
8
+ def self.register(name, klass)
9
+ @compilers ||= {}
10
+ @compilers[name] = klass
11
+ end
12
+
13
+ # private
14
+ def self.template_compiler_for_file(template_file_path, config)
15
+ compiler_name = config.template_compilers.fetch(file_ext(template_file_path))
16
+ @compilers.fetch(compiler_name)
17
+ end
18
+ private_class_method :template_compiler_for_file
5
19
 
6
- def self.compile(template_file_path)
7
- if template_file_path.ends_with?('.rb')
8
- SparkleFormation.sparkle_path = File.dirname(template_file_path)
9
- JSON.pretty_generate(SparkleFormation.compile(template_file_path))
10
- else
11
- template_body = File.read(template_file_path)
12
- if template_body.size > MAX_TEMPLATE_SIZE
13
- # Parse the json and rewrite compressed
14
- JSON.dump(JSON.parse(template_body))
15
- else
16
- template_body
17
- end
18
- end
20
+ def self.file_ext(template_file_path)
21
+ File.extname(template_file_path).gsub('.', '').to_sym
19
22
  end
23
+ private_class_method :file_ext
20
24
  end
21
25
  end
@@ -0,0 +1,18 @@
1
+ module StackMaster::TemplateCompilers
2
+ class Json
3
+ MAX_TEMPLATE_SIZE = 51200
4
+ private_constant :MAX_TEMPLATE_SIZE
5
+
6
+ def self.compile(template_file_path)
7
+ template_body = File.read(template_file_path)
8
+ if template_body.size > MAX_TEMPLATE_SIZE
9
+ # Parse the json and rewrite compressed
10
+ JSON.dump(JSON.parse(template_body))
11
+ else
12
+ template_body
13
+ end
14
+ end
15
+
16
+ StackMaster::TemplateCompiler.register(:json, self)
17
+ end
18
+ end
@@ -0,0 +1,10 @@
1
+ module StackMaster::TemplateCompilers
2
+ class SparkleFormation
3
+ def self.compile(template_file_path)
4
+ ::SparkleFormation.sparkle_path = File.dirname(template_file_path)
5
+ JSON.pretty_generate(::SparkleFormation.compile(template_file_path))
6
+ end
7
+
8
+ StackMaster::TemplateCompiler.register(:sparkle_formation, self)
9
+ end
10
+ end
@@ -60,6 +60,39 @@ module StackMaster
60
60
  @stack_events = {}
61
61
  @stack_resources = {}
62
62
  @stack_policies = {}
63
+ @change_sets = {}
64
+ end
65
+
66
+ def create_change_set(options)
67
+ id = SecureRandom.uuid
68
+ options.merge!(change_set_id: id)
69
+ @change_sets[id] = options
70
+ @change_sets[options.fetch(:change_set_name)] = options
71
+ OpenStruct.new(id: id)
72
+ end
73
+
74
+ def describe_change_set(options)
75
+ change_set_id = options.fetch(:change_set_name)
76
+ change_set = @change_sets.fetch(change_set_id)
77
+ change_details = [
78
+ OpenStruct.new(evaluation: 'Static', change_source: 'ResourceReference', target: OpenStruct.new(attribute: 'Properties', requires_recreation: 'Always', name: 'blah'))
79
+ ]
80
+ change = OpenStruct.new(action: 'Modify', replacement: 'True', scope: ['Properties'], details: change_details)
81
+ changes = [
82
+ OpenStruct.new(type: 'AWS::Resource', resource_change: change)
83
+ ]
84
+ OpenStruct.new(change_set.merge(changes: changes, status: 'CREATE_COMPLETE'))
85
+ end
86
+
87
+ def execute_change_set(options)
88
+ change_set_id = options.fetch(:change_set_name)
89
+ change_set = @change_sets.fetch(change_set_id)
90
+ update_stack(change_set)
91
+ end
92
+
93
+ def delete_change_set(options)
94
+ change_set_id = options.fetch(:change_set_name)
95
+ @change_sets.delete(change_set_id)
63
96
  end
64
97
 
65
98
  def describe_stacks(options = {})
@@ -1,16 +1,17 @@
1
1
  module StackMaster
2
2
  class Validator
3
- def self.valid?(stack_definition)
4
- new(stack_definition).perform
3
+ def self.valid?(stack_definition, config)
4
+ new(stack_definition, config).perform
5
5
  end
6
6
 
7
- def initialize(stack_definition)
7
+ def initialize(stack_definition, config)
8
8
  @stack_definition = stack_definition
9
+ @config = config
9
10
  end
10
11
 
11
12
  def perform
12
13
  StackMaster.stdout.print "#{@stack_definition.stack_name}: "
13
- template_body = TemplateCompiler.compile(@stack_definition.template_file_path)
14
+ template_body = TemplateCompiler.compile(@config, @stack_definition.template_file_path)
14
15
  cf.validate_template(template_body: template_body)
15
16
  StackMaster.stdout.puts "valid"
16
17
  true
@@ -1,3 +1,3 @@
1
1
  module StackMaster
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -0,0 +1,62 @@
1
+ RSpec.describe StackMaster::ChangeSet do
2
+ let(:cf) { instance_double(Aws::CloudFormation::Client) }
3
+ let(:region) { 'us-east-1' }
4
+ let(:stack_name) { 'myapp-vpc' }
5
+ let(:change_set_name) { 'changeset-123' }
6
+
7
+ describe '.create' do
8
+ before do
9
+ allow(StackMaster::ChangeSet).to receive(:generate_change_set_name).and_return(change_set_name)
10
+ allow(StackMaster).to receive(:cloud_formation_driver).and_return(cf)
11
+ allow(cf).to receive(:create_change_set).and_return(double(id: 'id-1'))
12
+ end
13
+
14
+ context 'successful response' do
15
+ before do
16
+ allow(cf).to receive(:describe_change_set).with(change_set_name: 'id-1', next_token: nil).and_return(double(next_token: nil, changes: [], :changes= => nil, :next_token= => nil, status: 'CREATE_COMPLETE'))
17
+ end
18
+
19
+ it 'calls the create change set API with the addition of a name' do
20
+ change_set = StackMaster::ChangeSet.create(stack_name: '123')
21
+ expect(cf).to have_received(:create_change_set).with(
22
+ stack_name: '123',
23
+ change_set_name: change_set_name
24
+ )
25
+ expect(change_set.failed?).to eq false
26
+ end
27
+ end
28
+
29
+ context 'unsuccessful response' do
30
+ before do
31
+ allow(cf).to receive(:describe_change_set).with(change_set_name: 'id-1', next_token: nil).and_return(double(next_token: nil, changes: [], :changes= => nil, :next_token= => nil, status: 'FAILED', status_reason: 'No changes'))
32
+ end
33
+
34
+ it 'is marked as failed' do
35
+ change_set = StackMaster::ChangeSet.create(stack_name: '123')
36
+ expect(change_set.failed?).to eq true
37
+ end
38
+ end
39
+ end
40
+
41
+ describe '#display' do
42
+ context 'a successful response' do
43
+ let(:target) { OpenStruct.new(name: 'GroupDescription', attribute: 'Properties', requires_recreation: 'Always') }
44
+ let(:changes) { [
45
+ OpenStruct.new(resource_change: OpenStruct.new(replacement: 'True', action: 'Modify', resource_type: 'EC2::Instance', logical_resource_id: '123', details: [OpenStruct.new(target: target, change_source: 'DirectModification', evaluation: 'Static', causing_entity: 'blah')]))
46
+ ] }
47
+ let(:cf_response) { double(next_token: nil, changes: changes, :changes= => nil, :next_token= => nil, status: 'FAILED', status_reason: 'No changes') }
48
+ let(:io) { StringIO.new }
49
+ subject(:change_set) { StackMaster::ChangeSet.new(cf_response) }
50
+ let(:message) { io.string }
51
+ before { change_set.display(io) }
52
+
53
+ it 'outputs key data' do
54
+ expect(message).to include 'Replace EC2::Instance 123'
55
+ end
56
+
57
+ it 'outputs detail data' do
58
+ expect(message).to include 'Properties.GroupDescription. Always requires recreation. Triggered by: DirectModification.blah'
59
+ end
60
+ end
61
+ end
62
+ end
@@ -3,12 +3,17 @@ RSpec.describe StackMaster::Command do
3
3
  Class.new do
4
4
  include StackMaster::Command
5
5
 
6
- def initialize(callable = nil)
6
+ def initialize(callable = nil, halt = nil)
7
7
  @callable = callable
8
+ @halt = halt
8
9
  end
9
10
 
11
+ attr_reader :finished
12
+
10
13
  def perform
11
14
  instance_eval(&@callable) if @callable
15
+ halt! if @halt
16
+ @finished = true
12
17
  false
13
18
  end
14
19
  end
@@ -25,4 +30,19 @@ RSpec.describe StackMaster::Command do
25
30
  expect(command_class.perform(proc { failed }).success?).to eq false
26
31
  end
27
32
  end
33
+
34
+ describe '#halt!' do
35
+ it 'exits the command' do
36
+ expect(command_class.perform(nil, true).finished).to_not eq true
37
+ end
38
+ end
39
+
40
+ context 'when a CF error occurs' do
41
+ it 'outputs the message' do
42
+ error_proc = proc {
43
+ raise Aws::CloudFormation::Errors::ServiceError.new('a', 'the message')
44
+ }
45
+ expect { command_class.perform(error_proc) }.to output(/the message/).to_stderr
46
+ end
47
+ end
28
48
  end
@@ -6,14 +6,14 @@ RSpec.describe StackMaster::Commands::Apply do
6
6
  let(:notification_arn) { 'test_arn' }
7
7
  let(:stack_definition) { StackMaster::StackDefinition.new(base_dir: '/base_dir', region: region, stack_name: stack_name) }
8
8
  let(:template_body) { '{}' }
9
- let(:proposed_stack) { StackMaster::Stack.new(template_body: template_body, tags: { 'environment' => 'production' } , parameters: { 'param_1' => 'hello' }, notification_arns: [notification_arn], stack_policy_body: stack_policy_body ) }
9
+ let(:parameters) { { 'param_1' => 'hello' } }
10
+ let(:proposed_stack) { StackMaster::Stack.new(template_body: template_body, tags: { 'environment' => 'production' } , parameters: parameters, notification_arns: [notification_arn], stack_policy_body: stack_policy_body ) }
10
11
  let(:stack_policy_body) { '{}' }
11
12
 
12
13
  before do
13
14
  allow(StackMaster::Stack).to receive(:find).with(region, stack_name).and_return(stack)
14
15
  allow(StackMaster::Stack).to receive(:generate).with(stack_definition, config).and_return(proposed_stack)
15
16
  allow(Aws::CloudFormation::Client).to receive(:new).and_return(cf)
16
- allow(cf).to receive(:update_stack)
17
17
  allow(cf).to receive(:create_stack)
18
18
  allow(StackMaster::StackDiffer).to receive(:new).with(proposed_stack, stack).and_return double.as_null_object
19
19
  allow(StackMaster::StackEvents::Streamer).to receive(:stream)
@@ -26,10 +26,17 @@ RSpec.describe StackMaster::Commands::Apply do
26
26
 
27
27
  context 'the stack exist' do
28
28
  let(:stack) { StackMaster::Stack.new(stack_id: '1') }
29
+ let(:change_set) { double(display: true, failed?: false, id: 'id-1') }
29
30
 
30
- it 'calls the update stack API method' do
31
+ before do
32
+ allow(cf).to receive(:create_change_set).and_return(OpenStruct.new(id: '1'))
33
+ allow(StackMaster::ChangeSet).to receive(:create).and_return(change_set)
34
+ allow(cf).to receive(:execute_change_set).and_return(OpenStruct.new(id: '1'))
35
+ end
36
+
37
+ it 'creates a change set' do
31
38
  apply
32
- expect(cf).to have_received(:update_stack).with(
39
+ expect(StackMaster::ChangeSet).to have_received(:create).with(
33
40
  stack_name: stack_name,
34
41
  template_body: proposed_stack.template_body,
35
42
  parameters: [
@@ -48,13 +55,31 @@ RSpec.describe StackMaster::Commands::Apply do
48
55
  end
49
56
  end
50
57
 
51
- context 'when a CF error occurs' do
58
+ context 'the changeset failed to create' do
59
+ before do
60
+ allow(change_set).to receive(:failed?).and_return(true)
61
+ allow(change_set).to receive(:status_reason).and_return('reason')
62
+ end
63
+
64
+ it 'outputs the status reason' do
65
+ expect { apply }.to output(/reason/).to_stdout
66
+ end
67
+ end
68
+
69
+ context 'user decides to not apply the change set' do
52
70
  before do
53
- allow(cf).to receive(:update_stack).with(anything).and_raise(Aws::CloudFormation::Errors::ServiceError.new('a', 'the message'))
71
+ allow(StackMaster).to receive(:non_interactive_answer).and_return('n')
72
+ allow(StackMaster::ChangeSet).to receive(:delete)
73
+ allow(StackMaster::ChangeSet).to receive(:execute)
74
+ apply
75
+ end
76
+
77
+ it 'deletes the change set' do
78
+ expect(StackMaster::ChangeSet).to have_received(:delete).with(change_set.id)
54
79
  end
55
80
 
56
- it 'outputs the message' do
57
- expect { apply }.to output(/the message/).to_stdout
81
+ it "doesn't execute the change set" do
82
+ expect(StackMaster::ChangeSet).to_not have_received(:execute).with(change_set.id)
58
83
  end
59
84
  end
60
85
  end
@@ -88,7 +113,7 @@ RSpec.describe StackMaster::Commands::Apply do
88
113
  end
89
114
 
90
115
  it 'exits with a message' do
91
- expect { apply }.to output(/The \(space compressed\) stack is larger than the limit set by AWS/).to_stdout
116
+ expect { apply }.to output(/The \(space compressed\) stack is larger than the limit set by AWS/).to_stderr
92
117
  end
93
118
  end
94
119
 
@@ -99,4 +124,23 @@ RSpec.describe StackMaster::Commands::Apply do
99
124
  end
100
125
  end
101
126
  end
127
+
128
+ context 'one or more parameters are empty' do
129
+ let(:stack) { StackMaster::Stack.new(stack_id: '1', parameters: parameters) }
130
+ let(:parameters) { { 'param_1' => nil } }
131
+
132
+ it "doesn't allow apply" do
133
+ expect { apply }.to_not output(/Continue and apply the stack/).to_stdout
134
+ end
135
+
136
+ it 'outputs a description of the problem' do
137
+ expect { apply }.to output(/Empty\/blank parameters detected/).to_stderr
138
+ end
139
+
140
+ it 'outputs where param files are loaded from' do
141
+ stack_definition.parameter_files.each do |parameter_file|
142
+ expect { apply }.to output(/#{parameter_file}/).to_stderr
143
+ end
144
+ end
145
+ end
102
146
  end