From 968b91442f9e8ef01da437af67d83ecd56c57625 Mon Sep 17 00:00:00 2001 From: Orien Madgwick <497874+orien@users.noreply.github.com> Date: Fri, 2 Feb 2024 10:40:57 +0700 Subject: [PATCH] Extract CloudFormationInterpolatingEruby class --- lib/stack_master.rb | 1 + .../cloudformation_interpolating_eruby.rb | 60 ++++++++++++++++++ .../sparkle_formation/template_file.rb | 52 +--------------- ...cloudformation_interpolating_eruby_spec.rb | 62 +++++++++++++++++++ 4 files changed, 125 insertions(+), 50 deletions(-) create mode 100644 lib/stack_master/cloudformation_interpolating_eruby.rb create mode 100644 spec/stack_master/cloudformation_interpolating_eruby_spec.rb diff --git a/lib/stack_master.rb b/lib/stack_master.rb index f52904e5..ae753c52 100644 --- a/lib/stack_master.rb +++ b/lib/stack_master.rb @@ -45,6 +45,7 @@ module StackMaster autoload :StackDefinition, 'stack_master/stack_definition' autoload :TemplateCompiler, 'stack_master/template_compiler' autoload :Identity, 'stack_master/identity' + autoload :CloudFormationInterpolatingEruby, 'stack_master/cloudformation_interpolating_eruby' autoload :StackDiffer, 'stack_master/stack_differ' autoload :Validator, 'stack_master/validator' diff --git a/lib/stack_master/cloudformation_interpolating_eruby.rb b/lib/stack_master/cloudformation_interpolating_eruby.rb new file mode 100644 index 00000000..852e7363 --- /dev/null +++ b/lib/stack_master/cloudformation_interpolating_eruby.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'erubis' + +module StackMaster + # This class is a modified version of `Erubis::Eruby`. It allows using + # `<%= %>` ERB expressions to interpolate values into a source string. We use + # this capability to enrich user data scripts with data and parameters pulled + # from the AWS CloudFormation service. The evaluation produces an array of + # objects ready for use in a CloudFormation `Fn::Join` intrinsic function. + class CloudFormationInterpolatingEruby < Erubis::Eruby + include Erubis::ArrayEnhancer + + # Load a template from a file at the specified path and evaluate it. + def self.evaluate_file(source_path, context = Erubis::Context.new) + template_contents = File.read(source_path) + eruby = new(template_contents) + eruby.filename = source_path + eruby.evaluate(context) + end + + # @return [Array] The result of evaluating the source: an array of strings + # from the source intermindled with Hash objects from the ERB + # expressions. To be included in a CloudFormation template, this + # value needs to be used in a CloudFormation `Fn::Join` intrinsic + # function. + # @see Erubis::Eruby#evaluate + # @example + # CloudFormationInterpolatingEruby.new("my_variable=<%= { 'Ref' => 'Param1' } %>;").evaluate + # #=> ['my_variable=', { 'Ref' => 'Param1' }, ';'] + def evaluate(_context = Erubis::Context.new) + format_lines_for_cloudformation(super) + end + + # @see Erubis::Eruby#add_expr + def add_expr(src, code, indicator) + if indicator == '=' + src << " #{@bufvar} << (" << code << ');' + else + super + end + end + + private + + # Split up long strings containing multiple lines. One string per line in the + # CloudFormation array makes the compiled template and diffs more readable. + def format_lines_for_cloudformation(source) + source.flat_map do |lines| + lines = lines.to_s if lines.is_a?(Symbol) + next(lines) unless lines.is_a?(String) + + newlines = Array.new(lines.count("\n"), "\n") + newlines = lines.split("\n").map { |line| "#{line}#{newlines.pop}" } + newlines.insert(0, "\n") if lines.start_with?("\n") + newlines + end + end + end +end diff --git a/lib/stack_master/sparkle_formation/template_file.rb b/lib/stack_master/sparkle_formation/template_file.rb index 1ca35cb7..f3ff7853 100644 --- a/lib/stack_master/sparkle_formation/template_file.rb +++ b/lib/stack_master/sparkle_formation/template_file.rb @@ -5,19 +5,6 @@ module StackMaster module SparkleFormation TemplateFileNotFound = ::Class.new(StandardError) - class SfEruby < Erubis::Eruby - include Erubis::ArrayEnhancer - - def add_expr(src, code, indicator) - case indicator - when '=' - src << " #{@bufvar} << (" << code << ');' - else - super - end - end - end - class TemplateContext < AttributeStruct include ::SparkleFormation::SparkleAttribute include ::SparkleFormation::SparkleAttribute::Aws @@ -49,47 +36,12 @@ def render(file_name, vars = {}) end end - # Splits up long strings with multiple lines in them to multiple strings - # in the CF array. Makes the compiled template and diffs more readable. - class CloudFormationLineFormatter - def self.format(template) - new(template).format - end - - def initialize(template) - @template = template - end - - def format - @template.flat_map do |lines| - lines = lines.to_s if Symbol === lines - if String === lines - newlines = [] - lines.count("\n").times do - newlines << "\n" - end - newlines = lines.split("\n").map do |line| - "#{line}#{newlines.pop}" - end - if lines.start_with?("\n") - newlines.insert(0, "\n") - end - newlines - else - lines - end - end - end - end - module Template def self.render(prefix, file_name, vars) file_path = File.join(::SparkleFormation.sparkle_path, prefix, file_name) - template = File.read(file_path) template_context = TemplateContext.build(vars, prefix) - compiled_template = SfEruby.new(template).evaluate(template_context) - CloudFormationLineFormatter.format(compiled_template) - rescue Errno::ENOENT => e + CloudFormationInterpolatingEruby.evaluate_file(file_path, template_context) + rescue Errno::ENOENT Kernel.raise TemplateFileNotFound, "Could not find template file at path: #{file_path}" end end diff --git a/spec/stack_master/cloudformation_interpolating_eruby_spec.rb b/spec/stack_master/cloudformation_interpolating_eruby_spec.rb new file mode 100644 index 00000000..a73028f7 --- /dev/null +++ b/spec/stack_master/cloudformation_interpolating_eruby_spec.rb @@ -0,0 +1,62 @@ +RSpec.describe(StackMaster::CloudFormationInterpolatingEruby) do + describe('#evaluate') do + subject(:evaluate) { described_class.new(user_data).evaluate } + + context('given a simple user data script') do + let(:user_data) { <<~SHELL } + #!/bin/bash + + REGION=ap-southeast-2 + echo $REGION + SHELL + + it 'returns an array of lines' do + expect(evaluate).to eq([ + "#!/bin/bash\n", + "\n", + "REGION=ap-southeast-2\n", + "echo $REGION\n", + ]) + end + end + + context('given a user data script referring parameters') do + let(:user_data) { <<~SHELL } + #!/bin/bash + <%= { 'Ref' => 'Param1' } %> <%= { 'Ref' => 'Param2' } %> + SHELL + + it 'includes CloudFormation objects in the array' do + expect(evaluate).to eq([ + "#!/bin/bash\n", + { 'Ref' => 'Param1' }, + ' ', + { 'Ref' => 'Param2' }, + "\n", + ]) + end + end + end + + describe('.evaluate_file') do + subject(:evaluate_file) { described_class.evaluate_file('my/userdata.sh') } + + context('given a simple user data script file') do + before { allow(File).to receive(:read).with('my/userdata.sh').and_return(<<~SHELL) } + #!/bin/bash + + REGION=ap-southeast-2 + echo $REGION + SHELL + + it 'returns an array of lines' do + expect(evaluate_file).to eq([ + "#!/bin/bash\n", + "\n", + "REGION=ap-southeast-2\n", + "echo $REGION\n", + ]) + end + end + end +end