#!/usr/bin/env ruby # Copyright 2013-2014 Bazaarvoice, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. unless RUBY_VERSION >= '1.9' # Ruby 1.9 preserves order within Hash objects which avoid scrambling the template output. $stderr.puts "This script requires ruby 1.9+. On OS/X use Homebrew to install ruby 1.9:" $stderr.puts " brew install ruby" exit(2) end require 'json' def main(argv) unless (argv & %w(-h --help -?)).empty? $stderr.puts <<"EOF" usage: #{$PROGRAM_NAME} [cloudformation-template.json] ... Converts the specified CloudFormation JSON template or template fragment to Ruby DSL syntax. Reads from stdin or from the specified json files. Note that the input must be valid JSON. Examples: # Convert a JSON CloudFormation template to Ruby DSL syntax #{$PROGRAM_NAME} my-template.json > my-template.rb chmod +x my-template.rb # Convert the JSON fragment in the clipboard to Ruby DSL syntax pbpaste | #{$PROGRAM_NAME} | less EOF exit(2) end if argv.empty? pprint(simplify(JSON.parse($stdin.read))) else argv.each do |filename| pprint(simplify(JSON.parse(File.read(filename)))) end end # The user should make the resulting template executable w/chmod +x end class FnCall attr_reader :name, :arguments, :multiline def initialize(name, arguments, multiline = false) @name = name @arguments = arguments @multiline = multiline end def to_s() @name + "(" + @arguments.join(', ') + ")" end end def pprint(val) case detect_type(val) when :template pprint_cfn_template(val) when :parameter pprint_cfn_section 'parameter', 'TODO', val when :resource pprint_cfn_resource 'TODO', val when :parameters val.each { |k, v| pprint_cfn_section 'parameter', k, v } when :resources val.each { |k, v| pprint_cfn_resource k, v } else pprint_value(val, '') end end # Attempt to figure out what fragment of the template we have. This is imprecise and can't # detect Mappings and Outputs sections reliably, so it doesn't attempt to. def detect_type(val) if val.is_a?(Hash) && val['AWSTemplateFormatVersion'] :template elsif val.is_a?(Hash) && /^(String|Number)$/ =~ val['Type'] :parameter elsif val.is_a?(Hash) && val['Type'] :resource elsif val.is_a?(Hash) && val.values.all? { |v| detect_type(v) == :parameter } :parameters elsif val.is_a?(Hash) && val.values.all? { |v| detect_type(v) == :resource } :resources end end def pprint_cfn_template(tpl) puts "#!/usr/bin/env ruby" puts puts "require 'bundler/setup'" puts "require 'cloudformation-ruby-dsl-addedvars/cfntemplate'" puts "require 'cloudformation-ruby-dsl-addedvars/spotprice'" puts "require 'cloudformation-ruby-dsl-addedvars/table'" puts puts "template do" puts tpl.each do |section, v| case section when 'Parameters' v.each { |name, options| pprint_cfn_section 'parameter', name, options } when 'Mappings' v.each { |name, options| pprint_cfn_section 'mapping', name, options } when 'Resources' v.each { |name, options| pprint_cfn_resource name, options } when 'Conditions' v.each { |name, options| pprint_cfn_section 'condition', name, options } when 'Outputs' v.each { |name, options| pprint_cfn_section 'output', name, options } else print " value #{fmt_key(section)} => " pprint_value v, ' ' puts puts end end puts "end.exec!" end def pprint_cfn_section(section, name, options) print " #{section} #{fmt_string(name)}" indent = ' ' + (' ' * section.length) + ' ' options.each do |k, v| puts "," print indent, fmt_key(k), " => " pprint_value v, indent end puts puts end def pprint_cfn_resource(name, options) print " resource #{fmt_string(name)}" indent = ' ' options.each do |k, v| unless k == 'Properties' print ", #{fmt_key(k)} => #{fmt(v)}" end end if options.key?('Properties') print ", #{fmt_key('Properties')} => " pprint_value options['Properties'], indent end puts puts end def pprint_value(val, indent) # Prefer to print the value on a single line if it's reasonable to do so single_line = is_single_line(val) || is_single_line_hack(val) if single_line && !is_multi_line_hack(val) s = fmt(val) if s.length < 120 || is_single_line_hack(val) print s return end end # Print the value across multiple lines if val.is_a?(Hash) puts "{" val.each do |k, v| print "#{indent} #{fmt_key(k)} => " pprint_value v, indent + ' ' puts "," end print "#{indent}}" elsif val.is_a?(Array) puts "[" val.each do |v| print "#{indent} " pprint_value v, indent + ' ' puts "," end print "#{indent}]" elsif val.is_a?(FnCall) && val.multiline print val.name, "(" args = val.arguments sep = '' sub_indent = indent + ' ' if val.name == 'join' && args.length > 1 pprint_value args[0], indent + ' ' args = args[1..-1] sep = ',' sub_indent = indent + ' ' end unless args.empty? args.each do |v| puts sep print sub_indent pprint_value v, sub_indent sep = ',' end if val.name == 'join' && args.length > 1 print "," end puts print indent end print ")" else print fmt(val) end end def is_single_line(val) if val.is_a?(Hash) is_single_line(val.values) elsif val.is_a?(Array) val.empty? || (val.length == 1 && is_single_line(val[0]) && !val[0].is_a?(Hash)) || val.all? { |v| v.is_a?(String) } else true end end # Emo-specific hacks to force the desired output formatting def is_single_line_hack(val) is_array_of_strings_hack(val) end # Emo-specific hacks to force the desired output formatting def is_multi_line_hack(val) val.is_a?(Hash) && val['email'] end # Emo-specific hacks to force the desired output formatting def is_array_of_strings_hack(val) val.is_a?(Array) && val.all? { |v| v.is_a?(String) } && val.grep(/\s/).empty? && ( val.include?('autoscaling:EC2_INSTANCE_LAUNCH') || val.include?('m1.small') ) end def fmt(val) if val == {} '{}' elsif val == [] '[]' elsif val.is_a?(Hash) '{ ' + (val.map { |k,v| fmt_key(k) + ' => ' + fmt(v) }).join(', ') + ' }' elsif val.is_a?(Array) && is_array_of_strings_hack(val) '%w(' + val.join(' ') + ')' elsif val.is_a?(Array) '[ ' + (val.map { |v| fmt(v) }).join(', ') + ' ]' elsif val.is_a?(FnCall) && val.arguments.empty? val.name elsif val.is_a?(FnCall) val.name + '(' + (val.arguments.map { |v| fmt(v) }).join(', ') + ')' elsif val.is_a?(String) fmt_string(val) elsif val == nil 'null' else val.to_s # number, boolean end end def fmt_key(s) ':' + (/^[a-zA-Z_]\w+$/ =~ s ? s : fmt_string(s)) # returns a symbol like :Foo or :'us-east-1' end def fmt_string(s) if /[^ -~]/ =~ s s.dump # contains, non-ascii or control char, return double-quoted string else '\'' + s.gsub(/([\\'])/, '\\\\\1') + '\'' # return single-quoted string, escape \ and ' end end def many(val) if val.is_a?(Array) val else [ val ] end end def simplify(val) if val.is_a?(Hash) val = Hash[val.map { |k,v| [k, simplify(v)] }] if val.length != 1 val else k, v = val.entries[0] # CloudFormation functions case when k == 'Fn::Base64' FnCall.new 'base64', [v], true when k == 'Fn::FindInMap' FnCall.new 'find_in_map', v when k == 'Fn::GetAtt' FnCall.new 'get_att', v when k == 'Fn::GetAZs' FnCall.new 'get_azs', v != '' ? [v] : [] when k == 'Fn::Join' FnCall.new 'join', [v[0]] + many(v[1]), true when k == 'Fn::Select' FnCall.new 'select', v when k == 'Ref' && v == 'AWS::AccountId' FnCall.new 'aws_account_id', [] when k == 'Ref' && v == 'AWS::NotificationARNs' FnCall.new 'aws_notification_arns', [] when k == 'Ref' && v == 'AWS::NoValue' FnCall.new 'aws_no_value', [] when k == 'Ref' && v == 'AWS::Region' FnCall.new 'aws_region', [] when k == 'Ref' && v == 'AWS::StackId' FnCall.new 'aws_stack_id', [] when k == 'Ref' && v == 'AWS::StackName' FnCall.new 'aws_stack_name', [] when k == 'Ref' FnCall.new 'ref', [v] else val end end elsif val.is_a?(Array) val.map { |v| simplify(v) } else val end end main(ARGV)