regentanz 0.3.3 → 1.0.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.
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