regentanz 0.3.3 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +268 -0
  3. data/bin/regentanz +16 -0
  4. data/lib/regentanz.rb +10 -11
  5. data/lib/regentanz/cli/common.rb +35 -0
  6. data/lib/regentanz/cli/compare.rb +85 -0
  7. data/lib/regentanz/cli/compile.rb +27 -0
  8. data/lib/regentanz/template_compiler.rb +263 -0
  9. data/lib/regentanz/version.rb +1 -2
  10. data/lib/regentanz/yaml-ext.rb +18 -0
  11. data/spec/regentanz/resources/test/unloaded.rb +11 -0
  12. data/spec/regentanz/template_compiler_spec.rb +692 -0
  13. data/spec/spec_helper.rb +2 -0
  14. metadata +45 -152
  15. data/.gitignore +0 -5
  16. data/.rvmrc +0 -4
  17. data/CHANGELOG.rdoc +0 -26
  18. data/Gemfile +0 -4
  19. data/LICENSE +0 -24
  20. data/README.rdoc +0 -54
  21. data/Rakefile +0 -23
  22. data/lib/regentanz/astronomy.rb +0 -69
  23. data/lib/regentanz/cache.rb +0 -2
  24. data/lib/regentanz/cache/base.rb +0 -51
  25. data/lib/regentanz/cache/file.rb +0 -86
  26. data/lib/regentanz/callbacks.rb +0 -18
  27. data/lib/regentanz/conditions.rb +0 -3
  28. data/lib/regentanz/conditions/base.rb +0 -16
  29. data/lib/regentanz/conditions/current.rb +0 -14
  30. data/lib/regentanz/conditions/forecast.rb +0 -14
  31. data/lib/regentanz/configuration.rb +0 -55
  32. data/lib/regentanz/configurator.rb +0 -22
  33. data/lib/regentanz/google_weather.rb +0 -151
  34. data/lib/regentanz/parser.rb +0 -1
  35. data/lib/regentanz/parser/google_weather.rb +0 -100
  36. data/lib/regentanz/test_helper.rb +0 -52
  37. data/regentanz.gemspec +0 -30
  38. data/test/factories.rb +0 -6
  39. data/test/support/tmp/.gitignore +0 -1
  40. data/test/support/valid_response.xml.erb +0 -26
  41. data/test/test_helper.rb +0 -14
  42. data/test/unit/astronomy_test.rb +0 -26
  43. data/test/unit/cache/base_test.rb +0 -53
  44. data/test/unit/cache/file_test.rb +0 -141
  45. data/test/unit/callbacks_test.rb +0 -27
  46. data/test/unit/configuration_test.rb +0 -57
  47. data/test/unit/current_condition_test.rb +0 -33
  48. data/test/unit/forecast_condition_test.rb +0 -35
  49. data/test/unit/google_weather_test.rb +0 -125
  50. data/test/unit/parser/google_weather_parser_test.rb +0 -71
@@ -0,0 +1,263 @@
1
+ module Regentanz
2
+ class TemplateCompiler
3
+ ParseError = Class.new(Regentanz::Error)
4
+ ValidationError = Class.new(Regentanz::Error)
5
+ AmbiguityError = Class.new(Regentanz::Error)
6
+ CredentialsError = Class.new(Regentanz::Error)
7
+ TemplateError = Class.new(Regentanz::Error)
8
+
9
+ def initialize(config, cloud_formation_client: nil, s3_client: nil)
10
+ @resource_compilers = {}
11
+ @region = config['default_region']
12
+ @template_url = config['template_url']
13
+ @cf_client = cloud_formation_client || Aws::CloudFormation::Client.new(region: @region)
14
+ @s3_client = s3_client || Aws::S3::Resource.new(region: @region)
15
+ rescue Aws::Sigv4::Errors::MissingCredentialsError => e
16
+ raise CredentialsError, 'Validation requires AWS credentials'
17
+ end
18
+
19
+ def compile_from_path(stack_path)
20
+ resources = []
21
+ options = {}
22
+ Dir.chdir(stack_path) do
23
+ options[:parameters] = load_top_level_file('parameters')
24
+ options[:mappings] = load_top_level_file('mappings')
25
+ options[:conditions] = load_top_level_file('conditions')
26
+ options[:outputs] = load_top_level_file('outputs')
27
+ resources = load_resources
28
+ compile_template(resources, options)
29
+ end
30
+ end
31
+
32
+ def compile_template(resources, options = {})
33
+ template = {'AWSTemplateFormatVersion' => '2010-09-09'}
34
+ compiled = compile_resources(resources)
35
+ template['Resources'] = compiled.delete(:resources)
36
+ options = compiled.merge(options) { |_, v1, v2| v1.merge(v2 || {}) }
37
+ if (parameters = options[:parameters]) && !parameters.empty?
38
+ parameters, metadata = compile_parameters(parameters)
39
+ template['Parameters'] = parameters
40
+ template['Metadata'] = {'AWS::CloudFormation::Interface' => metadata}
41
+ end
42
+ template['Mappings'] = expand_refs(options[:mappings]) if options[:mappings]
43
+ template['Conditions'] = expand_refs(options[:conditions]) if options[:conditions]
44
+ template['Outputs'] = expand_refs(options[:outputs]) if options[:outputs]
45
+ validate_parameter_use(template)
46
+ template
47
+ end
48
+
49
+ def validate_template(stack_path, template)
50
+ if template.bytesize > 460800
51
+ raise TemplateError, "Compiled template is too large: #{template.bytesize} bytes > 460800"
52
+ elsif template.bytesize >= 51200
53
+ template_url = upload_template(stack_path, template)
54
+ @cf_client.validate_template(template_url: template_url)
55
+ else
56
+ @cf_client.validate_template(template_body: template)
57
+ end
58
+ rescue Aws::CloudFormation::Errors::ValidationError => e
59
+ raise ValidationError, "Invalid template: #{e.message}", e.backtrace
60
+ end
61
+
62
+ private
63
+
64
+ PSEUDO_PARAMETERS = [
65
+ 'AWS::AccountId',
66
+ 'AWS::NotificationARNs',
67
+ 'AWS::NoValue',
68
+ 'AWS::Region',
69
+ 'AWS::StackId',
70
+ 'AWS::StackName',
71
+ ].map!(&:freeze).freeze
72
+
73
+ def upload_template(stack_path, template)
74
+ if @template_url
75
+ if (captures = @template_url.match(%r{\As3://(?<bucket>[^/]+)/(?<key>.+)\z}))
76
+ bucket = captures[:bucket]
77
+ bucket = bucket.gsub('${AWS_REGION}', @region)
78
+ key = captures[:key]
79
+ key = key.gsub('${TEMPLATE_NAME}', File.basename(stack_path))
80
+ key = key.gsub('${TIMESTAMP}', Time.now.to_i.to_s)
81
+ obj = @s3_client.bucket(bucket).object(key)
82
+ obj.put(body: template)
83
+ obj.public_url
84
+ else
85
+ raise ValidationError, format('Malformed template URL: %p', @template_url)
86
+ end
87
+ else
88
+ raise ValidationError, 'Unable to validate template: it is larger than 51200 bytes and no template URL has been configured'
89
+ end
90
+ end
91
+
92
+ def validate_parameter_use(template)
93
+ available = {}
94
+ template.fetch('Parameters', {}).each_key { |key| available[key] = true }
95
+ unused = available.dup
96
+ PSEUDO_PARAMETERS.each { |key| available[key] = true }
97
+ template.fetch('Resources', {}).each_key { |key| available[key] = true }
98
+ undefined = {}
99
+ each_ref(template) do |key|
100
+ unused.delete(key)
101
+ undefined[key] = true unless available[key]
102
+ end
103
+ unless unused.empty?
104
+ raise ValidationError, "Unused parameters: #{unused.keys.join(', ')}"
105
+ end
106
+ unless undefined.empty?
107
+ raise ValidationError, "Undefined parameters: #{undefined.keys.join(', ')}"
108
+ end
109
+ end
110
+
111
+ def load_top_level_file(name)
112
+ matches = Dir["#{name}.{json,yml,yaml}"]
113
+ case matches.size
114
+ when 1
115
+ load(matches.first)
116
+ when 0
117
+ nil
118
+ else
119
+ sprintf('Found multiple files when looking for %s: %s', name, matches.join(', '))
120
+ raise AmbiguityError, message
121
+ end
122
+ end
123
+
124
+ def load_resources
125
+ Dir['resources/**/*.{json,yml,yaml}'].sort.each_with_object({}) do |path, acc|
126
+ relative_path = path.sub(/^resources\//, '')
127
+ acc[relative_path] = load(path)
128
+ end
129
+ end
130
+
131
+ def load(path)
132
+ YAML.load_file(path)
133
+ rescue Psych::SyntaxError => e
134
+ raise ParseError, sprintf('Invalid template fragment: %s', e.message), e.backtrace
135
+ end
136
+
137
+ def compile_resources(resources)
138
+ compiled = {resources: {}}
139
+ resources.map do |relative_path, resource|
140
+ name = relative_path_to_name(relative_path)
141
+ if (type = resource['Type']).start_with?('Regentanz::Resources::')
142
+ expanded_template = resource_compiler(type).compile(name, resource)
143
+ expanded_template[:resources] = expand_refs(expanded_template[:resources])
144
+ compiled.merge!(expanded_template) { |_, v1, v2| v1.merge(v2) }
145
+ else
146
+ compiled[:resources][name] = expand_refs(resource)
147
+ end
148
+ end
149
+ compiled
150
+ end
151
+
152
+ def resource_compiler(type)
153
+ @resource_compilers[type] ||= begin
154
+ begin
155
+ create_instance(type)
156
+ rescue NameError
157
+ begin
158
+ load_resource_compiler(type)
159
+ create_instance(type)
160
+ rescue LoadError, NameError
161
+ raise Regentanz::Error, "No resource compiler for #{type}"
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ def create_instance(type)
168
+ type.split('::').reduce(Object, &:const_get).new
169
+ end
170
+
171
+ def load_resource_compiler(type)
172
+ require(type.gsub('::', '/').gsub(/\B[A-Z]/, '_\&').downcase)
173
+ end
174
+
175
+ def compile_parameters(specifications)
176
+ groups = []
177
+ parameters = {}
178
+ specifications.each do |name, options|
179
+ if options['Type'] == 'Regentanz::ParameterGroup'
180
+ group_parameters = options['Parameters']
181
+ parameters.merge!(group_parameters)
182
+ groups << {
183
+ 'Label' => {'default' => name},
184
+ 'Parameters' => group_parameters.keys
185
+ }
186
+ else
187
+ parameters[name] = options
188
+ end
189
+ end
190
+ labels = parameters.each_with_object({}) do |(name, options), labels|
191
+ if (label = options.delete('Label'))
192
+ labels[name] = {'default' => label}
193
+ end
194
+ end
195
+ metadata = {'ParameterGroups' => groups, 'ParameterLabels' => labels}
196
+ return parameters, metadata
197
+ end
198
+
199
+ def relative_path_to_name(relative_path)
200
+ name = relative_path.dup
201
+ name.sub!(/\.([^.]+)$/, '')
202
+ name.gsub!('/', '_')
203
+ name.gsub!(/_.|^./) { |str| str[-1].upcase }
204
+ name
205
+ end
206
+
207
+ def each_ref(resource, &block)
208
+ case resource
209
+ when Hash
210
+ if (ref = resource['Ref'])
211
+ yield ref
212
+ elsif (substitution = resource['Fn::Sub'])
213
+ case substitution
214
+ when Array
215
+ each_ref(substitution, &block)
216
+ else
217
+ substitution.scan(/\$\{([^}]+)\}/) do |matches|
218
+ block.call(matches[0])
219
+ end
220
+ end
221
+ else
222
+ resource.each_value { |v| each_ref(v, &block) }
223
+ end
224
+ when Array
225
+ resource.each { |v| each_ref(v, &block) }
226
+ end
227
+ end
228
+
229
+ def expand_refs(resource)
230
+ case resource
231
+ when Hash
232
+ if (reference = resource['ResolveRef'])
233
+ expanded_name = relative_path_to_name(reference)
234
+ expanded_resource = resource.merge('Ref' => expanded_name)
235
+ expanded_resource.delete('ResolveRef')
236
+ expanded_resource
237
+ elsif (reference = resource['ResolveName'])
238
+ relative_path_to_name(reference)
239
+ elsif (reference = resource['Regentanz::ReadFile'])
240
+ read_file(reference)
241
+ else
242
+ resource.merge(resource) do |_, v, _|
243
+ expand_refs(v)
244
+ end
245
+ end
246
+ when Array
247
+ resource.map do |v|
248
+ expand_refs(v)
249
+ end
250
+ else
251
+ resource
252
+ end
253
+ end
254
+
255
+ def read_file(filename)
256
+ if File.exists?(filename)
257
+ File.read(filename)
258
+ else
259
+ raise ParseError, "File #{filename} does not exist"
260
+ end
261
+ end
262
+ end
263
+ end
@@ -1,4 +1,3 @@
1
1
  module Regentanz
2
- VERSION = "0.3.3"
2
+ VERSION = '1.0.0'
3
3
  end
4
-
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ require 'yaml'
3
+
4
+ module YAML
5
+ add_domain_type('regentanz', 'GetAtt') do |_, value|
6
+ {'Fn::GetAtt' => value.to_s.split('.')}
7
+ end
8
+ %w[Ref ResolveRef].each do |name|
9
+ add_domain_type('regentanz', name) do |tag, value|
10
+ {name => value}
11
+ end
12
+ end
13
+ %w[FindInMap GetAZs ImportValue Join Select Split Sub].each do |name|
14
+ add_domain_type('regentanz', name) do |tag, value|
15
+ {['Fn', name].join('::') => value}
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,11 @@
1
+ module Regentanz
2
+ module Resources
3
+ module Test
4
+ class Unloaded
5
+ def compile(name, resource)
6
+ {:resources => {name => {'Type' => 'AWS::S3::Bucket'}}}
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,692 @@
1
+ require 'tmpdir'
2
+
3
+ module Regentanz
4
+ module Resources
5
+ module Test
6
+ class SecurityGroupPair
7
+ def compile(name, resource)
8
+ {
9
+ :resources => {
10
+ "#{name}1" => resource.merge('Type' => 'AWS::EC2::SecurityGroup'),
11
+ "#{name}2" => resource.merge('Type' => 'AWS::EC2::SecurityGroup'),
12
+ }
13
+ }
14
+ end
15
+ end
16
+
17
+ class BucketAndPolicy
18
+ def compile(name, resource)
19
+ result = {:parameters => {}, :resources => {}, :mappings => {}, :conditions => {}}
20
+ result[:parameters]['BucketName'] = {'Type' => 'String'}
21
+ result[:resources][name + 'Bucket'] = {'Type' => 'AWS::S3::Bucket', 'Properties' => {'BucketName' => {'Ref' => 'BucketName'}}}
22
+ result[:resources][name + 'Policy'] = {'Type' => 'AWS::IAM::Policy', 'Properties' => {'PolicyName' => 'SomePolicy'}}
23
+ result[:mappings]['Prefix'] = {'production' => 'prod-', 'test' => 'test-'}
24
+ result[:conditions]['IsEu'] = {'Fn::Equals' => [{'Ref' => 'AWS::Region'}, 'eu-west-1']}
25
+ result
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ describe TemplateCompiler do
32
+ let :compiler do
33
+ described_class.new(config, cloud_formation_client: cf_client, s3_client: s3_client)
34
+ end
35
+
36
+ let :config do
37
+ {
38
+ 'default_region' => 'ap-southeast-1',
39
+ }
40
+ end
41
+
42
+ let :cf_client do
43
+ double(:cf_client)
44
+ end
45
+
46
+ let :s3_client do
47
+ double(:s3)
48
+ end
49
+
50
+ before do
51
+ allow(cf_client).to receive(:validate_template)
52
+ end
53
+
54
+ describe '#compile_template' do
55
+ let :resources do
56
+ {
57
+ 'core/ec2_instance.json' => {
58
+ 'Type' => 'AWS::EC2::Instance',
59
+ 'Properties' => {
60
+ 'ImageId' => {'Fn::FindInMap' => ['Ami', 'amzn-ami-2016.03.1', 'hvm']},
61
+ }
62
+ },
63
+ 'core/asg.json' => {
64
+ 'Type' => 'AWS::AutoScaling::AutoScalingGroup',
65
+ 'Properties' => {
66
+ 'MinSize' => {'Ref' => 'MinInstances'},
67
+ 'MaxSize' => {'Ref' => 'MaxInstances'},
68
+ }
69
+ }
70
+ }
71
+ end
72
+
73
+ let :parameters do
74
+ {
75
+ 'MinInstances' => {
76
+ 'Type' => 'Number',
77
+ 'Default' => 1,
78
+ },
79
+ 'MaxInstances' => {
80
+ 'Type' => 'Number',
81
+ 'Default' => 1,
82
+ },
83
+ 'Environment' => {
84
+ 'Type' => 'String',
85
+ },
86
+ 'Volume' => {
87
+ 'Type' => 'AWS::EC2::Volume',
88
+ },
89
+ }
90
+ end
91
+
92
+ let :mappings do
93
+ {
94
+ 'Version' => {
95
+ 'Mongo' => {
96
+ 'production' => '2.6.0',
97
+ 'staging' => '3.4.0',
98
+ },
99
+ },
100
+ }
101
+ end
102
+
103
+ let :conditions do
104
+ {
105
+ "Staging" => {"Fn::Equals" => [{"Ref" => "Environment"}, "staging"]},
106
+ }
107
+ end
108
+
109
+ let :outputs do
110
+ {
111
+ "VolumeId" => {"Value" => { "Ref" => "Volume" }},
112
+ }
113
+ end
114
+
115
+ let :template do
116
+ compiler.compile_template(resources, parameters: parameters, mappings: mappings, conditions: conditions, outputs: outputs)
117
+ end
118
+
119
+ it 'generates an CloudFormation compatible template', aggregate_failures: true do
120
+ expect(template['AWSTemplateFormatVersion']).to eq('2010-09-09')
121
+ expect(template['Mappings']).to eq(mappings)
122
+ expect(template['Conditions']).to eq(conditions)
123
+ expect(template['Outputs']).to eq(outputs)
124
+ expect(template['Resources']).to be_a(Hash)
125
+ expect(template['Resources'].values).to eq(resources.values)
126
+ end
127
+
128
+ it 'names resources from their relative paths' do
129
+ expect(template['Resources'].keys).to eq(%w[CoreEc2Instance CoreAsg])
130
+ end
131
+
132
+ it 'resolves references in resource definitions' do
133
+ resources['extra'] = {
134
+ 'Type' => 'AWS::EC2::VolumeAttachment',
135
+ 'Properties' => {
136
+ 'InstanceId' => { 'ResolveRef' => 'core/ec2_instance' },
137
+ 'VolumeId' => { 'Ref' => 'Volume' },
138
+ 'Device' => '/dev/sdh',
139
+ }
140
+ }
141
+ expect(template['Resources']['Extra']).to eq(
142
+ 'Type' => 'AWS::EC2::VolumeAttachment',
143
+ 'Properties' => {
144
+ 'InstanceId' => { 'Ref' => 'CoreEc2Instance' },
145
+ 'VolumeId' => { 'Ref' => 'Volume' },
146
+ 'Device' => '/dev/sdh',
147
+ }
148
+ )
149
+ end
150
+
151
+ it 'resolves reference names in resource definitions' do
152
+ resources['extra'] = {
153
+ 'Type' => 'AWS::EC2::Instance',
154
+ 'Properties' => {
155
+ 'AvailabilityZone' => {'Fn::GetAtt' => [{'ResolveName' => 'core/ec2_instance'}]},
156
+ }
157
+ }
158
+ expect(template['Resources']['Extra']).to eq(
159
+ 'Type' => 'AWS::EC2::Instance',
160
+ 'Properties' => {
161
+ 'AvailabilityZone' => {'Fn::GetAtt' => ['CoreEc2Instance']},
162
+ }
163
+ )
164
+ end
165
+
166
+ it 'reads external files in resource definitions' do
167
+ Dir.mktmpdir do |dir|
168
+ Dir.chdir(dir) do
169
+ File.open('code.py', 'w') do |file|
170
+ file.puts 'Some code 1'
171
+ file.puts 'Some code 2'
172
+ end
173
+
174
+ resources['extra'] = {
175
+ 'Type' => 'AWS::Lambda::Function',
176
+ 'Properties' => {
177
+ 'Code' => {
178
+ 'ZipFile' => {'Regentanz::ReadFile' => 'code.py'}
179
+ }
180
+ }
181
+ }
182
+
183
+ template = compiler.compile_template(resources, parameters: parameters, mappings: mappings, conditions: conditions, outputs: outputs, stack_path: dir)
184
+
185
+ expect(template['Resources']['Extra']).to eq(
186
+ 'Type' => 'AWS::Lambda::Function',
187
+ 'Properties' => {
188
+ 'Code' => {
189
+ 'ZipFile' => "Some code 1\nSome code 2\n"
190
+ }
191
+ }
192
+ )
193
+ end
194
+ end
195
+ end
196
+
197
+ context 'with parameter labels' do
198
+ it 'removes the Label key from parameters' do
199
+ parameters['MinInstances']['Label'] = 'The X'
200
+ expect(template['Parameters']['MinInstances']).not_to include('Label')
201
+ end
202
+
203
+ it 'adds parameter labels to the interface metadata' do
204
+ parameters['MinInstances']['Label'] = 'The minimum number of instances'
205
+ expect(template['Metadata']['AWS::CloudFormation::Interface']['ParameterLabels']).to eq('MinInstances' => {'default' => 'The minimum number of instances'})
206
+ end
207
+ end
208
+
209
+ context 'with Regentanz::ParameterGroup' do
210
+ let :parameters do
211
+ super().merge(
212
+ 'Group' => {
213
+ 'Type' => 'Regentanz::ParameterGroup',
214
+ 'Parameters' => {
215
+ 'Nested' => {
216
+ 'Type' => 'AWS::EC2::Instance',
217
+ 'Properties' => {
218
+ 'AvailabilityZone' => {'Fn::GetAtt' => ['CoreEc2Instance']},
219
+ }
220
+ },
221
+ }
222
+ }
223
+ )
224
+ end
225
+
226
+ let :resources do
227
+ r = super()
228
+ r['core/asg.json']['Properties']['AvailabilityZones'] = {'Ref' => 'Nested'}
229
+ r
230
+ end
231
+
232
+ it 'removes the group parameters from the output' do
233
+ expect(template['Parameters']).not_to include('Group')
234
+ end
235
+
236
+ it 'lifts the grouped parameters to the top level' do
237
+ expect(template['Parameters'].keys).to include('Nested')
238
+ end
239
+
240
+ it 'adds parameter groups to the interface metadata' do
241
+ expect(template['Metadata']['AWS::CloudFormation::Interface']['ParameterGroups']).to eq([{'Label' => {'default' => 'Group'}, 'Parameters' => ['Nested']}])
242
+ end
243
+ end
244
+
245
+ context 'with a custom resource type' do
246
+ let :resources do
247
+ super().merge(
248
+ 'core/test.json' => {
249
+ 'Type' => 'Regentanz::Resources::Test::BucketAndPolicy',
250
+ 'Properties' => {
251
+ 'Name' => {'Ref' => 'CoreTestBucketName'},
252
+ }
253
+ },
254
+ 'core/lc.json' => {
255
+ 'Type' => 'AWS::AutoScaling::LaunchConfiguration',
256
+ 'Properties' => {
257
+ 'SecurityGroups' => [{'Ref' => 'AWS::NoValue'}]
258
+ }
259
+ },
260
+ )
261
+ end
262
+
263
+ it 'does not add the uncompiled resource' do
264
+ expect(template['Resources']).not_to include('CoreTest')
265
+ end
266
+
267
+ it 'adds resources from the compiled resource' do
268
+ expect(template['Resources']).to include('CoreTestBucket', 'CoreTestPolicy')
269
+ end
270
+
271
+ it 'keeps existing resources' do
272
+ expect(template['Resources']).to include('CoreEc2Instance')
273
+ end
274
+
275
+ it 'adds parameters from the compiled resource' do
276
+ expect(template['Parameters']).to include('BucketName')
277
+ end
278
+
279
+ it 'keeps parameters from outside the resource' do
280
+ expect(template['Parameters']).to include('MinInstances')
281
+ end
282
+
283
+ it 'adds mappings from the compiled resource' do
284
+ expect(template['Mappings']).to include('Prefix')
285
+ end
286
+
287
+ it 'keeps parameters from outside the resource' do
288
+ expect(template['Mappings']).to include('Version')
289
+ end
290
+
291
+ it 'adds conditions from the compiled resource' do
292
+ expect(template['Conditions']).to include('IsEu')
293
+ end
294
+
295
+ it 'keeps conditions from outside the resource' do
296
+ expect(template['Conditions']).to include('Staging')
297
+ end
298
+
299
+ context 'that contains a reference to another resource' do
300
+ let :resources do
301
+ r = super().merge(
302
+ 'core/sg.json' => {
303
+ 'Type' => 'Regentanz::Resources::Test::SecurityGroupPair',
304
+ 'Properties' => {
305
+ 'GroupDescription' => {'Fn::Join' => [' ', 'SG for ', {'ResolveName' => 'core/vpc'}]},
306
+ 'VpcId' => {'ResolveRef' => 'core/vpc'},
307
+ }
308
+ },
309
+ 'core/vpc.json' => {
310
+ 'Type' => 'AWS::EC2::VPC',
311
+ 'Properties' => {
312
+ 'CidrBlock' => '10.0.0.0/8',
313
+ }
314
+ },
315
+ 'core/lc.json' => {
316
+ 'Type' => 'AWS::AutoScaling::LaunchConfiguration',
317
+ 'Properties' => {
318
+ 'SecurityGroups' => [{'Ref' => 'AWS::NoValue'}]
319
+ }
320
+ },
321
+ )
322
+ end
323
+
324
+ it 'resolves references in the resources returned by the custom resource' do
325
+ aggregate_failures do
326
+ expect(template['Resources']['CoreSg1']['Properties']['VpcId']).to eq({'Ref' => 'CoreVpc'})
327
+ expect(template['Resources']['CoreSg1']['Properties']['GroupDescription']).to eq({'Fn::Join' => [' ', 'SG for ', 'CoreVpc']})
328
+ end
329
+ end
330
+
331
+ it 'does not mutate the input template' do
332
+ aggregate_failures do
333
+ expect(template['Resources']['CoreSg2']['Properties']['VpcId']).to eq({'Ref' => 'CoreVpc'})
334
+ expect(template['Resources']['CoreSg2']['Properties']['GroupDescription']).to eq({'Fn::Join' => [' ', 'SG for ', 'CoreVpc']})
335
+ end
336
+ end
337
+ end
338
+
339
+ context 'with nil valued options' do
340
+ let :parameters do
341
+ nil
342
+ end
343
+
344
+ let :conditions do
345
+ nil
346
+ end
347
+
348
+ let :mappings do
349
+ nil
350
+ end
351
+
352
+ let :outputs do
353
+ nil
354
+ end
355
+
356
+ before do
357
+ resources.delete_if { |k,v| k != 'core/test.json' }
358
+ end
359
+
360
+ it 'includes the values from the compiled resource', aggregate_failures: true do
361
+ expect(template['Resources']).to include('CoreTestBucket', 'CoreTestPolicy')
362
+ expect(template['Parameters']).to include('BucketName')
363
+ expect(template['Conditions']).to include('IsEu')
364
+ expect(template['Mappings']).to include('Prefix')
365
+ expect(template).not_to include('Outputs')
366
+ end
367
+ end
368
+
369
+ context 'when the resource is not loaded' do
370
+ let :resources do
371
+ super().merge(
372
+ 'core/unloaded_resource.json' => {
373
+ 'Type' => 'Regentanz::Resources::Test::Unloaded',
374
+ }
375
+ )
376
+ end
377
+
378
+ it 'converts the resource type to a path and loads it' do
379
+ expect(template['Resources']).to include('CoreUnloadedResource')
380
+ end
381
+ end
382
+ end
383
+
384
+ context 'with an non-existant resource type' do
385
+ let :resources do
386
+ super().merge(
387
+ 'core/test.json' => {
388
+ 'Type' => 'Regentanz::Resources::NonexistantResource',
389
+ 'Properties' => {
390
+ 'Role' => 'core-role',
391
+ }
392
+ }
393
+ )
394
+ end
395
+
396
+ it 'raises an error' do
397
+ expect { template }.to raise_error(Regentanz::Error, 'No resource compiler for Regentanz::Resources::NonexistantResource')
398
+ end
399
+ end
400
+
401
+ context 'when validating that parameters are used' do
402
+ let :parameters do
403
+ super().merge(
404
+ 'Foo' => {'Type' => 'String'},
405
+ 'Bar' => {'Type' => 'String'},
406
+ 'Baz' => {'Type' => 'String'},
407
+ )
408
+ end
409
+
410
+ context 'and there are unused parameters' do
411
+ it 'raises ValidationError' do
412
+ expect { template }.to raise_error(described_class::ValidationError, 'Unused parameters: Foo, Bar, Baz')
413
+ end
414
+ end
415
+
416
+ context 'and parameters are used in Fn::Sub' do
417
+ let :resources do
418
+ super().merge(
419
+ 'core/test.json' => {
420
+ 'Type' => 'AWS::EC2::Instance',
421
+ 'Properties' => {
422
+ 'SomeProp' => {'Fn::Sub' => 'xyz:${Foo}/bar/${Baz}'},
423
+ 'SomeOtherProp' => {'Fn::Sub' => ['${barbar}!', {'barbar' => {'Ref' => 'Bar'}}]},
424
+ }
425
+ }
426
+ )
427
+ end
428
+
429
+ it 'detects the usage' do
430
+ expect { template }.to_not raise_error
431
+ end
432
+ end
433
+ end
434
+
435
+ context 'when validating that no undefined parameters are used' do
436
+ context 'and parameters are used in Fn::Sub' do
437
+ let :resources do
438
+ super().merge(
439
+ 'core/test.json' => {
440
+ 'Type' => 'AWS::EC2::Instance',
441
+ 'Properties' => {
442
+ 'SomeProp' => {'Fn::Sub' => 'xyz:${Foo}/bar/${Baz}'},
443
+ 'SomeOtherProp' => {'Fn::Sub' => ['${barbar}!', {'barbar' => {'Ref' => 'Bar'}}]},
444
+ }
445
+ }
446
+ )
447
+ end
448
+
449
+ it 'raises ValidationError' do
450
+ expect { template }.to raise_error(described_class::ValidationError, 'Undefined parameters: Foo, Baz, Bar')
451
+ end
452
+ end
453
+
454
+ context 'and referring to other resources' do
455
+ let :resources do
456
+ super().merge(
457
+ 'core/test.json' => {
458
+ 'Type' => 'AWS::EC2::Instance',
459
+ 'Properties' => {
460
+ 'SomeProp' => {'ResolveRef' => 'core/test2'}
461
+ }
462
+ },
463
+ )
464
+ end
465
+
466
+ it 'raises ValidationError' do
467
+ expect { template }.to raise_error(described_class::ValidationError, 'Undefined parameters: CoreTest2')
468
+ end
469
+
470
+ context 'when resource exists' do
471
+ let :resources do
472
+ super().merge(
473
+ 'core/test2.json' => {
474
+ 'Type' => 'AWS::EC2::Instance',
475
+ 'Properties' => {
476
+ }
477
+ },
478
+ )
479
+ end
480
+
481
+ it 'accepts the parameter' do
482
+ expect { template }.not_to raise_error
483
+ end
484
+ end
485
+ end
486
+
487
+ context 'and using built-in pseudo parameters' do
488
+ let :resources do
489
+ super().merge(
490
+ 'core/test.json' => {
491
+ 'Type' => 'AWS::EC2::Instance',
492
+ 'Properties' => {
493
+ 'SomeProp' => {'Ref' => 'AWS::Region'}
494
+ }
495
+ }
496
+ )
497
+ end
498
+
499
+ it 'accepts the parameter' do
500
+ expect { template }.not_to raise_error
501
+ end
502
+ end
503
+ end
504
+ end
505
+
506
+ describe '#compile_from_path' do
507
+ around do |example|
508
+ Dir.mktmpdir do |dir|
509
+ Dir.chdir(dir) do
510
+ Dir.mkdir('template')
511
+ example.call
512
+ end
513
+ end
514
+ end
515
+
516
+ let :template do
517
+ compiler.compile_from_path('template')
518
+ end
519
+
520
+ it 'loads parameters, mappings, conditions, outputs from JSON files', aggregate_failures: true do
521
+ File.write('template/parameters.json', '{"Parameter":{"Type":"Number"}}')
522
+ File.write('template/mappings.json', '{"Mapping":{"A":{"B":"C"}}}')
523
+ File.write('template/conditions.json', '{"Staging":{"Fn:Equals":[{"Ref":"AWS::Region"},{"Ref":"Parameter"}]}}')
524
+ File.write('template/outputs.json', '{"VolumeId":{"Value":{"Ref":"Parameter"}}}')
525
+ expect(template['Parameters'].keys).to eq(%w[Parameter])
526
+ expect(template['Mappings'].keys).to eq(%w[Mapping])
527
+ expect(template['Conditions'].keys).to eq(%w[Staging])
528
+ expect(template['Outputs'].keys).to eq(%w[VolumeId])
529
+ end
530
+
531
+ it 'loads parameters, mappings, conditions, outputs from YAML files', aggregate_failures: true do
532
+ File.write('template/parameters.yaml', 'Parameter: {Type: Number}')
533
+ File.write('template/mappings.yaml', 'Mapping: {A: {B: C}}')
534
+ File.write('template/conditions.yml', 'Staging: {"Fn:Equals": [{Ref: "AWS::Region"}, {Ref: Parameter}]}')
535
+ File.write('template/outputs.yml', 'VolumeId: {Value: {Ref: Parameter}}')
536
+ expect(template['Parameters'].keys).to eq(%w[Parameter])
537
+ expect(template['Mappings'].keys).to eq(%w[Mapping])
538
+ expect(template['Conditions'].keys).to eq(%w[Staging])
539
+ expect(template['Outputs'].keys).to eq(%w[VolumeId])
540
+ end
541
+
542
+ it 'loads resource from JSON files' do
543
+ Dir.mkdir 'template/resources'
544
+ Dir.mkdir 'template/resources/core'
545
+ File.write('template/resources/core/instance.json', '{"Type":"AWS::EC2::Instance"}')
546
+ File.write('template/resources/attachment.json', '{"Type":"AWS::EC2::VolumeAttachment"}')
547
+ expect(template['Resources'].keys.sort).to eq(%w[Attachment CoreInstance])
548
+ end
549
+
550
+ it 'loads resource from YAML files' do
551
+ Dir.mkdir 'template/resources'
552
+ Dir.mkdir 'template/resources/core'
553
+ File.write('template/resources/core/instance.yaml', 'Type: AWS::EC2::Instance')
554
+ File.write('template/resources/attachment.yml', 'Type: AWS::EC2::VolumeAttachment')
555
+ expect(template['Resources'].keys.sort).to eq(%w[Attachment CoreInstance])
556
+ end
557
+
558
+ it 'handles short-form intrinsic functions in YAML' do
559
+ Dir.mkdir 'template/resources'
560
+ Dir.mkdir 'template/resources/core'
561
+ File.write('template/resources/core/instance.json', '{"Type":"AWS::EC2::Instance"}')
562
+ File.write('template/resources/volume.yml', <<-'YAML')
563
+ Type: AWS::EC2::Volume,
564
+ Properties:
565
+ Size: !ImportValue VolumeSize
566
+ AvailabilityZone: !GetAtt Instance.AvailabilityZone
567
+ Tags:
568
+ - Key: MyTag
569
+ Value: !Sub "${Volume}-${Volume}"
570
+ YAML
571
+ File.write('template/resources/attachment.yml', <<-'YAML')
572
+ Type: AWS::EC2::VolumeAttachment
573
+ Properties:
574
+ InstanceId: !ResolveRef core/instance
575
+ VolumeId: !Ref Volume
576
+ Device: !Join [/, ['', dev, sdh]]
577
+ YAML
578
+ expect(template['Resources']['Volume']['Properties']).to eq(
579
+ 'Size' => {'Fn::ImportValue' => 'VolumeSize'},
580
+ 'AvailabilityZone' => {'Fn::GetAtt' => ['Instance', 'AvailabilityZone']},
581
+ 'Tags' => [{'Key' => 'MyTag', 'Value' => {'Fn::Sub' => '${Volume}-${Volume}'}}],
582
+ )
583
+ expect(template['Resources']['Attachment']['Properties']).to eq(
584
+ 'InstanceId' => {'Ref' => 'CoreInstance'},
585
+ 'VolumeId' => {'Ref' => 'Volume'},
586
+ 'Device' => {'Fn::Join' => ['/', ['', 'dev', 'sdh']]},
587
+ )
588
+ end
589
+ end
590
+
591
+ describe '#validate_template' do
592
+ it 'uses the CloudFormation API to validate the template' do
593
+ compiler.validate_template('stack', 'my-template')
594
+ expect(cf_client).to have_received(:validate_template).with(template_body: 'my-template')
595
+ end
596
+
597
+ it 'produces a validation error when template is invalid' do
598
+ allow(cf_client).to receive(:validate_template).and_raise(Aws::CloudFormation::Errors::ValidationError.new(nil, 'boork'))
599
+ expect { compiler.validate_template('stack', 'my-template') }.to raise_error(described_class::ValidationError, 'Invalid template: boork')
600
+ end
601
+
602
+ context 'when the compiled template is larger than 51200 bytes' do
603
+ let :config do
604
+ super().merge('template_url' => 's3://templates/validate-me.json')
605
+ end
606
+
607
+ let :large_template do
608
+ '0' * 51201
609
+ end
610
+
611
+ let :bucket do
612
+ double(:bucket)
613
+ end
614
+
615
+ let :s3_obj do
616
+ double(:s3_obj)
617
+ end
618
+
619
+ before do
620
+ allow(s3_client).to receive(:bucket).with('templates').and_return(bucket)
621
+ allow(bucket).to receive(:object).with('validate-me.json').and_return(s3_obj)
622
+ allow(s3_obj).to receive(:put)
623
+ allow(s3_obj).to receive(:public_url).and_return('https://s3.amazonaws.com/templates/validate-me.json')
624
+ end
625
+
626
+ it 'uploads the template to S3 before validating the template' do
627
+ compiler.validate_template('stack', large_template)
628
+ expect(s3_obj).to have_received(:put).with(body: large_template).ordered
629
+ expect(cf_client).to have_received(:validate_template).with(template_url: 'https://s3.amazonaws.com/templates/validate-me.json').ordered
630
+ end
631
+
632
+ context 'and the template URL contains variables' do
633
+ let :config do
634
+ super().merge('template_url' => 's3://templates-${AWS_REGION}/some/prefix/${TEMPLATE_NAME}/${TEMPLATE_NAME}-${TIMESTAMP}.json')
635
+ end
636
+
637
+ before do
638
+ allow(s3_client).to receive(:bucket).and_return(bucket)
639
+ allow(bucket).to receive(:object).and_return(s3_obj)
640
+ end
641
+
642
+ it 'replaces ${AWS_REGION} in the bucket name with the configured AWS region' do
643
+ compiler.validate_template('stack', large_template)
644
+ expect(s3_client).to have_received(:bucket).with('templates-ap-southeast-1')
645
+ end
646
+
647
+ it 'replaces ${TEMPLATE_NAME} in the key with the directory name of the template' do
648
+ compiler.validate_template('some/path/to/a/template/called/foobar', large_template)
649
+ expect(bucket).to have_received(:object).with(start_with('some/prefix/foobar/foobar-'))
650
+ end
651
+
652
+ it 'replaces ${TIMESTAMP} in the key with current time as a UNIX timestamp' do
653
+ compiler.validate_template('some/path/to/a/template/called/foobar', large_template)
654
+ expect(bucket).to have_received(:object).with(/-\d+\.json$/)
655
+ end
656
+ end
657
+
658
+ context 'and the template URL is malformed' do
659
+ let :config do
660
+ super().merge('template_url' => 's5://templates-${AWS_REGION}/some/prefix/${TEMPLATE_NAME}-${TIMESTAMP}.json')
661
+ end
662
+
663
+ it 'raises an error' do
664
+ expect { compiler.validate_template('stack', large_template) }.to raise_error(described_class::ValidationError, 'Malformed template URL: "s5://templates-${AWS_REGION}/some/prefix/${TEMPLATE_NAME}-${TIMESTAMP}.json"')
665
+ end
666
+ end
667
+
668
+ context 'and no template bucket has been specified' do
669
+ let :config do
670
+ super().tap do |c|
671
+ c.delete('template_url')
672
+ end
673
+ end
674
+
675
+ it 'raises an error' do
676
+ expect { compiler.validate_template('stack', large_template) }.to raise_error(described_class::ValidationError, 'Unable to validate template: it is larger than 51200 bytes and no template URL has been configured')
677
+ end
678
+ end
679
+ end
680
+
681
+ context 'when the compiled template is larger than 460800 bytes' do
682
+ let :large_template do
683
+ '0' * 460801
684
+ end
685
+
686
+ it 'raises a template error' do
687
+ expect { compiler.validate_template('stack', large_template) }.to raise_error(described_class::TemplateError, 'Compiled template is too large: 460801 bytes > 460800')
688
+ end
689
+ end
690
+ end
691
+ end
692
+ end