stack_master 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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