stack_master 0.0.4 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +52 -7
  3. data/Rakefile +2 -0
  4. data/bin/stack_master +2 -1
  5. data/features/apply.feature +63 -26
  6. data/features/delete.feature +5 -18
  7. data/features/diff.feature +6 -18
  8. data/features/events.feature +1 -5
  9. data/features/outputs.feature +1 -5
  10. data/features/region_aliases.feature +0 -4
  11. data/features/resources.feature +1 -4
  12. data/features/stack_defaults.feature +1 -7
  13. data/features/status.feature +2 -8
  14. data/features/step_definitions/stack_steps.rb +16 -0
  15. data/features/support/env.rb +1 -0
  16. data/features/validate.feature +46 -0
  17. data/lib/stack_master.rb +21 -0
  18. data/lib/stack_master/aws_driver/cloud_formation.rb +20 -1
  19. data/lib/stack_master/cli.rb +54 -27
  20. data/lib/stack_master/command.rb +9 -1
  21. data/lib/stack_master/commands/apply.rb +2 -0
  22. data/lib/stack_master/commands/list_stacks.rb +2 -0
  23. data/lib/stack_master/commands/outputs.rb +2 -1
  24. data/lib/stack_master/commands/status.rb +2 -0
  25. data/lib/stack_master/commands/terminal_helper.rb +15 -0
  26. data/lib/stack_master/commands/validate.rb +1 -1
  27. data/lib/stack_master/config.rb +9 -5
  28. data/lib/stack_master/parameter_resolver.rb +11 -5
  29. data/lib/stack_master/parameter_resolvers/latest_ami_by_tags.rb +3 -1
  30. data/lib/stack_master/parameter_resolvers/secret.rb +3 -1
  31. data/lib/stack_master/parameter_resolvers/security_group.rb +4 -2
  32. data/lib/stack_master/parameter_resolvers/sns_topic_name.rb +3 -1
  33. data/lib/stack_master/parameter_resolvers/stack_output.rb +3 -1
  34. data/lib/stack_master/prompter.rb +10 -3
  35. data/lib/stack_master/resolver_array.rb +35 -0
  36. data/lib/stack_master/testing.rb +1 -0
  37. data/lib/stack_master/validator.rb +8 -4
  38. data/lib/stack_master/version.rb +1 -1
  39. data/spec/fixtures/stack_master.yml +4 -1
  40. data/spec/stack_master/command_spec.rb +28 -0
  41. data/spec/stack_master/commands/apply_spec.rb +12 -2
  42. data/spec/stack_master/commands/status_spec.rb +25 -2
  43. data/spec/stack_master/commands/validate_spec.rb +1 -1
  44. data/spec/stack_master/config_spec.rb +42 -8
  45. data/spec/stack_master/parameter_resolver_spec.rb +6 -2
  46. data/spec/stack_master/parameter_resolvers/security_group_spec.rb +5 -3
  47. data/spec/stack_master/parameter_resolvers/security_groups_spec.rb +32 -0
  48. data/spec/stack_master/prompter_spec.rb +23 -0
  49. data/spec/stack_master/resolver_array_spec.rb +42 -0
  50. data/spec/stack_master/validator_spec.rb +2 -2
  51. metadata +15 -3
@@ -0,0 +1,15 @@
1
+ module StackMaster
2
+ module Commands
3
+ module TerminalHelper
4
+ def window_size
5
+ size = ENV.fetch("COLUMNS") { `tput cols`.chomp }
6
+
7
+ if size.nil? || size == ""
8
+ 80
9
+ else
10
+ size.to_i
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -10,7 +10,7 @@ module StackMaster
10
10
  end
11
11
 
12
12
  def perform
13
- Validator.perform(@stack_definition)
13
+ failed unless Validator.valid?(@stack_definition)
14
14
  end
15
15
  end
16
16
  end
@@ -44,13 +44,17 @@ module StackMaster
44
44
  load_config
45
45
  end
46
46
 
47
- def find_stack(region, stack_name)
48
- @stacks.find do |s|
49
- (s.region == region || s.region == region.gsub('_', '-')) &&
50
- (s.stack_name == stack_name || s.stack_name == stack_name.gsub('_', '-'))
47
+ def filter(region = nil, stack_name = nil)
48
+ @stacks.select do |s|
49
+ (region.blank? || s.region == region || s.region == region.gsub('_', '-')) &&
50
+ (stack_name.blank? || s.stack_name == stack_name || s.stack_name == stack_name.gsub('_', '-'))
51
51
  end
52
52
  end
53
53
 
54
+ def find_stack(region, stack_name)
55
+ filter(region, stack_name).first
56
+ end
57
+
54
58
  def unalias_region(region)
55
59
  @region_aliases.fetch(region) { region }
56
60
  end
@@ -74,7 +78,7 @@ module StackMaster
74
78
  region = Utils.underscore_to_hyphen(region)
75
79
  stacks_for_region.each do |stack_name, attributes|
76
80
  stack_name = Utils.underscore_to_hyphen(stack_name)
77
- stack_attributes = build_stack_defaults(region).deeper_merge(attributes).merge(
81
+ stack_attributes = build_stack_defaults(region).deeper_merge!(attributes).merge(
78
82
  'region' => region,
79
83
  'stack_name' => stack_name,
80
84
  'base_dir' => @base_dir,
@@ -17,7 +17,7 @@ module StackMaster
17
17
  def resolve
18
18
  @parameters.reduce({}) do |parameters, (key, value)|
19
19
  begin
20
- parameters[key] = resolve_parameter_value(value)
20
+ parameters[key] = resolve_parameter_value(key, value)
21
21
  rescue InvalidParameter
22
22
  raise InvalidParameter, "Unable to resolve parameter #{key.inspect} value causing error: #{$!.message}"
23
23
  end
@@ -41,10 +41,10 @@ module StackMaster
41
41
  require_parameter_resolver(class_name.underscore)
42
42
  end
43
43
 
44
- def resolve_parameter_value(parameter_value)
45
- return parameter_value if String === parameter_value || parameter_value.nil?
46
- raise InvalidParameter, parameter_value unless Hash === parameter_value
47
- raise InvalidParameter, parameter_value unless parameter_value.keys.size == 1
44
+ def resolve_parameter_value(key, parameter_value)
45
+ return parameter_value.to_s if Numeric === parameter_value
46
+ return parameter_value unless Hash === parameter_value
47
+ validate_parameter_value!(key, parameter_value)
48
48
 
49
49
  resolver_name = parameter_value.keys.first.to_s
50
50
  load_parameter_resolver(resolver_name)
@@ -75,5 +75,11 @@ module StackMaster
75
75
  end
76
76
  end
77
77
  end
78
+
79
+ def validate_parameter_value!(key, parameter_value)
80
+ if parameter_value.keys.size != 1
81
+ raise InvalidParameter, "#{key} hash contained more than one key: #{parameter_value.inspect}"
82
+ end
83
+ end
78
84
  end
79
85
  end
@@ -1,6 +1,8 @@
1
1
  module StackMaster
2
2
  module ParameterResolvers
3
- class LatestAmiByTags
3
+ class LatestAmiByTags < Resolver
4
+ array_resolver class_name: 'LatestAmisByTags'
5
+
4
6
  def initialize(config, stack_definition)
5
7
  @config = config
6
8
  @stack_definition = stack_definition
@@ -1,8 +1,10 @@
1
1
  module StackMaster
2
2
  module ParameterResolvers
3
- class Secret
3
+ class Secret < Resolver
4
4
  SecretNotFound = Class.new(StandardError)
5
5
 
6
+ array_resolver
7
+
6
8
  def initialize(config, stack_definition)
7
9
  @config = config
8
10
  @stack_definition = stack_definition
@@ -1,6 +1,8 @@
1
1
  module StackMaster
2
2
  module ParameterResolvers
3
- class SecurityGroup
3
+ class SecurityGroup < Resolver
4
+ array_resolver
5
+
4
6
  def initialize(config, stack_definition)
5
7
  @config = config
6
8
  @stack_definition = stack_definition
@@ -10,7 +12,7 @@ module StackMaster
10
12
  security_group_finder.find(value)
11
13
  end
12
14
 
13
- private
15
+ private
14
16
 
15
17
  def security_group_finder
16
18
  StackMaster::SecurityGroupFinder.new(@stack_definition.region)
@@ -1,8 +1,10 @@
1
1
  module StackMaster
2
2
  module ParameterResolvers
3
- class SnsTopicName
3
+ class SnsTopicName < Resolver
4
4
  TopicNotFound = Class.new(StandardError)
5
5
 
6
+ array_resolver
7
+
6
8
  def initialize(config, stack_definition)
7
9
  @config = config
8
10
  @stack_definition = stack_definition
@@ -1,9 +1,11 @@
1
1
  module StackMaster
2
2
  module ParameterResolvers
3
- class StackOutput
3
+ class StackOutput < Resolver
4
4
  StackNotFound = Class.new(StandardError)
5
5
  StackOutputNotFound = Class.new(StandardError)
6
6
 
7
+ array_resolver
8
+
7
9
  def initialize(config, stack_definition)
8
10
  @config = config
9
11
  @stack_definition = stack_definition
@@ -2,10 +2,17 @@ module StackMaster
2
2
  module Prompter
3
3
  def ask?(question)
4
4
  StackMaster.stdout.print question
5
- answer = if ENV['STUB_AWS']
6
- ENV['ANSWER']
5
+ answer = if StackMaster.interactive?
6
+ if StackMaster.stdin.tty? && StackMaster.stdout.tty?
7
+ StackMaster.stdin.getch.chomp
8
+ else
9
+ StackMaster.stdout.puts
10
+ StackMaster.stdout.puts "STDOUT or STDIN was not a TTY. Defaulting to no. To force yes use -y"
11
+ 'n'
12
+ end
7
13
  else
8
- STDIN.getch.chomp
14
+ print StackMaster.non_interactive_answer
15
+ StackMaster.non_interactive_answer
9
16
  end
10
17
  StackMaster.stdout.puts
11
18
  answer == 'y'
@@ -0,0 +1,35 @@
1
+ module StackMaster
2
+ module ParameterResolvers
3
+ class ResolverArray
4
+ def initialize(config, stack_definition)
5
+ @config = config
6
+ @stack_definition = stack_definition
7
+ end
8
+
9
+ def resolve(values)
10
+ value_list = Array(values).map do |value|
11
+ resolver_class.new(@config, @stack_definition).resolve(value)
12
+ end
13
+ end
14
+
15
+ def resolver_class
16
+ fail "Method resolver_class not implemented on #{self.class}"
17
+ end
18
+ end
19
+
20
+ class Resolver
21
+ def self.array_resolver(options = {})
22
+ resolver_class ||= Object.const_get(self.name)
23
+ array_resolver_class_name = options[:class_name] || resolver_class.to_s.demodulize.pluralize
24
+
25
+ klass = Class.new(ResolverArray) do
26
+ define_method('resolver_class') do
27
+ resolver_class
28
+ end
29
+ end
30
+ StackMaster::ParameterResolvers.const_set("#{array_resolver_class_name}", klass)
31
+ end
32
+ end
33
+ end
34
+ end
35
+
@@ -5,3 +5,4 @@ Aws.config[:stub_responses] = true
5
5
  require 'stack_master/test_driver/cloud_formation'
6
6
 
7
7
  StackMaster.cloud_formation_driver = StackMaster::TestDriver::CloudFormation.new
8
+ StackMaster.non_interactive!
@@ -1,18 +1,22 @@
1
1
  module StackMaster
2
2
  class Validator
3
- include Command
3
+ def self.valid?(stack_definition)
4
+ new(stack_definition).perform
5
+ end
4
6
 
5
7
  def initialize(stack_definition)
6
8
  @stack_definition = stack_definition
7
9
  end
8
10
 
9
11
  def perform
12
+ StackMaster.stdout.print "#{@stack_definition.stack_name}: "
10
13
  template_body = TemplateCompiler.compile(@stack_definition.template_file_path)
11
14
  cf.validate_template(template_body: template_body)
12
- StackMaster.stdout.puts "Valid"
15
+ StackMaster.stdout.puts "valid"
16
+ true
13
17
  rescue Aws::CloudFormation::Errors::ValidationError => e
14
- $stderr.puts "Validation Failed"
15
- $stderr.puts e.message
18
+ StackMaster.stdout.puts "invalid. #{e.message}"
19
+ false
16
20
  end
17
21
 
18
22
  private
@@ -1,3 +1,3 @@
1
1
  module StackMaster
2
- VERSION = "0.0.4"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -15,6 +15,7 @@ region_defaults:
15
15
  staging:
16
16
  tags:
17
17
  environment: staging
18
+ test_override: 1
18
19
  notification_arns:
19
20
  - test_arn_3
20
21
  secret_file: staging.yml.gpg
@@ -32,4 +33,6 @@ stacks:
32
33
  notification_arns:
33
34
  - test_arn_4
34
35
  myapp_web:
35
- template: myapp_web
36
+ template: myapp_web
37
+ tags:
38
+ test_override: 2
@@ -0,0 +1,28 @@
1
+ RSpec.describe StackMaster::Command do
2
+ let(:command_class) {
3
+ Class.new do
4
+ include StackMaster::Command
5
+
6
+ def initialize(callable = nil)
7
+ @callable = callable
8
+ end
9
+
10
+ def perform
11
+ instance_eval(&@callable) if @callable
12
+ false
13
+ end
14
+ end
15
+ }
16
+
17
+ context 'when failed is not called' do
18
+ it 'is successful' do
19
+ expect(command_class.perform.success?).to eq true
20
+ end
21
+ end
22
+
23
+ context 'when failed is called' do
24
+ it 'is not successful' do
25
+ expect(command_class.perform(proc { failed }).success?).to eq false
26
+ end
27
+ end
28
+ end
@@ -16,9 +16,8 @@ RSpec.describe StackMaster::Commands::Apply do
16
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
- allow(STDOUT).to receive(:print)
20
- allow(STDIN).to receive(:getch).and_return('y')
21
19
  allow(StackMaster::StackEvents::Streamer).to receive(:stream)
20
+ allow(StackMaster).to receive(:interactive?).and_return(false)
22
21
  end
23
22
 
24
23
  def apply
@@ -48,6 +47,16 @@ RSpec.describe StackMaster::Commands::Apply do
48
47
  expect(StackMaster::StackEvents::Streamer).to have_received(:stream).with(stack_name, region, io: STDOUT, from: Time.now)
49
48
  end
50
49
  end
50
+
51
+ context 'when a CF error occurs' do
52
+ before do
53
+ allow(cf).to receive(:update_stack).with(anything).and_raise(Aws::CloudFormation::Errors::ServiceError.new('a', 'the message'))
54
+ end
55
+
56
+ it 'outputs the message' do
57
+ expect { apply }.to output(/the message/).to_stdout
58
+ end
59
+ end
51
60
  end
52
61
 
53
62
  context 'the stack does not exist' do
@@ -77,6 +86,7 @@ RSpec.describe StackMaster::Commands::Apply do
77
86
  let(:template_body) do
78
87
  "{\"a\":\"#{big_string}\"}"
79
88
  end
89
+
80
90
  it 'exits with a message' do
81
91
  expect { apply }.to output(/The \(space compressed\) stack is larger than the limit set by AWS/).to_stdout
82
92
  end
@@ -8,11 +8,14 @@ RSpec.describe StackMaster::Commands::Status do
8
8
 
9
9
  before do
10
10
  allow(Aws::CloudFormation::Client).to receive(:new).with(region: 'us-east-1').and_return cf
11
- allow(StackMaster::Stack).to receive(:find).and_return stack1, stack2
12
- allow(StackMaster::Stack).to receive(:generate).and_return proposed_stack1, proposed_stack2
13
11
  end
14
12
 
15
13
  context "#perform" do
14
+ before do
15
+ allow(StackMaster::Stack).to receive(:find).and_return stack1, stack2
16
+ allow(StackMaster::Stack).to receive(:generate).and_return proposed_stack1, proposed_stack2
17
+ end
18
+
16
19
  context "some parameters are different" do
17
20
  let(:stack1) { double(:stack1, template_hash: {}, parameters_with_defaults: {a: 1}, stack_status: 'UPDATE_COMPLETE') }
18
21
  let(:stack2) { double(:stack2, template_hash: {}, parameters_with_defaults: {a: 2}, stack_status: 'CREATE_COMPLETE') }
@@ -35,4 +38,24 @@ RSpec.describe StackMaster::Commands::Status do
35
38
  end
36
39
  end
37
40
  end
41
+
42
+ context "handles AWS throttling" do
43
+ let(:throttle_exception) { Aws::CloudFormation::Errors::Throttling.new(double(), "Rate exceeded.") }
44
+ let(:stack1) { double(:stack1, template_hash: {}, parameters_with_defaults: {a: 1}, stack_status: 'UPDATE_COMPLETE') }
45
+ let(:stack2) { double(:stack2, template_hash: {}, parameters_with_defaults: {a: 2}, stack_status: 'CREATE_COMPLETE') }
46
+ let(:proposed_stack1) { double(:proposed_stack1, template_body: "{}", parameters_with_defaults: {a: 1}) }
47
+ let(:proposed_stack2) { double(:proposed_stack2, template_body: "{}", parameters_with_defaults: {a: 1}) }
48
+
49
+ it "doubles the sleep time across calls" do
50
+ call_count = 0
51
+ expect(cf).to receive(:describe_stacks).at_least(1).times do
52
+ call_count += 1
53
+ call_count <= 3 ? raise(throttle_exception) : double(stacks: double(first: nil))
54
+ end
55
+ expect(StackMaster.cloud_formation_driver).to receive(:sleep).with(1).ordered
56
+ expect(StackMaster.cloud_formation_driver).to receive(:sleep).with(2).ordered
57
+ expect(StackMaster.cloud_formation_driver).to receive(:sleep).with(4).ordered
58
+ expect { status.perform }.to_not raise_exception
59
+ end
60
+ end
38
61
  end
@@ -18,7 +18,7 @@ RSpec.describe StackMaster::Commands::Validate do
18
18
  describe "#perform" do
19
19
  context "can find stack" do
20
20
  it "calls the validator to validate the stack definition" do
21
- expect(StackMaster::Validator).to receive(:perform).with(stack_definition)
21
+ expect(StackMaster::Validator).to receive(:valid?).with(stack_definition)
22
22
  validate.perform
23
23
  end
24
24
  end
@@ -31,14 +31,33 @@ RSpec.describe StackMaster::Config do
31
31
  end
32
32
  end
33
33
 
34
- it 'returns an object that can find stack definitions' do
35
- stack = loaded_config.find_stack('us-east-1', 'myapp-vpc')
36
- expect(stack).to eq(myapp_vpc_definition)
34
+ describe '#find_stack' do
35
+ it 'returns an object that can find stack definitions' do
36
+ stack = loaded_config.find_stack('us-east-1', 'myapp-vpc')
37
+ expect(stack).to eq(myapp_vpc_definition)
38
+ end
39
+
40
+ it 'can find things with underscores instead of hyphens' do
41
+ stack = loaded_config.find_stack('us_east_1', 'myapp_vpc')
42
+ expect(stack).to eq(myapp_vpc_definition)
43
+ end
37
44
  end
38
45
 
39
- it 'can find things with underscores instead of hyphens' do
40
- stack = loaded_config.find_stack('us_east_1', 'myapp_vpc')
41
- expect(stack).to eq(myapp_vpc_definition)
46
+ describe '#filter' do
47
+ it 'returns a list of stack definitions' do
48
+ stack = loaded_config.filter('us-east-1', 'myapp-vpc')
49
+ expect(stack).to eq([myapp_vpc_definition])
50
+ end
51
+
52
+ it 'can filter by region only' do
53
+ stacks = loaded_config.filter('us-east-1')
54
+ expect(stacks.size).to eq 2
55
+ end
56
+
57
+ it 'can return all stack definitions with no filters' do
58
+ stacks = loaded_config.filter
59
+ expect(stacks.size).to eq 4
60
+ end
42
61
  end
43
62
 
44
63
  it 'exposes the base_dir' do
@@ -60,7 +79,7 @@ RSpec.describe StackMaster::Config do
60
79
  'stack_policy_file' => 'my_policy.json'
61
80
  },
62
81
  'ap-southeast-2' => {
63
- 'tags' => {'environment' => 'staging'},
82
+ 'tags' => {'environment' => 'staging', 'test_override' => 1 },
64
83
  'notification_arns' => ['test_arn_3'],
65
84
  'secret_file' => 'staging.yml.gpg'
66
85
  }
@@ -80,7 +99,8 @@ RSpec.describe StackMaster::Config do
80
99
  region: 'ap-southeast-2',
81
100
  tags: {
82
101
  'application' => 'my-awesome-blog',
83
- 'environment' => 'staging'
102
+ 'environment' => 'staging',
103
+ 'test_override' => 1
84
104
  },
85
105
  notification_arns: ['test_arn_3', 'test_arn_4'],
86
106
  template: 'myapp_vpc.rb',
@@ -88,6 +108,20 @@ RSpec.describe StackMaster::Config do
88
108
  secret_file: 'staging.yml.gpg',
89
109
  additional_parameter_lookup_dirs: ['staging']
90
110
  ))
111
+ expect(loaded_config.find_stack('ap-southeast-2', 'myapp-web')).to eq(StackMaster::StackDefinition.new(
112
+ stack_name: 'myapp-web',
113
+ region: 'ap-southeast-2',
114
+ tags: {
115
+ 'application' => 'my-awesome-blog',
116
+ 'environment' => 'staging',
117
+ 'test_override' => 2
118
+ },
119
+ notification_arns: ['test_arn_3'],
120
+ template: 'myapp_web',
121
+ base_dir: base_dir,
122
+ secret_file: 'staging.yml.gpg',
123
+ additional_parameter_lookup_dirs: ['staging']
124
+ ))
91
125
  end
92
126
 
93
127
  it 'allows region aliases in region defaults' do