cfer 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,4 +1,6 @@
1
1
  require_relative '../core/client'
2
+ require 'uri'
3
+
2
4
  module Cfer::Cfn
3
5
 
4
6
  class Client < Cfer::Core::Client
@@ -6,9 +8,11 @@ module Cfer::Cfn
6
8
  attr_reader :stack
7
9
 
8
10
  def initialize(options)
11
+ super
9
12
  @name = options[:stack_name]
10
- options.delete :stack_name
11
- @cfn = Aws::CloudFormation::Client.new(options)
13
+ @options = options
14
+ @options.delete :stack_name
15
+ @cfn = Aws::CloudFormation::Client.new(@options)
12
16
  flush_cache
13
17
  end
14
18
 
@@ -20,6 +24,17 @@ module Cfer::Cfn
20
24
  end
21
25
  end
22
26
 
27
+
28
+ def delete_stack(stack_name)
29
+ begin
30
+ @cfn.delete_stack({
31
+ stack_name: stack_name, # required
32
+ })
33
+ rescue Aws::CloudFormation::Errors
34
+ raise CferError, "Stack delete #{stack_name}"
35
+ end
36
+ end
37
+
23
38
  def responds_to?(method)
24
39
  @cfn.responds_to? method
25
40
  end
@@ -28,6 +43,29 @@ module Cfer::Cfn
28
43
  @cfn.send(method, *args, &block)
29
44
  end
30
45
 
46
+ def estimate(stack, options = {})
47
+ response = validate_template(template_body: stack.to_cfn)
48
+
49
+ estimate_params = []
50
+ response.parameters.each do |tmpl_param|
51
+ input_param = stack.input_parameters[tmpl_param.parameter_key]
52
+ if input_param
53
+ output_val = tmpl_param.no_echo ? '*****' : input_param
54
+ Cfer::LOGGER.debug "Parameter #{tmpl_param.parameter_key}=#{output_val}"
55
+ p = {
56
+ parameter_key: tmpl_param.parameter_key,
57
+ parameter_value: input_param,
58
+ use_previous_value: false
59
+ }
60
+
61
+ estimate_params << p
62
+ end
63
+ end
64
+
65
+ estimate_response = estimate_template_cost(template_body: stack.to_cfn, parameters: estimate_params)
66
+ estimate_response.url
67
+ end
68
+
31
69
  def converge(stack, options = {})
32
70
  Preconditions.check(@name).is_not_nil
33
71
  Preconditions.check(stack) { is_not_nil and has_type(Cfer::Core::Stack) }
@@ -37,12 +75,15 @@ module Cfer::Cfn
37
75
  create_params = []
38
76
  update_params = []
39
77
 
40
- previous_parameters =
41
- begin
42
- fetch_parameters
43
- rescue Cfer::Util::StackDoesNotExistError
44
- nil
45
- end
78
+ previous_parameters = fetch_parameters rescue nil
79
+
80
+ current_version = Cfer::SEMANTIC_VERSION
81
+ previous_version = fetch_cfer_version rescue nil
82
+
83
+ current_hash = stack.git_version
84
+ previous_hash = fetch_git_hash rescue nil
85
+
86
+ # Compare current and previous versions and hashes?
46
87
 
47
88
  response.parameters.each do |tmpl_param|
48
89
  input_param = stack.input_parameters[tmpl_param.parameter_key]
@@ -81,7 +122,6 @@ module Cfer::Cfn
81
122
 
82
123
  stack_options = {
83
124
  stack_name: name,
84
- template_body: stack.to_cfn,
85
125
  capabilities: response.capabilities
86
126
  }
87
127
 
@@ -91,11 +131,19 @@ module Cfer::Cfn
91
131
  stack_options.merge! parse_stack_policy(:stack_policy, options[:stack_policy])
92
132
  stack_options.merge! parse_stack_policy(:stack_policy_during_update, options[:stack_policy_during_update])
93
133
 
134
+ stack_options.merge! upload_or_return_template(stack.to_cfn, options)
135
+
94
136
  cfn_stack =
95
137
  begin
96
138
  create_stack stack_options.merge parameters: create_params
139
+ :created
97
140
  rescue Cfer::Util::StackExistsError
98
- update_stack stack_options.merge parameters: update_params
141
+ if options[:change]
142
+ create_change_set stack_options.merge change_set_name: options[:change], description: options[:change_description], parameters: update_params
143
+ else
144
+ update_stack stack_options.merge parameters: update_params
145
+ end
146
+ :updated
99
147
  end
100
148
 
101
149
  flush_cache
@@ -150,21 +198,59 @@ module Cfer::Cfn
150
198
  end
151
199
  end
152
200
 
201
+ def stack_cache(stack_name)
202
+ @stack_cache[stack_name] ||= {}
203
+ end
204
+
153
205
  def fetch_stack(stack_name = @name)
154
206
  raise Cfer::Util::StackDoesNotExistError, 'Stack name must be specified' if stack_name == nil
155
207
  begin
156
- @stack_cache[stack_name] ||= describe_stacks(stack_name: stack_name).stacks.first.to_h
208
+ stack_cache(stack_name)[:stack] ||= describe_stacks(stack_name: stack_name).stacks.first.to_h
157
209
  rescue Aws::CloudFormation::Errors::ValidationError => e
158
210
  raise Cfer::Util::StackDoesNotExistError, e.message
159
211
  end
160
212
  end
161
213
 
214
+ def fetch_summary(stack_name = @name)
215
+ begin
216
+ stack_cache(stack_name)[:summary] ||= get_template_summary(stack_name: stack_name)
217
+ rescue Aws::CloudFormation::Errors::ValidationError => e
218
+ raise Cfer::Util::StackDoesNotExistError, e.message
219
+ end
220
+ end
221
+
222
+ def fetch_metadata(stack_name = @name)
223
+ md = fetch_summary(stack_name).metadata
224
+ stack_cache(stack_name)[:metadata] ||=
225
+ if md
226
+ JSON.parse(md)
227
+ else
228
+ {}
229
+ end
230
+ end
231
+
232
+ def remove(stack_name, options = {})
233
+ delete_stack(stack_name)
234
+ end
235
+
236
+ def fetch_cfer_version(stack_name = @name)
237
+ previous_version = Semantic::Version.new('0.0.0')
238
+ if previous_version_hash = fetch_metadata(stack_name).fetch('Cfer', {}).fetch('Version', nil)
239
+ previous_version_hash.each { |k, v| previous_version.send(k + '=', v) }
240
+ previous_version
241
+ end
242
+ end
243
+
244
+ def fetch_git_hash(stack_name = @name)
245
+ fetch_metadata(stack_name).fetch('Cfer', {}).fetch('Git', {}).fetch('Rev', nil)
246
+ end
247
+
162
248
  def fetch_parameters(stack_name = @name)
163
- @stack_parameters[stack_name] ||= cfn_list_to_hash('parameter', fetch_stack(stack_name)[:parameters])
249
+ stack_cache(stack_name)[:parameters] ||= cfn_list_to_hash('parameter', fetch_stack(stack_name)[:parameters])
164
250
  end
165
251
 
166
252
  def fetch_outputs(stack_name = @name)
167
- @stack_outputs[stack_name] ||= cfn_list_to_hash('output', fetch_stack(stack_name)[:outputs])
253
+ stack_cache(stack_name)[:outputs] ||= cfn_list_to_hash('output', fetch_stack(stack_name)[:outputs])
168
254
  end
169
255
 
170
256
  def fetch_output(stack_name, output_name)
@@ -181,22 +267,37 @@ module Cfer::Cfn
181
267
 
182
268
  private
183
269
 
270
+ def upload_or_return_template(cfn_hash, options = {})
271
+ if cfn_hash.bytesize <= 51200 && !options[:force_s3]
272
+ { template_body: cfn_hash }
273
+ else
274
+ raise Cfer::Util::CferError, 'Cfer needs to upload the template to S3, but no bucket was specified.' unless options[:s3_path]
275
+
276
+ uri = URI(options[:s3_path])
277
+ template = Aws::S3::Object.new bucket_name: uri.host, key: uri.path.reverse.chomp('/').reverse
278
+ template.put body: cfn_hash
279
+
280
+ template_url = template.public_url
281
+ template_url = template_url + '?versionId=' + template.version_id if template.version_id
282
+
283
+ { template_url: template_url }
284
+ end
285
+ end
286
+
184
287
  def cfn_list_to_hash(attribute, list)
288
+ return {} unless list
289
+
185
290
  key = :"#{attribute}_key"
186
291
  value = :"#{attribute}_value"
187
292
 
188
- Hash[ *list.map { |kv| [ kv[key].to_s, kv[value].to_s ] }.flatten ]
293
+ HashWithIndifferentAccess[ *list.map { |kv| [ kv[key].to_s, kv[value].to_s ] }.flatten ]
189
294
  end
190
295
 
191
296
  def flush_cache
192
297
  Cfer::LOGGER.debug "*********** FLUSH CACHE ***************"
193
298
  Cfer::LOGGER.debug "Stack cache: #{@stack_cache}"
194
- Cfer::LOGGER.debug "Stack parameters: #{@stack_parameters}"
195
- Cfer::LOGGER.debug "Stack outputs: #{@stack_outputs}"
196
299
  Cfer::LOGGER.debug "***************************************"
197
300
  @stack_cache = {}
198
- @stack_parameters = {}
199
- @stack_outputs = {}
200
301
  end
201
302
 
202
303
  def for_each_event(stack_name)
@@ -224,6 +325,8 @@ module Cfer::Cfn
224
325
  Cfer::LOGGER.debug "Using #{name} from: #{value}"
225
326
  if value.nil?
226
327
  {}
328
+ elsif value.is_a?(Hash)
329
+ {"#{name}_body".to_sym => value.to_json}
227
330
  elsif value.match(/\A#{URI::regexp(%w[http https s3])}\z/) # looks like a URL
228
331
  {"#{name}_url".to_sym => value}
229
332
  elsif File.exist?(value) # looks like a file to read
@@ -4,6 +4,7 @@ require 'table_print'
4
4
 
5
5
  module Cfer
6
6
  class Cli < Thor
7
+ map '-v' => :version, '--version' => :version
7
8
 
8
9
  namespace 'cfer'
9
10
  class_option :verbose, type: :boolean, default: false
@@ -12,11 +13,16 @@ module Cfer
12
13
  class_option :pretty_print, type: :boolean, default: :true, desc: 'Render JSON in a more human-friendly format'
13
14
 
14
15
  def self.template_options
15
-
16
16
  method_option :parameters,
17
17
  type: :hash,
18
18
  desc: 'The CloudFormation parameters to pass to the stack',
19
19
  default: {}
20
+ method_option :parameter_file,
21
+ type: :string,
22
+ desc: 'A YAML or JSON file with CloudFormation parameters to pass to the stack'
23
+ method_option :parameter_environment,
24
+ type: :string,
25
+ desc: 'If parameter_file is set, will merge the subkey of this into the parameter list.'
20
26
  end
21
27
 
22
28
  def self.stack_options
@@ -59,6 +65,20 @@ module Cfer
59
65
  method_option :timeout,
60
66
  type: :numeric,
61
67
  desc: 'The timeout (in minutes) before the stack operation aborts'
68
+ method_option :s3_path,
69
+ type: :string,
70
+ desc: 'Specifies an S3 path in case the stack is created with a URL.'
71
+ method_option :force_s3,
72
+ type: :boolean,
73
+ default: false,
74
+ desc: 'Forces Cfer to upload the template to S3 and pass CloudFormation a URL.'
75
+ method_option :change,
76
+ type: :string,
77
+ desc: 'Issues updates as a Cfn change set.'
78
+ method_option :change_description,
79
+ type: :string,
80
+ desc: 'The description of this Cfn change'
81
+
62
82
  template_options
63
83
  stack_options
64
84
  def converge(stack_name)
@@ -71,6 +91,12 @@ module Cfer
71
91
  Cfer.describe! stack_name, options
72
92
  end
73
93
 
94
+ desc 'delete <stack>', 'Deletes a CloudFormation stack'
95
+ stack_options
96
+ def delete(stack_name)
97
+ Cfer.delete! stack_name, options
98
+ end
99
+
74
100
  desc 'tail <stack>', 'Follows stack events on standard output as they occur'
75
101
  method_option :follow,
76
102
  aliases: :f,
@@ -96,6 +122,14 @@ module Cfer
96
122
  Cfer.generate! tmpl, options
97
123
  end
98
124
 
125
+ desc 'estimate [OPTIONS] <template.rb>', 'Prints a link to the Amazon cost caculator estimating the cost of the resulting CloudFormation stack'
126
+ long_desc <<-LONGDESC
127
+ LONGDESC
128
+ template_options
129
+ def estimate(tmpl)
130
+ Cfer.estimate! tmpl, options
131
+ end
132
+
99
133
  def self.main(args)
100
134
  Cfer::LOGGER.debug "Cfer version #{Cfer::VERSION}"
101
135
  begin
@@ -128,6 +162,11 @@ module Cfer
128
162
  end
129
163
  end
130
164
 
165
+ desc 'version', 'Prints the current version of Cfer'
166
+ def version
167
+ puts Cfer::VERSION
168
+ end
169
+
131
170
  private
132
171
 
133
172
  def cfn(opts = {})
@@ -1,5 +1,16 @@
1
+ require 'git'
2
+
1
3
  module Cfer::Core
2
4
  class Client
5
+ attr_reader :git
6
+
7
+ def initialize(options)
8
+ path = options[:working_directory] || '.'
9
+ if File.exist?("#{path}/.git")
10
+ @git = Git.open(path) rescue nil
11
+ end
12
+ end
13
+
3
14
  def converge
4
15
  raise Cfer::Util::CferError, 'converge not implemented on this client'
5
16
  end
@@ -1,6 +1,6 @@
1
- module Cfer::Cfn
2
- class Resource < Cfer::Block
3
- NON_PROXIED_METHODS = [:parameters, :options, :lookup_output]
1
+ module Cfer::Core
2
+ class Resource < Cfer::BlockHash
3
+ @@types = {}
4
4
 
5
5
  def initialize(name, type, **options, &block)
6
6
  @name = name
@@ -9,36 +9,34 @@ module Cfer::Cfn
9
9
  self.merge!(options)
10
10
  self[:Properties] = HashWithIndifferentAccess.new
11
11
  build_from_block(&block)
12
+
12
13
  end
13
14
 
15
+
14
16
  def tag(k, v, **options)
15
17
  self[:Properties][:Tags] ||= []
16
18
  self[:Properties][:Tags].unshift({"Key" => k, "Value" => v}.merge(options))
17
19
  end
18
20
 
19
- def properties(**keyvals)
21
+ def properties(keyvals = {})
20
22
  self[:Properties].merge!(keyvals)
21
23
  end
22
24
 
23
- def respond_to?(method_sym)
24
- !NON_PROXIED_METHODS.include?(method_sym)
25
+ def get_property(key)
26
+ puts self[:Properties]
27
+ self[:Properties].fetch key
25
28
  end
26
29
 
27
- def method_missing(method_sym, *arguments, &block)
28
- key = camelize_property(method_sym)
29
- case arguments.size
30
- when 0
31
- Cfer::Core::Fn::ref(method_sym)
32
- when 1
33
- properties key => arguments.first
34
- else
35
- properties key => arguments
30
+ class << self
31
+ def resource_class(type)
32
+ @@types[type] ||= "CferExt::#{type}".split('::').inject(Object) { |o, c| o.const_get c if o && o.const_defined?(c) } || Class.new(Cfer::Core::Resource)
33
+ end
34
+
35
+ def extend_resource(type, &block)
36
+ resource_class(type).instance_eval(&block)
36
37
  end
37
38
  end
38
39
 
39
40
  private
40
- def camelize_property(sym)
41
- sym.to_s.camelize.to_sym
42
- end
43
41
  end
44
42
  end
@@ -13,16 +13,18 @@ module Cfer::Core
13
13
 
14
14
  attr_reader :options
15
15
 
16
- def converge!
17
- if @options[:client]
18
- @options[:client].converge self
19
- end
16
+ attr_reader :git_version
17
+
18
+ def client
19
+ @options[:client] || raise('No client set on this stack')
20
20
  end
21
21
 
22
- def tail!(&block)
23
- if @options[:client]
24
- @options[:client].tail self, &block
25
- end
22
+ def converge!(options = {})
23
+ client.converge self, options
24
+ end
25
+
26
+ def tail!(options = {}, &block)
27
+ client.tail self, options, &block
26
28
  end
27
29
 
28
30
  def initialize(options = {})
@@ -31,12 +33,25 @@ module Cfer::Core
31
33
 
32
34
  @options = options
33
35
 
36
+ self[:Metadata] = {
37
+ :Cfer => {
38
+ :Version => Cfer::SEMANTIC_VERSION.to_h.delete_if { |k, v| v === nil }
39
+ }
40
+ }
41
+
34
42
  self[:Parameters] = {}
35
43
  self[:Mappings] = {}
36
44
  self[:Conditions] = {}
37
45
  self[:Resources] = {}
38
46
  self[:Outputs] = {}
39
47
 
48
+ if options[:client] && git = options[:client].git && @git_version = (git.object('HEAD^').sha rescue nil)
49
+ self[:Metadata][:Cfer][:Git] = {
50
+ Rev: @git_version,
51
+ Clean: git.status.changed.empty?
52
+ }
53
+ end
54
+
40
55
  @parameters = HashWithIndifferentAccess.new
41
56
  @input_parameters = HashWithIndifferentAccess.new
42
57
 
@@ -84,6 +99,8 @@ module Cfer::Core
84
99
  def parameter(name, options = {})
85
100
  param = {}
86
101
  options.each do |key, v|
102
+ next if v === nil
103
+
87
104
  k = key.to_s.camelize.to_sym
88
105
  param[k] =
89
106
  case k
@@ -119,9 +136,7 @@ module Cfer::Core
119
136
  def resource(name, type, options = {}, &block)
120
137
  Preconditions.check_argument(/[[:alnum:]]+/ =~ name, "Resource name must be alphanumeric")
121
138
 
122
- clazz = "CferExt::#{type}".split('::').inject(Object) { |o, c| o.const_get c if o && o.const_defined?(c) } || Cfer::Cfn::Resource
123
- Preconditions.check_argument clazz <= Cfer::Cfn::Resource, "#{type} is not a valid resource type because CferExt::#{type} does not inherit from `Cfer::Cfn::Resource`"
124
-
139
+ clazz = Cfer::Core::Resource.resource_class(type)
125
140
  rc = clazz.new(name, type, options, &block)
126
141
 
127
142
  self[:Resources][name] = rc
@@ -150,10 +165,9 @@ module Cfer::Core
150
165
  # Includes template code from one or more files, and evals it in the context of this stack.
151
166
  # Filenames are relative to the file containing the invocation of this method.
152
167
  def include_template(*files)
153
- calling_file = caller.first.split(/:\d/,2).first
154
- dirname = File.dirname(calling_file)
168
+ include_base = options[:include_base] || File.dirname(caller.first.split(/:\d/,2).first)
155
169
  files.each do |file|
156
- path = File.join(dirname, file)
170
+ path = File.join(include_base, file)
157
171
  instance_eval(File.read(path), path)
158
172
  end
159
173
  end
@@ -162,6 +176,63 @@ module Cfer::Core
162
176
  client = @options[:client] || raise(Cfer::Util::CferError, "Can not fetch stack outputs without a client")
163
177
  client.fetch_output(stack, out)
164
178
  end
179
+
180
+ def lookup_outputs(stack)
181
+ client = @options[:client] || raise(Cfer::Util::CferError, "Can not fetch stack outputs without a client")
182
+ client.fetch_outputs(stack)
183
+ end
184
+
185
+ private
186
+
187
+ def post_block
188
+ begin
189
+ validate_stack!(self)
190
+ rescue Cfer::Util::CferValidationError => e
191
+ Cfer::LOGGER.error "Cfer detected #{e.errors.size > 1 ? 'errors' : 'an error'} when generating the stack:"
192
+ e.errors.each do |err|
193
+ Cfer::LOGGER.error "* #{err[:error]} in Stack#{validation_contextualize(err[:context])}"
194
+ end
195
+ raise e
196
+ end
197
+ end
198
+
199
+ def validate_stack!(hash)
200
+ errors = []
201
+ context = []
202
+ _inner_validate_stack!(hash, errors, context)
203
+
204
+ raise Cfer::Util::CferValidationError, errors unless errors.empty?
205
+ end
206
+
207
+ def _inner_validate_stack!(hash, errors = [], context = [])
208
+ case hash
209
+ when Hash
210
+ hash.each do |k, v|
211
+ _inner_validate_stack!(v, errors, context + [k])
212
+ end
213
+ when Array
214
+ hash.each_index do |i|
215
+ _inner_validate_stack!(hash[i], errors, context + [i])
216
+ end
217
+ when nil
218
+ errors << {
219
+ error: "CloudFormation does not allow nulls in templates",
220
+ context: context
221
+ }
222
+ end
223
+ end
224
+
225
+ def validation_contextualize(err_ctx)
226
+ err_ctx.inject("") do |err_str, ctx|
227
+ err_str <<
228
+ case ctx
229
+ when String
230
+ ".#{ctx}"
231
+ when Numeric
232
+ "[#{ctx}]"
233
+ end
234
+ end
235
+ end
165
236
  end
166
237
 
167
238
  end