# -*- encoding: utf-8 -*- # # Author:: Fletcher Nichol () # # Copyright (C) 2014, Fletcher Nichol # # 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. require "thor/util" require_relative "lazy_hash" module Kitchen # A mixin for providing configuration-related behavior such as default # config (static, computed, inherited), required config, local path # expansion, etc. # # @author Fletcher Nichol module Configurable def self.included(base) base.extend(ClassMethods) end # @return [Kitchen::Instance] the associated instance attr_reader :instance # A lifecycle method that should be invoked when the object is about ready # to be used. A reference to an Instance is required as configuration # dependant data may be access through an Instance. This also acts as a # hook point where the object may wish to perform other last minute # checks, validations, or configuration expansions. # # @param instance [Instance] an associated instance # @return [self] itself, for use in chaining # @raise [ClientError] if instance parameter is nil def finalize_config!(instance) raise ClientError, "Instance must be provided to #{self}" if instance.nil? @instance = instance expand_paths! deprecate_config! validate_config! load_needed_dependencies! self end # Provides hash-like access to configuration keys. # # @param attr [Object] configuration key # @return [Object] value at configuration key def [](attr) config[attr] end # @return [TrueClass,FalseClass] true if `:shell_type` is `"bourne"` (or # unset, for backwards compatability) def bourne_shell? ["bourne", nil].include?(instance.platform.shell_type) end # Find an appropriate path to a file or directory, based on graceful # fallback rules or returns nil if path cannot be determined. # # Given an instance with suite named `"server"`, a `test_base_path` of # `"/a/b"`, and a path segement of `"roles"` then following will be tried # in order (first match that exists wins): # # 1. /a/b/server/roles # 2. /a/b/roles # 3. $PWD/roles # # @param path [String] the base path segment to search for # @param opts [Hash] options # @option opts [Symbol] :type either `:file` or `:directory` (default) # @option opts [Symbol] :base_path a custom base path to search under, # default uses value from `config[:test_base_path]` # @return [String] path to the existing file or directory, or nil if file # or directory was not found # @raise [UserError] if `config[:test_base_path]` is used and is not set def calculate_path(path, opts = {}) type = opts.fetch(:type, :directory) base = opts.fetch(:base_path) do config.fetch(:test_base_path) do |key| raise UserError, "#{key} is not found in #{self}" end end [ File.join(base, instance.suite.name, path), File.join(base, path), File.join(Dir.pwd, path), ].find do |candidate| type == :directory ? File.directory?(candidate) : File.file?(candidate) end end # Returns an array of configuration keys. # # @return [Array] array of configuration keys def config_keys config.keys end # Returns a Hash of configuration and other useful diagnostic information. # # @return [Hash] a diagnostic hash def diagnose result = {} config_keys.sort.each { |k| result[k] = config[k] } result end # Returns a Hash of configuration and other useful diagnostic information # associated with the plugin itself (such as loaded version, class name, # etc.). # # @return [Hash] a diagnostic hash def diagnose_plugin result = {} result[:name] = name result.merge!(self.class.diagnose) result end # Returns the name of this plugin, suitable for display in a CLI. # # @return [String] name of this plugin def name self.class.name.split("::").last end # @return [TrueClass,FalseClass] true if `:shell_type` is `"powershell"` def powershell_shell? ["powershell"].include?(instance.platform.shell_type) end # Builds a file path based on the `:os_type` (`"windows"` or `"unix"`). # # @return [String] joined path for instance's os_type def remote_path_join(*parts) path = File.join(*parts) windows_os? ? path.tr("/", '\\') : path.tr('\\', "/") end # @return [TrueClass,FalseClass] true if `:os_type` is `"unix"` (or # unset, for backwards compatibility) def unix_os? ["unix", nil].include?(instance.platform.os_type) end # Performs whatever tests that may be required to ensure that this plugin # will be able to function in the current environment. This may involve # checking for the presence of certain directories, software installed, # etc. # # @raise [UserError] if the plugin will not be able to perform or if a # documented dependency is missing from the system def verify_dependencies # this method may be left unimplemented if that is applicable end # @return [TrueClass,FalseClass] true if `:os_type` is `"windows"` def windows_os? ["windows"].include?(instance.platform.os_type) end private # @return [LzayHash] a configuration hash # @api private attr_reader :config # @return [Hash] a hash of the detected deprecated config attributes # @api private attr_reader :deprecated_config # @return [Hash] user provided configuration hash # @api private attr_reader :provided_config # Initializes an internal configuration hash. The hash may contain # callable blocks as values that are meant to be called lazily. This # method is intended to be included in an object's .initialize method. # # @param config [Hash] initial provided configuration # @api private def init_config(config) @config = LazyHash.new(config, self) @provided_config = config.dup self.class.defaults.each do |attr, value| @config[attr] = value unless @config.key?(attr) end end # Expands file paths for certain configuration values. A configuration # value is marked for file expansion with a expand_path_for declaration # in the included class. # # @api private def expand_paths! root_path = config[:kitchen_root] || Dir.pwd expanded_paths = LazyHash.new(self.class.expanded_paths, self).to_hash expanded_paths.each do |key, should_expand| next if !should_expand || config[key].nil? || config[key] == false config[key] = if config[key].is_a?(Array) config[key].map { |path| File.expand_path(path, root_path) } else File.expand_path(config[key], root_path) end end end # Initialize detected deprecated configuration hash. # Display warning if deprecations have been detected. # # @api private def deprecate_config! # We only want to display the deprecation list of config values once per execution on the default config. # This prevents the output of deprecations from being printed for each permutation of test suites. return if defined? @@has_been_warned_of_deprecations deprecated_attributes = LazyHash.new(self.class.deprecated_attributes, self) # Remove items from hash when not provided in the loaded config or when the rendered message is nil @deprecated_config = deprecated_attributes.delete_if { |attr, obj| !provided_config.key?(attr) || obj.nil? } unless deprecated_config.empty? warning = Util.outdent!(<<-MSG) Deprecated configuration detected: #{deprecated_config.keys.join("\n")} Run 'kitchen doctor' for details. MSG warn(warning) # Set global var that the deprecation message has been printed @@has_been_warned_of_deprecations = true end end # Loads any required third party Ruby libraries or runs any shell out # commands to prepare the plugin. This method will be called in the # context of the main thread of execution and so does not necessarily # have to be thread safe. # # **Note:** any subclasses overriding this method would be well advised # to call super when overriding this method, for example: # # @example overriding `#load_needed_dependencies!` # # class MyProvisioner < Kitchen::Provisioner::Base # def load_needed_dependencies! # super # # any further work # end # end # # @raise [ClientError] if any library loading fails or any of the # dependency requirements cannot be satisfied # @api private def load_needed_dependencies! # this method may be left unimplemented if that is applicable end # @return [Logger] the instance's logger or Test Kitchen's common logger # otherwise # @api private def logger instance ? instance.logger : Kitchen.logger end # @return [String] a powershell command to reload the `PATH` environment # variable, only to be used to support old Omnibus Chef packages that # require `PATH` to find the `ruby.exe` binary # @api private def reload_ps1_path [ "$env:PATH = try {", "[System.Environment]::GetEnvironmentVariable('PATH','Machine')", "} catch { $env:PATH }\n\n", ].join("\n") end # Builds a shell environment variable assignment string for the # required shell type. # # @param name [String] variable name # @param value [String] variable value # @return [String] shell variable assignment # @api private def shell_env_var(name, value) if powershell_shell? shell_var("env:#{name}", value) else "#{shell_var(name, value)}; export #{name}" end end # Builds a shell variable assignment string for the required shell type. # # @param name [String] variable name # @param value [String] variable value # @return [String] shell variable assignment # @api private def shell_var(name, value) if powershell_shell? %{$#{name} = "#{value}"} else %{#{name}="#{value}"} end end # Runs all validations set up for the included class. Each validation is # for a specific configuration attribute and has an associated callable # block. Each validation block is called with the attribute, its value, # and the included object for context. # # @api private def validate_config! self.class.validations.each do |attr, block| block.call(attr, config[attr], self) end end # Wraps a body of shell code with common context appropriate for the type # of shell. # # @param code [String] the shell code to be wrapped # @return [String] wrapped shell code # @api private def wrap_shell_code(code) return env_wrapped(code) if powershell_shell? Util.wrap_command((env_wrapped code)) end def env_wrapped(code) code_parts = resolve_proxy_settings_from_config code_parts << shell_env_var("TEST_KITCHEN", 1) code_parts << shell_env_var("CI", ENV["CI"]) if ENV["CI"] code_parts << shell_env_var("CHEF_LICENSE", ENV["CHEF_LICENSE"]) if ENV["CHEF_LICENSE"] ENV.select { |key, value| key.start_with?("TKENV_") }.each do |key, value| env_var_name = "#{key}".sub!("TKENV_", "") code_parts << shell_env_var(env_var_name, value) end code_parts << code code_parts.join("\n") end def proxy_setting_keys %i{http_proxy https_proxy ftp_proxy no_proxy} end def resolve_proxy_settings_from_config proxy_setting_keys.each_with_object([]) do |protocol, set_env| if !config.key?(protocol) || config[protocol].nil? export_proxy(set_env, protocol) elsif proxy_config_setting_present?(protocol) set_env << shell_env_var(protocol.downcase.to_s, config[protocol]) set_env << shell_env_var(protocol.upcase.to_s, config[protocol]) end end end def proxy_from_config? proxy_setting_keys.any? do |protocol| !config[protocol].nil? end end def proxy_from_environment? proxy_setting_keys.any? do |protocol| !ENV[protocol.downcase.to_s].nil? || !ENV[protocol.upcase.to_s].nil? end end def proxy_config_setting_present?(protocol) config.key?(protocol) && !config[protocol].nil? && !config[protocol].empty? end # Helper method to export # # @param env [Array] the environment to modify # @param code [String] the type of proxy to export, one of 'http', 'https' or 'ftp' # @api private def export_proxy(env, type) env << shell_env_var(type.to_s, ENV[type.to_s]) if ENV[type.to_s] env << shell_env_var(type.upcase.to_s, ENV[type.upcase.to_s]) if ENV[type.upcase.to_s] end # Class methods which will be mixed in on inclusion of Configurable module. module ClassMethods # Sets the loaded version of this plugin, usually corresponding to the # RubyGems version of the plugin's library. If the plugin does not set # this value, then `nil` will be used and reported. # # @example setting a version used by RubyGems # # require "kitchen/driver/vagrant_version" # # module Kitchen # module Driver # class Vagrant < Kitchen::Driver::Base # # plugin_version Kitchen::Driver::VAGRANT_VERSION # # end # end # end # # @param version [String] a version string def plugin_version(version) # rubocop:disable Style/TrivialAccessors @plugin_version = version end # Returns a Hash of configuration and other useful diagnostic # information. # # @return [Hash] a diagnostic hash def diagnose { class: name, version: @plugin_version ||= nil, api_version: @api_version ||= nil, } end # Sets a sane default value for a configuration attribute. These values # can be overridden by provided configuration or in a subclass with # another default_config declaration. # # @example a nil default value # # default_config :i_am_nil # # @example a primitive default value # # default_config :use_sudo, true # # @example a block to compute a default value # # default_config :box_name do |subject| # subject.instance.platform.name # end # # @param attr [String] configuration attribute name # @param value [Object, nil] static default value for attribute # @yieldparam object [Object] a reference to the instantiated object # @yieldreturn [Object, nil] dynamically computed value for the attribute def default_config(attr, value = nil, &block) defaults[attr] = block_given? ? block : value end # Ensures that an attribute which is a path will be fully expanded at # the right time. This helps make the configuration unambiguous and much # easier to debug and diagnose. # # Note that the file path expansion is only intended for paths on the # local workstation invking the Test Kitchen code. # # @example the default usage # # expand_path_for :data_path # # @example disabling path expansion with a falsey value # # expand_path_for :relative_path, false # # @example using a block to determine whether or not to expand # # expand_path_for :relative_or_not_path do |subject| # subject.instance.name =~ /default/ # end # # @param attr [String] configuration attribute name # @param value [Object, nil] whether or not to exand the file path # @yieldparam object [Object] a reference to the instantiated object # @yieldreturn [Object, nil] dynamically compute whether or not to # perform the file expansion def expand_path_for(attr, value = true, &block) expanded_paths[attr] = block_given? ? block : value end # Set the appropriate deprecation message for a given attribute name # # @example the default usage # # deprecate_config_for :attribute_name, "Detailed deprecation message." # # @example using a block # # deprecate_config_for :attribute_name do |subject| # "Detailed deprecation message." if subject == true # end # # @param attr [String] configuration attribute name # @param value [Object, nil] static default value for attribute # @yieldparam object [Object] a reference to the instantiated object # @yieldreturn [Object, nil] dynamically computed value for the attribute def deprecate_config_for(attr, value = nil, &block) deprecated_attributes[attr] = block_given? ? block : value end # Ensures that an attribute must have a non-nil, non-empty String value. # The default behavior will be to raise a user error and thereby halting # further configuration processing. Good use cases for require_config # might be cloud provider credential keys and other similar data. # # @example a value that must not be nil or an empty String # # required_config :cloud_api_token # # @example using a block to use custom validation logic # # required_config :email do |attr, value, subject| # raise UserError, "Must be an email address" unless value =~ /@/ # end # # @param attr [String] configuration attribute name # @yieldparam attr [Symbol] the attribute name # @yieldparam value [Object] the current value of the attribute # @yieldparam object [Object] a reference to the instantiated object def required_config(attr, &block) unless block_given? klass = self block = lambda do |_, value, thing| if value.nil? || value.to_s.empty? attribute = "#{klass}#{thing.instance.to_str}#config[:#{attr}]" raise UserError, "#{attribute} cannot be blank" end end end validations[attr] = block end # @return [Hash] a hash of attribute keys and default values which has # been merged with any superclass defaults # @api private def defaults @defaults ||= {}.merge(super_defaults) end # @return [Hash] a hash of defaults from the included class' superclass # if defined in the superclass, or an empty hash otherwise # @api private def super_defaults if superclass.respond_to?(:defaults) superclass.defaults else {} end end # @return [Hash] a hash of attribute keys and truthy/falsey values to # determine if said attribute needs to be fully file path expanded, # which has been merged with any superclass expanded paths # @api private def expanded_paths @expanded_paths ||= {}.merge(super_expanded_paths) end # @return [Hash] a hash of expanded paths from the included class' # superclass if defined in the superclass, or an empty hash otherwise # @api private def super_expanded_paths if superclass.respond_to?(:expanded_paths) superclass.expanded_paths else {} end end def deprecated_attributes @deprecated_attributes ||= {}.merge(super_deprecated_attributes) end def super_deprecated_attributes if superclass.respond_to?(:deprecated_attributes) superclass.deprecated_attributes else {} end end # @return [Hash] a hash of attribute keys and valudation callable blocks # which has been merged with any superclass valudations # @api private def validations @validations ||= {}.merge(super_validations) end # @return [Hash] a hash of validations from the included class' # superclass if defined in the superclass, or an empty hash otherwise # @api private def super_validations if superclass.respond_to?(:validations) superclass.validations else {} end end end end end