stack_master 0.0.4 → 0.1.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 (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