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.
- 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
|