stack_master 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (103) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.rspec +2 -0
  4. data/Gemfile +4 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +208 -0
  7. data/Rakefile +11 -0
  8. data/apply_demo.gif +0 -0
  9. data/bin/stack_master +16 -0
  10. data/example/simple/Gemfile +3 -0
  11. data/example/simple/parameters/myapp_vpc.yml +1 -0
  12. data/example/simple/parameters/myapp_web.yml +2 -0
  13. data/example/simple/stack_master.yml +13 -0
  14. data/example/simple/templates/myapp_vpc.rb +39 -0
  15. data/example/simple/templates/myapp_web.rb +16 -0
  16. data/features/apply.feature +241 -0
  17. data/features/delete.feature +43 -0
  18. data/features/diff.feature +191 -0
  19. data/features/events.feature +38 -0
  20. data/features/init.feature +6 -0
  21. data/features/outputs.feature +49 -0
  22. data/features/region_aliases.feature +66 -0
  23. data/features/resources.feature +45 -0
  24. data/features/stack_defaults.feature +88 -0
  25. data/features/status.feature +124 -0
  26. data/features/step_definitions/stack_steps.rb +50 -0
  27. data/features/support/env.rb +14 -0
  28. data/lib/stack_master.rb +81 -0
  29. data/lib/stack_master/aws_driver/cloud_formation.rb +56 -0
  30. data/lib/stack_master/cli.rb +164 -0
  31. data/lib/stack_master/command.rb +13 -0
  32. data/lib/stack_master/commands/apply.rb +104 -0
  33. data/lib/stack_master/commands/delete.rb +53 -0
  34. data/lib/stack_master/commands/diff.rb +31 -0
  35. data/lib/stack_master/commands/events.rb +39 -0
  36. data/lib/stack_master/commands/init.rb +109 -0
  37. data/lib/stack_master/commands/list_stacks.rb +16 -0
  38. data/lib/stack_master/commands/outputs.rb +27 -0
  39. data/lib/stack_master/commands/resources.rb +33 -0
  40. data/lib/stack_master/commands/status.rb +47 -0
  41. data/lib/stack_master/commands/validate.rb +17 -0
  42. data/lib/stack_master/config.rb +86 -0
  43. data/lib/stack_master/ctrl_c.rb +4 -0
  44. data/lib/stack_master/parameter_loader.rb +17 -0
  45. data/lib/stack_master/parameter_resolver.rb +45 -0
  46. data/lib/stack_master/parameter_resolvers/secret.rb +42 -0
  47. data/lib/stack_master/parameter_resolvers/security_group.rb +20 -0
  48. data/lib/stack_master/parameter_resolvers/sns_topic_name.rb +29 -0
  49. data/lib/stack_master/parameter_resolvers/stack_output.rb +53 -0
  50. data/lib/stack_master/prompter.rb +14 -0
  51. data/lib/stack_master/security_group_finder.rb +29 -0
  52. data/lib/stack_master/sns_topic_finder.rb +27 -0
  53. data/lib/stack_master/stack.rb +96 -0
  54. data/lib/stack_master/stack_definition.rb +49 -0
  55. data/lib/stack_master/stack_differ.rb +80 -0
  56. data/lib/stack_master/stack_events/fetcher.rb +45 -0
  57. data/lib/stack_master/stack_events/presenter.rb +27 -0
  58. data/lib/stack_master/stack_events/streamer.rb +55 -0
  59. data/lib/stack_master/stack_states.rb +34 -0
  60. data/lib/stack_master/template_compiler.rb +21 -0
  61. data/lib/stack_master/test_driver/cloud_formation.rb +139 -0
  62. data/lib/stack_master/testing.rb +7 -0
  63. data/lib/stack_master/utils.rb +31 -0
  64. data/lib/stack_master/validator.rb +25 -0
  65. data/lib/stack_master/version.rb +3 -0
  66. data/logo.png +0 -0
  67. data/script/buildkite/bundle.sh +5 -0
  68. data/script/buildkite/clean.sh +3 -0
  69. data/script/buildkite_rspec.sh +27 -0
  70. data/spec/fixtures/parameters/myapp_vpc.yml +1 -0
  71. data/spec/fixtures/stack_master.yml +35 -0
  72. data/spec/fixtures/templates/myapp_vpc.json +1 -0
  73. data/spec/spec_helper.rb +99 -0
  74. data/spec/stack_master/commands/apply_spec.rb +92 -0
  75. data/spec/stack_master/commands/delete_spec.rb +40 -0
  76. data/spec/stack_master/commands/init_spec.rb +17 -0
  77. data/spec/stack_master/commands/status_spec.rb +38 -0
  78. data/spec/stack_master/commands/validate_spec.rb +26 -0
  79. data/spec/stack_master/config_spec.rb +81 -0
  80. data/spec/stack_master/parameter_loader_spec.rb +81 -0
  81. data/spec/stack_master/parameter_resolver_spec.rb +58 -0
  82. data/spec/stack_master/parameter_resolvers/secret_spec.rb +66 -0
  83. data/spec/stack_master/parameter_resolvers/security_group_spec.rb +17 -0
  84. data/spec/stack_master/parameter_resolvers/sns_topic_name_spec.rb +43 -0
  85. data/spec/stack_master/parameter_resolvers/stack_output_spec.rb +77 -0
  86. data/spec/stack_master/security_group_finder_spec.rb +49 -0
  87. data/spec/stack_master/sns_topic_finder_spec.rb +25 -0
  88. data/spec/stack_master/stack_definition_spec.rb +37 -0
  89. data/spec/stack_master/stack_differ_spec.rb +34 -0
  90. data/spec/stack_master/stack_events/fetcher_spec.rb +65 -0
  91. data/spec/stack_master/stack_events/presenter_spec.rb +18 -0
  92. data/spec/stack_master/stack_events/streamer_spec.rb +33 -0
  93. data/spec/stack_master/stack_spec.rb +157 -0
  94. data/spec/stack_master/template_compiler_spec.rb +48 -0
  95. data/spec/stack_master/test_driver/cloud_formation_spec.rb +24 -0
  96. data/spec/stack_master/utils_spec.rb +30 -0
  97. data/spec/stack_master/validator_spec.rb +38 -0
  98. data/stack_master.gemspec +38 -0
  99. data/stacktemplates/parameter_region.yml +3 -0
  100. data/stacktemplates/parameter_stack_name.yml +3 -0
  101. data/stacktemplates/stack.json.erb +20 -0
  102. data/stacktemplates/stack_master.yml.erb +6 -0
  103. metadata +427 -0
@@ -0,0 +1,17 @@
1
+ module StackMaster
2
+ module Commands
3
+ class Validate
4
+ include Command
5
+ include Commander::UI
6
+
7
+ def initialize(config, stack_definition)
8
+ @config = config
9
+ @stack_definition = stack_definition
10
+ end
11
+
12
+ def perform
13
+ Validator.perform(@stack_definition)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,86 @@
1
+ require 'deep_merge/rails_compat'
2
+ require 'active_support/core_ext/object/deep_dup'
3
+
4
+ module StackMaster
5
+ class Config
6
+ def self.load!(config_file = 'stack_master.yml')
7
+ config = YAML.load(File.read(config_file))
8
+ base_dir = File.dirname(File.expand_path(config_file))
9
+ new(config, base_dir)
10
+ end
11
+
12
+ attr_accessor :stacks,
13
+ :base_dir,
14
+ :stack_defaults,
15
+ :region_defaults,
16
+ :region_aliases
17
+
18
+ def initialize(config, base_dir)
19
+ @config = config
20
+ @base_dir = base_dir
21
+ @stack_defaults = config.fetch('stack_defaults', {})
22
+ @region_aliases = Utils.underscore_keys_to_hyphen(config.fetch('region_aliases', {}))
23
+ @region_to_aliases = @region_aliases.inject({}) do |hash, (key, value)|
24
+ hash[value] ||= []
25
+ hash[value] << key
26
+ hash
27
+ end
28
+ @region_defaults = normalise_region_defaults(config.fetch('region_defaults', {}))
29
+ @stacks = []
30
+ load_config
31
+ end
32
+
33
+ def find_stack(region, stack_name)
34
+ @stacks.find do |s|
35
+ (s.region == region || s.region == region.gsub('_', '-')) &&
36
+ (s.stack_name == stack_name || s.stack_name == stack_name.gsub('_', '-'))
37
+ end
38
+ end
39
+
40
+ def unalias_region(region)
41
+ @region_aliases.fetch(region) { region }
42
+ end
43
+
44
+ private
45
+
46
+ def load_config
47
+ unaliased_stacks = resolve_region_aliases(@config.fetch('stacks'))
48
+ load_stacks(unaliased_stacks)
49
+ end
50
+
51
+ def resolve_region_aliases(stacks)
52
+ stacks.inject({}) do |hash, (region, attributes)|
53
+ hash[unalias_region(region)] = attributes
54
+ hash
55
+ end
56
+ end
57
+
58
+ def load_stacks(stacks)
59
+ stacks.each do |region, stacks_for_region|
60
+ region = Utils.underscore_to_hyphen(region)
61
+ stacks_for_region.each do |stack_name, attributes|
62
+ stack_name = Utils.underscore_to_hyphen(stack_name)
63
+ stack_attributes = build_stack_defaults(region).deeper_merge(attributes).merge(
64
+ 'region' => region,
65
+ 'stack_name' => stack_name,
66
+ 'base_dir' => @base_dir,
67
+ 'additional_parameter_lookup_dirs' => @region_to_aliases[region])
68
+ @stacks << StackDefinition.new(stack_attributes)
69
+ end
70
+ end
71
+ end
72
+
73
+ def build_stack_defaults(region)
74
+ region_defaults = @region_defaults.fetch(region, {}).deep_dup
75
+ @stack_defaults.deep_dup.deeper_merge(region_defaults)
76
+ end
77
+
78
+ def normalise_region_defaults(region_defaults)
79
+ region_defaults.inject({}) do |normalised_aliases, (region_or_alias, value)|
80
+ region = unalias_region(region_or_alias)
81
+ normalised_aliases[Utils.underscore_to_hyphen(region)] = value
82
+ normalised_aliases
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,4 @@
1
+ module StackMaster
2
+ class CtrlC < Exception
3
+ end
4
+ end
@@ -0,0 +1,17 @@
1
+ module StackMaster
2
+ class ParameterLoader
3
+ def self.load(parameter_files)
4
+ parameter_files.reduce({}) do |hash, file_name|
5
+ parameters = if File.exists?(file_name)
6
+ YAML.load(File.read(file_name)) || {}
7
+ else
8
+ {}
9
+ end
10
+ parameters.each do |key, value|
11
+ hash[key.camelize] = value
12
+ end
13
+ hash
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,45 @@
1
+ module StackMaster
2
+ class ParameterResolver
3
+ ResolverNotFound = Class.new(StandardError)
4
+ InvalidParameter = Class.new(StandardError)
5
+
6
+ def self.resolve(config, stack_definition, parameters)
7
+ new(config, stack_definition, parameters).resolve
8
+ end
9
+
10
+ def initialize(config, stack_definition, parameters)
11
+ @config = config
12
+ @stack_definition = stack_definition
13
+ @parameters = parameters
14
+ @resolvers = {}
15
+ end
16
+
17
+ def resolve
18
+ @parameters.reduce({}) do |parameters, (key, value)|
19
+ parameters[key] = resolve_parameter_value(value)
20
+ parameters
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def resolve_parameter_value(parameter_value)
27
+ return parameter_value if String === parameter_value || parameter_value.nil?
28
+ raise InvalidParameter, parameter_value unless Hash === parameter_value
29
+ raise InvalidParameter, parameter_value unless parameter_value.keys.size == 1
30
+ resolver_class_name = parameter_value.keys.first.to_s.camelize
31
+ value = parameter_value.values.first
32
+ resolver_class(resolver_class_name).resolve(value)
33
+ end
34
+
35
+ def resolver_class(class_name)
36
+ @resolvers.fetch(class_name) do
37
+ begin
38
+ @resolvers[class_name] = Kernel.const_get("StackMaster::ParameterResolvers::#{class_name}").new(@config, @stack_definition)
39
+ rescue NameError
40
+ raise ResolverNotFound, "Could not find parameter resolver called #{class_name}, please double check your configuration"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,42 @@
1
+ module StackMaster
2
+ module ParameterResolvers
3
+ class Secret
4
+ SecretNotFound = Class.new(StandardError)
5
+
6
+ def initialize(config, stack_definition)
7
+ @config = config
8
+ @stack_definition = stack_definition
9
+ end
10
+
11
+ def resolve(value)
12
+ secret_key = value
13
+ raise ArgumentError, "No secret_file defined for stack definition #{@stack_definition.stack_name} in #{@stack_definition.region}" unless !@stack_definition.secret_file.nil?
14
+ raise ArgumentError, "Could not find secret file at #{secret_file_path}" unless File.exist?(secret_file_path)
15
+ secrets_hash.fetch(secret_key) do
16
+ raise SecretNotFound, "Unable to find key #{secret_key} in file #{secret_file_path}"
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def secrets_hash
23
+ @secrets_hash ||= YAML.load(decrypt_with_dotgpg)
24
+ end
25
+
26
+ def decrypt_with_dotgpg
27
+ dir = Dotgpg::Dir.closest(secret_file_path)
28
+ stream = StringIO.new
29
+ dir.decrypt(secret_path_relative_to_base, stream)
30
+ stream.string
31
+ end
32
+
33
+ def secret_path_relative_to_base
34
+ @secret_path_relative_to_base ||= File.join('secrets', @stack_definition.secret_file)
35
+ end
36
+
37
+ def secret_file_path
38
+ @secret_file_path ||= File.join(@config.base_dir, secret_path_relative_to_base)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ module StackMaster
2
+ module ParameterResolvers
3
+ class SecurityGroup
4
+ def initialize(config, stack_definition)
5
+ @config = config
6
+ @stack_definition = stack_definition
7
+ end
8
+
9
+ def resolve(value)
10
+ security_group_finder.find(value)
11
+ end
12
+
13
+ private
14
+
15
+ def security_group_finder
16
+ StackMaster::SecurityGroupFinder.new(@stack_definition.region)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,29 @@
1
+ module StackMaster
2
+ module ParameterResolvers
3
+ class SnsTopicName
4
+ TopicNotFound = Class.new(StandardError)
5
+
6
+ def initialize(config, stack_definition)
7
+ @config = config
8
+ @stack_definition = stack_definition
9
+ @stacks = {}
10
+ end
11
+
12
+ def resolve(value)
13
+ sns_topic_finder.find(value)
14
+ rescue StackMaster::SnsTopicFinder::TopicNotFound => e
15
+ raise TopicNotFound.new(e.message)
16
+ end
17
+
18
+ private
19
+
20
+ def cf
21
+ @cf ||= StackMaster.cloud_formation_driver
22
+ end
23
+
24
+ def sns_topic_finder
25
+ StackMaster::SnsTopicFinder.new(@stack_definition.region)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,53 @@
1
+ module StackMaster
2
+ module ParameterResolvers
3
+ class StackOutput
4
+ StackNotFound = Class.new(StandardError)
5
+ StackOutputNotFound = Class.new(StandardError)
6
+
7
+ def initialize(config, stack_definition)
8
+ @config = config
9
+ @stack_definition = stack_definition
10
+ @stacks = {}
11
+ end
12
+
13
+ def resolve(value)
14
+ validate_value!(value)
15
+ stack_name, output_name = value.split('/')
16
+ stack = find_stack(stack_name)
17
+ if stack
18
+ output = stack.outputs.find { |output| output.output_key == output_name.camelize }
19
+ if output
20
+ output.output_value
21
+ else
22
+ raise StackOutputNotFound, "Stack exists (#{stack_name}), but output does not: #{output_name}"
23
+ end
24
+ else
25
+ raise StackNotFound, "Stack in StackOutput not found: #{value}"
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def cf
32
+ @cf ||= StackMaster.cloud_formation_driver
33
+ end
34
+
35
+ def validate_value!(value)
36
+ if !value.is_a?(String) || !value.include?('/')
37
+ raise ArgumentError, 'Stack output values must be in the form of stack-name/output-name'
38
+ end
39
+ end
40
+
41
+ def find_stack(stack_name)
42
+ @stacks.fetch(stack_name) do
43
+ cf_stack = cf.describe_stacks(stack_name: stack_name).stacks.first
44
+ @stacks[stack_name] = cf_stack
45
+ end
46
+ end
47
+
48
+ def cf
49
+ @cf ||= StackMaster.cloud_formation_driver
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,14 @@
1
+ module StackMaster
2
+ module Prompter
3
+ def ask?(question)
4
+ StackMaster.stdout.print question
5
+ answer = if ENV['STUB_AWS']
6
+ ENV['ANSWER']
7
+ else
8
+ STDIN.getch.chomp
9
+ end
10
+ StackMaster.stdout.puts
11
+ answer == 'y'
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ module StackMaster
2
+ class SecurityGroupFinder
3
+ SecurityGroupNotFound = Class.new(StandardError)
4
+ MultipleSecurityGroupsFound = Class.new(StandardError)
5
+
6
+ def initialize(region)
7
+ @resource = Aws::EC2::Resource.new(region: region)
8
+ end
9
+
10
+ def find(reference)
11
+ STDERR.puts "Resolving security group reference '#{reference}'"
12
+ raise ArgumentError, 'Security group references must be non-empty strings' unless reference.is_a?(String) && !reference.empty?
13
+
14
+ groups = @resource.security_groups({
15
+ filters: [
16
+ {
17
+ name: "group-name",
18
+ values: [reference],
19
+ },
20
+ ],
21
+ })
22
+
23
+ raise SecurityGroupNotFound, "No security group with name #{reference} found" unless groups.any?
24
+ raise MultipleSecurityGroupsFound, "More than one security group with name #{reference} found" if groups.count > 1
25
+
26
+ groups.first.id
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ module StackMaster
2
+ class SnsTopicFinder
3
+ TopicNotFound = Class.new(StandardError)
4
+
5
+ def initialize(region)
6
+ @resource = Aws::SNS::Resource.new(region: region)
7
+ end
8
+
9
+ def find(reference)
10
+ $stderr.puts "Resolving SNS topic reference '#{reference}'"
11
+ raise ArgumentError, 'SNS topic references must be non-empty strings' unless reference.is_a?(String) && !reference.empty?
12
+
13
+ topic = @resource.topics.detect { |t| topic_name_from_arn(t.arn) == reference }
14
+
15
+ raise TopicNotFound, "No topic with name #{reference} found" unless topic
16
+
17
+ topic.arn
18
+ end
19
+
20
+ private
21
+
22
+ def topic_name_from_arn(arn)
23
+ arn.split(":")[5]
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,96 @@
1
+ module StackMaster
2
+ class Stack
3
+ MAX_TEMPLATE_SIZE = 51200
4
+
5
+ include Virtus.model
6
+
7
+ attribute :stack_name, String
8
+ attribute :region, String
9
+ attribute :stack_id, String
10
+ attribute :stack_status, String
11
+ attribute :parameters, Hash
12
+ attribute :template_body, String
13
+ attribute :notification_arns, Array[String]
14
+ attribute :outputs, Array
15
+ attribute :stack_policy_body, String
16
+ attribute :tags, Hash
17
+
18
+ def template_hash
19
+ if template_body
20
+ @template_hash ||= JSON.parse(template_body)
21
+ end
22
+ end
23
+
24
+ def maybe_compressed_template_body
25
+ if template_body.size > MAX_TEMPLATE_SIZE
26
+ @compressed_template_body ||= JSON.dump(template_hash)
27
+ else
28
+ template_body
29
+ end
30
+ end
31
+
32
+ def template_default_parameters
33
+ template_hash.fetch('Parameters', {}).inject({}) do |result, (parameter_name, description)|
34
+ result[parameter_name] = description['Default']
35
+ result
36
+ end
37
+ end
38
+
39
+ def parameters_with_defaults
40
+ template_default_parameters.merge(parameters)
41
+ end
42
+
43
+ def self.find(region, stack_name)
44
+ cf = StackMaster.cloud_formation_driver
45
+ cf_stack = cf.describe_stacks(stack_name: stack_name).stacks.first
46
+ return unless cf_stack
47
+ parameters = cf_stack.parameters.inject({}) do |params_hash, param_struct|
48
+ params_hash[param_struct.parameter_key] = param_struct.parameter_value
49
+ params_hash
50
+ end
51
+ template_body ||= cf.get_template(stack_name: stack_name).template_body
52
+ stack_policy_body ||= cf.get_stack_policy(stack_name: stack_name).stack_policy_body
53
+ outputs = cf_stack.outputs
54
+
55
+ new(region: region,
56
+ stack_name: stack_name,
57
+ stack_id: cf_stack.stack_id,
58
+ parameters: parameters,
59
+ template_body: template_body,
60
+ outputs: outputs,
61
+ notification_arns: cf_stack.notification_arns,
62
+ stack_policy_body: stack_policy_body,
63
+ stack_status: cf_stack.stack_status)
64
+ rescue Aws::CloudFormation::Errors::ValidationError
65
+ nil
66
+ end
67
+
68
+ def self.generate(stack_definition, config)
69
+ parameter_hash = ParameterLoader.load(stack_definition.parameter_files)
70
+ template_body = TemplateCompiler.compile(stack_definition.template_file_path)
71
+ parameters = ParameterResolver.resolve(config, stack_definition, parameter_hash)
72
+ stack_policy_body = if stack_definition.stack_policy_file_path
73
+ File.read(stack_definition.stack_policy_file_path)
74
+ end
75
+ new(region: stack_definition.region,
76
+ stack_name: stack_definition.stack_name,
77
+ tags: stack_definition.tags,
78
+ parameters: parameters,
79
+ template_body: template_body,
80
+ notification_arns: stack_definition.notification_arns,
81
+ stack_policy_body: stack_policy_body)
82
+ end
83
+
84
+ def too_big?
85
+ maybe_compressed_template_body.size > MAX_TEMPLATE_SIZE
86
+ end
87
+
88
+ def aws_parameters
89
+ Utils.hash_to_aws_parameters(parameters)
90
+ end
91
+
92
+ def aws_tags
93
+ Utils.hash_to_aws_tags(tags)
94
+ end
95
+ end
96
+ end