From d61039ca60c0f8c3d179236f0d43c38aa6cfea00 Mon Sep 17 00:00:00 2001 From: Orien Madgwick <497874+orien@users.noreply.github.com> Date: Fri, 2 Feb 2024 10:41:53 +0700 Subject: [PATCH] Provide `user_data_file` helper method for YAML ERB templates --- CHANGELOG.md | 4 + lib/stack_master.rb | 1 + .../cloudformation_template_eruby.rb | 32 +++++ .../template_compilers/yaml_erb.rb | 3 +- .../cloudformation_template_eruby_spec.rb | 124 ++++++++++++++++++ 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 lib/stack_master/cloudformation_template_eruby.rb create mode 100644 spec/stack_master/cloudformation_template_eruby_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9089c7b9..b69ab6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,12 @@ The format is based on [Keep a Changelog], and this project adheres to - Test on Ruby 3.3 in the CI build ([#376]). +- Introduce `user_data_file`, `user_data_file_as_lines`, and `include_file` + convenience methods to the YAML ERB template compiler ([#377]). + [Unreleased]: https://github.com/envato/stack_master/compare/v2.13.4...HEAD [#376]: https://github.com/envato/stack_master/pull/376 +[#377]: https://github.com/envato/stack_master/pull/377 ## [2.13.4] - 2023-08-02 diff --git a/lib/stack_master.rb b/lib/stack_master.rb index ae753c52..bdc16996 100644 --- a/lib/stack_master.rb +++ b/lib/stack_master.rb @@ -46,6 +46,7 @@ module StackMaster autoload :TemplateCompiler, 'stack_master/template_compiler' autoload :Identity, 'stack_master/identity' autoload :CloudFormationInterpolatingEruby, 'stack_master/cloudformation_interpolating_eruby' + autoload :CloudFormationTemplateEruby, 'stack_master/cloudformation_template_eruby' autoload :StackDiffer, 'stack_master/stack_differ' autoload :Validator, 'stack_master/validator' diff --git a/lib/stack_master/cloudformation_template_eruby.rb b/lib/stack_master/cloudformation_template_eruby.rb new file mode 100644 index 00000000..50e51161 --- /dev/null +++ b/lib/stack_master/cloudformation_template_eruby.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'erubis' +require 'json' + +module StackMaster + # This class is a modified version of `Erubis::Eruby`. It provides extra + # helper methods to ease the dynamic creation of CloudFormation templates + # with ERB. These helper methods are available within `<%= %>` expressions. + class CloudFormationTemplateEruby < Erubis::Eruby + # Adds the contents of an EC2 userdata script to the CloudFormation + # template. Allows using the ERB `<%= %>` expressions within the user data + # script to interpolate CloudFormation values. + def user_data_file(filepath) + JSON.pretty_generate({ 'Fn::Base64' => { 'Fn::Join' => ['', user_data_file_as_lines(filepath)] } }) + end + + # Evaluate the ERB template at the specified filepath and return the result + # as an array of lines. Allows using ERB `<%= %>` expressions to interpolate + # CloudFormation objects into the result. + def user_data_file_as_lines(filepath) + StackMaster::CloudFormationInterpolatingEruby.evaluate_file(filepath, self) + end + + # Add the contents of another file into the CloudFormation template as a + # string. ERB `<%= %>` expressions within the referenced file are not + # evaluated. + def include_file(filepath) + JSON.pretty_generate(File.read(filepath)) + end + end +end diff --git a/lib/stack_master/template_compilers/yaml_erb.rb b/lib/stack_master/template_compilers/yaml_erb.rb index 79c0df14..57bed748 100644 --- a/lib/stack_master/template_compilers/yaml_erb.rb +++ b/lib/stack_master/template_compilers/yaml_erb.rb @@ -3,13 +3,12 @@ module StackMaster::TemplateCompilers class YamlErb def self.require_dependencies - require 'erubis' require 'yaml' end def self.compile(template_dir, template, compile_time_parameters, _compiler_options = {}) template_file_path = File.join(template_dir, template) - template = Erubis::Eruby.new(File.read(template_file_path)) + template = StackMaster::CloudFormationTemplateEruby.new(File.read(template_file_path)) template.filename = template_file_path template.result(params: compile_time_parameters) diff --git a/spec/stack_master/cloudformation_template_eruby_spec.rb b/spec/stack_master/cloudformation_template_eruby_spec.rb new file mode 100644 index 00000000..86ac349a --- /dev/null +++ b/spec/stack_master/cloudformation_template_eruby_spec.rb @@ -0,0 +1,124 @@ +RSpec.describe(StackMaster::CloudFormationTemplateEruby) do + subject(:evaluate) do + eruby = described_class.new(template) + eruby.evaluate(eruby) + end + + describe('.user_data_file') do + context('given a template that loads a simple user data script file') do + let(:template) { <<~YAML} + Resources: + LaunchConfig: + Type: 'AWS::AutoScaling::LaunchConfiguration' + Properties: + UserData: <%= user_data_file('my/userdata.sh') %> + YAML + + before do + allow(File).to receive(:read).with('my/userdata.sh').and_return(<<~SHELL) + #!/bin/bash + + REGION=ap-southeast-2 + echo $REGION + SHELL + end + + it 'embeds the script in the evaluated CFN template' do + expect(evaluate).to eq(<<~YAML) + Resources: + LaunchConfig: + Type: 'AWS::AutoScaling::LaunchConfiguration' + Properties: + UserData: { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\\n", + "\\n", + "REGION=ap-southeast-2\\n", + "echo $REGION\\n" + ] + ] + } + } + YAML + end + end + + context('given a template that loads a user data script file that includes another file') do + let(:template) { <<~YAML} + Resources: + LaunchConfig: + Type: 'AWS::AutoScaling::LaunchConfiguration' + Properties: + UserData: <%= user_data_file('my/userdata.sh') %> + YAML + + before do + allow(File).to receive(:read).with('my/userdata.sh').and_return(<<~SHELL) + #!/bin/bash + echo 'Hello from userdata.sh' + <%= user_data_file_as_lines('my/other.sh') %> + SHELL + allow(File).to receive(:read).with('my/other.sh').and_return(<<~SHELL) + echo 'Hello from other.sh' + SHELL + end + + it 'embeds the script in the evaluated CFN template' do + expect(evaluate).to eq(<<~YAML) + Resources: + LaunchConfig: + Type: 'AWS::AutoScaling::LaunchConfiguration' + Properties: + UserData: { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\\n", + "echo 'Hello from userdata.sh'\\n", + "echo 'Hello from other.sh'\\n", + "\\n" + ] + ] + } + } + YAML + end + end + end + + describe('.include_file') do + context('given a template that loads a lambda script') do + let(:template) { <<~YAML} + Resources: + Function: + Type: 'AWS::Lambda::Function' + Properties: + Code: + ZipFile: <%= include_file('my/lambda.sh') %> + YAML + + before do + allow(File).to receive(:read).with('my/lambda.sh').and_return(<<~SHELL) + #!/bin/bash + + echo 'Hello, world!' + SHELL + end + + it 'embeds the script in the evaluated CFN template' do + expect(evaluate).to eq(<<~YAML) + Resources: + Function: + Type: 'AWS::Lambda::Function' + Properties: + Code: + ZipFile: "#!/bin/bash\\n\\necho 'Hello, world!'\\n" + YAML + end + end + end +end