fog/fog-openstack

View on GitHub
lib/fog/openstack/orchestration/util/recursive_hot_file_loader.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require 'set'
require 'yaml'
require 'open-uri'
require 'objspace'
require 'fog/core'

module Fog
  module OpenStack
    module OrchestrationUtil
      #
      # Resolve get_file resources found in a HOT template populating
      #  a files Hash conforming to Heat Specs
      #  https://developer.openstack.org/api-ref/orchestration/v1/index.html?expanded=create-stack-detail#stacks
      #
      # Files present in :files are not processed further. The others
      #   are added to the Hash. This behavior is the same implemented in openstack-infra/shade
      #   see https://github.com/openstack-infra/shade/blob/1d16f64fbf376a956cafed1b3edd8e51ccc16f2c/shade/openstackcloud.py#L1200
      #
      # This implementation just process nested templates but not resource
      #  registries.
      class RecursiveHotFileLoader
        attr_reader :files
        attr_reader :template

        def initialize(template, files = nil)
          # According to https://github.com/fog/fog-openstack/blame/master/docs/orchestration.md#L122
          #  templates can be either String or Hash.
          #  If it's an Hash, we deep_copy it so the passed argument
          #  is not modified by get_file_contents.
          template = deep_copy(template)
          @visited = Set.new
          @files = files || {}
          @template = get_template_contents(template)
        end

        # Return string
        def url_join(prefix, suffix)
          if prefix
            # URI.join replaces prefix parts before a
            #  trailing slash. See https://docs.ruby-lang.org/en/2.3.0/URI.html.
            prefix += '/' unless prefix.to_s.end_with?("/")
            suffix = URI.join(prefix, suffix)
            # Force URI to use traditional file scheme representation.
            suffix.host = "" if suffix.scheme == "file"
          end
          suffix.to_s
        end

        # Retrieve a template content.
        #
        # @param template_file can be either:
        #          - a raw_template string
        #          - an URI string
        #          - an Hash containing the parsed template.
        #
        # XXX: we could use named parameters
        # and better mimic heatclient implementation.
        def get_template_contents(template_file)
          Fog::Logger.debug("get_template_contents [#{template_file}]")

          raise "template_file should be Hash or String, not #{template_file.class.name}" unless
            template_file.kind_of?(String) || template_file.kind_of?(Hash)

          local_base_url = url_join("file:/", File.absolute_path(Dir.pwd))

          if template_file.kind_of?(Hash)
            template_base_url = local_base_url
            template = template_file
          elsif template_is_raw?(template_file)
            template_base_url = local_base_url
            template = YAML.safe_load(template_file, :permitted_classes => [Date])
          elsif template_is_url?(template_file)
            template_file = normalise_file_path_to_url(template_file)
            template_base_url = base_url_for_url(template_file)
            raw_template = read_uri(template_file)
            template = YAML.safe_load(raw_template, :permitted_classes => [Date])

            Fog::Logger.debug("Template visited: #{@visited}")
            @visited.add(template_file)
          else
            raise "template_file is not a string of the expected form"
          end

          get_file_contents(template, template_base_url)

          template
        end

        # Traverse the template tree looking for get_file and type
        #   and populating the @files attribute with their content.
        #   Resource referenced by get_file and type are eventually
        #   replaced with their absolute URI as done in heatclient
        #   and shade.
        #
        def get_file_contents(from_data, base_url)
          Fog::Logger.debug("Processing #{from_data} with base_url #{base_url}")

          # Recursively traverse the tree
          #   if recurse_data is Array or Hash
          recurse_data = from_data.kind_of?(Hash) ? from_data.values : from_data
          recurse_data.each { |value| get_file_contents(value, base_url) } if recurse_data.kind_of?(Array)

          # I'm on a Hash, process it.
          return unless from_data.kind_of?(Hash)
          from_data.each do |key, value|
            next if ignore_if(key, value)

            # Resolve relative paths.
            str_url = url_join(base_url, value)

            next if @files.key?(str_url)

            file_content = read_uri(str_url)

            # get_file should not recurse hot templates.
            if key == "type" && template_is_raw?(file_content) && !@visited.include?(str_url)
              template = get_template_contents(str_url)
              file_content = YAML.dump(template)
            end

            @files[str_url] = file_content
            # replace the data value with the normalised absolute URL as required
            #  by https://docs.openstack.org/heat/pike/template_guide/hot_spec.html#get-file
            from_data[key] = str_url
          end
        end

        private

        # Retrive the content of a local or remote file.
        #
        # @param A local or remote uri.
        #
        # @raise ArgumentError if it's not a valid uri
        #
        # Protect open-uri from malign arguments like
        #  - "|ls"
        #  - multiline strings
        def read_uri(uri_or_filename)
          remote_schemes = %w[http https ftp]
          Fog::Logger.debug("Opening #{uri_or_filename}")

          begin
            # Validate URI to protect from open-uri attacks.
            url = URI(uri_or_filename)

            if remote_schemes.include?(url.scheme)
              # Remote schemes must contain an host.
              raise ArgumentError if url.host.nil?

              # Encode URI with spaces.
              uri_or_filename = uri_or_filename.gsub(/ /, "%20")
            end
          rescue URI::InvalidURIError
            raise ArgumentError, "Not a valid URI: #{uri_or_filename}"
          end

          # TODO: A future revision may implement a retry.
          content = ''
          # open-uri doesn't open "file:///" uris.
          uri_or_filename = uri_or_filename.sub(/^file:/, "")

          open(uri_or_filename) { |f| content = f.read }
          content
        end

        # Return true if the file is an heat template, false otherwise.
        def template_is_raw?(content)
          htv = content.strip.index("heat_template_version")
          # Tolerate some leading character in case of a json template.
          htv && htv < 5
        end

        # Return true if it's an URI, false otherwise.
        def template_is_url?(path)
          normalise_file_path_to_url(path)
          true
        rescue ArgumentError, URI::InvalidURIError
          false
        end

        # Return true if I should I process this this file.
        #
        # @param [String] An heat template key
        #
        def ignore_if(key, value)
          return true if key != 'get_file' && key != 'type'

          return true unless value.kind_of?(String)

          return true if key == 'type' &&
                         !value.end_with?('.yaml', '.template')

          false
        end

        # Returns the string baseurl of the given url.
        def base_url_for_url(url)
          parsed = URI(url)
          parsed_dir = File.dirname(parsed.path)
          url_join(parsed, parsed_dir)
        end

        def normalise_file_path_to_url(path)
          # Nothing to do on URIs
          return path if URI(path).scheme

          path = File.absolute_path(path)
          url_join('file:/', path)
        end

        def deep_copy(item)
          return item if item.kind_of?(String)

          YAML.safe_load(YAML.dump(item), :permitted_classes => [Date])
        end
      end
    end
  end
end