bora 0.9.4 → 1.0.0.beta1

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.
@@ -0,0 +1,168 @@
1
+ require 'set'
2
+ require 'open-uri'
3
+ require 'aws-sdk'
4
+ require 'diffy'
5
+ require 'bora/cfn/stack_status'
6
+ require 'bora/cfn/event'
7
+ require 'bora/cfn/output'
8
+
9
+ class Bora
10
+ module Cfn
11
+
12
+ class Stack
13
+ NO_UPDATE_MESSAGE = "No updates are to be performed"
14
+
15
+ def initialize(stack_name)
16
+ @stack_name = stack_name
17
+ @processed_events = Set.new
18
+ end
19
+
20
+ def create(options, &block)
21
+ call_cfn_action(:create, options, &block)
22
+ end
23
+
24
+ def update(options, &block)
25
+ call_cfn_action(:update, options, &block)
26
+ end
27
+
28
+ def create_or_update(options, &block)
29
+ exists? ? update(options, &block) : create(options, &block)
30
+ end
31
+
32
+ def recreate(options, &block)
33
+ delete(&block) if exists?
34
+ create(options, &block) if !exists?
35
+ end
36
+
37
+ def delete(&block)
38
+ call_cfn_action(:delete, &block)
39
+ end
40
+
41
+ def events
42
+ return if !exists?
43
+ events = cloudformation.describe_stack_events({stack_name: underlying_stack.stack_id}).stack_events
44
+ events.reverse.map { |e| Event.new(e) }
45
+ end
46
+
47
+ def outputs
48
+ return if !exists?
49
+ underlying_stack.outputs.map { |output| Output.new(output) }
50
+ end
51
+
52
+ def template(pretty = true)
53
+ return if !exists?
54
+ template = cloudformation.get_template({stack_name: @stack_name}).template_body
55
+ template = JSON.pretty_generate(JSON.parse(template)) if pretty
56
+ template
57
+ end
58
+
59
+ def new_template(options, pretty = true)
60
+ options = resolve_options(options, true)
61
+ template = options[:template_body]
62
+ if template
63
+ template = JSON.pretty_generate(JSON.parse(template)) if pretty
64
+ template
65
+ else
66
+ raise "new_template not yet implemented for URL #{options[:template_url]}"
67
+ end
68
+ end
69
+
70
+ def diff(options)
71
+ Diffy::Diff.new(template, new_template(options))
72
+ end
73
+
74
+ def validate(options)
75
+ cloudformation.validate_template(resolve_options(options).select { |k| [:template_body, :template_url].include?(k) })
76
+ end
77
+
78
+ def status
79
+ StackStatus.new(underlying_stack)
80
+ end
81
+
82
+ def exists?
83
+ status.exists?
84
+ end
85
+
86
+
87
+ # =============================================================================================
88
+ private
89
+
90
+ def cloudformation
91
+ @cfn ||= Aws::CloudFormation::Client.new
92
+ end
93
+
94
+ def method_missing(sym, *args, &block)
95
+ underlying_stack ? underlying_stack.send(sym, *args, &block) : nil
96
+ end
97
+
98
+ def call_cfn_action(action, options = {}, &block)
99
+ underlying_stack(refresh: true)
100
+ return true if action == :delete && !exists?
101
+ @previous_event_time = last_event_time
102
+ begin
103
+ action_options = {stack_name: @stack_name}.merge(resolve_options(options))
104
+ cloudformation.method("#{action.to_s.downcase}_stack").call(action_options)
105
+ wait_for_completion(&block)
106
+ rescue Aws::CloudFormation::Errors::ValidationError => e
107
+ raise e unless e.message.include?(NO_UPDATE_MESSAGE)
108
+ return nil
109
+ end
110
+ (action == :delete && !underlying_stack) || status.success?
111
+ end
112
+
113
+ def resolve_options(options, load_all = false)
114
+ return options if options[:template_body] || !options[:template_url]
115
+ uri = URI(options[:template_url])
116
+ if uri.scheme != "s3" || load_all
117
+ resolved_options = options.clone
118
+ resolved_options[:template_body] = open(options[:template_url]).read
119
+ resolved_options.delete(:template_url)
120
+ resolved_options
121
+ else
122
+ options
123
+ end
124
+ end
125
+
126
+ def wait_for_completion
127
+ begin
128
+ events = unprocessed_events
129
+ events.each { |e| yield e } if block_given?
130
+ finished = events.find do |e|
131
+ e.resource_type == 'AWS::CloudFormation::Stack' && e.logical_resource_id == @stack_name && e.status_complete?
132
+ end
133
+ sleep 10 unless finished
134
+ end until finished
135
+ underlying_stack(refresh: true)
136
+ end
137
+
138
+ def underlying_stack(refresh: false)
139
+ if !@_stack || refresh
140
+ begin
141
+ response = cloudformation.describe_stacks({stack_name: @stack_name})
142
+ @_stack = response.stacks[0]
143
+ rescue Aws::CloudFormation::Errors::ValidationError
144
+ @_stack = nil
145
+ end
146
+ end
147
+ @_stack
148
+ end
149
+
150
+ def unprocessed_events
151
+ return [] if !underlying_stack
152
+ events = cloudformation.describe_stack_events({stack_name: underlying_stack.stack_id}).stack_events
153
+ unprocessed_events = events.select do |event|
154
+ !@processed_events.include?(event.event_id) && @previous_event_time < event.timestamp
155
+ end
156
+ @processed_events.merge(unprocessed_events.map(&:event_id))
157
+ unprocessed_events.reverse.map { |e| Event.new(e) }
158
+ end
159
+
160
+ def last_event_time
161
+ return Time.at(0) if !underlying_stack
162
+ events = cloudformation.describe_stack_events({stack_name: @stack_name}).stack_events
163
+ events.length > 0 ? events[0].timestamp : Time.at(0)
164
+ end
165
+ end
166
+
167
+ end
168
+ end
@@ -0,0 +1,33 @@
1
+ require 'bora/cfn/status'
2
+
3
+ class Bora
4
+ module Cfn
5
+
6
+ class StackStatus
7
+ def initialize(underlying_stack)
8
+ @stack = underlying_stack
9
+ if @stack
10
+ @status = Status.new(@stack.stack_status)
11
+ end
12
+ end
13
+
14
+ def exists?
15
+ @status && !@status.deleted?
16
+ end
17
+
18
+ def success?
19
+ @status && @status.success?
20
+ end
21
+
22
+ def to_s
23
+ if @stack
24
+ status_reason = @stack.stack_status_reason ? " - #{@stack.stack_status_reason}" : ""
25
+ "#{@stack.stack_name} - #{@status}#{status_reason}"
26
+ else
27
+ "Stack does not exist"
28
+ end
29
+ end
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,43 @@
1
+ require 'colorize'
2
+
3
+ class Bora
4
+ module Cfn
5
+ class Status
6
+ def initialize(status)
7
+ @status = status
8
+ end
9
+
10
+ def success?
11
+ @status.end_with?("_COMPLETE") && !@status.include?("ROLLBACK")
12
+ end
13
+
14
+ def failure?
15
+ @status.end_with?("_FAILED") || @status.include?("ROLLBACK")
16
+ end
17
+
18
+ def deleted?
19
+ @status == "DELETE_COMPLETE"
20
+ end
21
+
22
+ def complete?
23
+ success? || failure?
24
+ end
25
+
26
+ def to_s
27
+ @status.colorize(color)
28
+ end
29
+
30
+
31
+ private
32
+
33
+ def color
34
+ case
35
+ when success?; :green
36
+ when failure?; :red
37
+ else; :yellow;
38
+ end
39
+ end
40
+ end
41
+
42
+ end
43
+ end
@@ -1,6 +1,6 @@
1
- require 'bora/stack'
1
+ require 'bora/cfn/stack'
2
2
 
3
- module Bora
3
+ class Bora
4
4
  class CfnParamResolver
5
5
  def initialize(param)
6
6
  @param = param
@@ -12,7 +12,7 @@ module Bora
12
12
  raise "Invalid parameter substitution: #{@param}"
13
13
  end
14
14
 
15
- stack = Stack.new(stack_name)
15
+ stack = Cfn::Stack.new(stack_name)
16
16
  if !stack.exists?
17
17
  raise "Output #{name} not found in stack #{stack_name} as the stack does not exist"
18
18
  end
data/lib/bora/cli.rb ADDED
@@ -0,0 +1,81 @@
1
+ require "thor"
2
+ require "bora"
3
+
4
+ class Bora
5
+ class Cli < Thor
6
+ class_option :file, type: :string, aliases: :f, default: Bora::DEFAULT_CONFIG_FILE, desc: "The Bora config file to use"
7
+
8
+ desc "apply STACK_NAME", "Creates or updates the stack"
9
+ option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
10
+ def apply(stack_name)
11
+ stack(options.file, stack_name).apply(params)
12
+ end
13
+
14
+ desc "delete STACK_NAME", "Deletes the stack"
15
+ def delete(stack_name)
16
+ stack(options.file, stack_name).delete
17
+ end
18
+
19
+ desc "diff STACK_NAME", "Diffs the new template with the stack's current template"
20
+ option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
21
+ def diff(stack_name)
22
+ stack(options.file, stack_name).diff(params)
23
+ end
24
+
25
+ desc "events STACK_NAME", "Outputs the latest events from the stack"
26
+ def events(stack_name)
27
+ stack(options.file, stack_name).events
28
+ end
29
+
30
+ desc "outputs STACK_NAME", "Shows the outputs from the stack"
31
+ def outputs(stack_name)
32
+ stack(options.file, stack_name).outputs
33
+ end
34
+
35
+ desc "recreate STACK_NAME", "Recreates (deletes then creates) the stack"
36
+ option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
37
+ def recreate(stack_name)
38
+ stack(options.file, stack_name).recreate(params)
39
+ end
40
+
41
+ desc "show STACK_NAME", "Shows the new template for stack"
42
+ option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
43
+ def show(stack_name)
44
+ stack(options.file, stack_name).show(params)
45
+ end
46
+
47
+ desc "show_current STACK_NAME", "Shows the current template for the stack"
48
+ def show_current(stack_name)
49
+ stack(options.file, stack_name).show_current
50
+ end
51
+
52
+ desc "status STACK_NAME", "Displays the current status of the stack"
53
+ def status(stack_name)
54
+ stack(options.file, stack_name).status
55
+ end
56
+
57
+ desc "validate STACK_NAME", "Checks the stack's template for validity"
58
+ option :params, type: :array, aliases: :p, desc: "Parameters to be passed to the template, eg: --params 'instance_type=t2.micro'"
59
+ def validate(stack_name)
60
+ stack(options.file, stack_name).validate(params)
61
+ end
62
+
63
+
64
+ private
65
+
66
+ def stack(config_file, stack_name)
67
+ bora = Bora.new(config_file_or_hash: config_file)
68
+ stack = bora.stack(stack_name)
69
+ if !stack
70
+ STDERR.puts "Could not find stack #{stack_name}"
71
+ exit(1)
72
+ end
73
+ stack
74
+ end
75
+
76
+ def params
77
+ options.params ? Hash[options.params.map { |param| param.split("=", 2) }] : {}
78
+ end
79
+
80
+ end
81
+ end
data/lib/bora/stack.rb CHANGED
@@ -1,164 +1,171 @@
1
- require 'set'
2
- require 'open-uri'
3
- require 'aws-sdk'
4
- require 'diffy'
5
- require 'bora/stack_status'
6
- require 'bora/event'
7
- require 'bora/output'
8
-
9
- module Bora
1
+ require 'tempfile'
2
+ require 'colorize'
3
+ require 'cfndsl'
4
+ require 'bora/cfn/stack'
5
+ require 'bora/cfn_param_resolver'
6
+ require 'bora/stack_tasks'
7
+
8
+ class Bora
10
9
  class Stack
11
- NO_UPDATE_MESSAGE = "No updates are to be performed"
12
-
13
- def initialize(stack_name)
10
+ def initialize(stack_name, template_file, stack_config)
14
11
  @stack_name = stack_name
15
- @processed_events = Set.new
12
+ @cfn_stack_name = stack_config['stack_name'] || @stack_name
13
+ @template_file = template_file
14
+ @stack_config = stack_config
15
+ @cfn_options = extract_cfn_options(stack_config)
16
+ @cfn_stack = Cfn::Stack.new(@cfn_stack_name)
16
17
  end
17
18
 
18
- def create(options, &block)
19
- call_cfn_action(:create, options, &block)
20
- end
19
+ attr_reader :stack_name
21
20
 
22
- def update(options, &block)
23
- call_cfn_action(:update, options, &block)
21
+ def rake_tasks
22
+ StackTasks.new(self)
24
23
  end
25
24
 
26
- def create_or_update(options, &block)
27
- exists? ? update(options, &block) : create(options, &block)
25
+ def apply(override_params = {})
26
+ generate(override_params)
27
+ success = invoke_action(@cfn_stack.exists? ? "update" : "create", @cfn_options)
28
+ if success
29
+ outputs = @cfn_stack.outputs
30
+ if outputs && outputs.length > 0
31
+ puts "Stack outputs"
32
+ outputs.each { |output| puts output }
33
+ end
34
+ end
28
35
  end
29
36
 
30
- def recreate(options, &block)
31
- delete(&block) if exists?
32
- create(options, &block) if !exists?
37
+ def delete
38
+ invoke_action("delete")
33
39
  end
34
40
 
35
- def delete(&block)
36
- call_cfn_action(:delete, &block)
41
+ def diff(override_params = {})
42
+ generate(override_params)
43
+ puts @cfn_stack.diff(@cfn_options).to_s(String.disable_colorization ? :text : :color)
37
44
  end
38
45
 
39
46
  def events
40
- return if !exists?
41
- events = cloudformation.describe_stack_events({stack_name: underlying_stack.stack_id}).stack_events
42
- events.reverse.map { |e| Event.new(e) }
43
- end
44
-
45
- def outputs
46
- return if !exists?
47
- underlying_stack.outputs.map { |output| Output.new(output) }
47
+ events = @cfn_stack.events
48
+ if events
49
+ if events.length > 0
50
+ puts "Events for stack '#{@cfn_stack_name}'"
51
+ @cfn_stack.events.each { |e| puts e }
52
+ else
53
+ puts "Stack '#{@cfn_stack_name}' has no events"
54
+ end
55
+ else
56
+ puts "Stack '#{@cfn_stack_name}' does not exist"
57
+ end
48
58
  end
49
59
 
50
- def template(pretty = true)
51
- return if !exists?
52
- template = cloudformation.get_template({stack_name: @stack_name}).template_body
53
- template = JSON.pretty_generate(JSON.parse(template)) if pretty
54
- template
60
+ def generate(override_params = {})
61
+ params = process_params(override_params)
62
+ if File.extname(@template_file) == ".rb"
63
+ template_body = run_cfndsl(@template_file, params)
64
+ template_json = JSON.parse(template_body)
65
+ if template_json["Parameters"]
66
+ cfn_param_keys = template_json["Parameters"].keys
67
+ cfn_params = params.select { |k, v| cfn_param_keys.include?(k) }.map do |k, v|
68
+ { parameter_key: k, parameter_value: v }
69
+ end
70
+ @cfn_options[:parameters] = cfn_params if !cfn_params.empty?
71
+ end
72
+ @cfn_options[:template_body] = template_body
73
+ else
74
+ @cfn_options[:template_url] = @template_file
75
+ if !params.empty?
76
+ @cfn_options[:parameters] = params.map do |k, v|
77
+ { parameter_key: k, parameter_value: v }
78
+ end
79
+ end
80
+ end
55
81
  end
56
82
 
57
- def new_template(options, pretty = true)
58
- options = resolve_options(options, true)
59
- template = options[:template_body]
60
- if template
61
- template = JSON.pretty_generate(JSON.parse(template)) if pretty
62
- template
83
+ def outputs
84
+ outputs = @cfn_stack.outputs
85
+ if outputs
86
+ if outputs.length > 0
87
+ puts "Outputs for stack '#{@cfn_stack_name}'"
88
+ outputs.each { |output| puts output }
89
+ else
90
+ puts "Stack '#{@cfn_stack_name}' has no outputs"
91
+ end
63
92
  else
64
- raise "new_template not yet implemented for URL #{options[:template_url]}"
93
+ puts "Stack '#{@cfn_stack_name}' does not exist"
65
94
  end
66
95
  end
67
96
 
68
- def diff(options)
69
- Diffy::Diff.new(template, new_template(options))
97
+ def recreate(override_params = {})
98
+ generate(override_params)
99
+ invoke_action("recreate", @cfn_options)
70
100
  end
71
101
 
72
- def validate(options)
73
- cloudformation.validate_template(resolve_options(options).select { |k| [:template_body, :template_url].include?(k) })
102
+ def show(override_params = {})
103
+ generate(override_params)
104
+ puts @cfn_stack.new_template(@cfn_options)
74
105
  end
75
106
 
76
- def status
77
- StackStatus.new(underlying_stack)
107
+ def show_current
108
+ template = @cfn_stack.template
109
+ puts template ? template : "Stack '#{@cfn_stack_name}' does not exist"
78
110
  end
79
111
 
80
- def exists?
81
- status.exists?
112
+ def status
113
+ puts @cfn_stack.status
82
114
  end
83
115
 
84
-
85
- # =============================================================================================
86
- private
87
-
88
- def cloudformation
89
- @cfn ||= Aws::CloudFormation::Client.new
116
+ def validate(override_params = {})
117
+ generate(override_params)
118
+ puts "Template for stack '#{@cfn_stack_name}' is valid" if @cfn_stack.validate(@cfn_options)
90
119
  end
91
120
 
92
- def method_missing(sym, *args, &block)
93
- underlying_stack ? underlying_stack.send(sym, *args, &block) : nil
94
- end
95
121
 
96
- def call_cfn_action(action, options = {}, &block)
97
- underlying_stack(refresh: true)
98
- return true if action == :delete && !exists?
99
- @previous_event_time = last_event_time
100
- begin
101
- action_options = {stack_name: @stack_name}.merge(resolve_options(options))
102
- cloudformation.method("#{action.to_s.downcase}_stack").call(action_options)
103
- wait_for_completion(&block)
104
- rescue Aws::CloudFormation::Errors::ValidationError => e
105
- raise e unless e.message.include?(NO_UPDATE_MESSAGE)
106
- return nil
107
- end
108
- (action == :delete && !underlying_stack) || status.success?
109
- end
122
+ protected
110
123
 
111
- def resolve_options(options, load_all = false)
112
- return options if options[:template_body] || !options[:template_url]
113
- uri = URI(options[:template_url])
114
- if uri.scheme != "s3" || load_all
115
- resolved_options = options.clone
116
- resolved_options[:template_body] = open(options[:template_url]).read
117
- resolved_options.delete(:template_url)
118
- resolved_options
124
+ def invoke_action(action, *args)
125
+ puts "#{action.capitalize} stack '#{@cfn_stack_name}'"
126
+ success = @cfn_stack.send(action, *args) { |event| puts event }
127
+ if success
128
+ puts "#{action.capitalize} stack '#{@cfn_stack_name}' completed successfully"
119
129
  else
120
- options
130
+ if success == nil
131
+ puts "#{action.capitalize} stack '#{@cfn_stack_name}' skipped as template has not changed"
132
+ else
133
+ raise("#{action.capitalize} stack '#{@cfn_stack_name}' failed")
134
+ end
121
135
  end
136
+ success
122
137
  end
123
138
 
124
- def wait_for_completion
125
- begin
126
- events = unprocessed_events
127
- events.each { |e| yield e } if block_given?
128
- finished = events.find do |e|
129
- e.resource_type == 'AWS::CloudFormation::Stack' && e.logical_resource_id == @stack_name && e.status_complete?
130
- end
131
- sleep 10 unless finished
132
- end until finished
133
- underlying_stack(refresh: true)
134
- end
135
-
136
- def underlying_stack(refresh: false)
137
- if !@_stack || refresh
138
- begin
139
- response = cloudformation.describe_stacks({stack_name: @stack_name})
140
- @_stack = response.stacks[0]
141
- rescue Aws::CloudFormation::Errors::ValidationError
142
- @_stack = nil
143
- end
144
- end
145
- @_stack
139
+ def run_cfndsl(template_file, params)
140
+ temp_extras = Tempfile.new("bora")
141
+ temp_extras.write(params.to_yaml)
142
+ temp_extras.close
143
+ template_body = CfnDsl.eval_file_with_extras(template_file, [[:yaml, temp_extras.path]]).to_json
144
+ temp_extras.unlink
145
+ template_body
146
146
  end
147
147
 
148
- def unprocessed_events
149
- return [] if !underlying_stack
150
- events = cloudformation.describe_stack_events({stack_name: underlying_stack.stack_id}).stack_events
151
- unprocessed_events = events.select do |event|
152
- !@processed_events.include?(event.event_id) && @previous_event_time < event.timestamp
148
+ def process_params(override_params)
149
+ params = @stack_config['params'] || {}
150
+ params.merge!(override_params) if override_params
151
+ params.map { |k, v| [k, process_param_substitutions(v)] }.to_h
152
+ end
153
+
154
+ def process_param_substitutions(val)
155
+ old_val = nil
156
+ while old_val != val
157
+ old_val = val
158
+ val = val.sub(/\${[^}]+}/) do |m|
159
+ token = m[2..-2]
160
+ CfnParamResolver.new(token).resolve
161
+ end
153
162
  end
154
- @processed_events.merge(unprocessed_events.map(&:event_id))
155
- unprocessed_events.reverse.map { |e| Event.new(e) }
163
+ val
156
164
  end
157
165
 
158
- def last_event_time
159
- return Time.at(0) if !underlying_stack
160
- events = cloudformation.describe_stack_events({stack_name: @stack_name}).stack_events
161
- events.length > 0 ? events[0].timestamp : Time.at(0)
166
+ def extract_cfn_options(config)
167
+ valid_options = ["capabilities"]
168
+ config.select { |k| valid_options.include?(k) }
162
169
  end
163
170
 
164
171
  end