cfer 0.3.0 → 0.4.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.
@@ -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