stack_master 0.0.1

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