Skip to content

Commit de715c4

Browse files
authored
Merge pull request #377 from envato/yaml-template-userdata
Introduce `user_data_file` helper method for YAML ERB templates
2 parents feab2eb + 3b5974c commit de715c4

12 files changed

+368
-63
lines changed

CHANGELOG.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,32 @@ The format is based on [Keep a Changelog], and this project adheres to
1010

1111
## [Unreleased]
1212

13+
[Unreleased]: https://github.com/envato/stack_master/compare/v2.13.4...HEAD
14+
15+
## [2.14.0] - 2024-02-05
16+
1317
### Added
1418

19+
- Allow the use of [commander](https://github.com/commander-rb/commander)
20+
major version 5 ([#375]).
21+
1522
- Test on Ruby 3.3 in the CI build ([#376]).
1623

17-
[Unreleased]: https://github.com/envato/stack_master/compare/v2.13.4...HEAD
24+
- Introduce `user_data_file`, `user_data_file_as_lines`, and `include_file`
25+
convenience methods to the YAML ERB template compiler ([#377]).
26+
27+
[2.13.4]: https://github.com/envato/stack_master/compare/v2.13.4...v2.14.0
28+
[#375]: https://github.com/envato/stack_master/pull/375
1829
[#376]: https://github.com/envato/stack_master/pull/376
30+
[#377]: https://github.com/envato/stack_master/pull/377
1931

2032
## [2.13.4] - 2023-08-02
2133

2234
### Fixed
2335

2436
- Resolve SparkleFormation template error caused by `SortedSet` class being removed from the `set` library in Ruby 3 ([#374]).
2537

26-
[2.13.3]: https://github.com/envato/stack_master/compare/v2.13.3...v2.13.4
38+
[2.13.4]: https://github.com/envato/stack_master/compare/v2.13.3...v2.13.4
2739
[#374]: https://github.com/envato/stack_master/pull/374
2840

2941
## [2.13.3] - 2023-02-01

lib/stack_master.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ module StackMaster
4545
autoload :StackDefinition, 'stack_master/stack_definition'
4646
autoload :TemplateCompiler, 'stack_master/template_compiler'
4747
autoload :Identity, 'stack_master/identity'
48+
autoload :CloudFormationInterpolatingEruby, 'stack_master/cloudformation_interpolating_eruby'
49+
autoload :CloudFormationTemplateEruby, 'stack_master/cloudformation_template_eruby'
4850

4951
autoload :StackDiffer, 'stack_master/stack_differ'
5052
autoload :Validator, 'stack_master/validator'
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
require 'erubis'
4+
5+
module StackMaster
6+
# This class is a modified version of `Erubis::Eruby`. It allows using
7+
# `<%= %>` ERB expressions to interpolate values into a source string. We use
8+
# this capability to enrich user data scripts with data and parameters pulled
9+
# from the AWS CloudFormation service. The evaluation produces an array of
10+
# objects ready for use in a CloudFormation `Fn::Join` intrinsic function.
11+
class CloudFormationInterpolatingEruby < Erubis::Eruby
12+
include Erubis::ArrayEnhancer
13+
14+
# Load a template from a file at the specified path and evaluate it.
15+
def self.evaluate_file(source_path, context = Erubis::Context.new)
16+
template_contents = File.read(source_path)
17+
eruby = new(template_contents)
18+
eruby.filename = source_path
19+
eruby.evaluate(context)
20+
end
21+
22+
# @return [Array] The result of evaluating the source: an array of strings
23+
# from the source intermindled with Hash objects from the ERB
24+
# expressions. To be included in a CloudFormation template, this
25+
# value needs to be used in a CloudFormation `Fn::Join` intrinsic
26+
# function.
27+
# @see Erubis::Eruby#evaluate
28+
# @example
29+
# CloudFormationInterpolatingEruby.new("my_variable=<%= { 'Ref' => 'Param1' } %>;").evaluate
30+
# #=> ['my_variable=', { 'Ref' => 'Param1' }, ';']
31+
def evaluate(_context = Erubis::Context.new)
32+
format_lines_for_cloudformation(super)
33+
end
34+
35+
# @see Erubis::Eruby#add_expr
36+
def add_expr(src, code, indicator)
37+
if indicator == '='
38+
src << " #{@bufvar} << (" << code << ');'
39+
else
40+
super
41+
end
42+
end
43+
44+
private
45+
46+
# Split up long strings containing multiple lines. One string per line in the
47+
# CloudFormation array makes the compiled template and diffs more readable.
48+
def format_lines_for_cloudformation(source)
49+
source.flat_map do |lines|
50+
lines = lines.to_s if lines.is_a?(Symbol)
51+
next(lines) unless lines.is_a?(String)
52+
53+
newlines = Array.new(lines.count("\n"), "\n")
54+
newlines = lines.split("\n").map { |line| "#{line}#{newlines.pop}" }
55+
newlines.insert(0, "\n") if lines.start_with?("\n")
56+
newlines
57+
end
58+
end
59+
end
60+
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# frozen_string_literal: true
2+
3+
require 'erubis'
4+
require 'json'
5+
6+
module StackMaster
7+
# This class is a modified version of `Erubis::Eruby`. It provides extra
8+
# helper methods to ease the dynamic creation of CloudFormation templates
9+
# with ERB. These helper methods are available within `<%= %>` expressions.
10+
class CloudFormationTemplateEruby < Erubis::Eruby
11+
# Adds the contents of an EC2 userdata script to the CloudFormation
12+
# template. Allows using the ERB `<%= %>` expressions within the user data
13+
# script to interpolate CloudFormation values.
14+
def user_data_file(filepath)
15+
JSON.pretty_generate({ 'Fn::Base64' => { 'Fn::Join' => ['', user_data_file_as_lines(filepath)] } })
16+
end
17+
18+
# Evaluate the ERB template at the specified filepath and return the result
19+
# as an array of lines. Allows using ERB `<%= %>` expressions to interpolate
20+
# CloudFormation objects into the result.
21+
def user_data_file_as_lines(filepath)
22+
StackMaster::CloudFormationInterpolatingEruby.evaluate_file(filepath, self)
23+
end
24+
25+
# Add the contents of another file into the CloudFormation template as a
26+
# string. ERB `<%= %>` expressions within the referenced file are not
27+
# evaluated.
28+
def include_file(filepath)
29+
JSON.pretty_generate(File.read(filepath))
30+
end
31+
end
32+
end

lib/stack_master/sparkle_formation/template_file.rb

Lines changed: 2 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,6 @@ module StackMaster
55
module SparkleFormation
66
TemplateFileNotFound = ::Class.new(StandardError)
77

8-
class SfEruby < Erubis::Eruby
9-
include Erubis::ArrayEnhancer
10-
11-
def add_expr(src, code, indicator)
12-
case indicator
13-
when '='
14-
src << " #{@bufvar} << (" << code << ');'
15-
else
16-
super
17-
end
18-
end
19-
end
20-
218
class TemplateContext < AttributeStruct
229
include ::SparkleFormation::SparkleAttribute
2310
include ::SparkleFormation::SparkleAttribute::Aws
@@ -49,47 +36,12 @@ def render(file_name, vars = {})
4936
end
5037
end
5138

52-
# Splits up long strings with multiple lines in them to multiple strings
53-
# in the CF array. Makes the compiled template and diffs more readable.
54-
class CloudFormationLineFormatter
55-
def self.format(template)
56-
new(template).format
57-
end
58-
59-
def initialize(template)
60-
@template = template
61-
end
62-
63-
def format
64-
@template.flat_map do |lines|
65-
lines = lines.to_s if Symbol === lines
66-
if String === lines
67-
newlines = []
68-
lines.count("\n").times do
69-
newlines << "\n"
70-
end
71-
newlines = lines.split("\n").map do |line|
72-
"#{line}#{newlines.pop}"
73-
end
74-
if lines.start_with?("\n")
75-
newlines.insert(0, "\n")
76-
end
77-
newlines
78-
else
79-
lines
80-
end
81-
end
82-
end
83-
end
84-
8539
module Template
8640
def self.render(prefix, file_name, vars)
8741
file_path = File.join(::SparkleFormation.sparkle_path, prefix, file_name)
88-
template = File.read(file_path)
8942
template_context = TemplateContext.build(vars, prefix)
90-
compiled_template = SfEruby.new(template).evaluate(template_context)
91-
CloudFormationLineFormatter.format(compiled_template)
92-
rescue Errno::ENOENT => e
43+
CloudFormationInterpolatingEruby.evaluate_file(file_path, template_context)
44+
rescue Errno::ENOENT
9345
Kernel.raise TemplateFileNotFound, "Could not find template file at path: #{file_path}"
9446
end
9547
end

lib/stack_master/template_compilers/yaml_erb.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
module StackMaster::TemplateCompilers
44
class YamlErb
55
def self.require_dependencies
6-
require 'erubis'
76
require 'yaml'
87
end
98

109
def self.compile(template_dir, template, compile_time_parameters, _compiler_options = {})
1110
template_file_path = File.join(template_dir, template)
12-
template = Erubis::Eruby.new(File.read(template_file_path))
11+
template = StackMaster::CloudFormationTemplateEruby.new(File.read(template_file_path))
1312
template.filename = template_file_path
1413

1514
template.result(params: compile_time_parameters)

lib/stack_master/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module StackMaster
2-
VERSION = "2.13.4"
2+
VERSION = "2.14.0"
33
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/bash
2+
3+
echo 'Hello, World!'
4+
REGION=<%= { 'Ref' => 'AWS::Region' } %>
5+
echo $REGION
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Description: A test case for storing the userdata script in a dedicated file
2+
3+
Resources:
4+
LaunchConfig:
5+
Type: 'AWS::AutoScaling::LaunchConfiguration'
6+
Properties:
7+
UserData: <%= user_data_file(File.join(__dir__, 'user_data.sh.erb')) %>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
RSpec.describe(StackMaster::CloudFormationInterpolatingEruby) do
2+
describe('#evaluate') do
3+
subject(:evaluate) { described_class.new(user_data).evaluate }
4+
5+
context('given a simple user data script') do
6+
let(:user_data) { <<~SHELL }
7+
#!/bin/bash
8+
9+
REGION=ap-southeast-2
10+
echo $REGION
11+
SHELL
12+
13+
it 'returns an array of lines' do
14+
expect(evaluate).to eq([
15+
"#!/bin/bash\n",
16+
"\n",
17+
"REGION=ap-southeast-2\n",
18+
"echo $REGION\n",
19+
])
20+
end
21+
end
22+
23+
context('given a user data script referring parameters') do
24+
let(:user_data) { <<~SHELL }
25+
#!/bin/bash
26+
<%= { 'Ref' => 'Param1' } %> <%= { 'Ref' => 'Param2' } %>
27+
SHELL
28+
29+
it 'includes CloudFormation objects in the array' do
30+
expect(evaluate).to eq([
31+
"#!/bin/bash\n",
32+
{ 'Ref' => 'Param1' },
33+
' ',
34+
{ 'Ref' => 'Param2' },
35+
"\n",
36+
])
37+
end
38+
end
39+
end
40+
41+
describe('.evaluate_file') do
42+
subject(:evaluate_file) { described_class.evaluate_file('my/userdata.sh') }
43+
44+
context('given a simple user data script file') do
45+
before { allow(File).to receive(:read).with('my/userdata.sh').and_return(<<~SHELL) }
46+
#!/bin/bash
47+
48+
REGION=ap-southeast-2
49+
echo $REGION
50+
SHELL
51+
52+
it 'returns an array of lines' do
53+
expect(evaluate_file).to eq([
54+
"#!/bin/bash\n",
55+
"\n",
56+
"REGION=ap-southeast-2\n",
57+
"echo $REGION\n",
58+
])
59+
end
60+
end
61+
end
62+
end

0 commit comments

Comments
 (0)