class Regentanz::TemplateCompiler

Constants

AmbiguityError
CredentialsError
PSEUDO_PARAMETERS
ParseError
TemplateError
ValidationError

Public Class Methods

new(config, cloud_formation_client: nil, s3_client: nil) click to toggle source
# File lib/regentanz/template_compiler.rb, line 9
def initialize(config, cloud_formation_client: nil, s3_client: nil)
  @resource_compilers = {}
  @region = config['default_region']
  @template_url = config['template_url']
  @cf_client = cloud_formation_client || Aws::CloudFormation::Client.new(region: @region)
  @s3_client = s3_client || Aws::S3::Resource.new(region: @region)
rescue Aws::Sigv4::Errors::MissingCredentialsError => e
  raise CredentialsError, 'Validation requires AWS credentials'
end

Public Instance Methods

compile_from_path(stack_path) click to toggle source
# File lib/regentanz/template_compiler.rb, line 19
def compile_from_path(stack_path)
  resources = []
  options = {}
  Dir.chdir(stack_path) do
    options[:parameters] = load_top_level_file('parameters')
    options[:mappings] = load_top_level_file('mappings')
    options[:conditions] = load_top_level_file('conditions')
    options[:outputs] = load_top_level_file('outputs')
    resources = load_resources
    compile_template(resources, options)
  end
end
compile_template(resources, options = {}) click to toggle source
# File lib/regentanz/template_compiler.rb, line 32
def compile_template(resources, options = {})
  template = {'AWSTemplateFormatVersion' => '2010-09-09'}
  compiled = compile_resources(resources)
  template['Resources'] = compiled.delete(:resources)
  options = compiled.merge(options) { |_, v1, v2| v1.merge(v2 || {}) }
  if (parameters = options[:parameters]) && !parameters.empty?
    parameters, metadata = compile_parameters(parameters)
    template['Parameters'] = parameters
    template['Metadata'] = {'AWS::CloudFormation::Interface' => metadata}
  end
  template['Mappings'] = expand_refs(options[:mappings]) if options[:mappings]
  template['Conditions'] = expand_refs(options[:conditions]) if options[:conditions]
  template['Outputs'] = expand_refs(options[:outputs]) if options[:outputs]
  validate_parameter_use(template)
  template
end
validate_template(stack_path, template) click to toggle source
# File lib/regentanz/template_compiler.rb, line 49
def validate_template(stack_path, template)
  if template.bytesize > 460800
    raise TemplateError, "Compiled template is too large: #{template.bytesize} bytes > 460800"
  elsif template.bytesize >= 51200
    template_url = upload_template(stack_path, template)
    @cf_client.validate_template(template_url: template_url)
  else
    @cf_client.validate_template(template_body: template)
  end
rescue Aws::CloudFormation::Errors::ValidationError => e
  raise ValidationError, "Invalid template: #{e.message}", e.backtrace
end

Private Instance Methods

compile_parameters(specifications) click to toggle source
# File lib/regentanz/template_compiler.rb, line 175
def compile_parameters(specifications)
  groups = []
  parameters = {}
  specifications.each do |name, options|
    if options['Type'] == 'Regentanz::ParameterGroup'
      group_parameters = options['Parameters']
      parameters.merge!(group_parameters)
      groups << {
        'Label' => {'default' => name},
        'Parameters' => group_parameters.keys
      }
    else
      parameters[name] = options
    end
  end
  labels = parameters.each_with_object({}) do |(name, options), labels|
    if (label = options.delete('Label'))
      labels[name] = {'default' => label}
    end
  end
  metadata = {'ParameterGroups' => groups, 'ParameterLabels' => labels}
  return parameters, metadata
end
compile_resources(resources) click to toggle source
# File lib/regentanz/template_compiler.rb, line 137
def compile_resources(resources)
  compiled = {resources: {}}
  resources.map do |relative_path, resource|
    name = relative_path_to_name(relative_path)
    if (type = resource['Type']).start_with?('Regentanz::Resources::')
      expanded_template = resource_compiler(type).compile(name, resource)
      expanded_template[:resources] = expand_refs(expanded_template[:resources])
      compiled.merge!(expanded_template) { |_, v1, v2| v1.merge(v2) }
    else
      compiled[:resources][name] = expand_refs(resource)
    end
  end
  compiled
end
create_instance(type) click to toggle source
# File lib/regentanz/template_compiler.rb, line 167
def create_instance(type)
  type.split('::').reduce(Object, &:const_get).new
end
each_ref(resource) { |ref| ... } click to toggle source
# File lib/regentanz/template_compiler.rb, line 207
def each_ref(resource, &block)
  case resource
  when Hash
    if (ref = resource['Ref'])
      yield ref
    elsif (substitution = resource['Fn::Sub'])
      case substitution
      when Array
        each_ref(substitution, &block)
      else
        substitution.scan(/\$\{([^}]+)\}/) do |matches|
          block.call(matches[0])
        end
      end
    else
      resource.each_value { |v| each_ref(v, &block) }
    end
  when Array
    resource.each { |v| each_ref(v, &block) }
  end
end
expand_refs(resource) click to toggle source
# File lib/regentanz/template_compiler.rb, line 229
def expand_refs(resource)
  case resource
  when Hash
    if (reference = resource['ResolveRef'])
      expanded_name = relative_path_to_name(reference)
      expanded_resource = resource.merge('Ref' => expanded_name)
      expanded_resource.delete('ResolveRef')
      expanded_resource
    elsif (reference = resource['ResolveName'])
      relative_path_to_name(reference)
    elsif (reference = resource['Regentanz::ReadFile'])
      read_file(reference)
    else
      resource.merge(resource) do |_, v, _|
        expand_refs(v)
      end
    end
  when Array
    resource.map do |v|
      expand_refs(v)
    end
  else
    resource
  end
end
load(path) click to toggle source
# File lib/regentanz/template_compiler.rb, line 131
def load(path)
  YAML.load_file(path)
rescue Psych::SyntaxError => e
  raise ParseError, sprintf('Invalid template fragment: %s', e.message), e.backtrace
end
load_resource_compiler(type) click to toggle source
# File lib/regentanz/template_compiler.rb, line 171
def load_resource_compiler(type)
  require(type.gsub('::', '/').gsub(/\B[A-Z]/, '_\&').downcase)
end
load_resources() click to toggle source
# File lib/regentanz/template_compiler.rb, line 124
def load_resources
  Dir['resources/**/*.{json,yml,yaml}'].sort.each_with_object({}) do |path, acc|
    relative_path = path.sub(/^resources\//, '')
    acc[relative_path] = load(path)
  end
end
load_top_level_file(name) click to toggle source
# File lib/regentanz/template_compiler.rb, line 111
def load_top_level_file(name)
  matches = Dir["#{name}.{json,yml,yaml}"]
  case matches.size
  when 1
    load(matches.first)
  when 0
    nil
  else
    sprintf('Found multiple files when looking for %s: %s', name, matches.join(', '))
    raise AmbiguityError, message
  end
end
read_file(filename) click to toggle source
# File lib/regentanz/template_compiler.rb, line 255
def read_file(filename)
  if File.exists?(filename)
    File.read(filename)
  else
    raise ParseError, "File #{filename} does not exist"
  end
end
relative_path_to_name(relative_path) click to toggle source
# File lib/regentanz/template_compiler.rb, line 199
def relative_path_to_name(relative_path)
  name = relative_path.dup
  name.sub!(/\.([^.]+)$/, '')
  name.gsub!('/', '_')
  name.gsub!(/_.|^./) { |str| str[-1].upcase }
  name
end
resource_compiler(type) click to toggle source
# File lib/regentanz/template_compiler.rb, line 152
def resource_compiler(type)
  @resource_compilers[type] ||= begin
    begin
      create_instance(type)
    rescue NameError
      begin
        load_resource_compiler(type)
        create_instance(type)
      rescue LoadError, NameError
        raise Regentanz::Error, "No resource compiler for #{type}"
      end
    end
  end
end
upload_template(stack_path, template) click to toggle source
# File lib/regentanz/template_compiler.rb, line 73
def upload_template(stack_path, template)
  if @template_url
    if (captures = @template_url.match(%r{\As3://(?<bucket>[^/]+)/(?<key>.+)\z}))
      bucket = captures[:bucket]
      bucket = bucket.gsub('${AWS_REGION}', @region)
      key = captures[:key]
      key = key.gsub('${TEMPLATE_NAME}', File.basename(stack_path))
      key = key.gsub('${TIMESTAMP}', Time.now.to_i.to_s)
      obj = @s3_client.bucket(bucket).object(key)
      obj.put(body: template)
      obj.public_url
    else
      raise ValidationError, format('Malformed template URL: %p', @template_url)
    end
  else
    raise ValidationError, 'Unable to validate template: it is larger than 51200 bytes and no template URL has been configured'
  end
end
validate_parameter_use(template) click to toggle source
# File lib/regentanz/template_compiler.rb, line 92
def validate_parameter_use(template)
  available = {}
  template.fetch('Parameters', {}).each_key { |key| available[key] = true }
  unused = available.dup
  PSEUDO_PARAMETERS.each { |key| available[key] = true }
  template.fetch('Resources', {}).each_key { |key| available[key] = true }
  undefined = {}
  each_ref(template) do |key|
    unused.delete(key)
    undefined[key] = true unless available[key]
  end
  unless unused.empty?
    raise ValidationError, "Unused parameters: #{unused.keys.join(', ')}"
  end
  unless undefined.empty?
    raise ValidationError, "Undefined parameters: #{undefined.keys.join(', ')}"
  end
end