cloudformation/make-dev-stack.rb (122 lines of code) (raw):
#!/usr/bin/env ruby
require 'yaml'
require 'optimist'
require 'awesome_print'
def types_to_filter(exclude_lambdas)
types_to_filter_out = [
"AWS::Elasticsearch::Domain",
"AWS::ElasticLoadBalancing::LoadBalancer",
"AWS::IAM::InstanceProfile",
"AWS::EC2::SecurityGroupIngress",
"AWS::AutoScaling::LaunchConfiguration",
"AWS::AutoScaling::AutoScalingGroup"
]
if exclude_lambdas
types_to_filter_out.concat([
"AWS::EC2::SecurityGroup",
"AWS::Lambda::Permission",
"AWS::IAM::Role",
"AWS::Lambda::Function",
"AWS::Events::Rule"
])
else
types_to_filter_out
end
end
# Filter out any resources that are not needed for the dev stack, based on the Type field
# Internally, this calls `types_to_filter` to get the list of resource types to remove
#
# @param data [Hash] parsed data of the entire Cloudformation document, in a Hash format
# @param exclude_lambdas [Boolean] if true, then also exclude any resources relating to lambda functions
# @return [Hash] an updated copy of the Cloudformation document, with the relevant resources filtered out
def filter_resources(data, exclude_lambdas)
types_to_filter_out = types_to_filter(exclude_lambdas)
data.merge({"Resources"=>data['Resources'].select {|name, entry|
not types_to_filter_out.include?(entry["Type"])
}})
end
# Gets a list of the names of each resource that will be filtered out, based on the Type field
# Internally, this calls `types_to_filter` to get the list of resource types to remove
#
# @param data [Hash] parsed data of the entire Cloudformation document, in a Hash format
# @param exclude_lambdas [Boolean] if truem then also exclude any resources relating to lambda functions (i.e., include their names in the output list)
# @return [Array] a list of the names of each resource that will be filtered out
def get_filtered_resource_names(data, exclude_lambdas)
types_to_filter_out = types_to_filter(exclude_lambdas)
excluded = data["Resources"].select do |name, entry|
types_to_filter_out.include?(entry["Type"])
end
excluded.keys
end
# Remove references in the Outputs to Resources that have been filtered out
#
# @param data [Hash] parsed data of the resource-filtered Cloudformation document, in Hash format
# @param resource_names [Array[String]] list of the resource names that have been filtered out. Call `get_filtered_resource_names` to generate this.
# @return [Hash] an updated copy of the filtered Cloudformation document, with the relevant Outputs filtered out
def remove_filtered_output_refs(data, resource_names)
data.merge({"Outputs"=>data["Outputs"].select {|name, entry|
not resource_names.reduce(false) do |acc,resource_name|
acc || entry['Value'].include?(resource_name)
end
}})
end
# Extract any ${} tokens from a !Sub parameter string and return them as an Array
#
# @param subString [String] body of a !Sub request, i.e. everything from after !Sub to the end of line
# @return Array[String] list of substition token names, i.e. the bit between ${ and }. If none present, then an empty array is returned/
def extract_sub_tokens(subString)
result = subString.scan(/\${([^}]+)}/)
if result
result[0][0]
else
[]
end
end
def extract_string_references(entry)
subStrings = entry.scan(/BangSub\s*(.*)$/)
subRefs = if subStrings.length>0
subStrings[0].map do |s| extract_sub_tokens(s) end
else
[]
end
refStrings = entry.scan(/Ref\s*(.*)$/)
if refStrings and refStrings[0]
subRefs + refStrings[0]
else
subRefs
end
end
#Recursively find all references within the provided data has.
# A "reference" in this context is the parameter to a !Ref or any tokens within a !Sub
#
# @param data [Hash] parsed data of the Resources section of a Cloudformation document, in Hash format
# @param level [Integer] recursion level, defaults to zero. Don't specify when calling.
# @return Array[String] list of references contained within the
def find_references(data, level=0)
data.keys.reduce(Array.new) do |acc, name|
entry = data[name]
if entry.is_a?(Hash)
next_level = find_references(entry, level+1)
if acc && next_level
acc + next_level
else
acc
end
elsif entry.is_a?(String)
acc + extract_string_references(entry)
elsif entry.is_a?(Array)
acc + entry.select { |item| item.is_a?(Hash)}.flat_map {|value| find_references(value, level+1)} + \
entry.select { |item| item.is_a?(String)}.flat_map {|value| extract_string_references(value) }
else
acc
end
end
end
# Removes anything from the Parameters block that is not referenced elsewhere in the (updated) document
#
# @param data [Hash] parsed data of the entire (filtered) Cloudformation document, in Hash format
# @param references [Array[String]] list of everything that _is_ referenced in the Resources section. Any value matching one of these is left in; everything else is filtered out.
# @return [Hash] updated Cloudformation document tree, with the Parameters section modified.
def filter_inputs(data, references)
data.merge({"Parameters"=>data["Parameters"].select {|name, entry|
references.include?(name)
}})
end
# The Ruby YAML module seems to drop the "free tags" that are preceded by a ! character. So, before parsing,
# we convert them into a standard string and after serialization we convert them back with `unescape_exclamations`.
#
# == Parameters:
# string:: a string representation of the entire cloudformation YAML, for processing
#
# == Returns:
# A string with !Sub, !GetAtt and !Fn::* references replaced with BangSub, BangGetAtt and BangFn*
def escape_exclamations(string)
pass = []
pass[0]=string.gsub(/\!Sub/,"BangSub")
pass[1]=pass[0].gsub(/\!GetAtt/,"BangGetAtt")
pass[2]=pass[1].gsub(/\!Fn::([^:]+)/, "BangFn\1")
pass[2].gsub(/\!Ref/,"BangRef")
end
# Convert the BangSub, BangGetAtt etc. references back into ! references post-serialization
#
# == Parameters:
# string:: a string representation of the serialized yaml for processing
#
# == Returns:
# A string with BangSub, BangGetAtt and BangFn* references replaced with !Sub, !GetAtt and !Fn::
def unescape_exclamations(string)
pass = []
pass[0]=string.gsub(/BangSub/,"!Sub")
pass[1]=pass[0].gsub(/BangGetAtt/,"!GetAtt")
pass[2]=pass[1].gsub(/BangFn([^:]+)/, "!Fn::\1")
pass[2].gsub(/BangRef/, "!Ref")
end
### START MAIN
opts = Optimist::options do
opt :input, "Name of the full cloudformation to strip down", :default=>"appstack.yaml"
opt :output, "Name of the file to output the stripped down cloudformation to", :default=>"appstack-dev.yaml"
opt :excludelambdas, "Also exclude lambda functions and associated resources", :type=>:boolean, :default=>false
end
data = File.open(opts.input) do |f|
YAML.load(escape_exclamations(f.read))
end
data_pass = []
data_pass[0] = filter_resources(data,opts.excludelambdas)
data_pass[1] = remove_filtered_output_refs(data_pass[0], get_filtered_resource_names(data, opts.excludelambdas))
references = find_references(data_pass[1]["Resources"])
data_pass[2] = filter_inputs(data_pass[1], references)
File.open(opts.output, "wb") do |f|
f.write(unescape_exclamations(YAML.dump(data_pass[2])))
end
puts "Output written to #{opts.output}"