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.
- checksums.yaml +4 -4
- data/README.md +52 -7
- data/Rakefile +2 -0
- data/bin/stack_master +2 -1
- data/features/apply.feature +63 -26
- data/features/delete.feature +5 -18
- data/features/diff.feature +6 -18
- data/features/events.feature +1 -5
- data/features/outputs.feature +1 -5
- data/features/region_aliases.feature +0 -4
- data/features/resources.feature +1 -4
- data/features/stack_defaults.feature +1 -7
- data/features/status.feature +2 -8
- data/features/step_definitions/stack_steps.rb +16 -0
- data/features/support/env.rb +1 -0
- data/features/validate.feature +46 -0
- data/lib/stack_master.rb +21 -0
- data/lib/stack_master/aws_driver/cloud_formation.rb +20 -1
- data/lib/stack_master/cli.rb +54 -27
- data/lib/stack_master/command.rb +9 -1
- data/lib/stack_master/commands/apply.rb +2 -0
- data/lib/stack_master/commands/list_stacks.rb +2 -0
- data/lib/stack_master/commands/outputs.rb +2 -1
- data/lib/stack_master/commands/status.rb +2 -0
- data/lib/stack_master/commands/terminal_helper.rb +15 -0
- data/lib/stack_master/commands/validate.rb +1 -1
- data/lib/stack_master/config.rb +9 -5
- data/lib/stack_master/parameter_resolver.rb +11 -5
- data/lib/stack_master/parameter_resolvers/latest_ami_by_tags.rb +3 -1
- data/lib/stack_master/parameter_resolvers/secret.rb +3 -1
- data/lib/stack_master/parameter_resolvers/security_group.rb +4 -2
- data/lib/stack_master/parameter_resolvers/sns_topic_name.rb +3 -1
- data/lib/stack_master/parameter_resolvers/stack_output.rb +3 -1
- data/lib/stack_master/prompter.rb +10 -3
- data/lib/stack_master/resolver_array.rb +35 -0
- data/lib/stack_master/testing.rb +1 -0
- data/lib/stack_master/validator.rb +8 -4
- data/lib/stack_master/version.rb +1 -1
- data/spec/fixtures/stack_master.yml +4 -1
- data/spec/stack_master/command_spec.rb +28 -0
- data/spec/stack_master/commands/apply_spec.rb +12 -2
- data/spec/stack_master/commands/status_spec.rb +25 -2
- data/spec/stack_master/commands/validate_spec.rb +1 -1
- data/spec/stack_master/config_spec.rb +42 -8
- data/spec/stack_master/parameter_resolver_spec.rb +6 -2
- data/spec/stack_master/parameter_resolvers/security_group_spec.rb +5 -3
- data/spec/stack_master/parameter_resolvers/security_groups_spec.rb +32 -0
- data/spec/stack_master/prompter_spec.rb +23 -0
- data/spec/stack_master/resolver_array_spec.rb +42 -0
- data/spec/stack_master/validator_spec.rb +2 -2
- metadata +15 -3
data/lib/stack_master/config.rb
CHANGED
@@ -44,13 +44,17 @@ module StackMaster
|
|
44
44
|
load_config
|
45
45
|
end
|
46
46
|
|
47
|
-
def
|
48
|
-
@stacks.
|
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
|
46
|
-
|
47
|
-
|
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 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
|
-
|
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
|
6
|
-
|
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
|
-
|
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
|
+
|
data/lib/stack_master/testing.rb
CHANGED
@@ -1,18 +1,22 @@
|
|
1
1
|
module StackMaster
|
2
2
|
class Validator
|
3
|
-
|
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 "
|
15
|
+
StackMaster.stdout.puts "valid"
|
16
|
+
true
|
13
17
|
rescue Aws::CloudFormation::Errors::ValidationError => e
|
14
|
-
|
15
|
-
|
18
|
+
StackMaster.stdout.puts "invalid. #{e.message}"
|
19
|
+
false
|
16
20
|
end
|
17
21
|
|
18
22
|
private
|
data/lib/stack_master/version.rb
CHANGED
@@ -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(:
|
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
|
-
|
35
|
-
stack
|
36
|
-
|
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
|
-
|
40
|
-
stack
|
41
|
-
|
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
|